@corbits/mcp 0.1.0 → 0.2.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.
- package/README.md +156 -53
- package/dist/client.d.ts +23 -5
- package/dist/client.js +195 -60
- package/dist/hub/discover.d.ts +32 -7
- package/dist/hub/discover.js +91 -28
- package/dist/hub/index.d.ts +1 -1
- package/dist/hub/index.js +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/naming.d.ts +14 -0
- package/dist/naming.js +33 -0
- package/dist/oauth-discovery.d.ts +71 -0
- package/dist/oauth-discovery.js +322 -0
- package/dist/sidecar-bundle.d.ts +2 -0
- package/dist/sidecar-bundle.js +24 -20
- package/dist/test-server.d.ts +9 -0
- package/dist/test-server.js +92 -0
- package/dist/tool.d.ts +2 -0
- package/dist/tool.js +40 -8
- package/package.json +72 -44
|
@@ -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
|
+
}
|
package/dist/sidecar-bundle.d.ts
CHANGED
|
@@ -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
|
package/dist/sidecar-bundle.js
CHANGED
|
@@ -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({
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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,
|
|
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
|
|
137
|
-
const result = await mcpCallTool(requestPath(found.server.url), found.tool.name, call.arguments, {
|
|
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,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 =
|
|
30
|
-
|
|
31
|
-
|
|
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,
|
|
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
|
|
76
|
-
|
|
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,
|