@modelprofile.com/mcp-crossharness
Connection-first MCP server for interacting with AI coding harness sessions through their explicit web-server APIs.
Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.
Transport Contract
Crossharness MCP tools only talk to explicitly connected harness web servers:
| Harness | Server transport | Support |
|---|---|---|
| OpenCode | http:// or https:// |
list, read, send, health |
| Codex | ws:// or wss:// app-server |
list, read, send, health |
| Claude Code | none | rejected at connection time |
There is no local session-store access, CLI invocation, process spawn, server discovery, auto-start, restart, or fallback. If a connection or request fails, the operation fails.
Claude Code 2.1.219 does not expose a URL-addressable session server. Its CLI and Agent SDK are local process/library interfaces, so it cannot participate in this server-only contract yet.
Connection Flow
Harness operations start with connect_harness:
connect_harness {
harness: "opencode",
serverUrl: "http://127.0.0.1:4096",
directory: "/absolute/path/on/server"
}
The result contains an opaque connectionId. Connection-scoped calls must reference it:
list_connections {}
list_chats { connectionId: "c-...", limit: 10 }
read_chat { connectionId: "c-...", chatId: "ses_..." }
send_message { connectionId: "c-...", chatId: "ses_...", message: "..." }
send_message_async { connectionId: "c-...", chatId: "ses_...", message: "..." }
check_reply { connectionId: "c-...", dispatchId: "d-..." }
connection_status { connectionId: "c-..." }
disconnect_harness { connectionId: "c-..." }
Connections and asynchronous dispatches live in the MCP process and do not survive a restart.
If disconnect_harness cannot close the connection, it returns an error and retains that
connection ID for a retry. Calling disconnect_harness again with the same ID retries cleanup.
send_message defaults to a 300-second timeout and send_message_async to 900 seconds; both accept 1-3,600 seconds. check_reply.waitSeconds accepts 0-60 seconds. The send timeout covers connection-scoped validation, queueing, and the harness request. Codex timeout recovery can then take up to 3 seconds for interrupt acknowledgment and 3 seconds for terminal completion.
Canceling a foreground send_message aborts its OpenCode request or safely interrupts its correlated Codex turn. Canceling check_reply stops only that wait. A send_message_async dispatch remains independent after its dispatchId is returned.
The in-memory dispatch registry retains at most 100 records. Settled records expire after two hours and may be evicted earlier to admit new work; if all 100 records are active, a new asynchronous send is rejected before delivery starts.
Trusted Origins
Agents must not be allowed to send harness credentials to arbitrary URLs. Configure exact approved origins before starting Crossharness:
export CROSSHARNESS_ALLOWED_SERVER_URLS="http://127.0.0.1:4096/,ws://127.0.0.1:4500/"
connect_harness rejects origins not in this comma-separated allowlist. URLs containing user info, query parameters, fragments, or non-root paths are also rejected. HTTP redirects are never followed. At most one connection may bind a given harness, origin, and directory in one MCP process; the registry accepts up to 32 connections.
The standalone MCP server reads credentials from the harnesses' fixed environment variables. They are never accepted as tool arguments or printed:
- OpenCode: required
OPENCODE_SERVER_PASSWORDand optionalOPENCODE_SERVER_USERNAME - Codex:
CODEX_REMOTE_TOKEN
For remote servers, use TLS: https:// for OpenCode and wss:// for Codex.
OpenCode
Start an OpenCode server on a fixed URL:
OPENCODE_SERVER_PASSWORD="..." opencode serve --hostname 127.0.0.1 --port 4096
Crossharness uses only the official HTTP API:
GET /global/healthGET /sessionGET /session/:idGET /session/:id/messagePOST /session/:id/message
The connection directory is sent as a server-side query filter and verified against every selected session. Concurrent sends are passed directly to OpenCode; OpenCode owns session queuing. Crossharness does not intercept permissions and does not issue session-wide aborts. If a local HTTP wait times out, the server may still have queued or started the turn; Crossharness reports that uncertainty and never retries.
Codex
Start a Codex app-server on an explicit WebSocket URL:
codex app-server --listen ws://127.0.0.1:4500
For remote listeners, configure Codex WebSocket authentication and use wss://. Crossharness sends CODEX_REMOTE_TOKEN as a bearer token.
Each connection owns one initialized WebSocket and uses the official app-server JSON-RPC protocol:
initializefollowed byinitializedthread/listwith the connection directory ascwdthread/readwithincludeTurnsthread/resumeturn/startand correlated turn/item notificationsturn/interruptfor a timed-out correlated turn
Turns on different threads may run concurrently. Sends to the same thread are sequenced so notification identity remains unambiguous. Each connection permits at most 100 queued or active sends, with at most 10 for one thread; a queued send can time out before delivery. Approval and elicitation requests are conservatively declined; unsupported server requests receive a JSON-RPC method-not-supported error instead of hanging. The one exception is account/chatgptAuthTokens/refresh, which the connection leaves unanswered: Codex sends it only when another client supplied the login tokens, and that client answers it.
If a Codex turn times out, Crossharness keeps the same-thread queue locked until turn/interrupt is acknowledged and turn/completed confirms terminal state. If the turn cannot be correlated, interruption is not acknowledged, or terminal state is not reported, the WebSocket connection is closed rather than risk overlapping turns or retrying an uncertain send.
Package Migration
The package and executable were renamed for consistent mcp-* naming:
| Previous | Replacement |
|---|---|
@modelprofile.com/crossharness-mcp |
@modelprofile.com/mcp-crossharness |
crossharness-mcp |
mcp-crossharness |
The previous package remains installable after deprecation, and existing lockfiles continue to resolve it. Update package references and MCP client commands explicitly; npm deprecation does not redirect imports or executables.
Install
pnpm add -g @modelprofile.com/mcp-crossharness
# library use
pnpm add @modelprofile.com/mcp-crossharness
Requires Node.js 24 or newer.
Register the mcp-crossharness binary as a stdio MCP server in your client.
Library API
Native Codex app-server client
CodexAppServerClient exposes the underlying app-server protocol independently of the MCP
chat tools. It accepts an explicit WebSocket endpoint, a Unix socket, or caller-owned JSONL stdio streams;
it never discovers or spawns a server. The embedding application owns process lifecycle,
session authorization, history projection, and human approval policy.
import { CodexAppServerClient } from '@modelprofile.com/mcp-crossharness';
const client = new CodexAppServerClient({
transport: { type: 'stdio', readable: child.stdout, writable: child.stdin },
clientInfo: { name: 'my-app', title: 'My App', version: '1.0.0' },
experimentalApi: true,
onNotification: ({ method, params }) => handleNotification(method, params),
onServerRequest: (request) => showApproval(request),
onClose: (error) => handleDisconnected(error),
});
await client.connect();
const models = await client.request('model/list', {});
// After a human decision, use the exact connection-scoped request ID:
client.respond(requestId, { decision: 'accept' });
client.close();
For WebSocket transport use { type: 'websocket', url: approvedServerUrl, token: bearerToken }.
To join an existing local Codex daemon, use
{ type: 'unix', socketPath: absoluteSocketPath }. This performs a WebSocket handshake
over the exact Unix socket, with the same deadlines, message limits and request lifecycle
as the network transport. The client does not discover, start, restart or stop the daemon;
closing it closes only its own connection. Socket paths remain filesystem paths, including
spaces and URL punctuation. Callers choose a trusted socket and own discovery and process
policy. Codex's app-server proxy forwards raw socket bytes; its stdio is not JSONL.
The caller must authorize the destination before supplying credentials. The client follows no
redirects and reads no credential environment variables. MCP tools retain their existing
allowlist and credential policy.
request(method, params, timeoutMs?, signal?) returns the protocol result as unknown for
validation by the caller. It performs no retries. A CodexAppServerRequestError exposes
dispatched and an optional server error code; a timeout or disconnect after dispatch
does not prove a mutation failed. Never replay an uncertain thread/start or turn/start.
The client handles initialize/initialized, response correlation, bounded framing, and close
cleanup. The default inbound frame limit is 4 MiB; maxMessageBytes accepts 1 KiB–64 MiB.
Outgoing buffers are capped at 4 MiB and pending requests at 256 in each direction.
handshakeTimeoutMs and initializeTimeoutMs independently configure startup deadlines
as integer milliseconds from 1 to 120,000; each defaults to 5,000. For a cold local
subprocess, set initializeTimeoutMs: 30000. Expiry closes the connection without retrying.
After connect(), the client exposes what the server reported in its initialize response:
serverVersion (parsed from its user agent), serverCodexHome (the server's absolute
$CODEX_HOME), serverPlatformFamily (for example unix or windows) and serverPlatformOs
(for example linux, macos or windows). Each is undefined when the server omits it:
Codex before 0.115 reports no platform and Codex before 0.120 no codexHome. A consumer
that rejoins a restarted daemon can compare serverCodexHome with the value it saw before
to confirm the same thread store. The client compares nothing itself. A reported but
malformed value closes the connection and rejects connect() with
Codex server returned an invalid initialize <field>. codexHome must be absolute on the
reported platform family (/…, or for windows a drive, UNC or verbatim prefix), well-formed
Unicode without control characters, and at most 4096 UTF-8 bytes. The platform fields must be
lowercase identifiers of at most 64 characters.
Server request handlers return immediately so notifications continue during human input.
Each request carries an AbortSignal, aborted when it is answered, resolved by the server,
or disconnected. respond(id, result) and rejectRequest(id, code, message) return false
for an expired ID. Unhandled server requests receive method-not-supported; this low-level
client never grants or automatically declines a permission. close() ends the supplied
stdio streams but never kills a subprocess. The existing MCP Codex adapter delegates to
this client and retains its conservative automatic rejection policy.
Codex sends some server requests to every client connected to the app-server, although only
one of them may answer. An accounts service running a managed Codex answers
account/chatgptAuthTokens/refresh itself; an attached client must neither answer nor reject
it, since any reply races the owner's. List such methods in ignoredServerRequestMethods:
const client = new CodexAppServerClient({
transport: { type: 'unix', socketPath: managedCodexSocketPath },
clientInfo: { name: 'my-app', title: 'My App', version: '1.0.0' },
ignoredServerRequestMethods: ['account/chatgptAuthTokens/refresh'],
onServerRequest: (request) => showApproval(request),
});
The contract is that another client connected to the same app-server answers these methods.
The client does not pass them to onServerRequest, sends no reply, not even method-not-supported,
and does not track them, so they never count toward the limit of 256 pending server requests.
Codex resolves only thread-bound server requests with serverRequest/resolved, so a client
that keeps an unanswered account request tracked reaches that limit, and the client then
closes the connection.
Codex Unix socket alias
Since Codex 0.156.0, codex app-server --listen unix://PATH does not bind PATH. Codex binds
the socket in its fixed per-user directory, <realpath /tmp>/codex-daemon-<euid> (mode 0700),
under the SHA-256 of PATH with its parent directory canonicalised, and publishes PATH as a
symlink to it. Its shared local daemon socket, $CODEX_HOME/app-server-control/app-server-control.sock,
is published the same way. The unix transport connects through that link like any other
path; these two functions let a caller check, before connecting, that the entry really is
Codex's own:
import { CodexAppServerClient, codexUnixSocketTarget, observeCodexUnixSocket } from '@modelprofile.com/mcp-crossharness';
const target = await codexUnixSocketTarget(socketPath); // where Codex binds PATH
if (await observeCodexUnixSocket(socketPath) === 'published') {
const client = new CodexAppServerClient({
transport: { type: 'unix', socketPath },
clientInfo: { name: 'my-app', title: 'My App', version: '1.0.0' },
});
await client.connect();
}
observeCodexUnixSocket(socketPath) returns 'absent' when nothing exists at the path and
'published' only when the path is a link owned by the effective user whose text is exactly
the derived target, in a parent directory other users cannot rewrite (owned by the user or
root, and not group- or world-writable unless sticky, which is Codex's own startup condition),
pointing at a socket owned by the effective user in a directory owned by that user with mode
0700. Every other entry throws, and nothing is created, removed or connected to. Ownership is
compared with the effective uid because Codex keys the directory by and checks against it.
A socket bound directly at the path, as Codex 0.155 and earlier did, is not this contract
and is refused like any other foreign entry.
Both functions take an optional { daemonDirectory } that replaces the per-user directory for
fixtures; codexUnixSocketTarget needs the socket's parent directory to exist. They throw
plain Errors with these exact messages:
| Message | Cause |
|---|---|
Codex Unix socket path must be a normalized absolute file path |
relative, non-normalized, trailing-separator or NUL-containing path |
Codex daemon socket directory must be a normalized absolute path |
invalid daemonDirectory |
Codex Unix socket inspection requires a POSIX effective user id |
no process.geteuid (Windows) |
Codex Unix socket path is not the alias Codex publishes |
a regular file, a direct socket, another user's link, or a link to any other target |
Codex Unix socket parent directory lets other users replace its entries |
the path's parent is not safe for a published alias |
Codex Unix socket alias has no socket behind it |
Codex's own link whose target is gone (a stale publication) |
Codex Unix socket directory is not private to the current user |
the per-user directory is not the user's own 0700 directory |
Codex Unix socket is not a socket owned by the current user |
the target is not a socket, or is another user's |
Filesystem errors other than a missing path propagate unchanged.
Codex version floor
A consumer that relies on the alias contract refuses an older Codex by version before it
starts one. CODEX_UNIX_SOCKET_MINIMUM_VERSION is '0.156.0', the first release that
publishes the alias, and requireCodexVersion checks a version against it:
import { CodexVersionUnsupportedError, requireCodexVersion } from '@modelprofile.com/mcp-crossharness';
await client.connect();
requireCodexVersion(client.serverVersion); // throws CodexVersionUnsupportedError below 0.156.0
parseCodexVersion(text) reads exactly one release number, MAJOR.MINOR.PATCH with an
optional prerelease such as 0.158.0-alpha.2, and returns { major, minor, patch, prerelease, text },
or null for anything else: a prefix such as codex-cli , surrounding whitespace, build
metadata, leading zeros or a component beyond the safe integer range. Strip Codex's own
prefix before parsing.
requireCodexVersion(version, minimum?) returns the parsed version when it is the minimum or
newer. The minimum defaults to CODEX_UNIX_SOCKET_MINIMUM_VERSION; a consumer that needs a
later release for its own reasons passes that release, which must be a release number and not
a prerelease. A prerelease of a newer release is accepted; a prerelease of the minimum itself
is older than the minimum. Otherwise it throws CodexVersionUnsupportedError, whose reason
is 'unreadable' (no version, or not a release number) or 'below_minimum', with the checked
version (null when none was reported) and the minimum:
| Message | Reason |
|---|---|
Codex did not report a readable version; Codex <minimum> or newer is required |
unreadable |
Codex <version> is older than the required Codex <minimum> |
below_minimum |
Composable tool registration
CrossHarnessMcpToolRegistrar registers the same nine Crossharness tools used by the standalone
server on an MCP SDK 1.30 McpServer owned by an embedding application:
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import {
ConnectionRegistry,
CrossHarnessMcpToolRegistrar,
} from '@modelprofile.com/mcp-crossharness';
const connections = new ConnectionRegistry({
allowedServerUrls: ['http://127.0.0.1:4096/'],
openCodeCredentials: {
serverUrl: 'http://127.0.0.1:4096/',
username: 'opencode',
password: controllerOwnedPassword,
},
});
const crossHarnessTools = new CrossHarnessMcpToolRegistrar(connections);
const sharedServer = new McpServer({ name: 'controller', version: '1.0.0' });
crossHarnessTools.register(sharedServer);
await sharedServer.connect(hostOwnedTransport);
// During host shutdown, close the shared server before its Crossharness registrar.
await sharedServer.close();
await crossHarnessTools.close();
The registrar takes lifecycle ownership of injected ConnectionRegistry and DispatchRegistry
instances. Call register() before connecting the shared server. A successful registrar can
register only once.
Composition supports an ordinary high-level McpServer tool surface, including host tools added
with McpServer.registerTool(). A duplicate high-level tool name throws and transactionally removes
the Crossharness tools added by that attempt without replacing the host's tool.
Do not preinstall custom low-level tools/list or tools/call handlers with
sharedServer.server.setRequestHandler(). SDK 1.30 adds the first high-level tool to its internal
map before discovering that low-level handler conflict and throwing, without returning a
RegisteredTool handle. The registrar cannot transactionally roll back that SDK mutation. Use the
ordinary high-level McpServer.registerTool() surface or a separate McpServer instead.
close() removes the registrar's tool registrations, closes every owned connection, and disposes
its dispatch registry. If the registered server is still connected, close() fails before
removing tools or mutating either registry. Close the shared server first, then call registrar
close(); failed connection cleanup remains retryable by calling it again. The registrar never
connects or closes the shared McpServer, never owns its transport, and installs no stdin,
process-signal, or shared-server close listeners.
CrossHarnessMcpServer is the standalone stdio lifecycle wrapper. It creates this exact registrar,
keeps the existing one-shot start() contract, and owns only its own MCP server, transport, and
stdin close handling in addition to delegating tool-resource cleanup to the registrar. It closes
the server before the registrar, retains the same server and transport when transport close fails,
and retries them on the next close(). Concurrent transport and registrar cleanup failures are
reported together as an AggregateError.
Process-held state
Connections and dispatch records exist only in the MCP process. A host that wants to restart or re-exec that process without losing anything can ask the registrar what it holds:
const connections = crossHarnessTools.connections.list();
const dispatches = crossHarnessTools.dispatches.list();
const holdsState = connections.length > 0 || dispatches.length > 0;
DispatchRegistry.list() returns one IDispatchSummary per record, oldest first:
dispatchId, connectionId, harness, status (working, completed or failed),
startedAt, and for a settled record completedAt and expiresAt. A summary never carries the
message, the reply, the error text or the chat id, and it is a snapshot the caller may keep or
change.
Every listed record is state a restart would lose. A working record loses the result of a send
that may still run in the harness; a settled record loses the reply check_reply would return
until expiresAt. Records outlive their connection, so an empty connection list does not mean an
empty dispatch registry. Expired records are pruned before listing. Registrar close() disposes
the dispatch registry, which then lists nothing.
Direct registry use
The registries remain public for applications that need the connection API without MCP tool registration:
import { ConnectionRegistry } from '@modelprofile.com/mcp-crossharness';
const connections = new ConnectionRegistry({
allowedServerUrls: ['http://127.0.0.1:4096/'],
openCodeCredentials: {
serverUrl: 'http://127.0.0.1:4096/',
username: 'opencode',
password: controllerOwnedPassword,
},
});
const connection = await connections.connect(
'opencode',
'http://127.0.0.1:4096/',
'/absolute/path/on/server',
);
const chats = await connections.get(connection.connectionId).listChats(10);
await connections.disconnect(connection.connectionId);
Connection closure is fail-closed and retryable. disconnect() retains ownership when a close
fails, and another call with the same connection ID retries it. Overlapping close requests share
the active close attempt. closeAll() permanently closes the registry to new connections, waits
for every current close attempt, and retains failed connections for a later closeAll() retry. A
single failure is rethrown directly; multiple failures are reported as an AggregateError.
openCodeCredentials is intended for an embedding application that owns the
OpenCode server credential. The registry validates and copies it during
construction. An injected password bypasses the environment credentials; an
omitted username uses the protocol default opencode. Injected credentials are
bound to their exact serverUrl, never included in connection metadata, and
redacted from OpenCode HTTP response errors. Plain HTTP injection is accepted
only for loopback hosts; use HTTPS for remote OpenCode servers. The standalone
MCP server continues to read its credentials from fixed environment variables;
credentials are never accepted as tool arguments.
License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the repository license file.
Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
Company Information
Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.