@teddysc/mcp-codemode 1.0.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.
package/src/server.ts ADDED
@@ -0,0 +1,550 @@
1
+ /**
2
+ * Generic Codemode MCP Server
3
+ *
4
+ * Wraps one or more upstream MCP servers (stdio or HTTP) and exposes two tools:
5
+ * - search: LLM writes JS to inspect tool schemas (progressive discovery)
6
+ * - execute: LLM writes JS to orchestrate tools via the codemode.* API
7
+ *
8
+ * The "search" tool is omitted when addSearch=false, and added automatically
9
+ * when addSearch=true or when aggregated upstream tools are >= 10.
10
+ */
11
+
12
+ import * as vm from "node:vm";
13
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
14
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
15
+ import {
16
+ ListToolsRequestSchema,
17
+ CallToolRequestSchema,
18
+ } from "@modelcontextprotocol/sdk/types.js";
19
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
20
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
21
+ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
22
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
23
+
24
+ import {
25
+ sanitizeToolName,
26
+ type ToolDescriptor,
27
+ type JSONSchema,
28
+ } from "./types.js";
29
+ import { LocalExecutor, type ToolFns } from "./executor.js";
30
+
31
+ export type StdioUpstreamConfig = {
32
+ id: string;
33
+ type: "stdio";
34
+ command: string;
35
+ args: string[];
36
+ };
37
+
38
+ export type HttpUpstreamConfig = {
39
+ id: string;
40
+ type: "http";
41
+ url: string;
42
+ headers?: Record<string, string>;
43
+ };
44
+
45
+ export type UpstreamConfig = StdioUpstreamConfig | HttpUpstreamConfig;
46
+
47
+ export interface ServerConfig {
48
+ /** One or more upstream MCP servers to aggregate */
49
+ upstreams: UpstreamConfig[];
50
+ /** Execution timeout in ms (default: 60000) */
51
+ timeoutMs?: number;
52
+ /** Server name shown in MCP metadata */
53
+ serverName?: string;
54
+ /**
55
+ * Whether to expose the "search" tool.
56
+ * true – always expose
57
+ * false – never expose (only "execute" is registered)
58
+ * undefined – auto: expose when aggregated upstream tools are >= 10
59
+ */
60
+ addSearch?: boolean;
61
+ /**
62
+ * Whether to include a full available-tools list in the execute tool
63
+ * description. Defaults to true.
64
+ */
65
+ includeExecuteToolList?: boolean;
66
+ }
67
+
68
+ const AUTO_SEARCH_THRESHOLD = 10;
69
+
70
+ function buildUpstreamTransport(upstream: UpstreamConfig) {
71
+ if (upstream.type === "http") {
72
+ const url = new URL(upstream.url);
73
+ const requestInit = upstream.headers
74
+ ? { headers: upstream.headers }
75
+ : undefined;
76
+ try {
77
+ return new StreamableHTTPClientTransport(url, { requestInit });
78
+ } catch {
79
+ return new SSEClientTransport(url, { requestInit });
80
+ }
81
+ }
82
+ return new StdioClientTransport({
83
+ command: upstream.command,
84
+ args: upstream.args,
85
+ env: Object.fromEntries(
86
+ Object.entries(process.env).filter(
87
+ (e): e is [string, string] => e[1] !== undefined,
88
+ ),
89
+ ),
90
+ });
91
+ }
92
+
93
+ type ToolBinding = {
94
+ callName: string;
95
+ originalName: string;
96
+ upstreamId: string;
97
+ description: string;
98
+ inputSchema: JSONSchema;
99
+ };
100
+
101
+ type SearchDescriptor = ToolDescriptor & {
102
+ upstream: string;
103
+ originalName: string;
104
+ };
105
+
106
+ type ContentBlock = { type: "text"; text: string };
107
+ type ToolEntry = {
108
+ name: string;
109
+ description: string;
110
+ inputSchema: object;
111
+ handler: (
112
+ args: Record<string, unknown>,
113
+ ) => Promise<{ content: ContentBlock[]; isError?: boolean }>;
114
+ };
115
+
116
+ function withTimeout<T>(
117
+ promise: Promise<T>,
118
+ ms: number,
119
+ label: string,
120
+ ): Promise<T> {
121
+ return Promise.race([
122
+ promise,
123
+ new Promise<never>((_, reject) =>
124
+ setTimeout(
125
+ () => reject(new Error(`${label} timed out after ${ms}ms`)),
126
+ ms,
127
+ ),
128
+ ),
129
+ ]);
130
+ }
131
+
132
+ function firstLine(text: string | undefined): string {
133
+ return (text ?? "").split("\n")[0] ?? "";
134
+ }
135
+
136
+ function toCallName(
137
+ binding: Pick<ToolBinding, "upstreamId" | "originalName">,
138
+ preferPrefixedName: boolean,
139
+ used: Set<string>,
140
+ ): string {
141
+ const base = sanitizeToolName(binding.originalName);
142
+ const prefixed = sanitizeToolName(
143
+ `${binding.upstreamId}_${binding.originalName}`,
144
+ );
145
+ const candidates = preferPrefixedName
146
+ ? [prefixed, base]
147
+ : [base, prefixed];
148
+
149
+ for (const candidate of candidates) {
150
+ if (candidate.length > 0 && !used.has(candidate)) return candidate;
151
+ }
152
+
153
+ const stem = candidates[0] || "_tool";
154
+ let i = 2;
155
+ let suffixed = `${stem}_${i}`;
156
+ while (used.has(suffixed)) {
157
+ i += 1;
158
+ suffixed = `${stem}_${i}`;
159
+ }
160
+ return suffixed;
161
+ }
162
+
163
+ export async function runServer(config: ServerConfig): Promise<void> {
164
+ const {
165
+ upstreams,
166
+ timeoutMs = 60_000,
167
+ serverName = "mcp-codemode",
168
+ addSearch,
169
+ includeExecuteToolList = true,
170
+ } = config;
171
+
172
+ if (upstreams.length === 0) {
173
+ throw new Error("At least one upstream must be configured.");
174
+ }
175
+
176
+ // Shared state populated once upstream is ready
177
+ let tools: ToolEntry[] = [];
178
+
179
+ // Promise that resolves (or rejects) when upstream setup is complete
180
+ let resolveUpstream!: () => void;
181
+ const upstreamReady = new Promise<void>((res) => {
182
+ resolveUpstream = res;
183
+ });
184
+ // Prevent unhandled rejection if nobody awaits it before it rejects
185
+ upstreamReady.catch(() => {});
186
+
187
+ // ─── Low-level MCP Server ─────────────────────────────────────────────────
188
+ // Declare tools capability upfront; actual tool list is resolved lazily.
189
+
190
+ const server = new Server(
191
+ { name: serverName, version: "1.0.0" },
192
+ { capabilities: { tools: {} } },
193
+ );
194
+
195
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
196
+ await upstreamReady;
197
+ return {
198
+ tools: tools.map(({ name, description, inputSchema }) => ({
199
+ name,
200
+ description,
201
+ inputSchema,
202
+ })),
203
+ };
204
+ });
205
+
206
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
207
+ await upstreamReady;
208
+ const tool = tools.find((t) => t.name === req.params.name);
209
+ if (!tool) {
210
+ return {
211
+ content: [
212
+ { type: "text" as const, text: `Unknown tool: ${req.params.name}` },
213
+ ],
214
+ isError: true,
215
+ };
216
+ }
217
+ return tool.handler(
218
+ (req.params.arguments ?? {}) as Record<string, unknown>,
219
+ );
220
+ });
221
+
222
+ // ─── Start transport immediately (client handshakes before upstream is up) ──
223
+
224
+ const transport = new StdioServerTransport();
225
+ await server.connect(transport);
226
+
227
+ // ─── Connect to upstreams in the background ───────────────────────────────
228
+
229
+ let upstreamClients: Client[] = [];
230
+
231
+ (async () => {
232
+ try {
233
+ const preferPrefixedName = upstreams.length > 1;
234
+ const results = await Promise.allSettled(
235
+ upstreams.map(async (upstream) => {
236
+ const upstreamTransport = buildUpstreamTransport(upstream);
237
+ const upstreamClientName =
238
+ upstreams.length === 1
239
+ ? `${serverName}-upstream`
240
+ : `${serverName}-upstream-${upstream.id}`;
241
+ const client = new Client(
242
+ { name: upstreamClientName, version: "1.0.0" },
243
+ { capabilities: {} },
244
+ );
245
+
246
+ try {
247
+ await withTimeout(
248
+ client.connect(upstreamTransport),
249
+ timeoutMs,
250
+ `Upstream "${upstream.id}" connection`,
251
+ );
252
+
253
+ const { tools: mcpTools } = await withTimeout(
254
+ client.listTools(),
255
+ timeoutMs,
256
+ `Upstream "${upstream.id}" listTools`,
257
+ );
258
+
259
+ return { upstream, client, mcpTools };
260
+ } catch (err) {
261
+ await client.close().catch(() => {});
262
+ throw err;
263
+ }
264
+ }),
265
+ );
266
+
267
+ const connected: Array<{
268
+ upstream: UpstreamConfig;
269
+ client: Client;
270
+ mcpTools: Array<{
271
+ name: string;
272
+ description?: string;
273
+ inputSchema?: object;
274
+ }>;
275
+ }> = [];
276
+
277
+ for (const [index, result] of results.entries()) {
278
+ const upstream = upstreams[index]!;
279
+ if (result.status === "fulfilled") {
280
+ connected.push(result.value);
281
+ continue;
282
+ }
283
+ process.stderr.write(
284
+ `[mcp-codemode] Upstream "${upstream.id}" failed: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}\n`,
285
+ );
286
+ }
287
+
288
+ if (connected.length === 0) {
289
+ throw new Error("Failed to connect to all configured upstream servers.");
290
+ }
291
+
292
+ upstreamClients = connected.map((entry) => entry.client);
293
+
294
+ const fns: ToolFns = {};
295
+ const descriptors: Record<string, SearchDescriptor> = {};
296
+ const usedCallNames = new Set<string>();
297
+ const bindings: Array<ToolBinding & { client: Client }> = [];
298
+
299
+ for (const { upstream, client, mcpTools } of connected) {
300
+ for (const tool of mcpTools) {
301
+ const callName = toCallName(
302
+ { upstreamId: upstream.id, originalName: tool.name },
303
+ preferPrefixedName,
304
+ usedCallNames,
305
+ );
306
+ usedCallNames.add(callName);
307
+ bindings.push({
308
+ callName,
309
+ originalName: tool.name,
310
+ upstreamId: upstream.id,
311
+ description: tool.description ?? "",
312
+ inputSchema: (tool.inputSchema ?? {}) as JSONSchema,
313
+ client,
314
+ });
315
+ }
316
+ }
317
+
318
+ for (const binding of bindings) {
319
+ descriptors[binding.callName] = {
320
+ upstream: binding.upstreamId,
321
+ originalName: binding.originalName,
322
+ description: binding.description,
323
+ inputSchema: binding.inputSchema,
324
+ };
325
+ fns[binding.callName] = async (callArgs: unknown) => {
326
+ const result = await binding.client.callTool({
327
+ name: binding.originalName,
328
+ arguments: (callArgs ?? {}) as Record<string, unknown>,
329
+ });
330
+ if (result.isError) {
331
+ const msg = (
332
+ result.content as Array<{ type: string; text?: string }>
333
+ )
334
+ .filter((c) => c.type === "text")
335
+ .map((c) => c.text)
336
+ .join("\n");
337
+ throw new Error(msg || "MCP tool returned an error");
338
+ }
339
+ const content = result.content as Array<{
340
+ type: string;
341
+ text?: string;
342
+ }>;
343
+ if (content.length === 1 && content[0].type === "text")
344
+ return content[0].text;
345
+ return content;
346
+ };
347
+ }
348
+
349
+ // ─── Decide whether to expose search ──────────────────────────────────
350
+
351
+ const exposeSearch =
352
+ addSearch === true
353
+ ? true
354
+ : addSearch === false
355
+ ? false
356
+ : bindings.length >= AUTO_SEARCH_THRESHOLD;
357
+
358
+ // ─── Build tool list for execute description ──────────────────────────
359
+
360
+ const toolList = bindings
361
+ .map(
362
+ (binding) =>
363
+ ` ${binding.callName}: [${binding.upstreamId}] ${firstLine(binding.description)}`,
364
+ )
365
+ .join("\n");
366
+
367
+ // ─── Tool descriptions ────────────────────────────────────────────────
368
+
369
+ const SEARCH_DESCRIPTION = `\
370
+ Search aggregated MCP tools and inspect their argument schemas.
371
+ Write a JavaScript async arrow function that queries the \`tools\` object.
372
+
373
+ \`tools\` is available in scope:
374
+ Record<toolName, { upstream: string, originalName: string, description: string, inputSchema: object }>`;
375
+
376
+ const executeToolListSection = includeExecuteToolList
377
+ ? `\n\nAvailable tools:\n${toolList || " (no upstream tools found)"}`
378
+ : "\n\nAvailable tools list is hidden by configuration. Use the search tool to discover tool names and schemas.";
379
+
380
+ const EXECUTE_DESCRIPTION = `\
381
+ Execute JavaScript code to orchestrate upstream MCP tools via the codemode.* API.${exposeSearch ? "\nUse the search tool first to discover the exact argument schemas for the tools you need." : ""}${executeToolListSection}
382
+
383
+ Rules:
384
+ - Write an async arrow function body that calls codemode.* methods
385
+ - Do NOT use TypeScript syntax (no type annotations, no interfaces, no generics)
386
+ - Always await async calls
387
+ - Return a value summarising what happened`;
388
+
389
+ // ─── Search runner (read-only vm sandbox) ──────────────────────────────
390
+
391
+ async function runSearch(code: string): Promise<unknown> {
392
+ const trimmed = code.trim();
393
+ const normalizedCode = /^async\s*(\(|[a-zA-Z_$])/.test(trimmed)
394
+ ? trimmed
395
+ : `async () => {\n${trimmed}\n}`;
396
+
397
+ const g = globalThis as Record<string, unknown>;
398
+ const sandbox = {
399
+ tools: descriptors,
400
+ JSON: g["JSON"],
401
+ Object: g["Object"],
402
+ Array: g["Array"],
403
+ Promise: g["Promise"],
404
+ };
405
+ const context = vm.createContext(sandbox);
406
+ const script = new vm.Script(`(${normalizedCode})()`);
407
+ return Promise.race([
408
+ script.runInContext(context),
409
+ new Promise<never>((_, reject) =>
410
+ setTimeout(
411
+ () => reject(new Error("Search timed out after 10s")),
412
+ 10_000,
413
+ ),
414
+ ),
415
+ ]);
416
+ }
417
+
418
+ // ─── Execute runner ────────────────────────────────────────────────────
419
+
420
+ const executor = new LocalExecutor({ timeoutMs });
421
+
422
+ // ─── Populate tool entries ─────────────────────────────────────────────
423
+
424
+ tools = [];
425
+
426
+ if (exposeSearch) {
427
+ tools.push({
428
+ name: "search",
429
+ description: SEARCH_DESCRIPTION,
430
+ inputSchema: {
431
+ type: "object",
432
+ properties: {
433
+ code: {
434
+ type: "string",
435
+ description:
436
+ "JS async arrow function that queries the `tools` object to find schemas",
437
+ },
438
+ },
439
+ required: ["code"],
440
+ },
441
+ handler: async ({ code }) => {
442
+ try {
443
+ const result = await runSearch(String(code));
444
+ return {
445
+ content: [
446
+ {
447
+ type: "text" as const,
448
+ text:
449
+ typeof result === "string"
450
+ ? result
451
+ : JSON.stringify(result, null, 2),
452
+ },
453
+ ],
454
+ };
455
+ } catch (err) {
456
+ return {
457
+ content: [
458
+ {
459
+ type: "text" as const,
460
+ text: `Error: ${err instanceof Error ? err.message : String(err)}`,
461
+ },
462
+ ],
463
+ isError: true,
464
+ };
465
+ }
466
+ },
467
+ });
468
+ }
469
+
470
+ tools.push({
471
+ name: "execute",
472
+ description: EXECUTE_DESCRIPTION,
473
+ inputSchema: {
474
+ type: "object",
475
+ properties: {
476
+ code: {
477
+ type: "string",
478
+ description:
479
+ "An async arrow function that uses codemode.* to call upstream tools",
480
+ },
481
+ },
482
+ required: ["code"],
483
+ },
484
+ handler: async ({ code }) => {
485
+ const result = await executor.execute(String(code), fns);
486
+ if (result.error) {
487
+ const parts = [`Error: ${result.error}`];
488
+ if (result.logs.length > 0)
489
+ parts.push(`Logs:\n${result.logs.join("\n")}`);
490
+ return {
491
+ content: [{ type: "text" as const, text: parts.join("\n\n") }],
492
+ isError: true,
493
+ };
494
+ }
495
+ const parts: string[] = [];
496
+ if (result.logs.length > 0)
497
+ parts.push(`Logs:\n${result.logs.join("\n")}`);
498
+ parts.push(
499
+ typeof result.result === "string"
500
+ ? result.result
501
+ : JSON.stringify(result.result, null, 2),
502
+ );
503
+ return {
504
+ content: [{ type: "text" as const, text: parts.join("\n\n") }],
505
+ };
506
+ },
507
+ });
508
+
509
+ resolveUpstream();
510
+ // Notify client to re-fetch the tool list
511
+ await server.sendToolListChanged();
512
+ } catch (err) {
513
+ process.stderr.write(
514
+ `[mcp-codemode] Upstream initialization error: ${err instanceof Error ? err.message : String(err)}\n`,
515
+ );
516
+ // Surface error via a stub execute tool
517
+ tools = [
518
+ {
519
+ name: "execute",
520
+ description: "Upstream MCP server failed to initialize.",
521
+ inputSchema: {
522
+ type: "object",
523
+ properties: { code: { type: "string" } },
524
+ required: ["code"],
525
+ },
526
+ handler: async () => ({
527
+ content: [
528
+ {
529
+ type: "text" as const,
530
+ text: `Upstream MCP server failed to initialize: ${err instanceof Error ? err.message : String(err)}`,
531
+ },
532
+ ],
533
+ isError: true,
534
+ }),
535
+ },
536
+ ];
537
+ resolveUpstream();
538
+ await server.sendToolListChanged();
539
+ }
540
+ })();
541
+
542
+ // ─── Cleanup ──────────────────────────────────────────────────────────────
543
+
544
+ const cleanup = () =>
545
+ Promise.allSettled(upstreamClients.map((client) => client.close())).finally(
546
+ () => process.exit(0),
547
+ );
548
+ process.on("SIGINT", cleanup);
549
+ process.on("SIGTERM", cleanup);
550
+ }