@capybara-hq/testing 0.0.0-stage → 0.1.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +2 -0
  3. package/README.md +34 -2
  4. package/dist/containers.d.ts +102 -0
  5. package/dist/containers.js +314 -0
  6. package/dist/dual-replica.d.ts +121 -0
  7. package/dist/dual-replica.js +247 -0
  8. package/dist/extension-config.d.ts +9 -0
  9. package/dist/extension-config.js +16 -0
  10. package/dist/fake-channel.d.ts +162 -0
  11. package/dist/fake-channel.js +325 -0
  12. package/dist/fake-e2b.d.ts +104 -0
  13. package/dist/fake-e2b.js +500 -0
  14. package/dist/fake-git-server.d.ts +57 -0
  15. package/dist/fake-git-server.js +323 -0
  16. package/dist/fake-lark.d.ts +234 -0
  17. package/dist/fake-lark.js +837 -0
  18. package/dist/fake-marketplace.d.ts +25 -0
  19. package/dist/fake-marketplace.js +58 -0
  20. package/dist/fake-mcp-server.d.ts +134 -0
  21. package/dist/fake-mcp-server.js +308 -0
  22. package/dist/fake-model-server.d.ts +28 -0
  23. package/dist/fake-model-server.js +67 -0
  24. package/dist/fake-npm-registry.d.ts +59 -0
  25. package/dist/fake-npm-registry.js +174 -0
  26. package/dist/fake-oauth-server.d.ts +63 -0
  27. package/dist/fake-oauth-server.js +284 -0
  28. package/dist/fake-openapi-server.d.ts +50 -0
  29. package/dist/fake-openapi-server.js +278 -0
  30. package/dist/fake-slack.d.ts +258 -0
  31. package/dist/fake-slack.js +654 -0
  32. package/dist/fixtures.d.ts +47 -0
  33. package/dist/fixtures.js +90 -0
  34. package/dist/global-setup.d.ts +15 -0
  35. package/dist/global-setup.js +58 -0
  36. package/dist/index.d.ts +25 -0
  37. package/dist/index.js +25 -0
  38. package/dist/kernel-helpers.d.ts +125 -0
  39. package/dist/kernel-helpers.js +336 -0
  40. package/dist/media-samples.d.ts +10 -0
  41. package/dist/media-samples.js +76 -0
  42. package/dist/model-conformance.d.ts +61 -0
  43. package/dist/model-conformance.js +287 -0
  44. package/dist/replica-runner.d.ts +1 -0
  45. package/dist/replica-runner.js +59 -0
  46. package/dist/sse.d.ts +20 -0
  47. package/dist/sse.js +87 -0
  48. package/fixtures/extensions/fake-bootstrap-a/.capybara-plugin/plugin.json +7 -0
  49. package/fixtures/extensions/fake-bootstrap-a/package.json +12 -0
  50. package/fixtures/extensions/fake-bootstrap-a/src/index.ts +62 -0
  51. package/fixtures/extensions/fake-bootstrap-b/.capybara-plugin/plugin.json +7 -0
  52. package/fixtures/extensions/fake-bootstrap-b/migrations/0001_init.sql +10 -0
  53. package/fixtures/extensions/fake-bootstrap-b/package.json +12 -0
  54. package/fixtures/extensions/fake-bootstrap-b/src/index.ts +123 -0
  55. package/fixtures/extensions/fake-connector/.capybara-plugin/plugin.json +7 -0
  56. package/fixtures/extensions/fake-connector/connectors/fake-demo.json +12 -0
  57. package/fixtures/extensions/fake-connector/package.json +13 -0
  58. package/fixtures/extensions/fake-connector/src/index.ts +477 -0
  59. package/fixtures/extensions/fake-dep-provider/.capybara-plugin/plugin.json +7 -0
  60. package/fixtures/extensions/fake-dep-provider/package.json +12 -0
  61. package/fixtures/extensions/fake-dep-provider/src/index.ts +19 -0
  62. package/fixtures/extensions/fake-dep-requirer/.capybara-plugin/plugin.json +8 -0
  63. package/fixtures/extensions/fake-dep-requirer/package.json +12 -0
  64. package/fixtures/extensions/fake-dep-requirer/src/index.ts +21 -0
  65. package/fixtures/extensions/fake-embed/.capybara-plugin/plugin.json +7 -0
  66. package/fixtures/extensions/fake-embed/package.json +12 -0
  67. package/fixtures/extensions/fake-embed/src/index.ts +207 -0
  68. package/fixtures/extensions/fake-im/.capybara-plugin/plugin.json +7 -0
  69. package/fixtures/extensions/fake-im/package.json +12 -0
  70. package/fixtures/extensions/fake-im/src/channel.ts +224 -0
  71. package/fixtures/extensions/fake-im/src/directory.ts +25 -0
  72. package/fixtures/extensions/fake-im/src/identity.ts +66 -0
  73. package/fixtures/extensions/fake-im/src/index.ts +91 -0
  74. package/fixtures/extensions/fake-object-store/.capybara-plugin/plugin.json +7 -0
  75. package/fixtures/extensions/fake-object-store/package.json +15 -0
  76. package/fixtures/extensions/fake-object-store/src/index.ts +210 -0
  77. package/fixtures/extensions/fake-openapi/.capybara-plugin/plugin.json +7 -0
  78. package/fixtures/extensions/fake-openapi/package.json +12 -0
  79. package/fixtures/extensions/fake-openapi/src/index.ts +258 -0
  80. package/fixtures/extensions/fake-storage-user/.capybara-plugin/plugin.json +7 -0
  81. package/fixtures/extensions/fake-storage-user/package.json +12 -0
  82. package/fixtures/extensions/fake-storage-user/src/index.ts +58 -0
  83. package/fixtures/extensions/fake-tools-ui/.capybara-plugin/plugin.json +7 -0
  84. package/fixtures/extensions/fake-tools-ui/connectors/fake-notes.json +11 -0
  85. package/fixtures/extensions/fake-tools-ui/migrations/0001_init.sql +7 -0
  86. package/fixtures/extensions/fake-tools-ui/package.json +12 -0
  87. package/fixtures/extensions/fake-tools-ui/skills/fake-notes/SKILL.md +7 -0
  88. package/fixtures/extensions/fake-tools-ui/src/index.ts +140 -0
  89. package/package.json +66 -3
