You built a harness, but you do your work in Claude Code, Cursor, or another agent. You want to give work to your harness from there. These clients speak MCP. createHarnessMcpServer serves your harness as an MCP server. The client can then chat with the harness, answer its approvals, and run its agents and commands.
npm i @tanstack/ai-harness @tanstack/ai-mcp @tanstack/ai-persistencepnpm add @tanstack/ai-harness @tanstack/ai-mcp @tanstack/ai-persistenceyarn add @tanstack/ai-harness @tanstack/ai-mcp @tanstack/ai-persistencebun add @tanstack/ai-harness @tanstack/ai-mcp @tanstack/ai-persistencePut this in mcp.ts. It opens sessions on your host, and reads MCP messages on stdin and writes the answers on stdout:
import { createHarnessHost, defineHarness } from '@tanstack/ai-harness'
import { createHarnessMcpServer } from '@tanstack/ai-mcp/harness'
import { serveMCPStdio } from '@tanstack/ai-mcp/server/stdio'
import { memoryPersistence } from '@tanstack/ai-persistence'
import { openaiText } from '@tanstack/ai-openai'
const assistant = defineHarness({
name: 'acme/assistant',
adapter: openaiText('gpt-6.1-sol'),
})
const host = createHarnessHost({ persistence: memoryPersistence() })
export const server = await createHarnessMcpServer({
host,
harness: assistant,
// One user on this machine, so no login.
openAccess: true,
})
serveMCPStdio(server)The host has no access, so the server needs openAccess. true opens each session with no user. { user } opens each session as that one user.
For Claude Code, run this command one time:
claude mcp add --env OPENAI_API_KEY=your-key assistant -- npx tsx /path/to/mcp.tsFor Claude Desktop, Cursor, and other clients with a JSON file, add the same command under mcpServers:
{
"mcpServers": {
"assistant": {
"command": "npx",
"args": ["tsx", "/path/to/mcp.ts"],
"env": { "OPENAI_API_KEY": "your-key" }
}
}
}The client starts the process when it needs the harness. The process stops when the client closes stdin. In this mode, stdout carries only MCP messages. Write your own logs with console.error.
Ask the client to use the harness, for example: "Ask assistant to summarize my open tickets." The client calls these tools.
To talk to the harness:
To answer the harness:
To run what the harness has:
The harness exposes nothing by default. Add the agents, commands, tools, and record types that an MCP client can use to expose in defineHarness. See Choose what clients can change.
Two names can give the same tool name, for example connect:notion and connect_notion. Then the later one in name order gets a number: command_connect_notion_2. The description of each of these tools names its command or agent.
Every tool takes an optional threadId. Each thread is its own conversation. Without a threadId, a tool uses the threadId option of the server (default main).
Because of that, the input of an agent, a command, or a tool cannot have its own threadId field. createHarnessMcpServer throws when one does. Give that field another name, for example ticketThreadId.
chat answers with JSON:
{
"status": "completed",
"text": "You have three open tickets.",
"interrupts": [],
"questions": [],
"waits": []
}The work of a tool call can stop for a wait. The result lists the open waits of that work in waits. Each wait has a kind:
The result also splits the waits by how you answer them:
For an approval, the client asks you in its own window (MCP elicitation). Answer yes to run the tool. Any other answer rejects it.
In these cases, chat returns the interrupts with status: "interrupted":
{
"status": "interrupted",
"text": "Here is the plan.",
"interrupts": [
{
"id": "interrupt-1",
"kind": "human",
"message": "Review the plan",
"responseSchema": {
"type": "object",
"properties": { "note": { "type": "string" } },
"required": ["note"]
}
}
],
"questions": [],
"waits": [
{
"waitId": "interrupt-1",
"kind": "human",
"runId": "run-1",
"message": "Review the plan",
"schema": {
"type": "object",
"properties": { "note": { "type": "string" } },
"required": ["note"]
}
}
]
}Then the client calls approve, reject, or resolve. That call waits for the turn to continue and returns the same JSON as chat. approve and reject answer approvals only. When a wait of another kind also waits, they refuse, and the client uses resolve.
resolve takes one decision for each interrupt that waits. This example answers a turn with one approval and one human wait:
{
"decisions": [
{ "interruptId": "approval-1", "approved": true },
{ "interruptId": "interrupt-1", "payload": { "note": "Ship it." } }
]
}resolve refuses a list that does not answer every interrupt. It also refuses a decision without the field that its kind needs.
To approve every tool call without a question, set approvals: 'auto'. Use it only with tools that you trust. It answers approvals only, so other waits still come back in the result.
Some work keeps running while it waits. The result then has status: "waiting":
answer and resolve wait for the work, then return its result. The status tool lists every wait, and each background agent with status: "running" or "waiting".
chat takes attachments, the files to send with the message. Each attachment is one of these:
Each attachment also takes an optional name. This chat call sends a screenshot:
{
"message": "What is wrong in this screenshot?",
"attachments": [{ "path": "./bug.png" }]
}The server reads a path only inside the filePaths option. By default it reads no files, so send url or data.
When the work makes media, the results of chat, approve, reject, resolve, and answer include it:
Several people use the server over HTTP, so each request needs its user. Give the host access, and leave out openAccess. The server then reads the user of each HTTP request with getUser:
import { createHarnessHost, defineHarness, membersOnly } from '@tanstack/ai-harness'
import { createHarnessMcpServer } from '@tanstack/ai-mcp/harness'
import { memoryPersistence } from '@tanstack/ai-persistence'
import { openaiText } from '@tanstack/ai-openai'
const assistant = defineHarness({
name: 'acme/assistant',
adapter: openaiText('gpt-6.1-sol'),
})
// Your own token check: a bearer token to a user.
const users = new Map([['Bearer token-ada', { id: 'ada', tenantId: 'acme' }]])
const host = createHarnessHost({
persistence: memoryPersistence(),
access: {
getUser: ({ request }) => users.get(request.headers.get('authorization') ?? '') ?? null,
rules: membersOnly(),
},
})
const server = await createHarnessMcpServer({ host, harness: assistant })
// Mount it on a route of your app, for example `/mcp`.
export const handleMcp = (request: Request) => server.fetch(request)Each request runs as its user, and the host checks each tool call. A user reaches only the threads and the media that isAllowed allows.
Then add it to Claude Code:
claude mcp add --transport http assistant https://your-app.example.com/mcp --header "Authorization: Bearer your-token"createHarnessMcpServer takes these options next to host and harness: