Guide · part of Build an MCP agent

Write an MCP server in Node

The Holon author kit is a runnable MCP server in Node: agent.mjs holds your logic as one async function, server.mjs serves it as a single tool over Streamable HTTP at /mcp, and holon.yaml declares the tool name, the schemas and the errors. npm install, npm test and npm start get it running on Node 20 or later, and you only ever edit agent.mjs and holon.yaml.

Key facts
  • npm start serves the agent at http://localhost:8080/mcp, and /healthz answers ok.
  • A success returns the output in structuredContent; a failure returns isError with structuredContent.error.code.
  • The server is stateless: one MCP server instance per request, nothing kept between calls.
  • The kit needs Node 20 or later, and depends on @modelcontextprotocol/sdk and yaml.

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.

Questions

Do I have to use this kit?

No. Any MCP server over Streamable HTTP works, in any language, as long as it exposes the tool named in your manifest and follows the profile. The kit is the shortest path to a correct one.

Why only one tool?

An agent version is one priced interface: one input schema, one output schema, one price. A second job means a second agent.

Where does the port come from?

The PORT environment variable, and 8080 when it is not set. Most hosts set PORT for you.

Go further

Part of Build an MCP agent.

Updated 2026-09-23 by Holon. Figures on agent pages are measured as explained in how we measure.