@@ -0,0 +1,25 @@
1
+ /** One request the fake marketplace received. */
2
+ export interface FakeMarketplaceRequest {
3
+ /** The `If-None-Match` header. */
4
+ etag?: string;
5
+ /** The `Authorization` header. */
6
+ authorization?: string;
7
+ }
8
+ export interface FakeMarketplace {
9
+ /** The manifest URL `http://127.0.0.1:<port>/marketplace.json`. */
10
+ url: string;
11
+ /** Serves another manifest (a string is served as is, for invalid JSON); the ETag follows it. */
12
+ setManifest(m: unknown): void;
13
+ /** Makes the manifest URL answer 302 to `location` (`null` restores it). */
14
+ setRedirect(location: string | null): void;
15
+ /** The requests received, in order (absent headers omitted). */
16
+ requests: FakeMarketplaceRequest[];
17
+ stop(): Promise<void>;
18
+ }
19
+ /**
20
+ * A fake marketplace: `GET /marketplace.json` serves the current manifest (`application/json`) with a
21
+ * strong ETag (sha1 of the content); 304 when `If-None-Match` equals it. Other paths 404.
22
+ */
23
+ export declare function startFakeMarketplace(opts: {
24
+ manifest: unknown;
25
+ }): Promise<FakeMarketplace>;
@@ -0,0 +1,58 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createServer } from "node:http";
3
+ /**
4
+ * A fake marketplace: `GET /marketplace.json` serves the current manifest (`application/json`) with a
5
+ * strong ETag (sha1 of the content); 304 when `If-None-Match` equals it. Other paths 404.
6
+ */
7
+ export async function startFakeMarketplace(opts) {
8
+ let body = "";
9
+ let etag = "";
10
+ let redirect = null;
11
+ const setManifest = (m) => {
12
+ body = typeof m === "string" ? m : JSON.stringify(m);
13
+ etag = `"${createHash("sha1").update(body).digest("hex")}"`;
14
+ };
15
+ setManifest(opts.manifest);
16
+ const requests = [];
17
+ const server = createServer((req, res) => {
18
+ if (req.method !== "GET" || (req.url ?? "").split("?")[0] !== "/marketplace.json") {
19
+ res.writeHead(404).end();
20
+ return;
21
+ }
22
+ const r = {};
23
+ const inm = req.headers["if-none-match"];
24
+ if (typeof inm === "string")
25
+ r.etag = inm;
26
+ if (typeof req.headers.authorization === "string")
27
+ r.authorization = req.headers.authorization;
28
+ requests.push(r);
29
+ if (redirect !== null) {
30
+ res.writeHead(302, { location: redirect }).end();
31
+ return;
32
+ }
33
+ if (inm === etag) {
34
+ res.writeHead(304, { etag }).end();
35
+ return;
36
+ }
37
+ res
38
+ .writeHead(200, {
39
+ "content-type": "application/json",
40
+ "content-length": String(Buffer.byteLength(body)),
41
+ etag,
42
+ })
43
+ .end(body);
44
+ });
45
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
46
+ return {
47
+ url: `http://127.0.0.1:${server.address().port}/marketplace.json`,
48
+ setManifest,
49
+ setRedirect: (l) => {
50
+ redirect = l;
51
+ },
52
+ requests,
53
+ stop: () => new Promise((resolve) => {
54
+ server.closeAllConnections();
55
+ server.close(() => resolve());
56
+ }),
57
+ };
58
+ }
@@ -0,0 +1,134 @@
1
+ import { type CallToolResult, type ElicitRequestFormParams, type ElicitRequestURLParams, type ElicitResult, type GetPromptResult } from "@modelcontextprotocol/sdk/types.js";
2
+ /** 工具处理函数抛出它 → 服务以 JSON-RPC 错误(该 code 与消息)响应,而不是 `isError` 结果。 */
3
+ export declare class FakeMcpRpcError extends Error {
4
+ readonly code: number;
5
+ /** JSON-RPC 错误的 `data`(如 `-32042` URL elicitation 的 `{ elicitations: [...] }`)。 */
6
+ readonly data?: unknown;
7
+ constructor(code: number, message: string,
8
+ /** JSON-RPC 错误的 `data`(如 `-32042` URL elicitation 的 `{ elicitations: [...] }`)。 */
9
+ data?: unknown);
10
+ }
11
+ /** 工具处理函数收到的请求信息(头名一律小写)。 */
12
+ export interface FakeMcpCallInfo {
13
+ name: string;
14
+ args: Record<string, unknown>;
15
+ headers: Record<string, string>;
16
+ }
17
+ export interface FakeMcpTool {
18
+ name: string;
19
+ title?: string;
20
+ /** 不给时清单里不带 description(测试「缺描述」的映射)。 */
21
+ description?: string;
22
+ inputSchema?: Record<string, unknown>;
23
+ annotations?: Record<string, unknown>;
24
+ _meta?: Record<string, unknown>;
25
+ /**
26
+ * 返回工具结果。可模拟慢(await 一段时间)、报错(返回 `isError: true`,或抛 `McpError` 得到 JSON-RPC 错误)、
27
+ * 回显请求头(`info.headers`)、超大输出。缺省返回 `ok`。
28
+ * `ctx.notifyToolsChanged()` 在本次调用的响应流里发 `notifications/tools/list_changed`(需 `sseResponses`)。
29
+ */
30
+ handler?: (info: FakeMcpCallInfo, ctx: FakeMcpHandlerContext) => CallToolResult | Promise<CallToolResult>;
31
+ }
32
+ /** 工具处理函数的第二个参数。 */
33
+ export interface FakeMcpHandlerContext {
34
+ /** 发一条 `notifications/tools/list_changed`(与本次调用关联,经响应的 SSE 流送达)。 */
35
+ notifyToolsChanged(): Promise<void>;
36
+ /**
37
+ * 向客户端发 `elicitation/create`(经本次调用的 SSE 流)并等待答复(需 `sessions`)。客户端没有声明
38
+ * elicitation 能力时抛错(SDK 的能力检查)。每次调用记入 `FakeMcpServer.elicitations`。
39
+ */
40
+ elicit(params: ElicitRequestFormParams | ElicitRequestURLParams): Promise<ElicitResult>;
41
+ }
42
+ /** 提示模板(prompts 能力)。 */
43
+ export interface FakeMcpPrompt {
44
+ name: string;
45
+ title?: string;
46
+ description?: string;
47
+ arguments?: {
48
+ name: string;
49
+ description?: string;
50
+ required?: boolean;
51
+ }[];
52
+ /** 渲染:返回消息(缺省一条 user 文本 `prompt <name> <args JSON>`)。可抛 `FakeMcpRpcError`。 */
53
+ render?: (args: Record<string, string>, headers: Record<string, string>) => GetPromptResult["messages"];
54
+ }
55
+ export interface FakeMcpResource {
56
+ uri: string;
57
+ name: string;
58
+ description?: string;
59
+ mimeType?: string;
60
+ text?: string;
61
+ /** base64。 */
62
+ blob?: string;
63
+ }
64
+ /** 收到的一个 HTTP 请求(JSON-RPC 方法名与请求头)。 */
65
+ export interface FakeMcpRequest {
66
+ httpMethod: string;
67
+ /** JSON-RPC 方法名;批量 / 无法解析时为 undefined。 */
68
+ method: string | undefined;
69
+ headers: Record<string, string>;
70
+ }
71
+ export interface FakeMcpServerOptions {
72
+ tools: FakeMcpTool[];
73
+ /** 给出时声明 resources 能力(空数组也算支持)。 */
74
+ resources?: FakeMcpResource[];
75
+ /** 给出时声明 prompts 能力(空数组也算支持)。 */
76
+ prompts?: FakeMcpPrompt[];
77
+ /**
78
+ * 有状态模式(`Mcp-Session-Id`,SSE 响应):同一会话的请求由同一个 SDK `Server` 处理,服务端发起的请求
79
+ * (elicitation)的答复才能送回发起它的调用。缺省无状态。
80
+ */
81
+ sessions?: boolean;
82
+ /** 要求 `Authorization: Bearer <token>`,不符 → HTTP 401。 */
83
+ requireBearer?: string;
84
+ /** 自定义 bearer 校验(如交给假授权服务器的 `verify`):返回 false → HTTP 401。与 `requireBearer` 二选一。 */
85
+ verifyBearer?: (token: string) => boolean;
86
+ /**
87
+ * OAuth 受保护资源(RFC 9728):401 响应带 `WWW-Authenticate: Bearer`。`advertise: "header"` 时元数据放在非标准
88
+ * 路径、只经 `resource_metadata` 参数告知;`"well-known"` 时元数据只在
89
+ * `/.well-known/oauth-protected-resource/mcp`,401 不带该参数。
90
+ */
91
+ protectedResource?: {
92
+ authorizationServers: string[];
93
+ scopesSupported?: string[];
94
+ advertise: "header" | "well-known";
95
+ };
96
+ /** 前 n 个 HTTP 请求无条件返回 401(模拟凭据过期后刷新)。 */
97
+ unauthorizedTimes?: number;
98
+ /** 每次工具调用(在处理函数之前)调用。 */
99
+ onCall?: (info: FakeMcpCallInfo) => void;
100
+ /**
101
+ * 在 MCP 处理之前拦截 HTTP 请求:返回 `{ status, body, location? }` 时直接以该状态与正文响应(模拟网关报错、
102
+ * 正文回显请求头、重定向等),返回 undefined 时正常处理。
103
+ */
104
+ intercept?: (req: FakeMcpRequest & {
105
+ body: unknown;
106
+ }) => {
107
+ status: number;
108
+ body: string;
109
+ location?: string;
110
+ } | undefined;
111
+ /**
112
+ * 以 SSE 流响应 POST(缺省为 JSON 响应)。只有 SSE 响应能在调用途中带上服务端通知(如 list_changed)。
113
+ */
114
+ sseResponses?: boolean;
115
+ }
116
+ export interface FakeMcpServer {
117
+ /** MCP 端点(`http://127.0.0.1:<port>/mcp`)。 */
118
+ url: string;
119
+ /** 工具调用记录(按到达顺序)。 */
120
+ calls: FakeMcpCallInfo[];
121
+ /** 全部 HTTP 请求(含被 401 拒绝的)。 */
122
+ requests: FakeMcpRequest[];
123
+ /** 服务发起的 elicitation 与客户端的答复(答复前中止 / 会话关闭时 `result` 为 `"error"`)。 */
124
+ elicitations: {
125
+ params: ElicitRequestFormParams | ElicitRequestURLParams;
126
+ result?: ElicitResult | "error";
127
+ }[];
128
+ stop(): Promise<void>;
129
+ }
130
+ /**
131
+ * 本地假 MCP 服务(Streamable HTTP,无状态模式,缺省 JSON 响应,`sseResponses` 时 SSE):每个 HTTP 请求新建一个 SDK `Server` 处理,
132
+ * 监听 127.0.0.1 随机端口。GET(独立 SSE 流)与 DELETE(结束会话)返回 405,客户端按规范忽略。
133
+ */
134
+ export declare function startFakeMcpServer(opts: FakeMcpServerOptions): Promise<FakeMcpServer>;
@@ -0,0 +1,308 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { createServer } from "node:http";
3
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
4
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
5
+ import { CallToolRequestSchema, ErrorCode, GetPromptRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, McpError, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
6
+ /** 工具处理函数抛出它 → 服务以 JSON-RPC 错误(该 code 与消息)响应,而不是 `isError` 结果。 */
7
+ export class FakeMcpRpcError extends Error {
8
+ code;
9
+ data;
10
+ constructor(code, message,
11
+ /** JSON-RPC 错误的 `data`(如 `-32042` URL elicitation 的 `{ elicitations: [...] }`)。 */
12
+ data) {
13
+ super(message);
14
+ this.code = code;
15
+ this.data = data;
16
+ this.name = "FakeMcpRpcError";
17
+ }
18
+ }
19
+ const headersOf = (req) => {
20
+ const out = {};
21
+ for (const [k, v] of Object.entries(req.headers)) {
22
+ if (v !== undefined)
23
+ out[k] = Array.isArray(v) ? v.join(", ") : v;
24
+ }
25
+ return out;
26
+ };
27
+ const readBody = (req) => new Promise((resolve, reject) => {
28
+ const chunks = [];
29
+ req.on("data", (c) => chunks.push(c));
30
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
31
+ req.on("error", reject);
32
+ });
33
+ const send = (res, status, body, location, extra = {}) => {
34
+ res.writeHead(status, {
35
+ "content-type": "text/plain",
36
+ ...(location !== undefined && { location }),
37
+ ...extra,
38
+ });
39
+ res.end(body);
40
+ };
41
+ /** 受保护资源元数据的两个位置(见 `FakeMcpServerOptions.protectedResource`)。 */
42
+ const PRM_HEADER_PATH = "/meta/resource.json";
43
+ const PRM_WELL_KNOWN_PATH = "/.well-known/oauth-protected-resource/mcp";
44
+ /**
45
+ * 本地假 MCP 服务(Streamable HTTP,无状态模式,缺省 JSON 响应,`sseResponses` 时 SSE):每个 HTTP 请求新建一个 SDK `Server` 处理,
46
+ * 监听 127.0.0.1 随机端口。GET(独立 SSE 流)与 DELETE(结束会话)返回 405,客户端按规范忽略。
47
+ */
48
+ export async function startFakeMcpServer(opts) {
49
+ const calls = [];
50
+ const requests = [];
51
+ const elicitations = [];
52
+ /** 有状态模式下的会话(按 `Mcp-Session-Id`)。 */
53
+ const sessions = new Map();
54
+ let rejected = 0;
55
+ const mcpServer = (headers) => {
56
+ const server = new Server({ name: "fake-mcp", version: "1.0.0" }, {
57
+ capabilities: {
58
+ tools: { listChanged: true },
59
+ ...(opts.resources !== undefined && { resources: {} }),
60
+ ...(opts.prompts !== undefined && { prompts: {} }),
61
+ },
62
+ });
63
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
64
+ tools: opts.tools.map((t) => ({
65
+ name: t.name,
66
+ ...(t.title !== undefined && { title: t.title }),
67
+ ...(t.description !== undefined && { description: t.description }),
68
+ inputSchema: (t.inputSchema ?? { type: "object", properties: {} }),
69
+ ...(t.annotations !== undefined && { annotations: t.annotations }),
70
+ ...(t._meta !== undefined && { _meta: t._meta }),
71
+ })),
72
+ }));
73
+ server.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
74
+ const tool = opts.tools.find((t) => t.name === req.params.name);
75
+ if (!tool)
76
+ throw new McpError(ErrorCode.InvalidParams, `Unknown tool ${req.params.name}`);
77
+ const info = {
78
+ name: req.params.name,
79
+ args: (req.params.arguments ?? {}),
80
+ headers,
81
+ };
82
+ calls.push(info);
83
+ opts.onCall?.(info);
84
+ if (!tool.handler)
85
+ return { content: [{ type: "text", text: "ok" }] };
86
+ try {
87
+ return await tool.handler(info, {
88
+ notifyToolsChanged: () => extra.sendNotification({ method: "notifications/tools/list_changed" }),
89
+ elicit: async (params) => {
90
+ const entry = { params };
91
+ elicitations.push(entry);
92
+ try {
93
+ entry.result = await server.elicitInput(params, {
94
+ relatedRequestId: extra.requestId,
95
+ });
96
+ return entry.result;
97
+ }
98
+ catch (e) {
99
+ entry.result = "error";
100
+ throw e;
101
+ }
102
+ },
103
+ });
104
+ }
105
+ catch (e) {
106
+ if (e instanceof FakeMcpRpcError)
107
+ throw new McpError(e.code, e.message, e.data);
108
+ throw e;
109
+ }
110
+ });
111
+ if (opts.prompts !== undefined) {
112
+ const prompts = opts.prompts;
113
+ server.setRequestHandler(ListPromptsRequestSchema, async () => ({
114
+ prompts: prompts.map((p) => ({
115
+ name: p.name,
116
+ ...(p.title !== undefined && { title: p.title }),
117
+ ...(p.description !== undefined && { description: p.description }),
118
+ ...(p.arguments !== undefined && { arguments: p.arguments }),
119
+ })),
120
+ }));
121
+ server.setRequestHandler(GetPromptRequestSchema, async (req) => {
122
+ const p = prompts.find((x) => x.name === req.params.name);
123
+ if (!p)
124
+ throw new McpError(ErrorCode.InvalidParams, `Unknown prompt ${req.params.name}`);
125
+ const args = (req.params.arguments ?? {});
126
+ try {
127
+ return {
128
+ ...(p.description !== undefined && { description: p.description }),
129
+ messages: p.render
130
+ ? p.render(args, headers)
131
+ : [
132
+ {
133
+ role: "user",
134
+ content: {
135
+ type: "text",
136
+ text: `prompt ${p.name} ${JSON.stringify(args)}`,
137
+ },
138
+ },
139
+ ],
140
+ };
141
+ }
142
+ catch (e) {
143
+ if (e instanceof FakeMcpRpcError)
144
+ throw new McpError(e.code, e.message, e.data);
145
+ throw e;
146
+ }
147
+ });
148
+ }
149
+ if (opts.resources !== undefined) {
150
+ const resources = opts.resources;
151
+ server.setRequestHandler(ListResourcesRequestSchema, async () => ({
152
+ resources: resources.map((r) => ({
153
+ uri: r.uri,
154
+ name: r.name,
155
+ ...(r.description !== undefined && { description: r.description }),
156
+ ...(r.mimeType !== undefined && { mimeType: r.mimeType }),
157
+ })),
158
+ }));
159
+ server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
160
+ const r = resources.find((x) => x.uri === req.params.uri);
161
+ if (!r)
162
+ throw new McpError(ErrorCode.InvalidParams, `Resource not found: ${req.params.uri}`);
163
+ return {
164
+ contents: [
165
+ r.blob !== undefined
166
+ ? {
167
+ uri: r.uri,
168
+ blob: r.blob,
169
+ ...(r.mimeType !== undefined && { mimeType: r.mimeType }),
170
+ }
171
+ : {
172
+ uri: r.uri,
173
+ text: r.text ?? "",
174
+ ...(r.mimeType !== undefined && { mimeType: r.mimeType }),
175
+ },
176
+ ],
177
+ };
178
+ });
179
+ }
180
+ return server;
181
+ };
182
+ let base = "";
183
+ const prm = opts.protectedResource;
184
+ const unauthorized = (res) => {
185
+ const challenge = prm?.advertise === "header"
186
+ ? `Bearer resource_metadata="${base}${PRM_HEADER_PATH}"`
187
+ : prm
188
+ ? `Bearer realm="fake-mcp"`
189
+ : undefined;
190
+ send(res, 401, "Unauthorized", undefined, challenge === undefined ? {} : { "www-authenticate": challenge });
191
+ };
192
+ const handle = async (req, res) => {
193
+ const path = new URL(req.url ?? "/", base).pathname;
194
+ if (prm && req.method === "GET") {
195
+ const at = prm.advertise === "header" ? PRM_HEADER_PATH : PRM_WELL_KNOWN_PATH;
196
+ if (path === at) {
197
+ res.writeHead(200, { "content-type": "application/json" });
198
+ res.end(JSON.stringify({
199
+ resource: `${base}/mcp`,
200
+ authorization_servers: prm.authorizationServers,
201
+ bearer_methods_supported: ["header"],
202
+ ...(prm.scopesSupported !== undefined && { scopes_supported: prm.scopesSupported }),
203
+ }));
204
+ return;
205
+ }
206
+ if (path.startsWith("/.well-known/") || path === PRM_HEADER_PATH) {
207
+ return send(res, 404, "Not Found");
208
+ }
209
+ }
210
+ const headers = headersOf(req);
211
+ const raw = req.method === "POST" ? await readBody(req) : "";
212
+ let body;
213
+ try {
214
+ body = raw === "" ? undefined : JSON.parse(raw);
215
+ }
216
+ catch {
217
+ body = raw;
218
+ }
219
+ const method = typeof body === "object" && body !== null && !Array.isArray(body)
220
+ ? body.method
221
+ : undefined;
222
+ const entry = { httpMethod: req.method ?? "GET", method, headers };
223
+ requests.push(entry);
224
+ if (rejected < (opts.unauthorizedTimes ?? 0)) {
225
+ rejected++;
226
+ return unauthorized(res);
227
+ }
228
+ if (opts.requireBearer !== undefined &&
229
+ headers.authorization !== `Bearer ${opts.requireBearer}`) {
230
+ return unauthorized(res);
231
+ }
232
+ if (opts.verifyBearer) {
233
+ const m = /^Bearer (.+)$/.exec(headers.authorization ?? "");
234
+ if (!m || !opts.verifyBearer(m[1]))
235
+ return unauthorized(res);
236
+ }
237
+ const over = opts.intercept?.({ ...entry, body });
238
+ if (over)
239
+ return send(res, over.status, over.body, over.location);
240
+ if (opts.sessions)
241
+ return handleSession(req, res, headers, body);
242
+ if (req.method !== "POST")
243
+ return send(res, 405, "Method Not Allowed");
244
+ const server = mcpServer(headers);
245
+ // 不给 sessionIdGenerator = 无状态模式
246
+ const transport = new StreamableHTTPServerTransport({
247
+ enableJsonResponse: opts.sseResponses !== true,
248
+ });
249
+ res.on("close", () => {
250
+ void transport.close();
251
+ void server.close();
252
+ });
253
+ // exactOptionalPropertyTypes 下 SDK 自身的 Transport 类型不兼容(onclose 可为 undefined),按 SDK 文档用法转型
254
+ await server.connect(transport);
255
+ await transport.handleRequest(req, res, body);
256
+ };
257
+ /** 有状态模式:按 `Mcp-Session-Id` 路由到会话的 transport;initialize 新建会话。 */
258
+ const handleSession = async (req, res, headers, body) => {
259
+ const sid = headers["mcp-session-id"];
260
+ const existing = sid === undefined ? undefined : sessions.get(sid);
261
+ if (existing) {
262
+ await existing.handleRequest(req, res, body);
263
+ return;
264
+ }
265
+ if (sid !== undefined)
266
+ return send(res, 404, "Session not found");
267
+ if (req.method !== "POST")
268
+ return send(res, 405, "Method Not Allowed");
269
+ const server = mcpServer(headers);
270
+ const transport = new StreamableHTTPServerTransport({
271
+ sessionIdGenerator: () => randomUUID(),
272
+ onsessioninitialized: (id) => {
273
+ sessions.set(id, transport);
274
+ },
275
+ });
276
+ transport.onclose = () => {
277
+ if (transport.sessionId)
278
+ sessions.delete(transport.sessionId);
279
+ void server.close();
280
+ };
281
+ await server.connect(transport);
282
+ await transport.handleRequest(req, res, body);
283
+ };
284
+ const http = createServer((req, res) => {
285
+ handle(req, res).catch((e) => {
286
+ if (!res.headersSent)
287
+ send(res, 500, String(e));
288
+ else
289
+ res.end();
290
+ });
291
+ });
292
+ await new Promise((resolve) => http.listen(0, "127.0.0.1", resolve));
293
+ const { port } = http.address();
294
+ base = `http://127.0.0.1:${port}`;
295
+ return {
296
+ url: `${base}/mcp`,
297
+ calls,
298
+ requests,
299
+ elicitations,
300
+ stop: () => new Promise((resolve, reject) => {
301
+ for (const t of sessions.values())
302
+ void t.close();
303
+ sessions.clear();
304
+ http.closeAllConnections();
305
+ http.close((err) => (err ? reject(err) : resolve()));
306
+ }),
307
+ };
308
+ }
@@ -0,0 +1,28 @@
1
+ export interface FakeRequest {
2
+ path: string;
3
+ body: unknown;
4
+ headers: Record<string, string>;
5
+ }
6
+ export type FakeResponse = {
7
+ status: number;
8
+ json: unknown;
9
+ headers?: Record<string, string>;
10
+ } | {
11
+ status: 200;
12
+ sse: {
13
+ event?: string;
14
+ data: string;
15
+ }[];
16
+ delayMs?: number;
17
+ /** 写完全部事件后不结束响应(连接保持打开),用于验证客户端在终止事件后自行停止读取。 */
18
+ keepOpen?: boolean;
19
+ } | {
20
+ stall: true;
21
+ };
22
+ export interface FakeModelServer {
23
+ url: string;
24
+ requests: FakeRequest[];
25
+ stop(): Promise<void>;
26
+ }
27
+ /** 本地假模型服务:供插件用原生协议编写夹具。 */
28
+ export declare function startFakeModelServer(routes: (req: FakeRequest) => FakeResponse): Promise<FakeModelServer>;
@@ -0,0 +1,67 @@
1
+ import { createServer } from "node:http";
2
+ /** 本地假模型服务:供插件用原生协议编写夹具。 */
3
+ export async function startFakeModelServer(routes) {
4
+ const requests = [];
5
+ const server = createServer((req, res) => {
6
+ const chunks = [];
7
+ req.on("data", (c) => chunks.push(c));
8
+ req.on("end", () => {
9
+ const raw = Buffer.concat(chunks).toString("utf8");
10
+ let body = raw;
11
+ try {
12
+ body = raw === "" ? undefined : JSON.parse(raw);
13
+ }
14
+ catch {
15
+ body = raw;
16
+ }
17
+ const headers = {};
18
+ for (const [k, v] of Object.entries(req.headers)) {
19
+ if (v !== undefined)
20
+ headers[k] = Array.isArray(v) ? v.join(", ") : v;
21
+ }
22
+ const fake = { path: req.url ?? "/", body, headers };
23
+ requests.push(fake);
24
+ let out;
25
+ try {
26
+ out = routes(fake);
27
+ }
28
+ catch (e) {
29
+ res.writeHead(500, { "content-type": "application/json" });
30
+ res.end(JSON.stringify({ error: String(e) }));
31
+ return;
32
+ }
33
+ if ("stall" in out) {
34
+ res.writeHead(200, { "content-type": "text/event-stream" });
35
+ res.flushHeaders();
36
+ return;
37
+ }
38
+ if ("json" in out) {
39
+ res.writeHead(out.status, { ...out.headers, "content-type": "application/json" });
40
+ res.end(JSON.stringify(out.json));
41
+ return;
42
+ }
43
+ res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
44
+ const payload = out.sse
45
+ .map((e) => `${e.event !== undefined ? `event: ${e.event}\n` : ""}data: ${e.data}\n\n`)
46
+ .join("");
47
+ const finish = out.keepOpen ? () => res.write(payload) : () => res.end(payload);
48
+ if (out.delayMs) {
49
+ const t = setTimeout(finish, out.delayMs);
50
+ res.on("close", () => clearTimeout(t));
51
+ }
52
+ else {
53
+ finish();
54
+ }
55
+ });
56
+ });
57
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
58
+ const { port } = server.address();
59
+ return {
60
+ url: `http://127.0.0.1:${port}`,
61
+ requests,
62
+ stop: () => new Promise((resolve) => {
63
+ server.close(() => resolve());
64
+ server.closeAllConnections();
65
+ }),
66
+ };
67
+ }
@@ -0,0 +1,59 @@
1
+ /** 假 registry 上的一个包版本。 */
2
+ export interface FakeNpmPackage {
3
+ /** npm 包名(可带 scope)。 */
4
+ name: string;
5
+ version: string;
6
+ tgz: Uint8Array;
7
+ /** 元数据里的 `dist.integrity`;不给 = 按 tgz 算出的 sha512(可故意给错)。 */
8
+ integrity?: string;
9
+ /** 元数据里的 `dist.tarball`;不给 = 本服务上的地址(给别的源测「不同源」)。 */
10
+ tarballUrl?: string;
11
+ /** tarball 地址先 302 到真实地址(测「不跟随重定向」)。 */
12
+ redirectTarball?: boolean;
13
+ /** 打上的 dist-tag(如 `latest`);同名包没有任何版本带 `latest` 时取最后一个版本。 */
14
+ tags?: string[];
15
+ /**
16
+ * The body of `GET /-/npm/v1/attestations/<name>@<version>` (e.g. from
17
+ * {@link npmProvenanceAttestations}); not given = 404, as for a package published without provenance.
18
+ */
19
+ attestations?: unknown;
20
+ }
21
+ export interface FakeNpmRegistry {
22
+ /** registry 基址 `http://127.0.0.1:<port>`。 */
23
+ url: string;
24
+ /** 收到的请求路径(含查询串),按到达顺序。 */
25
+ requests: string[];
26
+ /** 收到的 `authorization` 请求头(没有为 undefined),与 {@link requests} 一一对应。 */
27
+ authorizations: (string | undefined)[];
28
+ /** The `accept` request header of each request (undefined when absent), as {@link requests}. */
29
+ accepts: (string | undefined)[];
30
+ stop(): Promise<void>;
31
+ }
32
+ /**
33
+ * An attestations answer in the shape npm serves (`{ attestations: [...] }`): the npm publish
34
+ * attestation and the SLSA v1 provenance, each a sigstore bundle with a DSSE envelope whose payload
35
+ * is a base64 in-toto statement about `pkg:npm/<name>@<version>` (subject digest = the tgz's sha512,
36
+ * or `subjectSha512` to make it about another tarball). The signatures are placeholders (the kernel
37
+ * does not verify them). `predicate` replaces the SLSA predicate (to test missing fields).
38
+ */
39
+ export declare function npmProvenanceAttestations(o: {
40
+ name: string;
41
+ version: string;
42
+ tgz: Uint8Array;
43
+ repository: string;
44
+ commit: string;
45
+ workflow?: string;
46
+ ref?: string;
47
+ subjectSha512?: string;
48
+ predicate?: unknown;
49
+ }): unknown;
50
+ /**
51
+ * A fake npm registry: `GET /<name>` (a scoped package written as npm does, `@scope%2fname`; an
52
+ * unencoded `/` is accepted too) returns the packument (`versions[v].dist.{tarball, integrity}` and
53
+ * `dist-tags`), `GET /<name>/-/<file>.tgz` returns the tarball, and
54
+ * `GET /-/npm/v1/attestations/<name>@<version>` returns that version's `attestations` (404 when it
55
+ * has none). Unknown packages are 404.
56
+ */
57
+ export declare function startFakeNpmRegistry(opts: {
58
+ packages: FakeNpmPackage[];
59
+ }): Promise<FakeNpmRegistry>;