I spent the last few months building a remote MCP server for the blog platform I run (JustBlogged). It lets Claude, Cursor or Claude Code create posts, set tags and SEO fields, upload images and publish
Reading data over MCP is easy. Writing to something that is public on the internet is where every small decision starts to matter. Below is what I decided, what I'm still unsure about, and what I'd do differently
Auth: OAuth instead of an API key in a config file
The first version of most MCP servers I looked at was an API key pasted into a JSON config. It works. It also means a long-lived secret with full account access sitting in a dotfile, often synced, sometimes committed
I went with what the MCP authorization spec describes: OAuth 2.1 with PKCE. The flow in practice:
- The client calls a tool without a token and gets a
401with aWWW-Authenticateheader. That header carriesresource_metadata(RFC 9728) and the scope to ask for - The client reads the protected resource metadata, finds the authorization server, then reads its metadata (RFC 8414)
- It checks that
code_challenge_methods_supportedincludesS256. The spec says a client should refuse to continue without PKCE - It gets a client ID, either by registering itself at runtime (Dynamic Client Registration, RFC 7591) or by using a Client ID Metadata Document URL, which the 2025-11-25 revision added
- The browser opens, the user logs in and approves, and the code comes back to a loopback redirect like
http://127.0.0.1:33418/callback - The client exchanges the code plus the PKCE verifier for an access token and a refresh token, with a
resourceparameter (RFC 8707) so the token is bound to this one server
From the user side that's "paste a URL, log in". No secret to copy anywhere
Dynamic registration has a cost I didn't think about at first. Every client instance registers, so the server ends up with a table of registrations and knows nothing about who is behind a given client_id. Metadata documents fix that by tying the client ID to a domain the client developer controls. I support both, because not every client does the newer one yet
Two smaller things that took me longer than they should have. The metadata URL is the well-known prefix plus the endpoint path (/.well-known/oauth-protected-resource/mcp), not just the root. And I left initialize and tools/list public on purpose, so anyone can see every tool schema before signing in. I think that's the right call for a write API... you should know what an agent will be able to do before you hand it a token
Scope design: one scope that covers delete
This is the part I'm least happy with
There is exactly one scope, blogs:manage. A token with it can do whatever the signed-in account can do, across every blog the account can manage: read, publish, delete posts, change domains, edit themes. Normal account permissions still apply, so a team member who can't delete a blog in the dashboard can't do it over MCP either
Why one scope. It keeps the consent screen honest and short, and every client gets the same capabilities, so I'm not debugging "works in Cursor, not in Claude" because of scope negotiation
The cost is real though. You can't give an agent read-only access to audit your archive. You can't give it write access to one blog and not the other three. If you want it to fix a typo, it gets the same token that can delete the blog
The obvious split is read vs write. I'm not sure that's enough. Fixing a typo in a draft and deleting a blog are both "write". Maybe it's read / write drafts / publish / destructive. Maybe it's per blog. And every extra scope is one more checkbox on a consent screen people click through without reading
Still not decided
Tool design and annotations
MCP tools can carry annotations like readOnlyHint, destructiveHint and idempotentHint. Clients use them to decide when to ask the user before running a call. They're hints, not enforcement, but a client that trusts them will auto-approve what you mark as safe
I got some of these wrong in the first version and had to go back through every tool. The questions I ended up asking per tool:
- Can this make something disappear or overwrite content someone can't get back? Then
destructiveHint: true. Deletes obviously, but also some updates - If the agent retries the same call with the same arguments, does it end in the same state? An update that sets fields to given values usually is. A create is not, because a retry makes a second post. That's
idempotentHint - Does it only read? Then say so, so clients don't nag the user on every list call
Agents retry. A timeout in the middle of a create is exactly when a client will call it again, so I think idempotency matters more here than in a normal API where a human clicks a button once
The other tool design choice that mattered: small, boring tools instead of a few clever ones. list_blogs returns the blogId every other tool needs. create_post requires only blogId, title and content, and everything else is optional. get_post reads back what the server actually stored. Server-side limits (10 tags, 50 characters per tag, 100 characters for a slug) get enforced in validation, not left to the model to remember
I also had a case where create_post failed on a fresh blog because it expected a collection to exist that a new user hadn't set up. Our bug. A human would figure that out from the dashboard. An agent just gets an error and guesses
Images: base64 over MCP
upload_image takes the file as base64 in a data field, with filename, mimeType and the blog ID, up to 8 MB decoded. It returns a path you then set as the post image
The reason is boring. An MCP tool call is JSON-RPC, so arguments are JSON. There's no multipart body to stream a file into. The REST API takes a normal multipart upload, and a featured image can be set from a URL you already host
Base64 passes through the model's context though, so a big image burns a lot of tokens before it even reaches the server. Resizing first helps a lot. Upload-from-URL over MCP would fix most of it and I haven't built it yet
Letting an agent touch a live blog
The thing I worried about most is an agent publishing something half-finished to a real audience
So status on create_post defaults to draft. Publishing is a separate update_post call that sets status to published. I'd rather the agent need one extra explicit instruction to go public than have one missed instruction make it public
Some other things that help:
- Revisions are exposed as tools (
list_content_revisions,get_content_revision), so if an agent overwrites a draft you edited by hand, the earlier version is still there - Reading back with
get_postafter every write. What the model says it did and what the server stored are not always the same thing - Content goes in as HTML, which means code blocks with a raw
<div>inside them can turn into real markup if the conversion is sloppy. A draft you look at rendered catches that. A direct publish doesn't
What I haven't solved is the delete case. With one scope, delete_post is available to any connected client, and the only real guard is the client asking before a destructive call (which depends on the annotations being right) and the user actually reading the prompt
Not sure how many ppl want an agent anywhere near a live blog in the first place. If you've built a write-capable MCP server, how did you split your scopes?
If you want to poke at it, the MCP endpoint is https://justblogged.com/mcp and tools/list works without an account
Top comments (0)