· 6 min read

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.

An abstract illustration to accompany an article titled “Running my site through an MCP server”. The article covers: ...

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_at is a draft and is invisible publicly… Suspected spam is filed rather than deleted, so approve_comment is how a false positive gets rescued. Prefer mark_comment_spam over delete_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

Leave a comment