@homeflare/seat-runtime 0.1.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.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +200 -0
  3. package/dist/index.d.ts +20 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +508 -0
  6. package/dist/index.js.map +21 -0
  7. package/dist/mcp-connect.d.ts +72 -0
  8. package/dist/mcp-connect.d.ts.map +1 -0
  9. package/dist/mcp-error.d.ts +77 -0
  10. package/dist/mcp-error.d.ts.map +1 -0
  11. package/dist/mcp-pages.d.ts +18 -0
  12. package/dist/mcp-pages.d.ts.map +1 -0
  13. package/dist/mcp-render.d.ts +25 -0
  14. package/dist/mcp-render.d.ts.map +1 -0
  15. package/dist/mcp-tool.d.ts +61 -0
  16. package/dist/mcp-tool.d.ts.map +1 -0
  17. package/dist/mcp-toolkit.d.ts +61 -0
  18. package/dist/mcp-toolkit.d.ts.map +1 -0
  19. package/dist/mcp-toolset.d.ts +33 -0
  20. package/dist/mcp-toolset.d.ts.map +1 -0
  21. package/dist/rounds.d.ts +108 -0
  22. package/dist/rounds.d.ts.map +1 -0
  23. package/dist/seat-model.d.ts +59 -0
  24. package/dist/seat-model.d.ts.map +1 -0
  25. package/dist/seat-obs.d.ts +23 -0
  26. package/dist/seat-obs.d.ts.map +1 -0
  27. package/dist/seat-state.d.ts +33 -0
  28. package/dist/seat-state.d.ts.map +1 -0
  29. package/dist/stamp.d.ts +23 -0
  30. package/dist/stamp.d.ts.map +1 -0
  31. package/dist/state-dsn.d.ts +41 -0
  32. package/dist/state-dsn.d.ts.map +1 -0
  33. package/dist/state-postgres.d.ts +58 -0
  34. package/dist/state-postgres.d.ts.map +1 -0
  35. package/dist/state-valkey-connection.d.ts +49 -0
  36. package/dist/state-valkey-connection.d.ts.map +1 -0
  37. package/dist/state-valkey-scrub.d.ts +37 -0
  38. package/dist/state-valkey-scrub.d.ts.map +1 -0
  39. package/dist/state-valkey-send.d.ts +35 -0
  40. package/dist/state-valkey-send.d.ts.map +1 -0
  41. package/dist/state-valkey.d.ts +83 -0
  42. package/dist/state-valkey.d.ts.map +1 -0
  43. package/dist/state.d.ts +9 -0
  44. package/dist/state.d.ts.map +1 -0
  45. package/dist/state.js +327 -0
  46. package/dist/state.js.map +16 -0
  47. package/dist/version.d.ts +2 -0
  48. package/dist/version.d.ts.map +1 -0
  49. package/docs/mcp.md +40 -0
  50. package/docs/pairing.md +39 -0
  51. package/docs/state.md +174 -0
  52. package/package.json +45 -0
  53. package/src/index.ts +25 -0
  54. package/src/mcp-connect.ts +172 -0
  55. package/src/mcp-error.ts +150 -0
  56. package/src/mcp-pages.ts +37 -0
  57. package/src/mcp-render.ts +67 -0
  58. package/src/mcp-tool.ts +101 -0
  59. package/src/mcp-toolkit.ts +183 -0
  60. package/src/mcp-toolset.ts +91 -0
  61. package/src/rounds.ts +211 -0
  62. package/src/seat-model.ts +94 -0
  63. package/src/seat-obs.ts +103 -0
  64. package/src/seat-state.ts +53 -0
  65. package/src/stamp.ts +88 -0
  66. package/src/state-dsn.ts +112 -0
  67. package/src/state-postgres.ts +114 -0
  68. package/src/state-valkey-connection.ts +113 -0
  69. package/src/state-valkey-scrub.ts +60 -0
  70. package/src/state-valkey-send.ts +67 -0
  71. package/src/state-valkey.ts +193 -0
  72. package/src/state.ts +8 -0
  73. package/src/version.ts +2 -0
