@irisrun/channel-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ export declare const PACKAGE = "@irisrun/channel-mcp";
2
+ export { makeMcpChannel } from "./server.js";
3
+ export type { McpChannel, McpChannelOptions, TurnInputs, JsonRpcRequest, JsonRpcResponse, } from "./server.js";
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ // @irisrun/channel-mcp — public surface (host; agent exposed AS an MCP server).
2
+ export const PACKAGE = "@irisrun/channel-mcp";
3
+ export { makeMcpChannel } from "./server.js";
@@ -0,0 +1,44 @@
1
+ import type { Readable, Writable } from "node:stream";
2
+ import { type HostAdapter } from "@irisrun/host";
3
+ import type { Program, PerformerRegistry, LogicalClock, Json } from "@irisrun/core";
4
+ export interface TurnInputs<S extends Json> {
5
+ program: Program<S>;
6
+ performers: PerformerRegistry;
7
+ clock: LogicalClock;
8
+ defDigest: string;
9
+ snapshotThreshold?: number;
10
+ holderId?: string;
11
+ }
12
+ export interface McpChannelOptions<S extends Json> {
13
+ adapter: HostAdapter;
14
+ makeTurnInputs: (sessionId: string, args: Json) => TurnInputs<S>;
15
+ mintSessionId?: () => string;
16
+ mintToken?: () => string;
17
+ serverInfo?: {
18
+ name: string;
19
+ version: string;
20
+ };
21
+ }
22
+ export interface JsonRpcRequest {
23
+ jsonrpc: "2.0";
24
+ id?: number | string | null;
25
+ method: string;
26
+ params?: Json;
27
+ }
28
+ export type JsonRpcResponse = {
29
+ jsonrpc: "2.0";
30
+ id: number | string | null;
31
+ result: Json;
32
+ } | {
33
+ jsonrpc: "2.0";
34
+ id: number | string | null;
35
+ error: {
36
+ code: number;
37
+ message: string;
38
+ };
39
+ };
40
+ export interface McpChannel {
41
+ handle(req: unknown): Promise<JsonRpcResponse>;
42
+ serve(input: Readable, output: Writable): void;
43
+ }
44
+ export declare function makeMcpChannel<S extends Json>(opts: McpChannelOptions<S>): McpChannel;
package/dist/server.js ADDED
@@ -0,0 +1,141 @@
1
+ // makeMcpChannel (ADR-0009, MCP is dual-use): the agent exposed AS an MCP server
2
+ // over JSON-RPC 2.0. `handle(req)` is the testable core; `serve(in,out)` frames
3
+ // newline-delimited JSON-RPC over a stream (stdin/stdout in production). It speaks
4
+ // the SAME two-identifier protocol as channel-rest — the channel MINTS the
5
+ // sessionId and OWNS/ISSUES the continuationToken (rotated per committed turn,
6
+ // ATOMICALLY single-use via a per-session in-flight claim taken with no `await`
7
+ // between the token check and the claim) — and surfaces every failure as a LOUD
8
+ // JSON-RPC error, never a silent OK. Host-side (node:crypto); core stays pure.
9
+ import { randomUUID } from "node:crypto";
10
+ import { runTurnOn } from "@irisrun/host";
11
+ // JSON-RPC + MCP error codes. Protocol errors use the standard codes; app errors
12
+ // (token/session) use the implementation-defined -32000 range — all LOUD.
13
+ const PARSE_ERROR = -32700;
14
+ const INVALID_REQUEST = -32600;
15
+ const METHOD_NOT_FOUND = -32601;
16
+ const INVALID_PARAMS = -32602;
17
+ const UNKNOWN_SESSION = -32001;
18
+ const MISSING_TOKEN = -32002;
19
+ const STALE_TOKEN = -32003;
20
+ const IN_FLIGHT = -32004;
21
+ const PROTOCOL_VERSION = "2024-11-05";
22
+ const START_TOOL = {
23
+ name: "start",
24
+ description: "Begin a session: run the first turn; returns {sessionId, continuationToken, status, output?}.",
25
+ inputSchema: { type: "object", properties: {}, additionalProperties: true },
26
+ };
27
+ const MESSAGE_TOOL = {
28
+ name: "message",
29
+ description: "Continue a session: present the issued continuationToken; returns a NEW token + status.",
30
+ inputSchema: {
31
+ type: "object",
32
+ properties: { sessionId: { type: "string" }, continuationToken: { type: "string" } },
33
+ required: ["sessionId", "continuationToken"],
34
+ },
35
+ };
36
+ export function makeMcpChannel(opts) {
37
+ const mintSessionId = opts.mintSessionId ?? (() => randomUUID());
38
+ const mintToken = opts.mintToken ?? (() => randomUUID());
39
+ const serverInfo = opts.serverInfo ?? { name: "iris", version: "0.0.0" };
40
+ const tokens = new Map(); // the channel OWNS the current token per session
41
+ const inFlight = new Set(); // single-use guard under concurrency (see channel-rest)
42
+ const ok = (id, result) => ({ jsonrpc: "2.0", id, result });
43
+ const err = (id, code, message) => ({
44
+ jsonrpc: "2.0",
45
+ id,
46
+ error: { code, message },
47
+ });
48
+ const toolResult = (payload) => ({ content: [{ type: "text", text: JSON.stringify(payload) }] });
49
+ const runOne = async (sessionId, args) => runTurnOn(opts.adapter, { sessionId, ...opts.makeTurnInputs(sessionId, args) });
50
+ const turnPayload = (sessionId, token, outcome) => {
51
+ const base = { sessionId, continuationToken: token, status: outcome.status };
52
+ if (outcome.status === "finished" && outcome.output !== undefined)
53
+ base.output = outcome.output;
54
+ if (outcome.status === "parked")
55
+ base.wait = outcome.wait;
56
+ if (outcome.status === "contended")
57
+ base.current = outcome.current;
58
+ return base;
59
+ };
60
+ const callStart = async (id, args) => {
61
+ const sessionId = mintSessionId();
62
+ const outcome = await runOne(sessionId, args);
63
+ const token = mintToken();
64
+ tokens.set(sessionId, token);
65
+ return ok(id, toolResult(turnPayload(sessionId, token, outcome)));
66
+ };
67
+ const callMessage = async (id, args) => {
68
+ const sessionId = typeof args.sessionId === "string" ? args.sessionId : "";
69
+ if (!sessionId || !tokens.has(sessionId))
70
+ return err(id, UNKNOWN_SESSION, `unknown session '${sessionId}'`);
71
+ const presented = typeof args.continuationToken === "string" ? args.continuationToken : null;
72
+ if (presented === null || presented === "")
73
+ return err(id, MISSING_TOKEN, "missing continuationToken");
74
+ if (presented !== tokens.get(sessionId))
75
+ return err(id, STALE_TOKEN, "stale or invalid continuationToken");
76
+ // ATOMIC single-use: the token check above and this claim run in ONE callback
77
+ // with no `await` between them; a concurrent second message is refused here.
78
+ if (inFlight.has(sessionId))
79
+ return err(id, IN_FLIGHT, "a turn is already in flight for this session");
80
+ inFlight.add(sessionId);
81
+ try {
82
+ const outcome = await runOne(sessionId, args);
83
+ const next = mintToken();
84
+ tokens.set(sessionId, next); // rotate ONLY after a committed turn → single-use
85
+ return ok(id, toolResult(turnPayload(sessionId, next, outcome)));
86
+ }
87
+ finally {
88
+ inFlight.delete(sessionId);
89
+ }
90
+ };
91
+ const handle = async (raw) => {
92
+ const req = raw;
93
+ const id = (req && (typeof req.id === "number" || typeof req.id === "string") ? req.id : null);
94
+ if (!req || req.jsonrpc !== "2.0" || typeof req.method !== "string") {
95
+ return err(id, INVALID_REQUEST, "invalid JSON-RPC request");
96
+ }
97
+ switch (req.method) {
98
+ case "initialize":
99
+ return ok(id, { protocolVersion: PROTOCOL_VERSION, serverInfo, capabilities: { tools: {} } });
100
+ case "tools/list":
101
+ return ok(id, { tools: [START_TOOL, MESSAGE_TOOL] });
102
+ case "tools/call": {
103
+ const params = (req.params ?? {});
104
+ const args = (params.arguments ?? {});
105
+ if (params.name === "start")
106
+ return callStart(id, args);
107
+ if (params.name === "message")
108
+ return callMessage(id, args);
109
+ return err(id, INVALID_PARAMS, `unknown tool '${params.name}'`);
110
+ }
111
+ default:
112
+ return err(id, METHOD_NOT_FOUND, `method not found: ${req.method}`);
113
+ }
114
+ };
115
+ // Frame newline-delimited JSON-RPC over a stream (the manual stdio smoke). A
116
+ // malformed line is answered with a loud parse error, never dropped silently.
117
+ const serve = (input, output) => {
118
+ let buf = "";
119
+ input.setEncoding("utf8");
120
+ input.on("data", (chunk) => {
121
+ buf += chunk;
122
+ let nl;
123
+ while ((nl = buf.indexOf("\n")) >= 0) {
124
+ const line = buf.slice(0, nl).trim();
125
+ buf = buf.slice(nl + 1);
126
+ if (line === "")
127
+ continue;
128
+ let parsed;
129
+ try {
130
+ parsed = JSON.parse(line);
131
+ }
132
+ catch {
133
+ output.write(JSON.stringify(err(null, PARSE_ERROR, "parse error")) + "\n");
134
+ continue;
135
+ }
136
+ void handle(parsed).then((resp) => output.write(JSON.stringify(resp) + "\n"));
137
+ }
138
+ });
139
+ };
140
+ return { handle, serve };
141
+ }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@irisrun/channel-mcp",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Iris MCP-server channel (ADR-0009, MCP dual-use) — the agent exposed AS an MCP server over JSON-RPC 2.0 (stdio). Same two-identifier protocol as channel-rest: the channel mints the sessionId, issues + rotates the continuationToken (atomically single-use), and surfaces every failure as a loud JSON-RPC error. A real MCP stdio client is a manual smoke.",
6
+ "exports": {
7
+ ".": {
8
+ "iris-src": "./src/index.ts",
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "dependencies": {
14
+ "@irisrun/core": "^0.1.0",
15
+ "@irisrun/host": "^0.1.0"
16
+ },
17
+ "license": "MIT",
18
+ "engines": {
19
+ "node": ">=24"
20
+ },
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/xoai/iris.git",
27
+ "directory": "packages/channel-mcp"
28
+ },
29
+ "homepage": "https://github.com/xoai/iris#readme",
30
+ "files": [
31
+ "dist"
32
+ ]
33
+ }