@corbits/mcp 0.1.0 → 0.3.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.
@@ -0,0 +1,322 @@
1
+ import { type } from "arktype";
2
+ import { OAuthDiscoveryError, } from "@corbits/oauth-core";
3
+ // Generic MCP-OAuth discovery (RFC 9728 protected-resource metadata, RFC 8414
4
+ // authorization-server metadata) plus RFC 7591 dynamic client registration.
5
+ // Nothing here names a provider: the caller supplies the MCP server's resource
6
+ // URL and gets back the endpoints plus a registered client id it can feed to
7
+ // buildAuthorizeUrl/exchangeCode. Hosts persist the client id alongside the
8
+ // tokens so refresh keeps working after the login completes.
9
+ //
10
+ // Grant types, scopes, and the PKCE code-challenge method are negotiated from
11
+ // server metadata rather than hard-coded, per the MCP authorization spec
12
+ // (https://modelcontextprotocol.io/specification/latest/basic/authorization)
13
+ // and its "Client Registration" and "Security Considerations" sub-pages.
14
+ // RFC 9728 §2 — protected-resource metadata. Only the fields discovery needs
15
+ // are validated; anything else the server sends is ignored.
16
+ const ProtectedResourceMetadata = type({
17
+ resource: "string",
18
+ "authorization_servers?": "string[]",
19
+ "scopes_supported?": "string[]",
20
+ });
21
+ // RFC 8414 §2 — authorization-server metadata, narrowed to the endpoints and
22
+ // capabilities a loopback PKCE login needs. `registration_endpoint` is
23
+ // optional: its absence means the server does not support dynamic
24
+ // registration.
25
+ const AuthorizationServerMetadata = type({
26
+ issuer: "string",
27
+ authorization_endpoint: "string",
28
+ token_endpoint: "string",
29
+ "registration_endpoint?": "string",
30
+ "grant_types_supported?": "string[]",
31
+ "scopes_supported?": "string[]",
32
+ "token_endpoint_auth_methods_supported?": "string[]",
33
+ "code_challenge_methods_supported?": "string[]",
34
+ });
35
+ // RFC 7591 §3.2.1 — registration response, narrowed to the assigned client id
36
+ // plus what the server echoes back as actually granted. A server MAY narrow
37
+ // the request (e.g. drop refresh_token, trim scope); callers that need to
38
+ // persist what was granted read these instead of assuming the request stuck.
39
+ const ClientRegistrationResponse = type({
40
+ client_id: "string",
41
+ "grant_types?": "string[]",
42
+ "scope?": "string",
43
+ });
44
+ const DEFAULT_TIMEOUT_MS = 10_000;
45
+ const DEFAULT_TOKEN_TIMEOUT_MS = 30_000;
46
+ // Build a well-known URI by inserting `/.well-known/<suffix>` before the
47
+ // URL's path (RFC 8414 §3.1, RFC 9728 §3.1): for a bare origin the well-known
48
+ // path stands alone, for `https://host/mcp` it becomes
49
+ // `https://host/.well-known/<suffix>/mcp`.
50
+ function wellKnownUrl(raw, suffix) {
51
+ let parsed;
52
+ try {
53
+ parsed = new URL(raw);
54
+ }
55
+ catch {
56
+ throw new OAuthDiscoveryError(`not a valid URL: ${raw}`);
57
+ }
58
+ const path = parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/$/, "");
59
+ parsed.pathname = `/.well-known/${suffix}${path}`;
60
+ parsed.search = "";
61
+ parsed.hash = "";
62
+ return parsed.toString();
63
+ }
64
+ // RFC 8414 §3: the issuer a well-known URL was derived from, with the same
65
+ // trailing-slash normalization `wellKnownUrl` applies.
66
+ function normalizedIssuer(raw) {
67
+ const parsed = new URL(raw);
68
+ const path = parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/$/, "");
69
+ return `${parsed.origin}${path}`;
70
+ }
71
+ async function getJson(url, fetchImpl, timeoutMs) {
72
+ let res;
73
+ try {
74
+ res = await fetchImpl(url, {
75
+ headers: { accept: "application/json" },
76
+ signal: AbortSignal.timeout(timeoutMs),
77
+ });
78
+ }
79
+ catch (error) {
80
+ throw new OAuthDiscoveryError(`request to ${url} failed: ${error instanceof Error ? error.message : String(error)}`);
81
+ }
82
+ return res;
83
+ }
84
+ async function readJson(res, url) {
85
+ try {
86
+ return await res.json();
87
+ }
88
+ catch {
89
+ throw new OAuthDiscoveryError(`endpoint ${url} returned invalid JSON.`);
90
+ }
91
+ }
92
+ function badStatus(url, status) {
93
+ return new OAuthDiscoveryError(`endpoint ${url} returned status ${String(status)}.`);
94
+ }
95
+ // MCP authorization spec, "Security Considerations" → "Authorization Code
96
+ // Protection": if `code_challenge_methods_supported` is absent, or present
97
+ // without S256, the client MUST refuse to proceed rather than fall back to a
98
+ // weaker challenge method.
99
+ function assertSupportsS256(asMetadataUrl, codeChallengeMethodsSupported) {
100
+ if (codeChallengeMethodsSupported === undefined ||
101
+ !codeChallengeMethodsSupported.includes("S256"))
102
+ throw new OAuthDiscoveryError(`authorization server at ${asMetadataUrl} does not advertise PKCE S256 support (code_challenge_methods_supported).`);
103
+ }
104
+ // This package only ever registers a public loopback client
105
+ // (token_endpoint_auth_method: "none"). If the server advertises auth
106
+ // methods but excludes "none", it cannot accept this client at all.
107
+ function assertSupportsPublicClients(asMetadataUrl, tokenEndpointAuthMethodsSupported) {
108
+ if (tokenEndpointAuthMethodsSupported !== undefined &&
109
+ !tokenEndpointAuthMethodsSupported.includes("none"))
110
+ throw new OAuthDiscoveryError(`authorization server at ${asMetadataUrl} does not accept public clients (token_endpoint_auth_methods_supported omits "none").`);
111
+ }
112
+ // Resolve the authorization server for a resource URL: protected-resource
113
+ // metadata first (RFC 9728), falling back to authorization-server metadata
114
+ // served directly at the resource (RFC 8414 §3.1) for servers that skip the
115
+ // protected-resource document.
116
+ export async function discoverMcpLoginEntry(opts) {
117
+ const fetchImpl = opts.fetchImpl ?? fetch;
118
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
119
+ const resourceUrl = opts.resourceUrl;
120
+ try {
121
+ new URL(resourceUrl);
122
+ }
123
+ catch {
124
+ throw new OAuthDiscoveryError(`not a valid URL: ${resourceUrl}`);
125
+ }
126
+ let issuer;
127
+ let resourceScopesSupported;
128
+ const resourceMetadataUrl = wellKnownUrl(resourceUrl, "oauth-protected-resource");
129
+ const resourceRes = await getJson(resourceMetadataUrl, fetchImpl, timeoutMs);
130
+ if (resourceRes.ok) {
131
+ const payload = ProtectedResourceMetadata(await readJson(resourceRes, resourceMetadataUrl));
132
+ if (payload instanceof type.errors)
133
+ throw new OAuthDiscoveryError(`protected-resource metadata at ${resourceMetadataUrl} is malformed: ${payload.summary}`);
134
+ if (payload.resource !== resourceUrl)
135
+ throw new OAuthDiscoveryError(`protected-resource metadata resource mismatch: expected ${resourceUrl}, got ${payload.resource}.`);
136
+ issuer = payload.authorization_servers?.[0];
137
+ if (issuer === undefined)
138
+ throw new OAuthDiscoveryError(`protected-resource metadata at ${resourceMetadataUrl} names no authorization server.`);
139
+ resourceScopesSupported = payload.scopes_supported;
140
+ }
141
+ else if (resourceRes.status !== 404) {
142
+ throw badStatus(resourceMetadataUrl, resourceRes.status);
143
+ }
144
+ // No protected-resource document: the resource itself may serve
145
+ // authorization-server metadata (some MCP servers do).
146
+ const asMetadataUrl = issuer === undefined
147
+ ? wellKnownUrl(resourceUrl, "oauth-authorization-server")
148
+ : wellKnownUrl(issuer, "oauth-authorization-server");
149
+ const asRes = await getJson(asMetadataUrl, fetchImpl, timeoutMs);
150
+ if (!asRes.ok)
151
+ throw badStatus(asMetadataUrl, asRes.status);
152
+ const metadata = AuthorizationServerMetadata(await readJson(asRes, asMetadataUrl));
153
+ if (metadata instanceof type.errors)
154
+ throw new OAuthDiscoveryError(`authorization-server metadata at ${asMetadataUrl} is malformed: ${metadata.summary}`);
155
+ // RFC 8414 §3.3: metadata must name the issuer it was fetched for. In the
156
+ // fallback that issuer is the one the resource URL itself identifies.
157
+ const expectedIssuer = issuer ?? normalizedIssuer(resourceUrl);
158
+ if (!URL.canParse(metadata.issuer) ||
159
+ normalizedIssuer(metadata.issuer) !== normalizedIssuer(expectedIssuer))
160
+ throw new OAuthDiscoveryError(`authorization-server metadata issuer mismatch: expected ${expectedIssuer}, got ${metadata.issuer}.`);
161
+ assertSupportsS256(asMetadataUrl, metadata.code_challenge_methods_supported);
162
+ assertSupportsPublicClients(asMetadataUrl, metadata.token_endpoint_auth_methods_supported);
163
+ const authorizationServer = {
164
+ issuer: metadata.issuer,
165
+ authorizationEndpoint: metadata.authorization_endpoint,
166
+ tokenEndpoint: metadata.token_endpoint,
167
+ };
168
+ if (metadata.registration_endpoint !== undefined)
169
+ authorizationServer.registrationEndpoint = metadata.registration_endpoint;
170
+ if (metadata.grant_types_supported !== undefined)
171
+ authorizationServer.grantTypesSupported = metadata.grant_types_supported;
172
+ if (metadata.scopes_supported !== undefined)
173
+ authorizationServer.scopesSupported = metadata.scopes_supported;
174
+ if (metadata.token_endpoint_auth_methods_supported !== undefined)
175
+ authorizationServer.tokenEndpointAuthMethodsSupported =
176
+ metadata.token_endpoint_auth_methods_supported;
177
+ if (metadata.code_challenge_methods_supported !== undefined)
178
+ authorizationServer.codeChallengeMethodsSupported =
179
+ metadata.code_challenge_methods_supported;
180
+ const entry = { resourceUrl, authorizationServer };
181
+ if (resourceScopesSupported !== undefined)
182
+ entry.resourceScopesSupported = resourceScopesSupported;
183
+ return entry;
184
+ }
185
+ // RFC 7591 §3.2.1: request refresh_token only when the server's advertised
186
+ // grant_types_supported includes it, or the server didn't advertise grant
187
+ // types at all (nothing to negotiate against, so ask for what we want).
188
+ function grantTypesToRequest(grantTypesSupported) {
189
+ const wantsRefresh = grantTypesSupported === undefined ||
190
+ grantTypesSupported.includes("refresh_token");
191
+ return wantsRefresh
192
+ ? ["authorization_code", "refresh_token"]
193
+ : ["authorization_code"];
194
+ }
195
+ // The registration response MAY echo back what was actually granted, which
196
+ // can narrow the request. Fall back to what was requested when the server
197
+ // stays silent on a field.
198
+ function grantedGrantTypes(echoed, requested) {
199
+ return echoed ?? requested;
200
+ }
201
+ function grantedScope(echoed, requested) {
202
+ return echoed ?? requested;
203
+ }
204
+ /**
205
+ * Register a loopback public client with the authorization server (RFC 7591).
206
+ * Loopback clients cannot keep a secret, so registration requests
207
+ * `token_endpoint_auth_method: "none"`. `grant_types` is negotiated from
208
+ * `grantTypesSupported` (see grantTypesToRequest); `scope`, when supplied, is
209
+ * carried on the registration request per RFC 7591 §2. Throws
210
+ * OAuthDiscoveryError when the server has no registration endpoint or
211
+ * rejects the request.
212
+ */
213
+ export async function registerMcpClient(opts) {
214
+ const fetchImpl = opts.fetchImpl ?? fetch;
215
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
216
+ const requestedGrantTypes = grantTypesToRequest(opts.grantTypesSupported);
217
+ let res;
218
+ try {
219
+ res = await fetchImpl(opts.registrationEndpoint, {
220
+ method: "POST",
221
+ headers: {
222
+ "content-type": "application/json",
223
+ accept: "application/json",
224
+ },
225
+ body: JSON.stringify({
226
+ client_name: opts.clientName,
227
+ redirect_uris: opts.redirectUris,
228
+ grant_types: requestedGrantTypes,
229
+ response_types: ["code"],
230
+ token_endpoint_auth_method: "none",
231
+ scope: opts.scope,
232
+ }),
233
+ signal: AbortSignal.timeout(timeoutMs),
234
+ });
235
+ }
236
+ catch (error) {
237
+ throw new OAuthDiscoveryError(`registration request to ${opts.registrationEndpoint} failed: ${error instanceof Error ? error.message : String(error)}`);
238
+ }
239
+ if (!res.ok) {
240
+ let detail = "";
241
+ try {
242
+ detail = await res.text();
243
+ }
244
+ catch {
245
+ // Non-2xx is the error; the body is optional detail.
246
+ }
247
+ throw new OAuthDiscoveryError(`registration endpoint ${opts.registrationEndpoint} returned status ${String(res.status)}${detail ? `: ${detail}` : ""}`);
248
+ }
249
+ const payload = ClientRegistrationResponse(await readJson(res, opts.registrationEndpoint));
250
+ if (payload instanceof type.errors)
251
+ throw new OAuthDiscoveryError(`registration endpoint ${opts.registrationEndpoint} returned a malformed response: ${payload.summary}`);
252
+ const scope = grantedScope(payload.scope, opts.scope);
253
+ const registration = {
254
+ clientId: payload.client_id,
255
+ grantTypes: grantedGrantTypes(payload.grant_types, requestedGrantTypes),
256
+ };
257
+ if (scope !== undefined)
258
+ registration.scope = scope;
259
+ return registration;
260
+ }
261
+ // MCP authorization spec, "Scope Selection Strategy": priority order is (1)
262
+ // the `scope` parameter from a 401 WWW-Authenticate challenge, (2) all scopes
263
+ // in the protected resource's `scopes_supported`, (3) omit the scope
264
+ // parameter entirely. `offline_access` is layered on separately per the
265
+ // spec's "Refresh Tokens" section: `wantRefresh` defaults to true (this
266
+ // package always registers for refresh_token, see grantTypesToRequest), so
267
+ // offline_access is added whenever the authorization server's own
268
+ // `scopes_supported` lists it; pass `wantRefresh: false` to opt out.
269
+ export function selectMcpScopes(opts) {
270
+ const priorityScopes = selectPriorityScopes(opts);
271
+ return addOfflineAccessScope(priorityScopes, opts);
272
+ }
273
+ function selectPriorityScopes(opts) {
274
+ if (opts.challengeScope !== undefined)
275
+ return opts.challengeScope.split(" ").filter((scope) => scope.length > 0);
276
+ const resourceScopes = opts.entry.resourceScopesSupported;
277
+ if (resourceScopes !== undefined)
278
+ return [...resourceScopes];
279
+ return [];
280
+ }
281
+ function addOfflineAccessScope(scopes, opts) {
282
+ if (opts.wantRefresh === false)
283
+ return scopes;
284
+ const authServerScopes = opts.entry.authorizationServer.scopesSupported;
285
+ if (authServerScopes === undefined)
286
+ return scopes;
287
+ if (!authServerScopes.includes("offline_access"))
288
+ return scopes;
289
+ if (scopes.includes("offline_access"))
290
+ return scopes;
291
+ return [...scopes, "offline_access"];
292
+ }
293
+ /**
294
+ * Build the OAuth client config for a discovered entry plus a registered
295
+ * client id. The RFC 8707 `resource` indicator binds the token to the MCP
296
+ * server; callers pass further provider-required params through
297
+ * `extraAuthorizeParams`. Scopes come from `selectMcpScopes` unless the
298
+ * caller passes an explicit `scopes` override; `wantRefresh` defaults to
299
+ * true, matching this package's default refresh_token registration request.
300
+ */
301
+ export function mcpClientConfig(entry, opts) {
302
+ const scopeOptions = {
303
+ entry,
304
+ wantRefresh: opts.wantRefresh ?? true,
305
+ };
306
+ if (opts.challengeScope !== undefined)
307
+ scopeOptions.challengeScope = opts.challengeScope;
308
+ const scopes = opts.scopes ?? selectMcpScopes(scopeOptions);
309
+ const extraAuthorizeParams = {};
310
+ for (const [key, value] of Object.entries(opts.extraAuthorizeParams ?? {}))
311
+ extraAuthorizeParams[key] = value;
312
+ extraAuthorizeParams.resource = entry.resourceUrl;
313
+ return {
314
+ clientId: opts.clientId,
315
+ authorizeUrl: entry.authorizationServer.authorizationEndpoint,
316
+ tokenUrl: entry.authorizationServer.tokenEndpoint,
317
+ redirectUri: opts.redirectUri,
318
+ scopes,
319
+ extraAuthorizeParams,
320
+ tokenTimeoutMs: opts.tokenTimeoutMs ?? DEFAULT_TOKEN_TIMEOUT_MS,
321
+ };
322
+ }
@@ -33,6 +33,8 @@ export interface McpBoundServer {
33
33
  }