package/docs/state.md ADDED
@@ -0,0 +1,174 @@
1
+ # `SeatState`: Postgres and Valkey, on the `/state` subpath
2
+
3
+ A seat's durable state is Postgres (runs, resumes, results) and its fast state is Valkey (locks,
4
+ counters, caches). `@homeflare/seat-runtime/state` builds each as the **Effect service that already
5
+ exists for it**, so consumer code is ordinary Effect SQL and Redis:
6
+
7
+ | store | service | from |
8
+ | -------- | ----------------------------------------------------------------- | -------------------------------------- |
9
+ | Postgres | `SqlClient` (and `PgClient`, re-exported as `SeatState.PgClient`) | `@effect/sql-pg` rc.115, its own layer |
10
+ | Valkey | `Redis` (`send`, `eval`; not `subscribe`) | `effect/unstable/persistence/Redis` |
11
+
12
+ ```ts
13
+ import { Effect, Redacted } from 'effect';
14
+ import * as Redis from 'effect/unstable/persistence/Redis';
15
+ import * as SqlClient from 'effect/unstable/sql/SqlClient';
16
+ import { SeatState } from '@homeflare/seat-runtime/state';
17
+
18
+ const state = SeatState.layer({
19
+ postgres: { url: Redacted.make(pgDsn) }, // postgres://user:pass@host:5432/db
20
+ valkey: { url: Redacted.make(vkUrl) }, // redis://seat:pass@host:6381
21
+ });
22
+ // or, each read from an environment variable: SEAT_POSTGRES_URL and SEAT_VALKEY_URL
23
+ const fromEnv = SeatState.layerFromEnv();
24
+
25
+ const program = Effect.gen(function* () {
26
+ const sql = yield* SqlClient.SqlClient;
27
+ const redis = yield* Redis.Redis;
28
+ yield* sql`insert into runs (id) values (${id})`;
29
+ yield* redis.send('SET', `seat:run:${id}`, 'started');
30
+ }).pipe(Effect.provide(state));
31
+ ```
32
+
33
+ `SeatState.postgres`, `postgresFromEnv`, `valkey`, `valkeyFromEnv`, `layer` and `layerFromEnv` are
34
+ the constructors. Each `*FromEnv` takes `{ variable }` to read another name. ⛔ **This package holds
35
+ no host**: every URL is the consumer's, held `Redacted`, and no default points anywhere.
36
+
37
+ ## Building a layer connects
38
+
39
+ Both layers connect and authenticate when they are built, so a wrong URL, user or password fails at
40
+ **startup** as a typed error (`SqlError`, `RedisError`, or `ConfigError` for a missing variable), not
41
+ at the first query. Postgres runs `select 1`; Valkey runs `connect()`. Each is bounded:
42
+ `connectTimeout` (Postgres) and `connectionTimeout` (Valkey), 5 s by default. A Valkey server that is
43
+ down is retried until that timeout, so one that is a moment late is waited for.
44
+
45
+ ## A Valkey that goes away, and comes back
46
+
47
+ Commands fail **at once** while the connection is down (`RedisError`, the offline queue is off), and
48
+ the seat retries in Effect, where an attempt is a span. What makes that retry worth anything is that
49
+ **the layer reconnects itself**: 🔴 measured 2026-09-29 (Bun 1.4.0, default `maxRetries` 20), a
50
+ `Bun.RedisClient` retries on its own for about 31 s of outage and then GIVES UP, after which it stays
51
+ dead (`Connection has failed`) even when the server is back, until `connect()` is called again. The
52
+ review that found it measured the same cliff between 30 s (recovered) and 45 s (not). So a seat whose
53
+ Valkey restarted for a minute lost it until the process restarted.
54
+
55
+ Off Bun's `onclose` the layer now calls `connect()` again: one attempt at a time, each bounded by
56
+ `connectionTimeout`, pausing 250 ms doubling to 5 s between attempts, with one `valkey reconnect`
57
+ span per attempt, a warning when it starts and an info line when it succeeds. It stops with the
58
+ layer's scope. `maxRetries` (option) sets when this loop takes over from Bun's. Measured through the
59
+ layer with the DEFAULT budget (scratch server, `connectionTimeout` 3 s): after a 45 s outage the first
60
+ `PING` was answered 1.5 s after the server returned, after a 75 s outage 1.8 s. The test suite uses
61
+ `maxRetries: 2`, which makes Bun give up after about 0.3 s.
62
+
63
+ - ⛔ `onclose` also runs for every failed `connect()` and for the client's own `close()`, so it is a
64
+ hint: the loop runs only while `client.connected` is false.
65
+ - 🔴 **`client.onclose = null` breaks `close()`.** Bun accepts it, and `close()` then calls the null
66
+ and throws `TypeError: ... is not a function` (worded with whatever call site is on the stack).
67
+ The layer assigns a no-op instead.
68
+
69
+ ## The typed error for an out-of-prefix write
70
+
71
+ Every Valkey failure is Effect's `RedisError` (`_tag: 'RedisError'`, so `Effect.catchTag` works).
72
+ `SeatState.isPermissionDenied(error)` says whether it is the server's `NOPERM`: a key outside the
73
+ seat user's prefix, or a command outside its categories. Measured 2026-09-29 against a scratch
74
+ Valkey 9.1.1 with `user default off` and `user seat on >… ~seat:* +@all -@dangerous`:
75
+
76
+ | call | result |
77
+ | ---------------------------- | --------------------------------------------------------------------------------- |
78
+ | no credentials | layer build fails, `NOAUTH` (`ERR_REDIS_AUTHENTICATION_FAILED`) |
79
+ | wrong password | layer build fails, `Connection closed` (Bun's text for a server that is down too) |
80
+ | `SET seat:a` / `GET seat:a` | ok |
81
+ | `SET other:a`, `GET other:a` | `RedisError`, `NOPERM No permissions to access a key` |
82
+ | `FLUSHALL` | `RedisError`, `NOPERM User seat has no permissions to run the 'flushall' command` |
83
+
84
+ The layer stays usable after a denied call. The URL carries the user and password, percent-encoded
85
+ (`redis://seat:p%40ss@host:6381`); `rediss://` is TLS (not tested).
86
+
87
+ ## In the trace
88
+
89
+ - **Postgres:** `@effect/sql-pg` makes one `sql.execute` client span per statement, with
90
+ `db.system.name=postgresql`, `db.namespace`, `server.address`, `server.port` and `db.query.text`.
91
+ The text holds `$1` placeholders, never a parameter value (asserted).
92
+ - **Valkey:** one client span per command, named `valkey <COMMAND>`, with `db.system.name=redis`
93
+ (the OpenTelemetry registry has `redis` and no `valkey`, read 2026-09-29), `db.operation.name`,
94
+ `server.address`, `server.port` and the database index as `db.namespace`. ⛔ The span's ATTRIBUTES
95
+ never hold a key, a value or the URL's user or password (asserted in process and in the exported
96
+ OTLP payload).
97
+
98
+ 🔴 **A failed span is not only its attributes.** `OtlpTracer` exports the error a failed span ended
99
+ with as `exception.message` and `exception.stacktrace`, with the whole `cause` chain, so an error
100
+ text that quotes an argument carries it into the trace. Measured 2026-09-29 with canary values, in
101
+ the exported payload:
102
+
103
+ - **Valkey** quotes them in `ERR unknown command 'JSON.SET', with args beginning with: '<key>'
104
+ '<value>'` and `ERR unknown subcommand '<key>'`. ✅ **Scrubbed:** the error that leaves the layer is
105
+ a new `Error` with the arguments cut (`src/state-valkey-scrub.ts`), asserted on the wire.
106
+ - **Postgres** quotes them in `invalid input syntax for type integer: "<value>"`, which rides in the
107
+ `[cause]` of the exported stack. ⚠️ **Not scrubbed:** `@effect/sql-pg` builds the span and the error
108
+ itself and has no hook, so a test pins the leak (`tests/state-error-text.test.ts`).
109
+
110
+ A Valkey text that echoes an argument without quotes, and the text a Lua script raises itself
111
+ (`error(...)` comes back as `ERR user_script:1: <text>`), are not covered. For Postgres, do not put a
112
+ secret in a parameter whose type the server can reject, or keep the exporter's destination one you
113
+ trust with seat data; a scrub in `SeatObs` would cover it and was not built (it would change every span).
114
+
115
+ Both spans join the trace of the run that made the call, and `SeatObs` exports them: asserted against
116
+ the stub's OTLP payload (`db.system.name` with `postgresql` or `redis`, the span names, no secret in a
117
+ SUCCESSFUL call). Ingest by the live VictoriaTraces is not measured, as for the rest of this package.
118
+
119
+ ## Measured traps, and what this package does about each
120
+
121
+ Measured 2026-09-29, Bun 1.4.0, `@effect/sql-pg` rc.115, Valkey 9.1.1, Postgres 18.6.
122
+
123
+ - 🔴 **A DSN that will not parse leaks through the driver's error.** `@effect/sql-pg` fails it as
124
+ `SqlError` whose `cause` is the URL `TypeError`, and that carries the whole string, password
125
+ included: three of six bad DSNs tried leaked through `JSON.stringify` and `Bun.inspect`. `postgres`
126
+ parses the URL first, and a string `new URL` rejects fails with a message that names the reason.
127
+ - 🔴 **The driver ignores the URL when it labels spans.** From `url` alone every query says
128
+ `server.address: localhost`, `server.port: 5432`, `db.namespace: postgres`. `postgres` passes the
129
+ URL's host, port, database and user as discrete fields too (except when a `?host=`, `?port=`,
130
+ `?user=` or `?dbname=` in the URL overrides them, where the driver's reading stands).
131
+ - 🔴 **A `Bun.RedisClient` on its defaults waits forever for a server that is gone.** A command sent
132
+ to a dead port sat unresolved past 8 s. The layer turns the offline queue off (through the layer, a
133
+ command after the server was killed failed in 1 ms, and the first one after a restart succeeded
134
+ within 0.5 s; `tests/state-valkey.test.ts` holds it) and
135
+ gives every command a deadline (`commandTimeout`, 10 s) against a server that holds the socket
136
+ open and says nothing. Past Bun's retry budget it reconnects itself (above).
137
+ - 🔴 **`connectionTimeout` does not bound DNS.** A name that does not resolve took 31 s with
138
+ `connectionTimeout: 700`; the layer wraps `connect()` in an Effect timeout of the same length.
139
+ - ⚠️ **Not `BunRedis` from `@effect/platform-bun`.** Rc.115 ships one, but depending on
140
+ `@effect/platform-bun` puts the `platform-node-shared` trap ([pairing.md](./pairing.md)) on every
141
+ consumer. This is the same `send` over `client.send`, plus the two things above.
142
+ - ⚠️ **`sql-pg` rc.115 has no driver package**: it speaks the wire protocol over `node:net`, one peer
143
+ (`effect`), no dependency. The Postgres half runs under Bun and Node; the Valkey half needs Bun
144
+ (`Bun.RedisClient`), and building it under Node fails as `RedisError` saying so.
145
+ - ⚠️ **A consumer typechecking with `skipLibCheck: false` needs `@types/node`** for `/state`: `sql-pg`'s
146
+ own `.d.ts` names `node:stream` and `node:tls` (4 `TS2591` errors, upstream's; `scripts/smoke.ts`
147
+ allows exactly those).
148
+
149
+ ## What is not here
150
+
151
+ - **No `subscribe`.** `Redis.subscribe` fails with `RedisError`: a subscriber needs a connection of
152
+ its own. (`BunRedis` opens one, without reconnect.)
153
+ - ⚠️ **`commandTimeout` (10 s) applies to every command**, a blocking one (`BLPOP`, `XREAD BLOCK`)
154
+ included: raise it, or such a call fails as `RedisError` at the deadline.
155
+ - No migrations, no schema, no key-prefix helper (the server's ACL is the enforcement), no metrics
156
+ of its own (the spans are the signal).
157
+ - **UNVERIFIED:** `rediss://` TLS; a password rotated while the server was down (the loop would
158
+ retry an authentication that fails, one `valkey reconnect` span per attempt, and never stop); and
159
+ the seats' real stores: `valkey-seats :6381` was not listening on CT100 (`ss`, 2026-09-29) and no
160
+ test used the `hf_agent` role. Everything above ran against scratch stores. The Postgres suite ran
161
+ twice: once against a scratch database on CT100 (trust auth, through an ssh tunnel), once against a
162
+ local scram-sha-256 Postgres 18 (which is what runs the wrong-password test).
163
+
164
+ ## The tests, and running them
165
+
166
+ `bun test packages/seat-runtime` runs everything that needs no server (DSN parsing, the failures, the
167
+ command deadline, the spans against a fake client). Two suites need a store and **skip, loudly, when
168
+ it is missing** (CI has neither); `SEAT_RUNTIME_REQUIRE_SERVERS=1` makes a missing store a failure.
169
+
170
+ - **Valkey:** a `valkey-server` (or `redis-server`) on `PATH`. The tests start it on a loopback port
171
+ with `user default off` and one `seat` user on `~seat:*`, with a per-run password.
172
+ - **Postgres:** `SEAT_RUNTIME_TEST_POSTGRES_URL` naming a **scratch** database. The tests create one
173
+ table with a per-run name and drop it. A URL with a password (scram-sha-256) also runs the
174
+ wrong-password test; a trust-auth one skips it.
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@homeflare/seat-runtime",
3
+ "version": "0.1.0",
4
+ "description": "One Effect AI model layer and one OTLP layer for HomeFlare's coding seats: LiteLLM tags, no-cache and no-retry per request, traces, logs and metrics to VictoriaMetrics on CT100.",
5
+ "homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/seat-runtime#readme",
6
+ "bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
7
+ "license": "MIT",
8
+ "author": "Timothy Schneider",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/taslabs-net/homeflare-kit.git",
12
+ "directory": "packages/seat-runtime"
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "docs",
17
+ "src",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "type": "module",
22
+ "types": "./dist/index.d.ts",
23
+ "exports": {
24
+ ".": {
25
+ "types": "./dist/index.d.ts",
26
+ "default": "./dist/index.js"
27
+ },
28
+ "./state": {
29
+ "types": "./dist/state.d.ts",
30
+ "default": "./dist/state.js"
31
+ },
32
+ "./package.json": "./package.json"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "dependencies": {
38
+ "@effect/ai-openai-compat": "4.0.0-rc.115",
39
+ "@effect/sql-pg": "4.0.0-rc.115",
40
+ "@modelcontextprotocol/sdk": "1.31.0"
41
+ },
42
+ "peerDependencies": {
43
+ "effect": "4.0.0-rc.115"
44
+ }
45
+ }
package/src/index.ts ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * @homeflare/seat-runtime — what every HomeFlare coding seat shares: the model and telemetry
3
+ * layers, the round loop, and MCP servers as a toolkit.
4
+ *
5
+ * ⛔ RUNTIME-NEUTRAL: nothing here imports `bun:*` or `node:*`. It rides `fetch`, so it runs
6
+ * under Bun, Node and workerd alike. ⚠️ That includes the MCP SDK's client entry: bundled with
7
+ * workerd's resolution conditions, this entrypoint and the SDK's client hold no `node:`, `bun:`
8
+ * or bare builtin import (tests/sdk-neutral.test.ts), but its default JSON Schema validator is
9
+ * `ajv`, which needs `new Function`, so workerd itself is untested for `mcpToolkit`.
10
+ */
11
+ export * as SeatModel from './seat-model.ts';
12
+ export * as SeatObs from './seat-obs.ts';
13
+ export { runRounds } from './rounds.ts';
14
+ export type { Round, RoundsOptions, RoundsResult } from './rounds.ts';
15
+ export { mcpToolkit } from './mcp-toolkit.ts';
16
+ export type { McpHeaders } from './mcp-connect.ts';
17
+ export type {
18
+ McpResource,
19
+ McpResourceContent,
20
+ McpToolkit,
21
+ McpToolkitOptions,
22
+ McpTools,
23
+ } from './mcp-toolkit.ts';
24
+ export { McpToolkitError } from './mcp-error.ts';
25
+ export { VERSION } from './version.ts';
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Connect to a Streamable HTTP MCP server with the official SDK client: the transport, the
3
+ * caller's headers on every request, and the WHOLE handshake (`initialize` and the
4
+ * `notifications/initialized` that follows it) bounded in time and interruptible.
5
+ *
6
+ * ⛔ HEADERS ARE CREDENTIALS. They ride `requestInit` to the server and nowhere else: not in an
7
+ * error, a span or a log (mcp-error.ts). A `Redacted` value is unwrapped only here, at the
8
+ * moment the transport is built.
9
+ */
10
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
11
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
12
+ import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
13
+ import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
14
+ import * as Effect from 'effect/Effect';
15
+ import * as Redacted from 'effect/Redacted';
16
+ import type * as Scope from 'effect/Scope';
17
+ import { McpToolkitError, type Redact } from './mcp-error.ts';
18
+ import { VERSION } from './version.ts';
19
+
20
+ /** Header values; a `Redacted` is unwrapped only at the moment the transport is built. */
21
+ export type McpHeaders = Readonly<Record<string, string | Redacted.Redacted<string>>>;
22
+
23
+ export const DEFAULT_CONNECT_TIMEOUT_MS = 15_000;
24
+
25
+ /**
26
+ * The longest a timer can run: `setTimeout` takes a 32-bit signed delay, and a larger one (like
27
+ * one below 1) is set to 1 ms. Node documents that; Bun does the same (measured, see below).
28
+ */
29
+ export const MAX_TIMEOUT_MS: number = 2 ** 31 - 1;
30
+
31
+ /**
32
+ * ⛔ A timeout that is not a finite number of milliseconds in `(0, MAX_TIMEOUT_MS]` is a
33
+ * programming error, and dies with a `RangeError` before any request, as `maxRounds` does.
34
+ * The budget feeds two timers (ours and the SDK's per request), and an out-of-range value made
35
+ * them disagree: measured 2026-09-29 (review of PR 328) against a healthy local stub,
36
+ * `Infinity` failed at once ("did not finish within Infinity ms") while `0`, `-1`, `NaN` and
37
+ * `2 ** 31` each happened to succeed, because the timer clamped to 1 ms and the handshake won
38
+ * the race. A real handshake takes longer than that 1 ms (not measured remotely), so those
39
+ * would lose it.
40
+ */
41
+ export const validateTimeout = (timeout: number): Effect.Effect<number> =>
42
+ Number.isFinite(timeout) && timeout > 0 && timeout <= MAX_TIMEOUT_MS
43
+ ? Effect.succeed(timeout)
44
+ : Effect.die(
45
+ new RangeError(
46
+ `connectTimeoutMs must be a finite number of milliseconds above 0 and at most ${String(MAX_TIMEOUT_MS)}, got ${String(timeout)}`,
47
+ ),
48
+ );
49
+
50
+ const plainHeaders = (headers: McpHeaders | undefined): Record<string, string> =>
51
+ Object.fromEntries(
52
+ Object.entries(headers ?? {}).map(([name, value]) => [
53
+ name,
54
+ Redacted.isRedacted(value) ? Redacted.value(value) : value,
55
+ ]),
56
+ );
57
+
58
+ /**
59
+ * The header values as plain strings, for `redactor` and for nothing else: a value an error
60
+ * message must never repeat is exactly a value the redactor has to know. Kept here so that a
61
+ * `Redacted` is unwrapped in this file only.
62
+ */
63
+ export function headerValues(headers: McpHeaders | undefined): string[] {
64
+ return Object.values(plainHeaders(headers));
65
+ }
66
+
67
+ /**
68
+ * ★ THE BUDGET COVERS THE WHOLE HANDSHAKE, NOT ONE REQUEST. SDK 1.31.0 `Client.connect` sends
69
+ * `initialize` (bounded by its `timeout` option) and then AWAITS a `notifications/initialized`
70
+ * POST that carries no timeout at all: only the transport's own abort signal ends it. Measured
71
+ * 2026-09-29 (review of PR 328): a server that answered `initialize` and held the second POST
72
+ * left a caller's `Effect.timeout('2 seconds')` unsettled at 8 s. So a timer here closes the
73
+ * client when the budget is spent, and `close` aborts the transport's fetch, which is the only
74
+ * thing that cancels that pending POST.
75
+ * ★ THE CALLER'S `signal` CLOSES IT TOO. It fires when the fiber is interrupted (a seat shutting
76
+ * down, an `Effect.timeout`), which only means something because `connectAndBuild` runs the handshake
77
+ * in the `restore`d, interruptible part of its mask: a handshake inside `acquireRelease`'s
78
+ * uninterruptible acquire would ignore it (that was the first version of this timeout).
79
+ * ⚠️ Whatever the SDK rejects with after the budget is spent (an `AbortError` from the cancelled
80
+ * fetch, or its own `Request timed out`) is an artefact of the close, so it is replaced by one
81
+ * message that says what happened.
82
+ */
83
+ async function handshake(
84
+ client: Client,
85
+ url: URL,
86
+ headers: McpHeaders | undefined,
87
+ timeout: number,
88
+ signal: AbortSignal,
89
+ ): Promise<void> {
90
+ const transport = new StreamableHTTPClientTransport(url, {
91
+ requestInit: { headers: plainHeaders(headers) },
92
+ });
93
+ const budget = new AbortController();
94
+ const stop = AbortSignal.any([signal, budget.signal]);
95
+ const timer = setTimeout(() => budget.abort(), timeout);
96
+ const close = (): void => void client.close().catch(() => undefined);
97
+ stop.addEventListener('abort', close, { once: true });
98
+ try {
99
+ // ⚠️ The SDK's own `sessionId?: string` reads as `string | undefined` against its own
100
+ // `Transport` under `exactOptionalPropertyTypes` (this repo's baseline), so the two
101
+ // of its types do not line up here. Upstream's typing, not a wrong argument: the
102
+ // class IS the transport, and nothing of it reaches this package's declarations.
103
+ await client.connect(transport as Transport, { signal: stop, timeout });
104
+ } catch (error) {
105
+ // A failure AFTER the transport starts (a refused `initialize`) leaves the client holding
106
+ // an open transport: close it here rather than wait for the scope.
107
+ await client.close().catch(() => undefined);
108
+ const spent = budget.signal.aborted;
109
+ const sdkTimeout = error instanceof McpError && error.code === ErrorCode.RequestTimeout;
110
+ throw (spent || sdkTimeout) && !signal.aborted
111
+ ? new Error(`the MCP handshake did not finish within ${String(timeout)} ms`)
112
+ : error;
113
+ } finally {
114
+ clearTimeout(timer);
115
+ stop.removeEventListener('abort', close);
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Connect, handshake and BUILD whatever the caller needs from the client (`build`: for
121
+ * `mcpToolkit`, listing the tools and making the toolkit). The client lives as long as the
122
+ * surrounding `Scope`, and only when ALL of that worked.
123
+ *
124
+ * ★ THE FINALIZER IS REGISTERED LAST: after the handshake AND after `build`. A scoped constructor
125
+ * that fails must leave nothing in the caller's scope, and this one has been wrong twice:
126
+ * - it used to register the `Client`'s `close` before the handshake. Each `Client` carries its
127
+ * own JSON Schema validator (SDK client/index.js), so a seat retrying `mcpToolkit` through a
128
+ * gateway outage held one per failed attempt until its scope closed: measured 2026-09-29
129
+ * (review of PR 328), 64.7 MB against 10.6 MB after 3 001 refused attempts in one scope,
130
+ * about 18 KB each. `handshake` closes the client on every failure, so a refused handshake
131
+ * has nothing left to release.
132
+ * - it then registered it right after the handshake, BEFORE the `tools/list` that `build` does,
133
+ * and that listing can fail too: a JSON-RPC error, a page held past `connectTimeoutMs`, a
134
+ * caller who interrupts. Measured 2026-09-29 (review of PR 328, later round), a loopback
135
+ * server that completed the handshake: 20 attempts whose listing errored left 20 finalizers
136
+ * and 20 open SSE streams in one scope; 10 whose listing was held left 10 finalizers, 10
137
+ * streams and 10 `tools/list` POSTs still open (the SDK's per-request timeout rejects the
138
+ * promise and does not abort the fetch). A seat retrying once a second against a slow gateway
139
+ * is about 3 600 sessions and sockets an hour, held for the life of the seat.
140
+ * `close` aborts the transport's fetches (the SSE stream and a held POST alike), which is why
141
+ * closing the client is what releases them.
142
+ * ⚠️ `uninterruptibleMask` keeps the gaps between the steps from being interruption points (a
143
+ * client opened and never closed); only the handshake and `build` are restored to
144
+ * interruptible, and each has an `onError` that closes the client when it fails OR is
145
+ * interrupted, so the SDK's own close-on-abort is not the only line of defence. `onError` runs
146
+ * uninterruptibly, so the close itself cannot be cut off.
147
+ * ★ THE `seat.mcp.connect` SPAN IS THE HANDSHAKE ONLY, not `build`: it is what a slow or refused
148
+ * connect looks like in a trace, and the listing is not that.
149
+ */
150
+ export const connectAndBuild = <A>(
151
+ url: URL,
152
+ headers: McpHeaders | undefined,
153
+ server: string,
154
+ timeout: number,
155
+ redact: Redact,
156
+ build: (client: Client) => Effect.Effect<A, McpToolkitError>,
157
+ ): Effect.Effect<A, McpToolkitError, Scope.Scope> =>
158
+ Effect.uninterruptibleMask((restore) =>
159
+ Effect.gen(function* () {
160
+ const client = new Client({ name: '@homeflare/seat-runtime', version: VERSION });
161
+ const close = Effect.ignore(Effect.tryPromise(() => client.close()));
162
+ yield* restore(
163
+ Effect.tryPromise({
164
+ try: (signal) => handshake(client, url, headers, timeout, signal),
165
+ catch: (cause) => new McpToolkitError({ operation: 'connect', server, cause, redact }),
166
+ }).pipe(Effect.withSpan('seat.mcp.connect', { attributes: { 'server.address': server } })),
167
+ ).pipe(Effect.onError(() => close));
168
+ const built = yield* restore(build(client)).pipe(Effect.onError(() => close));
169
+ yield* Effect.addFinalizer(() => close);
170
+ return built;
171
+ }),
172
+ );
@@ -0,0 +1,150 @@
1
+ /**
2
+ * `mcpToolkit`'s typed failure, and the one place an MCP server's address is spelled for a
3
+ * message or a span.
4
+ *
5
+ * ⛔ THE ADDRESS IS ORIGIN AND PATH ONLY. A query string is where a token lands when a server
6
+ * wants one there, and the headers a caller passes are bearer credentials: neither may reach
7
+ * an error message, a log line or a span attribute. Header NAMES and the header map are never
8
+ * read here; a header VALUE is read only to be redacted (`redactor`).
9
+ * 🔴 A SERVER ECHOES A VALUE ON ITS OWN. Redacting the query STRING and the address is not
10
+ * enough: a server that answers "rejected key QVALUE" prints the value alone, and one that says
11
+ * "bad credential Bearer HVALUE" prints a header's. Measured 2026-09-29 (review of PR 328,
12
+ * round 2): both reached `McpToolkitError.message`. So `redactor` also takes out each query
13
+ * value and each header value the caller passed (`MIN_SECRET_LENGTH` and up). What it cannot
14
+ * catch: a value the server TRANSFORMS (hashed, base64, truncated), and one under the minimum.
15
+ * 🔴 THE `cause` IS A COPY, NEVER THE ORIGINAL. Measured 2026-09-29 (review of PR 328): under Bun a
16
+ * refused fetch is a `TypeError` whose own `path` field is the FULL URL, query string included,
17
+ * so attaching it as `cause` printed the token through `Bun.inspect`, `console.error`, an
18
+ * uncaught rejection and the default `Effect.logError` — while `error.message` was clean, which
19
+ * is all the first tests looked at. `scrubCause` rebuilds the chain from name, message, stack
20
+ * and a string or numeric `code` only, so no other field (`path`, `url`, `data`, `body`) is
21
+ * carried, and `redactor` takes the query string, fragment and password out of what remains.
22
+ * The price: `cause instanceof McpError` no longer holds; read `cause.code` instead.
23
+ */
24
+ /** Which call failed. `connect` covers the transport and the whole handshake (mcp-connect.ts). */
25
+ export type McpOperation = 'connect' | 'listTools' | 'listResources' | 'readResource';
26
+
27
+ /** A server address safe to print: `https://host:port/path`, no credentials, query or fragment. */
28
+ export function serverLabel(url: URL): string {
29
+ return `${url.origin}${url.pathname}`;
30
+ }
31
+
32
+ /** Text with a server's query string, fragment and password taken out of it. */
33
+ export type Redact = (text: string) => string;
34
+
35
+ /**
36
+ * The shortest value `redactor` treats as a secret ON ITS OWN. Redacting every occurrence of a
37
+ * value costs legible messages (a `?v=1` would blank every "1"), and a value this short is not
38
+ * a credential anyone relies on. The query STRING, fragment and password are redacted whole
39
+ * whatever their length.
40
+ */
41
+ export const MIN_SECRET_LENGTH = 6;
42
+
43
+ /** The values in a raw query string, as written (percent-encoded) and as decoded. */
44
+ function queryValues(url: URL): string[] {
45
+ const raw = url.search
46
+ .slice(1)
47
+ .split('&')
48
+ .map((pair) => pair.slice(pair.indexOf('=') + 1));
49
+ return [...raw, ...url.searchParams.values()];
50
+ }
51
+
52
+ /**
53
+ * A header value, and its credential when it has a scheme: `Bearer abc` is echoed whole or as
54
+ * `abc`, and "Bearer" alone is not a secret.
55
+ */
56
+ function headerParts(value: string): string[] {
57
+ const token = value.trim().split(/\s+/).at(-1) ?? '';
58
+ return [value, token];
59
+ }
60
+
61
+ /**
62
+ * A `Redact` for one server address, plus any header values the caller passes. The whole URL
63
+ * becomes `serverLabel`; a bare query string, fragment or password left over, each query value
64
+ * and each header value (`MIN_SECRET_LENGTH` and up, longest first) becomes `[redacted]`: a
65
+ * server can echo the address or a value back in an error body, and a fetch failure can print it.
66
+ */
67
+ export function redactor(url: URL, headerValues: ReadonlyArray<string> = []): Redact {
68
+ const label = serverLabel(url);
69
+ const whole = [
70
+ url.search.length > 1 ? url.search : '',
71
+ url.hash.length > 1 ? url.hash : '',
72
+ url.password,
73
+ ];
74
+ const parts = [...queryValues(url), ...headerValues.flatMap(headerParts)];
75
+ const secrets = [
76
+ ...whole,
77
+ ...[...new Set(parts)]
78
+ .filter((part) => part.length >= MIN_SECRET_LENGTH)
79
+ .sort((a, b) => b.length - a.length),
80
+ ].filter((secret) => secret !== '');
81
+ return (text) => {
82
+ let out = text.split(url.href).join(label);
83
+ for (const secret of secrets) out = out.split(secret).join('[redacted]');
84
+ return out;
85
+ };
86
+ }
87
+
88
+ /** How many `cause` links `scrubCause` follows: a cycle or a deep chain ends here. */
89
+ const MAX_CAUSE_DEPTH = 5;
90
+
91
+ /**
92
+ * A copy of `cause` that is safe to print. ⚠️ An allowlist, not a denylist: an Error is rebuilt
93
+ * from its name, message, stack and `code` (a string or number) and its own `cause`, each run
94
+ * through `redact`; every other own field is left behind, whatever it is called. See the header.
95
+ */
96
+ export function scrubCause(cause: unknown, redact: Redact, depth = 0): unknown {
97
+ if (!(cause instanceof Error)) return redact(String(cause));
98
+ const inner = depth < MAX_CAUSE_DEPTH && cause.cause !== undefined;
99
+ const copy = new Error(
100
+ redact(cause.message),
101
+ inner ? { cause: scrubCause(cause.cause, redact, depth + 1) } : undefined,
102
+ );
103
+ copy.name = redact(cause.name);
104
+ if (typeof cause.stack === 'string') copy.stack = redact(cause.stack);
105
+ const code: unknown = (cause as { code?: unknown }).code;
106
+ if (typeof code === 'string') Object.assign(copy, { code: redact(code) });
107
+ else if (typeof code === 'number') Object.assign(copy, { code });
108
+ return copy;
109
+ }
110
+
111
+ /** The message of whatever a promise rejected with; an SDK `McpError` carries the JSON-RPC text. */
112
+ export function describeCause(cause: unknown): string {
113
+ return cause instanceof Error ? cause.message : String(cause);
114
+ }
115
+
116
+ /**
117
+ * The connection, a listing or a resource read failed.
118
+ *
119
+ * ★ A PLAIN `Error` WITH A `_tag`, NOT `Data.TaggedError`. `class X extends Data.TaggedError(…)<…>`
120
+ * is TS9021 under `isolatedDeclarations` ("extends clause can't contain an expression"; measured
121
+ * 2026-09-15, see packages/alchemy/tsconfig.json), and this package keeps that flag on. The
122
+ * `_tag` is all `Effect.catchTag('McpToolkitError', …)` reads.
123
+ */
124
+ export class McpToolkitError extends Error {
125
+ readonly _tag = 'McpToolkitError' as const;
126
+ readonly operation: McpOperation;
127
+ /** `serverLabel` of the server: never a header, never a query string. */
128
+ readonly server: string;
129
+
130
+ constructor(fields: {
131
+ readonly operation: McpOperation;
132
+ readonly server: string;
133
+ /**
134
+ * What the SDK threw: an `McpError` (a JSON-RPC error), a `StreamableHTTPError`, a fetch
135
+ * failure. Never stored as given: `cause` on the error is `scrubCause` of it.
136
+ */
137
+ readonly cause: unknown;
138
+ /** `redactor` of the server's URL. Left out, only the structural scrub of `cause` applies. */
139
+ readonly redact?: Redact | undefined;
140
+ }) {
141
+ const redact = fields.redact ?? ((text: string) => text);
142
+ super(
143
+ `MCP ${fields.operation} against ${fields.server} failed: ${redact(describeCause(fields.cause))}`,
144
+ { cause: scrubCause(fields.cause, redact) },
145
+ );
146
+ this.name = 'McpToolkitError';
147
+ this.operation = fields.operation;
148
+ this.server = fields.server;
149
+ }
150
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * MCP's cursor pagination, followed to the end — but not forever.
3
+ *
4
+ * ⛔ THE PAGE COUNT IS CAPPED. A server that answers every page with another `nextCursor` (a
5
+ * bug, or a hostile one) would otherwise hold a seat in a listing loop before its first
6
+ * model call. A hundred pages is far past any estate server's tool list; the cap fails the
7
+ * listing loudly instead of truncating it quietly.
8
+ */
9
+ import * as Effect from 'effect/Effect';
10
+ import { type McpOperation, McpToolkitError, type Redact } from './mcp-error.ts';
11
+
12
+ export const MAX_PAGES = 100;
13
+
14
+ export type Page<T> = { readonly items: ReadonlyArray<T>; readonly next: string | undefined };
15
+
16
+ /** Every item of a paged listing, or an `McpToolkitError` for `operation`. Interruption aborts the request in flight. */
17
+ export function collect<T>(
18
+ operation: McpOperation,
19
+ server: string,
20
+ page: (cursor: string | undefined, signal: AbortSignal) => Promise<Page<T>>,
21
+ redact?: Redact | undefined,
22
+ ): Effect.Effect<ReadonlyArray<T>, McpToolkitError> {
23
+ return Effect.tryPromise({
24
+ try: async (signal) => {
25
+ const items: T[] = [];
26
+ let cursor: string | undefined;
27
+ for (let pages = 0; pages < MAX_PAGES; pages += 1) {
28
+ const next = await page(cursor, signal);
29
+ items.push(...next.items);
30
+ if (next.next === undefined) return items;
31
+ cursor = next.next;
32
+ }
33
+ throw new Error(`the server returned more than ${String(MAX_PAGES)} pages`);
34
+ },
35
+ catch: (cause) => new McpToolkitError({ operation, server, cause, redact }),
36
+ });
37
+ }