DEV Community

Nylah Reynard
Nylah Reynard

Posted on

An MCP server where a tool call can sit for 55 seconds and spend your money

Some context before anything else. HumanPen is where I work, humanpen-mcp is the MCP front end we ship for it, and the service behind it charges money, so read all of this as an interested party talking.

The job it does: a .docx or .pptx goes in, the prose gets rewritten to read as human-written and to score lower on AI detectors, and the same file comes back editable. Citations, footnotes, TOC fields, cross-references and tables are fenced off rather than paraphrased, and a score never gets lowered by sprinkling in typos.

The document half of this problem, meaning what actually breaks when you flatten a .docx into a string, I wrote up separately. This post is the other half, which I found harder: what changes about your MCP server when the thing behind it is slow, moves files instead of strings, and spends the user's money on every call.

Version 1.4.3, Apache-2.0, source is public, so none of the below has to be taken on my word.

The payload is a path, and I underrated what that costs

No tool result here ever carries the document itself. Every path in and out is an absolute one on the caller's disk. The client reads the file, uploads it over HTTPS, writes the finished document beside the original, and answers with where it put it. The upside is the obvious one, and I've made it before: a large file never occupies context.

The consequence runs the other way, though, and that is the part I got wrong. Whatever came back, the model has never seen it. No checking its own work, no telling you whether the table on page 7 came through intact. Here is the complete set of fields a finished call hands back:

{ job_id, operation, status, finished, progress_percent,
  credits_charged, source_words, result_words, output_path, error }
Enter fullscreen mode Exit fullscreen mode

No content, no diff. Anything you want steered has to be written blind into an instructions string before the job starts.

The reply I get to this is always the same: my client has a file read tool, I will just read the output myself. A .docx is a zip of XML parts, so reading the bytes gets you nothing, and unzipping it into the conversation throws away exactly the structure that made keeping it a file worthwhile. I don't have a clean answer here. For a whole thesis this is obviously the right trade. For two paragraphs you wanted tidied up it is obviously the wrong one, and the server has no way to tell which situation it is in.

A call can sit there for 55 seconds

Rewriting a real paper takes minutes. The default wait is 55 seconds, the per-call wait_seconds argument can push that to 240, and past the budget you get a job id instead of a file. The return value in that case carries an explicit instruction to call check_job, plus a note that the job keeps running server-side regardless.

We skipped the other design, where you submit, hand back an id straight away, and let the client poll. I want to be clear that this was a judgment and never a measurement. The reasoning was that handing a model an id and expecting it to remember to poll produces a loop that gets abandoned halfway more often than not. No A/B, no numbers, just that call.

It's not a decision I'm comfortable with. Any client whose timeout is shorter than ours gets a dead request out of it, and 55 is really a guess about where client timeouts cluster.

The protocol has no slot for "this is about to cost you"

Four of the eight tools spend credits. MCP annotations cover read-only and destructive. Neither of those is money, there is no confirm-a-charge primitive, and a client is free to ignore whatever hints you send anyway.

What is left is the description string, which is the one field every client reliably puts in front of the model. So the pricing lives there, in caps, and the sentence ends with an imperative rather than a fact: say so and get agreement first. That's not enforcement, obviously. It's the only surface the protocol gave me.

Two things support it. get_credit_balance is free and read-only, so an agent can look before starting something large. And read_detection_report parses a Turnitin or iThenticate AI report without opening a job at all, which usually settles on its own whether the paid call is worth making.

One annotation detail worth copying. Every writing tool here is marked destructiveHint: false, because none of them overwrites anything, they add a new file beside an existing one. destructiveHint defaults to true for any tool that is not read-only, so that explicit false is the half of the pair that carries information.

An error has to be marked as an error

Failures come back with isError: true and a JSON body holding error, code and retryable.

Without that flag, the failure text arrives as ordinary content and the model reads it as data, then carries on as though the work happened. A polite human-readable "that did not work" string with no isError is the version of this bug that looks completely fine in your terminal and quietly poisons whatever the agent does next.

Validate on the client when the server would charge first

humanize_document takes optional whole-document min_words and max_words. It also takes report_path, a Turnitin or iThenticate AI report PDF, and when you pass one, only the passages that report flagged are rewritten. Everything else is left alone.

Those two cannot combine. A whole-document band scopes the job to the whole document and a report scopes it to specific passages. The API rejects the mix, but it derives the report's passages later in the pipeline, so it would take the job and freeze the charge before noticing. The client therefore throws INVALID_WORD_BUDGET before the request is ever sent.

Per-passage bounds are a different story, and those do go together with a report. That is what segments exists for: each entry is one flagged passage carrying its own min_words and max_words, and the report's flagged passages are already exactly the scope those bounds apply to.

The rule I would take out of this: put the check on the client whenever the server's rejection would arrive after it has taken something from the user.

A null that is neither missing nor zero

Steal this one if you ever parse Turnitin reports. read_detection_report returns ai_percent, and it can come back null. That is not a failure.

Turnitin prints * instead of a number whenever AI writing is under 20 percent, a band it declines to quantify. So null means "under 20, and you are not getting a figure", which is usually good news. Read it as zero and you have invented a claim nobody made. Read it as a broken parse and you have thrown away a perfectly good result.

Three small ones

The server starts and answers without an API key. initialize and tools/list both work, and the missing-key error only surfaces on the first call that actually needs the credential. That is deliberate, because clients and directory scanners inspect a server by launching it, and one that exits at startup for want of a key looks broken to every one of them.

On stdio transport the startup warning goes to stderr. stdout is the JSON-RPC channel and anything else written there corrupts the protocol.

check_job downloads the finished result only when you tell it where to put the file. Falling back to a bare filename would drop the document into whatever directory the agent happened to be running in, which is nobody's intent and is annoying to undo.

The limits, since they are real

English input only, and the humanizer takes .docx and .pptx, nothing else. There's no paste-a-string path, so it is useless for chat-shaped work. Translation is a separate tool and accepts more formats, but that is not what this post is about.

Billing counts the words actually rewritten.

A call can block for most of a minute, which some clients will not sit through.

And the promise on the other side of that, since limits are only half the picture. Run a fresh report on the output, and if it still reads 20 percent or above, the passages it still flags get reprocessed at no charge. Each job carries exactly one of those, but the free run is itself a job with its own, so it chains. A per-day cap sits on top of it, set per deployment.

Running it

{
  "mcpServers": {
    "humanpen": {
      "command": "npx",
      "args": ["-y", "humanpen-mcp"],
      "env": { "HUMANPEN_API_KEY": "hp_..." }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Source and issues: github.com/humanpen/humanpen-mcp, Apache-2.0.
Registry entry: io.github.humanpen/humanpen-mcp on the official MCP registry.
Keys and the plain HTTP API: humanpen.net/developers. New accounts come with some free credits.

If you ship tools that take minutes to finish, I would like to know which way you went. Block inside the call, or hand back an id and trust the client to come back for it? I stopped at 55 for no better reason than that the arguing had to end somewhere.

Top comments (0)