@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.
@@ -1,37 +1,62 @@
1
1
  import type { DB } from "@intx/db";
2
+ import type { FetchLike } from "@intx/harness";
2
3
  import type { TenantEnv } from "@intx/hub-api";
3
4
  import { type CredentialCipher } from "@intx/types";
4
5
  import type { Hono, MiddlewareHandler } from "hono";
5
- import { type FetchLike, type McpTool, type McpServerInfo } from "../client.js";
6
+ import { type McpTool, type McpServerInfo } from "../client.js";
6
7
  export type MountMcpDiscoveryOpts = {
7
8
  readonly db: DB["db"];
8
9
  readonly cipher: CredentialCipher;
9
10
  /** The host's stock grant middleware, so authority is checked exactly once, its way. */
10
11
  readonly requireGrant: MiddlewareHandler<TenantEnv>;
12
+ /**
13
+ * Further origins a credential may be sent to, keyed by the credential's
14
+ * pinned origin, for a server whose MCP endpoint is not on that origin.
15
+ * Host-wide: an entry applies to every tenant's credential pinned to that
16
+ * origin. Passed through to `@corbits/credential-http`. Empty by default.
17
+ */
18
+ readonly extraOrigins?: Readonly<Record<string, readonly string[]>>;
19
+ /**
20
+ * Allow plain-http loopback targets, for local development only. Off by
21
+ * default so the route cannot be pointed at the hub's own ports.
22
+ */
23
+ readonly allowLoopback?: boolean;
11
24
  /** Reported when a discovery attempt fails; the caller only sees a message. */
12
25
  readonly onError?: (error: unknown, context: {
13
26
  url: string;
14
27
  }) => void;
15
28
  };
29
+ export type McpCredential = {
30
+ readonly secret: string;
31
+ /** The provider's API origin the secret is pinned to, when it has one. */
32
+ readonly origin?: string;
33
+ };
16
34
  /**
17
- * Read a tenant credential's decrypted secret. Scoped by tenant so a
18
- * credential id from another tenant reads as absent, not as a secret.
35
+ * Read a tenant credential's decrypted secret and the origin it is pinned to.
36
+ * Scoped by tenant so a credential id from another tenant reads as absent,
37
+ * not as a secret; a revoked, errored or expired credential reads as absent
38
+ * too.
19
39
  */
20
- export declare function readCredentialSecret(opts: {
40
+ export declare function readCredential(opts: {
21
41
  readonly db: DB["db"];
22
42
  readonly cipher: CredentialCipher;
23
43
  readonly tenantId: string;
24
44
  readonly credentialId: string;
25
- }): Promise<string | undefined>;
45
+ }): Promise<McpCredential | undefined>;
26
46
  export type McpDiscovery = {
27
47
  readonly serverInfo: McpServerInfo;
28
48
  readonly tools: readonly McpTool[];
29
49
  };
30
- /** Handshake and read a server's catalog over an origin-pinned fetch. */
50
+ /**
51
+ * Handshake and read a server's catalog over a fetch pinned to the
52
+ * credential's origin, or to the URL's origin when no secret is sent.
53
+ */
31
54
  export declare function discoverMcpServer(args: {
32
55
  readonly url: string;
33
- readonly secret?: string;
56
+ readonly credential?: McpCredential;
57
+ readonly extraOrigins?: Readonly<Record<string, readonly string[]>>;
34
58
  readonly fetch?: FetchLike;
59
+ readonly timeoutMs?: number;
35
60
  }): Promise<McpDiscovery>;
