@agjs/tsforge 0.1.7 → 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 +1 -1
- package/src/config/tsforge-config.ts +57 -31
- package/src/loop/rule-docs.generated.json +15 -0
- package/src/loop/session.ts +18 -1
- 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 +2 -0
- package/src/rule-packs/nextjs/index.ts +26 -0
- package/src/rule-packs/nextjs/rules/client-hooks-require-use-client.ts +74 -0
- package/src/rule-packs/nextjs/rules/no-next-head-in-app.ts +36 -0
- package/src/rule-packs/nextjs/rules/no-pages-router-data-fetching-in-app.ts +71 -0
- package/src/rule-packs/nextjs/utils.ts +55 -0
- package/src/stack-detection/packs.ts +11 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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];
|
|
@@ -299,6 +299,21 @@
|
|
|
299
299
|
"bad": "// Example that violates the rule",
|
|
300
300
|
"good": "// Corrected version"
|
|
301
301
|
},
|
|
302
|
+
"tsforge/client-hooks-require-use-client": {
|
|
303
|
+
"what": "Require the 'use client' directive in app-router page/layout/template files that call client-only hooks. Server Components cannot use state/effect/navigation hooks — doing so crashes at runtime.",
|
|
304
|
+
"bad": "// Example that violates the rule",
|
|
305
|
+
"good": "// Corrected version"
|
|
306
|
+
},
|
|
307
|
+
"tsforge/no-next-head-in-app": {
|
|
308
|
+
"what": "Disallow importing 'next/head' in app-router files. The <Head> component is a no-op under app/ — use the Metadata API (export const metadata / generateMetadata) instead.",
|
|
309
|
+
"bad": "// Example that violates the rule",
|
|
310
|
+
"good": "// Corrected version"
|
|
311
|
+
},
|
|
312
|
+
"tsforge/no-pages-router-data-fetching-in-app": {
|
|
313
|
+
"what": "Disallow pages-router data-fetching exports (getServerSideProps, getStaticProps, getStaticPaths, getInitialProps) in app-router files. Next.js ignores them under app/, so they are silent dead code — use async Server Components or route handlers instead.",
|
|
314
|
+
"bad": "// Example that violates the rule",
|
|
315
|
+
"good": "// Corrected version"
|
|
316
|
+
},
|
|
302
317
|
"tsforge/pkce-required-for-oidc": {
|
|
303
318
|
"what": "OIDC providers must use PKCE: `buildAuthorizationURL` must call `generateCodeVerifier()` and pass it to `createAuthorizationURL`.",
|
|
304
319
|
"bad": "// Example that violates the rule",
|
package/src/loop/session.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
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
|
@@ -9,6 +9,7 @@ import { envAccessPack } from "./env-access";
|
|
|
9
9
|
import { i18nKeysPack } from "./i18n-keys";
|
|
10
10
|
import { jwtCookiesPack } from "./jwt-cookies";
|
|
11
11
|
import { moduleBoundariesPack } from "./module-boundaries";
|
|
12
|
+
import { nextjsPack } from "./nextjs";
|
|
12
13
|
import { oauthSecurityPack } from "./oauth-security";
|
|
13
14
|
import { reactComponentArchitecturePack } from "./react-component-architecture";
|
|
14
15
|
import { structuredLoggingPack } from "./structured-logging";
|
|
@@ -27,6 +28,7 @@ export const RULE_PACKS = {
|
|
|
27
28
|
"i18n-keys": i18nKeysPack,
|
|
28
29
|
"jwt-cookies": jwtCookiesPack,
|
|
29
30
|
"module-boundaries": moduleBoundariesPack,
|
|
31
|
+
nextjs: nextjsPack,
|
|
30
32
|
"oauth-security": oauthSecurityPack,
|
|
31
33
|
"react-component-architecture": reactComponentArchitecturePack,
|
|
32
34
|
"structured-logging": structuredLoggingPack,
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { TSESLint } from "@typescript-eslint/utils";
|
|
2
|
+
|
|
3
|
+
import { clientHooksRequireUseClientRule } from "./rules/client-hooks-require-use-client";
|
|
4
|
+
import { noNextHeadInAppRule } from "./rules/no-next-head-in-app";
|
|
5
|
+
import { noPagesRouterDataFetchingInAppRule } from "./rules/no-pages-router-data-fetching-in-app";
|
|
6
|
+
import type { IRulePack } from "../rule-packs.types";
|
|
7
|
+
|
|
8
|
+
const rules: Record<string, TSESLint.RuleModule<string, readonly unknown[]>> = {
|
|
9
|
+
"client-hooks-require-use-client": clientHooksRequireUseClientRule,
|
|
10
|
+
"no-next-head-in-app": noNextHeadInAppRule,
|
|
11
|
+
"no-pages-router-data-fetching-in-app": noPagesRouterDataFetchingInAppRule,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const nextjsPack: IRulePack = {
|
|
15
|
+
id: "nextjs",
|
|
16
|
+
description:
|
|
17
|
+
"Next.js app-router correctness: server/client component boundaries and dead pages-router APIs.",
|
|
18
|
+
rules,
|
|
19
|
+
rulesConfig: {
|
|
20
|
+
"client-hooks-require-use-client": "error",
|
|
21
|
+
"no-next-head-in-app": "error",
|
|
22
|
+
"no-pages-router-data-fetching-in-app": "error",
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export default nextjsPack;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { createRule } from "../../create-rule";
|
|
2
|
+
import {
|
|
3
|
+
calleeName,
|
|
4
|
+
hasDirective,
|
|
5
|
+
isAppRouterFile,
|
|
6
|
+
isRouteEntryFile,
|
|
7
|
+
} from "../utils";
|
|
8
|
+
|
|
9
|
+
export const RULE_NAME = "client-hooks-require-use-client";
|
|
10
|
+
|
|
11
|
+
type MessageIds = "missingUseClient";
|
|
12
|
+
|
|
13
|
+
/** Hooks that only work in Client Components — calling them in a Server
|
|
14
|
+
* Component throws at runtime. */
|
|
15
|
+
const CLIENT_HOOKS = new Set<string>([
|
|
16
|
+
"useState",
|
|
17
|
+
"useEffect",
|
|
18
|
+
"useLayoutEffect",
|
|
19
|
+
"useReducer",
|
|
20
|
+
"useImperativeHandle",
|
|
21
|
+
"useSyncExternalStore",
|
|
22
|
+
"useRouter",
|
|
23
|
+
"usePathname",
|
|
24
|
+
"useSearchParams",
|
|
25
|
+
"useParams",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
export const clientHooksRequireUseClientRule = createRule<[], MessageIds>({
|
|
29
|
+
name: RULE_NAME,
|
|
30
|
+
meta: {
|
|
31
|
+
type: "problem",
|
|
32
|
+
docs: {
|
|
33
|
+
description:
|
|
34
|
+
"Require the 'use client' directive in app-router page/layout/template files that call client-only hooks. Server Components cannot use state/effect/navigation hooks — doing so crashes at runtime.",
|
|
35
|
+
},
|
|
36
|
+
schema: [],
|
|
37
|
+
messages: {
|
|
38
|
+
missingUseClient:
|
|
39
|
+
"'{{hook}}' is a client-only hook but this Server Component has no 'use client' directive. Add 'use client' at the top of the file or move the interactivity into a Client Component.",
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
defaultOptions: [],
|
|
43
|
+
create(context) {
|
|
44
|
+
if (
|
|
45
|
+
!isAppRouterFile(context.filename) ||
|
|
46
|
+
!isRouteEntryFile(context.filename)
|
|
47
|
+
) {
|
|
48
|
+
return {};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let clientComponent = false;
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
Program(node) {
|
|
55
|
+
clientComponent = hasDirective(node, "use client");
|
|
56
|
+
},
|
|
57
|
+
CallExpression(node) {
|
|
58
|
+
if (clientComponent) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const name = calleeName(node.callee);
|
|
63
|
+
|
|
64
|
+
if (name !== null && CLIENT_HOOKS.has(name)) {
|
|
65
|
+
context.report({
|
|
66
|
+
node,
|
|
67
|
+
messageId: "missingUseClient",
|
|
68
|
+
data: { hook: name },
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { createRule } from "../../create-rule";
|
|
2
|
+
import { isAppRouterFile } from "../utils";
|
|
3
|
+
|
|
4
|
+
export const RULE_NAME = "no-next-head-in-app";
|
|
5
|
+
|
|
6
|
+
type MessageIds = "nextHeadInApp";
|
|
7
|
+
|
|
8
|
+
export const noNextHeadInAppRule = createRule<[], MessageIds>({
|
|
9
|
+
name: RULE_NAME,
|
|
10
|
+
meta: {
|
|
11
|
+
type: "problem",
|
|
12
|
+
docs: {
|
|
13
|
+
description:
|
|
14
|
+
"Disallow importing 'next/head' in app-router files. The <Head> component is a no-op under app/ — use the Metadata API (export const metadata / generateMetadata) instead.",
|
|
15
|
+
},
|
|
16
|
+
schema: [],
|
|
17
|
+
messages: {
|
|
18
|
+
nextHeadInApp:
|
|
19
|
+
"'next/head' does nothing in the app router. Use the Metadata API (export const metadata or generateMetadata) instead.",
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
defaultOptions: [],
|
|
23
|
+
create(context) {
|
|
24
|
+
if (!isAppRouterFile(context.filename)) {
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
ImportDeclaration(node) {
|
|
30
|
+
if (node.source.value === "next/head") {
|
|
31
|
+
context.report({ node: node.source, messageId: "nextHeadInApp" });
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { TSESTree } from "@typescript-eslint/utils";
|
|
2
|
+
|
|
3
|
+
import { createRule } from "../../create-rule";
|
|
4
|
+
import { isAppRouterFile } from "../utils";
|
|
5
|
+
|
|
6
|
+
export const RULE_NAME = "no-pages-router-data-fetching-in-app";
|
|
7
|
+
|
|
8
|
+
type MessageIds = "pagesDataFnInApp";
|
|
9
|
+
|
|
10
|
+
/** Pages-router data-fetching exports — inert (dead code) under the app router. */
|
|
11
|
+
const PAGES_DATA_FNS = new Set<string>([
|
|
12
|
+
"getServerSideProps",
|
|
13
|
+
"getStaticProps",
|
|
14
|
+
"getStaticPaths",
|
|
15
|
+
"getInitialProps",
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
export const noPagesRouterDataFetchingInAppRule = createRule<[], MessageIds>({
|
|
19
|
+
name: RULE_NAME,
|
|
20
|
+
meta: {
|
|
21
|
+
type: "problem",
|
|
22
|
+
docs: {
|
|
23
|
+
description:
|
|
24
|
+
"Disallow pages-router data-fetching exports (getServerSideProps, getStaticProps, getStaticPaths, getInitialProps) in app-router files. Next.js ignores them under app/, so they are silent dead code — use async Server Components or route handlers instead.",
|
|
25
|
+
},
|
|
26
|
+
schema: [],
|
|
27
|
+
messages: {
|
|
28
|
+
pagesDataFnInApp:
|
|
29
|
+
"'{{name}}' is a pages-router API and is ignored under app/. Fetch data inside an async Server Component or a route handler instead.",
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
defaultOptions: [],
|
|
33
|
+
create(context) {
|
|
34
|
+
if (!isAppRouterFile(context.filename)) {
|
|
35
|
+
return {};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function reportName(node: TSESTree.Node, name: string): void {
|
|
39
|
+
context.report({ node, messageId: "pagesDataFnInApp", data: { name } });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
ExportNamedDeclaration(node) {
|
|
44
|
+
const decl = node.declaration;
|
|
45
|
+
|
|
46
|
+
if (
|
|
47
|
+
decl?.type === "FunctionDeclaration" &&
|
|
48
|
+
decl.id !== null &&
|
|
49
|
+
PAGES_DATA_FNS.has(decl.id.name)
|
|
50
|
+
) {
|
|
51
|
+
reportName(decl.id, decl.id.name);
|
|
52
|
+
} else if (decl?.type === "VariableDeclaration") {
|
|
53
|
+
for (const d of decl.declarations) {
|
|
54
|
+
if (d.id.type === "Identifier" && PAGES_DATA_FNS.has(d.id.name)) {
|
|
55
|
+
reportName(d.id, d.id.name);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const spec of node.specifiers) {
|
|
61
|
+
if (
|
|
62
|
+
spec.exported.type === "Identifier" &&
|
|
63
|
+
PAGES_DATA_FNS.has(spec.exported.name)
|
|
64
|
+
) {
|
|
65
|
+
reportName(spec, spec.exported.name);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
},
|
|
71
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { TSESTree } from "@typescript-eslint/utils";
|
|
2
|
+
|
|
3
|
+
/** True if the file lives under a Next.js app-router directory (an `app` segment). */
|
|
4
|
+
export function isAppRouterFile(filename: string): boolean {
|
|
5
|
+
return filename.split(/[\\/]/).includes("app");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** True if the file is an app-router route-entry file (page/layout/template),
|
|
9
|
+
* which default to React Server Components. */
|
|
10
|
+
export function isRouteEntryFile(filename: string): boolean {
|
|
11
|
+
const base = filename.split(/[\\/]/).pop() ?? "";
|
|
12
|
+
|
|
13
|
+
return /^(?:page|layout|template)\.(?:tsx|ts|jsx|js)$/.test(base);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** True if the program's directive prologue contains `directive`
|
|
17
|
+
* (e.g. "use client" / "use server"). */
|
|
18
|
+
export function hasDirective(
|
|
19
|
+
program: TSESTree.Program,
|
|
20
|
+
directive: string
|
|
21
|
+
): boolean {
|
|
22
|
+
for (const stmt of program.body) {
|
|
23
|
+
if (
|
|
24
|
+
stmt.type !== "ExpressionStatement" ||
|
|
25
|
+
stmt.expression.type !== "Literal" ||
|
|
26
|
+
typeof stmt.expression.value !== "string"
|
|
27
|
+
) {
|
|
28
|
+
return false; // directive prologue has ended
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (stmt.expression.value === directive) {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Resolve a call's callee to a simple name: `useState` or `React.useState`
|
|
40
|
+
* → "useState". Returns null for computed or complex callees. */
|
|
41
|
+
export function calleeName(callee: TSESTree.Node): string | null {
|
|
42
|
+
if (callee.type === "Identifier") {
|
|
43
|
+
return callee.name;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (
|
|
47
|
+
callee.type === "MemberExpression" &&
|
|
48
|
+
!callee.computed &&
|
|
49
|
+
callee.property.type === "Identifier"
|
|
50
|
+
) {
|
|
51
|
+
return callee.property.name;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
@@ -61,6 +61,17 @@ export const PACK_REGISTRY = {
|
|
|
61
61
|
guidance: "Follow Elysia patterns for HTTP routing and middleware.",
|
|
62
62
|
} as const satisfies IRulePackDescriptor,
|
|
63
63
|
|
|
64
|
+
nextjs: {
|
|
65
|
+
id: "nextjs",
|
|
66
|
+
label: "Next.js",
|
|
67
|
+
description:
|
|
68
|
+
"App-router correctness: server/client boundaries and dead pages-router APIs",
|
|
69
|
+
category: "framework",
|
|
70
|
+
appliesWhen: { anyDeps: ["next"] },
|
|
71
|
+
guidance:
|
|
72
|
+
"Follow Next.js app-router conventions: mark interactive files 'use client' and use the Metadata API and Server Components instead of pages-router APIs.",
|
|
73
|
+
} as const satisfies IRulePackDescriptor,
|
|
74
|
+
|
|
64
75
|
bullmq: {
|
|
65
76
|
id: "bullmq",
|
|
66
77
|
label: "BullMQ",
|