@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 +1 -1
- package/src/config/external-plugins.ts +152 -0
- package/src/config/tsforge-config.ts +74 -31
- package/src/loop/run.ts +20 -4
- package/src/loop/session.ts +36 -2
- package/src/loop/tools/execute-tool.ts +7 -0
- package/src/loop/tools/tool-context.ts +5 -0
- package/src/loop/turn.ts +7 -0
- package/src/mcp/config.ts +106 -0
- package/src/mcp/index.ts +11 -0
- package/src/mcp/jsonrpc.ts +74 -0
- package/src/mcp/mcp.types.ts +66 -0
- package/src/mcp/registry.ts +91 -0
- package/src/mcp/schema-mapping.ts +45 -0
- package/src/mcp/setup.ts +53 -0
- package/src/mcp/stdio-transport.ts +209 -0
- package/src/rule-packs/index.ts +27 -1
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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",
|
package/src/loop/session.ts
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
+
}
|
package/src/mcp/index.ts
ADDED
|
@@ -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
|
+
}
|
package/src/mcp/setup.ts
ADDED
|
@@ -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
|
+
}
|
package/src/rule-packs/index.ts
CHANGED
|
@@ -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 =
|
|
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) {
|