@procedures/mcp-server 0.0.0-stage → 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.
Files changed (65) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE +21 -0
  3. package/README.md +366 -2
  4. package/dist/.tsbuildinfo +1 -0
  5. package/dist/index.d.ts +22 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +22 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/module.d.ts +11 -0
  10. package/dist/module.d.ts.map +1 -0
  11. package/dist/module.js +16 -0
  12. package/dist/module.js.map +1 -0
  13. package/dist/procedures/mcp/index.d.ts +6 -0
  14. package/dist/procedures/mcp/index.d.ts.map +1 -0
  15. package/dist/procedures/mcp/index.js +6 -0
  16. package/dist/procedures/mcp/index.js.map +1 -0
  17. package/dist/procedures/mcp/list-tools.d.ts +8 -0
  18. package/dist/procedures/mcp/list-tools.d.ts.map +1 -0
  19. package/dist/procedures/mcp/list-tools.js +60 -0
  20. package/dist/procedures/mcp/list-tools.js.map +1 -0
  21. package/dist/procedures/mcp/serve.d.ts +38 -0
  22. package/dist/procedures/mcp/serve.d.ts.map +1 -0
  23. package/dist/procedures/mcp/serve.js +123 -0
  24. package/dist/procedures/mcp/serve.js.map +1 -0
  25. package/dist/register.d.ts +11 -0
  26. package/dist/register.d.ts.map +1 -0
  27. package/dist/register.js +21 -0
  28. package/dist/register.js.map +1 -0
  29. package/dist/surface.d.ts +36 -0
  30. package/dist/surface.d.ts.map +1 -0
  31. package/dist/surface.js +74 -0
  32. package/dist/surface.js.map +1 -0
  33. package/dist/transport/index.d.ts +7 -0
  34. package/dist/transport/index.d.ts.map +1 -0
  35. package/dist/transport/index.js +7 -0
  36. package/dist/transport/index.js.map +1 -0
  37. package/dist/transport/mcp-transport.d.ts +94 -0
  38. package/dist/transport/mcp-transport.d.ts.map +1 -0
  39. package/dist/transport/mcp-transport.js +368 -0
  40. package/dist/transport/mcp-transport.js.map +1 -0
  41. package/dist/transport/sse.d.ts +20 -0
  42. package/dist/transport/sse.d.ts.map +1 -0
  43. package/dist/transport/sse.js +23 -0
  44. package/dist/transport/sse.js.map +1 -0
  45. package/dist/transport/stdio.d.ts +16 -0
  46. package/dist/transport/stdio.d.ts.map +1 -0
  47. package/dist/transport/stdio.js +18 -0
  48. package/dist/transport/stdio.js.map +1 -0
  49. package/dist/types.d.ts +55 -0
  50. package/dist/types.d.ts.map +1 -0
  51. package/dist/types.js +7 -0
  52. package/dist/types.js.map +1 -0
  53. package/package.json +81 -3
  54. package/src/index.ts +48 -0
  55. package/src/module.ts +18 -0
  56. package/src/procedures/mcp/index.ts +6 -0
  57. package/src/procedures/mcp/list-tools.ts +84 -0
  58. package/src/procedures/mcp/serve.ts +170 -0
  59. package/src/register.ts +24 -0
  60. package/src/surface.ts +85 -0
  61. package/src/transport/index.ts +7 -0
  62. package/src/transport/mcp-transport.ts +418 -0
  63. package/src/transport/sse.ts +29 -0
  64. package/src/transport/stdio.ts +19 -0
  65. package/src/types.ts +64 -0
