@agjs/tsforge 0.1.8 → 0.1.9

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@agjs/tsforge",
3
3
  "type": "module",
4
- "version": "0.1.8",
4
+ "version": "0.1.9",
5
5
  "license": "MIT",
6
6
  "description": "TypeScript coding harness with a deterministic gate, stack-aware guardrails, and stream-level correction.",
7
7
  "repository": {
@@ -1,6 +1,7 @@
1
1
  import { join } from "node:path";
2
2
  import { isRecord } from "../lib/guards";
3
3
  import { PACK_REGISTRY } from "../stack-detection";
4
+ import { parseMcpServers, type IMcpServerConfig } from "../mcp";
4
5
 
5
6
  /**
6
7
  * User-defined configuration from tsforge.config.json
@@ -23,6 +24,13 @@ export interface ITsforgeProjectConfig {
23
24
  * Values: "error" | "warn" | "off" (off silences the rule).
24
25
  */
25
26
  readonly rules?: Readonly<Record<string, "error" | "warn" | "off">>;
27
+
28
+ /**
29
+ * External MCP (Model Context Protocol) servers whose tools are offered to the
30
+ * agent. Keyed by a short server name. `${VAR}` references in string values are
31
+ * interpolated from the environment at load time. Opt-in: absent ⇒ no MCP.
32
+ */
33
+ readonly mcpServers?: Readonly<Record<string, IMcpServerConfig>>;
26
34
  }
27
35
 
