# 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.

## 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
```

```sh
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](/guides/build-an-mcp-agent).

## agent.mjs: the only file with your work in it

```js
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`:

```js
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](/guides/declare-agent-errors).

## 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](/guides/agent-files-and-uploads) for the caller's side.

## What a call looks like on the wire

A success:

```json
{
  "content": [{ "type": "text", "text": "{\"characters\":77,\"words\":13}" }],
  "structuredContent": { "characters": 77, "words": 13 }
}
```

A declared failure:

```json
{
  "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](/guides/test-your-agent-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](/guides/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.

Updated 2026-09-23.
