@agjs/tsforge 0.1.8 → 0.1.10

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.10",
5
5
  "license": "MIT",
6
6
  "description": "TypeScript coding harness with a deterministic gate, stack-aware guardrails, and stream-level correction.",
7
7
  "repository": {
@@ -0,0 +1,152 @@
1
+ import { resolve } from "node:path";
2
+ import { isRecord } from "../lib/guards";
3
+ import { registerExternalPack } from "../rule-packs";
4
+ import type { IRulePack } from "../rule-packs/rule-packs.types";
5
+
6
+ /** One external plugin entry from tsforge.config.json `plugins`. */
7
+ export interface IExternalPlugin {
8
+ /** Module specifier or path (relative paths resolve against the repo root). */
9
+ readonly path: string;
10
+ /** Named exports to load as rule packs. Omit to load every exported pack. */
11
+ readonly packs?: readonly string[];
12
+ }
13
+
14
+ function errMessage(err: unknown): string {
15
+ return err instanceof Error ? err.message : String(err);
16
+ }
17
+
18
+ /** Type guard: a well-formed IRulePack (no `as` — every field is checked). */
19
+ export function isRulePack(value: unknown): value is IRulePack {
20
+ if (!isRecord(value)) {
21
+ return false;
22
+ }
23
+
24
+ if (typeof value.id !== "string" || value.id.length === 0) {
25
+ return false;
26
+ }
27
+
28
+ if (typeof value.description !== "string") {
29
+ return false;
30
+ }
31
+
32
+ if (!isRecord(value.rules) || !isRecord(value.rulesConfig)) {
33
+ return false;
34
+ }
35
+
36
+ for (const severity of Object.values(value.rulesConfig)) {
37
+ if (severity !== "error" && severity !== "warn") {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ return true;
43
+ }
44
+
45
+ /** Parse the `plugins` config field into validated entries. */
46
+ export function parsePlugins(raw: unknown): IExternalPlugin[] {
47
+ if (!Array.isArray(raw)) {
48
+ return [];
49
+ }
50
+
51
+ const plugins: IExternalPlugin[] = [];
52
+
53
+ for (const item of raw) {
54
+ if (
55
+ !isRecord(item) ||
56
+ typeof item.path !== "string" ||
57
+ item.path.length === 0
58
+ ) {
59
+ continue;
60
+ }
61
+
62
+ const packs = Array.isArray(item.packs)
63
+ ? item.packs.filter((p): p is string => typeof p === "string")
64
+ : undefined;
65
+
66
+ plugins.push({
67
+ path: item.path,
68
+ ...(packs !== undefined && packs.length > 0 ? { packs } : {}),
69
+ });
70
+ }
71
+
72
+ return plugins;
73
+ }
74
+
75
+ /** Collect the candidate exports to validate from a loaded module. */
76
+ function candidateExports(
77
+ mod: Record<string, unknown>,
78
+ names: readonly string[] | undefined
79
+ ): unknown[] {
80
+ if (names === undefined) {
81
+ return Object.values(mod);
82
+ }
83
+
84
+ return names.map((name) => mod[name]);
85
+ }
86
+
87
+ /**
88
+ * Dynamically import each plugin and collect its valid exported rule packs.
89
+ * Never throws — an unimportable module or an export that is not a valid pack is
90
+ * reported and skipped, so a broken plugin can't take down a run.
91
+ */
92
+ export async function loadExternalPacks(
93
+ plugins: readonly IExternalPlugin[],
94
+ cwd: string,
95
+ report: (message: string) => void
96
+ ): Promise<IRulePack[]> {
97
+ const out: IRulePack[] = [];
98
+
99
+ for (const plugin of plugins) {
100
+ const specifier = plugin.path.startsWith(".")
101
+ ? resolve(cwd, plugin.path)
102
+ : plugin.path;
103
+
104
+ let mod: unknown;
105
+
106
+ try {
107
+ mod = await import(specifier);
108
+ } catch (err) {
109
+ report(`plugin '${plugin.path}' failed to load: ${errMessage(err)}`);
110
+
111
+ continue;
112
+ }
113
+
114
+ if (!isRecord(mod)) {
115
+ continue;
116
+ }
117
+
118
+ for (const candidate of candidateExports(mod, plugin.packs)) {
119
+ if (isRulePack(candidate)) {
120
+ out.push(candidate);
121
+ report(`plugin '${plugin.path}': loaded pack '${candidate.id}'`);
122
+ } else {
123
+ report(
124
+ `plugin '${plugin.path}': an export is not a valid rule pack — skipped`
125
+ );
126
+ }
127
+ }
128
+ }
129
+
130
+ return out;
131
+ }
132
+
133
+ /**
134
+ * Load every configured plugin, register its packs in the rule-pack registry,
135
+ * and return the registered pack ids (to fold into the active pack list so the
136
+ * gate runs them). Never throws.
137
+ */
138
+ export async function loadAndRegisterPlugins(
139
+ plugins: readonly IExternalPlugin[],
140
+ cwd: string,
141
+ report: (message: string) => void
142
+ ): Promise<string[]> {
143
+ const packs = await loadExternalPacks(plugins, cwd, report);
144
+ const ids: string[] = [];
145
+
146
+ for (const pack of packs) {
147
+ registerExternalPack(pack);
148
+ ids.push(pack.id);
149
+ }
150
+
151
+ return ids;
152
+ }
@@ -1,6 +1,8 @@
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";
5
+ import { parsePlugins, type IExternalPlugin } from "./external-plugins";
4
6
 
5
7
  /**
6
8
  * User-defined configuration from tsforge.config.json
@@ -23,6 +25,20 @@ export interface ITsforgeProjectConfig {
23
25
  * Values: "error" | "warn" | "off" (off silences the rule).
24
26
  */
25
27
  readonly rules?: Readonly<Record<string, "error" | "warn" | "off">>;
28
+
29
+ /**
30
+ * External MCP (Model Context Protocol) servers whose tools are offered to the
31
+ * agent. Keyed by a short server name. `${VAR}` references in string values are
32
+ * interpolated from the environment at load time. Opt-in: absent ⇒ no MCP.
33
+ */
34
+ readonly mcpServers?: Readonly<Record<string, IMcpServerConfig>>;
35
+
36
+ /**
37
+ * External plugins providing extra rule packs, loaded without recompiling
38
+ * tsforge. Each entry names a module (or relative path) and, optionally, which
39
+ * exported packs to use. Opt-in: absent ⇒ only built-in packs.
40
+ */
41
+ readonly plugins?: readonly IExternalPlugin[];
26
42
  }
27
43
 
28
44
  function warnConfig(msg: string): void {
@@ -158,6 +174,63 @@ function validateRules(
158
174
  return Object.keys(rulesFields).length > 0 ? rulesFields : undefined;
159
175
  }
160
176
 
177
+ /** Validate each known field of an already-parsed config object, dropping any
178
+ * that fail their per-field validator. Kept separate from the file IO so the
179
+ * loader stays simple. */
180
+ function buildConfigFields(
181
+ parsed: Record<string, unknown>
182
+ ): ITsforgeProjectConfig {
183
+ const configFields: {
184
+ stack?: string;
185
+ packs?: { include?: readonly string[]; exclude?: readonly string[] };
186
+ rules?: Record<string, "error" | "warn" | "off">;
187
+ mcpServers?: Record<string, IMcpServerConfig>;
188
+ plugins?: readonly IExternalPlugin[];
189
+ } = {};
190
+
191
+ if (parsed.stack !== undefined) {
192
+ const stack = validateStack(parsed.stack);
193
+
194
+ if (stack !== undefined) {
195
+ configFields.stack = stack;
196
+ }
197
+ }
198
+
199
+ if (parsed.packs !== undefined) {
200
+ const packs = validatePacks(parsed.packs);
201
+
202
+ if (packs !== undefined) {
203
+ configFields.packs = packs;
204
+ }
205
+ }
206
+
207
+ if (parsed.rules !== undefined) {
208
+ const rules = validateRules(parsed.rules);
209
+
210
+ if (rules !== undefined) {
211
+ configFields.rules = rules;
212
+ }
213
+ }
214
+
215
+ if (parsed.mcpServers !== undefined) {
216
+ const servers = parseMcpServers(parsed.mcpServers, process.env);
217
+
218
+ if (Object.keys(servers).length > 0) {
219
+ configFields.mcpServers = servers;
220
+ }
221
+ }
222
+
223
+ if (parsed.plugins !== undefined) {
224
+ const plugins = parsePlugins(parsed.plugins);
225
+
226
+ if (plugins.length > 0) {
227
+ configFields.plugins = plugins;
228
+ }
229
+ }
230
+
231
+ return configFields;
232
+ }
233
+
161
234
  /** Load tsforge.config.json from cwd, defaulting to empty config on missing/invalid files. */
162
235
  export async function loadTsforgeConfig(
163
236
  cwd: string
@@ -181,37 +254,7 @@ export async function loadTsforgeConfig(
181
254
  return {};
182
255
  }
183
256
 
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;
257
+ return buildConfigFields(parsed);
215
258
  } catch (err) {
216
259
  if (err instanceof SyntaxError) {
217
260
  const firstLine = err.message.split("\n")[0];
package/src/loop/run.ts CHANGED
@@ -191,20 +191,31 @@ function effectiveParserFor(
191
191
  return flags.legacyFeedback() ? parseEslintJson : parse;
192
192
  }
193
193
 
194
- /** Detect the stack and fold in tsforge.config.json pack/rule overrides. */
195
- async function resolveStackForRun(cwd: string): Promise<{
194
+ /** Detect the stack and fold in tsforge.config.json pack/rule overrides, plus any
195
+ * rule packs from configured external plugins. */
196
+ async function resolveStackForRun(
197
+ cwd: string,
198
+ report: (message: string) => void
199
+ ): Promise<{
196
200
  stackProfile: Awaited<ReturnType<typeof detectStack>>;
197
201
  ruleOverrides: Readonly<Record<string, "error" | "warn" | "off">>;
198
202
  }> {
199
203
  const detectedProfile = await detectStack(cwd);
200
204
  const { loadTsforgeConfig, resolveActivePacks, normalizeRuleOverrides } =
201
205
  await import("../config/tsforge-config");
206
+ const { loadAndRegisterPlugins } = await import("../config/external-plugins");
202
207
  const cfg = await loadTsforgeConfig(cwd);
208
+ const activePacks = resolveActivePacks(detectedProfile.packs, cfg);
209
+ const externalIds =
210
+ cfg.plugins === undefined
211
+ ? []
212
+ : await loadAndRegisterPlugins(cfg.plugins, cwd, report);
203
213
 
204
214
  return {
205
215
  stackProfile: {
206
216
  ...detectedProfile,
207
- packs: resolveActivePacks(detectedProfile.packs, cfg),
217
+ packs:
218
+ externalIds.length > 0 ? [...activePacks, ...externalIds] : activePacks,
208
219
  },
209
220
  ruleOverrides: normalizeRuleOverrides(cfg),
210
221
  };
@@ -268,7 +279,12 @@ export async function runTask(
268
279
  });
269
280
 
270
281
  // Detect stack once per run, early; tsforge.config.json may adjust it
271
- const { stackProfile, ruleOverrides } = await resolveStackForRun(cwd);
282
+ const { stackProfile, ruleOverrides } = await resolveStackForRun(
283
+ cwd,
284
+ (message) => {
285
+ report({ kind: "tool", task: task.id, message });
286
+ }
287
+ );
272
288
 
273
289
  report({
274
290
  kind: "tool",
@@ -25,6 +25,8 @@ import {
25
25
  normalizeRuleOverrides,
26
26
  resolveActivePacks,
27
27
  } from "../config/tsforge-config";
28
+ import { connectMcpServers } from "../mcp";
29
+ import { loadAndRegisterPlugins } from "../config/external-plugins";
28
30
  import { LOOP_LIMITS, RUN_STATUS } from "./loop.constants";
29
31
  import type { Reporter } from "./loop.types";
30
32
  import { CHAT_SYSTEM, COMPACT_SYSTEM } from "./prompt";
@@ -433,12 +435,38 @@ export class Session {
433
435
  // pack selection and rule-severity overrides.
434
436
  const detected = await detectStack(cfg.cwd);
435
437
  const projectConfig = await loadTsforgeConfig(cfg.cwd);
438
+ const activePacks = resolveActivePacks(detected.packs, projectConfig);
439
+ // Opt-in: load rule packs from external plugins and fold their ids into the
440
+ // active packs so the gate runs them. loadAndRegisterPlugins never throws.
441
+ const externalPackIds =
442
+ projectConfig.plugins === undefined
443
+ ? []
444
+ : await loadAndRegisterPlugins(
445
+ projectConfig.plugins,
446
+ cfg.cwd,
447
+ (message) => {
448
+ report({ kind: "tool", task: SESSION_ID, message });
449
+ }
450
+ );
436
451
  const stackProfile = {
437
452
  ...detected,
438
- packs: resolveActivePacks(detected.packs, projectConfig),
453
+ packs:
454
+ externalPackIds.length > 0
455
+ ? [...activePacks, ...externalPackIds]
456
+ : activePacks,
439
457
  };
440
458
  const ruleOverrides = normalizeRuleOverrides(projectConfig);
441
459
 
460
+ // Opt-in: connect any configured MCP servers so their tools are offered to
461
+ // the agent. A bad server is reported and skipped (connectMcpServers never
462
+ // throws), so MCP can never block an interactive session from starting.
463
+ const mcpRegistry =
464
+ projectConfig.mcpServers === undefined
465
+ ? null
466
+ : await connectMcpServers(projectConfig.mcpServers, (message) => {
467
+ report({ kind: "tool", task: SESSION_ID, message });
468
+ });
469
+
442
470
  const ctx: ILoopCtx = {
443
471
  task,
444
472
  cwd: cfg.cwd,
@@ -447,6 +475,7 @@ export class Session {
447
475
  parse: cfg.parse,
448
476
  report,
449
477
  stackProfile,
478
+ ...(mcpRegistry === null ? {} : { mcpRegistry }),
450
479
  ...(Object.keys(ruleOverrides).length > 0 ? { ruleOverrides } : {}),
451
480
  messages:
452
481
  cfg.history !== undefined && cfg.history.length > 0
@@ -916,13 +945,18 @@ export class Session {
916
945
  // enforces a read-only command allowlist) — the model never sees a write
917
946
  // tool. Filtered per call, so `this.tools` is untouched and toggling the
918
947
  // mode off restores the full set with zero bookkeeping.
919
- const offeredTools = this.planMode
948
+ const baseTools = this.planMode
920
949
  ? this.tools.filter(
921
950
  (t) =>
922
951
  READ_ONLY_TOOL_NAMES.has(t.function.name) ||
923
952
  t.function.name === TOOL_NAME.run
924
953
  )
925
954
  : this.tools;
955
+ // MCP tools are external context sources (not workspace writes), so they ride
956
+ // alongside the built-ins even in plan mode — appended after the filter.
957
+ const mcpSchemas = this.ctx.mcpRegistry?.toolSchemas() ?? [];
958
+ const offeredTools =
959
+ mcpSchemas.length > 0 ? [...baseTools, ...mcpSchemas] : baseTools;
926
960
  const res = await this.provider.complete(ctx.messages, {
927
961
  tools: offeredTools,
928
962
  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
+ }
@@ -1,5 +1,6 @@
1
1
  import type { TSESLint } from "@typescript-eslint/utils";
2
2
 
3
+ import type { IRulePack } from "./rule-packs.types";
3
4
  import { bullmqPack } from "./bullmq";
4
5
  import { commentHygienePack } from "./comment-hygiene";
5
6
  import { codeFlowPack } from "./code-flow";
@@ -43,6 +44,31 @@ function isRulePackId(id: unknown): id is IRulePackId {
43
44
  return typeof id === "string" && id in RULE_PACKS;
44
45
  }
45
46
 
47
+ /** Externally-registered rule packs (from tsforge.config.json `plugins`). Kept
48
+ * separate from the built-in RULE_PACKS so a user pack can never shadow a
49
+ * built-in by id; rule-name collisions still fail the build in
50
+ * buildPackEslintConfig. */
51
+ const EXTERNAL_PACKS = new Map<string, IRulePack>();
52
+
53
+ /** Register an external rule pack so its id resolves in buildPackEslintConfig. */
54
+ export function registerExternalPack(pack: IRulePack): void {
55
+ EXTERNAL_PACKS.set(pack.id, pack);
56
+ }
57
+
58
+ /** Drop all registered external packs (used by tests for isolation). */
59
+ export function clearExternalPacks(): void {
60
+ EXTERNAL_PACKS.clear();
61
+ }
62
+
63
+ /** Resolve a pack id to its definition, built-ins first, then external packs. */
64
+ function lookupPack(packId: string): IRulePack | undefined {
65
+ if (isRulePackId(packId)) {
66
+ return RULE_PACKS[packId];
67
+ }
68
+
69
+ return EXTERNAL_PACKS.get(packId);
70
+ }
71
+
46
72
  /** Apply rule overrides: "off" drops a rule, error/warn replaces its severity. */
47
73
  function applyOverrides(
48
74
  mergedRulesConfig: Readonly<Record<string, "error" | "warn">>,
@@ -93,7 +119,7 @@ export function buildPackEslintConfig(
93
119
  const seenRuleNames = new Set<string>();
94
120
 
95
121
  for (const packId of packIds) {
96
- const pack = isRulePackId(packId) ? RULE_PACKS[packId] : undefined;
122
+ const pack = lookupPack(packId);
97
123
 
98
124
  // Skip pack IDs known to stack-detection but absent from RULE_PACKS
99
125
  if (pack === undefined) {