Running my site through an MCP server
The admin area of this site has a second front door: a Model Context Protocol server at POST /mcp. Here is what that takes in Rails — JSON-RPC over one route, a bearer token, and twenty tools that go through the same models as the browser.

This site has an admin area — a form for writing posts, a table for moderating
comments, the usual. It also has a second front door onto exactly the same
thing: a Model Context Protocol server at POST /mcp. Point an MCP client at
it and the assistant can list drafts, write a post, rename a slug, approve a
comment that got caught by the spam heuristics, or tell me how many comments
are waiting.
This post is here because of it. I asked for a draft about the pattern; it was
written, create_post was called, and it landed in my drafts for me to read
before anything went live.
That last part is the whole point, so it's worth saying plainly before the code: the interesting thing isn't automation, it's that the site already had rules and the tools inherit them. Drafts are invisible. Comments are held for moderation. Deleting a post deletes its comments. Those are properties of the models, not of the admin UI, so a tool that goes through the models gets them for free. The MCP server is a second client, not a second implementation.
What MCP actually is, from a Rails point of view
MCP has a reputation for being complicated, mostly because the ecosystem around it is — SDKs, session management, OAuth flows, server-initiated event streams. The protocol underneath is smaller than that.
Strip it back and MCP is JSON-RPC 2.0 messages over HTTP. A client POSTs
{"jsonrpc":"2.0","id":1,"method":"tools/list"} and gets a result back. That's
it. For a server that only exposes tools, there are four methods worth
answering:
| Method | What it does |
|---|---|
initialize |
The handshake: agree a protocol version, say what you support |
ping |
Liveness |
tools/list |
Every tool, with its JSON Schema |
tools/call |
Run one |
There's more in the spec — resources, prompts, sampling, subscriptions — but a
server is allowed to declare that it does none of it. The capabilities object in
the handshake is how you say so. Mine says {"tools": {"listChanged": false}}
and nothing else, and a well-behaved client stops asking.
So the Rails side is one route:
post "mcp" => "mcp#handle", as: :mcp
match "mcp" => "mcp#unsupported", via: %i[ get delete ]
The second line matters more than it looks. The Streamable HTTP transport also
defines a GET (open a stream the server can push down) and a DELETE (end a
session). This server is stateless and never speaks first, so the honest answer
to both is 405 Method Not Allowed with an Allow: POST header — the spec's
own answer for a server that doesn't offer them. Clients probe for these. Tell
them no clearly and they get on with it.
No gem. No new dependency. The protocol is JSON in, JSON out, and Rails has been good at that for twenty years.
The controller does two things
class McpController < ActionController::API
rate_limit to: 120, within: 1.minute, with: -> { render_error(:too_many_requests, "Slow down.") }
before_action :authenticate
def handle
message = begin
JSON.parse(request.raw_post)
rescue JSON::ParserError
return render json: Mcp::Server.parse_error, status: :bad_request
end
reply = Mcp::Server.new.call(message)
# A notification carries no id and gets no response body.
reply ? render(json: reply) : head(:accepted)
end
end
Authenticate, then hand the parsed message to a plain Ruby object. Everything
protocol-shaped lives in app/lib/mcp/, away from Rails.
Two decisions in there that I'd defend:
It inherits from ActionController::API, not ApplicationController. No
session, no cookie, no CSRF token. The bearer token is the entire
authentication story, and keeping this endpoint off the cookie path means a
browser can't be tricked into calling it with my logged-in credentials. An
admin endpoint that trusts a cookie is an admin endpoint that trusts any tab I
have open.
A message with no id is a notification and gets no reply. JSON-RPC says
notifications are acknowledged, never answered. Clients send them — most
noticeably notifications/initialized, right after the handshake — and a
server that helpfully replies anyway will confuse a strict one. head :accepted
and move on.
Authentication: one admin, one credential
There is exactly one person who writes this site, so there is exactly one credential — a bearer token. No OAuth dance, no per-client registration.
module Mcp
module Token
module_function
def configured
ENV["MCP_TOKEN"].presence || Rails.application.credentials.mcp_token.presence
end
def valid?(candidate)
expected = configured
return false if expected.blank? || candidate.blank?
ActiveSupport::SecurityUtils.secure_compare(candidate.to_s, expected.to_s)
end
end
end
Generate one with bin/rails secret | cut -c1-64, put it in the environment or
in credentials, and hand it to the client as a header. The rule I care about is
the return false if expected.blank?: an unset token closes the endpoint
rather than opening it. A forgotten secret must never mean "open to
everyone," and that's a mistake you make once by writing the obvious version of
this method.
Connecting a client is one command:
claude mcp add --transport http seanbehan https://seanbehan.com/mcp \
--header "Authorization: Bearer $MCP_TOKEN"
And you can check it by hand, which is a genuine advantage of the protocol being this plain:
curl -s https://seanbehan.com/mcp \
-H "Authorization: Bearer $MCP_TOKEN" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
Tools are a schema and a block
Twenty tools, grouped into four modules — site, posts, projects, comments — declared with about thirty lines of DSL:
tool "publish_post",
title: "Publish a post",
description: "Make a post live. Pass published_at with a future date to schedule it instead.",
properties: {
slug: SLUG,
published_at: { type: "string", description: "When it goes live. Defaults to now; a future date schedules it." }
},
required: %w[ slug ] do |args|
post = Posts.find(args.fetch!("slug"))
post.update!(published_at: args.key?("published_at") ? args.time("published_at") : Time.current)
{ published: true, post: Presenter.post_summary(post) }
end
The properties hash is JSON Schema, sent verbatim in tools/list. The block
is the handler. A registry collects them, so adding a tool means adding a
tool block and nothing else.
Three things I learned writing these:
Descriptions are the API. The model never reads your code. The schema and
the prose are the entire interface, and the prose does most of the work —
"a future date schedules it" is the difference between a model that schedules
posts correctly and one that guesses. Write them for a capable colleague who
has never seen the codebase.
Failure has two kinds, and only one is an error. A bad slug or a validation
failure isn't a broken call — it's the model being told what to fix. Those come
back as a normal result with isError: true and a readable message, and the
model reads it and corrects itself. Only malformed protocol messages become
JSON-RPC errors. Getting this backwards means every typo looks like an outage.
Distinguish "not supplied" from "blank." An update tool must only touch
fields the caller actually mentioned, so the argument wrapper keys off
Hash#key? rather than truthiness. Otherwise every partial update quietly
blanks the summary of the post you were only trying to retitle.
There's also an instructions string in the handshake, which is the most
underrated field in the protocol. It's a paragraph of orientation the client
puts in front of the model before it does anything:
Posts are Markdown and identified by slug. A post with no
published_atis a draft and is invisible publicly… Suspected spam is filed rather than deleted, soapprove_commentis how a false positive gets rescued. Prefermark_comment_spamoverdelete_comment.
Twenty tool descriptions can't teach the shape of a system. One paragraph can.
Is this a good idea?
Honestly: for a personal site, with one admin, where the destructive operations are few and the blast radius is my own writing — yes, and it's been more useful than I expected. Not for the novelty of "the AI wrote a post," but for the boring things. What's still sitting in drafts? Anything caught as spam that shouldn't have been? Retag these six posts. Those are all worse in a browser.
The failure mode to respect is that a tool call is a real write to a real
database. That's why deletion is the one thing I keep leaning away from —
mark_comment_spam files a comment instead of destroying it, the instructions
say to prefer it, and delete_post says in its own description that it cascades
and cannot be undone. And it's why the interesting move is drafts: a model can
write anything it likes, and none of it is public until a person publishes it.
Which is what happens to this post next.
Comments