@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.
- package/README.md +160 -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 +99 -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
package/README.md
CHANGED
|
@@ -1,81 +1,188 @@
|
|
|
1
1
|
# @corbits/mcp
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
3
|
+
A Corbits tool pack for the Interchange sidecar that turns each tool on a remote Model Context Protocol server into its own `@intx/agent` tool, over streamable HTTP (2025-03-26 spec). It also ships a hub route for catalog discovery and OAuth discovery helpers, and the client works standalone.
|
|
4
|
+
|
|
5
|
+
## Why @corbits/mcp?
|
|
6
|
+
|
|
7
|
+
1. **One agent tool per remote tool.** `linear.list_issues` and `linear.delete_issue` are separate tools, so Interchange grants (per-principal permission records) allow, ask or deny each one.
|
|
8
|
+
2. **The agent never holds the server's token.** Calls go through a mediated credential: a fetch pinned to the server's origin that adds the bearer per request.
|
|
9
|
+
3. **Destructive tools stay behind approval.** Every generated tool starts `ask`-marked, and a tool the server flags `destructiveHint: true` cannot be lowered to `allow`.
|
|
10
|
+
|
|
11
|
+
It supports `initialize`, `tools/list` and `tools/call` only, against stateless and stateful (`Mcp-Session-Id`) servers.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bun add @corbits/mcp @intx/agent @intx/harness
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Runs on Node.js 24+ and Bun 1.2+.
|
|
20
|
+
|
|
21
|
+
For the `@corbits/mcp/hub` routes, also install:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
bun add @corbits/credential-http @intx/authz @intx/crypto @intx/db @intx/hub-api drizzle-orm hono
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quickstart
|
|
28
|
+
|
|
29
|
+
Lists the tools on a public MCP server:
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { mcpInitialize, mcpListTools } from "@corbits/mcp";
|
|
33
|
+
|
|
34
|
+
const url = "https://mcp.deepwiki.com/mcp";
|
|
35
|
+
const session = await mcpInitialize(url);
|
|
36
|
+
for (const tool of await mcpListTools(url, { session })) {
|
|
37
|
+
console.log(`${tool.name}: ${tool.description ?? ""}`);
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Every client call takes `{ fetch, timeoutMs, signal, session }` as its last argument, for example a fetch that adds an `Authorization` header. `timeoutMs` covers the request and reading its body, and `signal` cancels both. `mcpTools` and `mcpServers` take a `timeoutMs` option, 60 seconds by default, and pass each tool call's abort signal through, so a stalling server fails that call instead of hanging it. A response body or event-stream frame over 4 MiB is refused and the stream cancelled.
|
|
42
|
+
|
|
43
|
+
## Where it fits
|
|
44
|
+
|
|
45
|
+
[Interchange](https://github.com/faremeter/interchange) runs AI agents as principals: accounts with their own identity, permissions and credentials. Its hub is the multi-tenant control plane that holds tenants, grants and credentials; its sidecar is the agent runtime.
|
|
46
|
+
|
|
47
|
+
- **Hub:** `@corbits/mcp/hub` mounts a discovery route on an [`@intx/hub-api`](https://github.com/faremeter/interchange/tree/main/packages/hub-api) app and reads tenant credentials from [`@intx/db`](https://github.com/faremeter/interchange/tree/main/packages/db).
|
|
48
|
+
- **Sidecar:** `@corbits/mcp/sidecar-bundle` builds [`@intx/agent`](https://github.com/faremeter/interchange/tree/main/packages/agent) tools from a stored catalog.
|
|
49
|
+
- **Pairs with:** [`@corbits/oauth-core`](https://github.com/corbitsdev/corbits-oauth-core) for the login flow and [`@corbits/credential-http`](https://github.com/corbitsdev/credential-http) for the origin-pinned credential provider.
|
|
50
|
+
|
|
51
|
+
## Reference
|
|
52
|
+
|
|
53
|
+
| Export | Entry | Purpose |
|
|
54
|
+
| ------------------------------------------------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
|
55
|
+
| `mcpInitialize(url, opts?)` | `@corbits/mcp` | Send `initialize` and `notifications/initialized`; returns the `McpSession` to pass to later calls. |
|
|
56
|
+
| `mcpListTools(url, opts?)` | `@corbits/mcp` | Send `tools/list`; returns `McpTool[]`. |
|
|
57
|
+
| `mcpCallTool(url, name, args, opts?)` | `@corbits/mcp` | Send `tools/call`; returns `McpToolResult`. |
|
|
58
|
+
| `mcpTools(options, env?)` | `@corbits/mcp` | Discover each server live, then build tools. For hosts that can await; servers use `name` and `credentialHandle`. |
|
|
59
|
+
| `qualifiedName`, `toolDescription`, `isAskExempt` | `@corbits/mcp` | Naming and `ask`-floor rules the tool builders use. |
|
|
60
|
+
| `discoverMcpLoginEntry({ resourceUrl })` | `@corbits/mcp` | Resolve the authorization server (RFC 9728, then RFC 8414). |
|
|
61
|
+
| `registerMcpClient(opts)` | `@corbits/mcp` | Register a loopback public client (RFC 7591). |
|
|
62
|
+
| `mcpClientConfig(entry, opts)` | `@corbits/mcp` | Build the `OAuthClientConfig` oauth-core's login helpers take. |
|
|
63
|
+
| `selectMcpScopes(opts)` | `@corbits/mcp` | Pick scopes in the MCP spec's selection order. |
|
|
64
|
+
| `mcpServers(config)` | `@corbits/mcp/sidecar-bundle` | Build tools from stored catalogs over mediated credentials. |
|
|
65
|
+
| `shapeMcpContent(content)`, `SIDECAR_BUNDLE_ID` | `@corbits/mcp/sidecar-bundle` | Result shaping and the bundle's tool id. |
|
|
66
|
+
| `mountMcpDiscovery(app, opts)` | `@corbits/mcp/hub` | Mount `POST /mcp/discover`. |
|
|
67
|
+
| `discoverMcpServer(args)`, `readCredential(opts)` | `@corbits/mcp/hub` | The discovery and credential reads the route uses. |
|
|
68
|
+
|
|
69
|
+
`McpToolSchema` validates a catalog entry (`McpTool`). Transport and protocol failures throw `McpError`. OAuth discovery failures throw `OAuthDiscoveryError` from `@corbits/oauth-core`.
|
|
70
|
+
|
|
71
|
+
### Grants
|
|
72
|
+
|
|
73
|
+
Every call is checked as resource `tool:<name>`. The most specific match wins, and `deny` beats `ask` beats `allow`. `tool:<handle>.<tool>` grants one remote tool and `tool:<handle>.*` covers a whole server. A handle or server name may not contain `.`, and a catalog may not list a tool name twice, so no tool falls under another server's grant.
|
|
74
|
+
|
|
75
|
+
List `"<handle>.<tool>"` in `allowWithoutAsk` to let an `allow` grant run that tool without approval. The list is ignored for tools flagged `destructiveHint: true`.
|
|
76
|
+
|
|
77
|
+
## Using with Interchange
|
|
78
|
+
|
|
79
|
+
The deployer discovers a server's catalog once through the hub route and stores it with the agent's deploy config. The sidecar bundle turns the catalog into tools that call through a mediated credential.
|
|
80
|
+
|
|
81
|
+
### Mount the hub route
|
|
26
82
|
|
|
27
83
|
```ts
|
|
28
84
|
import { mountMcpDiscovery } from "@corbits/mcp/hub";
|
|
85
|
+
import { timeWindowEvaluator } from "@intx/authz";
|
|
86
|
+
import { createEnvKeyCredentialCipher } from "@intx/crypto";
|
|
87
|
+
import { createDB, createGrantStore } from "@intx/db";
|
|
88
|
+
import { createRequireGrant, type TenantEnv } from "@intx/hub-api";
|
|
89
|
+
import { Hono } from "hono";
|
|
90
|
+
|
|
91
|
+
const { db } = createDB({
|
|
92
|
+
host: "localhost",
|
|
93
|
+
port: 5432,
|
|
94
|
+
user: "postgres",
|
|
95
|
+
password: "postgres",
|
|
96
|
+
database: "interchange",
|
|
97
|
+
});
|
|
98
|
+
const requireGrant = createRequireGrant({
|
|
99
|
+
grantStore: createGrantStore(db),
|
|
100
|
+
conditionRegistry: { time_window: timeWindowEvaluator },
|
|
101
|
+
});
|
|
29
102
|
|
|
30
|
-
const
|
|
31
|
-
mountMcpDiscovery(
|
|
32
|
-
|
|
103
|
+
export const mcpRoutes = new Hono<TenantEnv>();
|
|
104
|
+
mountMcpDiscovery(mcpRoutes, {
|
|
105
|
+
db,
|
|
106
|
+
cipher: createEnvKeyCredentialCipher(
|
|
107
|
+
Buffer.from(String(process.env["CREDENTIAL_ENCRYPTION_KEY"]), "hex"),
|
|
108
|
+
),
|
|
109
|
+
requireGrant: requireGrant("credential:*", "read"),
|
|
110
|
+
});
|
|
33
111
|
```
|
|
34
112
|
|
|
35
|
-
`
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
bearer, and the keyless sentinel sends no `authorization` header at all. The
|
|
39
|
-
outbound fetch is origin-pinned and refuses a 3xx, so the secret never leaves
|
|
40
|
-
the server's own origin.
|
|
113
|
+
Mount `mcpRoutes` on the hub app under `/api/tenants/:tenantId`, behind the hub's auth and tenant middleware.
|
|
114
|
+
|
|
115
|
+
`POST /api/tenants/:tenantId/mcp/discover` with `{ url, credentialId? }` returns `{ data: { serverInfo, tools } }`. `url` must be https; plain-http loopback is refused unless the host sets `allowLoopback: true` for local development. `credentialId` names a tenant credential whose secret is sent as a bearer; the credential's type must be `api_key` or `oauth_token`, and any other type reads as not found. An `oauth_token` credential's secret is its current access token, which [`@corbits/oauth-core`](https://github.com/corbitsdev/corbits-oauth-core)'s refresher renews in place, so nothing here refreshes it, and one past its `expiresAt` or marked `error` reads as not found until it is renewed or the user signs in again; a credential holding `MCP_NO_TOKEN_SENTINEL` from `@corbits/credential-http` sends no `authorization` header. Errors: 400 for a bad body or URL, 404 for an unknown credential or one that is not active or has expired, 422 when the server fails discovery, the secret is not a valid header value, or the request would leave the credential's origin. A 422 carries only this package's own messages and never the upstream HTTP status; any other failure reads as a generic handshake error, so no response or `onError` text quotes the secret. `requireGrant` is the host's own grant middleware for this route.
|
|
41
116
|
|
|
42
|
-
|
|
117
|
+
When a secret is sent, the fetch is pinned to the origin of the credential's provider `apiBaseUrl`; with no credential or a keyless one, to the URL's origin. Redirects are always refused, so the secret never leaves that origin. Each discovery request times out after 30 seconds.
|
|
118
|
+
|
|
119
|
+
#### Extra origins
|
|
120
|
+
|
|
121
|
+
Some servers serve MCP on a different origin from the one their credential is issued for. Nothing is allowed off the pinned origin by default. The host lists extra origins per pinned origin in `extraOrigins`, which is passed through to `@corbits/credential-http`:
|
|
43
122
|
|
|
44
123
|
```ts
|
|
124
|
+
mountMcpDiscovery(mcpRoutes, {
|
|
125
|
+
db,
|
|
126
|
+
cipher,
|
|
127
|
+
requireGrant: requireGrant("credential:*", "read"),
|
|
128
|
+
extraOrigins: {
|
|
129
|
+
"https://api.example.com": ["https://mcp.example.net"],
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
| Option | Type | Default |
|
|
135
|
+
| -------------- | --------------------------------------------- | ------- |
|
|
136
|
+
| `extraOrigins` | `Readonly<Record<string, readonly string[]>>` | `{}` |
|
|
137
|
+
|
|
138
|
+
A credential pinned to `https://api.example.com` may then be sent to `https://mcp.example.net`; no other credential can.
|
|
139
|
+
|
|
140
|
+
`extraOrigins` is host-wide, not per tenant: an entry applies to every tenant's credential pinned to that origin, so list only origins you trust with all of them. `discoverMcpServer` takes the same option. For the sidecar bundle, configure the same allowance on the credential provider the host registers.
|
|
141
|
+
|
|
142
|
+
### Load the sidecar bundle
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
import { mcpListTools } from "@corbits/mcp";
|
|
45
146
|
import { mcpServers } from "@corbits/mcp/sidecar-bundle";
|
|
46
147
|
|
|
148
|
+
const url = "https://mcp.deepwiki.com/mcp";
|
|
149
|
+
|
|
47
150
|
export const tools = mcpServers({
|
|
48
151
|
servers: [
|
|
49
152
|
{
|
|
50
|
-
handle: "
|
|
51
|
-
url
|
|
52
|
-
tools:
|
|
53
|
-
allowWithoutAsk: ["
|
|
153
|
+
handle: "deepwiki",
|
|
154
|
+
url,
|
|
155
|
+
tools: await mcpListTools(url),
|
|
156
|
+
allowWithoutAsk: ["deepwiki.read_wiki_structure"],
|
|
54
157
|
},
|
|
55
158
|
],
|
|
56
159
|
});
|
|
57
160
|
```
|
|
58
161
|
|
|
59
|
-
Each catalog entry becomes a tool named `<handle>.<tool>`, with the remote
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
162
|
+
Each catalog entry becomes a tool named `<handle>.<tool>`, with the remote `inputSchema` passed through and the call proxied to `tools/call`. Text content comes back as text and anything else as JSON; `isError` passes through. A handle that does not resolve, or a server that fails `initialize`, fails only that server's calls.
|
|
163
|
+
|
|
164
|
+
`handle` is the credential handle the host binds this server's token to. The bundle resolves it from the runtime `credentials` capability and expects an `http` credential, such as the one `@corbits/credential-http`'s MCP provider returns. The package manifest declares the `mcp-server` credential for this.
|
|
165
|
+
|
|
166
|
+
#### OAuth servers
|
|
167
|
+
|
|
168
|
+
A host records how a server authenticates in the credential's `metadata.mcp.auth`: `"none"` (the keyless sentinel), `"token"` (a pasted `api_key`) or `"oauth"` (an `oauth_token` login through `@corbits/oauth-core`, using `discoverMcpLoginEntry` and `mcpClientConfig` above). This package does not read the value; it reads the credential's type, so pass the `oauth_token` credential's id as `credentialId`. At run time the credential's provider shapes the handle from the credential's current secret, so a refreshed token reaches running agents without any change to the bundle.
|
|
64
169
|
|
|
65
|
-
|
|
66
|
-
await discovery itself.
|
|
170
|
+
### Grant access
|
|
67
171
|
|
|
68
|
-
|
|
172
|
+
Grant the agent's principal `tool:deepwiki.*` for the whole server, or `tool:deepwiki.<tool>` per tool, with `allow`, `ask` or `deny`.
|
|
69
173
|
|
|
70
|
-
|
|
71
|
-
`deny` beats `ask` beats `allow`. Because each remote tool is its own agent
|
|
72
|
-
tool, `tool:<handle>.<tool>` is grantable individually and `tool:<handle>.*`
|
|
73
|
-
covers a whole server.
|
|
174
|
+
## Upgrading from 0.1
|
|
74
175
|
|
|
75
|
-
|
|
76
|
-
`
|
|
77
|
-
|
|
176
|
+
- Existing server configs, stored catalogs and the `mcp-server` credential keep working unchanged.
|
|
177
|
+
- `@intx/harness` is a required peer. Import `FetchLike` from it; `@corbits/mcp` no longer exports that type.
|
|
178
|
+
- `@intx/db`, `@intx/hub-api`, `drizzle-orm`, `hono` and `@corbits/credential-http` are optional peers. Install them if you use `@corbits/mcp/hub`. `@corbits/credential-http` replaces `@corbits/credential-mcp`.
|
|
179
|
+
- Discovery with a credential pins to the credential's provider `apiBaseUrl` origin, not to the requested URL's origin. A credential with a secret whose provider has no `apiBaseUrl` is refused, and a URL on another origin needs `extraOrigins`. No origin is allowed by default.
|
|
180
|
+
- `readCredentialSecret` is now `readCredential`, which returns `{ secret, origin? }`. `discoverMcpServer` takes `credential: { secret, origin? }` instead of `secret`.
|
|
181
|
+
- `mcpInitialize` returns an `McpSession` (`protocolVersion`, `sessionId?`, `serverInfo?`) and sends `notifications/initialized`. Pass it as `{ session }` to `mcpListTools` and `mcpCallTool`; a stateful server needs it.
|
|
182
|
+
- The discovery route refuses plain-http loopback URLs unless `allowLoopback` is set. Handles and server names containing `.`, and catalogs repeating a tool name, are refused. `mcpTools` refuses an empty or repeated server name, and a non-https URL, before any request.
|
|
183
|
+
- `@intx/agent` and `@intx/harness` peers are `^0.4.0`.
|
|
184
|
+
- Discovery rejects authorization-server metadata whose `issuer` differs from the one the protected resource names.
|
|
78
185
|
|
|
79
186
|
## License
|
|
80
187
|
|
|
81
|
-
LGPL-2.1-only.
|
|
188
|
+
[LGPL-2.1-only](https://github.com/corbitsdev/corbits-mcp/blob/main/LICENSE)
|
package/dist/client.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { FetchLike } from "@intx/harness";
|
|
1
2
|
export declare const McpToolSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
|
2
3
|
name: string;
|
|
3
4
|
inputSchema: Record<string, unknown>;
|
|
@@ -14,13 +15,22 @@ export interface McpToolResult {
|
|
|
14
15
|
content: unknown;
|
|
15
16
|
isError?: boolean;
|
|
16
17
|
}
|
|
17
|
-
export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
18
18
|
export interface McpClientOptions {
|
|
19
19
|
/** Injectable `fetch` for tests and mediated-credential handles. */
|
|
20
20
|
fetch?: FetchLike;
|
|
21
|
+
/** Abort the request, including reading its body, after this long. */
|
|
22
|
+
timeoutMs?: number;
|
|
23
|
+
/** Abort the request when this fires, e.g. a cancelled tool call. */
|
|
24
|
+
signal?: AbortSignal;
|
|
25
|
+
/** The session `mcpInitialize` opened; required by stateful servers. */
|
|
26
|
+
session?: McpSession;
|
|
21
27
|
}
|
|
28
|
+
/** The run-time `tools/call` and `initialize` bound when a caller sets none. */
|
|
29
|
+
export declare const DEFAULT_TIMEOUT_MS = 60000;
|
|
22
30
|
export declare class McpError extends Error {
|
|
23
|
-
|
|
31
|
+
/** The HTTP status, when the server answered with a non-2xx one. */
|
|
32
|
+
readonly status: number | undefined;
|
|
33
|
+
constructor(message: string, status?: number);
|
|
24
34
|
}
|
|
25
35
|
declare const InitializeResult: import("arktype/internal/variants/object.ts").ObjectType<{
|
|
26
36
|
protocolVersion?: string;
|
|
@@ -30,9 +40,17 @@ declare const InitializeResult: import("arktype/internal/variants/object.ts").Ob
|
|
|
30
40
|
};
|
|
31
41
|
}, {}>;
|
|
32
42
|
export type McpServerInfo = typeof InitializeResult.infer;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
43
|
+
export type McpSession = {
|
|
44
|
+
/** The negotiated version, sent as `MCP-Protocol-Version`. */
|
|
45
|
+
readonly protocolVersion: string;
|
|
46
|
+
/** The `Mcp-Session-Id` a stateful server assigned, if any. */
|
|
47
|
+
readonly sessionId?: string;
|
|
48
|
+
/** The server's own identification, for a caller that shows it. */
|
|
49
|
+
readonly serverInfo?: NonNullable<McpServerInfo["serverInfo"]>;
|
|
50
|
+
};
|
|
51
|
+
/** `initialize` handshake followed by `notifications/initialized`. Pass the
|
|
52
|
+
* returned session to every later call on this server. */
|
|
53
|
+
export declare function mcpInitialize(url: string, opts?: McpClientOptions): Promise<McpSession>;
|
|
36
54
|
/** `tools/list`: the remote server's tool catalog. */
|
|
37
55
|
export declare function mcpListTools(url: string, opts?: McpClientOptions): Promise<McpTool[]>;
|
|
38
56
|
export declare function mcpCallTool(url: string, name: string, toolArguments: Record<string, unknown>, opts?: McpClientOptions): Promise<McpToolResult>;
|
package/dist/client.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
// MCP streamable-HTTP transport client (2025-03-26 spec)
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// MCP streamable-HTTP transport client (2025-03-26 spec). Each call sends one
|
|
2
|
+
// request and awaits its one reply, which is all initialize/tools-list/
|
|
3
|
+
// tools-call need; the session `initialize` opens is carried in `session`.
|
|
4
|
+
// No resumption.
|
|
4
5
|
import { type } from "arktype";
|
|
5
6
|
export const McpToolSchema = type({
|
|
6
7
|
name: "string",
|
|
@@ -13,6 +14,11 @@ export const McpToolSchema = type({
|
|
|
13
14
|
"openWorldHint?": "boolean",
|
|
14
15
|
},
|
|
15
16
|
});
|
|
17
|
+
/** The run-time `tools/call` and `initialize` bound when a caller sets none. */
|
|
18
|
+
export const DEFAULT_TIMEOUT_MS = 60_000;
|
|
19
|
+
const PROTOCOL_VERSION = "2025-03-26";
|
|
20
|
+
/** Largest response body or SSE frame read before the stream is cancelled. */
|
|
21
|
+
const MAX_MESSAGE_BYTES = 4 * 1024 * 1024;
|
|
16
22
|
const JsonRpcResponse = type({
|
|
17
23
|
jsonrpc: "'2.0'",
|
|
18
24
|
id: "string | number",
|
|
@@ -20,12 +26,36 @@ const JsonRpcResponse = type({
|
|
|
20
26
|
"error?": { code: "number", message: "string", "data?": "unknown" },
|
|
21
27
|
});
|
|
22
28
|
export class McpError extends Error {
|
|
23
|
-
|
|
29
|
+
/** The HTTP status, when the server answered with a non-2xx one. */
|
|
30
|
+
status;
|
|
31
|
+
constructor(message, status) {
|
|
24
32
|
super(message);
|
|
25
33
|
this.name = "McpError";
|
|
34
|
+
this.status = status;
|
|
26
35
|
}
|
|
27
36
|
}
|
|
28
37
|
let nextId = 1;
|
|
38
|
+
/** A reply to request `id`: a message carrying `result` or `error` and no
|
|
39
|
+
* `method`, which would make it a server request reusing the id. */
|
|
40
|
+
function isResponseTo(message, id) {
|
|
41
|
+
return (typeof message === "object" &&
|
|
42
|
+
message !== null &&
|
|
43
|
+
message.id === id &&
|
|
44
|
+
!("method" in message) &&
|
|
45
|
+
("result" in message || "error" in message));
|
|
46
|
+
}
|
|
47
|
+
function headersFor(session) {
|
|
48
|
+
return {
|
|
49
|
+
"content-type": "application/json",
|
|
50
|
+
accept: "application/json, text/event-stream",
|
|
51
|
+
...(session === undefined
|
|
52
|
+
? {}
|
|
53
|
+
: { "mcp-protocol-version": session.protocolVersion }),
|
|
54
|
+
...(session?.sessionId === undefined
|
|
55
|
+
? {}
|
|
56
|
+
: { "mcp-session-id": session.sessionId }),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
29
59
|
/**
|
|
30
60
|
* Send one JSON-RPC request over streamable HTTP and return its `result`.
|
|
31
61
|
* Handles both response shapes the spec allows: a direct `application/json`
|
|
@@ -35,71 +65,153 @@ let nextId = 1;
|
|
|
35
65
|
async function sendRequest(url, method, params, opts) {
|
|
36
66
|
const fetchImpl = opts.fetch ?? fetch;
|
|
37
67
|
const id = nextId++;
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
method,
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
68
|
+
const timeout = opts.timeoutMs === undefined
|
|
69
|
+
? undefined
|
|
70
|
+
: AbortSignal.timeout(opts.timeoutMs);
|
|
71
|
+
const signals = [timeout, opts.signal].filter((s) => s !== undefined);
|
|
72
|
+
const signal = signals.length > 0 ? AbortSignal.any(signals) : undefined;
|
|
73
|
+
let message;
|
|
74
|
+
let sessionId;
|
|
75
|
+
try {
|
|
76
|
+
const response = await fetchImpl(url, {
|
|
77
|
+
method: "POST",
|
|
78
|
+
headers: headersFor(opts.session),
|
|
79
|
+
body: JSON.stringify({
|
|
80
|
+
jsonrpc: "2.0",
|
|
81
|
+
id,
|
|
82
|
+
method,
|
|
83
|
+
...(params !== undefined ? { params } : {}),
|
|
84
|
+
}),
|
|
85
|
+
...(signal !== undefined ? { signal } : {}),
|
|
86
|
+
});
|
|
87
|
+
if (!response.ok) {
|
|
88
|
+
await response.body?.cancel();
|
|
89
|
+
throw new McpError(`MCP server ${url} responded ${response.status} to ${method}`, response.status);
|
|
90
|
+
}
|
|
91
|
+
sessionId = response.headers.get("mcp-session-id") ?? undefined;
|
|
92
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
93
|
+
message = contentType.includes("text/event-stream")
|
|
94
|
+
? await readSseJsonRpc(response, id, signal)
|
|
95
|
+
: parseJson(await readCapped(response, signal));
|
|
96
|
+
}
|
|
97
|
+
catch (cause) {
|
|
98
|
+
if (opts.signal?.aborted === true) {
|
|
99
|
+
throw new McpError(`${method} to MCP server ${url} was cancelled`);
|
|
100
|
+
}
|
|
101
|
+
if (timeout?.aborted === true) {
|
|
102
|
+
throw new McpError(`MCP server ${url} did not answer ${method} within ${String(opts.timeoutMs)}ms`);
|
|
103
|
+
}
|
|
104
|
+
throw cause;
|
|
53
105
|
}
|
|
54
|
-
const contentType = response.headers.get("content-type") ?? "";
|
|
55
|
-
const message = contentType.includes("text/event-stream")
|
|
56
|
-
? await readSseJsonRpc(response, id)
|
|
57
|
-
: await response.json();
|
|
58
106
|
const parsed = JsonRpcResponse(message);
|
|
59
|
-
if (parsed instanceof type.errors) {
|
|
60
|
-
throw new McpError(`MCP server ${url} sent a malformed response to ${method}
|
|
107
|
+
if (parsed instanceof type.errors || !isResponseTo(message, id)) {
|
|
108
|
+
throw new McpError(`MCP server ${url} sent a malformed response to ${method}${parsed instanceof type.errors ? `: ${parsed.summary}` : ""}`);
|
|
61
109
|
}
|
|
62
110
|
if (parsed.error !== undefined) {
|
|
63
111
|
throw new McpError(`MCP server ${url} rejected ${method}: ${parsed.error.message}`);
|
|
64
112
|
}
|
|
65
|
-
return parsed.result;
|
|
113
|
+
return { result: parsed.result, sessionId };
|
|
66
114
|
}
|
|
67
|
-
/**
|
|
68
|
-
async function
|
|
115
|
+
/** Send a JSON-RPC notification; the server answers 202 with no body. */
|
|
116
|
+
async function sendNotification(url, method, opts) {
|
|
117
|
+
const fetchImpl = opts.fetch ?? fetch;
|
|
118
|
+
const timeout = opts.timeoutMs === undefined
|
|
119
|
+
? undefined
|
|
120
|
+
: AbortSignal.timeout(opts.timeoutMs);
|
|
121
|
+
let response;
|
|
122
|
+
try {
|
|
123
|
+
response = await fetchImpl(url, {
|
|
124
|
+
method: "POST",
|
|
125
|
+
headers: headersFor(opts.session),
|
|
126
|
+
body: JSON.stringify({ jsonrpc: "2.0", method }),
|
|
127
|
+
...(timeout !== undefined ? { signal: timeout } : {}),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
catch (cause) {
|
|
131
|
+
if (timeout?.aborted === true) {
|
|
132
|
+
throw new McpError(`MCP server ${url} did not answer ${method} within ${String(opts.timeoutMs)}ms`);
|
|
133
|
+
}
|
|
134
|
+
throw cause;
|
|
135
|
+
}
|
|
136
|
+
await response.body?.cancel();
|
|
137
|
+
if (!response.ok) {
|
|
138
|
+
throw new McpError(`MCP server ${url} responded ${response.status} to ${method}`, response.status);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function parseJson(text) {
|
|
142
|
+
try {
|
|
143
|
+
return JSON.parse(text);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
throw new McpError("MCP server sent a body that is not JSON");
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function overflow() {
|
|
150
|
+
return new McpError(`MCP server sent a message larger than ${String(MAX_MESSAGE_BYTES)} bytes`);
|
|
151
|
+
}
|
|
152
|
+
/** Read a body's chunks, cancelling the stream when the signal aborts. */
|
|
153
|
+
async function* chunks(response, signal) {
|
|
69
154
|
const body = response.body;
|
|
70
155
|
if (body === null) {
|
|
71
|
-
throw new McpError("MCP server sent
|
|
156
|
+
throw new McpError("MCP server sent a response with no body");
|
|
72
157
|
}
|
|
73
158
|
const reader = body.getReader();
|
|
74
|
-
const
|
|
75
|
-
|
|
159
|
+
const abort = () => void reader.cancel().catch(() => undefined);
|
|
160
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
76
161
|
try {
|
|
77
162
|
for (;;) {
|
|
78
163
|
const { done, value } = await reader.read();
|
|
79
164
|
if (done)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const frames = buffer.split("\n\n");
|
|
83
|
-
buffer = frames.pop() ?? "";
|
|
84
|
-
for (const frame of frames) {
|
|
85
|
-
const dataLines = frame
|
|
86
|
-
.split("\n")
|
|
87
|
-
.filter((line) => line.startsWith("data:"))
|
|
88
|
-
.map((line) => line.slice(5).trim());
|
|
89
|
-
if (dataLines.length === 0)
|
|
90
|
-
continue;
|
|
91
|
-
const candidate = JSON.parse(dataLines.join("\n"));
|
|
92
|
-
if (typeof candidate === "object" &&
|
|
93
|
-
candidate !== null &&
|
|
94
|
-
"id" in candidate &&
|
|
95
|
-
candidate.id === id) {
|
|
96
|
-
return candidate;
|
|
97
|
-
}
|
|
98
|
-
}
|
|
165
|
+
return;
|
|
166
|
+
yield value;
|
|
99
167
|
}
|
|
100
168
|
}
|
|
101
169
|
finally {
|
|
102
|
-
|
|
170
|
+
signal?.removeEventListener("abort", abort);
|
|
171
|
+
await reader.cancel().catch(() => undefined);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
async function readCapped(response, signal) {
|
|
175
|
+
const decoder = new TextDecoder();
|
|
176
|
+
let text = "";
|
|
177
|
+
let bytes = 0;
|
|
178
|
+
for await (const chunk of chunks(response, signal)) {
|
|
179
|
+
bytes += chunk.byteLength;
|
|
180
|
+
if (bytes > MAX_MESSAGE_BYTES)
|
|
181
|
+
throw overflow();
|
|
182
|
+
text += decoder.decode(chunk, { stream: true });
|
|
183
|
+
}
|
|
184
|
+
return text + decoder.decode();
|
|
185
|
+
}
|
|
186
|
+
/** Read an SSE body and return the first JSON-RPC message whose id matches. */
|
|
187
|
+
async function readSseJsonRpc(response, id, signal) {
|
|
188
|
+
const decoder = new TextDecoder();
|
|
189
|
+
let buffer = "";
|
|
190
|
+
for await (const chunk of chunks(response, signal)) {
|
|
191
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
192
|
+
const frames = buffer.split(/\r\n\r\n|\n\n|\r\r/);
|
|
193
|
+
buffer = frames.pop() ?? "";
|
|
194
|
+
if (buffer.length > MAX_MESSAGE_BYTES)
|
|
195
|
+
throw overflow();
|
|
196
|
+
for (const frame of frames) {
|
|
197
|
+
if (frame.length > MAX_MESSAGE_BYTES)
|
|
198
|
+
throw overflow();
|
|
199
|
+
const dataLines = frame
|
|
200
|
+
.split(/\r\n|\r|\n/)
|
|
201
|
+
.filter((line) => line.startsWith("data:"))
|
|
202
|
+
.map((line) => line.slice(5).trim());
|
|
203
|
+
if (dataLines.length === 0)
|
|
204
|
+
continue;
|
|
205
|
+
let candidate;
|
|
206
|
+
try {
|
|
207
|
+
candidate = JSON.parse(dataLines.join("\n"));
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (isResponseTo(candidate, id))
|
|
213
|
+
return candidate;
|
|
214
|
+
}
|
|
103
215
|
}
|
|
104
216
|
throw new McpError("MCP server closed the event stream without a matching response");
|
|
105
217
|
}
|
|
@@ -107,25 +219,48 @@ const InitializeResult = type({
|
|
|
107
219
|
"protocolVersion?": "string",
|
|
108
220
|
"serverInfo?": { name: "string", "version?": "string" },
|
|
109
221
|
});
|
|
110
|
-
/** `initialize` handshake
|
|
111
|
-
*
|
|
222
|
+
/** `initialize` handshake followed by `notifications/initialized`. Pass the
|
|
223
|
+
* returned session to every later call on this server. */
|
|
112
224
|
export async function mcpInitialize(url, opts = {}) {
|
|
113
|
-
const result = await sendRequest(url, "initialize", {
|
|
114
|
-
protocolVersion:
|
|
225
|
+
const { result, sessionId } = await sendRequest(url, "initialize", {
|
|
226
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
115
227
|
capabilities: {},
|
|
116
|
-
clientInfo: { name: "@corbits/mcp", version: "0.
|
|
117
|
-
},
|
|
228
|
+
clientInfo: { name: "@corbits/mcp", version: "0.2.0" },
|
|
229
|
+
}, {
|
|
230
|
+
...(opts.fetch !== undefined ? { fetch: opts.fetch } : {}),
|
|
231
|
+
...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
|
|
232
|
+
...(opts.signal !== undefined ? { signal: opts.signal } : {}),
|
|
233
|
+
});
|
|
118
234
|
const parsed = InitializeResult(result);
|
|
119
|
-
|
|
235
|
+
const info = parsed instanceof type.errors ? {} : parsed;
|
|
236
|
+
// Echoed into the MCP-Protocol-Version header, so printable ASCII only.
|
|
237
|
+
if (info.protocolVersion !== undefined &&
|
|
238
|
+
!/^[\x20-\x7e]+$/.test(info.protocolVersion)) {
|
|
239
|
+
throw new McpError(`MCP server ${url} sent a protocolVersion that is not printable ASCII`);
|
|
240
|
+
}
|
|
241
|
+
const session = {
|
|
242
|
+
protocolVersion: info.protocolVersion ?? PROTOCOL_VERSION,
|
|
243
|
+
...(sessionId !== undefined ? { sessionId } : {}),
|
|
244
|
+
...(info.serverInfo !== undefined ? { serverInfo: info.serverInfo } : {}),
|
|
245
|
+
};
|
|
246
|
+
await sendNotification(url, "notifications/initialized", {
|
|
247
|
+
...opts,
|
|
248
|
+
session,
|
|
249
|
+
});
|
|
250
|
+
return session;
|
|
120
251
|
}
|
|
121
252
|
const ToolsListResult = type({ tools: McpToolSchema.array() });
|
|
122
253
|
/** `tools/list`: the remote server's tool catalog. */
|
|
123
254
|
export async function mcpListTools(url, opts = {}) {
|
|
124
|
-
const result = await sendRequest(url, "tools/list", undefined, opts);
|
|
255
|
+
const { result } = await sendRequest(url, "tools/list", undefined, opts);
|
|
125
256
|
const parsed = ToolsListResult(result);
|
|
126
257
|
if (parsed instanceof type.errors) {
|
|
127
258
|
throw new McpError(`MCP server ${url} sent a malformed tools/list result: ${parsed.summary}`);
|
|
128
259
|
}
|
|
260
|
+
const names = new Set(parsed.tools.map((tool) => tool.name));
|
|
261
|
+
if (names.size !== parsed.tools.length) {
|
|
262
|
+
throw new McpError(`MCP server ${url} sent a tools/list result with duplicate tool names`);
|
|
263
|
+
}
|
|
129
264
|
return parsed.tools;
|
|
130
265
|
}
|
|
131
266
|
/** `tools/call`: invoke a remote tool by name with JSON arguments. */
|
|
@@ -134,7 +269,7 @@ const ToolCallResult = type({
|
|
|
134
269
|
"isError?": "boolean",
|
|
135
270
|
});
|
|
136
271
|
export async function mcpCallTool(url, name, toolArguments, opts = {}) {
|
|
137
|
-
const result = await sendRequest(url, "tools/call", { name, arguments: toolArguments }, opts);
|
|
272
|
+
const { result } = await sendRequest(url, "tools/call", { name, arguments: toolArguments }, opts);
|
|
138
273
|
const parsed = ToolCallResult(result);
|
|
139
274
|
if (parsed instanceof type.errors) {
|
|
140
275
|
throw new McpError(`MCP server ${url} sent a malformed tools/call result for ${name}: ${parsed.summary}`);
|