@skillstate/mcp
v3.0.1
Published
Zero-dependency MCP server + adapter for the skillstate runtime.
Readme
@skillstate/mcp
Zero-dependency Model Context Protocol server + adapter for the @skillstate/core runtime.
@skillstate/mcp exposes the skillstate runtime (@skillstate/core)
as a Model Context Protocol server (protocol revision 2026-07-28) over
stdio (JSON-RPC 2.0, newline-delimited). It reuses the paper-exact core
directly — mergeState, createInitialState, validatePatchDeep, migrate,
redactSecrets — so any MCP client can read, patch, checkpoint, and roll back
the execution state as tools and resources.
@non-paper — the server is additive; no MCP exists in arXiv 2608.26263v3. Unlike the prompting adapters, MCP is runtime access, not prompting, so the O(1) question does not apply.
Installation
npm i @skillstate/core @skillstate/mcpRequires Node.js >= 20. TypeScript types are bundled. Ships a skillstate-mcp
bin that launches the server directly.
Quick start
New to the server? See
QUICKSTART.md— a verified launch-and-drive tour (handshake, spec selection, session lifecycle).
import { McpAdapter, McpServer, launch } from '@skillstate/mcp';
import { INTERCODE_CTF_SPEC } from '@skillstate/core/schemas';
const adapter = new McpAdapter();
// .mcp.json config registering the skillstate stdio server:
const config = adapter.generateMcpConfig('/path/to/.mcp.json');
// -> { "mcpServers": { "skillstate": { "command", "args", "env" } } }
// Or run an in-process server and drive it line-by-line:
const server = new McpServer({
spec: INTERCODE_CTF_SPEC,
root: '.',
name: '.skillstate.json',
});
const response = await server.handleLine(
JSON.stringify({
jsonrpc: '2.0', id: 1, method: 'tools/call',
params: { name: 'state.get', arguments: {} },
}),
);
// Or launch a stdio server (state resolves per session from the server's cwd):
await launch({ spec: INTERCODE_CTF_SPEC });Command-line:
skillstate-mcp # reads SKILLSTATE_SPEC_PATH; state resolves from the cwdRegistering the server
skillstate init (per project) and skillstate install (machine-level,
Codex only) register the server for you — always as
npx -y @skillstate/mcp@^3, pinned to the v3 major, with no embedded
environment (the server resolves the state from its own cwd). The three
shapes:
Project opencode.json(c) → mcp object (OpenCode):
{
"mcp": {
"skillstate": {
"type": "local",
"command": ["npx", "-y", "@skillstate/mcp@^3"],
"enabled": true
}
}
}Project .mcp.json → mcpServers (Claude Code, stdio wire format):
{
"mcpServers": {
"skillstate": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@skillstate/mcp@^3"]
}
}
}~/.codex/config.toml → [mcp_servers.skillstate] (machine-level, via
skillstate install):
[mcp_servers.skillstate]
command = "npx"
args = ["-y", "@skillstate/mcp@^3"]
enabled = trueAll entries are committed per project (the Codex TOML is the one
machine-level exception) and the server is inert until the project has been
initialized with skillstate init — until then every state/agent tool and
the skillstate://state/skillstate://summary
resources return no skillstate state in this directory — run \skillstate
init`` and nothing is created. Verify with:
# NOTE: this server is registered for claude, codex and any other MCP-capable
# host. It is deliberately NOT registered for opencode, whose v2 plugin
# provides the same state as native tools — a second surface over one file
# with different write rules is a hazard, and costs ~1.6k tokens per request
# in resident tool descriptions.
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-07-28","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}
{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \
| node packages/mcp/bin/mcp.js
# -> protocolVersion "2026-07-28" + 14 toolsAPI / Exports
Root path @skillstate/mcp exports McpAdapter, McpServer, launch,
PROTOCOL_VERSION, and SUPPORTED_PROTOCOL_VERSIONS (plus the types
McpServerOptions, LaunchArgs, JsonRpcRequest, McpToolResult,
ToolAnnotations, and McpConfigOptions).
new McpAdapter()—name = 'mcp'.generateMcpConfig(target, options?): string— a deterministic, secret-free.mcp.jsondocument (McpConfigOptions.specPath,.command,.launcherPath,.env). No state path is embedded — the server resolves the state from its own cwd.saveMcpConfig(target, options?): Promise<string>— atomic write.
new McpServer(options: McpServerOptions)—{ spec, root, name, agent?, tracker? }.protocolVersionis the newest supported revision ('2026-07-28').initializeechoes the client's requested revision when it is one ofSUPPORTED_PROTOCOL_VERSIONS(2024-11-05…2026-07-28) and answers with the newest otherwise — the client decides whether it can work with the negotiated revision (per the MCP spec).handleLine(line): Promise<string | null>— process one already-framed JSON-RPC message.feed(chunk): Promise<string[]>— consume streamed stdin (newline-delimited JSON-RPC; partial lines are buffered).start(input?, output?): Promise<McpServer>/stop()/get isRunning().
launch(args?): Promise<McpServer>— resolves the spec from args or env and starts a stdio server; the state always resolves from the server's cwd.
Tools: state.get, state.patch (the single write op — validates via
validatePatchDeep, returns { state, changes, warnings }), state.validate
(dry-run), state.diff (changes since the last call, { full: true } for
before/after), state.checkpoint (named sidecar snapshot),
state.rollback (restore from a checkpoint), state.summary (compact
orientation + session info), state.metrics, state.finalize (the agent's
"I am done" lifecycle marker), spec.get (with a ready-made
valid example_state_patch), spec.next (goal/next/blockers guidance),
plus the AGENT tools agent.list / agent.read / agent.merge.
state.merge and state.reset are gone — state.patch validates, and
rollback replaces reset.
Multi-agent (2.2.0). Every state tool accepts { agent } (sanitized
[A-Za-z0-9_-], ≤ 64) scoping the file to
<stateDir>/agents/<agentId>/<name>; the server default comes from the
SKILLSTATE_AGENT_ID env (launch) or the McpServerOptions.agent
constructor option; the default '' is the main agent. All writes
(state.patch, state.rollback, state.checkpoint, agent.merge) run
under withStateLock — a cross-process lockfile at <state>.lock with
stale-TTL takeover — so 2-3 concurrent agent processes never interleave
state writes. The state.diff baseline is persisted to
<stateDir>/.diff-baseline.json (atomic, under the lock) — the
"since your last look" semantics is now consistent across processes.
The agent tools: agent.list scans <stateDir>/agents/ and returns
{ agents: [{ id, statePath, exists, status, lastActivityAt, staleness,
ageMs, summary, lastModified }] } (light summary: keys + size, no values);
agent.read returns a sub-agent's state read-only; agent.merge folds a
sub-agent copy into the main state under the lock — keys only in the sub
state are taken, nested objects merge recursively, conflicting scalars
follow keep: 'main' (default) or 'sub' (schema defaults count as
"never set"), and the sub copy is NOT deleted — it is marked mergedAt
(history) and its session sidecar flips to status: 'merged'.
Session lifecycle (2.3.0). The state envelope belongs to the
procedure; the session lifecycle lives in a separate sidecar next to
every state file — <stateDir>/.session-meta.json (agent scopes:
agents/<id>/.session-meta.json), written atomically under its own
withStateLock:
launch()stamps{ status: 'running', startedAt, agentId, protocolVersion }— a new launch overwrites any previousinterrupted/completedmarker (a fresh run has begun).- Every state write (
state.patch/state.rollback/state.checkpoint/agent.merge) refresheslastActivityAt, debounced to at most one sidecar write per 5 s; a broken sidecar never fails a state write. state.finalize { status: 'completed' | 'failed', result? }is the agent's own "I am done" signal — it writes{ status, finishedAt, result }soagent.list/state.summaryshow a finished session instead of a running/interrupted one.- SIGINT/SIGTERM flush
status: 'interrupted'+ re-pin the diff baseline to the surviving state, then exit 130 (installShutdownfrom@skillstate/core; terminal statuses recorded by the agent are never clobbered). Embedders that own the process passinstallInterruptHandler: false. - Staleness (
STALE_MS= 5 min in@skillstate/core):active— fresh running session or a terminal status;stale—runningwith no writes for 5 min (the provider died without a signal);orphan— no (or corrupt) sidecar.agent.listaddsageMsfor running sessions;state.summaryaddsstatus/lastActivityAt/stalenessto itssessionobject.
Resources (resources/read): skillstate://state (the full
{ version, state } envelope), skillstate://spec, and
skillstate://summary (compact projection). State is redacted on every read,
and the server conserves its own buffering so transports may split lines
mid-message.
Inert until init. The server may be registered machine-level (e.g. codex
~/.codex/config.toml) and launched per-project, so it never materializes
state in a project that was never initialized: every tool call except
spec.get (pure config) and every resource read except skillstate://spec
checks that the launch-time state directory (root) exists on disk first —
when it is missing the call returns an isError result with the fixed text
no skillstate state in this directory — run \skillstate init`, the
launch-time session stamp is skipped, and nothing (not even the directory)
is ever created. Once skillstate init` has run, the same calls proceed
normally.
Notes
- Zero dependencies.
@skillstate/mcpdeclares only@skillstate/core; it uses Node'sfs/path/streamfor the stdio transport and crash-safe state writes (temp sibling + fsync + rename). - Transport is newline-delimited JSON only (the MCP stdio framing);
Content-Length-framed input is not understood and errors as-32700. - Every patch — including
state.patch— runsvalidatePatchDeep(defense-in-depth) before the ⊕ merge; an invalid patch is anisErrorresult carryingerrorandfield, and nothing is written.redactSecretsfails closed so secrets never leave the process through a tool result or resource read. - Checkpoints live in
<stateDir>/checkpoints/<seq>-<label>.jsonsidecars (atomic writes) and also pin<path>.snapshotviaFileStore.snapshot(); the sequence numbers derive from the sidecar catalog, so they survive restarts. The sessionseqreported bystate.summarycounts writes applied through the server in this session.
Related
- Paper: arXiv:2608.26263.
- Core runtime:
@skillstate/core. state.md— design notes.- Prompting adapters:
@skillstate/claude,@skillstate/opencode,@skillstate/codex.
License
MIT © 2026 Vitaly Kuzyaev
