MCP
@ufo-soft/caelus-mcp serves a registry as an MCP server: one tool per command, one input schema field per parameter. It is built on the official MCP TypeScript SDK.
npm install @ufo-soft/caelus @ufo-soft/caelus-mcp @modelcontextprotocol/sdk zodThe SDK and zod are peer dependencies, so the server uses the app's copies.
Over stdio
import { Registry, failed, ok } from '@ufo-soft/caelus'
import { stdio } from '@ufo-soft/caelus-mcp'
const todos: string[] = []
const registry = new Registry()
registry.register({
name: 'add',
command: {
description: 'Add a todo',
parameters: [{ name: 'text', type: 'string', description: 'The todo text' }],
},
handler: ({ text }) => {
if (typeof text !== 'string' || text === '') return failed('text must be a non-empty string')
todos.push(text)
return ok(todos)
},
})
await stdio(registry, { name: 'todo', version: '1.0.0' })stdio(registry, info) serves the registry on the process's stdin and stdout, the way an MCP host runs a server it starts. info is the name and version the server reports to the host. To add it to Claude Code:
claude mcp add todo -- node todo.jsstdio resolves when the host closes stdin, after every request already received has been answered.
stdout belongs to the protocol
Over stdio, the host reads stdout as protocol messages, so anything else a handler writes there corrupts the stream. console.log writes to stdout. Log to stderr with console.error.
Tools
Each command becomes a tool with the command's name and description. Each parameter becomes a field of the tool's input schema:
{
"name": "add",
"description": "Add a todo",
"inputSchema": {
"type": "object",
"properties": {
"text": { "type": "string", "description": "The todo text" }
},
"required": ["text"]
}
}| Parameter type | Schema field | Rules |
|---|---|---|
string | string | Required. |
integer | integer | Required. Must be a whole number. |
boolean | boolean | Optional. false when absent. |
The SDK validates the arguments against the schema before the handler runs. The rules match the CLI's.
Results
| Outcome | Tool result |
|---|---|
ok(data) | One text block: the data as a string or JSON, or ok when there is none. |
failed(reason) | One text block with the reason, marked isError. |
Invalid arguments and a handler that throws also come back as results marked isError, with the message as the text. For the add command above:
{ "content": [{ "type": "text", "text": "[\"write the docs\"]" }] }
{ "content": [{ "type": "text", "text": "text must be a non-empty string" }], "isError": true }Other transports
mcp(registry, info) returns the SDK's McpServer with the tools registered, for any transport the SDK supports. Over streamable HTTP, without sessions:
import { createServer } from 'node:http'
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
import { Registry, ok } from '@ufo-soft/caelus'
import { mcp } from '@ufo-soft/caelus-mcp'
const registry = new Registry()
registry.register({
name: 'count',
command: { description: 'How many todos there are', parameters: [] },
handler: () => ok(0),
})
// Stateless: a new server and transport per request
createServer(async (request, response) => {
const server = mcp(registry, { name: 'todo', version: '1.0.0' })
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
response.on('close', () => {
void transport.close()
void server.close()
})
await server.connect(transport)
await transport.handleRequest(request, response)
}).listen(3000)The tools are the commands registered when mcp is called. A command registered later is not a tool on that server. A stateless HTTP server like this one builds a new server per request, so it always has the current commands.
See the API reference for mcp, stdio, and the shapeOf and resultOf helpers.