36
61
  /**
37
62
  * Mount `POST /mcp/discover` on a tenant router. It answers with the server's
@@ -1,57 +1,107 @@
1
1
  // Server-side MCP catalog discovery. The browser never holds an MCP server's
2
2
  // token, so the only place an OAuth-protected server's `tools/list` can be
3
3
  // read is here, behind the hub's own tenant-member gate and with the secret
4
- // decrypted in this process and sent only to the server's own origin.
5
- import { credential } from "@intx/db/schema";
4
+ // decrypted in this process and sent only to the credential's own origin.
5
+ import { credential, provider } from "@intx/db/schema";
6
6
  import { credentialAad } from "@intx/types";
7
- import { MCP_NO_TOKEN_SENTINEL, mcpOriginPinnedFetch, } from "@corbits/credential-mcp";
7
+ import { createOriginPinnedFetch, MCP_NO_TOKEN_SENTINEL, } from "@corbits/credential-http";
8
8
  import { type } from "arktype";
9
9
  import { and, eq } from "drizzle-orm";
10
- import { mcpInitialize, mcpListTools, } from "../client.js";
10
+ import { McpError, mcpInitialize, mcpListTools, } from "../client.js";
11
11
  import { parseMcpEndpoint } from "../url.js";
12
12
  const DiscoverBody = type({
13
13
  url: "string > 0",
14
14
  "credentialId?": "string > 0",
15
15
  });
16
16
  /**
17
- * Read a tenant credential's decrypted secret. Scoped by tenant so a
18
- * credential id from another tenant reads as absent, not as a secret.
17
+ * Read a tenant credential's decrypted secret and the origin it is pinned to.
18
+ * Scoped by tenant so a credential id from another tenant reads as absent,
19
+ * not as a secret; a revoked, errored or expired credential reads as absent
20
+ * too.
19
21
  */