34
34
  export interface McpServersConfig {
35
35
  readonly servers: readonly McpBoundServer[];
36
+ /** Per-request bound on `initialize` and `tools/call`; defaults to 60s. */
37
+ readonly timeoutMs?: number;
36
38
  }
37
39
  /**
38
40
  * MCP results are never interpreted: text blocks come back as their text so
@@ -11,9 +11,8 @@
11
11
  // point at which this bundle could do a `tools/list` round trip.
12
12
  import { defineTool, } from "@intx/agent";
13
13
  import { type } from "arktype";
14
- import { mcpCallTool, mcpInitialize, McpToolSchema, } from "./client.js";
15
- import { isAskExempt, qualifiedName, toolDescription } from "./naming.js";
16
- import { parseMcpEndpoint } from "./url.js";
14
+ import { DEFAULT_TIMEOUT_MS, mcpCallTool, mcpInitialize, McpToolSchema, } from "./client.js";
15
+ import { assertCatalogNames, assertServers, isAskExempt, qualifiedName, toolDescription, } from "./naming.js";
17
16
  /** The consumer key a host matches a credential binding against. */
18
17
  export const SIDECAR_BUNDLE_ID = "@corbits/mcp/sidecar-bundle";
19
18
  const BoundServerSchema = type({
@@ -22,7 +21,10 @@ const BoundServerSchema = type({
22
21
  tools: McpToolSchema.array(),
23
22
  "allowWithoutAsk?": "string[]",
24
23
  });
25
- const ConfigSchema = type({ servers: BoundServerSchema.array() });
24
+ const ConfigSchema = type({
25
+ servers: BoundServerSchema.array(),
26
+ "timeoutMs?": "number > 0",
27
+ });
26
28
  /** Validate at construction: a malformed deploy config is a deploy bug, not a
27
29
  * tool error the model should see. */
28
30
  function assertConfig(config) {
@@ -30,19 +32,15 @@ function assertConfig(config) {
30
32
  if (parsed instanceof type.errors) {
31
33
  throw new Error(`invalid @corbits/mcp server config: ${parsed.summary}`);
32
34
  }
33
- const seen = new Set();
34
- for (const server of config.servers) {
35
- if (seen.has(server.handle)) {
36
- throw new Error(`invalid @corbits/mcp server config: duplicate credential handle "${server.handle}"`);
37
- }
38
- seen.add(server.handle);
39
- try {
40
- parseMcpEndpoint(server.url);
41
- }
42
- catch (cause) {
43
- throw new Error(`invalid @corbits/mcp server config: ${cause instanceof Error ? cause.message : String(cause)}`);
35
+ try {
36
+ assertServers(config.servers.map(({ handle, url }) => ({ name: handle, url })), "credential handle");
37
+ for (const server of config.servers) {
38
+ assertCatalogNames(server.handle, server.tools);
44
39
  }
45
40
  }
41
+ catch (cause) {
42
+ throw new Error(`invalid @corbits/mcp server config: ${cause instanceof Error ? cause.message : String(cause)}`);
43
+ }
46
44
  }
47
45
  const TextBlock = type({ type: "'text'", text: "string" }).array();
48
46
  /**
@@ -71,6 +69,7 @@ function requestPath(url) {
71
69
  */
72
70
  export function mcpServers(config) {
73
71
  assertConfig(config);
72
+ const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
74
73
  const entries = config.servers.flatMap((server) => server.tools.map((tool) => ({
75
74
  server,
76
75
  tool,
@@ -109,10 +108,11 @@ export function mcpServers(config) {
109
108
  throw new Error(`the "${runtime.server.handle}" credential is a ${credential.kind} handle; an MCP server needs an http one`);
110
109
  }
111
110
  const mediated = (input, init) => credential.fetch(input, init);
112
- await mcpInitialize(requestPath(runtime.server.url), {
111
+ const session = await mcpInitialize(requestPath(runtime.server.url), {
113
112
  fetch: mediated,
113
+ timeoutMs,
114
114
  });
115
- return mediated;
115
+ return { fetch: mediated, session };
116
116
  })().catch((cause) => {
117
117
  runtime.ready = undefined;
118
118
  runtime.handle = undefined;
@@ -122,7 +122,7 @@ export function mcpServers(config) {
122
122
  }
123
123
  return {
124
124
  definitions: toolDefinitions,
125
- async run(call, _signal) {
125
+ async run(call, signal) {
126
126
  const found = byName.get(call.name);
127
127
  const runtime = found === undefined ? undefined : runtimes.get(found.server.handle);
128
128
  if (found === undefined || runtime === undefined) {
@@ -133,8 +133,12 @@ export function mcpServers(config) {
133
133
  };
134
134
  }
135
135
  try {
136
- const mediated = await connect(runtime);
137
- const result = await mcpCallTool(requestPath(found.server.url), found.tool.name, call.arguments, { fetch: mediated });
136
+ const client = await connect(runtime);
137
+ const result = await mcpCallTool(requestPath(found.server.url), found.tool.name, call.arguments, { ...client, timeoutMs, signal }).catch((cause) => {
138
+ // The session may have expired; the next call opens a new one.
139
+ runtime.ready = undefined;
140
+ throw cause;
141
+ });
138
142
  return {
139
143
  callId: call.id,
140
144
  isError: result.isError === true,
@@ -0,0 +1,9 @@
1
+ export interface TestServerHandle {
2
+ url: string;
3
+ requestsSeen: Request[];
4
+ stop(): void;
5
+ }
6
+ export declare function startTestMcpServer(opts?: {
7
+ requireAuth?: string;
8
+ stallToolsCall?: boolean;
9
+ }): TestServerHandle;
@@ -0,0 +1,92 @@
1
+ // In-test streamable-HTTP MCP server (`echo`, and `delete_thing` flagged
2
+ // `destructiveHint`) so the suite needs no network.
3
+ import { type } from "arktype";
4
+ const JsonRpcRequestBody = type({
5
+ id: "number",
6
+ method: "string",
7
+ "params?": "Record<string, unknown>",
8
+ });
9
+ const ToolCallParams = type({
10
+ name: "string",
11
+ arguments: "Record<string, unknown>",
12
+ });
13
+ export function startTestMcpServer(opts = {}) {
14
+ const requestsSeen = [];
15
+ const server = Bun.serve({
16
+ port: 0,
17
+ async fetch(req) {
18
+ requestsSeen.push(req);
19
+ if (opts.requireAuth !== undefined &&
20
+ req.headers.get("authorization") !== opts.requireAuth) {
21
+ return new Response("unauthorized", { status: 401 });
22
+ }
23
+ const raw = await req.json();
24
+ if (typeof raw === "object" && raw !== null && !("id" in raw)) {
25
+ return new Response(null, { status: 202 });
26
+ }
27
+ const parsedBody = JsonRpcRequestBody(raw);
28
+ if (parsedBody instanceof type.errors) {
29
+ return new Response("bad request", { status: 400 });
30
+ }
31
+ const body = parsedBody;
32
+ const reply = (result) => new Response(JSON.stringify({ jsonrpc: "2.0", id: body.id, result }), {
33
+ headers: { "content-type": "application/json" },
34
+ });
35
+ if (body.method === "initialize") {
36
+ return reply({
37
+ protocolVersion: "2025-03-26",
38
+ capabilities: {},
39
+ serverInfo: { name: "test", version: "0" },
40
+ });
41
+ }
42
+ if (body.method === "tools/list") {
43
+ return reply({
44
+ tools: [
45
+ {
46
+ name: "echo",
47
+ description: "Echoes the given text back.",
48
+ inputSchema: {
49
+ type: "object",
50
+ properties: { text: { type: "string" } },
51
+ required: ["text"],
52
+ },
53
+ },
54
+ {
55
+ name: "delete_thing",
56
+ description: "Deletes a thing by id.",
57
+ inputSchema: {
58
+ type: "object",
59
+ properties: { id: { type: "string" } },
60
+ required: ["id"],
61
+ },
62
+ annotations: { destructiveHint: true },
63
+ },
64
+ ],
65
+ });
66
+ }
67
+ if (body.method === "tools/call") {
68
+ if (opts.stallToolsCall === true)
69
+ return new Promise(() => { });
70
+ const params = ToolCallParams(body.params);
71
+ if (params instanceof type.errors) {
72
+ return new Response("bad request", { status: 400 });
73
+ }
74
+ if (params.name === "echo") {
75
+ return reply({ content: params.arguments.text });
76
+ }
77
+ if (params.name === "delete_thing") {
78
+ return reply({ content: `deleted ${String(params.arguments.id)}` });
79
+ }
80
+ return reply({ content: `unknown tool ${params.name}`, isError: true });
81
+ }
82
+ return new Response("not found", { status: 404 });
83
+ },
84
+ });
85
+ return {
86
+ url: new URL("mcp", server.url).toString(),
87
+ requestsSeen,
88
+ stop: () => {
89
+ void server.stop(true);
90
+ },
91
+ };
92
+ }
package/dist/tool.d.ts CHANGED
@@ -25,6 +25,8 @@ export interface McpToolsOptions {
25
25
  * that flag wins no matter what the operator lists here.
26
26
  */
27
27
  allowWithoutAsk?: string[];
28
+ /** Per-request bound on every MCP round trip; defaults to 60s. */
29
+ timeoutMs?: number;
28
30
  }
29
31
  /**
30
32
  * Discover every server's tool catalog and build the `@corbits/mcp` tool
package/dist/tool.js CHANGED
@@ -2,8 +2,8 @@
2
2
  // each is grantable individually. Discovery runs up front because
3
3
  // `createAgent` calls a tool factory synchronously.
4
4
  import { defineTool, } from "@intx/agent";
5
- import { mcpCallTool, mcpInitialize, mcpListTools, } from "./client.js";
6
- import { isAskExempt, qualifiedName, toolDescription } from "./naming.js";
5
+ import { DEFAULT_TIMEOUT_MS, mcpCallTool, mcpInitialize, mcpListTools, } from "./client.js";
6
+ import { assertCatalogNames, assertServers, isAskExempt, qualifiedName, toolDescription, } from "./naming.js";
7
7
  async function resolveFetch(server, credentials) {
8
8
  if (server.credentialHandle === undefined)
9
9
  return undefined;
@@ -23,12 +23,18 @@ async function resolveFetch(server, credentials) {
23
23
  */
24
24
  export async function mcpTools(options, discoveryEnv = {}) {
25
25
  const allowWithoutAsk = options.allowWithoutAsk ?? [];
26
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
27
+ assertServers(options.servers, "MCP server name");
26
28
  const discovered = [];
27
29
  for (const server of options.servers) {
28
30
  const clientFetch = await resolveFetch(server, discoveryEnv.credentials);
29
- const clientOpts = clientFetch !== undefined ? { fetch: clientFetch } : {};
30
- await mcpInitialize(server.url, clientOpts);
31
- const tools = await mcpListTools(server.url, clientOpts);
31
+ const clientOpts = {
32
+ ...(clientFetch !== undefined ? { fetch: clientFetch } : {}),
33
+ timeoutMs,
34
+ };
35
+ const session = await mcpInitialize(server.url, clientOpts);
36
+ const tools = await mcpListTools(server.url, { ...clientOpts, session });
37
+ assertCatalogNames(server.name, tools);
32
38
  for (const tool of tools) {
33
39
  discovered.push({
34
40
  server,
@@ -60,9 +66,28 @@ export async function mcpTools(options, discoveryEnv = {}) {
60
66
  for (const server of options.servers) {
61
67
  fetchByServer.set(server.name, resolveFetch(server, env.credentials));
62
68
  }
69
+ // One session per server, opened on first use and reopened after a
70
+ // failed call in case the server expired it.
71
+ const clientByServer = new Map();
72
+ function connect(server) {
73
+ let client = clientByServer.get(server.name);
74
+ if (client === undefined) {
75
+ client = (async () => {
76
+ const clientFetch = await fetchByServer.get(server.name);
77
+ const opts = {
78
+ ...(clientFetch !== undefined ? { fetch: clientFetch } : {}),
79
+ timeoutMs,
80
+ };
81
+ const session = await mcpInitialize(server.url, opts);
82
+ return { ...opts, session };
83
+ })();
84
+ clientByServer.set(server.name, client);
85
+ }
86
+ return client;
87
+ }
63
88
  return {
64
89
  definitions: toolDefinitions,
65
- async run(call, _signal) {
90
+ async run(call, signal) {
66
91
  const found = byName.get(call.name);
67
92
  if (found === undefined) {
68
93
  return {
@@ -72,8 +97,15 @@ export async function mcpTools(options, discoveryEnv = {}) {
72
97
  };
73
98
  }
74
99
  try {
75
- const clientFetch = await fetchByServer.get(found.server.name);
76
- const result = await mcpCallTool(found.server.url, found.tool.name, call.arguments, clientFetch !== undefined ? { fetch: clientFetch } : {});
100
+ const result = await connect(found.server)
101
+ .then((client) => mcpCallTool(found.server.url, found.tool.name, call.arguments, {
102
+ ...client,
103
+ signal,
104
+ }))
105
+ .catch((cause) => {
106
+ clientByServer.delete(found.server.name);
107
+ throw cause;
108
+ });
77
109
  return {
78
110
  callId: call.id,
79
111
  isError: result.isError === true,