@@ -0,0 +1,418 @@
1
+ /**
2
+ * MCP Server Transport
3
+ *
4
+ * Implements ServerTransport interface for MCP protocol.
5
+ * Maps PROCEDURE_REGISTRY to MCP tools.
6
+ */
7
+
8
+ import { Server as McpServer } from "@modelcontextprotocol/sdk/server/index.js";
9
+ import {
10
+ CallToolRequestSchema,
11
+ ListToolsRequestSchema,
12
+ type ServerNotification,
13
+ } from "@modelcontextprotocol/sdk/types.js";
14
+ import type { Transport as SdkTransport } from "@modelcontextprotocol/sdk/shared/transport.js";
15
+ import type { ServerTransport, Server, ProcedureRegistry } from "@procedures/client";
16
+ import { PROCEDURE_REGISTRY, pathToMethod } from "@procedures/client";
17
+ import {
18
+ proceduresToMcpTools,
19
+ toToolDefinition,
20
+ decodePath,
21
+ type McpTool,
22
+ } from "@procedures/mcp";
23
+ import type { McpServerTransportOptions, McpServerTransportState } from "../types.js";
24
+ import { createStdioTransport } from "./stdio.js";
25
+ import { createSseTransport } from "./sse.js";
26
+
27
+ /**
28
+ * Generate a unique request ID.
29
+ */
30
+ /** The most text blocks of a stream result: the last items of a long stream. */
31
+ const MAX_STREAM_BLOCKS = 100;
32
+
33
+ function toText(value: unknown): string {
34
+ return typeof value === "string" ? value : (JSON.stringify(value, null, 2) ?? String(value));
35
+ }
36
+
37
+ /**
38
+ * The structured content of a result: the result as JSON, when it is an object. JSON drops
39
+ * what JSON cannot show (a function, `undefined`) and turns a class instance into its JSON form
40
+ * (an ObjectId into its hex text), as the text block does.
41
+ */
42
+ function structuredContentOf(value: unknown): Record<string, unknown> | undefined {
43
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined;
44
+ try {
45
+ const json = JSON.parse(JSON.stringify(value)) as unknown;
46
+ return json !== null && typeof json === "object" && !Array.isArray(json) ? (json as Record<string, unknown>) : undefined;
47
+ } catch {
48
+ return undefined;
49
+ }
50
+ }
51
+
52
+ function generateRequestId(): string {
53
+ return `mcp-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
54
+ }
55
+
56
+ /**
57
+ * MCP Server Transport
58
+ *
59
+ * Implements the ServerTransport interface for MCP (Model Context Protocol).
60
+ * Maps procedures from PROCEDURE_REGISTRY to MCP tools, handling:
61
+ * - ListToolsRequest: Returns all registered procedures as tools
62
+ * - CallToolRequest: Executes the corresponding procedure
63
+ *
64
+ * @example
65
+ * ```typescript
66
+ * const server = new ProcedureServer({ autoRegister: true });
67
+ * const mcpTransport = new McpServerTransport(server, {
68
+ * transport: "stdio",
69
+ * serverInfo: { name: "my-server", version: "1.0.0" }
70
+ * });
71
+ * server.addTransport(mcpTransport);
72
+ * await server.start();
73
+ * ```
74
+ */
75
+ export class McpServerTransport implements ServerTransport {
76
+ readonly name = "mcp";
77
+
78
+ private mcpServer: McpServer;
79
+ private server: Server;
80
+ private registry: ProcedureRegistry;
81
+ private options: Required<Pick<McpServerTransportOptions, "transport" | "serverInfo">> &
82
+ McpServerTransportOptions;
83
+ private running = false;
84
+ private tools: Map<string, McpTool> = new Map();
85
+ private registerListener: ((procedure: unknown) => void) | undefined;
86
+ private unregisterListener: ((procedure: unknown) => void) | undefined;
87
+ /** A pending list_changed notification: a burst of registrations sends one */
88
+ private listChangedTimer: ReturnType<typeof setTimeout> | undefined;
89
+
90
+ constructor(server: Server, options: McpServerTransportOptions = {}) {
91
+ this.server = server;
92
+ this.options = {
93
+ transport: options.transport ?? "stdio",
94
+ serverInfo: options.serverInfo ?? {
95
+ name: "procedure-server",
96
+ version: "1.0.0",
97
+ },
98
+ ...options,
99
+ };
100
+ // The registry of the server, when it has one (a ProcedureServer with an explicit registry):
101
+ // before, the tools came from the global registry, also when the server served another one
102
+ this.registry =
103
+ options.registry ??
104
+ (server as { getRegistry?: () => ProcedureRegistry }).getRegistry?.() ??
105
+ PROCEDURE_REGISTRY;
106
+
107
+ // Create MCP server with capabilities. The tool list follows the registry: when it changes,
108
+ // the server sends notifications/tools/list_changed (deep dive roadmap 0.4)
109
+ this.mcpServer = new McpServer(this.options.serverInfo, {
110
+ capabilities: {
111
+ tools: { listChanged: true },
112
+ },
113
+ });
114
+
115
+ // Setup request handlers
116
+ this.setupHandlers();
117
+
118
+ // The registry listeners are attached in start() and removed in stop(): attaching them here
119
+ // leaked them for a transport that never started or failed to start (BUGS-2026-07 L28)
120
+ }
121
+
122
+ /**
123
+ * Setup MCP request handlers.
124
+ */
125
+ private setupHandlers(): void {
126
+ // ListToolsRequestSchema -> List all procedures as tools
127
+ this.mcpServer.setRequestHandler(ListToolsRequestSchema, async () => {
128
+ this.log("ListToolsRequest received");
129
+
130
+ const tools = Array.from(this.tools.values()).map(toToolDefinition);
131
+
132
+ this.log(`Returning ${tools.length} tools`);
133
+
134
+ return { tools };
135
+ });
136
+
137
+ // CallToolRequestSchema -> Execute procedure
138
+ this.mcpServer.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
139
+ const { name, arguments: args } = request.params;
140
+
141
+ this.log(`CallToolRequest: ${name}`);
142
+
143
+ // Find tool
144
+ const tool = this.tools.get(name);
145
+ if (!tool) {
146
+ this.log(`Tool not found: ${name}`);
147
+ return {
148
+ content: [{ type: "text" as const, text: `Tool not found: ${name}` }],
149
+ isError: true,
150
+ };
151
+ }
152
+
153
+ // Decode path from tool name. The method is the core mapping of every host (deep dive
154
+ // architecture review 3.3)
155
+ const path = decodePath(name);
156
+
157
+ // Build ServerRequest
158
+ const serverRequest = {
159
+ id: generateRequestId(),
160
+ method: pathToMethod(path),
161
+ payload: args ?? {},
162
+ metadata: {
163
+ transport: "mcp" as const,
164
+ mcpRequestId: generateRequestId(),
165
+ },
166
+ // A cancelled tool call (notifications/cancelled) aborts the procedure
167
+ signal: extra.signal,
168
+ };
169
+
170
+ try {
171
+ // Process through universal server
172
+ const response = await this.server.handle(serverRequest);
173
+
174
+ this.log(`Tool ${name} completed: ${response.status.type}`);
175
+
176
+ // Convert to MCP result
177
+ if (response.status.type === "success" && response.stream) {
178
+ return await this.streamResult(response.stream, extra);
179
+ }
180
+ if (response.status.type === "success") {
181
+ const structured = tool.outputSchema ? structuredContentOf(response.payload) : undefined;
182
+ return {
183
+ content: [{ type: "text" as const, text: toText(response.payload) }],
184
+ // MCP structured output: the result as an object, for a tool with an output schema.
185
+ // The text block stays, for clients that do not read structured content.
186
+ ...(structured ? { structuredContent: structured } : {}),
187
+ };
188
+ } else {
189
+ return {
190
+ content: [
191
+ {
192
+ type: "text" as const,
193
+ text: response.status.message ?? "Unknown error",
194
+ },
195
+ ],
196
+ isError: true,
197
+ };
198
+ }
199
+ } catch (error) {
200
+ const message = error instanceof Error ? error.message : String(error);
201
+ this.log(`Tool ${name} error: ${message}`);
202
+
203
+ return {
204
+ content: [{ type: "text" as const, text: message }],
205
+ isError: true,
206
+ };
207
+ }
208
+ });
209
+ }
210
+
211
+ /**
212
+ * Setup registry event listeners for dynamic tool updates.
213
+ */
214
+ /**
215
+ * The result of a streaming procedure. An MCP tool result is one message, so the transport
216
+ * reads the whole stream. Each item becomes a text block (the last MAX_STREAM_BLOCKS items),
217
+ * and a progress notification when the caller sent a progress token, so a client can show
218
+ * the items while the stream runs.
219
+ */
220
+ private async streamResult(
221
+ stream: AsyncIterable<unknown>,
222
+ extra: {
223
+ _meta?: { progressToken?: string | number | undefined } | undefined;
224
+ sendNotification: (notification: ServerNotification) => Promise<void>;
225
+ }
226
+ ): Promise<{ content: Array<{ type: "text"; text: string }>; isError?: boolean }> {
227
+ const progressToken = extra._meta?.progressToken;
228
+ const blocks: Array<{ type: "text"; text: string }> = [];
229
+ let count = 0;
230
+ try {
231
+ for await (const item of stream) {
232
+ count++;
233
+ const text = toText(item);
234
+ blocks.push({ type: "text", text });
235
+ if (blocks.length > MAX_STREAM_BLOCKS) blocks.shift();
236
+ if (progressToken !== undefined) {
237
+ await extra
238
+ .sendNotification({
239
+ method: "notifications/progress",
240
+ params: { progressToken, progress: count, message: text.slice(0, 1000) },
241
+ })
242
+ .catch(() => undefined);
243
+ }
244
+ }
245
+ } catch (error) {
246
+ blocks.push({ type: "text", text: error instanceof Error ? error.message : String(error) });
247
+ return { content: blocks, isError: true };
248
+ }
249
+ if (count > MAX_STREAM_BLOCKS) {
250
+ blocks.unshift({ type: "text", text: `(${count} items: the first ${count - MAX_STREAM_BLOCKS} are not shown)` });
251
+ }
252
+ if (count === 0) blocks.push({ type: "text", text: "(no items)" });
253
+ return { content: blocks };
254
+ }
255
+
256
+ private setupRegistryListeners(): void {
257
+ // Create listener functions that we can later remove
258
+ this.registerListener = () => {
259
+ this.log("Procedure registered, refreshing tools");
260
+ this.refreshTools();
261
+ };
262
+
263
+ this.unregisterListener = () => {
264
+ this.log("Procedure unregistered, refreshing tools");
265
+ this.refreshTools();
266
+ };
267
+
268
+ // Refresh tools when procedures are registered/unregistered
269
+ this.registry.on("register", this.registerListener);
270
+ this.registry.on("unregister", this.unregisterListener);
271
+ }
272
+
273
+ /**
274
+ * Remove the registry event listeners, if attached.
275
+ */
276
+ private removeRegistryListeners(): void {
277
+ if (this.registerListener) {
278
+ this.registry.off("register", this.registerListener);
279
+ this.registerListener = undefined;
280
+ }
281
+ if (this.unregisterListener) {
282
+ this.registry.off("unregister", this.unregisterListener);
283
+ this.unregisterListener = undefined;
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Refresh the tool list from the registry.
289
+ */
290
+ private refreshTools(): void {
291
+ const procedures = this.registry.getAll();
292
+ const mcpTools = proceduresToMcpTools(procedures, this.options.toolFilter);
293
+ const before = this.toolSignature();
294
+
295
+ this.tools.clear();
296
+ for (const tool of mcpTools) {
297
+ this.tools.set(tool.name, tool);
298
+ }
299
+
300
+ this.log(`Refreshed tools: ${this.tools.size} available`);
301
+ if (this.running && this.toolSignature() !== before) this.scheduleListChanged();
302
+ }
303
+
304
+ /** The tool names and descriptions: a change of this text is a change of the tool list. */
305
+ private toolSignature(): string {
306
+ const entries = Array.from(this.tools.values(), (tool) => [tool.name, tool.description ?? ""]);
307
+ return JSON.stringify(entries.sort((a, b) => (a[0]! < b[0]! ? -1 : a[0]! > b[0]! ? 1 : 0)));
308
+ }
309
+
310
+ /** Send one notifications/tools/list_changed for a burst of registry events. */
311
+ private scheduleListChanged(): void {
312
+ if (this.listChangedTimer) return;
313
+ this.listChangedTimer = setTimeout(() => {
314
+ this.listChangedTimer = undefined;
315
+ if (!this.running) return;
316
+ this.mcpServer.sendToolListChanged().catch((error: unknown) => {
317
+ this.log(`list_changed failed: ${error instanceof Error ? error.message : String(error)}`);
318
+ });
319
+ }, 0);
320
+ this.listChangedTimer.unref?.();
321
+ }
322
+
323
+ /**
324
+ * Log a debug message if debug mode is enabled.
325
+ */
326
+ private log(message: string): void {
327
+ if (this.options.debug) {
328
+ // MUST be stderr: the stdio MCP transport uses stdout for JSON-RPC framing, so any
329
+ // stdout write here corrupts the protocol stream. See documentation/BUGS-2026-07.md (M39/Bug4).
330
+ console.error(`[${this.name}] ${message}`);
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Start the MCP transport.
336
+ */
337
+ async start(): Promise<void> {
338
+ if (this.running) {
339
+ this.log("Already running");
340
+ return;
341
+ }
342
+
343
+ // Refresh tools before starting, and keep them current while running
344
+ this.refreshTools();
345
+ this.setupRegistryListeners();
346
+
347
+ try {
348
+ // Create appropriate transport
349
+ let transport: SdkTransport;
350
+ if (this.options.sdkTransport) {
351
+ transport = this.options.sdkTransport;
352
+ this.log("Using the given transport");
353
+ } else if (this.options.transport === "stdio") {
354
+ transport = createStdioTransport();
355
+ this.log("Using stdio transport");
356
+ } else if (this.options.transport === "sse") {
357
+ transport = createSseTransport(this.options.sseOptions);
358
+ this.log(`Using SSE transport on ${this.options.sseOptions?.path ?? "/mcp/sse"}`);
359
+ } else {
360
+ throw new Error(`Unknown MCP transport: ${this.options.transport}`);
361
+ }
362
+
363
+ // Connect MCP server to transport
364
+ await this.mcpServer.connect(transport);
365
+ } catch (error) {
366
+ this.removeRegistryListeners();
367
+ throw error;
368
+ }
369
+
370
+ this.running = true;
371
+ this.log(`MCP server started (${this.options.transport}), ${this.tools.size} tools available`);
372
+ }
373
+
374
+ /**
375
+ * Stop the MCP transport.
376
+ */
377
+ async stop(): Promise<void> {
378
+ if (!this.running) {
379
+ return;
380
+ }
381
+
382
+ // Cleanup registry listeners
383
+ this.removeRegistryListeners();
384
+ if (this.listChangedTimer) {
385
+ clearTimeout(this.listChangedTimer);
386
+ this.listChangedTimer = undefined;
387
+ }
388
+
389
+ await this.mcpServer.close();
390
+ this.running = false;
391
+ this.log("MCP server stopped");
392
+ }
393
+
394
+ /**
395
+ * Check if the transport is running.
396
+ */
397
+ isRunning(): boolean {
398
+ return this.running;
399
+ }
400
+
401
+ /**
402
+ * Get current transport state.
403
+ */
404
+ getState(): McpServerTransportState {
405
+ return {
406
+ running: this.running,
407
+ toolCount: this.tools.size,
408
+ transport: this.options.transport,
409
+ };
410
+ }
411
+
412
+ /**
413
+ * Get the list of available tools.
414
+ */
415
+ getTools(): McpTool[] {
416
+ return Array.from(this.tools.values());
417
+ }
418
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * SSE Transport Wrapper
3
+ *
4
+ * Creates an MCP SSE (Server-Sent Events) transport for HTTP streaming.
5
+ * NOTE: SSE transport requires HTTP server integration - use with Express or similar.
6
+ */
7
+
8
+ import type { SseTransportOptions } from "../types.js";
9
+
10
+ /**
11
+ * SSE transport is not yet implemented as a standalone transport.
12
+ *
13
+ * SSE requires HTTP server integration with:
14
+ * - GET endpoint for SSE connection
15
+ * - POST endpoint for messages
16
+ *
17
+ * For now, use stdio transport or implement SSE with your HTTP framework directly.
18
+ *
19
+ * @throws Error - SSE transport requires HTTP server integration
20
+ */
21
+ export function createSseTransport(
22
+ _options?: SseTransportOptions
23
+ ): never {
24
+ throw new Error(
25
+ "SSE transport requires HTTP server integration. " +
26
+ "Use stdio transport for standalone MCP server, or integrate SSE " +
27
+ "with your HTTP framework (Express, etc.) using SSEServerTransport directly."
28
+ );
29
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Stdio Transport Wrapper
3
+ *
4
+ * Creates an MCP stdio transport for CLI usage.
5
+ */
6
+
7
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
+
9
+ /**
10
+ * Create a stdio transport for MCP server.
11
+ *
12
+ * Stdio transport uses standard input/output for communication,
13
+ * suitable for CLI tools and desktop app integrations.
14
+ *
15
+ * @returns MCP stdio transport instance
16
+ */
17
+ export function createStdioTransport(): StdioServerTransport {
18
+ return new StdioServerTransport();
19
+ }
package/src/types.ts ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * MCP Transport Types
3
+ *
4
+ * Configuration options for the MCP server transport.
5
+ */
6
+
7
+ import type { Transport as SdkTransport } from "@modelcontextprotocol/sdk/shared/transport.js";
8
+ import type { ProcedureRegistry } from "@procedures/client";
9
+ import type { McpServerInfo, McpToolFilter } from "@procedures/mcp";
10
+
11
+ /**
12
+ * SSE transport configuration options.
13
+ */
14
+ export interface SseTransportOptions {
15
+ /** Port to listen on (default: 3002) */
16
+ port?: number;
17
+ /** Host to bind to (default: "0.0.0.0") */
18
+ host?: string;
19
+ /** Path for SSE endpoint (default: "/mcp/sse") */
20
+ path?: string;
21
+ /** Enable CORS (default: true) */
22
+ cors?: boolean;
23
+ }
24
+
25
+ /**
26
+ * MCP Server Transport configuration options.
27
+ */
28
+ export interface McpServerTransportOptions {
29
+ /** Transport type: stdio or sse (default: "stdio") */
30
+ transport?: "stdio" | "sse";
31
+
32
+ /** Procedure registry to use (defaults to PROCEDURE_REGISTRY) */
33
+ registry?: ProcedureRegistry;
34
+
35
+ /** Server info for MCP protocol */
36
+ serverInfo?: McpServerInfo;
37
+
38
+ /** Tool filtering options */
39
+ toolFilter?: McpToolFilter;
40
+
41
+ /** SSE-specific options (when transport is "sse") */
42
+ sseOptions?: SseTransportOptions;
43
+
44
+ /**
45
+ * An MCP SDK transport to connect, in place of `transport`. Use it to embed the server in
46
+ * another process, or in a test (the SDK's `InMemoryTransport`).
47
+ */
48
+ sdkTransport?: SdkTransport;
49
+
50
+ /** Enable debug logging */
51
+ debug?: boolean;
52
+ }
53
+
54
+ /**
55
+ * MCP Server Transport state.
56
+ */
57
+ export interface McpServerTransportState {
58
+ /** Whether the transport is running */
59
+ running: boolean;
60
+ /** Number of registered tools */
61
+ toolCount: number;
62
+ /** Transport type in use */
63
+ transport: "stdio" | "sse";
64
+ }