Screenshotline was an HTTP API before it was an MCP server. Adding MCP, the protocol agents use to call tools, took less code than I expected and more decisions than I expected. This is both, for anyone about to do the same to an API they already run.
The two tools are screenshot, which returns an image, and read_page, which
returns the same render
as clean Markdown.
Hosted and stateless, not an npm package
The default assumption is that an MCP server is a package someone installs and runs locally over stdio. I built that first. It works, it's four files, and it's still in the repo.
Then I ran into something dull: I had no npm account, and publishing needs 2FA set up. That is a solvable problem, but it made me ask whether npm was on the critical path at all, and for a service whose whole job is a network call, it isn't. The renderer doesn't run on the user's machine. The package would have been a thin client for an HTTP endpoint, installed, versioned and updated by every user separately.
So the shipped thing is a hosted, stateless MCP endpoint:
claude mcp add --transport http screenshotline \
https://api.screenshotline.com/mcp --header "X-Access-Key: $KEY"
One URL, no install, nothing to update, and everyone gets fixes the moment they deploy. It answers JSON rather than holding an SSE stream open, because a long-lived stream behind a reverse proxy is a class of problem I would rather not own.
If your API is a network service, a remote MCP endpoint is probably the right shape. If your tool needs the user's filesystem, their credentials or their local processes, stdio and a package is the right shape. The question isn't which is more modern; it's where the work happens.
Tool calls re-enter your own API
The tempting implementation is to call your internal functions directly from the tool handler. It's faster and it looks cleaner.
Don't. Each tool call goes back through this process's own /take and
/extract over loopback, carrying the caller's key:
// A tool call goes back through this process's own /take and /extract over
// loopback with the caller's key, so keys, quota, the free-tier fences,
// metering and rate limits are exactly the HTTP API's. There is no second
// implementation of billing to drift.
That one decision removes a whole category of future bug. Authentication, quota, rate limits, the blank-capture detector, usage metering and the cache are identical between the two surfaces, because there is only one of each. If I'd called internals directly, every future change to billing would need doing twice, and the MCP path would quietly drift into being the cheap way to use the API for free.
The one thing that deliberately needs no key is listing the tools. Clients and directories have to be able to discover what a server offers before anyone has signed up, and a discovery call that demands credentials is a server most directories will skip.
Write tool descriptions for a reader who cannot see your docs
This is where an existing API is at a disadvantage. Your parameters were named for developers who read documentation. A model gets the name, the type and one sentence, and has to choose correctly from that alone.
So the descriptions say when to use the tool, not just what it does:
description: 'Capture a screenshot of a web page and return it as an image. '
+ 'Handles consent banners, ads, lazy-loaded images and late-rendering SPAs. '
+ 'Use this when the layout matters...'
with read_page described for the opposite case: when the text matters and an
image would just have to be read back out again. Defaults are stated in the
parameter descriptions ("Default png", "Default true"), because a model that
doesn't know the default will set the parameter anyway, usually to something
it invented.
One thing I have not done yet, and should: MCP supports tool annotations,
small flags saying a tool is read-only, non-destructive, idempotent or
open-world. Mine has none, and a directory's automated audit called that out.
For a tool that fetches a public web page and changes nothing, readOnlyHint
is simply true, and clients can use it to decide what to run without asking the
user first. It's on the list.
The registry details that cost me time
Publishing to the official MCP Registry is quick, and three details are not obvious:
A published version is immutable. Any change to server.json needs a
version bump and a fresh publish. Plan for that before you publish a typo.
The namespace is your GitHub login, exactly as GitHub spells it. Mine is
io.github.MAMubeenKhan/screenshotline, capitals included, and it has to match
the name in package.json. The registry builds that namespace from the login
GitHub returns, so "it's a URL, case can't matter" is wrong here.
A packages entry whose package doesn't exist is rejected. Since I never
published to npm, the manifest is remotes-only: it advertises the hosted URL
and nothing else. That's a valid, complete listing, and it's the honest one.
Authentication for publishing worked with the GitHub CLI's token directly, so there was no browser step. Do expect the registry API to be slow.
Most directories ingest from the registry, so being listed there is what gets you into the rest of them with no effort. After that, a couple of the bigger ones take a submission form, and one asked $39 to publish immediately without review, which is a fine business model and an easy no for a listing that will arrive free eventually.
The short version
- Hosted endpoint if the work happens on your server, a package if it happens on the user's machine.
- Re-enter your own API so auth, quota and metering can't drift apart.
- Let tool listing work without a key, so discovery is possible.
- Write descriptions for someone with no documentation, and state defaults.
- Add annotations. Do as I say, not as I did.
- Publish to the official registry first; the directories follow.
The server is in
mcp/, about
four files, and the tool definitions are shared between the hosted endpoint and
the stdio bridge so the two can't disagree. A free key is 500 renders a month
if you want to point an agent at it.