@hediet/linkrpc-mcp 0.0.1

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,229 @@
1
+ import { a as HubSenderProvider, c as ConnectionPool, d as HubAccess, f as IConnectionPool, i as McpToolCall, l as ConnectionPoolOptions, m as TraceListener, n as LinkRpcMcpServerOptions, o as McpSessionInfo, p as PooledConnection, r as McpExploreCall, s as ProviderPool, t as LinkRpcMcpServer, u as DefaultTransport } from "./chunks/server-Bquqk9Mk.js";
2
+ import { IRequestSender, LinkRpcInterfaceSchema, SigningCallCtx } from "@hediet/linkrpc";
3
+ //#region src/explore.d.ts
4
+ interface ExploreCommonArgs {
5
+ /** Exact directory-level filters, applied before browsing or searching. */
6
+ readonly serviceId?: string;
7
+ readonly interfaceId?: string;
8
+ /** Include reflection plumbing such as `hubrpc.directory` and `hubrpc.schemas`. */
9
+ readonly includeInternal?: boolean;
10
+ /** Request one broad reflection grant when a gated directory is encountered. */
11
+ readonly requestPermission?: boolean;
12
+ }
13
+ interface ExploreBrowseArgs extends ExploreCommonArgs {
14
+ readonly kind: 'browse';
15
+ /** Number of interfaces to return. Defaults to 20; maximum 100. */
16
+ readonly limit?: number;
17
+ /** Opaque continuation cursor returned by a previous browse call. */
18
+ readonly cursor?: string;
19
+ }
20
+ interface ExploreGrepArgs extends ExploreCommonArgs {
21
+ readonly kind: 'grep';
22
+ /** Pattern searched against the generated `defineInterface` source, one line at a time. */
23
+ readonly pattern: string;
24
+ /** Defaults to `regex`. Both modes are case-insensitive. */
25
+ readonly syntax?: 'regex' | 'literal';
26
+ /** Number of matching virtual documents to return. Defaults to 20; maximum 100. */
27
+ readonly limit?: number;
28
+ /** Opaque continuation cursor returned by a previous grep call. */
29
+ readonly cursor?: string;
30
+ /** Source lines included before and after each match. Defaults to 1; maximum 5. */
31
+ readonly contextLines?: number;
32
+ }
33
+ interface ExploreInspectArgs extends ExploreCommonArgs {
34
+ readonly kind: 'inspect';
35
+ readonly serviceId: string;
36
+ readonly interfaceId: string;
37
+ /** Generated `defineInterface` source by default; use `schema` for the raw wire schema. */
38
+ readonly format?: 'source' | 'schema';
39
+ }
40
+ type ExploreArgs = ExploreBrowseArgs | ExploreGrepArgs | ExploreInspectArgs;
41
+ interface ExploreDeps {
42
+ requestReflectionAccess(): Promise<boolean>;
43
+ }
44
+ interface ExploreListing {
45
+ readonly serviceId: string;
46
+ readonly serviceDescription?: string;
47
+ readonly interfaceId: string;
48
+ readonly interfaceHash: string;
49
+ readonly documentId: string;
50
+ }
51
+ interface ExploreGrepMatch {
52
+ /** Matching source plus the requested surrounding context, joined with newlines. */
53
+ readonly searchResult: string;
54
+ /** Inclusive, 1-based line range of `searchResult` in the virtual document. */
55
+ readonly lineRange: readonly [start: number, end: number];
56
+ /** LinkRPC member containing every matched line in this chunk, when unambiguous. */
57
+ readonly member?: string;
58
+ }
59
+ interface ExploreGrepEntry extends ExploreListing {
60
+ readonly matches: readonly ExploreGrepMatch[];
61
+ /** True when this document contains more matches than were returned. */
62
+ readonly matchesTruncated?: boolean;
63
+ }
64
+ interface ExploreDocumentError {
65
+ readonly serviceId: string;
66
+ readonly interfaceId: string;
67
+ readonly error: string;
68
+ }
69
+ interface ExploreInaccessible {
70
+ readonly serviceId: string;
71
+ readonly reason: string;
72
+ readonly hint: string;
73
+ }
74
+ interface ExploreBrowseResult {
75
+ readonly kind: 'browse';
76
+ readonly total: number;
77
+ readonly entries: readonly ExploreListing[];
78
+ readonly nextCursor?: string;
79
+ readonly inaccessible?: readonly ExploreInaccessible[];
80
+ }
81
+ interface ExploreGrepResult {
82
+ readonly kind: 'grep';
83
+ readonly pattern: string;
84
+ readonly syntax: 'regex' | 'literal';
85
+ readonly total: number;
86
+ readonly entries: readonly ExploreGrepEntry[];
87
+ readonly nextCursor?: string;
88
+ readonly documentErrors?: readonly ExploreDocumentError[];
89
+ readonly inaccessible?: readonly ExploreInaccessible[];
90
+ }
91
+ interface ExploreInspectResult extends ExploreListing {
92
+ readonly kind: 'inspect';
93
+ readonly format: 'source' | 'schema';
94
+ readonly source?: string;
95
+ readonly schema?: LinkRpcInterfaceSchema;
96
+ readonly inaccessible?: readonly ExploreInaccessible[];
97
+ }
98
+ type ExploreResult = ExploreBrowseResult | ExploreGrepResult | ExploreInspectResult;
99
+ /**
100
+ * Browse the reflected directory, grep generated interface source, or inspect
101
+ * one exact virtual document. Generated source is cached by the directory
102
+ * route and interface hash; every call still walks the live directory.
103
+ */
104
+ declare function explore(channel: IRequestSender<SigningCallCtx>, args: ExploreArgs, deps?: ExploreDeps): Promise<ExploreResult>;
105
+ //#endregion
106
+ //#region src/sandbox.d.ts
107
+ /**
108
+ * Async host functions exposed to the guest. Each returns either:
109
+ * - a JSON-stringified value (the guest will `JSON.parse` it), or
110
+ * - the empty string for `undefined` results (used for `notify`).
111
+ *
112
+ * Host rejections become guest `Error`s with a bounded snapshot of every
113
+ * serializable own data property preserved.
114
+ */
115
+ interface SandboxHostApi {
116
+ call(method: string, paramsJson: string, optsJson: string, onStreamMessage: (payloadJson: string) => void, signal: AbortSignal): Promise<string>;
117
+ notify(method: string, paramsJson: string): Promise<string>;
118
+ explore(argsJson: string): Promise<string>;
119
+ requestAccess(argsJson: string): Promise<string>;
120
+ grants(argsJson: string): Promise<string>;
121
+ }
122
+ type SandboxLog = {
123
+ level: "log" | "warn" | "error";
124
+ text: string;
125
+ };
126
+ /** Legacy options for {@link runSandboxed} (run-to-completion, no parking). */
127
+ interface SandboxOptions {
128
+ /** Foreground budget in ms. Defaults to {@link DEFAULT_FOREGROUND_MS}. */
129
+ readonly timeoutMs?: number;
130
+ readonly memoryLimitBytes?: number;
131
+ }
132
+ /** Options for {@link startSandbox} (parking-capable). */
133
+ interface StartSandboxOptions {
134
+ /**
135
+ * How long the call may run synchronously before it is parked as a
136
+ * background task. Defaults to {@link DEFAULT_FOREGROUND_MS}.
137
+ */
138
+ readonly foregroundMs?: number;
139
+ /**
140
+ * Absolute lifetime cap for a parked task. After this the task is
141
+ * soft-aborted and settled with an error. Defaults to
142
+ * {@link DEFAULT_MAX_LIFETIME_MS}. Must be `> foregroundMs` for parking
143
+ * to be possible.
144
+ */
145
+ readonly maxLifetimeMs?: number;
146
+ readonly memoryLimitBytes?: number;
147
+ /**
148
+ * Human-readable debug name for the parked task. When omitted, the name
149
+ * is inferred from the in-flight RPC(s) at park time, falling back to the
150
+ * first line of `code`.
151
+ */
152
+ readonly label?: string;
153
+ }
154
+ /** Legacy result shape for {@link runSandboxed}. */
155
+ interface SandboxRunResult {
156
+ readonly resultJson: string;
157
+ readonly logs: readonly SandboxLog[];
158
+ }
159
+ /** Terminal state of a parked task. */
160
+ type TaskOutcome = {
161
+ readonly status: "completed";
162
+ readonly result: unknown;
163
+ readonly logs: readonly SandboxLog[];
164
+ } | {
165
+ readonly status: "error";
166
+ readonly error: string;
167
+ readonly logs: readonly SandboxLog[];
168
+ } | {
169
+ readonly status: "cancelled";
170
+ readonly logs: readonly SandboxLog[];
171
+ };
172
+ /** Result of the foreground phase of {@link startSandbox}. */
173
+ type SandboxOutcome = {
174
+ readonly status: "completed";
175
+ readonly result: unknown;
176
+ readonly logs: readonly SandboxLog[];
177
+ } | {
178
+ readonly status: "error";
179
+ readonly error: string;
180
+ readonly logs: readonly SandboxLog[];
181
+ } | {
182
+ readonly status: "parked";
183
+ readonly debugName: string;
184
+ readonly task: ParkedTask;
185
+ };
186
+ /**
187
+ * A live sandbox execution that did not settle within the foreground budget
188
+ * and is now pumping in the background until its in-flight work resolves, it
189
+ * is cancelled, or it hits the max-lifetime guard.
190
+ */
191
+ interface ParkedTask {
192
+ /** Labels of the RPC(s) currently in flight (snapshot). */
193
+ inFlight(): string[];
194
+ /** Live view of logs captured so far. */
195
+ readonly logs: readonly SandboxLog[];
196
+ /** Resolves when the task reaches a terminal state. Never rejects. */
197
+ readonly done: Promise<TaskOutcome>;
198
+ /**
199
+ * Soft-abort the task and settle it. Resolves with the terminal outcome
200
+ * (`cancelled`, or `completed`/`error` if the guest settled cleanly
201
+ * within its grace window).
202
+ */
203
+ cancel(): Promise<TaskOutcome>;
204
+ }
205
+ /**
206
+ * Run-to-completion entry point preserved for callers that just want a result
207
+ * and treat the timeout as a hard wall (the original behaviour). Throws on
208
+ * guest error or deadline; never parks.
209
+ */
210
+ declare function runSandboxed(userCode: string, host: SandboxHostApi, lastResultVal: unknown, options?: SandboxOptions): Promise<SandboxRunResult>;
211
+ /**
212
+ * Parking-capable entry point. Runs `userCode` for up to `foregroundMs`; if it
213
+ * settles in that window the result is returned inline, otherwise the live VM
214
+ * is detached into a {@link ParkedTask} that keeps pumping in the background.
215
+ */
216
+ declare function startSandbox(userCode: string, host: SandboxHostApi, lastResultVal: unknown, options?: StartSandboxOptions): Promise<SandboxOutcome>;
217
+ //#endregion
218
+ //#region src/connectionDts.d.ts
219
+ /**
220
+ * Documentation that the MCP server exposes as a resource. The text is the
221
+ * single source of truth for what `runLinkRpcScript` sees inside the QuickJS
222
+ * sandbox — it lives as the real declaration file `connection.d.ts` and is
223
+ * embedded here verbatim. Keep `connection.d.ts` in sync with `sandbox.ts`
224
+ * and `explore.ts`.
225
+ */
226
+ declare const CONNECTION_DTS: string;
227
+ //#endregion
228
+ export { CONNECTION_DTS, ConnectionPool, ConnectionPoolOptions, DefaultTransport, ExploreArgs, ExploreBrowseArgs, ExploreBrowseResult, ExploreDeps, ExploreDocumentError, ExploreGrepArgs, ExploreGrepEntry, ExploreGrepMatch, ExploreGrepResult, ExploreInaccessible, ExploreInspectArgs, ExploreInspectResult, ExploreListing, ExploreResult, HubAccess, HubSenderProvider, IConnectionPool, LinkRpcMcpServer, LinkRpcMcpServerOptions, McpExploreCall, McpSessionInfo, McpToolCall, ParkedTask, PooledConnection, ProviderPool, SandboxHostApi, SandboxLog, SandboxOptions, SandboxOutcome, SandboxRunResult, StartSandboxOptions, TaskOutcome, TraceListener, explore, runSandboxed, startSandbox };
229
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ import { a as CONNECTION_DTS, i as startSandbox, n as explore, o as ProviderPool, r as runSandboxed, s as ConnectionPool, t as LinkRpcMcpServer } from "./chunks/server-C4ZM6bfK.js";
2
+ export { CONNECTION_DTS, ConnectionPool, LinkRpcMcpServer, ProviderPool, explore, runSandboxed, startSandbox };
package/dist/node.d.ts ADDED
@@ -0,0 +1,121 @@
1
+ import { n as LinkRpcMcpServerOptions } from "./chunks/server-Bquqk9Mk.js";
2
+ //#region src/socketHost.d.ts
3
+ /**
4
+ * Per-session wiring the host asks its owner to provide. Created once per MCP
5
+ * `initialize`, keyed by `sessionId`. The owner decides how that session reaches
6
+ * the hub (e.g. an in-process `defaultConnection`) and what to tear down when the
7
+ * session closes.
8
+ */
9
+ interface McpSession {
10
+ /** Options for this session's {@link LinkRpcMcpServer} (e.g. a `defaultConnection`). */
11
+ readonly serverOptions: LinkRpcMcpServerOptions;
12
+ /**
13
+ * Teardown for resources scoped to this session, run after the session's
14
+ * server is disposed. Note: this fires on MCP *session* close — do **not**
15
+ * wipe long-lived identity here if you want it to survive a client reload.
16
+ */
17
+ readonly dispose?: () => void;
18
+ }
19
+ interface McpSocketHostOptions {
20
+ /** Display name used only for log lines. */
21
+ readonly label?: string;
22
+ /**
23
+ * Mints per-session wiring. Called once per MCP `initialize`, before the
24
+ * client's session id is acknowledged. The `sessionId` is the id the host
25
+ * will report back to the client, so the owner can key identity on it (the
26
+ * consumer-provisioned mode).
27
+ */
28
+ readonly createSession: (ctx: {
29
+ readonly sessionId: string;
30
+ }) => Promise<McpSession>;
31
+ /** Optional structured logger; defaults to a no-op. */
32
+ readonly log?: (message: string) => void;
33
+ /**
34
+ * Where the host listens:
35
+ * - `"socket"` (default): a local Unix domain socket / named pipe. Most
36
+ * private (no TCP port at all), but some MCP clients — notably the Copilot
37
+ * harness — don't support `unix`/`pipe` transports yet.
38
+ * - `"http"`: a loopback (`127.0.0.1`) TCP port with a random bearer token.
39
+ * Use this until the socket transport is supported everywhere.
40
+ */
41
+ readonly transport?: "socket" | "http";
42
+ }
43
+ /**
44
+ * Describes where the host is listening, as transport-level facts — not as any
45
+ * particular client's URI encoding. A consumer (e.g. the VS Code extension)
46
+ * translates this into whatever its MCP client expects; for VS Code that means a
47
+ * `unix`/`pipe` URI with the socket path in `uri.path` and {@link requestPath}
48
+ * in `uri.fragment`, but that encoding is the consumer's concern, not ours.
49
+ */
50
+ type McpSocketEndpoint = {
51
+ /**
52
+ * `unixSocket` on posix, `namedPipe` on Windows. Discriminates how
53
+ * {@link McpSocketEndpoint.path} is interpreted by the OS / an HTTP
54
+ * client's `socketPath`.
55
+ */
56
+ readonly kind: "unixSocket" | "namedPipe";
57
+ /**
58
+ * Exact OS path the server listens on and that an HTTP client passes to
59
+ * `socketPath`: a filesystem path for `unixSocket`, a `\\.\pipe\…` path
60
+ * for `namedPipe`. Note a `namedPipe` path contains backslashes and is
61
+ * therefore not representable as a URL string — keep it as-is rather
62
+ * than round-tripping through `URL`/`Uri.parse`.
63
+ */
64
+ readonly path: string;
65
+ /** HTTP request path the host serves (defaults to `/mcp`). */
66
+ readonly requestPath: string;
67
+ /** Bearer token the client must send in the `Authorization` header. */
68
+ readonly authorizationToken: string;
69
+ } | {
70
+ /** A loopback TCP port, reachable over plain `http://`. */
71
+ readonly kind: "tcp";
72
+ /** Host the server is bound to (always `127.0.0.1`). */
73
+ readonly host: string;
74
+ /** TCP port the server listens on. */
75
+ readonly port: number;
76
+ /** HTTP request path the host serves (defaults to `/mcp`). */
77
+ readonly requestPath: string;
78
+ /** Bearer token the client must send in the `Authorization` header. */
79
+ readonly authorizationToken: string;
80
+ };
81
+ /**
82
+ * Hosts one or more {@link LinkRpcMcpServer} sessions over a local endpoint —
83
+ * by default a Unix domain socket on posix / a named pipe on Windows, or, when
84
+ * `transport: "http"` is set, a loopback (`127.0.0.1`) TCP port. The `http`
85
+ * transport exists for clients that don't yet support the `unix`/`pipe`
86
+ * transport (e.g. the Copilot harness).
87
+ *
88
+ * The host owns the generic plumbing: the socket/pipe or TCP port, bearer-token
89
+ * auth, and the per-session Streamable HTTP transport lifecycle. *How* each
90
+ * session reaches the hub — and what identity it signs as — is delegated
91
+ * entirely to {@link McpSocketHostOptions.createSession}, so the same host serves
92
+ * both the server-provisioned and consumer-provisioned identity modes.
93
+ */
94
+ declare class McpSocketHost {
95
+ static start(options: McpSocketHostOptions): Promise<McpSocketHost>;
96
+ private readonly _options;
97
+ private readonly _http;
98
+ private readonly _token;
99
+ private readonly _transport;
100
+ private readonly _kind;
101
+ /** Set for the socket/pipe transport; the OS path we listen on. */
102
+ private readonly _path;
103
+ /** Loopback host for the TCP (`http`) transport. */
104
+ private readonly _host;
105
+ /** Bound port for the TCP (`http`) transport; filled in after `_listen`. */
106
+ private _port;
107
+ private readonly _transports;
108
+ private readonly _active;
109
+ private _disposed;
110
+ private constructor();
111
+ /** The advertised endpoint (socket/pipe path or TCP host:port + bearer token). */
112
+ get endpoint(): McpSocketEndpoint;
113
+ dispose(): void;
114
+ private _log;
115
+ private _listen;
116
+ private _handleHttp;
117
+ private _openSession;
118
+ }
119
+ //#endregion
120
+ export { McpSession, McpSocketEndpoint, McpSocketHost, McpSocketHostOptions };
121
+ //# sourceMappingURL=node.d.ts.map
package/dist/node.js ADDED
@@ -0,0 +1,163 @@
1
+ import { t as LinkRpcMcpServer } from "./chunks/server-C4ZM6bfK.js";
2
+ import { randomBytes, randomUUID } from "crypto";
3
+ import * as fs from "fs";
4
+ import * as http from "http";
5
+ import * as os from "os";
6
+ import * as path from "path";
7
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
8
+ //#region src/socketHost.ts
9
+ const REQUEST_PATH = "/mcp";
10
+ /**
11
+ * Hosts one or more {@link LinkRpcMcpServer} sessions over a local endpoint —
12
+ * by default a Unix domain socket on posix / a named pipe on Windows, or, when
13
+ * `transport: "http"` is set, a loopback (`127.0.0.1`) TCP port. The `http`
14
+ * transport exists for clients that don't yet support the `unix`/`pipe`
15
+ * transport (e.g. the Copilot harness).
16
+ *
17
+ * The host owns the generic plumbing: the socket/pipe or TCP port, bearer-token
18
+ * auth, and the per-session Streamable HTTP transport lifecycle. *How* each
19
+ * session reaches the hub — and what identity it signs as — is delegated
20
+ * entirely to {@link McpSocketHostOptions.createSession}, so the same host serves
21
+ * both the server-provisioned and consumer-provisioned identity modes.
22
+ */
23
+ var McpSocketHost = class McpSocketHost {
24
+ static async start(options) {
25
+ const host = new McpSocketHost(options);
26
+ await host._listen();
27
+ return host;
28
+ }
29
+ _options;
30
+ _http;
31
+ _token = randomBytes(24).toString("base64url");
32
+ _transport;
33
+ _kind;
34
+ /** Set for the socket/pipe transport; the OS path we listen on. */
35
+ _path;
36
+ /** Loopback host for the TCP (`http`) transport. */
37
+ _host = "127.0.0.1";
38
+ /** Bound port for the TCP (`http`) transport; filled in after `_listen`. */
39
+ _port = 0;
40
+ _transports = /* @__PURE__ */ new Map();
41
+ _active = /* @__PURE__ */ new Map();
42
+ _disposed = false;
43
+ constructor(options) {
44
+ this._options = options;
45
+ this._http = http.createServer((req, res) => void this._handleHttp(req, res));
46
+ this._transport = options.transport ?? "socket";
47
+ if (this._transport === "http") {
48
+ this._kind = "tcp";
49
+ this._path = void 0;
50
+ } else {
51
+ this._kind = process.platform === "win32" ? "namedPipe" : "unixSocket";
52
+ this._path = process.platform === "win32" ? `\\\\.\\pipe\\linkrpc-mcp-${randomUUID()}` : path.join(os.tmpdir(), `linkrpc-mcp-${randomUUID()}.sock`);
53
+ }
54
+ }
55
+ /** The advertised endpoint (socket/pipe path or TCP host:port + bearer token). */
56
+ get endpoint() {
57
+ if (this._kind === "tcp") return {
58
+ kind: "tcp",
59
+ host: this._host,
60
+ port: this._port,
61
+ requestPath: REQUEST_PATH,
62
+ authorizationToken: this._token
63
+ };
64
+ return {
65
+ kind: this._kind,
66
+ path: this._path,
67
+ requestPath: REQUEST_PATH,
68
+ authorizationToken: this._token
69
+ };
70
+ }
71
+ dispose() {
72
+ if (this._disposed) return;
73
+ this._disposed = true;
74
+ for (const { server, session } of this._active.values()) {
75
+ server.dispose();
76
+ session.dispose?.();
77
+ }
78
+ this._active.clear();
79
+ this._transports.clear();
80
+ this._http.close();
81
+ if (this._kind === "unixSocket" && this._path) fs.rm(this._path, { force: true }, () => {});
82
+ }
83
+ _log(message) {
84
+ this._options.log?.(this._options.label ? `[${this._options.label}] ${message}` : message);
85
+ }
86
+ _listen() {
87
+ return new Promise((resolve, reject) => {
88
+ this._http.once("error", reject);
89
+ const onListening = () => {
90
+ this._http.removeListener("error", reject);
91
+ if (this._kind === "tcp") {
92
+ const addr = this._http.address();
93
+ if (!addr || typeof addr === "string") {
94
+ reject(/* @__PURE__ */ new Error("linkrpc-mcp: failed to bind loopback TCP port"));
95
+ return;
96
+ }
97
+ this._port = addr.port;
98
+ this._log(`listening on tcp ${this._host}:${this._port}`);
99
+ } else this._log(`listening on ${this._kind} ${this._path}`);
100
+ resolve();
101
+ };
102
+ if (this._kind === "tcp") this._http.listen(0, this._host, onListening);
103
+ else this._http.listen(this._path, onListening);
104
+ });
105
+ }
106
+ async _handleHttp(req, res) {
107
+ if (!req.url || !req.url.startsWith(REQUEST_PATH)) {
108
+ res.statusCode = 404;
109
+ res.end();
110
+ return;
111
+ }
112
+ if (req.headers.authorization !== `Bearer ${this._token}`) {
113
+ this._log(`${req.method} ${req.url} → 401 (bad/missing auth)`);
114
+ res.statusCode = 401;
115
+ res.end();
116
+ return;
117
+ }
118
+ const sessionHeader = req.headers["mcp-session-id"];
119
+ const sessionId = Array.isArray(sessionHeader) ? sessionHeader[0] : sessionHeader;
120
+ let transport = sessionId ? this._transports.get(sessionId) : void 0;
121
+ if (!transport) {
122
+ if (req.method !== "POST") {
123
+ this._log(`${req.method} without known session id → 400 (only POST initialize starts a session)`);
124
+ res.statusCode = 400;
125
+ res.end();
126
+ return;
127
+ }
128
+ transport = await this._openSession();
129
+ }
130
+ await transport.handleRequest(req, res);
131
+ }
132
+ async _openSession() {
133
+ const sessionId = randomUUID();
134
+ const session = await this._options.createSession({ sessionId });
135
+ const server = new LinkRpcMcpServer(session.serverOptions);
136
+ const transport = new StreamableHTTPServerTransport({
137
+ sessionIdGenerator: () => sessionId,
138
+ onsessioninitialized: (sid) => {
139
+ this._transports.set(sid, transport);
140
+ this._log(`session initialized sid=${sid}`);
141
+ }
142
+ });
143
+ this._active.set(transport, {
144
+ server,
145
+ session
146
+ });
147
+ transport.onclose = () => {
148
+ if (transport.sessionId) {
149
+ this._transports.delete(transport.sessionId);
150
+ this._log(`session closed sid=${transport.sessionId}`);
151
+ }
152
+ this._active.delete(transport);
153
+ server.dispose();
154
+ session.dispose?.();
155
+ };
156
+ await server.connect(transport);
157
+ return transport;
158
+ }
159
+ };
160
+ //#endregion
161
+ export { McpSocketHost };
162
+
163
+ //# sourceMappingURL=node.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"node.js","names":[],"sources":["../src/socketHost.ts"],"sourcesContent":["import * as fs from \"fs\";\nimport * as http from \"http\";\nimport * as os from \"os\";\nimport * as path from \"path\";\nimport { randomBytes, randomUUID } from \"crypto\";\nimport { StreamableHTTPServerTransport } from \"@modelcontextprotocol/sdk/server/streamableHttp.js\";\nimport { LinkRpcMcpServer, type LinkRpcMcpServerOptions } from \"./server\";\n\n/**\n * Per-session wiring the host asks its owner to provide. Created once per MCP\n * `initialize`, keyed by `sessionId`. The owner decides how that session reaches\n * the hub (e.g. an in-process `defaultConnection`) and what to tear down when the\n * session closes.\n */\nexport interface McpSession {\n /** Options for this session's {@link LinkRpcMcpServer} (e.g. a `defaultConnection`). */\n readonly serverOptions: LinkRpcMcpServerOptions;\n /**\n * Teardown for resources scoped to this session, run after the session's\n * server is disposed. Note: this fires on MCP *session* close — do **not**\n * wipe long-lived identity here if you want it to survive a client reload.\n */\n readonly dispose?: () => void;\n}\n\nexport interface McpSocketHostOptions {\n /** Display name used only for log lines. */\n readonly label?: string;\n /**\n * Mints per-session wiring. Called once per MCP `initialize`, before the\n * client's session id is acknowledged. The `sessionId` is the id the host\n * will report back to the client, so the owner can key identity on it (the\n * consumer-provisioned mode).\n */\n readonly createSession: (ctx: { readonly sessionId: string }) => Promise<McpSession>;\n /** Optional structured logger; defaults to a no-op. */\n readonly log?: (message: string) => void;\n /**\n * Where the host listens:\n * - `\"socket\"` (default): a local Unix domain socket / named pipe. Most\n * private (no TCP port at all), but some MCP clients — notably the Copilot\n * harness — don't support `unix`/`pipe` transports yet.\n * - `\"http\"`: a loopback (`127.0.0.1`) TCP port with a random bearer token.\n * Use this until the socket transport is supported everywhere.\n */\n readonly transport?: \"socket\" | \"http\";\n}\n\n/**\n * Describes where the host is listening, as transport-level facts — not as any\n * particular client's URI encoding. A consumer (e.g. the VS Code extension)\n * translates this into whatever its MCP client expects; for VS Code that means a\n * `unix`/`pipe` URI with the socket path in `uri.path` and {@link requestPath}\n * in `uri.fragment`, but that encoding is the consumer's concern, not ours.\n */\nexport type McpSocketEndpoint =\n | {\n /**\n * `unixSocket` on posix, `namedPipe` on Windows. Discriminates how\n * {@link McpSocketEndpoint.path} is interpreted by the OS / an HTTP\n * client's `socketPath`.\n */\n readonly kind: \"unixSocket\" | \"namedPipe\";\n /**\n * Exact OS path the server listens on and that an HTTP client passes to\n * `socketPath`: a filesystem path for `unixSocket`, a `\\\\.\\pipe\\…` path\n * for `namedPipe`. Note a `namedPipe` path contains backslashes and is\n * therefore not representable as a URL string — keep it as-is rather\n * than round-tripping through `URL`/`Uri.parse`.\n */\n readonly path: string;\n /** HTTP request path the host serves (defaults to `/mcp`). */\n readonly requestPath: string;\n /** Bearer token the client must send in the `Authorization` header. */\n readonly authorizationToken: string;\n }\n | {\n /** A loopback TCP port, reachable over plain `http://`. */\n readonly kind: \"tcp\";\n /** Host the server is bound to (always `127.0.0.1`). */\n readonly host: string;\n /** TCP port the server listens on. */\n readonly port: number;\n /** HTTP request path the host serves (defaults to `/mcp`). */\n readonly requestPath: string;\n /** Bearer token the client must send in the `Authorization` header. */\n readonly authorizationToken: string;\n };\n\nconst REQUEST_PATH = \"/mcp\";\n\ninterface ActiveSession {\n readonly server: LinkRpcMcpServer;\n readonly session: McpSession;\n}\n\n/**\n * Hosts one or more {@link LinkRpcMcpServer} sessions over a local endpoint —\n * by default a Unix domain socket on posix / a named pipe on Windows, or, when\n * `transport: \"http\"` is set, a loopback (`127.0.0.1`) TCP port. The `http`\n * transport exists for clients that don't yet support the `unix`/`pipe`\n * transport (e.g. the Copilot harness).\n *\n * The host owns the generic plumbing: the socket/pipe or TCP port, bearer-token\n * auth, and the per-session Streamable HTTP transport lifecycle. *How* each\n * session reaches the hub — and what identity it signs as — is delegated\n * entirely to {@link McpSocketHostOptions.createSession}, so the same host serves\n * both the server-provisioned and consumer-provisioned identity modes.\n */\nexport class McpSocketHost {\n public static async start(options: McpSocketHostOptions): Promise<McpSocketHost> {\n const host = new McpSocketHost(options);\n await host._listen();\n return host;\n }\n\n private readonly _options: McpSocketHostOptions;\n private readonly _http: http.Server;\n private readonly _token = randomBytes(24).toString(\"base64url\");\n private readonly _transport: \"socket\" | \"http\";\n private readonly _kind: McpSocketEndpoint[\"kind\"];\n /** Set for the socket/pipe transport; the OS path we listen on. */\n private readonly _path: string | undefined;\n /** Loopback host for the TCP (`http`) transport. */\n private readonly _host = \"127.0.0.1\";\n /** Bound port for the TCP (`http`) transport; filled in after `_listen`. */\n private _port = 0;\n private readonly _transports = new Map<string, StreamableHTTPServerTransport>();\n private readonly _active = new Map<StreamableHTTPServerTransport, ActiveSession>();\n private _disposed = false;\n\n private constructor(options: McpSocketHostOptions) {\n this._options = options;\n this._http = http.createServer((req, res) => void this._handleHttp(req, res));\n this._transport = options.transport ?? \"socket\";\n if (this._transport === \"http\") {\n this._kind = \"tcp\";\n this._path = undefined;\n } else {\n this._kind = process.platform === \"win32\" ? \"namedPipe\" : \"unixSocket\";\n this._path =\n process.platform === \"win32\"\n ? `\\\\\\\\.\\\\pipe\\\\linkrpc-mcp-${randomUUID()}`\n : path.join(os.tmpdir(), `linkrpc-mcp-${randomUUID()}.sock`);\n }\n }\n\n /** The advertised endpoint (socket/pipe path or TCP host:port + bearer token). */\n public get endpoint(): McpSocketEndpoint {\n if (this._kind === \"tcp\") {\n return {\n kind: \"tcp\",\n host: this._host,\n port: this._port,\n requestPath: REQUEST_PATH,\n authorizationToken: this._token,\n };\n }\n return {\n kind: this._kind,\n path: this._path!,\n requestPath: REQUEST_PATH,\n authorizationToken: this._token,\n };\n }\n\n public dispose(): void {\n if (this._disposed) {\n return;\n }\n this._disposed = true;\n for (const { server, session } of this._active.values()) {\n server.dispose();\n session.dispose?.();\n }\n this._active.clear();\n this._transports.clear();\n this._http.close();\n // Node unlinks the Unix socket on graceful close; remove it best-effort\n // in case the server never fully started. Named pipes / TCP need no cleanup.\n if (this._kind === \"unixSocket\" && this._path) {\n fs.rm(this._path, { force: true }, () => {});\n }\n }\n\n private _log(message: string): void {\n this._options.log?.(this._options.label ? `[${this._options.label}] ${message}` : message);\n }\n\n private _listen(): Promise<void> {\n return new Promise((resolve, reject) => {\n this._http.once(\"error\", reject);\n const onListening = () => {\n this._http.removeListener(\"error\", reject);\n if (this._kind === \"tcp\") {\n const addr = this._http.address();\n if (!addr || typeof addr === \"string\") {\n reject(new Error(\"linkrpc-mcp: failed to bind loopback TCP port\"));\n return;\n }\n this._port = addr.port;\n this._log(`listening on tcp ${this._host}:${this._port}`);\n } else {\n this._log(`listening on ${this._kind} ${this._path}`);\n }\n resolve();\n };\n if (this._kind === \"tcp\") {\n this._http.listen(0, this._host, onListening);\n } else {\n this._http.listen(this._path!, onListening);\n }\n });\n }\n\n private async _handleHttp(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {\n if (!req.url || !req.url.startsWith(REQUEST_PATH)) {\n res.statusCode = 404;\n res.end();\n return;\n }\n if (req.headers.authorization !== `Bearer ${this._token}`) {\n this._log(`${req.method} ${req.url} → 401 (bad/missing auth)`);\n res.statusCode = 401;\n res.end();\n return;\n }\n\n const sessionHeader = req.headers[\"mcp-session-id\"];\n const sessionId = Array.isArray(sessionHeader) ? sessionHeader[0] : sessionHeader;\n let transport = sessionId ? this._transports.get(sessionId) : undefined;\n\n if (!transport) {\n if (req.method !== \"POST\") {\n this._log(`${req.method} without known session id → 400 (only POST initialize starts a session)`);\n res.statusCode = 400;\n res.end();\n return;\n }\n transport = await this._openSession();\n }\n\n await transport.handleRequest(req, res);\n }\n\n private async _openSession(): Promise<StreamableHTTPServerTransport> {\n // The session id is fixed up front so `createSession` can key identity on\n // it (the consumer-provisioned mode) and the transport reports it back.\n const sessionId = randomUUID();\n const session = await this._options.createSession({ sessionId });\n const server = new LinkRpcMcpServer(session.serverOptions);\n\n const transport = new StreamableHTTPServerTransport({\n sessionIdGenerator: () => sessionId,\n onsessioninitialized: (sid) => {\n this._transports.set(sid, transport);\n this._log(`session initialized sid=${sid}`);\n },\n });\n this._active.set(transport, { server, session });\n\n transport.onclose = () => {\n if (transport.sessionId) {\n this._transports.delete(transport.sessionId);\n this._log(`session closed sid=${transport.sessionId}`);\n }\n this._active.delete(transport);\n // Dispose the server (tears down its connection pool / in-process leg)\n // before the session's own teardown.\n server.dispose();\n session.dispose?.();\n };\n\n await server.connect(transport);\n return transport;\n }\n}\n"],"mappings":"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"}