@effect-uai/mcp 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/Client.d.mts +55 -0
- package/dist/Client.d.mts.map +1 -0
- package/dist/Client.mjs +85 -0
- package/dist/Client.mjs.map +1 -0
- package/dist/McpError-DWg9BlW5.d.mts +83 -0
- package/dist/McpError-DWg9BlW5.d.mts.map +1 -0
- package/dist/McpError.d.mts +2 -0
- package/dist/McpError.mjs +61 -0
- package/dist/McpError.mjs.map +1 -0
- package/dist/Toolkit.d.mts +20 -0
- package/dist/Toolkit.d.mts.map +1 -0
- package/dist/Toolkit.mjs +70 -0
- package/dist/Toolkit.mjs.map +1 -0
- package/dist/auth-ZDvsxpCo.d.mts +126 -0
- package/dist/auth-ZDvsxpCo.d.mts.map +1 -0
- package/dist/index.d.mts +4 -0
- package/dist/index.mjs +4 -0
- package/dist/internal/auth.d.mts +2 -0
- package/dist/internal/auth.mjs +25 -0
- package/dist/internal/auth.mjs.map +1 -0
- package/dist/internal/httpTransport.d.mts +17 -0
- package/dist/internal/httpTransport.d.mts.map +1 -0
- package/dist/internal/httpTransport.mjs +88 -0
- package/dist/internal/httpTransport.mjs.map +1 -0
- package/dist/internal/protocol.d.mts +2 -0
- package/dist/internal/protocol.mjs +23 -0
- package/dist/internal/protocol.mjs.map +1 -0
- package/dist/internal/protocols/2025-06-18.d.mts +11 -0
- package/dist/internal/protocols/2025-06-18.d.mts.map +1 -0
- package/dist/internal/protocols/2025-06-18.mjs +70 -0
- package/dist/internal/protocols/2025-06-18.mjs.map +1 -0
- package/dist/internal/protocols/2025-11-25.d.mts +2 -0
- package/dist/internal/protocols/2025-11-25.mjs +2 -0
- package/dist/internal/protocols/2026-07-28.d.mts +16 -0
- package/dist/internal/protocols/2026-07-28.d.mts.map +1 -0
- package/dist/internal/protocols/2026-07-28.mjs +83 -0
- package/dist/internal/protocols/2026-07-28.mjs.map +1 -0
- package/dist/internal/rpc.d.mts +2 -0
- package/dist/internal/rpc.mjs +78 -0
- package/dist/internal/rpc.mjs.map +1 -0
- package/dist/internal/schema.d.mts +2 -0
- package/dist/internal/schema.mjs +168 -0
- package/dist/internal/schema.mjs.map +1 -0
- package/dist/internal/stdioTransport.d.mts +15 -0
- package/dist/internal/stdioTransport.d.mts.map +1 -0
- package/dist/internal/stdioTransport.mjs +46 -0
- package/dist/internal/stdioTransport.mjs.map +1 -0
- package/dist/protocol-kjC3iAMb.d.mts +35 -0
- package/dist/protocol-kjC3iAMb.d.mts.map +1 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/dist/rpc-Bq95Lio9.d.mts +34 -0
- package/dist/rpc-Bq95Lio9.d.mts.map +1 -0
- package/dist/schema-D2oUNuo5.d.mts +245 -0
- package/dist/schema-D2oUNuo5.d.mts.map +1 -0
- package/package.json +70 -0
- package/src/Client.ts +180 -0
- package/src/McpError.ts +93 -0
- package/src/Toolkit.ts +109 -0
- package/src/index.ts +3 -0
- package/src/internal/auth.ts +48 -0
- package/src/internal/httpTransport.ts +171 -0
- package/src/internal/protocol.ts +67 -0
- package/src/internal/protocols/2025-06-18.ts +110 -0
- package/src/internal/protocols/2025-11-25.ts +8 -0
- package/src/internal/protocols/2026-07-28.test.ts +131 -0
- package/src/internal/protocols/2026-07-28.ts +152 -0
- package/src/internal/rpc.test.ts +156 -0
- package/src/internal/rpc.ts +189 -0
- package/src/internal/schema.ts +223 -0
- package/src/internal/stdioTransport.ts +63 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `Protocol` seam: everything that differs between the stateless
|
|
3
|
+
* 2026-07-28 revision and the 2025-06-18 handshake era, behind one interface.
|
|
4
|
+
* `modern.ts` and `legacy.ts` implement it independently and never import each
|
|
5
|
+
* other; `Client.connect` picks one, once, from the probes.
|
|
6
|
+
*
|
|
7
|
+
* The negotiated `version` is the single source of truth. There is no separate
|
|
8
|
+
* era label: nothing branches on it at runtime, so carrying one would only
|
|
9
|
+
* create state that can drift out of sync with the version.
|
|
10
|
+
*/
|
|
11
|
+
import { Effect, Option, Predicate, type Scope } from "effect"
|
|
12
|
+
import type { McpError } from "../McpError.js"
|
|
13
|
+
import type { Inbound, McpConnection, SendMeta } from "./rpc.js"
|
|
14
|
+
import {
|
|
15
|
+
decodeUnsupportedVersionData,
|
|
16
|
+
type McpMethod,
|
|
17
|
+
MODERN_ERROR_CODES,
|
|
18
|
+
type ProtocolVersion,
|
|
19
|
+
type ServerInfo,
|
|
20
|
+
} from "./schema.js"
|
|
21
|
+
|
|
22
|
+
export type Protocol = {
|
|
23
|
+
readonly version: ProtocolVersion
|
|
24
|
+
readonly serverInfo: ServerInfo
|
|
25
|
+
/** Wrap outgoing params: modern adds the `_meta` envelope, legacy passes through. */
|
|
26
|
+
readonly envelope: (method: McpMethod, params: unknown) => unknown
|
|
27
|
+
/** Per-request transport hints: modern headers, or the legacy session id. */
|
|
28
|
+
readonly meta: (method: McpMethod, params: unknown) => SendMeta
|
|
29
|
+
/** Answer a server-initiated frame. Modern servers never send one. */
|
|
30
|
+
readonly onInbound: (inbound: Inbound) => Effect.Effect<void>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Probe for one protocol era. `None` means "not this era, try the next"; a
|
|
35
|
+
* failure means the server is this era but unusable (no mutual version).
|
|
36
|
+
*/
|
|
37
|
+
export type ProtocolProbe = (
|
|
38
|
+
connection: McpConnection,
|
|
39
|
+
) => Effect.Effect<Option.Option<Protocol>, McpError, Scope.Scope>
|
|
40
|
+
|
|
41
|
+
const isProtocolError = Predicate.isTagged("McpProtocolError")
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A `-32020` / `-32021` / `-32022` reply, which is the spec's era
|
|
45
|
+
* discriminator: it proves the peer speaks a modern version, so the client
|
|
46
|
+
* corrects and retries rather than falling back to `initialize`. `supported`
|
|
47
|
+
* carries the versions a `-32022` offered, decoded through the schema so a
|
|
48
|
+
* server that adds fields cannot break it.
|
|
49
|
+
*/
|
|
50
|
+
export type ModernRejection = {
|
|
51
|
+
readonly code: number
|
|
52
|
+
readonly supported: ReadonlyArray<string>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const modernRejection = (error: McpError): Option.Option<ModernRejection> =>
|
|
56
|
+
isProtocolError(error)
|
|
57
|
+
? Option.fromNullishOr(error.code).pipe(
|
|
58
|
+
Option.filter((code) => MODERN_ERROR_CODES.has(code)),
|
|
59
|
+
Option.map((code) => ({ code, supported: supportedVersions(error.raw) })),
|
|
60
|
+
)
|
|
61
|
+
: Option.none()
|
|
62
|
+
|
|
63
|
+
const supportedVersions = (raw: unknown): ReadonlyArray<string> =>
|
|
64
|
+
decodeUnsupportedVersionData(Predicate.hasProperty(raw, "data") ? raw.data : undefined).pipe(
|
|
65
|
+
Option.flatMap((data) => Option.fromNullishOr(data.supported)),
|
|
66
|
+
Option.getOrElse((): ReadonlyArray<string> => []),
|
|
67
|
+
)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Protocol revision 2025-06-18: the handshake era, and the compatibility mode
|
|
3
|
+
* this client keeps for the servers that have not migrated. Also serves
|
|
4
|
+
* 2025-11-25, which is wire-compatible for a tools-only client (see
|
|
5
|
+
* `2025-11-25.ts`).
|
|
6
|
+
*
|
|
7
|
+
* Three things differ from the stateless era, and they are all here:
|
|
8
|
+
* an `initialize` exchange at connect, a negotiated version echoed on every
|
|
9
|
+
* later request, and server-initiated requests arriving on the connection.
|
|
10
|
+
*
|
|
11
|
+
* The HTTP session id (`Mcp-Session-Id`) is deliberately *not* here: it is a
|
|
12
|
+
* transport concern that `httpTransport` captures and echoes on its own, so
|
|
13
|
+
* this file stays transport-agnostic.
|
|
14
|
+
*/
|
|
15
|
+
import { Effect, Option, type Scope } from "effect"
|
|
16
|
+
import { type McpError, McpUnsupportedProtocol } from "../../McpError.js"
|
|
17
|
+
import type { Protocol, ProtocolProbe } from "../protocol.js"
|
|
18
|
+
import type { Inbound, McpConnection, SendMeta } from "../rpc.js"
|
|
19
|
+
import {
|
|
20
|
+
asProtocolVersion,
|
|
21
|
+
CLIENT_INFO,
|
|
22
|
+
CODE_METHOD_NOT_FOUND,
|
|
23
|
+
decodeInitializeResult,
|
|
24
|
+
LEGACY_VERSION,
|
|
25
|
+
type McpMethod,
|
|
26
|
+
type ProtocolVersion,
|
|
27
|
+
type ServerInfo,
|
|
28
|
+
} from "../schema.js"
|
|
29
|
+
|
|
30
|
+
const UNKNOWN_SERVER: ServerInfo = { name: "unknown" }
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Legacy params travel bare: no `_meta` envelope. Only the negotiated version
|
|
34
|
+
* rides along, and on HTTP only as a header.
|
|
35
|
+
*/
|
|
36
|
+
const makeProtocol = (
|
|
37
|
+
connection: McpConnection,
|
|
38
|
+
version: ProtocolVersion,
|
|
39
|
+
serverInfo: ServerInfo,
|
|
40
|
+
): Protocol => ({
|
|
41
|
+
version,
|
|
42
|
+
serverInfo,
|
|
43
|
+
envelope: (_method: McpMethod, params: unknown) => params ?? {},
|
|
44
|
+
meta: (): SendMeta => ({ headers: { "MCP-Protocol-Version": version } }),
|
|
45
|
+
onInbound: (inbound: Inbound) => answer(connection, inbound),
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Legacy servers may send requests to the client. We are a headless tools
|
|
50
|
+
* client: answer `ping` so the connection stays healthy, and decline
|
|
51
|
+
* everything else (sampling, elicitation, roots) with method-not-found rather
|
|
52
|
+
* than leaving the server waiting.
|
|
53
|
+
*/
|
|
54
|
+
const answer = (connection: McpConnection, inbound: Inbound): Effect.Effect<void> =>
|
|
55
|
+
Option.match(inbound.id, {
|
|
56
|
+
// A notification expects no reply.
|
|
57
|
+
onNone: () => Effect.void,
|
|
58
|
+
onSome: (id) =>
|
|
59
|
+
(inbound.method === "ping"
|
|
60
|
+
? connection.respond(id, {})
|
|
61
|
+
: connection.respondError(
|
|
62
|
+
id,
|
|
63
|
+
CODE_METHOD_NOT_FOUND,
|
|
64
|
+
`${inbound.method} is not supported by this client`,
|
|
65
|
+
)
|
|
66
|
+
).pipe(Effect.ignore),
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Run the `initialize` handshake. Unlike the stateless probe there is nothing
|
|
71
|
+
* to fall back to afterwards, so a failure here propagates rather than
|
|
72
|
+
* yielding `None`: this is the last era we speak.
|
|
73
|
+
*/
|
|
74
|
+
export const probe: ProtocolProbe = (connection: McpConnection) =>
|
|
75
|
+
handshake(connection).pipe(Effect.map(Option.some))
|
|
76
|
+
|
|
77
|
+
const handshake = (connection: McpConnection): Effect.Effect<Protocol, McpError, Scope.Scope> =>
|
|
78
|
+
Effect.gen(function* () {
|
|
79
|
+
const raw = yield* connection.request("initialize", {
|
|
80
|
+
protocolVersion: LEGACY_VERSION,
|
|
81
|
+
capabilities: {},
|
|
82
|
+
clientInfo: CLIENT_INFO,
|
|
83
|
+
})
|
|
84
|
+
const result = yield* decodeInitializeResult(raw).pipe(
|
|
85
|
+
Effect.mapError(
|
|
86
|
+
() =>
|
|
87
|
+
new McpUnsupportedProtocol({
|
|
88
|
+
offered: [],
|
|
89
|
+
reason: "initialize returned an undecodable result",
|
|
90
|
+
}),
|
|
91
|
+
),
|
|
92
|
+
)
|
|
93
|
+
// The server picks the version; we either speak it or we stop here.
|
|
94
|
+
const version = yield* Option.match(asProtocolVersion(result.protocolVersion), {
|
|
95
|
+
onNone: () =>
|
|
96
|
+
Effect.fail(
|
|
97
|
+
new McpUnsupportedProtocol({
|
|
98
|
+
offered: [result.protocolVersion],
|
|
99
|
+
reason: `the server negotiated ${result.protocolVersion}, which this client does not support`,
|
|
100
|
+
}),
|
|
101
|
+
),
|
|
102
|
+
onSome: Effect.succeed,
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
// Required by the spec before any other request; failing to send it is
|
|
106
|
+
// fatal to the session, so it is not ignored.
|
|
107
|
+
yield* connection.notify("notifications/initialized")
|
|
108
|
+
|
|
109
|
+
return makeProtocol(connection, version, result.serverInfo ?? UNKNOWN_SERVER)
|
|
110
|
+
})
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Protocol revision 2025-11-25. Wire-compatible with 2025-06-18 for a
|
|
3
|
+
* tools-only client: its additions (icons, tasks, OIDC discovery) do not touch
|
|
4
|
+
* `initialize`, `tools/list` or `tools/call`, so it re-exports that
|
|
5
|
+
* implementation rather than duplicating it. If a future delta does affect our
|
|
6
|
+
* subset, it belongs here.
|
|
7
|
+
*/
|
|
8
|
+
export { probe } from "./2025-06-18.js"
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, it } from "@effect/vitest"
|
|
2
|
+
import { Cause, Effect, Option, Queue, Stream } from "effect"
|
|
3
|
+
import { expect } from "vitest"
|
|
4
|
+
import { McpUnsupportedProtocol } from "../../McpError.js"
|
|
5
|
+
import { open, type SendMeta, type Transport } from "../rpc.js"
|
|
6
|
+
import { LATEST_VERSION, META_PROTOCOL_VERSION } from "../schema.js"
|
|
7
|
+
import { probe } from "./2026-07-28.js"
|
|
8
|
+
|
|
9
|
+
type Sent = { readonly frame: string; readonly meta: SendMeta | undefined }
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A scripted server: `reply` sees each parsed request and returns the JSON-RPC
|
|
13
|
+
* body to answer with (`undefined` stays silent).
|
|
14
|
+
*/
|
|
15
|
+
const scripted = (reply: (request: Record<string, any>) => unknown) =>
|
|
16
|
+
Effect.gen(function* () {
|
|
17
|
+
const inbox = yield* Queue.make<string, Cause.Done>()
|
|
18
|
+
const sent: Array<Sent> = []
|
|
19
|
+
const transport: Transport = {
|
|
20
|
+
send: (frame, meta) =>
|
|
21
|
+
Effect.gen(function* () {
|
|
22
|
+
sent.push({ frame, meta })
|
|
23
|
+
const request = JSON.parse(frame) as Record<string, any>
|
|
24
|
+
const body = reply(request)
|
|
25
|
+
if (body !== undefined) {
|
|
26
|
+
yield* Queue.offer(
|
|
27
|
+
inbox,
|
|
28
|
+
JSON.stringify({ jsonrpc: "2.0", id: request.id, ...(body as object) }),
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
}),
|
|
32
|
+
messages: Stream.fromQueue(inbox),
|
|
33
|
+
}
|
|
34
|
+
return { transport, sent }
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
// Spec-conformant servers identify themselves in the result's `_meta`, not at
|
|
38
|
+
// the top level. Verified against Hugging Face, which does exactly this.
|
|
39
|
+
const discovered = {
|
|
40
|
+
result: {
|
|
41
|
+
resultType: "complete",
|
|
42
|
+
supportedVersions: [LATEST_VERSION],
|
|
43
|
+
capabilities: { tools: {} },
|
|
44
|
+
_meta: {
|
|
45
|
+
"io.modelcontextprotocol/serverInfo": {
|
|
46
|
+
name: "scripted",
|
|
47
|
+
version: "1.0.0",
|
|
48
|
+
title: "Scripted",
|
|
49
|
+
websiteUrl: "https://example.com",
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const unsupported = (supported: ReadonlyArray<string>) => ({
|
|
56
|
+
error: {
|
|
57
|
+
code: -32022,
|
|
58
|
+
message: "Unsupported protocol version",
|
|
59
|
+
data: { supported, requested: "1900-01-01" },
|
|
60
|
+
},
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const runProbe = (reply: (request: Record<string, any>) => unknown) =>
|
|
64
|
+
Effect.scoped(
|
|
65
|
+
Effect.gen(function* () {
|
|
66
|
+
const { sent, transport } = yield* scripted(reply)
|
|
67
|
+
const connection = yield* open(transport, () => Effect.void)
|
|
68
|
+
const protocol = yield* probe(connection)
|
|
69
|
+
return { protocol, sent }
|
|
70
|
+
}),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
describe("2026-07-28 probe", () => {
|
|
74
|
+
it.effect("negotiates the stateless protocol from a server/discover result", () =>
|
|
75
|
+
Effect.gen(function* () {
|
|
76
|
+
const { protocol } = yield* runProbe(() => discovered)
|
|
77
|
+
const value = Option.getOrThrow(protocol)
|
|
78
|
+
expect(value.version).toBe(LATEST_VERSION)
|
|
79
|
+
expect(value.serverInfo.name).toBe("scripted")
|
|
80
|
+
}),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
it.effect("keeps the MCP-Protocol-Version header equal to the _meta version", () =>
|
|
84
|
+
Effect.gen(function* () {
|
|
85
|
+
// A divergence between the two is exactly what servers reject with -32020.
|
|
86
|
+
const { sent } = yield* runProbe(() => discovered)
|
|
87
|
+
const frame = JSON.parse(sent[0]?.frame ?? "{}") as Record<string, any>
|
|
88
|
+
expect(sent[0]?.meta?.headers?.["MCP-Protocol-Version"]).toBe(
|
|
89
|
+
frame.params._meta[META_PROTOCOL_VERSION],
|
|
90
|
+
)
|
|
91
|
+
}),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
it.effect("retries on a mutually supported version after a -32022", () =>
|
|
95
|
+
Effect.gen(function* () {
|
|
96
|
+
let attempts = 0
|
|
97
|
+
const { protocol, sent } = yield* runProbe(() => {
|
|
98
|
+
attempts += 1
|
|
99
|
+
return attempts === 1 ? unsupported([LATEST_VERSION]) : discovered
|
|
100
|
+
})
|
|
101
|
+
expect(sent).toHaveLength(2)
|
|
102
|
+
expect(Option.getOrThrow(protocol).version).toBe(LATEST_VERSION)
|
|
103
|
+
}),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
it.effect("fails typed when the server shares no supported version", () =>
|
|
107
|
+
Effect.gen(function* () {
|
|
108
|
+
const exit = yield* Effect.exit(runProbe(() => unsupported(["1999-01-01"])))
|
|
109
|
+
const error = exit._tag === "Failure" ? Cause.squash(exit.cause) : undefined
|
|
110
|
+
expect(error).toBeInstanceOf(McpUnsupportedProtocol)
|
|
111
|
+
expect((error as McpUnsupportedProtocol).offered).toEqual(["1999-01-01"])
|
|
112
|
+
}),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
it.effect("yields None on a non-modern error so the caller falls back to the handshake", () =>
|
|
116
|
+
Effect.gen(function* () {
|
|
117
|
+
const { protocol } = yield* runProbe(() => ({
|
|
118
|
+
error: { code: -32601, message: "Method not found" },
|
|
119
|
+
}))
|
|
120
|
+
expect(Option.isNone(protocol)).toBe(true)
|
|
121
|
+
}),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
it.effect("Base64-encodes an Mcp-Name that is not header-safe", () =>
|
|
125
|
+
Effect.gen(function* () {
|
|
126
|
+
const { protocol } = yield* runProbe(() => discovered)
|
|
127
|
+
const { headers } = Option.getOrThrow(protocol).meta("tools/call", { name: "Hello, 世界" })
|
|
128
|
+
expect(headers?.["Mcp-Name"]).toBe("=?base64?SGVsbG8sIOS4lueVjA==?=")
|
|
129
|
+
}),
|
|
130
|
+
)
|
|
131
|
+
})
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Protocol revision 2026-07-28: the stateless era. No handshake and no
|
|
3
|
+
* session; every request self-describes via `_meta`, mirrored into the HTTP
|
|
4
|
+
* request headers. Servers never send requests back, so `onInbound` has
|
|
5
|
+
* nothing to answer.
|
|
6
|
+
*
|
|
7
|
+
* Files here are named for the revision they implement. A later revision that
|
|
8
|
+
* keeps this wire shape gets its own file re-exporting this one.
|
|
9
|
+
*/
|
|
10
|
+
import { Effect, Encoding, Option, Predicate, Record, type Scope } from "effect"
|
|
11
|
+
import { type McpError, McpUnsupportedProtocol } from "../../McpError.js"
|
|
12
|
+
import { modernRejection, type Protocol, type ProtocolProbe } from "../protocol.js"
|
|
13
|
+
import type { McpConnection, SendMeta } from "../rpc.js"
|
|
14
|
+
import {
|
|
15
|
+
asProtocolVersion,
|
|
16
|
+
CLIENT_INFO,
|
|
17
|
+
decodeDiscoverResult,
|
|
18
|
+
META_CLIENT_CAPABILITIES,
|
|
19
|
+
META_CLIENT_INFO,
|
|
20
|
+
META_SERVER_INFO,
|
|
21
|
+
META_PROTOCOL_VERSION,
|
|
22
|
+
type McpMethod,
|
|
23
|
+
LATEST_VERSION,
|
|
24
|
+
type ProtocolVersion,
|
|
25
|
+
type ServerInfo,
|
|
26
|
+
} from "../schema.js"
|
|
27
|
+
|
|
28
|
+
const UNKNOWN_SERVER: ServerInfo = { name: "unknown" }
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Which param field carries the subject name mirrored into `Mcp-Name`, per
|
|
32
|
+
* method. Keyed by string rather than `McpMethod` so the methods a later
|
|
33
|
+
* revision adds need no cast here.
|
|
34
|
+
*/
|
|
35
|
+
const NAME_FIELD: Record<string, string> = {
|
|
36
|
+
"tools/call": "name",
|
|
37
|
+
"prompts/get": "name",
|
|
38
|
+
"resources/read": "uri",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const subjectName = (method: McpMethod, params: unknown): Option.Option<string> =>
|
|
42
|
+
Record.get(NAME_FIELD, method).pipe(
|
|
43
|
+
Option.flatMap((field) => Record.get(asRecord(params), field)),
|
|
44
|
+
Option.filter(Predicate.isString),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
const asRecord = (value: unknown): Record<string, unknown> =>
|
|
48
|
+
Predicate.isObject(value) ? value : {}
|
|
49
|
+
|
|
50
|
+
const SENTINEL = /^=\?base64\?.*\?=$/
|
|
51
|
+
// Visible ASCII only, no leading or trailing space (RFC 9110 field values).
|
|
52
|
+
const HEADER_SAFE = /^[\x21-\x7e]([\x20-\x7e]*[\x21-\x7e])?$/
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Header values outside the safe set (and any literal that would be mistaken
|
|
56
|
+
* for the marker) ride the spec's Base64 sentinel form.
|
|
57
|
+
*/
|
|
58
|
+
export const headerValue = (raw: string): string =>
|
|
59
|
+
HEADER_SAFE.test(raw) && !SENTINEL.test(raw) ? raw : `=?base64?${Encoding.encodeBase64(raw)}?=`
|
|
60
|
+
|
|
61
|
+
const makeProtocol = (version: ProtocolVersion, serverInfo: ServerInfo): Protocol => ({
|
|
62
|
+
version,
|
|
63
|
+
serverInfo,
|
|
64
|
+
|
|
65
|
+
envelope: (_method, params) => ({
|
|
66
|
+
...asRecord(params),
|
|
67
|
+
_meta: {
|
|
68
|
+
[META_PROTOCOL_VERSION]: version,
|
|
69
|
+
[META_CLIENT_INFO]: CLIENT_INFO,
|
|
70
|
+
[META_CLIENT_CAPABILITIES]: {},
|
|
71
|
+
},
|
|
72
|
+
}),
|
|
73
|
+
|
|
74
|
+
// `MCP-Protocol-Version` MUST equal the `_meta` version or the server
|
|
75
|
+
// answers -32020, so both are minted from the same `version`.
|
|
76
|
+
meta: (method, params): SendMeta => ({
|
|
77
|
+
headers: {
|
|
78
|
+
"MCP-Protocol-Version": version,
|
|
79
|
+
"Mcp-Method": method,
|
|
80
|
+
...subjectName(method, params).pipe(
|
|
81
|
+
Option.map((name) => ({ "Mcp-Name": headerValue(name) })),
|
|
82
|
+
Option.getOrElse(() => ({})),
|
|
83
|
+
),
|
|
84
|
+
},
|
|
85
|
+
}),
|
|
86
|
+
|
|
87
|
+
onInbound: () => Effect.void,
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Probe with `server/discover`. A `DiscoverResult` means modern. A modern
|
|
92
|
+
* error code means modern-but-rejected: on `-32022` retry with a mutual
|
|
93
|
+
* version. Anything else yields `None`, i.e. "try the legacy handshake".
|
|
94
|
+
*/
|
|
95
|
+
export const probe: ProtocolProbe = (connection) =>
|
|
96
|
+
discover(connection, LATEST_VERSION).pipe(
|
|
97
|
+
Effect.map(Option.some),
|
|
98
|
+
Effect.catch((error) =>
|
|
99
|
+
Option.match(modernRejection(error), {
|
|
100
|
+
// Not a modern error shape, so this is not a modern server.
|
|
101
|
+
onNone: () => Effect.succeedNone,
|
|
102
|
+
onSome: ({ supported }) => retryOnMutualVersion(connection, supported),
|
|
103
|
+
}),
|
|
104
|
+
),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
const retryOnMutualVersion = (
|
|
108
|
+
connection: McpConnection,
|
|
109
|
+
offered: ReadonlyArray<string>,
|
|
110
|
+
): Effect.Effect<Option.Option<Protocol>, McpError, Scope.Scope> =>
|
|
111
|
+
Option.match(Option.firstSomeOf(offered.map(asProtocolVersion)), {
|
|
112
|
+
onNone: () =>
|
|
113
|
+
Effect.fail(
|
|
114
|
+
new McpUnsupportedProtocol({
|
|
115
|
+
offered,
|
|
116
|
+
reason: "the server is modern but shares no protocol version with this client",
|
|
117
|
+
}),
|
|
118
|
+
),
|
|
119
|
+
onSome: (version) => discover(connection, version).pipe(Effect.map(Option.some)),
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
const discover = (
|
|
123
|
+
connection: McpConnection,
|
|
124
|
+
version: ProtocolVersion,
|
|
125
|
+
): Effect.Effect<Protocol, McpError, Scope.Scope> =>
|
|
126
|
+
Effect.gen(function* () {
|
|
127
|
+
const probing = makeProtocol(version, UNKNOWN_SERVER)
|
|
128
|
+
const raw = yield* connection.request(
|
|
129
|
+
"server/discover",
|
|
130
|
+
probing.envelope("server/discover", {}),
|
|
131
|
+
probing.meta("server/discover", {}),
|
|
132
|
+
)
|
|
133
|
+
const result = yield* decodeDiscoverResult(raw).pipe(
|
|
134
|
+
Effect.mapError(
|
|
135
|
+
() =>
|
|
136
|
+
new McpUnsupportedProtocol({
|
|
137
|
+
offered: [],
|
|
138
|
+
reason: "server/discover returned an undecodable result",
|
|
139
|
+
}),
|
|
140
|
+
),
|
|
141
|
+
)
|
|
142
|
+
// The server answered on `version`, so it supports it; prefer it, and only
|
|
143
|
+
// fall to its list if it somehow disagrees.
|
|
144
|
+
const agreed = result.supportedVersions.includes(version)
|
|
145
|
+
? version
|
|
146
|
+
: Option.getOrElse(
|
|
147
|
+
Option.firstSomeOf(result.supportedVersions.map(asProtocolVersion)),
|
|
148
|
+
() => version,
|
|
149
|
+
)
|
|
150
|
+
const identity = result._meta?.[META_SERVER_INFO] ?? result.serverInfo ?? UNKNOWN_SERVER
|
|
151
|
+
return makeProtocol(agreed, identity)
|
|
152
|
+
})
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { Cause, Effect, Exit, Fiber, Option, Queue, Stream } from "effect"
|
|
2
|
+
import { describe, expect, it } from "vitest"
|
|
3
|
+
import { McpProtocolError, McpTransportClosed } from "../McpError.js"
|
|
4
|
+
import { type Inbound, open, type SendMeta, type Transport } from "./rpc.js"
|
|
5
|
+
|
|
6
|
+
type Sent = { readonly frame: string; readonly meta: SendMeta | undefined }
|
|
7
|
+
|
|
8
|
+
// In-memory Transport stub: a scripted `messages` queue + a recording `send`.
|
|
9
|
+
const makeStub = Effect.gen(function* () {
|
|
10
|
+
const queue = yield* Queue.make<string, Cause.Done>()
|
|
11
|
+
const sent: Array<Sent> = []
|
|
12
|
+
const transport: Transport = {
|
|
13
|
+
send: (frame, meta) => Effect.sync(() => void sent.push({ frame, meta })),
|
|
14
|
+
messages: Stream.fromQueue(queue),
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
transport,
|
|
18
|
+
sent,
|
|
19
|
+
push: (message: unknown) => Queue.offer(queue, JSON.stringify(message)),
|
|
20
|
+
pushRaw: (frame: string) => Queue.offer(queue, frame),
|
|
21
|
+
close: Queue.end(queue),
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
// The reader fiber consumes frames asynchronously, so tests wait on the
|
|
26
|
+
// observable effect rather than sleeping.
|
|
27
|
+
const until = (pred: () => boolean): Effect.Effect<void> =>
|
|
28
|
+
Effect.suspend(() => (pred() ? Effect.void : Effect.flatMap(Effect.yieldNow, () => until(pred))))
|
|
29
|
+
|
|
30
|
+
const noInbound = (): Effect.Effect<void> => Effect.void
|
|
31
|
+
|
|
32
|
+
const frames = (sent: ReadonlyArray<Sent>): ReadonlyArray<Record<string, unknown>> =>
|
|
33
|
+
sent.map((s) => JSON.parse(s.frame) as Record<string, unknown>)
|
|
34
|
+
|
|
35
|
+
const failureOf = (exit: Exit.Exit<unknown, unknown>): unknown =>
|
|
36
|
+
Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined
|
|
37
|
+
|
|
38
|
+
describe("rpc correlation core", () => {
|
|
39
|
+
it("correlates out-of-order replies by id", async () => {
|
|
40
|
+
const program = Effect.gen(function* () {
|
|
41
|
+
const stub = yield* makeStub
|
|
42
|
+
const connection = yield* open(stub.transport, noInbound)
|
|
43
|
+
const first = yield* Effect.forkChild(connection.request("tools/list"))
|
|
44
|
+
const second = yield* Effect.forkChild(connection.request("tools/call", { name: "a" }))
|
|
45
|
+
yield* until(() => stub.sent.length === 2)
|
|
46
|
+
// Reply to the second request first: correlation must be by id, not order.
|
|
47
|
+
yield* stub.push({ jsonrpc: "2.0", id: 2, result: { fromSecond: true } })
|
|
48
|
+
yield* stub.push({ jsonrpc: "2.0", id: 1, result: { fromFirst: true } })
|
|
49
|
+
const secondResult = yield* Fiber.join(second)
|
|
50
|
+
const firstResult = yield* Fiber.join(first)
|
|
51
|
+
return { firstResult, secondResult, sent: frames(stub.sent) }
|
|
52
|
+
})
|
|
53
|
+
const { firstResult, secondResult, sent } = await Effect.runPromise(Effect.scoped(program))
|
|
54
|
+
expect(firstResult).toEqual({ fromFirst: true })
|
|
55
|
+
expect(secondResult).toEqual({ fromSecond: true })
|
|
56
|
+
expect(sent[0]).toEqual({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} })
|
|
57
|
+
expect(sent[1]?.params).toEqual({ name: "a" })
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it("maps a JSON-RPC error reply to McpProtocolError with its code", async () => {
|
|
61
|
+
const program = Effect.gen(function* () {
|
|
62
|
+
const stub = yield* makeStub
|
|
63
|
+
const connection = yield* open(stub.transport, noInbound)
|
|
64
|
+
const fiber = yield* Effect.forkChild(Effect.exit(connection.request("tools/call")))
|
|
65
|
+
yield* until(() => stub.sent.length === 1)
|
|
66
|
+
yield* stub.push({ jsonrpc: "2.0", id: 1, error: { code: -32602, message: "bad params" } })
|
|
67
|
+
return yield* Fiber.join(fiber)
|
|
68
|
+
})
|
|
69
|
+
const error = failureOf(await Effect.runPromise(Effect.scoped(program)))
|
|
70
|
+
expect(error).toBeInstanceOf(McpProtocolError)
|
|
71
|
+
const protocolError = error as McpProtocolError
|
|
72
|
+
expect(protocolError.code).toBe(-32602)
|
|
73
|
+
expect(protocolError.method).toBe("tools/call")
|
|
74
|
+
expect(protocolError.reason).toBe("bad params")
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it("fails every pending request when the transport closes", async () => {
|
|
78
|
+
const program = Effect.gen(function* () {
|
|
79
|
+
const stub = yield* makeStub
|
|
80
|
+
const connection = yield* open(stub.transport, noInbound)
|
|
81
|
+
const first = yield* Effect.forkChild(Effect.exit(connection.request("tools/list")))
|
|
82
|
+
const second = yield* Effect.forkChild(Effect.exit(connection.request("tools/call")))
|
|
83
|
+
yield* until(() => stub.sent.length === 2)
|
|
84
|
+
yield* stub.close
|
|
85
|
+
return [yield* Fiber.join(first), yield* Fiber.join(second)]
|
|
86
|
+
})
|
|
87
|
+
const exits = await Effect.runPromise(Effect.scoped(program))
|
|
88
|
+
for (const exit of exits) {
|
|
89
|
+
expect(failureOf(exit)).toBeInstanceOf(McpTransportClosed)
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it("routes server-initiated requests and notifications to onInbound", async () => {
|
|
94
|
+
const program = Effect.gen(function* () {
|
|
95
|
+
const stub = yield* makeStub
|
|
96
|
+
const inbound: Array<Inbound> = []
|
|
97
|
+
yield* open(stub.transport, (message) => Effect.sync(() => void inbound.push(message)))
|
|
98
|
+
yield* stub.push({ jsonrpc: "2.0", id: 9, method: "ping" })
|
|
99
|
+
yield* stub.push({ jsonrpc: "2.0", method: "notifications/tools/list_changed" })
|
|
100
|
+
yield* until(() => inbound.length === 2)
|
|
101
|
+
return inbound
|
|
102
|
+
})
|
|
103
|
+
const inbound = await Effect.runPromise(Effect.scoped(program))
|
|
104
|
+
expect(inbound[0]?.method).toBe("ping")
|
|
105
|
+
expect(inbound[0]?.id).toEqual(Option.some(9))
|
|
106
|
+
expect(inbound[1]?.method).toBe("notifications/tools/list_changed")
|
|
107
|
+
expect(Option.isNone(inbound[1]?.id ?? Option.none())).toBe(true)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it("notify sends an id-less frame", async () => {
|
|
111
|
+
const program = Effect.gen(function* () {
|
|
112
|
+
const stub = yield* makeStub
|
|
113
|
+
const connection = yield* open(stub.transport, noInbound)
|
|
114
|
+
yield* connection.notify("notifications/initialized")
|
|
115
|
+
return frames(stub.sent)
|
|
116
|
+
})
|
|
117
|
+
const sent = await Effect.runPromise(Effect.scoped(program))
|
|
118
|
+
expect(sent[0]).toEqual({ jsonrpc: "2.0", method: "notifications/initialized" })
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it("fails pending requests on an id-less error instead of hanging forever", async () => {
|
|
122
|
+
// JSON-RPC returns a null id for errors raised before the request could be
|
|
123
|
+
// attributed (parse failure, rejected content type). Nothing correlates, so
|
|
124
|
+
// the caller must fail rather than await a reply that cannot arrive.
|
|
125
|
+
const program = Effect.gen(function* () {
|
|
126
|
+
const stub = yield* makeStub
|
|
127
|
+
const connection = yield* open(stub.transport, noInbound)
|
|
128
|
+
const fiber = yield* Effect.forkChild(Effect.exit(connection.request("server/discover")))
|
|
129
|
+
yield* until(() => stub.sent.length === 1)
|
|
130
|
+
yield* stub.push({
|
|
131
|
+
jsonrpc: "2.0",
|
|
132
|
+
id: null,
|
|
133
|
+
error: { code: -32700, message: "Parse error" },
|
|
134
|
+
})
|
|
135
|
+
return yield* Fiber.join(fiber)
|
|
136
|
+
})
|
|
137
|
+
const error = failureOf(await Effect.runPromise(Effect.scoped(program)))
|
|
138
|
+
expect(error).toBeInstanceOf(McpProtocolError)
|
|
139
|
+
expect((error as McpProtocolError).code).toBe(-32700)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it("survives replies for unknown ids and non-JSON frames", async () => {
|
|
143
|
+
const program = Effect.gen(function* () {
|
|
144
|
+
const stub = yield* makeStub
|
|
145
|
+
const connection = yield* open(stub.transport, noInbound)
|
|
146
|
+
yield* stub.push({ jsonrpc: "2.0", id: 99, result: {} })
|
|
147
|
+
yield* stub.pushRaw("this is not json")
|
|
148
|
+
const fiber = yield* Effect.forkChild(connection.request("tools/list"))
|
|
149
|
+
yield* until(() => stub.sent.length === 1)
|
|
150
|
+
yield* stub.push({ jsonrpc: "2.0", id: 1, result: { ok: true } })
|
|
151
|
+
return yield* Fiber.join(fiber)
|
|
152
|
+
})
|
|
153
|
+
const result = await Effect.runPromise(Effect.scoped(program))
|
|
154
|
+
expect(result).toEqual({ ok: true })
|
|
155
|
+
})
|
|
156
|
+
})
|