TanStack
Harness

Use a harness from any MCP client

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.

1. Install the MCP package

shell
npm i @tanstack/ai-harness @tanstack/ai-mcp @tanstack/ai-persistence

2. Serve the harness on stdio

Put this in mcp.ts. It opens sessions on your host, and reads MCP messages on stdin and writes the answers on stdout:

ts
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:

shell
claude mcp add --env OPENAI_API_KEY=your-key assistant -- npx tsx /path/to/mcp.ts

For Claude Desktop, Cursor, and other clients with a JSON file, add the same command under mcpServers:

json
{
  "mcpServers": {
    "assistant": {
      "command": "npx",
      "args": ["tsx", "/path/to/mcp.ts"],
      "env": { "OPENAI_API_KEY": "your-key" }
    }
  }
}
  • Claude Desktop keeps this in claude_desktop_config.json.
  • Cursor keeps this in .cursor/mcp.json.

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.

3. Use the tools

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:

  • chat: send a message and wait for the answer. While a turn runs, the message waits in the queue. To send files too, see Send files and get media.
  • steer: add a message to the running turn.
  • cancel: cancel the running turn.
  • status: show the status, every wait, the background agents, and the queued turns.

To answer the harness:

  • approve and reject: answer every approval of the stopped turn.
  • resolve: answer every interrupt of the stopped turn in one call, or the waits of a background agent run.
  • answer: answer a question from a command, a durable tool, or an MCP server.

To run what the harness has:

  • agent_<name>: one tool for each agent in expose.agents. It takes the agent input and returns the agent result.
  • command_<name>: one tool for each command in expose.commands. The command connect:notion becomes the tool command_connect_notion.
  • tool_<name>: one tool for each tool in expose.tools. It runs that one tool with no model turn. See Run a tool without a model.
  • append: add records to the thread, when expose.records lists record types. The next turn reads them. See Let a client add records.

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:

json
{
  "status": "completed",
  "text": "You have three open tickets.",
  "interrupts": [],
  "questions": [],
  "waits": []
}

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:

  • approval: a tool call that needs your yes or no.
  • question: an answer that fits the schema of the wait, for example from the question tool or a step.ask.
  • sign-in: a connector that needs you to sign in.
  • client-tool: a tool that the client runs. The work waits for the tool output.
  • human: a person who takes over, from an interrupt of defineInterrupt.

The result also splits the waits by how you answer them:

  • interrupts: the waits of a stopped turn. Each one has id, kind, message, and responseSchema. An approval and a client tool also have tool and args, the tool call that waits.
  • questions: the questions that a command, a durable tool, or an MCP server asks while it runs.

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":

  • The client cannot ask you.
  • You close the question without an answer.
  • A wait of another kind also waits.
json
{
  "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:

json
{
  "decisions": [
    { "interruptId": "approval-1", "approved": true },
    { "interruptId": "interrupt-1", "payload": { "note": "Ship it." } }
  ]
}
  • For an approval, set approved. true runs the tool, and false rejects it.
  • For a client tool, set payload to the tool output.
  • For any other kind, set payload to a value that matches its schema.

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.

Questions and background agents

Some work keeps running while it waits. The result then has status: "waiting":

  • A question in questions: the client calls answer with the question id as questionId and your value.
  • A wait of a background agent run (an approval of permissions(), for example): the client calls resolve with one decision for each wait of that run. The interruptId is the waitId.

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

Send files and get media

chat takes attachments, the files to send with the message. Each attachment is one of these:

  • { path }: a file on the machine of the server. The file must be inside a folder that the server can read.
  • { url, mimeType }: a URL that goes to the model as it is. The server does not download it. mimeType is optional when the URL ends in a known file extension.
  • { data, mimeType }: the bytes of the file in base64.

Each attachment also takes an optional name. This chat call sends a screenshot:

json
{
  "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:

  • An image or an audio file up to 5 MB comes back inline, as MCP image or audio content.
  • Any other file comes back as a resource_link to harness-media://<threadId>/<id>. The client reads the bytes with resources/read.
  • The JSON result lists each file in media, with its id, kind, name, mimeType, size, and uri.

Serve it over HTTP

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:

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

  • A request that getUser refuses gets 401. A refused action comes back as a tool error.
  • A server on a host with access works over HTTP only. Over stdio, it throws.
  • It opens no session at the start, so it lists only the agents of the harness definition. The commands and the agents that plugins add get no tool on this server.

Then add it to Claude Code:

shell
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:

  • threadId: the conversation of a tool call without a threadId. Default main.
  • approvals: 'ask' (default) asks in the client, or returns the approvals when the client cannot ask. 'auto' approves every tool call. Both answer approvals only.
  • filePaths: the folders that a path attachment can read from. The real path of the file, after every symlink, must be inside one of them. Default: none, so the server refuses every path attachment.
  • openAccess: for a host with no access only. true opens each session with no user, and { user } opens each session as that user.
  • name and version: the MCP server name and version. Default: the harness name and 1.0.0.

What you have now

  • Your harness in Claude Code, Claude Desktop, Cursor, or any other MCP client.
  • Approvals that the client asks you about, or answers with approve and reject.
  • Every kind of wait in one list, answered with resolve or answer.
  • The agents, commands, and tools of your harness as MCP tools.
  • Records that the client adds to a thread with append.
  • Files that the client sends with a message, and the media of each turn in the result.