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

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 +40 -2
  4. package/dist/containers.d.ts +102 -0
  5. package/dist/containers.js +314 -0
  6. package/dist/dual-replica.d.ts +142 -0
  7. package/dist/dual-replica.js +270 -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 +49 -0
  33. package/dist/fixtures.js +92 -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 +67 -3
@@ -0,0 +1,123 @@
1
+ import {
2
+ defineExtension,
3
+ type ExtensionContext,
4
+ OBJECT_STORE_EXTENSION_POINT,
5
+ type ObjectHead,
6
+ type ObjectStore,
7
+ secret,
8
+ } from "@capybara-hq/plugin-sdk";
9
+ import { z } from "zod";
10
+
11
+ /**
12
+ * Test fake for the bootstrap step: an object store (`object-store@1`) that keeps objects in its
13
+ * own PostgreSQL table, so bootstrap tests need no S3 container. Its deployment config has a
14
+ * non-secret `bucket` (can come from the admin console, i.e. PG) and a required secret `secretKey`
15
+ * (from the startup config), so an unconfigured deployment leaves it pending configuration, as the
16
+ * image's object store is. Presigning is not supported (it throws): bootstrap tests never upload
17
+ * from a browser.
18
+ */
19
+
20
+ const DeploymentConfig = z.object({
21
+ bucket: z.string().min(1),
22
+ secretKey: secret(z.string().min(1)),
23
+ });
24
+
25
+ type Db = ExtensionContext["db"];
26
+
27
+ const PAGE_MAX = 1000;
28
+
29
+ const toBytes = async (body: Uint8Array | ReadableStream<Uint8Array>): Promise<Uint8Array> =>
30
+ body instanceof Uint8Array ? body : new Uint8Array(await new Response(body).arrayBuffer());
31
+
32
+ function createPgObjectStore(db: Db, bucket: string): ObjectStore {
33
+ const objects = () => db.selectFrom("objects").where("bucket", "=", bucket);
34
+ const headOf = (r: { key: string; size: number | string; content_type: string }): ObjectHead => ({
35
+ key: r.key,
36
+ size: Number(r.size),
37
+ contentType: r.content_type,
38
+ });
39
+ return {
40
+ async put(key, body, opts) {
41
+ const data = await toBytes(body);
42
+ await db
43
+ .insertInto("objects")
44
+ .values({ bucket, key, content_type: opts.contentType, data: Buffer.from(data) })
45
+ .onConflict((oc) =>
46
+ oc.columns(["bucket", "key"]).doUpdateSet((eb) => ({
47
+ content_type: eb.ref("excluded.content_type"),
48
+ data: eb.ref("excluded.data"),
49
+ updated_at: new Date(),
50
+ })),
51
+ )
52
+ .execute();
53
+ return { key, size: data.byteLength, contentType: opts.contentType };
54
+ },
55
+ async get(key, opts) {
56
+ const row = await objects()
57
+ .select(["key", "content_type", "data"])
58
+ .where("key", "=", key)
59
+ .executeTakeFirst();
60
+ if (row === undefined) return null;
61
+ const all = new Uint8Array(row.data as Buffer);
62
+ const range = opts?.range;
63
+ const bytes =
64
+ range === undefined ? all : all.subarray(range.start, (range.end ?? all.length - 1) + 1);
65
+ return {
66
+ head: { key, size: all.byteLength, contentType: row.content_type as string },
67
+ body: new Response(bytes).body as ReadableStream<Uint8Array>,
68
+ };
69
+ },
70
+ async head(key) {
71
+ const row = await objects()
72
+ .select(["key", "content_type"])
73
+ .select((eb) => eb.fn<number>("octet_length", ["data"]).as("size"))
74
+ .where("key", "=", key)
75
+ .executeTakeFirst();
76
+ return row === undefined ? null : headOf(row);
77
+ },
78
+ async delete(key) {
79
+ await db.deleteFrom("objects").where("bucket", "=", bucket).where("key", "=", key).execute();
80
+ },
81
+ async deletePrefix(prefix) {
82
+ const r = await db
83
+ .deleteFrom("objects")
84
+ .where("bucket", "=", bucket)
85
+ .where((eb) => eb.fn<boolean>("starts_with", [eb.ref("key"), eb.val(prefix)]))
86
+ .executeTakeFirst();
87
+ return Number(r.numDeletedRows);
88
+ },
89
+ async list(prefix, opts) {
90
+ const limit = Math.min(Math.max(1, opts?.limit ?? PAGE_MAX), PAGE_MAX);
91
+ let q = objects()
92
+ .select(["key", "content_type"])
93
+ .select((eb) => eb.fn<number>("octet_length", ["data"]).as("size"))
94
+ .where((eb) => eb.fn<boolean>("starts_with", [eb.ref("key"), eb.val(prefix)]))
95
+ .orderBy("key")
96
+ .limit(limit + 1);
97
+ if (opts?.cursor !== undefined) q = q.where("key", ">", opts.cursor);
98
+ const rows = await q.execute();
99
+ const items = rows.slice(0, limit).map((r) => ({ ...headOf(r), contentType: "" }));
100
+ const last = items.at(-1);
101
+ return rows.length > limit && last !== undefined ? { items, cursor: last.key } : { items };
102
+ },
103
+ async presignGet() {
104
+ throw new Error("fake-bootstrap-b: presigned URLs are not supported");
105
+ },
106
+ async presignPut() {
107
+ throw new Error("fake-bootstrap-b: presigned URLs are not supported");
108
+ },
109
+ };
110
+ }
111
+
112
+ export default defineExtension({
113
+ name: "fake-bootstrap-b",
114
+ version: "0.1.0",
115
+ sdk: "^0.1.0-rc.0",
116
+ provides: [OBJECT_STORE_EXTENSION_POINT],
117
+ migrations: "../migrations",
118
+ config: { deployment: DeploymentConfig },
119
+ setup(ctx) {
120
+ const { bucket } = DeploymentConfig.parse(ctx.config);
121
+ ctx.provide(OBJECT_STORE_EXTENSION_POINT, createPgObjectStore(ctx.db, bucket));
122
+ },
123
+ });
@@ -0,0 +1,7 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "name": "fake-connector",
4
+ "version": "0.1.0",
5
+ "publisher": "Capybara",
6
+ "description": "Test fake: connector@1 (kind fake) for startFakeMcpServer, with the connector template fake-demo"
7
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "displayName": { "zh": "演示连接器", "en": "Demo connector" },
3
+ "description": "A connector template for the fake connector (kind fake)",
4
+ "template": {
5
+ "plugin": "fake-connector",
6
+ "connector": "fake",
7
+ "endpoint": { "url": "https://mcp.example.com/mcp" },
8
+ "editableEndpoint": ["url"],
9
+ "auth": { "type": "bearer", "ownership": "org" },
10
+ "defaultToolPolicies": { "search": "allow" }
11
+ }
12
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "@capybara-hq/test-plugin-fake-connector",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "Test fake: connector@1 (kind fake), a small MCP client for startFakeMcpServer",
6
+ "license": "Apache-2.0",
7
+ "type": "module",
8
+ "dependencies": {
9
+ "@capybara-hq/plugin-sdk": "^0.1.0-rc.0",
10
+ "@modelcontextprotocol/sdk": "^1.32.1",
11
+ "zod": "^4.6.7"
12
+ }
13
+ }
@@ -0,0 +1,477 @@
1
+ import {
2
+ type AuthHint,
3
+ CONNECTOR_EXTENSION_POINT,
4
+ type Connector,
5
+ type ConnectorInspection,
6
+ type ConnectorOpenArgs,
7
+ type ConnectorSession,
8
+ defineExtension,
9
+ type ElicitationRequest,
10
+ type Logger,
11
+ type PromptInfo,
12
+ type ToolContent,
13
+ ToolError,
14
+ type ToolResult,
15
+ type ToolSpec,
16
+ } from "@capybara-hq/plugin-sdk";
17
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
18
+ import {
19
+ StreamableHTTPClientTransport,
20
+ StreamableHTTPError,
21
+ } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
22
+ import {
23
+ ElicitRequestSchema,
24
+ ElicitRequestURLParamsSchema,
25
+ ErrorCode,
26
+ McpError,
27
+ ToolListChangedNotificationSchema,
28
+ } from "@modelcontextprotocol/sdk/types.js";
29
+ import { z } from "zod";
30
+
31
+ /**
32
+ * Test fake of a connector extension (`connector@1`, kind `fake` unless the deployment config
33
+ * `kind` names another): a deliberately small MCP client
34
+ * (Streamable HTTP) meant for `startFakeMcpServer` from `@capybara-hq/testing`. It covers the
35
+ * kernel side of the connector contract, not MCP edge cases:
36
+ * - `endpointSchema`: `{ url, headers? }`, https or (with `allowInsecureLoopback`, default on)
37
+ * loopback http; credential headers are rejected;
38
+ * - `auth()` before every request, one `onUnauthorized()` + retry on 401, redirects not followed;
39
+ * a 5xx answer's body is logged as the warning `fake-connector request failed` (the kernel
40
+ * redacts credentials in extension logs);
41
+ * - `listTools` puts `{ tool: <route> }` into `connectorData`, where the route is the tool's
42
+ * `_meta["fake/route"]` (the name of the remote tool that serves it) or else its name; `callTool`
43
+ * calls the route of `ctx.spec.connectorData` when the kernel passes it, so a route change
44
+ * without a rename is a definition drift and calls keep the reviewed route until approval;
45
+ * - the endpoint's optional `notesText` is free-form text the connector ignores (a `*Text` field
46
+ * the kernel summarizes in connector views instead of echoing it);
47
+ * - the URL-mode elicitation error `-32042` goes through `onElicit` (each step accepted -> one
48
+ * retry), as connector-mcp does;
49
+ * - `onToolsChanged` (MCP `tools/list_changed`), `onElicit` (MCP `elicitation/create`), resources
50
+ * and prompts when the server declares them;
51
+ * - `inspect`: an unauthenticated `tools/list`; a 401 yields the auth hint the deployment config
52
+ * `inspectAuth` picks (`bearer`, default, or `api_key` in header `X-API-Key`), success yields
53
+ * the tools as operations.
54
+ */
55
+
56
+ /** Model-visible text after the one refresh-and-retry still got 401. */
57
+ const AUTH_FAILED_TEXT = "Authentication failed for this connector; ask the user to reconnect it.";
58
+
59
+ /** Model-visible text when the server cannot be reached. */
60
+ const UNREACHABLE_TEXT = "Could not reach the connector's server; retry later.";
61
+
62
+ const CLIENT_INFO = { name: "capybara-fake-connector", version: "0.1.0" };
63
+
64
+ /** The kernel's pipeline timeout (through the signal) decides, not the SDK's default 60 s. */
65
+ const REQUEST_TIMEOUT_MS = 3_600_000;
66
+
67
+ const DeploymentConfig = z.object({
68
+ /** Accept http://127.0.0.1 / localhost / [::1] endpoints (on by default: this is a test fake). */
69
+ allowInsecureLoopback: z.boolean().default(true),
70
+ /** The auth hint `inspect` reports behind a 401. */
71
+ inspectAuth: z.enum(["bearer", "api_key"]).default("bearer"),
72
+ /**
73
+ * The connector kind it provides (default `fake`). A deployment that loads no other connector
74
+ * extension can name it `mcp`, so clients that know only the official kinds (the web install
75
+ * wizard) drive it like an MCP connector.
76
+ */
77
+ kind: z
78
+ .string()
79
+ .regex(/^[a-z][a-z0-9-]{0,31}$/)
80
+ .default("fake"),
81
+ });
82
+
83
+ /** The `_meta` key of a remote tool that names the remote tool serving it (its route). */
84
+ export const FAKE_ROUTE_META = "fake/route";
85
+
86
+ const isLoopbackHttp = (u: URL): boolean =>
87
+ u.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(u.hostname);
88
+
89
+ const urlAllowed = (raw: string, loopback: boolean): boolean => {
90
+ try {
91
+ const u = new URL(raw);
92
+ return u.protocol === "https:" || (loopback && isLoopbackHttp(u));
93
+ } catch {
94
+ return true; // z.url() reports the format error
95
+ }
96
+ };
97
+
98
+ const isCredentialHeader = (name: string): boolean =>
99
+ ["authorization", "proxy-authorization", "cookie"].includes(name.toLowerCase());
100
+
101
+ /** The endpoint: the server URL and optional non-secret fixed headers. */
102
+ export function fakeEndpointSchema(allowInsecureLoopback: boolean) {
103
+ return z
104
+ .object({
105
+ url: z.url().refine((v) => urlAllowed(v, allowInsecureLoopback), {
106
+ message: "URL must use https (plain http only for loopback hosts)",
107
+ }),
108
+ /** Free-form text the connector ignores (large-endpoint tests). */
109
+ notesText: z.string().optional(),
110
+ headers: z
111
+ .record(z.string(), z.string())
112
+ .default({})
113
+ .refine((h) => !Object.keys(h).some(isCredentialHeader), {
114
+ message: "Do not put credentials in endpoint headers; configure the connector auth",
115
+ }),
116
+ })
117
+ .strict();
118
+ }
119
+
120
+ type Endpoint = z.infer<ReturnType<typeof fakeEndpointSchema>>;
121
+
122
+ const isObj = (v: unknown): v is Record<string, unknown> =>
123
+ typeof v === "object" && v !== null && !Array.isArray(v);
124
+
125
+ function mapContent(c: unknown): ToolContent {
126
+ if (isObj(c) && c.type === "text") return { type: "text", text: String(c.text ?? "") };
127
+ if (isObj(c) && c.type === "image") {
128
+ return { type: "image", mimeType: String(c.mimeType ?? ""), data: String(c.data ?? "") };
129
+ }
130
+ return { type: "text", text: `[Unsupported content omitted]` };
131
+ }
132
+
133
+ function mapTool(raw: Record<string, unknown>): ToolSpec {
134
+ const name = String(raw.name);
135
+ const description =
136
+ typeof raw.description === "string" && raw.description.trim() !== "" ? raw.description : name;
137
+ const meta = isObj(raw._meta) ? raw._meta : {};
138
+ const route = typeof meta[FAKE_ROUTE_META] === "string" ? meta[FAKE_ROUTE_META] : name;
139
+ return {
140
+ name,
141
+ description,
142
+ inputSchema: isObj(raw.inputSchema) ? raw.inputSchema : {},
143
+ ...(isObj(raw.annotations) && { annotations: raw.annotations }),
144
+ connectorData: { tool: route },
145
+ };
146
+ }
147
+
148
+ /** Maps errors to ToolError (5xx and connection failures count for the kernel's breaker). */
149
+ function mapError(e: unknown): unknown {
150
+ if (e instanceof ToolError) return e;
151
+ if (e instanceof McpError) {
152
+ return new ToolError(
153
+ `The connector returned an error: ${e.message.replace(/^(MCP error -?\d+: )+/, "")}; check the arguments and retry.`,
154
+ );
155
+ }
156
+ if (e instanceof StreamableHTTPError && typeof e.code === "number" && e.code >= 400) {
157
+ return new ToolError(`The connector's server responded with HTTP ${e.code}; retry later.`, {
158
+ kind: e.code >= 500 ? "unavailable" : "error",
159
+ });
160
+ }
161
+ if (e instanceof TypeError && e.message === "fetch failed") {
162
+ return new ToolError(UNREACHABLE_TEXT, { kind: "unavailable" });
163
+ }
164
+ return e;
165
+ }
166
+
167
+ async function guarded<T>(work: () => Promise<T>): Promise<T> {
168
+ try {
169
+ return await work();
170
+ } catch (e) {
171
+ throw mapError(e);
172
+ }
173
+ }
174
+
175
+ /** A fetch that injects `auth()` before every request and retries once after `onUnauthorized()`. */
176
+ function authedFetch(
177
+ endpoint: Endpoint,
178
+ auth: ConnectorOpenArgs["auth"] | undefined,
179
+ onUnauthorized: ConnectorOpenArgs["onUnauthorized"] | undefined,
180
+ signal: AbortSignal,
181
+ log?: Logger,
182
+ ) {
183
+ return async (input: string | URL, init?: RequestInit): Promise<Response> => {
184
+ const method = (init?.method ?? "GET").toUpperCase();
185
+ const send = async (): Promise<Response> => {
186
+ const inj = auth ? await auth() : { headers: {} };
187
+ const url = new URL(input);
188
+ for (const [k, v] of Object.entries(inj.query ?? {})) url.searchParams.set(k, v);
189
+ const headers = new Headers(init?.headers);
190
+ for (const [k, v] of Object.entries(endpoint.headers)) if (!headers.has(k)) headers.set(k, v);
191
+ for (const [k, v] of Object.entries(inj.headers)) headers.set(k, v);
192
+ const s = init?.signal ? AbortSignal.any([init.signal, signal]) : signal;
193
+ return fetch(url, { ...init, headers, signal: s, redirect: "manual" });
194
+ };
195
+ const noRedirect = async (r: Response) => {
196
+ if (r.status >= 300 && r.status < 400) {
197
+ await r.body?.cancel();
198
+ throw new ToolError(
199
+ `The connector's server responded with a redirect (HTTP ${r.status}); ask an administrator to check the endpoint URL.`,
200
+ );
201
+ }
202
+ };
203
+ let res = await send();
204
+ await noRedirect(res);
205
+ // GET is the optional standalone SSE stream; the SDK ignores its failures
206
+ if (res.status === 401 && method !== "GET" && onUnauthorized) {
207
+ await res.body?.cancel();
208
+ await onUnauthorized();
209
+ res = await send();
210
+ await noRedirect(res);
211
+ if (res.status === 401) {
212
+ await res.body?.cancel();
213
+ throw new ToolError(AUTH_FAILED_TEXT);
214
+ }
215
+ }
216
+ // A server error's body goes to the extension log, where the kernel redacts credentials
217
+ if (res.status >= 500 && method === "POST") {
218
+ const body = await res
219
+ .clone()
220
+ .text()
221
+ .catch(() => "");
222
+ log?.warn("fake-connector request failed", { status: res.status, body: body.slice(0, 500) });
223
+ }
224
+ return res;
225
+ };
226
+ }
227
+
228
+ async function connect(
229
+ endpoint: Endpoint,
230
+ fetchImpl: ReturnType<typeof authedFetch>,
231
+ args: Partial<ConnectorOpenArgs> & { signal: AbortSignal },
232
+ ): Promise<{ client: Client; transport: StreamableHTTPClientTransport }> {
233
+ const transport = new StreamableHTTPClientTransport(new URL(endpoint.url), { fetch: fetchImpl });
234
+ const onElicit = args.onElicit;
235
+ const client = new Client(CLIENT_INFO, {
236
+ capabilities: onElicit ? { elicitation: { form: {}, url: {} } } : {},
237
+ });
238
+ client.onerror = () => {};
239
+ if (onElicit) {
240
+ client.setRequestHandler(ElicitRequestSchema, async (req) => {
241
+ const p = req.params;
242
+ const request: ElicitationRequest =
243
+ p.mode === "url"
244
+ ? { mode: "url", message: p.message, url: p.url, elicitationId: p.elicitationId }
245
+ : {
246
+ mode: "form",
247
+ message: p.message,
248
+ requestedSchema: p.requestedSchema as Record<string, unknown>,
249
+ };
250
+ const r = await onElicit(request);
251
+ return r.action === "accept"
252
+ ? { action: "accept", ...(r.content !== undefined && { content: r.content }) }
253
+ : { action: r.action };
254
+ });
255
+ }
256
+ const onToolsChanged = args.onToolsChanged;
257
+ if (onToolsChanged) {
258
+ client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
259
+ try {
260
+ onToolsChanged();
261
+ } catch {
262
+ // the callback must not fail the call
263
+ }
264
+ });
265
+ }
266
+ try {
267
+ // The SDK's own Transport type does not satisfy exactOptionalPropertyTypes (see connector-mcp)
268
+ const t = transport as unknown as Parameters<Client["connect"]>[0];
269
+ await guarded(() => client.connect(t, { signal: args.signal, timeout: REQUEST_TIMEOUT_MS }));
270
+ } catch (e) {
271
+ await client.close().catch(() => {});
272
+ throw e;
273
+ }
274
+ return { client, transport };
275
+ }
276
+
277
+ async function openSession(
278
+ endpoint: Endpoint,
279
+ args: ConnectorOpenArgs,
280
+ log?: Logger,
281
+ ): Promise<ConnectorSession> {
282
+ const fetchImpl = authedFetch(endpoint, args.auth, args.onUnauthorized, args.signal, log);
283
+ const { client, transport } = await connect(endpoint, fetchImpl, args);
284
+ const opts = (signal: AbortSignal = args.signal) => ({ signal, timeout: REQUEST_TIMEOUT_MS });
285
+ const session: ConnectorSession = {
286
+ listTools: () =>
287
+ guarded(async () => {
288
+ const r = await client.listTools({}, opts());
289
+ return r.tools.map((t) => mapTool(t as Record<string, unknown>));
290
+ }),
291
+ callTool: (name, input, signal, ctx) =>
292
+ guarded(async () => {
293
+ const routed = ctx?.spec.connectorData?.tool;
294
+ const call = async () =>
295
+ (await client.callTool(
296
+ {
297
+ name: typeof routed === "string" ? routed : name,
298
+ arguments: isObj(input) ? input : {},
299
+ },
300
+ undefined,
301
+ opts(AbortSignal.any([signal, args.signal])),
302
+ )) as Record<string, unknown>;
303
+ let r: Record<string, unknown>;
304
+ try {
305
+ r = await call();
306
+ } catch (e) {
307
+ // URL-mode elicitation as an error (-32042): each step through onElicit, then one retry
308
+ const steps = urlElicitations(e);
309
+ if (!steps) throw e;
310
+ if (!args.onElicit) {
311
+ throw new ToolError(
312
+ `The connector requires a browser step first: ${steps.map((x) => x.url).join(", ")}; ask the user to complete it, then retry.`,
313
+ );
314
+ }
315
+ for (const step of steps) {
316
+ const answer = await args.onElicit(step);
317
+ if (answer.action !== "accept") {
318
+ throw new ToolError(
319
+ `The user did not complete the step the connector required (${step.message}); ask the user whether to try again.`,
320
+ );
321
+ }
322
+ }
323
+ r = await call();
324
+ }
325
+ const content = Array.isArray(r.content) ? r.content.map(mapContent) : [];
326
+ return { content, ...(r.isError === true && { isError: true }) } satisfies ToolResult;
327
+ }),
328
+ async close() {
329
+ if (transport.sessionId) await transport.terminateSession().catch(() => {});
330
+ await client.close();
331
+ },
332
+ };
333
+ const caps = client.getServerCapabilities();
334
+ if (caps?.resources) {
335
+ session.listResources = () =>
336
+ guarded(async () =>
337
+ (await client.listResources({}, opts())).resources.map((x) => ({
338
+ uri: x.uri,
339
+ name: x.name,
340
+ ...(typeof x.description === "string" && { description: x.description }),
341
+ ...(typeof x.mimeType === "string" && { mimeType: x.mimeType }),
342
+ })),
343
+ );
344
+ session.readResource = (uri) =>
345
+ guarded(async () => {
346
+ const r = await client.readResource({ uri }, opts());
347
+ return {
348
+ content: r.contents.map((c) =>
349
+ "text" in c && typeof c.text === "string"
350
+ ? { type: "text" as const, text: c.text }
351
+ : { type: "text" as const, text: `[Binary resource ${uri} omitted]` },
352
+ ),
353
+ };
354
+ });
355
+ }
356
+ if (caps?.prompts) {
357
+ session.listPrompts = () =>
358
+ guarded(async () =>
359
+ (await client.listPrompts({}, opts())).prompts.map(
360
+ (p): PromptInfo => ({
361
+ name: p.name,
362
+ ...(typeof p.title === "string" && { title: p.title }),
363
+ ...(typeof p.description === "string" && { description: p.description }),
364
+ arguments: (p.arguments ?? []).map((a) => ({
365
+ name: a.name,
366
+ ...(typeof a.description === "string" && { description: a.description }),
367
+ ...(typeof a.required === "boolean" && { required: a.required }),
368
+ })),
369
+ }),
370
+ ),
371
+ );
372
+ session.getPrompt = (name, promptArgs) =>
373
+ guarded(async () => {
374
+ const r = await client.getPrompt({ name, arguments: promptArgs }, opts());
375
+ return {
376
+ messages: r.messages.map((m) => ({
377
+ role: m.role === "assistant" ? ("assistant" as const) : ("user" as const),
378
+ content: [mapContent(m.content)],
379
+ })),
380
+ };
381
+ });
382
+ }
383
+ return session;
384
+ }
385
+
386
+ /** The steps of a `-32042` (URL elicitation required) error; undefined for anything else. */
387
+ function urlElicitations(e: unknown): Extract<ElicitationRequest, { mode: "url" }>[] | undefined {
388
+ if (!(e instanceof McpError) || e.code !== ErrorCode.UrlElicitationRequired) return undefined;
389
+ const raw = (e.data as { elicitations?: unknown } | undefined)?.elicitations;
390
+ if (!Array.isArray(raw) || raw.length === 0) return undefined;
391
+ const out: Extract<ElicitationRequest, { mode: "url" }>[] = [];
392
+ for (const r of raw) {
393
+ const p = ElicitRequestURLParamsSchema.safeParse(r);
394
+ if (!p.success) return undefined;
395
+ out.push({
396
+ mode: "url",
397
+ message: p.data.message,
398
+ url: p.data.url,
399
+ elicitationId: p.data.elicitationId,
400
+ });
401
+ }
402
+ return out;
403
+ }
404
+
405
+ type InspectAuth = z.infer<typeof DeploymentConfig>["inspectAuth"];
406
+
407
+ /** The auth hint of {@link inspect} behind a 401. */
408
+ const authHint = (kind: InspectAuth): AuthHint =>
409
+ kind === "api_key"
410
+ ? { scheme: "apiKey", type: "api_key", in: "header", name: "X-API-Key" }
411
+ : { scheme: "bearer", type: "bearer" };
412
+
413
+ /** Install preview without credentials: 401 -> the configured auth hint; otherwise the tools as operations. */
414
+ async function inspect(
415
+ endpoint: Endpoint,
416
+ signal: AbortSignal,
417
+ hint: InspectAuth,
418
+ ): Promise<ConnectorInspection> {
419
+ let unauthorized = false;
420
+ const probe = authedFetch(endpoint, undefined, undefined, signal);
421
+ const fetchImpl = async (input: string | URL, init?: RequestInit) => {
422
+ const r = await probe(input, init);
423
+ if (r.status === 401) unauthorized = true;
424
+ return r;
425
+ };
426
+ try {
427
+ const { client } = await connect(endpoint, fetchImpl, { signal });
428
+ try {
429
+ const tools = (await client.listTools({}, { signal, timeout: REQUEST_TIMEOUT_MS })).tools;
430
+ return {
431
+ authHints: [],
432
+ operations: tools.map((t) => ({
433
+ id: t.name,
434
+ method: "POST",
435
+ path: `/tools/${t.name}`,
436
+ ...(typeof t.description === "string" && { summary: t.description }),
437
+ })),
438
+ };
439
+ } finally {
440
+ await client.close().catch(() => {});
441
+ }
442
+ } catch (e) {
443
+ if (unauthorized) return { authHints: [authHint(hint)] };
444
+ throw mapError(e);
445
+ }
446
+ }
447
+
448
+ /** Creates the fake connector (exported for unit tests that call it without a kernel). */
449
+ export function createFakeConnector(
450
+ allowInsecureLoopback: boolean,
451
+ inspectAuth: InspectAuth = "bearer",
452
+ kind = "fake",
453
+ log?: Logger,
454
+ ): Connector {
455
+ const schema = fakeEndpointSchema(allowInsecureLoopback);
456
+ return {
457
+ kind,
458
+ endpointSchema: schema,
459
+ open: (args) => openSession(schema.parse(args.endpoint), args, log),
460
+ inspect: ({ endpoint, signal }) => inspect(schema.parse(endpoint), signal, inspectAuth),
461
+ };
462
+ }
463
+
464
+ export default defineExtension({
465
+ name: "fake-connector",
466
+ version: "0.1.0",
467
+ sdk: "^0.1.0-rc.0",
468
+ provides: [CONNECTOR_EXTENSION_POINT],
469
+ config: { deployment: DeploymentConfig },
470
+ setup(ctx) {
471
+ const { allowInsecureLoopback, inspectAuth, kind } = DeploymentConfig.parse(ctx.config ?? {});
472
+ ctx.provide(
473
+ CONNECTOR_EXTENSION_POINT,
474
+ createFakeConnector(allowInsecureLoopback, inspectAuth, kind, ctx.log),
475
+ );
476
+ },
477
+ });
@@ -0,0 +1,7 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "name": "fake-dep-provider",
4
+ "version": "0.1.0",
5
+ "publisher": "Capybara",
6
+ "description": "Test fake: provides counter@1 (the provider side of a dependency pair)"
7
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "@capybara-hq/test-plugin-fake-dep-provider",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "Test fake: provides counter@1 (the provider side of a dependency pair)",
6
+ "license": "Apache-2.0",
7
+ "type": "module",
8
+ "dependencies": {
9
+ "@capybara-hq/plugin-sdk": "^0.1.0-rc.0",
10
+ "zod": "^4.6.7"
11
+ }
12
+ }
@@ -0,0 +1,19 @@
1
+ import { defineExtension } from "@capybara-hq/plugin-sdk";
2
+
3
+ /**
4
+ * Test fake: the provider side of a dependency pair. Provides `counter@1` (an object with
5
+ * `ping()` returning `pong from fake-dep-provider`) and the route `GET /ping` with the same text.
6
+ * `fake-dep-requirer` requires the point and lists this plugin as a plugin dependency.
7
+ */
8
+ export default defineExtension({
9
+ name: "fake-dep-provider",
10
+ version: "0.1.0",
11
+ sdk: "^0.1.0-rc.0",
12
+ provides: ["counter@1"],
13
+ permissions: { http: [{ method: "GET", path: "/ping" }] },
14
+ setup(ctx) {
15
+ const pong = "pong from fake-dep-provider";
16
+ ctx.provide("counter@1", { ping: () => pong });
17
+ ctx.http.route("GET", "/ping", async () => new Response(pong));
18
+ },
19
+ });
@@ -0,0 +1,8 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "name": "fake-dep-requirer",
4
+ "version": "0.1.0",
5
+ "publisher": "Capybara",
6
+ "description": "Test fake: requires counter@1 and depends on the plugin fake-dep-provider",
7
+ "dependencies": [{ "name": "fake-dep-provider", "version": "^0.1.0" }]
8
+ }