mcp-orbit 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 (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +247 -0
  3. package/dist/__tests__/helpers/test-server.d.ts +2 -0
  4. package/dist/__tests__/helpers/test-server.d.ts.map +1 -0
  5. package/dist/__tests__/helpers/test-server.js +27 -0
  6. package/dist/__tests__/helpers/test-server.js.map +1 -0
  7. package/dist/cli.d.ts +23 -0
  8. package/dist/cli.d.ts.map +1 -0
  9. package/dist/cli.js +56 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/clients/mcp-http.d.ts +36 -0
  12. package/dist/clients/mcp-http.d.ts.map +1 -0
  13. package/dist/clients/mcp-http.js +148 -0
  14. package/dist/clients/mcp-http.js.map +1 -0
  15. package/dist/clients/mcp-stdio.d.ts +38 -0
  16. package/dist/clients/mcp-stdio.d.ts.map +1 -0
  17. package/dist/clients/mcp-stdio.js +164 -0
  18. package/dist/clients/mcp-stdio.js.map +1 -0
  19. package/dist/clients/types.d.ts +104 -0
  20. package/dist/clients/types.d.ts.map +1 -0
  21. package/dist/clients/types.js +8 -0
  22. package/dist/clients/types.js.map +1 -0
  23. package/dist/core/prompt-registry.d.ts +56 -0
  24. package/dist/core/prompt-registry.d.ts.map +1 -0
  25. package/dist/core/prompt-registry.js +100 -0
  26. package/dist/core/prompt-registry.js.map +1 -0
  27. package/dist/core/resource-registry.d.ts +79 -0
  28. package/dist/core/resource-registry.d.ts.map +1 -0
  29. package/dist/core/resource-registry.js +135 -0
  30. package/dist/core/resource-registry.js.map +1 -0
  31. package/dist/core/resource-uri-templates.d.ts +64 -0
  32. package/dist/core/resource-uri-templates.d.ts.map +1 -0
  33. package/dist/core/resource-uri-templates.js +168 -0
  34. package/dist/core/resource-uri-templates.js.map +1 -0
  35. package/dist/core/server-http.d.ts +15 -0
  36. package/dist/core/server-http.d.ts.map +1 -0
  37. package/dist/core/server-http.js +302 -0
  38. package/dist/core/server-http.js.map +1 -0
  39. package/dist/core/server-stdio.d.ts +8 -0
  40. package/dist/core/server-stdio.d.ts.map +1 -0
  41. package/dist/core/server-stdio.js +15 -0
  42. package/dist/core/server-stdio.js.map +1 -0
  43. package/dist/core/server.d.ts +29 -0
  44. package/dist/core/server.d.ts.map +1 -0
  45. package/dist/core/server.js +265 -0
  46. package/dist/core/server.js.map +1 -0
  47. package/dist/core/types.d.ts +265 -0
  48. package/dist/core/types.d.ts.map +1 -0
  49. package/dist/core/types.js +9 -0
  50. package/dist/core/types.js.map +1 -0
  51. package/dist/index.d.ts +28 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +26 -0
  54. package/dist/index.js.map +1 -0
  55. package/dist/utils/dynamic-resource-manager.d.ts +115 -0
  56. package/dist/utils/dynamic-resource-manager.d.ts.map +1 -0
  57. package/dist/utils/dynamic-resource-manager.js +460 -0
  58. package/dist/utils/dynamic-resource-manager.js.map +1 -0
  59. package/dist/utils/http-client.d.ts +29 -0
  60. package/dist/utils/http-client.d.ts.map +1 -0
  61. package/dist/utils/http-client.js +59 -0
  62. package/dist/utils/http-client.js.map +1 -0
  63. package/dist/utils/logger.d.ts +25 -0
  64. package/dist/utils/logger.d.ts.map +1 -0
  65. package/dist/utils/logger.js +105 -0
  66. package/dist/utils/logger.js.map +1 -0
  67. package/dist/utils/zod-to-mcp-schema.d.ts +42 -0
  68. package/dist/utils/zod-to-mcp-schema.d.ts.map +1 -0
  69. package/dist/utils/zod-to-mcp-schema.js +87 -0
  70. package/dist/utils/zod-to-mcp-schema.js.map +1 -0
  71. package/package.json +57 -0
  72. package/src/__tests__/helpers/test-server.ts +31 -0
  73. package/src/__tests__/plugin-system.basic.test.ts +137 -0
  74. package/src/__tests__/server.basic.test.ts +37 -0
  75. package/src/__tests__/stdio-roundtrip.basic.test.ts +67 -0
  76. package/src/__tests__/tool-registry.basic.test.ts +114 -0
  77. package/src/__tests__/zod-schema.basic.test.ts +105 -0
  78. package/src/cli.ts +58 -0
  79. package/src/clients/mcp-http.ts +192 -0
  80. package/src/clients/mcp-stdio.ts +209 -0
  81. package/src/clients/types.ts +136 -0
  82. package/src/core/prompt-registry.ts +114 -0
  83. package/src/core/resource-registry.ts +166 -0
  84. package/src/core/resource-uri-templates.ts +216 -0
  85. package/src/core/server-http.ts +407 -0
  86. package/src/core/server-stdio.ts +20 -0
  87. package/src/core/server.ts +320 -0
  88. package/src/core/types.ts +312 -0
  89. package/src/index.ts +92 -0
  90. package/src/utils/dynamic-resource-manager.ts +581 -0
  91. package/src/utils/http-client.ts +86 -0
  92. package/src/utils/logger.ts +138 -0
  93. package/src/utils/zod-to-mcp-schema.ts +127 -0
@@ -0,0 +1,114 @@
1
+ import {describe, it, expect, beforeEach} from "vitest";
2
+ import {z} from "zod";
3
+ import {registerTool, getTool, getToolCount, getToolDefinitions, executeTool} from "../core/server.js";
4
+ import type {Tool} from "../core/types.js";
5
+
6
+ function createEchoTool(name = "test_echo"): Tool {
7
+ const inputSchema = z.object({
8
+ message: z.string().describe("Message to echo"),
9
+ });
10
+
11
+ return {
12
+ definition: {
13
+ name,
14
+ description: "Echo a message back",
15
+ inputSchema: {
16
+ type: "object",
17
+ properties: {message: {type: "string"}},
18
+ required: ["message"],
19
+ },
20
+ },
21
+ runtimeInputSchema: inputSchema,
22
+ async execute(args: unknown) {
23
+ const {message} = inputSchema.parse(args);
24
+ return {
25
+ content: [{type: "text", text: message}],
26
+ };
27
+ },
28
+ };
29
+ }
30
+
31
+ describe("Tool Registry", () => {
32
+ // Note: tool registry is global (module-level Map), so tools persist across tests.
33
+ // We use unique names per test to avoid interference.
34
+
35
+ it("registers and retrieves a tool", () => {
36
+ const tool = createEchoTool("reg_retrieve");
37
+ registerTool(tool);
38
+
39
+ const retrieved = getTool("reg_retrieve");
40
+ expect(retrieved).toBeDefined();
41
+ expect(retrieved!.definition.name).toBe("reg_retrieve");
42
+ });
43
+
44
+ it("getTool returns undefined for unknown tools", () => {
45
+ expect(getTool("nonexistent_tool_xyz")).toBeUndefined();
46
+ });
47
+
48
+ it("getToolCount reflects registered tools", () => {
49
+ const before = getToolCount();
50
+ registerTool(createEchoTool(`count_test_${Date.now()}`));
51
+ expect(getToolCount()).toBe(before + 1);
52
+ });
53
+
54
+ it("getToolDefinitions returns all definitions", () => {
55
+ registerTool(createEchoTool("def_test_a"));
56
+ registerTool(createEchoTool("def_test_b"));
57
+
58
+ const defs = getToolDefinitions();
59
+ const names = defs.map((d) => d.name);
60
+ expect(names).toContain("def_test_a");
61
+ expect(names).toContain("def_test_b");
62
+ });
63
+
64
+ it("overwrites existing tool on re-register", () => {
65
+ const tool1 = createEchoTool("overwrite_test");
66
+ tool1.definition.description = "Version 1";
67
+ registerTool(tool1);
68
+
69
+ const tool2 = createEchoTool("overwrite_test");
70
+ tool2.definition.description = "Version 2";
71
+ registerTool(tool2);
72
+
73
+ const retrieved = getTool("overwrite_test");
74
+ expect(retrieved!.definition.description).toBe("Version 2");
75
+ });
76
+ });
77
+
78
+ describe("Tool Execution", () => {
79
+ it("executes a tool with valid args", async () => {
80
+ registerTool(createEchoTool("exec_valid"));
81
+
82
+ const result = await executeTool("exec_valid", {message: "hello"});
83
+ expect(result.content).toHaveLength(1);
84
+ expect(result.content[0]).toEqual({type: "text", text: "hello"});
85
+ expect(result.isError).toBeFalsy();
86
+ });
87
+
88
+ it("throws on unknown tool", async () => {
89
+ await expect(executeTool("unknown_tool_xyz", {})).rejects.toThrow("Unknown tool");
90
+ });
91
+
92
+ it("throws on invalid args (Zod validation)", async () => {
93
+ registerTool(createEchoTool("exec_invalid"));
94
+
95
+ await expect(executeTool("exec_invalid", {message: 123})).rejects.toThrow();
96
+ });
97
+
98
+ it("handles tool execution errors", async () => {
99
+ const failingTool: Tool = {
100
+ definition: {
101
+ name: "exec_failing",
102
+ description: "Always fails",
103
+ inputSchema: {type: "object", properties: {}},
104
+ },
105
+ runtimeInputSchema: z.object({}),
106
+ async execute() {
107
+ throw new Error("Intentional failure");
108
+ },
109
+ };
110
+ registerTool(failingTool);
111
+
112
+ await expect(executeTool("exec_failing", {})).rejects.toThrow("Intentional failure");
113
+ });
114
+ });
@@ -0,0 +1,105 @@
1
+ import {describe, it, expect} from "vitest";
2
+ import {z} from "zod";
3
+ import {zodToMcpJsonSchema, isZodArray, fixAdditionalProperties} from "../utils/zod-to-mcp-schema.js";
4
+
5
+ describe("zodToMcpJsonSchema", () => {
6
+ it("converts a simple object schema", () => {
7
+ const schema = z.object({
8
+ name: z.string().describe("A name"),
9
+ age: z.number().describe("An age"),
10
+ });
11
+
12
+ const result = zodToMcpJsonSchema(schema);
13
+ expect(result).toHaveProperty("type", "object");
14
+ expect(result).toHaveProperty("properties.name");
15
+ expect(result).toHaveProperty("properties.age");
16
+ expect((result as any).properties.name.type).toBe("string");
17
+ expect((result as any).properties.age.type).toBe("number");
18
+ });
19
+
20
+ it("includes required fields", () => {
21
+ const schema = z.object({
22
+ required: z.string(),
23
+ optional: z.string().optional(),
24
+ });
25
+
26
+ const result = zodToMcpJsonSchema(schema) as any;
27
+ expect(result.required).toContain("required");
28
+ expect(result.required).not.toContain("optional");
29
+ });
30
+
31
+ it("handles enum types", () => {
32
+ const schema = z.object({
33
+ color: z.enum(["red", "green", "blue"]),
34
+ });
35
+
36
+ const result = zodToMcpJsonSchema(schema) as any;
37
+ expect(result.properties.color.enum).toEqual(["red", "green", "blue"]);
38
+ });
39
+
40
+ it("handles nested objects", () => {
41
+ const schema = z.object({
42
+ nested: z.object({
43
+ inner: z.string(),
44
+ }),
45
+ });
46
+
47
+ const result = zodToMcpJsonSchema(schema) as any;
48
+ expect(result.properties.nested.type).toBe("object");
49
+ expect(result.properties.nested.properties.inner.type).toBe("string");
50
+ });
51
+
52
+ it("handles arrays", () => {
53
+ const schema = z.object({
54
+ items: z.array(z.string()),
55
+ });
56
+
57
+ const result = zodToMcpJsonSchema(schema) as any;
58
+ expect(result.properties.items.type).toBe("array");
59
+ expect(result.properties.items.items.type).toBe("string");
60
+ });
61
+
62
+ it("wraps top-level array schemas in object wrapper", () => {
63
+ const schema = z.array(z.string());
64
+
65
+ const result = zodToMcpJsonSchema(schema) as any;
66
+ // Array schemas should be wrapped for MCP compliance
67
+ expect(result.type).toBe("object");
68
+ expect(result.properties.result).toBeDefined();
69
+ });
70
+
71
+ it("preserves descriptions", () => {
72
+ const schema = z.object({
73
+ field: z.string().describe("My description"),
74
+ });
75
+
76
+ const result = zodToMcpJsonSchema(schema) as any;
77
+ expect(result.properties.field.description).toBe("My description");
78
+ });
79
+ });
80
+
81
+ describe("isZodArray", () => {
82
+ it("detects array schemas", () => {
83
+ expect(isZodArray(z.array(z.string()))).toBe(true);
84
+ });
85
+
86
+ it("rejects non-array schemas", () => {
87
+ expect(isZodArray(z.object({}))).toBe(false);
88
+ expect(isZodArray(z.string())).toBe(false);
89
+ expect(isZodArray(z.number())).toBe(false);
90
+ });
91
+ });
92
+
93
+ describe("fixAdditionalProperties", () => {
94
+ it("normalizes additionalProperties to boolean", () => {
95
+ const schema = {type: "object", additionalProperties: {}};
96
+ const result = fixAdditionalProperties(schema, true);
97
+ expect(result.additionalProperties).toBe(true);
98
+ });
99
+
100
+ it("preserves existing boolean additionalProperties", () => {
101
+ const schema = {type: "object", additionalProperties: false};
102
+ const result = fixAdditionalProperties(schema, true);
103
+ expect(result.additionalProperties).toBe(false);
104
+ });
105
+ });
package/src/cli.ts ADDED
@@ -0,0 +1,58 @@
1
+ /**
2
+ * CLI Helpers — shared by all mcp-orbit packages
3
+ *
4
+ * parseArgs(): Reads --stdio / --http / --http-port / --http-host from argv
5
+ * and PORT / MCP_HTTP_HOST from env.
6
+ */
7
+
8
+ import type {ServerConfig, TransportMode} from "./core/server.js";
9
+
10
+ /**
11
+ * Parse command line arguments into a ServerConfig.
12
+ *
13
+ * Examples:
14
+ * node index.js → stdio (default, unless PORT is set)
15
+ * node index.js --stdio → stdio (explicit)
16
+ * node index.js --http → HTTP on 0.0.0.0:3333 (or PORT)
17
+ * node index.js --http --http-port=8080 → HTTP on 0.0.0.0:8080
18
+ * node index.js --http --http-host=127.0.0.1 → HTTP on 127.0.0.1:3333
19
+ *
20
+ * Environment variables:
21
+ * PORT → auto-enables HTTP mode (Render/Cloud standard)
22
+ * MCP_HTTP_HOST → override host binding (default 0.0.0.0)
23
+ */
24
+ export function parseArgs(): ServerConfig {
25
+ const args = process.argv.slice(2);
26
+
27
+ let mode: TransportMode = process.env.PORT ? "http" : "stdio";
28
+ let httpPort: number | undefined = process.env.PORT ? parseInt(process.env.PORT, 10) : undefined;
29
+ let httpHost: string | undefined = process.env.MCP_HTTP_HOST || "0.0.0.0";
30
+
31
+ for (let i = 0; i < args.length; i++) {
32
+ const arg = args[i];
33
+ if (arg === "--http") {
34
+ mode = "http";
35
+ } else if (arg === "--stdio") {
36
+ mode = "stdio";
37
+ } else if (arg.startsWith("--http-port=")) {
38
+ httpPort = parseInt(arg.split("=")[1] || "", 10);
39
+ mode = "http";
40
+ } else if (arg === "--http-port") {
41
+ httpPort = parseInt(args[++i] || "", 10);
42
+ mode = "http";
43
+ } else if (arg.startsWith("--http-host=")) {
44
+ httpHost = arg.split("=")[1];
45
+ mode = "http";
46
+ } else if (arg === "--http-host") {
47
+ httpHost = args[++i];
48
+ mode = "http";
49
+ }
50
+ }
51
+
52
+ if (mode === "http" && (!httpPort || isNaN(httpPort))) {
53
+ httpPort = 3333;
54
+ }
55
+
56
+ return {mode, httpPort, httpHost};
57
+ }
58
+
@@ -0,0 +1,192 @@
1
+ /**
2
+ * HTTP MCP Client (Protocol 2025-03-26)
3
+ *
4
+ * Modern MCP client using StreamableHTTPClientTransport
5
+ * Features:
6
+ * - Session management with automatic reconnection
7
+ * - Bearer token authentication via custom headers
8
+ * - Type-safe tool execution
9
+ * - Resource & Prompt management
10
+ * - Connection pooling & caching
11
+ *
12
+ * Based on MCP SDK:
13
+ * @see @modelcontextprotocol/sdk/client/streamableHttp
14
+ */
15
+
16
+ import {Client} from "@modelcontextprotocol/sdk/client/index.js";
17
+ import {StreamableHTTPClientTransport} from "@modelcontextprotocol/sdk/client/streamableHttp.js";
18
+ import type {
19
+ McpHttpServerConfig,
20
+ MCPToolSchema,
21
+ MCPToolResponse,
22
+ MCPResourceList,
23
+ MCPResourceContent,
24
+ MCPPromptList,
25
+ MCPPromptResponse,
26
+ IMcpClient,
27
+ } from "./types.js";
28
+
29
+ export class HttpMCPClient implements IMcpClient {
30
+ private clientCache = new Map<string, Client>();
31
+ private connectionPromises = new Map<string, Promise<Client>>();
32
+ private readonly serverConfig: McpHttpServerConfig;
33
+
34
+ // Event handlers (exposed from underlying MCP SDK Client)
35
+ public onerror?: (error: Error) => void;
36
+ public onclose?: () => void;
37
+
38
+ constructor(serverConfig: McpHttpServerConfig) {
39
+ this.serverConfig = serverConfig;
40
+ }
41
+
42
+ // ============================================================================
43
+ // TOOLS
44
+ // ============================================================================
45
+
46
+ async listTools(timeoutMs = 15000): Promise<{tools: MCPToolSchema[]}> {
47
+ const client = await this.getOrCreateClient(this.serverConfig, timeoutMs);
48
+ const resp = (await client.listTools({}, {timeout: timeoutMs} as any)) as any;
49
+
50
+ const tools: MCPToolSchema[] = Array.isArray(resp?.tools)
51
+ ? resp.tools.map((tool: any) => ({
52
+ name: tool.name,
53
+ description: tool.description,
54
+ input_schema: tool.input_schema ?? tool.inputSchema ?? {type: "object", properties: {}},
55
+ tags: tool.tags ?? tool._meta?.tags,
56
+ }))
57
+ : [];
58
+
59
+ return {tools};
60
+ }
61
+
62
+ async callTool(toolName: string, args: Record<string, any>, timeoutMs = 15000): Promise<MCPToolResponse> {
63
+ const client = await this.getOrCreateClient(this.serverConfig, timeoutMs);
64
+
65
+ const response = (await client.callTool(
66
+ {
67
+ name: toolName,
68
+ arguments: args,
69
+ },
70
+ undefined,
71
+ {timeout: timeoutMs} as any,
72
+ )) as any;
73
+
74
+ return {
75
+ content: Array.isArray(response?.content) ? response.content : [],
76
+ isError: response?.isError ?? false,
77
+ structuredContent: response?.structuredContent,
78
+ _meta: response?._meta,
79
+ };
80
+ }
81
+
82
+ // ============================================================================
83
+ // RESOURCES
84
+ // ============================================================================
85
+
86
+ async listResources(timeoutMs = 15000): Promise<MCPResourceList> {
87
+ const client = await this.getOrCreateClient(this.serverConfig, timeoutMs);
88
+ const response = (await client.listResources({}, {timeout: timeoutMs} as any)) as any;
89
+ return {
90
+ resources: response.resources || [],
91
+ };
92
+ }
93
+
94
+ async readResource(uri: string, timeoutMs = 15000): Promise<MCPResourceContent> {
95
+ const client = await this.getOrCreateClient(this.serverConfig, timeoutMs);
96
+ const response = (await client.readResource({uri}, {timeout: timeoutMs} as any)) as any;
97
+ return {
98
+ contents: response.contents || [],
99
+ };
100
+ }
101
+
102
+ // ============================================================================
103
+ // PROMPTS
104
+ // ============================================================================
105
+
106
+ async listPrompts(timeoutMs = 15000): Promise<MCPPromptList> {
107
+ const client = await this.getOrCreateClient(this.serverConfig, timeoutMs);
108
+ const response = (await client.listPrompts({}, {timeout: timeoutMs} as any)) as any;
109
+ return {
110
+ prompts: response.prompts || [],
111
+ };
112
+ }
113
+
114
+ async getPrompt(name: string, args?: Record<string, any>, timeoutMs = 15000): Promise<MCPPromptResponse> {
115
+ const client = await this.getOrCreateClient(this.serverConfig, timeoutMs);
116
+ const response = (await client.getPrompt({name, arguments: args}, {timeout: timeoutMs} as any)) as any;
117
+ return {
118
+ messages: response.messages || [],
119
+ };
120
+ }
121
+
122
+ // ============================================================================
123
+ // PRIVATE HELPERS
124
+ // ============================================================================
125
+
126
+ private async createClient(server: McpHttpServerConfig, timeoutMs: number): Promise<Client> {
127
+ const client = new Client({name: "mcp-orbit-client", version: "1.0.0"});
128
+
129
+ // Wire up event handlers from SDK Client to our wrapper
130
+ client.onerror = (error) => {
131
+ this.onerror?.(error);
132
+ };
133
+
134
+ client.onclose = () => {
135
+ this.onclose?.();
136
+ };
137
+
138
+ const baseUrl = new URL(server.url);
139
+
140
+ // Create Streamable HTTP transport with optional authentication
141
+ const transport = new StreamableHTTPClientTransport(baseUrl, {
142
+ // Add custom headers (e.g., Authorization) to all requests
143
+ requestInit: server.headers
144
+ ? {
145
+ headers: server.headers,
146
+ }
147
+ : undefined,
148
+ });
149
+
150
+ await client.connect(transport, {timeout: timeoutMs} as any);
151
+ return client;
152
+ }
153
+
154
+ private async getOrCreateClient(server: McpHttpServerConfig, timeoutMs: number): Promise<Client> {
155
+ const key = this.getServerKey(server);
156
+
157
+ const existing = this.clientCache.get(key);
158
+ if (existing) {
159
+ return existing;
160
+ }
161
+
162
+ const pending = this.connectionPromises.get(key);
163
+ if (pending !== undefined) {
164
+ return pending;
165
+ }
166
+
167
+ const connectPromise = this.createClient(server, timeoutMs)
168
+ .then((client) => {
169
+ this.connectionPromises.delete(key);
170
+ this.clientCache.set(key, client);
171
+ return client;
172
+ })
173
+ .catch((error) => {
174
+ this.connectionPromises.delete(key);
175
+ throw error;
176
+ });
177
+
178
+ this.connectionPromises.set(key, connectPromise);
179
+ return connectPromise;
180
+ }
181
+
182
+ private getServerKey(config: McpHttpServerConfig): string {
183
+ return `streamable-http:${config.url}`;
184
+ }
185
+
186
+ async close(): Promise<void> {
187
+ const closePromises = Array.from(this.clientCache.values()).map((client) => client.close().catch(() => undefined));
188
+ this.clientCache.clear();
189
+ this.connectionPromises.clear();
190
+ await Promise.all(closePromises);
191
+ }
192
+ }
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Stdio MCP Client
3
+ *
4
+ * Client for connecting to MCP servers via stdio (local child process)
5
+ * Features:
6
+ * - Client caching & connection pooling
7
+ * - Type-safe tool execution
8
+ * - Resource & Prompt management
9
+ */
10
+
11
+ import {Client} from "@modelcontextprotocol/sdk/client/index.js";
12
+ import {StdioClientTransport} from "@modelcontextprotocol/sdk/client/stdio.js";
13
+ import type {
14
+ McpStdioServerConfig,
15
+ MCPToolSchema,
16
+ MCPToolResponse,
17
+ MCPResourceList,
18
+ MCPResourceContent,
19
+ MCPPromptList,
20
+ MCPPromptResponse,
21
+ IMcpClient,
22
+ } from "./types.js";
23
+
24
+ export class StdioMCPClient implements IMcpClient {
25
+ private clientCache = new Map<string, Client>();
26
+ private connectionPromises = new Map<string, Promise<Client>>();
27
+ private readonly serverConfig: McpStdioServerConfig;
28
+
29
+ // Event handlers (exposed from underlying MCP SDK Client)
30
+ public onerror?: (error: Error) => void;
31
+ public onclose?: () => void;
32
+
33
+ constructor(serverConfig: McpStdioServerConfig) {
34
+ this.serverConfig = serverConfig;
35
+ }
36
+
37
+ // ============================================================================
38
+ // TOOLS
39
+ // ============================================================================
40
+
41
+ async listTools(timeoutMs = 15000): Promise<{tools: MCPToolSchema[]}> {
42
+ const client = await this.getOrCreateClient(this.serverConfig, timeoutMs);
43
+ const resp = (await client.listTools({}, {timeout: timeoutMs} as any)) as any;
44
+
45
+ const tools: MCPToolSchema[] = Array.isArray(resp?.tools)
46
+ ? resp.tools.map((tool: any) => ({
47
+ name: tool.name,
48
+ description: tool.description,
49
+ input_schema: tool.input_schema ?? tool.inputSchema ?? {type: "object", properties: {}},
50
+ tags: tool.tags ?? tool._meta?.tags,
51
+ }))
52
+ : [];
53
+
54
+ return {tools};
55
+ }
56
+
57
+ async callTool(toolName: string, args: Record<string, any>, timeoutMs = 15000): Promise<MCPToolResponse> {
58
+ const client = await this.getOrCreateClient(this.serverConfig, timeoutMs);
59
+
60
+ const response = (await client.callTool(
61
+ {
62
+ name: toolName,
63
+ arguments: args,
64
+ },
65
+ undefined,
66
+ {timeout: timeoutMs} as any,
67
+ )) as any;
68
+
69
+ return {
70
+ content: Array.isArray(response?.content) ? response.content : [],
71
+ isError: response?.isError ?? false,
72
+ structuredContent: response?.structuredContent,
73
+ _meta: response?._meta,
74
+ };
75
+ }
76
+
77
+ // ============================================================================
78
+ // RESOURCES
79
+ // ============================================================================
80
+
81
+ async listResources(timeoutMs = 15000): Promise<MCPResourceList> {
82
+ const client = await this.getOrCreateClient(this.serverConfig, timeoutMs);
83
+ const response = (await client.listResources({}, {timeout: timeoutMs} as any)) as any;
84
+ return {
85
+ resources: response.resources || [],
86
+ };
87
+ }
88
+
89
+ async readResource(uri: string, timeoutMs = 15000): Promise<MCPResourceContent> {
90
+ const client = await this.getOrCreateClient(this.serverConfig, timeoutMs);
91
+ const response = (await client.readResource({uri}, {timeout: timeoutMs} as any)) as any;
92
+ return {
93
+ contents: response.contents || [],
94
+ };
95
+ }
96
+
97
+ // ============================================================================
98
+ // PROMPTS
99
+ // ============================================================================
100
+
101
+ async listPrompts(timeoutMs = 15000): Promise<MCPPromptList> {
102
+ const client = await this.getOrCreateClient(this.serverConfig, timeoutMs);
103
+ const response = (await client.listPrompts({}, {timeout: timeoutMs} as any)) as any;
104
+ return {
105
+ prompts: response.prompts || [],
106
+ };
107
+ }
108
+
109
+ async getPrompt(name: string, args?: Record<string, any>, timeoutMs = 15000): Promise<MCPPromptResponse> {
110
+ const client = await this.getOrCreateClient(this.serverConfig, timeoutMs);
111
+ const response = (await client.getPrompt({name, arguments: args}, {timeout: timeoutMs} as any)) as any;
112
+ return {
113
+ messages: response.messages || [],
114
+ };
115
+ }
116
+
117
+ // ============================================================================
118
+ // PRIVATE HELPERS
119
+ // ============================================================================
120
+
121
+ /**
122
+ * Resolve environment variable placeholders like ${VAR_NAME}
123
+ * @param value - String that may contain placeholders
124
+ * @param env - Current environment variables to resolve from
125
+ * @returns Resolved string with placeholders replaced
126
+ */
127
+ private resolveEnvPlaceholders(value: string, env: Record<string, string>): string {
128
+ return value.replace(/\$\{([^}]+)\}/g, (match, varName) => {
129
+ return env[varName] || match; // Replace with env value or keep placeholder if not found
130
+ });
131
+ }
132
+
133
+ private async createClient(server: McpStdioServerConfig, timeoutMs: number): Promise<Client> {
134
+ const client = new Client({name: "mcp-orbit-client", version: "1.0.0"});
135
+
136
+ // Wire up event handlers from SDK Client to our wrapper
137
+ client.onerror = (error) => {
138
+ this.onerror?.(error);
139
+ };
140
+
141
+ client.onclose = () => {
142
+ this.onclose?.();
143
+ };
144
+
145
+ // Build env: start with current process.env, then overlay server.env
146
+ const env: Record<string, string> = {};
147
+ for (const [key, value] of Object.entries(process.env)) {
148
+ if (value !== undefined) {
149
+ env[key] = value;
150
+ }
151
+ }
152
+
153
+ // Resolve placeholders in server.env values
154
+ if (server.env) {
155
+ for (const [key, value] of Object.entries(server.env)) {
156
+ env[key] = this.resolveEnvPlaceholders(value, env);
157
+ }
158
+ }
159
+
160
+ const transport = new StdioClientTransport({
161
+ command: server.command,
162
+ args: server.args,
163
+ env,
164
+ cwd: server.cwd,
165
+ stderr: "pipe", // Use "pipe" instead of "inherit" for proper error event handling
166
+ });
167
+ await client.connect(transport, {timeout: timeoutMs} as any);
168
+ return client;
169
+ }
170
+
171
+ private async getOrCreateClient(server: McpStdioServerConfig, timeoutMs: number): Promise<Client> {
172
+ const key = this.getServerKey(server);
173
+
174
+ const existing = this.clientCache.get(key);
175
+ if (existing) {
176
+ return existing;
177
+ }
178
+
179
+ const pending = this.connectionPromises.get(key);
180
+ if (pending !== undefined) {
181
+ return pending;
182
+ }
183
+
184
+ const connectPromise = this.createClient(server, timeoutMs)
185
+ .then((client) => {
186
+ this.connectionPromises.delete(key);
187
+ this.clientCache.set(key, client);
188
+ return client;
189
+ })
190
+ .catch((error) => {
191
+ this.connectionPromises.delete(key);
192
+ throw error;
193
+ });
194
+
195
+ this.connectionPromises.set(key, connectPromise);
196
+ return connectPromise;
197
+ }
198
+
199
+ private getServerKey(config: McpStdioServerConfig): string {
200
+ return `stdio:${config.command}:${config.args?.join(",")}`;
201
+ }
202
+
203
+ async close(): Promise<void> {
204
+ const closePromises = Array.from(this.clientCache.values()).map((client) => client.close().catch(() => undefined));
205
+ this.clientCache.clear();
206
+ this.connectionPromises.clear();
207
+ await Promise.all(closePromises);
208
+ }
209
+ }