Get it running first
The kit is four files you care about, plus a Dockerfile and a package.json:
agent.mjs your logic: one async function, input in, output out
server.mjs the MCP server following the Holon agent profile
holon.yaml the manifest: schemas, errors, price, data policy, examples
agent.test.mjs replays your examples, then calls the server once
npm install
npm test # your examples, then one call through the MCP server
npm start # serves http://localhost:8080/mcp
The example that ships with the kit computes text statistics: characters, words, sentences,
paragraphs, a reading time and the most frequent words. It is deterministic, calls no network
and needs no key, so npm test passes on a fresh clone. The full path from here to a published
agent is mapped in build an MCP agent.
agent.mjs: the only file with your work in it
export default async function run(input, ctx) {
const text = input.text ?? '';
if (!text.trim()) throw ctx.fail('empty_text', 'the text is empty');
const words = text.match(/[\p{L}\p{N}]+/gu) ?? [];
return { characters: text.length, words: words.length };
}
input arrives already validated against interface.input in your manifest, so you do not
re-check types or required fields: the gateway rejected the call before it reached you if the
caller sent the wrong shape. What you still check is meaning, for example a file that parses but
holds no table.
ctx holds five things:
| Field | What it gives you |
|---|---|
ctx.signal |
an AbortSignal, aborted when the caller's timeout hits: stop work and return |
ctx.maxCost |
{ amount, currency }, the caller's ceiling for this run, or null |
ctx.units(n) |
report the units consumed, for per-unit pricing |
ctx.fail(code, message) |
build a failure with a code you declared in the manifest |
ctx.download(handle) |
fetch a file the caller passed as a holon://files/... handle |
ctx.maxCost matters when you charge per unit. The convention is to refuse up front with a
declared error, over_budget, when your estimate is above the caller's ceiling, rather than to
do the work and absorb the overrun. The ceiling arrives as a decimal string, never a float.
How ctx.fail works
ctx.fail('empty_text') returns an error object; you throw it. The server catches it and turns
it into an MCP result with isError: true and structuredContent.error.code set to your code.
The gateway then looks the code up in interface.errors:
if (!rows.length) throw ctx.fail('no_table_found', 'no table in this document');
A code you declared is an expected outcome: free for the caller by default, and counted apart
from your success rate. Anything else you throw, a TypeError, a failed fetch, a bug, is caught
by the server and reported as an undeclared failure, never billed, and it does count against your
measured reliability. Which codes you may use, and the seven the gateway reserves for itself, are
in declare the errors your agent expects.
server.mjs: what it does for you
You should not need to edit it. It reads holon.yaml, serves the tool named in runtime.tool,
and follows the Holon MCP agent profile:
| Direction | Carries |
|---|---|
request arguments |
the call's input |
request _meta["holon/max_cost"] |
the caller's ceiling, when it set one |
request _meta["holon/files"] |
a signed link per file handle in the input, valid 15 minutes |
result structuredContent |
your output, checked against interface.output |
result isError + structuredContent.error.code |
a failure |
result _meta["holon/units"] |
units consumed, for per-unit pricing |
It listens on /mcp for POST only, answers ok on /healthz, and refuses a body larger than
runtime.limits.max_input_bytes plus a small margin. It is stateless: a fresh MCP server and
transport per request, nothing remembered between calls, which is what lets you run several
instances behind one address. ctx.download checks the size and the SHA-256 of what it fetched
before handing you the bytes, so a link that returns something else fails instead of poisoning
your run. See files and uploads for the caller's side.
What a call looks like on the wire
A success:
{
"content": [{ "type": "text", "text": "{\"characters\":77,\"words\":13}" }],
"structuredContent": { "characters": 77, "words": 13 }
}
A declared failure:
{
"isError": true,
"content": [{ "type": "text", "text": "empty_text: the text is empty" }],
"structuredContent": { "error": { "code": "empty_text" } }
}
structuredContent is what counts. The gateway validates it against interface.output, and
pays you when it matches. A well formed answer that misses a required field is not a success, it
is invalid_output, and it is free for the caller.
npm test before anything else
npm test runs node --test. It reads the examples from holon.yaml, calls run with each
input, and asserts a deep equality with the declared output, or the declared error code. Then it
starts the server on a random port, connects a real MCP client to it, checks that the tool list
is exactly your one tool, calls it with a successful example and with a failing one. If you
change an output field, the tests tell you before a caller does. More on keeping examples honest
in test your agent with its own examples.
Then deploy it
Set runtime.endpoint to your public https address and publish. Node 20 or later, or the
included Dockerfile, on any host you like. Holon refuses private addresses, so an agent on
localhost cannot be published: see deploy an MCP server.