28
36
  function warnConfig(msg: string): void {
@@ -158,6 +166,54 @@ function validateRules(
158
166
  return Object.keys(rulesFields).length > 0 ? rulesFields : undefined;
159
167
  }
160
168
 
169
+ /** Validate each known field of an already-parsed config object, dropping any
170
+ * that fail their per-field validator. Kept separate from the file IO so the
171
+ * loader stays simple. */
172
+ function buildConfigFields(
173
+ parsed: Record<string, unknown>
174
+ ): ITsforgeProjectConfig {
175
+ const configFields: {
176
+ stack?: string;
177
+ packs?: { include?: readonly string[]; exclude?: readonly string[] };
178
+ rules?: Record<string, "error" | "warn" | "off">;
179
+ mcpServers?: Record<string, IMcpServerConfig>;
180
+ } = {};
181
+
182
+ if (parsed.stack !== undefined) {
183
+ const stack = validateStack(parsed.stack);
184
+
185
+ if (stack !== undefined) {
186
+ configFields.stack = stack;
187
+ }
188
+ }
189
+
190
+ if (parsed.packs !== undefined) {
191
+ const packs = validatePacks(parsed.packs);
192
+
193
+ if (packs !== undefined) {
194
+ configFields.packs = packs;
195
+ }
196
+ }
197
+
198
+ if (parsed.rules !== undefined) {
199
+ const rules = validateRules(parsed.rules);
200
+
201
+ if (rules !== undefined) {
202
+ configFields.rules = rules;
203
+ }
204
+ }
205
+
206
+ if (parsed.mcpServers !== undefined) {
207
+ const servers = parseMcpServers(parsed.mcpServers, process.env);
208
+
209
+ if (Object.keys(servers).length > 0) {
210
+ configFields.mcpServers = servers;
211
+ }
212
+ }
213
+
214
+ return configFields;
215
+ }
216
+
161
217
  /** Load tsforge.config.json from cwd, defaulting to empty config on missing/invalid files. */
162
218
  export async function loadTsforgeConfig(
163
219
  cwd: string
@@ -181,37 +237,7 @@ export async function loadTsforgeConfig(
181
237
  return {};
182
238
  }
183
239
 
184
- const configFields: {
185
- stack?: string;
186
- packs?: { include?: readonly string[]; exclude?: readonly string[] };
187
- rules?: Record<string, "error" | "warn" | "off">;
188
- } = {};
189
-
190
- if (parsed.stack !== undefined) {
191
- const stack = validateStack(parsed.stack);
192
-
193
- if (stack !== undefined) {
194
- configFields.stack = stack;
195
- }
196
- }
197
-
198
- if (parsed.packs !== undefined) {
199
- const packs = validatePacks(parsed.packs);
200
-
201
- if (packs !== undefined) {
202
- configFields.packs = packs;
203
- }
204
- }
205
-
206
- if (parsed.rules !== undefined) {
207
- const rules = validateRules(parsed.rules);
208
-
209
- if (rules !== undefined) {
210
- configFields.rules = rules;
211
- }
212
- }
213
-
214
- return configFields;
240
+ return buildConfigFields(parsed);
215
241
  } catch (err) {
216
242
  if (err instanceof SyntaxError) {
217
243
  const firstLine = err.message.split("\n")[0];
@@ -25,6 +25,7 @@ import {
25
25
  normalizeRuleOverrides,
26
26
  resolveActivePacks,
27
27
  } from "../config/tsforge-config";
28
+ import { connectMcpServers } from "../mcp";
28
29
  import { LOOP_LIMITS, RUN_STATUS } from "./loop.constants";
29
30
  import type { Reporter } from "./loop.types";
30
31
  import { CHAT_SYSTEM, COMPACT_SYSTEM } from "./prompt";
@@ -439,6 +440,16 @@ export class Session {
439
440
  };
440
441
  const ruleOverrides = normalizeRuleOverrides(projectConfig);
441
442
 
443
+ // Opt-in: connect any configured MCP servers so their tools are offered to
444
+ // the agent. A bad server is reported and skipped (connectMcpServers never
445
+ // throws), so MCP can never block an interactive session from starting.
446
+ const mcpRegistry =
447
+ projectConfig.mcpServers === undefined
448
+ ? null
449
+ : await connectMcpServers(projectConfig.mcpServers, (message) => {
450
+ report({ kind: "tool", task: SESSION_ID, message });
451
+ });
452
+
442
453
  const ctx: ILoopCtx = {
443
454
  task,
444
455
  cwd: cfg.cwd,
@@ -447,6 +458,7 @@ export class Session {
447
458
  parse: cfg.parse,
448
459
  report,
449
460
  stackProfile,
461
+ ...(mcpRegistry === null ? {} : { mcpRegistry }),
450
462
  ...(Object.keys(ruleOverrides).length > 0 ? { ruleOverrides } : {}),
451
463
  messages:
452
464
  cfg.history !== undefined && cfg.history.length > 0
@@ -916,13 +928,18 @@ export class Session {
916
928
  // enforces a read-only command allowlist) — the model never sees a write
917
929
  // tool. Filtered per call, so `this.tools` is untouched and toggling the
918
930
  // mode off restores the full set with zero bookkeeping.
919
- const offeredTools = this.planMode
931
+ const baseTools = this.planMode
920
932
  ? this.tools.filter(
921
933
  (t) =>
922
934
  READ_ONLY_TOOL_NAMES.has(t.function.name) ||
923
935
  t.function.name === TOOL_NAME.run
924
936
  )
925
937
  : this.tools;
938
+ // MCP tools are external context sources (not workspace writes), so they ride
939
+ // alongside the built-ins even in plan mode — appended after the filter.
940
+ const mcpSchemas = this.ctx.mcpRegistry?.toolSchemas() ?? [];
941
+ const offeredTools =
942
+ mcpSchemas.length > 0 ? [...baseTools, ...mcpSchemas] : baseTools;
926
943
  const res = await this.provider.complete(ctx.messages, {
927
944
  tools: offeredTools,
928
945
  temperature: this.cfg.temperature ?? 0,
@@ -55,6 +55,13 @@ export async function executeTool(
55
55
  call: IToolCall,
56
56
  ctx: IToolContext
57
57
  ): Promise<string> {
58
+ // MCP tools (mcp__<server>__<tool>) are dispatched to their server. They are
59
+ // external context sources — never workspace mutations — so they bypass the
60
+ // built-in name table and the plan-mode write guard below.
61
+ if (ctx.mcpRegistry?.has(call.name) === true) {
62
+ return ctx.mcpRegistry.callTool(call.name, call.arguments);
63
+ }
64
+
58
65
  if (!isToolName(call.name)) {
59
66
  return `unknown tool: ${call.name}`;
60
67
  }
@@ -2,6 +2,7 @@ import { repairArgs } from "../../agent/tool-repair";
2
2
  import type { TsService } from "../../lsp";
3
3
  import type { Reporter } from "../loop.types";
4
4
  import type { SessionSnapshotStore } from "../../files/hashline";
5
+ import type { McpRegistry } from "../../mcp";
5
6
 
6
7
  export interface IToolContext {
7
8
  cwd: string;
@@ -28,6 +29,10 @@ export interface IToolContext {
28
29
  readOnly?: boolean;
29
30
  /** Hashline snapshot store for stale-anchor recovery (per-session, lazily initialized). */
30
31
  snapshotStore?: SessionSnapshotStore;
32
+ /** Connected MCP servers. When present, `mcp__<server>__<tool>` calls are routed
33
+ * here. These are external context/tool sources — they never touch the editable
34
+ * scope or the deterministic gate. Absent ⇒ no MCP configured. */
35
+ mcpRegistry?: McpRegistry;
31
36
  }
32
37
 
33
38
  /** A required string arg, or "" if missing/wrong-type. */
package/src/loop/turn.ts CHANGED
@@ -32,6 +32,7 @@ import {
32
32
  TOOL_NAME,
33
33
  } from "../agent";
34
34
  import { TsService, type ITsDiagnostic } from "../lsp";
35
+ import type { McpRegistry } from "../mcp";
35
36
  import type { FileLinter, IFileLintProblem } from "../detect-gate";
36
37
  import {
37
38
  buildMetaRuleContext,
@@ -115,6 +116,9 @@ export interface ILoopCtx {
115
116
  /** PLAN MODE (set via Session.setPlanMode): threaded into the tool context so
116
117
  * mutating tools are rejected at dispatch — the model only plans. */
117
118
  readOnly?: boolean;
119
+ /** Connected MCP servers (opt-in via tsforge.config.json `mcpServers`). Threaded
120
+ * into the tool context so `mcp__<server>__<tool>` calls dispatch to them. */
121
+ mcpRegistry?: McpRegistry;
118
122
  }
119
123
 
120
124
  /** Mutable state threaded across turns (the gradient the loop descends). */
@@ -448,6 +452,9 @@ export async function runToolCalls(
448
452
  ...(ctx.signal === undefined ? {} : { signal: ctx.signal }),
449
453
  ...(ctx.setupWeb === undefined ? {} : { setupWeb: ctx.setupWeb }),
450
454
  ...(ctx.readOnly === undefined ? {} : { readOnly: ctx.readOnly }),
455
+ ...(ctx.mcpRegistry === undefined
456
+ ? {}
457
+ : { mcpRegistry: ctx.mcpRegistry }),
451
458
  });
452
459
 
453
460
  let feedback = "";
@@ -0,0 +1,106 @@
1
+ import { isRecord } from "../lib/guards";
2
+ import type { IMcpServerConfig } from "./mcp.types";
3
+
4
+ type EnvLookup = Readonly<Record<string, string | undefined>>;
5
+
6
+ /** Interpolate `${VAR}` references from `env` into a string (missing → ""). */
7
+ export function interpolateEnv(value: string, env: EnvLookup): string {
8
+ return value.replace(
9
+ /\$\{([A-Za-z0-9_]+)\}/g,
10
+ (_match: string, name: string) => env[name] ?? ""
11
+ );
12
+ }
13
+
14
+ function stringArray(value: unknown, env: EnvLookup): string[] | undefined {
15
+ if (!Array.isArray(value)) {
16
+ return undefined;
17
+ }
18
+
19
+ const out = value
20
+ .filter((item): item is string => typeof item === "string")
21
+ .map((item) => interpolateEnv(item, env));
22
+
23
+ return out.length > 0 ? out : undefined;
24
+ }
25
+
26
+ function envRecord(
27
+ value: unknown,
28
+ env: EnvLookup
29
+ ): Record<string, string> | undefined {
30
+ if (!isRecord(value)) {
31
+ return undefined;
32
+ }
33
+
34
+ const out: Record<string, string> = {};
35
+
36
+ for (const [key, raw] of Object.entries(value)) {
37
+ if (typeof raw === "string") {
38
+ out[key] = interpolateEnv(raw, env);
39
+ }
40
+ }
41
+
42
+ return Object.keys(out).length > 0 ? out : undefined;
43
+ }
44
+
45
+ /** Parse one server entry, applying env interpolation. Returns null if the entry
46
+ * is malformed or missing the field its transport requires. */
47
+ function parseOne(value: unknown, env: EnvLookup): IMcpServerConfig | null {
48
+ if (!isRecord(value)) {
49
+ return null;
50
+ }
51
+
52
+ const type = value.type === "http" ? "http" : "stdio";
53
+ const command =
54
+ typeof value.command === "string"
55
+ ? interpolateEnv(value.command, env)
56
+ : undefined;
57
+ const url =
58
+ typeof value.url === "string" ? interpolateEnv(value.url, env) : undefined;
59
+ const timeoutMs =
60
+ typeof value.timeoutMs === "number" && value.timeoutMs > 0
61
+ ? value.timeoutMs
62
+ : undefined;
63
+
64
+ if (type === "stdio" && (command === undefined || command.length === 0)) {
65
+ return null;
66
+ }
67
+
68
+ if (type === "http" && (url === undefined || url.length === 0)) {
69
+ return null;
70
+ }
71
+
72
+ return {
73
+ type,
74
+ command,
75
+ args: stringArray(value.args, env),
76
+ env: envRecord(value.env, env),
77
+ url,
78
+ timeoutMs,
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Parse the `mcpServers` block of tsforge.config.json into validated configs,
84
+ * keyed by server name. Malformed or incomplete entries are dropped (the loader
85
+ * warns); a missing/invalid block yields {}.
86
+ */
87
+ export function parseMcpServers(
88
+ raw: unknown,
89
+ env: EnvLookup
90
+ ): Record<string, IMcpServerConfig> {
91
+ if (!isRecord(raw)) {
92
+ return {};
93
+ }
94
+
95
+ const servers: Record<string, IMcpServerConfig> = {};
96
+
97
+ for (const [name, value] of Object.entries(raw)) {
98
+ const parsed = parseOne(value, env);
99
+
100
+ if (parsed !== null) {
101
+ servers[name] = parsed;
102
+ }
103
+ }
104
+
105
+ return servers;
106
+ }
@@ -0,0 +1,11 @@
1
+ export type {
2
+ IMcpServerConfig,
3
+ IMcpToolInfo,
4
+ IMcpTransport,
5
+ } from "./mcp.types";
6
+ export { McpRegistry } from "./registry";
7
+ export { connectMcpServers } from "./setup";
8
+ export { StdioMcpTransport } from "./stdio-transport";
9
+ export { parseMcpServers, interpolateEnv } from "./config";
10
+ export { mcpToolName, mapMcpTool, type IToolSchema } from "./schema-mapping";
11
+ export { LineDecoder, encodeMessage } from "./jsonrpc";
@@ -0,0 +1,74 @@
1
+ import { isRecord } from "../lib/guards";
2
+ import type { IJsonRpcResponse } from "./mcp.types";
3
+
4
+ /** Encode a JSON-RPC message as a single newline-terminated line.
5
+ * MCP's stdio transport frames each message as one line of UTF-8 JSON. */
6
+ export function encodeMessage(message: unknown): string {
7
+ return `${JSON.stringify(message)}\n`;
8
+ }
9
+
10
+ /**
11
+ * Incremental decoder for newline-delimited JSON. Feed it raw stdout chunks
12
+ * (which may split or join messages); it returns whichever complete JSON values
13
+ * are now available and retains any partial trailing line. Malformed lines are
14
+ * skipped rather than throwing, so one bad line can't wedge the stream.
15
+ */
16
+ export class LineDecoder {
17
+ private buffer = "";
18
+
19
+ push(chunk: string): unknown[] {
20
+ this.buffer += chunk;
21
+
22
+ const out: unknown[] = [];
23
+ let newline = this.buffer.indexOf("\n");
24
+
25
+ while (newline !== -1) {
26
+ const line = this.buffer.slice(0, newline).trim();
27
+
28
+ this.buffer = this.buffer.slice(newline + 1);
29
+
30
+ if (line.length > 0) {
31
+ const parsed = tryParse(line);
32
+
33
+ if (parsed !== undefined) {
34
+ out.push(parsed);
35
+ }
36
+ }
37
+
38
+ newline = this.buffer.indexOf("\n");
39
+ }
40
+
41
+ return out;
42
+ }
43
+ }
44
+
45
+ function tryParse(line: string): unknown {
46
+ try {
47
+ return JSON.parse(line);
48
+ } catch {
49
+ return undefined;
50
+ }
51
+ }
52
+
53
+ /** Type guard for a JSON-RPC response carrying the given request id. */
54
+ export function isResponseFor(
55
+ value: unknown,
56
+ id: number
57
+ ): value is IJsonRpcResponse {
58
+ return isRecord(value) && value.jsonrpc === "2.0" && value.id === id;
59
+ }
60
+
61
+ /** Extract a human-readable error string from a JSON-RPC error member. */
62
+ export function errorText(value: unknown): string | null {
63
+ if (!isRecord(value)) {
64
+ return null;
65
+ }
66
+
67
+ const error = value.error;
68
+
69
+ if (isRecord(error) && typeof error.message === "string") {
70
+ return error.message;
71
+ }
72
+
73
+ return null;
74
+ }
@@ -0,0 +1,66 @@
1
+ /** JSON-RPC 2.0 request (has an id; expects a response). */
2
+ export interface IJsonRpcRequest {
3
+ readonly jsonrpc: "2.0";
4
+ readonly id: number;
5
+ readonly method: string;
6
+ readonly params?: unknown;
7
+ }
8
+
9
+ /** JSON-RPC 2.0 notification (no id; fire-and-forget). */
10
+ export interface IJsonRpcNotification {
11
+ readonly jsonrpc: "2.0";
12
+ readonly method: string;
13
+ readonly params?: unknown;
14
+ }
15
+
16
+ /** JSON-RPC 2.0 response (result xor error). */
17
+ export interface IJsonRpcResponse {
18
+ readonly jsonrpc: "2.0";
19
+ readonly id: number;
20
+ readonly result?: unknown;
21
+ readonly error?: { readonly code: number; readonly message: string };
22
+ }
23
+
24
+ /** A tool advertised by an MCP server (one `tools/list` entry). */
25
+ export interface IMcpToolInfo {
26
+ readonly name: string;
27
+ readonly description?: string;
28
+ /** JSON Schema for the tool's arguments. */
29
+ readonly inputSchema: Record<string, unknown>;
30
+ }
31
+
32
+ /**
33
+ * A connection to a single MCP server. The stdio implementation spawns a child
34
+ * process; tests use an in-memory fake. Methods reject on protocol/transport
35
+ * failure — the registry decides how to degrade so a dead server never breaks
36
+ * the agent loop.
37
+ */
38
+ export interface IMcpTransport {
39
+ /** Open the connection and perform the MCP `initialize` handshake. */
40
+ connect(): Promise<void>;
41
+
42
+ /** List the server's tools (`tools/list`). */
43
+ listTools(): Promise<IMcpToolInfo[]>;
44
+
45
+ /** Call a tool (`tools/call`) and return its result rendered as text. */
46
+ callTool(name: string, args: Record<string, unknown>): Promise<string>;
47
+
48
+ /** Shut the connection down. */
49
+ close(): Promise<void>;
50
+ }
51
+
52
+ /** Config for one MCP server, declared under `mcpServers` in tsforge.config.json. */
53
+ export interface IMcpServerConfig {
54
+ /** Transport kind. `stdio` (default) spawns `command`; `http` POSTs to `url`. */
55
+ readonly type?: "stdio" | "http";
56
+ /** stdio: executable to spawn. */
57
+ readonly command?: string;
58
+ /** stdio: arguments for `command`. */
59
+ readonly args?: readonly string[];
60
+ /** stdio: extra environment variables (merged over the parent env). */
61
+ readonly env?: Readonly<Record<string, string>>;
62
+ /** http: server URL. */
63
+ readonly url?: string;
64
+ /** Per-call timeout in milliseconds (default 30000). */
65
+ readonly timeoutMs?: number;
66
+ }
@@ -0,0 +1,91 @@
1
+ import type { IMcpTransport } from "./mcp.types";
2
+ import { mapMcpTool, mcpToolName, type IToolSchema } from "./schema-mapping";
3
+
4
+ interface IRegisteredTool {
5
+ readonly server: string;
6
+ readonly toolName: string;
7
+ readonly transport: IMcpTransport;
8
+ }
9
+
10
+ /**
11
+ * Holds the live MCP connections and the tools they expose. Tools are advertised
12
+ * to the model under a namespaced `mcp__<server>__<tool>` name and dispatched back
13
+ * to the owning transport. Designed to fail soft: a tool call that throws is
14
+ * returned as text (the model sees a tool failure and adapts) rather than
15
+ * crashing the agent loop.
16
+ */
17
+ export class McpRegistry {
18
+ private readonly schemas: IToolSchema[] = [];
19
+ private readonly byName = new Map<string, IRegisteredTool>();
20
+ private readonly transports: IMcpTransport[] = [];
21
+
22
+ /** Connect a server, list its tools, and register each under its namespaced
23
+ * name. Returns the number of tools registered. May reject if the server
24
+ * fails to connect or list — the caller catches per-server so one bad server
25
+ * does not abort startup. */
26
+ async addServer(server: string, transport: IMcpTransport): Promise<number> {
27
+ await transport.connect();
28
+ this.transports.push(transport);
29
+
30
+ const tools = await transport.listTools();
31
+ let count = 0;
32
+
33
+ for (const tool of tools) {
34
+ const name = mcpToolName(server, tool.name);
35
+
36
+ if (this.byName.has(name)) {
37
+ continue;
38
+ }
39
+
40
+ this.byName.set(name, { server, toolName: tool.name, transport });
41
+ this.schemas.push(mapMcpTool(server, tool));
42
+ count += 1;
43
+ }
44
+
45
+ return count;
46
+ }
47
+
48
+ /** Tool schemas to append to the model's advertised tool list. */
49
+ toolSchemas(): IToolSchema[] {
50
+ return [...this.schemas];
51
+ }
52
+
53
+ /** Number of registered MCP tools. */
54
+ get size(): number {
55
+ return this.byName.size;
56
+ }
57
+
58
+ /** True if `name` is a registered MCP tool. */
59
+ has(name: string): boolean {
60
+ return this.byName.has(name);
61
+ }
62
+
63
+ /** Call a registered MCP tool. Never throws: a transport failure is returned
64
+ * as text so the model treats it as a tool result. */
65
+ async callTool(name: string, args: Record<string, unknown>): Promise<string> {
66
+ const entry = this.byName.get(name);
67
+
68
+ if (entry === undefined) {
69
+ return `unknown MCP tool: ${name}`;
70
+ }
71
+
72
+ try {
73
+ return await entry.transport.callTool(entry.toolName, args);
74
+ } catch (err) {
75
+ const message = err instanceof Error ? err.message : String(err);
76
+
77
+ return `MCP tool '${name}' failed: ${message}`;
78
+ }
79
+ }
80
+
81
+ /** Close every connected transport (best effort). */
82
+ async closeAll(): Promise<void> {
83
+ for (const transport of this.transports) {
84
+ try {
85
+ await transport.close();
86
+ } catch {
87
+ // best effort — a server that already died needs no clean shutdown
88
+ }
89
+ }
90
+ }
91
+ }
@@ -0,0 +1,45 @@
1
+ import type { IMcpToolInfo } from "./mcp.types";
2
+
3
+ /** A tool schema in the OpenAI "function" wire shape the model is given. */
4
+ export interface IToolSchema {
5
+ readonly type: "function";
6
+ readonly function: {
7
+ readonly name: string;
8
+ readonly description: string;
9
+ readonly parameters: Record<string, unknown>;
10
+ };
11
+ }
12
+
13
+ /** OpenAI function names allow [a-zA-Z0-9_-]; replace anything else with "_". */
14
+ function sanitize(value: string): string {
15
+ return value.replace(/[^a-zA-Z0-9_-]/g, "_");
16
+ }
17
+
18
+ /** The namespaced name advertised to the model for an MCP tool. The double-underscore
19
+ * separators mirror the conventional `mcp__<server>__<tool>` form. */
20
+ export function mcpToolName(server: string, tool: string): string {
21
+ return `mcp__${sanitize(server)}__${sanitize(tool)}`;
22
+ }
23
+
24
+ /** A tool with no declared parameters still needs a valid object schema. */
25
+ function paramsOrEmptyObject(
26
+ schema: Record<string, unknown>
27
+ ): Record<string, unknown> {
28
+ if (Object.keys(schema).length === 0) {
29
+ return { type: "object", properties: {} };
30
+ }
31
+
32
+ return schema;
33
+ }
34
+
35
+ /** Map one MCP tool to the OpenAI function schema the model is given. */
36
+ export function mapMcpTool(server: string, tool: IMcpToolInfo): IToolSchema {
37
+ return {
38
+ type: "function",
39
+ function: {
40
+ name: mcpToolName(server, tool.name),
41
+ description: tool.description ?? `${server}: ${tool.name}`,
42
+ parameters: paramsOrEmptyObject(tool.inputSchema),
43
+ },
44
+ };
45
+ }
@@ -0,0 +1,53 @@
1
+ import type { IMcpServerConfig } from "./mcp.types";
2
+ import { McpRegistry } from "./registry";
3
+ import { StdioMcpTransport } from "./stdio-transport";
4
+
5
+ /**
6
+ * Connect every configured MCP server and return a registry, or null if none are
7
+ * configured or none connected. Per-server failures are reported and skipped so
8
+ * a single bad server can never block the run. Only the stdio transport is wired
9
+ * today; http entries are reported and skipped.
10
+ */
11
+ export async function connectMcpServers(
12
+ servers: Readonly<Record<string, IMcpServerConfig>>,
13
+ report: (message: string) => void
14
+ ): Promise<McpRegistry | null> {
15
+ const names = Object.keys(servers);
16
+
17
+ if (names.length === 0) {
18
+ return null;
19
+ }
20
+
21
+ const registry = new McpRegistry();
22
+
23
+ for (const name of names) {
24
+ const config = servers[name];
25
+
26
+ if (config === undefined) {
27
+ continue;
28
+ }
29
+
30
+ if (config.type === "http") {
31
+ report(
32
+ `MCP server '${name}': http transport not yet supported (stdio only)`
33
+ );
34
+
35
+ continue;
36
+ }
37
+
38
+ try {
39
+ const count = await registry.addServer(
40
+ name,
41
+ new StdioMcpTransport(name, config)
42
+ );
43
+
44
+ report(`MCP server '${name}': ${count} tool(s) registered`);
45
+ } catch (err) {
46
+ const message = err instanceof Error ? err.message : String(err);
47
+
48
+ report(`MCP server '${name}' failed to connect: ${message}`);
49
+ }
50
+ }
51
+
52
+ return registry.size > 0 ? registry : null;
53
+ }
@@ -0,0 +1,209 @@
1
+ import { isRecord } from "../lib/guards";
2
+ import { LineDecoder, encodeMessage, errorText } from "./jsonrpc";
3
+ import type {
4
+ IMcpServerConfig,
5
+ IMcpToolInfo,
6
+ IMcpTransport,
7
+ } from "./mcp.types";
8
+
9
+ const PROTOCOL_VERSION = "2024-11-05";
10
+ const DEFAULT_TIMEOUT_MS = 30000;
11
+
12
+ interface IPending {
13
+ readonly resolve: (value: unknown) => void;
14
+ readonly reject: (err: Error) => void;
15
+ readonly timer: ReturnType<typeof setTimeout>;
16
+ }
17
+
18
+ /** Render an MCP `tools/list` result into typed tool info, dropping bad entries. */
19
+ function extractTools(result: unknown): IMcpToolInfo[] {
20
+ if (!isRecord(result) || !Array.isArray(result.tools)) {
21
+ return [];
22
+ }
23
+
24
+ const tools: IMcpToolInfo[] = [];
25
+
26
+ for (const entry of result.tools) {
27
+ if (!isRecord(entry) || typeof entry.name !== "string") {
28
+ continue;
29
+ }
30
+
31
+ tools.push({
32
+ name: entry.name,
33
+ description:
34
+ typeof entry.description === "string" ? entry.description : undefined,
35
+ inputSchema: isRecord(entry.inputSchema) ? entry.inputSchema : {},
36
+ });
37
+ }
38
+
39
+ return tools;
40
+ }
41
+
42
+ /** Render an MCP `tools/call` result's content array into plain text. */
43
+ function extractText(result: unknown): string {
44
+ if (!isRecord(result) || !Array.isArray(result.content)) {
45
+ return JSON.stringify(result);
46
+ }
47
+
48
+ const parts: string[] = [];
49
+
50
+ for (const item of result.content) {
51
+ if (isRecord(item) && typeof item.text === "string") {
52
+ parts.push(item.text);
53
+ }
54
+ }
55
+
56
+ return parts.length > 0 ? parts.join("\n") : JSON.stringify(result);
57
+ }
58
+
59
+ /**
60
+ * MCP transport over a child process's stdio, framed as newline-delimited
61
+ * JSON-RPC. Performs the `initialize` handshake on connect, multiplexes requests
62
+ * by id, and enforces a per-call timeout so a hung server cannot stall the loop.
63
+ */
64
+ export class StdioMcpTransport implements IMcpTransport {
65
+ private proc: Bun.Subprocess<"pipe", "pipe", "inherit"> | null = null;
66
+ private readonly decoder = new LineDecoder();
67
+ private readonly pending = new Map<number, IPending>();
68
+ private readonly timeoutMs: number;
69
+ private nextId = 0;
70
+
71
+ constructor(
72
+ private readonly name: string,
73
+ private readonly config: IMcpServerConfig
74
+ ) {
75
+ this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
76
+ }
77
+
78
+ async connect(): Promise<void> {
79
+ if (this.config.command === undefined) {
80
+ throw new Error(`MCP server '${this.name}' has no command`);
81
+ }
82
+
83
+ const proc = Bun.spawn({
84
+ cmd: [this.config.command, ...(this.config.args ?? [])],
85
+ env: { ...process.env, ...(this.config.env ?? {}) },
86
+ stdin: "pipe",
87
+ stdout: "pipe",
88
+ stderr: "inherit",
89
+ });
90
+
91
+ this.proc = proc;
92
+ void this.readLoop(proc.stdout);
93
+
94
+ await this.request("initialize", {
95
+ protocolVersion: PROTOCOL_VERSION,
96
+ capabilities: {},
97
+ clientInfo: { name: "tsforge", version: "0" },
98
+ });
99
+ this.notify("notifications/initialized", {});
100
+ }
101
+
102
+ async listTools(): Promise<IMcpToolInfo[]> {
103
+ return extractTools(await this.request("tools/list", {}));
104
+ }
105
+
106
+ async callTool(name: string, args: Record<string, unknown>): Promise<string> {
107
+ return extractText(
108
+ await this.request("tools/call", { name, arguments: args })
109
+ );
110
+ }
111
+
112
+ close(): Promise<void> {
113
+ for (const pending of this.pending.values()) {
114
+ clearTimeout(pending.timer);
115
+ pending.reject(new Error("transport closed"));
116
+ }
117
+
118
+ this.pending.clear();
119
+ this.proc?.kill();
120
+ this.proc = null;
121
+
122
+ return Promise.resolve();
123
+ }
124
+
125
+ private async readLoop(stdout: ReadableStream<Uint8Array>): Promise<void> {
126
+ const reader = stdout.getReader();
127
+ const textDecoder = new TextDecoder();
128
+ let done = false;
129
+
130
+ while (!done) {
131
+ const result = await reader.read();
132
+
133
+ done = result.done;
134
+
135
+ if (result.value !== undefined) {
136
+ const text = textDecoder.decode(result.value, { stream: true });
137
+
138
+ for (const message of this.decoder.push(text)) {
139
+ this.handleMessage(message);
140
+ }
141
+ }
142
+ }
143
+ }
144
+
145
+ private handleMessage(message: unknown): void {
146
+ if (!isRecord(message) || typeof message.id !== "number") {
147
+ return;
148
+ }
149
+
150
+ const pending = this.pending.get(message.id);
151
+
152
+ if (pending === undefined) {
153
+ return;
154
+ }
155
+
156
+ clearTimeout(pending.timer);
157
+ this.pending.delete(message.id);
158
+
159
+ const err = errorText(message);
160
+
161
+ if (err !== null) {
162
+ pending.reject(new Error(err));
163
+
164
+ return;
165
+ }
166
+
167
+ pending.resolve(message.result);
168
+ }
169
+
170
+ private request(method: string, params: unknown): Promise<unknown> {
171
+ const proc = this.proc;
172
+
173
+ if (proc === null) {
174
+ return Promise.reject(new Error("transport not connected"));
175
+ }
176
+
177
+ const id = this.nextId;
178
+
179
+ this.nextId += 1;
180
+
181
+ return new Promise<unknown>((resolve, reject) => {
182
+ const timer = setTimeout(() => {
183
+ this.pending.delete(id);
184
+ reject(
185
+ new Error(
186
+ `MCP request '${method}' timed out after ${this.timeoutMs}ms`
187
+ )
188
+ );
189
+ }, this.timeoutMs);
190
+
191
+ this.pending.set(id, { resolve, reject, timer });
192
+ void proc.stdin.write(
193
+ encodeMessage({ jsonrpc: "2.0", id, method, params })
194
+ );
195
+ void proc.stdin.flush();
196
+ });
197
+ }
198
+
199
+ private notify(method: string, params: unknown): void {
200
+ const proc = this.proc;
201
+
202
+ if (proc === null) {
203
+ return;
204
+ }
205
+
206
+ void proc.stdin.write(encodeMessage({ jsonrpc: "2.0", method, params }));
207
+ void proc.stdin.flush();
208
+ }
209
+ }