@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.
- package/LICENSE +21 -0
- package/README.md +200 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +508 -0
- package/dist/index.js.map +21 -0
- package/dist/mcp-connect.d.ts +72 -0
- package/dist/mcp-connect.d.ts.map +1 -0
- package/dist/mcp-error.d.ts +77 -0
- package/dist/mcp-error.d.ts.map +1 -0
- package/dist/mcp-pages.d.ts +18 -0
- package/dist/mcp-pages.d.ts.map +1 -0
- package/dist/mcp-render.d.ts +25 -0
- package/dist/mcp-render.d.ts.map +1 -0
- package/dist/mcp-tool.d.ts +61 -0
- package/dist/mcp-tool.d.ts.map +1 -0
- package/dist/mcp-toolkit.d.ts +61 -0
- package/dist/mcp-toolkit.d.ts.map +1 -0
- package/dist/mcp-toolset.d.ts +33 -0
- package/dist/mcp-toolset.d.ts.map +1 -0
- package/dist/rounds.d.ts +108 -0
- package/dist/rounds.d.ts.map +1 -0
- package/dist/seat-model.d.ts +59 -0
- package/dist/seat-model.d.ts.map +1 -0
- package/dist/seat-obs.d.ts +23 -0
- package/dist/seat-obs.d.ts.map +1 -0
- package/dist/seat-state.d.ts +33 -0
- package/dist/seat-state.d.ts.map +1 -0
- package/dist/stamp.d.ts +23 -0
- package/dist/stamp.d.ts.map +1 -0
- package/dist/state-dsn.d.ts +41 -0
- package/dist/state-dsn.d.ts.map +1 -0
- package/dist/state-postgres.d.ts +58 -0
- package/dist/state-postgres.d.ts.map +1 -0
- package/dist/state-valkey-connection.d.ts +49 -0
- package/dist/state-valkey-connection.d.ts.map +1 -0
- package/dist/state-valkey-scrub.d.ts +37 -0
- package/dist/state-valkey-scrub.d.ts.map +1 -0
- package/dist/state-valkey-send.d.ts +35 -0
- package/dist/state-valkey-send.d.ts.map +1 -0
- package/dist/state-valkey.d.ts +83 -0
- package/dist/state-valkey.d.ts.map +1 -0
- package/dist/state.d.ts +9 -0
- package/dist/state.d.ts.map +1 -0
- package/dist/state.js +327 -0
- package/dist/state.js.map +16 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/docs/mcp.md +40 -0
- package/docs/pairing.md +39 -0
- package/docs/state.md +174 -0
- package/package.json +45 -0
- package/src/index.ts +25 -0
- package/src/mcp-connect.ts +172 -0
- package/src/mcp-error.ts +150 -0
- package/src/mcp-pages.ts +37 -0
- package/src/mcp-render.ts +67 -0
- package/src/mcp-tool.ts +101 -0
- package/src/mcp-toolkit.ts +183 -0
- package/src/mcp-toolset.ts +91 -0
- package/src/rounds.ts +211 -0
- package/src/seat-model.ts +94 -0
- package/src/seat-obs.ts +103 -0
- package/src/seat-state.ts +53 -0
- package/src/stamp.ts +88 -0
- package/src/state-dsn.ts +112 -0
- package/src/state-postgres.ts +114 -0
- package/src/state-valkey-connection.ts +113 -0
- package/src/state-valkey-scrub.ts +60 -0
- package/src/state-valkey-send.ts +67 -0
- package/src/state-valkey.ts +193 -0
- package/src/state.ts +8 -0
- 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
|
+
);
|
package/src/mcp-error.ts
ADDED
|
@@ -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
|
+
}
|
package/src/mcp-pages.ts
ADDED
|
@@ -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
|
+
}
|