@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.
Files changed (71) hide show
  1. package/LICENSE +21 -0
  2. package/dist/Client.d.mts +55 -0
  3. package/dist/Client.d.mts.map +1 -0
  4. package/dist/Client.mjs +85 -0
  5. package/dist/Client.mjs.map +1 -0
  6. package/dist/McpError-DWg9BlW5.d.mts +83 -0
  7. package/dist/McpError-DWg9BlW5.d.mts.map +1 -0
  8. package/dist/McpError.d.mts +2 -0
  9. package/dist/McpError.mjs +61 -0
  10. package/dist/McpError.mjs.map +1 -0
  11. package/dist/Toolkit.d.mts +20 -0
  12. package/dist/Toolkit.d.mts.map +1 -0
  13. package/dist/Toolkit.mjs +70 -0
  14. package/dist/Toolkit.mjs.map +1 -0
  15. package/dist/auth-ZDvsxpCo.d.mts +126 -0
  16. package/dist/auth-ZDvsxpCo.d.mts.map +1 -0
  17. package/dist/index.d.mts +4 -0
  18. package/dist/index.mjs +4 -0
  19. package/dist/internal/auth.d.mts +2 -0
  20. package/dist/internal/auth.mjs +25 -0
  21. package/dist/internal/auth.mjs.map +1 -0
  22. package/dist/internal/httpTransport.d.mts +17 -0
  23. package/dist/internal/httpTransport.d.mts.map +1 -0
  24. package/dist/internal/httpTransport.mjs +88 -0
  25. package/dist/internal/httpTransport.mjs.map +1 -0
  26. package/dist/internal/protocol.d.mts +2 -0
  27. package/dist/internal/protocol.mjs +23 -0
  28. package/dist/internal/protocol.mjs.map +1 -0
  29. package/dist/internal/protocols/2025-06-18.d.mts +11 -0
  30. package/dist/internal/protocols/2025-06-18.d.mts.map +1 -0
  31. package/dist/internal/protocols/2025-06-18.mjs +70 -0
  32. package/dist/internal/protocols/2025-06-18.mjs.map +1 -0
  33. package/dist/internal/protocols/2025-11-25.d.mts +2 -0
  34. package/dist/internal/protocols/2025-11-25.mjs +2 -0
  35. package/dist/internal/protocols/2026-07-28.d.mts +16 -0
  36. package/dist/internal/protocols/2026-07-28.d.mts.map +1 -0
  37. package/dist/internal/protocols/2026-07-28.mjs +83 -0
  38. package/dist/internal/protocols/2026-07-28.mjs.map +1 -0
  39. package/dist/internal/rpc.d.mts +2 -0
  40. package/dist/internal/rpc.mjs +78 -0
  41. package/dist/internal/rpc.mjs.map +1 -0
  42. package/dist/internal/schema.d.mts +2 -0
  43. package/dist/internal/schema.mjs +168 -0
  44. package/dist/internal/schema.mjs.map +1 -0
  45. package/dist/internal/stdioTransport.d.mts +15 -0
  46. package/dist/internal/stdioTransport.d.mts.map +1 -0
  47. package/dist/internal/stdioTransport.mjs +46 -0
  48. package/dist/internal/stdioTransport.mjs.map +1 -0
  49. package/dist/protocol-kjC3iAMb.d.mts +35 -0
  50. package/dist/protocol-kjC3iAMb.d.mts.map +1 -0
  51. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
  52. package/dist/rpc-Bq95Lio9.d.mts +34 -0
  53. package/dist/rpc-Bq95Lio9.d.mts.map +1 -0
  54. package/dist/schema-D2oUNuo5.d.mts +245 -0
  55. package/dist/schema-D2oUNuo5.d.mts.map +1 -0
  56. package/package.json +70 -0
  57. package/src/Client.ts +180 -0
  58. package/src/McpError.ts +93 -0
  59. package/src/Toolkit.ts +109 -0
  60. package/src/index.ts +3 -0
  61. package/src/internal/auth.ts +48 -0
  62. package/src/internal/httpTransport.ts +171 -0
  63. package/src/internal/protocol.ts +67 -0
  64. package/src/internal/protocols/2025-06-18.ts +110 -0
  65. package/src/internal/protocols/2025-11-25.ts +8 -0
  66. package/src/internal/protocols/2026-07-28.test.ts +131 -0
  67. package/src/internal/protocols/2026-07-28.ts +152 -0
  68. package/src/internal/rpc.test.ts +156 -0
  69. package/src/internal/rpc.ts +189 -0
  70. package/src/internal/schema.ts +223 -0
  71. 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
+ })