@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
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "@effect-uai/mcp",
3
+ "version": "0.12.1",
4
+ "description": "Model Context Protocol client provider for @effect-uai/core.",
5
+ "keywords": [
6
+ "agents",
7
+ "ai",
8
+ "effect",
9
+ "mcp",
10
+ "model-context-protocol",
11
+ "tools"
12
+ ],
13
+ "homepage": "https://github.com/betalyra/effect-uai/tree/main/packages/providers/mcp",
14
+ "bugs": {
15
+ "url": "https://github.com/betalyra/effect-uai/issues"
16
+ },
17
+ "license": "MIT",
18
+ "author": "Betalyra",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/betalyra/effect-uai",
22
+ "directory": "packages/providers/mcp"
23
+ },
24
+ "files": [
25
+ "dist",
26
+ "src",
27
+ "README.md",
28
+ "LICENSE"
29
+ ],
30
+ "type": "module",
31
+ "main": "./dist/index.mjs",
32
+ "types": "./dist/index.d.mts",
33
+ "exports": {
34
+ ".": {
35
+ "types": "./dist/index.d.mts",
36
+ "import": "./dist/index.mjs"
37
+ },
38
+ "./Client": {
39
+ "types": "./dist/Client.d.mts",
40
+ "import": "./dist/Client.mjs"
41
+ },
42
+ "./Toolkit": {
43
+ "types": "./dist/Toolkit.d.mts",
44
+ "import": "./dist/Toolkit.mjs"
45
+ },
46
+ "./McpError": {
47
+ "types": "./dist/McpError.d.mts",
48
+ "import": "./dist/McpError.mjs"
49
+ }
50
+ },
51
+ "publishConfig": {
52
+ "access": "public"
53
+ },
54
+ "dependencies": {},
55
+ "devDependencies": {
56
+ "@effect/platform-node": "4.0.0-rc.111",
57
+ "@effect/vitest": "4.0.0-rc.111",
58
+ "effect": "4.0.0-rc.111",
59
+ "typescript": "^7.0.2",
60
+ "@effect-uai/core": "0.12.1"
61
+ },
62
+ "peerDependencies": {
63
+ "@effect-uai/core": ">=0.5.0 <1",
64
+ "effect": ">=4.0.0-rc.111 <5.0.0"
65
+ },
66
+ "scripts": {
67
+ "build": "tsdown",
68
+ "typecheck": "tsc --noEmit"
69
+ }
70
+ }
package/src/Client.ts ADDED
@@ -0,0 +1,180 @@
1
+ /**
2
+ * `connect` opens a scoped MCP client: transport -> rpc core -> protocol probe.
3
+ * The protocol era is a property of the server, so it is negotiated once here
4
+ * and fixed for the connection's lifetime. The client surface is era-uniform.
5
+ */
6
+ import { Context, Effect, Layer, Option, Ref, type Scope } from "effect"
7
+ import type { HttpClient } from "effect/unstable/http"
8
+ import type { ChildProcessSpawner } from "effect/unstable/process"
9
+ import { McpConnectFailed, type McpError, McpProtocolError } from "./McpError.js"
10
+ import { Auth, type TokenSource } from "./internal/auth.js"
11
+ import * as Handshake from "./internal/protocols/2025-06-18.js"
12
+ import * as Stateless from "./internal/protocols/2026-07-28.js"
13
+ import type { Protocol } from "./internal/protocol.js"
14
+ import { type McpConnection, open } from "./internal/rpc.js"
15
+ import {
16
+ type CallToolResult,
17
+ decodeCallToolResult,
18
+ decodeListToolsResult,
19
+ LATEST_VERSION,
20
+ type McpMethod,
21
+ type ProtocolVersion,
22
+ type ServerInfo,
23
+ type ToolInfo,
24
+ } from "./internal/schema.js"
25
+ import * as HttpTransport from "./internal/httpTransport.js"
26
+ import * as StdioTransport from "./internal/stdioTransport.js"
27
+
28
+ export { Auth, type TokenSource }
29
+
30
+ /** `auto` runs the probe; a pinned version skips detection. */
31
+ export type ProtocolPin = "auto" | ProtocolVersion
32
+
33
+ export type McpClientConfig =
34
+ | {
35
+ readonly transport: "http"
36
+ readonly url: string
37
+ /** Static, non-auth headers sent on every request. */
38
+ readonly headers?: Record<string, string>
39
+ /** Omit for a public server. See `Auth` for the three shapes. */
40
+ readonly auth?: Auth
41
+ readonly protocol?: ProtocolPin
42
+ }
43
+ | {
44
+ readonly transport: "stdio"
45
+ readonly command: string
46
+ readonly args?: ReadonlyArray<string>
47
+ readonly env?: Record<string, string>
48
+ readonly cwd?: string
49
+ readonly protocol?: ProtocolPin
50
+ }
51
+
52
+ /**
53
+ * Services a client needs, by transport. Both are listed because the config is
54
+ * a runtime value: an app that only uses one still provides the other's layer,
55
+ * which is cheap (`FetchHttpClient.layer` / `NodeServices.layer`).
56
+ */
57
+ export type McpRequirements = HttpClient.HttpClient | ChildProcessSpawner.ChildProcessSpawner
58
+
59
+ export type McpServerInfo = ServerInfo & {
60
+ /** The negotiated protocol version: the only era signal callers need. */
61
+ readonly protocolVersion: ProtocolVersion
62
+ }
63
+
64
+ export type McpClient = {
65
+ readonly listTools: Effect.Effect<ReadonlyArray<ToolInfo>, McpError>
66
+ readonly callTool: (name: string, args: unknown) => Effect.Effect<CallToolResult, McpError>
67
+ readonly serverInfo: McpServerInfo
68
+ }
69
+
70
+ const undecodable = (method: McpMethod, detail: string, raw: unknown): McpError =>
71
+ new McpProtocolError({ method, reason: `undecodable ${method} result${detail}`, raw })
72
+
73
+ const makeClient = (connection: McpConnection, protocol: Protocol): McpClient => {
74
+ const request = (method: McpMethod, params: unknown) =>
75
+ connection.request(method, protocol.envelope(method, params), protocol.meta(method, params))
76
+
77
+ return {
78
+ listTools: request("tools/list", {}).pipe(
79
+ Effect.flatMap((raw) =>
80
+ decodeListToolsResult(raw).pipe(
81
+ Effect.mapError(() => undecodable("tools/list", "", raw)),
82
+ Effect.map((result) => result.tools),
83
+ ),
84
+ ),
85
+ ),
86
+
87
+ callTool: (name, args) =>
88
+ request("tools/call", { name, arguments: args ?? {} }).pipe(
89
+ Effect.flatMap((raw) =>
90
+ decodeCallToolResult(raw).pipe(
91
+ Effect.mapError(() => undecodable("tools/call", ` for ${name}`, raw)),
92
+ ),
93
+ ),
94
+ ),
95
+
96
+ serverInfo: { ...protocol.serverInfo, protocolVersion: protocol.version },
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Try the stateless protocol first, then the handshake era. The stateless
102
+ * probe yields `None` only when the server proves it is not stateless (an
103
+ * error that is not one of the recognized modern codes), which is exactly the
104
+ * spec's era discriminator. A pinned `protocol` skips detection.
105
+ *
106
+ * The result is cached for the connection's lifetime by the caller: era is a
107
+ * property of the server, not of a request.
108
+ */
109
+ const negotiate = (
110
+ connection: McpConnection,
111
+ pin: ProtocolPin,
112
+ ): Effect.Effect<Protocol, McpError, Scope.Scope> => {
113
+ if (pin === LATEST_VERSION) return required(Stateless.probe(connection))
114
+ if (pin !== "auto") return required(Handshake.probe(connection))
115
+ return Stateless.probe(connection).pipe(
116
+ Effect.flatMap(
117
+ Option.match({
118
+ onNone: () => required(Handshake.probe(connection)),
119
+ onSome: Effect.succeed<Protocol>,
120
+ }),
121
+ ),
122
+ )
123
+ }
124
+
125
+ const required = (
126
+ probe: Effect.Effect<Option.Option<Protocol>, McpError, Scope.Scope>,
127
+ ): Effect.Effect<Protocol, McpError, Scope.Scope> =>
128
+ probe.pipe(
129
+ Effect.flatMap(
130
+ Option.match({
131
+ onNone: () =>
132
+ Effect.fail(
133
+ new McpConnectFailed({ reason: "the server does not speak a supported MCP protocol" }),
134
+ ),
135
+ onSome: Effect.succeed<Protocol>,
136
+ }),
137
+ ),
138
+ )
139
+
140
+ /**
141
+ * Open a scoped client. The connection lives exactly as long as the enclosing
142
+ * scope, so `Effect.scoped`, a `Layer`, or `Stream.scoped` all give correct
143
+ * teardown with no explicit close.
144
+ */
145
+ export const connect = (
146
+ config: McpClientConfig,
147
+ ): Effect.Effect<McpClient, McpError, Scope.Scope | McpRequirements> =>
148
+ Effect.gen(function* () {
149
+ const transport = yield* config.transport === "http"
150
+ ? HttpTransport.make(config)
151
+ : StdioTransport.make(config)
152
+
153
+ // Inbound handling belongs to the protocol, which is not chosen until the
154
+ // probe completes, so the reader reads it from a Ref that starts empty.
155
+ // Frames arriving before then can only be legacy noise; ignore them.
156
+ const protocolRef = yield* Ref.make(Option.none<Protocol>())
157
+ const connection = yield* open(transport, (inbound) =>
158
+ Ref.get(protocolRef).pipe(
159
+ Effect.flatMap(
160
+ Option.match({
161
+ onNone: () => Effect.void,
162
+ onSome: (protocol) => protocol.onInbound(inbound),
163
+ }),
164
+ ),
165
+ ),
166
+ )
167
+
168
+ const protocol = yield* negotiate(connection, config.protocol ?? "auto")
169
+ yield* Ref.set(protocolRef, Option.some(protocol))
170
+
171
+ return makeClient(connection, protocol)
172
+ })
173
+
174
+ export class Mcp extends Context.Service<Mcp, McpClient>()(
175
+ "@betalyra/effect-uai/providers/mcp/Mcp",
176
+ ) {}
177
+
178
+ /** DI convenience for the single-server app: connects when the layer builds. */
179
+ export const layer = (config: McpClientConfig): Layer.Layer<Mcp, McpError, McpRequirements> =>
180
+ Layer.effect(Mcp, connect(config))
@@ -0,0 +1,93 @@
1
+ import { Data, Match } from "effect"
2
+
3
+ /**
4
+ * Transport could not establish: DNS / TLS / non-2xx on the first request,
5
+ * a failed spawn, or a failed era probe. The raw transport error on `raw`.
6
+ */
7
+ export class McpConnectFailed extends Data.TaggedError("McpConnectFailed")<{
8
+ reason?: string
9
+ raw?: unknown
10
+ }> {}
11
+
12
+ /**
13
+ * Negotiation ended on a protocol version this client does not support
14
+ * (e.g. a 2025-03-26-only server, or a modern server whose `supported`
15
+ * list has no mutual version). `offered` is what the server proposed.
16
+ */
17
+ export class McpUnsupportedProtocol extends Data.TaggedError("McpUnsupportedProtocol")<{
18
+ offered: ReadonlyArray<string>
19
+ reason?: string
20
+ }> {}
21
+
22
+ /**
23
+ * A JSON-RPC error reply (carrying its `code`), a reply that failed to
24
+ * decode, or an unsupported protocol feature (v1 fails `input_required`
25
+ * results here). `method` names the request that was in flight.
26
+ */
27
+ export class McpProtocolError extends Data.TaggedError("McpProtocolError")<{
28
+ method?: string
29
+ code?: number
30
+ reason?: string
31
+ raw?: unknown
32
+ }> {}
33
+
34
+ /**
35
+ * The connection dropped with a request in flight, or a legacy session
36
+ * expired (HTTP 404 on a known `Mcp-Session-Id`). Not resumable; the
37
+ * remedy is reconnecting and re-issuing the request.
38
+ */
39
+ export class McpTransportClosed extends Data.TaggedError("McpTransportClosed")<{
40
+ method?: string
41
+ reason?: string
42
+ raw?: unknown
43
+ }> {}
44
+
45
+ /**
46
+ * The server answered `401`. `resourceMetadataUrl` is the RFC 9728
47
+ * Protected Resource Metadata pointer parsed from `WWW-Authenticate`,
48
+ * when present; an OAuth layer starts discovery there.
49
+ */
50
+ export class McpAuthRequired extends Data.TaggedError("McpAuthRequired")<{
51
+ resourceMetadataUrl?: string
52
+ wwwAuthenticate?: string
53
+ }> {}
54
+
55
+ /** A `TokenSource` / OAuth grant failed to mint or refresh a token. */
56
+ export class McpAuthError extends Data.TaggedError("McpAuthError")<{
57
+ reason?: string
58
+ raw?: unknown
59
+ }> {}
60
+
61
+ export type McpError =
62
+ | McpConnectFailed
63
+ | McpUnsupportedProtocol
64
+ | McpProtocolError
65
+ | McpTransportClosed
66
+ | McpAuthRequired
67
+ | McpAuthError
68
+
69
+ const withReason = (base: string, reason: string | undefined): string =>
70
+ reason === undefined ? base : `${base}: ${reason}`
71
+
72
+ /**
73
+ * Short human-readable description of an error, for logs and model-facing
74
+ * failure messages (`Toolkit.describeFailures(McpError.describe)`). Prose,
75
+ * not a contract; branch on `_tag` instead.
76
+ */
77
+ export const describe: (e: McpError) => string = Match.type<McpError>().pipe(
78
+ Match.discriminatorsExhaustive("_tag")({
79
+ McpConnectFailed: (e) => withReason("could not connect to the MCP server", e.reason),
80
+ McpUnsupportedProtocol: (e) =>
81
+ `the MCP server only offers unsupported protocol versions (${e.offered.join(", ")})`,
82
+ McpProtocolError: (e) =>
83
+ withReason(
84
+ `the MCP server rejected ${e.method ?? "the request"}${
85
+ e.code === undefined ? "" : ` (${e.code})`
86
+ }`,
87
+ e.reason,
88
+ ),
89
+ McpTransportClosed: (e) => withReason("the MCP connection closed", e.reason),
90
+ McpAuthRequired: () => "the MCP server requires authentication",
91
+ McpAuthError: (e) => withReason("MCP authentication failed", e.reason),
92
+ }),
93
+ )
package/src/Toolkit.ts ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Turn a connected MCP server into a core `Toolkit`, one `LocalTool` per MCP
3
+ * tool. Effectful because the tool list is fetched; composes with
4
+ * `Toolkit.compose` like any other kit.
5
+ */
6
+ import { Effect, Option } from "effect"
7
+ import * as Tool from "@effect-uai/core/Tool"
8
+ import * as CoreToolkit from "@effect-uai/core/Toolkit"
9
+ import type { McpClient } from "./Client.js"
10
+ import { type McpError, McpProtocolError } from "./McpError.js"
11
+ import {
12
+ type CallToolResult,
13
+ type ContentBlock,
14
+ isTextContent,
15
+ type ToolInfo,
16
+ } from "./internal/schema.js"
17
+
18
+ export type McpToolkitOptions = {
19
+ /** Namespace every tool as `<prefix>__<name>`; recommended when composing servers. */
20
+ readonly prefix?: string
21
+ }
22
+
23
+ /**
24
+ * The server owns validation, so the input schema is a passthrough: it carries
25
+ * the server's JSON Schema verbatim (the model sees the real thing) and
26
+ * accepts whatever the model produced. `strict: false` because server schemas
27
+ * are not written to OpenAI strict rules.
28
+ */
29
+ const passthroughSchema = (tool: ToolInfo): Tool.ToolInputSchema<unknown> => ({
30
+ "~standard": {
31
+ version: 1,
32
+ vendor: "effect-uai-mcp",
33
+ validate: (value: unknown) => ({ value }),
34
+ jsonSchema: {
35
+ input: () => tool.inputSchema,
36
+ output: () => ({ type: "object" }),
37
+ },
38
+ },
39
+ })
40
+
41
+ const textOf = (block: ContentBlock): string =>
42
+ isTextContent(block) ? block.text : `[${block.type} content omitted]`
43
+
44
+ /**
45
+ * Serialize a successful result for the model: `structuredContent` when the
46
+ * server provides it, otherwise the joined text blocks. Non-text blocks become
47
+ * a short placeholder until the loop grows a multimodal tool-output path.
48
+ */
49
+ const outputOf = (result: CallToolResult): unknown =>
50
+ result.structuredContent ?? (result.content ?? []).map(textOf).join("\n")
51
+
52
+ /**
53
+ * Two failure channels, deliberately: `isError` is the server telling the
54
+ * model it went wrong, so it becomes a model-visible `ToolFailed`; an
55
+ * `input_required` result is a protocol capability gap the model cannot fix,
56
+ * so it stays a typed `McpError`.
57
+ */
58
+ const interpret = (
59
+ name: string,
60
+ result: CallToolResult,
61
+ ): Effect.Effect<unknown, McpError | Tool.ToolFailed> => {
62
+ if (result.isError === true) {
63
+ return Tool.fail(String(outputOf(result)), { kind: "tool_failed" })
64
+ }
65
+ if (result.resultType === "input_required") {
66
+ return Effect.fail(
67
+ new McpProtocolError({
68
+ method: "tools/call",
69
+ reason: `${name} requires interactive input (MRTR), unsupported by this client`,
70
+ }),
71
+ )
72
+ }
73
+ return Effect.succeed(outputOf(result))
74
+ }
75
+
76
+ const runTool =
77
+ (client: McpClient, name: string) =>
78
+ (input: unknown): Effect.Effect<unknown, McpError | Tool.ToolFailed> =>
79
+ client.callTool(name, input).pipe(Effect.flatMap((result) => interpret(name, result)))
80
+
81
+ const toLocalTool = (client: McpClient, info: ToolInfo) =>
82
+ Tool.make({
83
+ name: info.name,
84
+ description: info.description ?? info.name,
85
+ inputSchema: passthroughSchema(info),
86
+ run: runTool(client, info.name),
87
+ strict: false,
88
+ })
89
+
90
+ /**
91
+ * Build a `Toolkit` from the server's advertised tools. The list is snapshotted
92
+ * here; a tool that later vanishes surfaces through `Toolkit.run`'s graceful
93
+ * `unknown_tool` path.
94
+ */
95
+ export const mcpToolkit = (
96
+ client: McpClient,
97
+ options?: McpToolkitOptions,
98
+ ): Effect.Effect<CoreToolkit.Toolkit, McpError> =>
99
+ client.listTools.pipe(
100
+ Effect.map((tools) => CoreToolkit.fromArray(tools.map((info) => toLocalTool(client, info)))),
101
+ Effect.map((kit) =>
102
+ Option.fromNullishOr(options?.prefix).pipe(
103
+ Option.match({
104
+ onNone: () => kit,
105
+ onSome: (prefix) => CoreToolkit.namespace(prefix, kit),
106
+ }),
107
+ ),
108
+ ),
109
+ )
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * as Client from "./Client.js"
2
+ export * as McpError from "./McpError.js"
3
+ export * as Toolkit from "./Toolkit.js"
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Auth resolution for the HTTP transport. Era-blind: the stateless and
3
+ * handshake protocols both ride an OAuth 2.1 resource server, and neither
4
+ * knows this file exists. Tokens stay `Redacted` until the wire boundary.
5
+ */
6
+ import { Data, Effect, Option, Redacted } from "effect"
7
+ import type { McpAuthError } from "../McpError.js"
8
+
9
+ /**
10
+ * An Effect yielding a bearer token. Any external OAuth, vault, or secret
11
+ * manager plugs in here. Read per request, so a producer minting expensive
12
+ * tokens caches internally (as `OAuth.clientCredentials` does).
13
+ */
14
+ export type TokenSource = Effect.Effect<Redacted.Redacted<string>, McpAuthError>
15
+
16
+ export type Auth = Data.TaggedEnum<{
17
+ /** A fixed token: the turnkey case for a token-gated server. */
18
+ Static: { readonly token: Redacted.Redacted<string>; readonly scheme?: string }
19
+ /** Bring your own, re-read before every request. */
20
+ TokenSource: { readonly token: TokenSource; readonly scheme?: string }
21
+ /** Built-in OAuth 2.1, which produces a `TokenSource` and rides the same seam. */
22
+ OAuth: { readonly source: TokenSource; readonly scheme?: string }
23
+ }>
24
+
25
+ export const Auth = Data.taggedEnum<Auth>()
26
+
27
+ const tokenOf: (auth: Auth) => TokenSource = Auth.$match({
28
+ Static: ({ token }) => Effect.succeed(token),
29
+ TokenSource: ({ token }) => token,
30
+ OAuth: ({ source }) => source,
31
+ })
32
+
33
+ /**
34
+ * The `Authorization` header for one request, or none for a public server.
35
+ * `Redacted.value` is unwrapped only here.
36
+ */
37
+ export const authHeaders = (
38
+ auth: Option.Option<Auth>,
39
+ ): Effect.Effect<Record<string, string>, McpAuthError> =>
40
+ Option.match(auth, {
41
+ onNone: () => Effect.succeed({}),
42
+ onSome: (a) =>
43
+ tokenOf(a).pipe(
44
+ Effect.map((token) => ({
45
+ authorization: `${a.scheme ?? "Bearer"} ${Redacted.value(token)}`,
46
+ })),
47
+ ),
48
+ })
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Streamable HTTP `Transport`. Each client message is its own POST; the server
3
+ * answers with either one JSON object or a request-scoped SSE stream ending in
4
+ * the JSON-RPC response, and a client MUST accept both. Frames from every
5
+ * in-flight POST demux into one `messages` stream, which the rpc core
6
+ * correlates by id.
7
+ *
8
+ * Interrupting a request aborts its POST, which is exactly the spec's
9
+ * cancellation signal (closing the response stream).
10
+ */
11
+ import { Cause, Effect, Match, Option, Queue, Ref, type Scope, Stream } from "effect"
12
+ import { HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/unstable/http"
13
+ import * as SSE from "@effect-uai/core/SSE"
14
+ import {
15
+ McpAuthRequired,
16
+ McpConnectFailed,
17
+ type McpError,
18
+ McpProtocolError,
19
+ McpTransportClosed,
20
+ } from "../McpError.js"
21
+ import { type Auth, authHeaders } from "./auth.js"
22
+ import type { SendMeta, Transport } from "./rpc.js"
23
+
24
+ export type HttpConfig = {
25
+ readonly url: string
26
+ /** Static, non-auth headers applied to every request. */
27
+ readonly headers?: Record<string, string>
28
+ /** Omit for a public server. Resolved per request, so a rotating source works. */
29
+ readonly auth?: Auth
30
+ }
31
+
32
+ /** RFC 9728: the `resource_metadata` pointer on a 401's `WWW-Authenticate`. */
33
+ const resourceMetadataUrl = (wwwAuthenticate: string): string | undefined =>
34
+ /resource_metadata="([^"]+)"/.exec(wwwAuthenticate)?.[1]
35
+
36
+ const unauthorized = (response: HttpClientResponse.HttpClientResponse): McpError => {
37
+ const header = response.headers["www-authenticate"]
38
+ const pointer = header === undefined ? undefined : resourceMetadataUrl(header)
39
+ return new McpAuthRequired({
40
+ ...(pointer !== undefined ? { resourceMetadataUrl: pointer } : {}),
41
+ ...(header !== undefined ? { wwwAuthenticate: header } : {}),
42
+ })
43
+ }
44
+
45
+ /**
46
+ * A non-2xx that is not a 401. The body still matters: a modern server answers
47
+ * `400` for `-32022` / `-32021` / `-32020` and `404` for `-32601`, and era
48
+ * detection reads those codes off the JSON-RPC body. So the body is forwarded
49
+ * into `messages` whenever it parses as JSON, and only a bodyless failure
50
+ * becomes a transport error.
51
+ */
52
+ const isJsonRpcBody = (body: string): boolean => body.trimStart().startsWith("{")
53
+
54
+ export const make = (
55
+ config: HttpConfig,
56
+ ): Effect.Effect<Transport, McpError, Scope.Scope | HttpClient.HttpClient> =>
57
+ Effect.gen(function* () {
58
+ const client = yield* HttpClient.HttpClient
59
+ const inbox = yield* Queue.make<string, Cause.Done>()
60
+ yield* Effect.addFinalizer(() => Queue.end(inbox))
61
+
62
+ // The legacy era's `Mcp-Session-Id` is a transport concern, not a protocol
63
+ // one: capture whatever the server mints and echo it from then on, so the
64
+ // protocol implementations stay transport-agnostic. Stateless servers
65
+ // never send one, and this stays empty.
66
+ const session = yield* Ref.make(Option.none<string>())
67
+
68
+ const post = (frame: string, meta?: SendMeta): Effect.Effect<void, McpError> =>
69
+ Effect.gen(function* () {
70
+ const sessionId = yield* Ref.get(session)
71
+ const auth = yield* authHeaders(Option.fromNullishOr(config.auth))
72
+ const request = HttpClientRequest.post(config.url).pipe(
73
+ HttpClientRequest.setHeaders({
74
+ "content-type": "application/json",
75
+ // Both response shapes are legal for any request.
76
+ accept: "application/json, text/event-stream",
77
+ ...config.headers,
78
+ ...auth,
79
+ ...meta?.headers,
80
+ ...Option.match(sessionId, {
81
+ onNone: () => ({}),
82
+ onSome: (id) => ({ "Mcp-Session-Id": id }),
83
+ }),
84
+ }),
85
+ // The content type must be set on the body: it overrides any header.
86
+ HttpClientRequest.bodyText(frame, "application/json"),
87
+ )
88
+ const response = yield* client
89
+ .execute(request)
90
+ .pipe(
91
+ Effect.mapError(
92
+ (cause) => new McpConnectFailed({ reason: "MCP request failed", raw: cause }),
93
+ ),
94
+ )
95
+ yield* captureSession(response)
96
+ yield* ingest(response)
97
+ })
98
+
99
+ const captureSession = (
100
+ response: HttpClientResponse.HttpClientResponse,
101
+ ): Effect.Effect<void> => {
102
+ const minted = response.headers["mcp-session-id"]
103
+ return minted === undefined ? Effect.void : Ref.set(session, Option.some(minted))
104
+ }
105
+
106
+ const ingest = (
107
+ response: HttpClientResponse.HttpClientResponse,
108
+ ): Effect.Effect<void, McpError> =>
109
+ Match.value(response.status).pipe(
110
+ Match.when(401, () => Effect.fail(unauthorized(response))),
111
+ Match.when(404, () => expiredOrBody(response)),
112
+ // 202 answers a notification and carries no body.
113
+ Match.when(202, () => Effect.void),
114
+ Match.orElse(() => readFrames(response)),
115
+ )
116
+
117
+ // A 404 against a session we hold means the server dropped it; the caller
118
+ // reconnects rather than retrying into a dead session.
119
+ const expiredOrBody = (
120
+ response: HttpClientResponse.HttpClientResponse,
121
+ ): Effect.Effect<void, McpError> =>
122
+ Ref.get(session).pipe(
123
+ Effect.flatMap((held) =>
124
+ Option.isSome(held)
125
+ ? Effect.fail(new McpTransportClosed({ reason: "the server expired this session" }))
126
+ : readBody(response),
127
+ ),
128
+ )
129
+
130
+ const readFrames = (
131
+ response: HttpClientResponse.HttpClientResponse,
132
+ ): Effect.Effect<void, McpError> =>
133
+ (response.headers["content-type"] ?? "").includes("text/event-stream")
134
+ ? readSse(response)
135
+ : readBody(response)
136
+
137
+ const readSse = (
138
+ response: HttpClientResponse.HttpClientResponse,
139
+ ): Effect.Effect<void, McpError> =>
140
+ response.stream.pipe(
141
+ SSE.fromBytes,
142
+ Stream.runForEach((event) => Queue.offer(inbox, event.data)),
143
+ Effect.mapError(
144
+ (cause) => new McpTransportClosed({ reason: "SSE stream failed", raw: cause }),
145
+ ),
146
+ )
147
+
148
+ const readBody = (
149
+ response: HttpClientResponse.HttpClientResponse,
150
+ ): Effect.Effect<void, McpError> =>
151
+ response.text.pipe(
152
+ Effect.mapError(
153
+ (cause) => new McpTransportClosed({ reason: "could not read response", raw: cause }),
154
+ ),
155
+ Effect.flatMap((body) =>
156
+ isJsonRpcBody(body)
157
+ ? Effect.asVoid(Queue.offer(inbox, body))
158
+ : Effect.fail(
159
+ new McpProtocolError({
160
+ code: response.status,
161
+ reason: `HTTP ${response.status} with a non-JSON-RPC body`,
162
+ raw: body.slice(0, 500),
163
+ }),
164
+ ),
165
+ ),
166
+ )
167
+
168
+ // The POST runs on the caller's fiber so interruption aborts it, which is
169
+ // the spec's cancellation signal.
170
+ return { send: post, messages: Stream.fromQueue(inbox) } satisfies Transport
171
+ })