20
- export async function readCredentialSecret(opts) {
22
+ export async function readCredential(opts) {
21
23
  const [row] = await opts.db
22
- .select()
24
+ .select({
25
+ id: credential.id,
26
+ secret: credential.secret,
27
+ status: credential.status,
28
+ expiresAt: credential.expiresAt,
29
+ apiBaseUrl: provider.apiBaseUrl,
30
+ })
23
31
  .from(credential)
32
+ .innerJoin(provider, eq(provider.id, credential.providerId))
24
33
  .where(and(eq(credential.id, opts.credentialId), eq(credential.tenantId, opts.tenantId)))
25
34
  .limit(1);
26
- if (row === undefined || row.secret === null)
35
+ if (row === undefined ||
36
+ row.status !== "active" ||
37
+ (row.expiresAt !== null && row.expiresAt.getTime() <= Date.now())) {
27
38
  return undefined;
28
- return opts.cipher.decrypt(row.secret, credentialAad(row.id, "secret"));
39
+ }
40
+ const secret = await opts.cipher.decrypt(row.secret, credentialAad(row.id, "secret"));
41
+ return row.apiBaseUrl === null
42
+ ? { secret }
43
+ : { secret, origin: new URL(row.apiBaseUrl).origin };
29
44
  }
45
+ const HEADER_VALUE = /^[\t\x20-\x7e]*$/;
30
46
  /** A 3xx would send the bearer onward, so the pinned fetch's manual redirect
31
47
  * is turned into a refusal rather than a response the client tries to read. */
32
48
  function refusingRedirects(inner) {
33
49
  return async (input, init) => {
34
50
  const response = await inner(input, init);
35
51
  if (response.status >= 300 && response.status < 400) {
36
- throw new Error(`the MCP server answered a ${String(response.status)} redirect; refusing to follow it`);
52
+ throw new McpError(`the MCP server answered a ${String(response.status)} redirect; refusing to follow it`);
37
53
  }
38
54
  return response;
39
55
  };
40
56
  }
41
- /** Handshake and read a server's catalog over an origin-pinned fetch. */
57
+ /** How long one discovery request, including its body, may take. */
58
+ const DISCOVERY_TIMEOUT_MS = 30_000;
59
+ /**
60
+ * Handshake and read a server's catalog over a fetch pinned to the
61
+ * credential's origin, or to the URL's origin when no secret is sent.
62
+ */
42
63
  export async function discoverMcpServer(args) {
43
64
  const target = parseMcpEndpoint(args.url);
44
- const token = args.secret === undefined || args.secret === MCP_NO_TOKEN_SENTINEL
65
+ const secret = args.credential?.secret;
66
+ const token = secret === undefined || secret === MCP_NO_TOKEN_SENTINEL
45
67
  ? undefined
46
- : args.secret;
47
- const pinned = refusingRedirects(mcpOriginPinnedFetch({
48
- pinnedOrigin: target.origin,
49
- readToken: () => token,
50
- ...(args.fetch !== undefined ? { fetch: args.fetch } : {}),
68
+ : secret;
69
+ const origin = token === undefined ? target.origin : args.credential?.origin;
70
+ if (origin === undefined) {
71
+ throw new McpError("the credential's provider has no API origin to pin to");
72
+ }
73
+ // A secret that is not a valid header value makes fetch throw a message
74
+ // quoting it, so it is refused here with a fixed one.
75
+ if (token !== undefined && !HEADER_VALUE.test(token)) {
76
+ throw new McpError("the credential's secret is not a valid header value");
77
+ }
78
+ const extraOrigins = Object.entries(args.extraOrigins ?? {}).flatMap(([pinned, extra]) => (new URL(pinned).origin === origin ? extra : []));
79
+ const allowed = [origin, ...extraOrigins.map((o) => new URL(o).origin)];
80
+ if (!allowed.includes(target.origin)) {
81
+ throw new McpError(`credential is pinned to ${origin}; refusing cross-origin request to ${target.origin}`);
82
+ }
83
+ const pinned = refusingRedirects(createOriginPinnedFetch({
84
+ origin,
85
+ header: "authorization",
86
+ readValue: () => (token === undefined ? undefined : `Bearer ${token}`),
87
+ extraOrigins,
88
+ fetch: args.fetch ?? globalThis.fetch,
51
89
  }));
52
- const serverInfo = await mcpInitialize(args.url, { fetch: pinned });
53
- const tools = await mcpListTools(args.url, { fetch: pinned });
54
- return { serverInfo, tools };
90
+ const client = {
91
+ fetch: pinned,
92
+ timeoutMs: args.timeoutMs ?? DISCOVERY_TIMEOUT_MS,
93
+ };
94
+ const session = await mcpInitialize(args.url, client);
95
+ const tools = await mcpListTools(args.url, { ...client, session });
96
+ return {
97
+ serverInfo: {
98
+ protocolVersion: session.protocolVersion,
99
+ ...(session.serverInfo !== undefined
100
+ ? { serverInfo: session.serverInfo }
101
+ : {}),
102
+ },
103
+ tools,
104
+ };
55
105
  }
56
106
  /**
57
107
  * Mount `POST /mcp/discover` on a tenant router. It answers with the server's
@@ -71,29 +121,42 @@ export function mountMcpDiscovery(app, opts) {
71
121
  catch (cause) {
72
122
  return c.json({ error: cause instanceof Error ? cause.message : String(cause) }, 400);
73
123
  }
124
+ if (target.protocol === "http:" && opts.allowLoopback !== true) {
125
+ return c.json({ error: "the MCP server URL must be https" }, 400);
126
+ }
74
127
  try {
75
- const secret = body.credentialId === undefined
128
+ const found = body.credentialId === undefined
76
129
  ? undefined
77
- : await readCredentialSecret({
130
+ : await readCredential({
78
131
  db: opts.db,
79
132
  cipher: opts.cipher,
80
133
  tenantId: c.get("tenant").id,
81
134
  credentialId: body.credentialId,
82
135
  });
83
- if (body.credentialId !== undefined && secret === undefined) {
136
+ if (body.credentialId !== undefined && found === undefined) {
84
137
  return c.json({ error: "credential not found" }, 404);
85
138
  }
86
139
  const data = await discoverMcpServer({
87
140
  url: body.url,
88
- ...(secret !== undefined ? { secret } : {}),
141
+ ...(found !== undefined ? { credential: found } : {}),
142
+ ...(opts.extraOrigins !== undefined
143
+ ? { extraOrigins: opts.extraOrigins }
144
+ : {}),
89
145
  });
90
146
  return c.json({ data });
91
147
  }
92
148
  catch (cause) {
93
- opts.onError?.(cause, { url: body.url });
94
- // The message describes the handshake, never the material it used.
149
+ // Only this package's own messages are passed on: anything else, such
150
+ // as a fetch error, may quote the material the request carried. An
151
+ // upstream status is not echoed, so the route is no port-probing oracle.
152
+ const error = cause instanceof McpError && cause.status === undefined
153
+ ? cause
154
+ : new McpError(cause instanceof McpError
155
+ ? "the server refused the request"
156
+ : "the handshake failed");
157
+ opts.onError?.(error, { url: body.url });
95
158
  return c.json({
96
- error: `the MCP server at ${target.origin} could not be discovered: ${cause instanceof Error ? cause.message : String(cause)}`,
159
+ error: `the MCP server at ${target.origin} could not be discovered: ${error.message}`,
97
160
  }, 422);
98
161
  }
99
162
  });
@@ -1 +1 @@
1
- export { mountMcpDiscovery, discoverMcpServer, readCredentialSecret, type McpDiscovery, type MountMcpDiscoveryOpts, } from "./discover.js";
1
+ export { mountMcpDiscovery, discoverMcpServer, readCredential, type McpCredential, type McpDiscovery, type MountMcpDiscoveryOpts, } from "./discover.js";
package/dist/hub/index.js CHANGED
@@ -1,4 +1,4 @@
1
1
  // Server-only entry point: the hub-hosted MCP discovery mount. The root and
2
2
  // `./sidecar-bundle` entries stay free of `hono` and `@intx/db` so neither a
3
3
  // browser bundle nor a deployed agent ever reaches them.
4
- export { mountMcpDiscovery, discoverMcpServer, readCredentialSecret, } from "./discover.js";
4
+ export { mountMcpDiscovery, discoverMcpServer, readCredential, } from "./discover.js";
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export { mcpTools, type McpServerConfig, type McpToolsEnv, type McpToolsOptions, } from "./tool.js";
2
2
  export { qualifiedName, isAskExempt, toolDescription } from "./naming.js";
3
- export { mcpInitialize, mcpListTools, mcpCallTool, McpError, McpToolSchema, type McpTool, type McpToolResult, type McpClientOptions, type FetchLike, } from "./client.js";
3
+ export { mcpInitialize, mcpListTools, mcpCallTool, McpError, McpToolSchema, type McpTool, type McpToolResult, type McpClientOptions, type McpSession, } from "./client.js";
4
+ export { discoverMcpLoginEntry, registerMcpClient, mcpClientConfig, selectMcpScopes, type DiscoverMcpLoginEntryOptions, type McpAuthorizationServer, type McpClientConfigOptions, type McpClientRegistration, type McpLoginEntry, type RegisterMcpClientOptions, type SelectMcpScopesOptions, } from "./oauth-discovery.js";
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export { mcpTools, } from "./tool.js";
2
2
  export { qualifiedName, isAskExempt, toolDescription } from "./naming.js";
3
3
  export { mcpInitialize, mcpListTools, mcpCallTool, McpError, McpToolSchema, } from "./client.js";
4
+ export { discoverMcpLoginEntry, registerMcpClient, mcpClientConfig, selectMcpScopes, } from "./oauth-discovery.js";
package/dist/naming.d.ts CHANGED
@@ -1,6 +1,20 @@
1
1
  import type { McpTool } from "./client.js";
2
2
  /** `<server>.<tool>`, the grantable unit: `tool:<server>.*` covers a server. */
3
3
  export declare function qualifiedName(server: string, tool: string): string;
4
+ /**
5
+ * Refuse a catalog whose names could reach another server's grants: a server
6
+ * name with a `.` could make `<server>.<tool>` fall under another server's
7
+ * `tool:<other>.*`, and a repeated tool name would make two tools one name.
8
+ */
9
+ export declare function assertCatalogNames(server: string, tools: readonly McpTool[]): void;
10
+ /**
11
+ * Refuse a server list before any request: every name non-empty and unique,
12
+ * so no two servers share a grant prefix, and every URL a permitted endpoint.
13
+ */
14
+ export declare function assertServers(servers: readonly {
15
+ name: string;
16
+ url: string;
17
+ }[], label: string): void;
4
18
  export declare function toolDescription(tool: McpTool, server: string): string;
5
19
  /**
6
20
  * Whether `qualified` may drop below the `ask` floor: it must be on the
package/dist/naming.js CHANGED
@@ -1,10 +1,43 @@
1
1
  // Naming and the ask floor, shared by the author-time `mcpTools` factory and
2
2
  // the deployed `mcpServers` sidecar bundle so both project a server's catalog
3
3
  // onto identical tool names and identical approval marks.
4
+ import { parseMcpEndpoint } from "./url.js";
4
5
  /** `<server>.<tool>`, the grantable unit: `tool:<server>.*` covers a server. */
5
6
  export function qualifiedName(server, tool) {
6
7
  return `${server}.${tool}`;
7
8
  }
9
+ /**
10
+ * Refuse a catalog whose names could reach another server's grants: a server
11
+ * name with a `.` could make `<server>.<tool>` fall under another server's
12
+ * `tool:<other>.*`, and a repeated tool name would make two tools one name.
13
+ */
14
+ export function assertCatalogNames(server, tools) {
15
+ if (server.includes(".")) {
16
+ throw new Error(`MCP server name "${server}" must not contain "."; it would overlap another server's grants`);
17
+ }
18
+ const seen = new Set();
19
+ for (const tool of tools) {
20
+ if (seen.has(tool.name)) {
21
+ throw new Error(`MCP server "${server}" lists the tool "${tool.name}" more than once`);
22
+ }
23
+ seen.add(tool.name);
24
+ }
25
+ }
26
+ /**
27
+ * Refuse a server list before any request: every name non-empty and unique,
28
+ * so no two servers share a grant prefix, and every URL a permitted endpoint.
29
+ */
30
+ export function assertServers(servers, label) {
31
+ const seen = new Set();
32
+ for (const { name, url } of servers) {
33
+ if (name.length === 0)
34
+ throw new Error(`empty ${label}`);
35
+ if (seen.has(name))
36
+ throw new Error(`duplicate ${label} "${name}"`);
37
+ seen.add(name);
38
+ parseMcpEndpoint(url);
39
+ }
40
+ }
8
41
  export function toolDescription(tool, server) {
9
42
  const hints = Object.entries(tool.annotations ?? {})
10
43
  .filter(([, v]) => v === true)
@@ -0,0 +1,71 @@
1
+ import type { FetchLike } from "@intx/harness";
2
+ import { type OAuthClientConfig } from "@corbits/oauth-core";
3
+ export type McpAuthorizationServer = {
4
+ issuer: string;
5
+ authorizationEndpoint: string;
6
+ tokenEndpoint: string;
7
+ registrationEndpoint?: string;
8
+ grantTypesSupported?: readonly string[];
9
+ scopesSupported?: readonly string[];
10
+ tokenEndpointAuthMethodsSupported?: readonly string[];
11
+ codeChallengeMethodsSupported?: readonly string[];
12
+ };
13
+ export type McpLoginEntry = {
14
+ resourceUrl: string;
15
+ authorizationServer: McpAuthorizationServer;
16
+ resourceScopesSupported?: readonly string[];
17
+ };
18
+ export type DiscoverMcpLoginEntryOptions = {
19
+ resourceUrl: string;
20
+ fetchImpl?: FetchLike;
21
+ timeoutMs?: number;
22
+ };
23
+ export type RegisterMcpClientOptions = {
24
+ registrationEndpoint: string;
25
+ redirectUris: readonly string[];
26
+ clientName: string;
27
+ grantTypesSupported?: readonly string[];
28
+ scope?: string;
29
+ fetchImpl?: FetchLike;
30
+ timeoutMs?: number;
31
+ };
32
+ export type McpClientRegistration = {
33
+ clientId: string;
34
+ grantTypes: string[];
35
+ scope?: string;
36
+ };
37
+ export type McpClientConfigOptions = {
38
+ clientId: string;
39
+ redirectUri: string;
40
+ scopes?: readonly string[];
41
+ challengeScope?: string;
42
+ wantRefresh?: boolean;
43
+ extraAuthorizeParams?: Record<string, string>;
44
+ tokenTimeoutMs?: number;
45
+ };
46
+ export type SelectMcpScopesOptions = {
47
+ entry: McpLoginEntry;
48
+ challengeScope?: string;
49
+ wantRefresh?: boolean;
50
+ };
51
+ export declare function discoverMcpLoginEntry(opts: DiscoverMcpLoginEntryOptions): Promise<McpLoginEntry>;
52
+ /**
53
+ * Register a loopback public client with the authorization server (RFC 7591).
54
+ * Loopback clients cannot keep a secret, so registration requests
55
+ * `token_endpoint_auth_method: "none"`. `grant_types` is negotiated from
56
+ * `grantTypesSupported` (see grantTypesToRequest); `scope`, when supplied, is
57
+ * carried on the registration request per RFC 7591 §2. Throws
58
+ * OAuthDiscoveryError when the server has no registration endpoint or
59
+ * rejects the request.
60
+ */
61
+ export declare function registerMcpClient(opts: RegisterMcpClientOptions): Promise<McpClientRegistration>;
62
+ export declare function selectMcpScopes(opts: SelectMcpScopesOptions): string[];
63
+ /**
64
+ * Build the OAuth client config for a discovered entry plus a registered
65
+ * client id. The RFC 8707 `resource` indicator binds the token to the MCP
66
+ * server; callers pass further provider-required params through
67
+ * `extraAuthorizeParams`. Scopes come from `selectMcpScopes` unless the
68
+ * caller passes an explicit `scopes` override; `wantRefresh` defaults to
69
+ * true, matching this package's default refresh_token registration request.
70
+ */
71
+ export declare function mcpClientConfig(entry: McpLoginEntry, opts: McpClientConfigOptions): OAuthClientConfig;