@ahrzb/personal-mcp-client 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.
@@ -0,0 +1,304 @@
1
+ /**
2
+ * The author's MCP server — `Server` (or `McpServer`) from `@modelcontextprotocol/server`
3
+ * v2; external, never imported here, and therefore named by the one method serve() uses
4
+ * rather than by a type this module cannot see. `connect` is the SDK session's own entry:
5
+ * it owns the MCP handshake and calls the transport's start() itself.
6
+ *
7
+ * Stated structurally on purpose. A wider type (`unknown`, with a runtime probe for
8
+ * `connect`) would let a wrong object through to a fallback that registers with the hub and
9
+ * then never assigns `onmessage` — the hub believes the service is healthy while every
10
+ * forwarded call times out. Failing at the call site instead is the whole difference
11
+ * between a typo and an unknown-unknown. A hand-rolled session is served by constructing
12
+ * {@link HubTransport} directly, which is what that class documents.
13
+ */
14
+ export type McpServer = {
15
+ connect(transport: HubTransport): Promise<void>;
16
+ /**
17
+ * Optional: the SDK's own `ServerCapabilities`-shaped answer, read at
18
+ * registration time to answer the hub's `server/discover` (§11/§6, added
19
+ * §20). Structural and optional on purpose — every service already in the
20
+ * field is an object with no such method, and §11 pins that absence as the
21
+ * hub's "capabilities unknown" fallback rather than a TypeError here.
22
+ */
23
+ getCapabilities?(): Record<string, unknown>;
24
+ };
25
+ /** One MCP wire message — `JSONRPCMessage` from `@modelcontextprotocol/server`; external, never imported here. */
26
+ export type JsonRpcMessage = unknown;
27
+ /**
28
+ * The role declaration sent in `hub/register`: role name → either a bare pattern
29
+ * list — tools, forever, so every service written before §20 keeps registering
30
+ * unchanged — or a per-family object, each key optional (§20.3). Validation is
31
+ * the hub's job, not this library's: names must match `[a-z0-9_-]{1,64}`, `all`
32
+ * is reserved, an unknown family key is a violation, and pattern length (≤128)
33
+ * and per-family pattern count (≤64) are capped — a violating declaration is
34
+ * rejected at registration, which serve() surfaces as RegistrationError. The two
35
+ * spellings may be mixed across roles in one declaration; this library sends
36
+ * whichever an author wrote, unchanged — no normalization here (§20.6).
37
+ */
38
+ export type Roles = Record<string, string[] | {
39
+ tools?: string[];
40
+ prompts?: string[];
41
+ resources?: string[];
42
+ }>;
43
+ /**
44
+ * The `hub/*` control-frame method names (contracts/tunnel-frames.json `methods`, the
45
+ * hub's own `HUB_METHODS` export). Exported because it is exactly the set this transport
46
+ * CONSUMES: a `hub/` method outside it is ordinary traffic and reaches the SDK session
47
+ * untouched, so a new control frame cannot be swallowed silently.
48
+ */
49
+ export declare const HUB_METHODS: {
50
+ readonly register: "hub/register";
51
+ readonly replaced: "hub/replaced";
52
+ };
53
+ /** The pinned MCP revision of the tunnel wire (contracts/tunnel-frames.json). */
54
+ export declare const PROTOCOL_VERSION = "2026-07-28";
55
+ /**
56
+ * The two seams the reconnect policy is otherwise unobservable through — the jitter draw
57
+ * and the wait itself — as MODULE state the loop calls by name, never as constructor
58
+ * options. Both are production concerns rather than test workarounds: the schedule is full
59
+ * jitter, so `max_only` and `exponential` overlap at every attempt and are told apart only
60
+ * at a FIXED draw, and a suite that waited out a real 60 s window is a suite nobody runs.
61
+ *
62
+ * Here rather than on the constructor because §11 promises `{url, token, roles}` and
63
+ * nothing else: an author reading `new HubTransport(…)` should not have to learn about
64
+ * jitter injection to use the three options that matter. This is the same spelling the
65
+ * Python twin uses (`pmcp_client._rng` / `._sleep`, replaced with monkeypatch), so the two
66
+ * libraries state one contract in one shape; a suite replaces these members and restores
67
+ * them afterwards.
68
+ */
69
+ export declare const seams: {
70
+ rng: () => number;
71
+ sleep: (ms: number) => Promise<void>;
72
+ };
73
+ export type ServeOptions = {
74
+ /**
75
+ * The hub's https origin, e.g. "https://mcp.example.com" — a bare origin, no
76
+ * path (PMCP_URL, §10). Default: the PMCP_URL env var; neither set is a
77
+ * TypeError. The wss://<host>/connect address is derived internally — it is
78
+ * never passed in directly.
79
+ */
80
+ url?: string;
81
+ /**
82
+ * The service token (`pmcp_svc_…`). Default: the PMCP_SERVICE_TOKEN env var.
83
+ * The service identity comes entirely from this token — there is deliberately
84
+ * no service/slug option (§6: a token for one slug can never touch another).
85
+ */
86
+ token?: string;
87
+ /**
88
+ * Role declaration for this service. Omitted or `{}` declares none — the
89
+ * service is then reachable only by owner tokens or grants of the built-in
90
+ * `all` role (§6).
91
+ */
92
+ roles?: Roles;
93
+ };
94
+ /**
95
+ * The credential is dead: 401 at upgrade or close 4001 after establishment —
96
+ * revoked/expired token, wrong token kind, or deleted service. Terminal; the
97
+ * library never retries a dead credential (§6's upgrade matrix).
98
+ */
99
+ export declare class CredentialsError extends Error {
100
+ }
101
+ /**
102
+ * The hub rejected the `hub/register` role declaration (bad role name,
103
+ * non-compiling pattern, over caps — §6). Terminal: identical input cannot start
104
+ * succeeding, so this is surfaced immediately instead of retried.
105
+ */
106
+ export declare class RegistrationError extends Error {
107
+ }
108
+ /**
109
+ * Run `server` as a tunneled hub service: dial, register the role declaration,
110
+ * and stay reachable until the hub says otherwise. The returned promise pends for
111
+ * the life of the service — hours to months; treat it as the bot's main loop.
112
+ *
113
+ * Terminal outcomes are the whole resolution contract:
114
+ * - resolves quietly when the hub replaces this connection with a newer one for
115
+ * the same service (`hub/replaced`, close 4000) — this copy steps aside and
116
+ * never reconnects (§6: two copies fighting for the slot is an operator error
117
+ * worth surfacing);
118
+ * - rejects with CredentialsError or RegistrationError (see those types);
119
+ * - every other failure — network drop, hub deploy, registration deadline (4004),
120
+ * archived service (403 at upgrade / close 4002) — reconnects forever and never
121
+ * resolves. The full policy lives on HubTransport.
122
+ *
123
+ * Reconnects re-register and re-warm the hub's tool cache automatically; nothing
124
+ * is buffered while offline (module header).
125
+ */
126
+ export declare function serve(server: McpServer, options?: ServeOptions): Promise<void>;
127
+ /**
128
+ * The Transport bridge: implements the MCP SDK `Transport` interface (from
129
+ * `@modelcontextprotocol/server` v2) over an outbound `ws` WebSocket to
130
+ * wss://<host>/connect. serve() is sugar over this; construct it directly only to
131
+ * wire the SDK session yourself.
132
+ *
133
+ * One transport is one service lifetime, not one socket: reconnects — jittered
134
+ * exponential backoff, 1 s → 60 s cap, forever (hub deploys sever every socket,
135
+ * so this is routine; the schedule itself is backoffDelay, exported pure) —
136
+ * happen inside, invisible to the SDK session, and
137
+ * `hub/register` is re-sent on every (re)connect. `onclose` fires once, at a
138
+ * terminal state only.
139
+ *
140
+ * The disconnect policy, decided here and nowhere else (§6):
141
+ * - 401 at upgrade, close 4001 — dead credential: terminal, CredentialsError.
142
+ * - 403 at upgrade, close 4002 — archived: keep retrying at max backoff, so
143
+ * unarchiving heals within a minute without touching the bot.
144
+ * - close 4000 (after `hub/replaced`) — a newer connection took the slot: stop
145
+ * quietly, never reconnect.
146
+ * - register rejected — terminal, RegistrationError.
147
+ * - everything else (network, deploy, close 4003/4004) — reconnect with backoff;
148
+ * a truly deleted service becomes 401 at the next upgrade, the fatal path.
149
+ *
150
+ * `hub/*` control frames never reach the SDK session; everything else is MCP
151
+ * traffic, one JSON-RPC message per WS text frame. Liveness is WebSocket protocol
152
+ * pings (~25 s); there is no application heartbeat.
153
+ */
154
+ export declare class HubTransport {
155
+ /**
156
+ * Settles when the transport reaches a terminal state: resolves after
157
+ * `hub/replaced` (close 4000) or a local close(); rejects with
158
+ * CredentialsError / RegistrationError. Never settles on an ordinary
159
+ * disconnect — those reconnect. serve() awaits exactly this.
160
+ */
161
+ readonly closed: Promise<void>;
162
+ /** Assigned by the SDK session (Transport contract): called once per inbound MCP message; hub/* control frames are consumed internally and never appear here. */
163
+ onmessage?: (message: JsonRpcMessage) => void;
164
+ /** Assigned by the SDK session (Transport contract): fired once, at the terminal state only — reconnects are invisible. */
165
+ onclose?: () => void;
166
+ /** Assigned by the SDK session (Transport contract): transport-level errors worth logging; every fatal one also settles `closed`. */
167
+ onerror?: (error: Error) => void;
168
+ private readonly address;
169
+ private readonly token;
170
+ private readonly roles;
171
+ /** Answers the hub's `server/discover` (§11/§6, §20). `undefined` when the
172
+ * caller gave none — every `server/discover` then gets `-32601`, the same
173
+ * "capabilities unknown" a library that predates this method sends. */
174
+ private readonly discover;
175
+ private readonly settleClosed;
176
+ private readonly started;
177
+ private socket;
178
+ private attempt;
179
+ private stopped;
180
+ private running;
181
+ private pinger;
182
+ /**
183
+ * `url` is the hub's https origin — a bare origin, no path; anything else is a
184
+ * TypeError here, before any I/O. `token` is the `pmcp_svc_` credential the
185
+ * whole connection authenticates as. No network happens until start().
186
+ *
187
+ * These three and nothing else (§11). The reconnect policy's two observation
188
+ * seams are the module-level {@link seams}, not options here.
189
+ */
190
+ constructor(options: {
191
+ url: string;
192
+ token: string;
193
+ roles?: Roles;
194
+ /** Internal: serve()'s wiring for `server/discover` (§20). Not part of §11's
195
+ * three public options — a hand-rolled session that wants to answer it
196
+ * passes this directly; one that does not gets the `-32601` fallback. */
197
+ discover?: () => Record<string, unknown> | undefined;
198
+ });
199
+ /**
200
+ * Open the connection: upgrade, `hub/register`, then MCP traffic (Transport
201
+ * contract — the SDK calls this once). Resolves after the first successful
202
+ * registration; rejects only on a terminal state reached before then. Later
203
+ * disconnects are handled internally per the class policy.
204
+ */
205
+ start(): Promise<void>;
206
+ /**
207
+ * Send one MCP message to the hub (Transport contract). While the socket is
208
+ * down the message is dropped, never queued — the hub re-lists after every
209
+ * registration, so a dropped `notifications/tools/list_changed` heals itself,
210
+ * and responses to requests from a dead socket have no reader anyway.
211
+ */
212
+ send(message: JsonRpcMessage): Promise<void>;
213
+ /**
214
+ * Local, graceful shutdown (Transport contract; SIGTERM path): closes the
215
+ * socket, stops reconnecting, resolves `closed`. Idempotent.
216
+ */
217
+ close(): Promise<void>;
218
+ /** One transport is one service lifetime: this loop outlives every socket it opens. */
219
+ private loop;
220
+ /** One dial, from the upgrade to the ending — the only place the wire is touched. */
221
+ private connectOnce;
222
+ /**
223
+ * Answer the hub's registration-time `server/discover` (§6/§11/§20) — never
224
+ * forwarded to the SDK session. `undefined` capabilities means "unknown", the
225
+ * fallback that keeps every service predating this method warming tools only
226
+ * (§6); a real capability set is relayed exactly as the author's SDK reports
227
+ * it, in the same DiscoverResult shape the reverse direction (hub→consumer)
228
+ * uses, so both ends of the wire speak one envelope.
229
+ */
230
+ private answerDiscover;
231
+ private startPings;
232
+ private stopPings;
233
+ /** The quiet terminal state: replaced, or a local close(). Idempotent. */
234
+ private finish;
235
+ /** The fatal terminal state: a dead credential or a refused declaration. */
236
+ private fail;
237
+ }
238
+ /**
239
+ * wss://<host>/connect, DERIVED from the hub's origin — never passed in (§6). The scheme
240
+ * follows the origin's and is never downgraded: https → wss, and the http a local
241
+ * `wrangler dev` serves → ws. Anything but a bare origin is a TypeError before any I/O.
242
+ *
243
+ * Exported because the derivation is the one part of the handshake a pure test can see:
244
+ * a fixture hub speaks ws, so "https derives wss" is otherwise unobservable without TLS.
245
+ */
246
+ export declare function connectAddress(url: string): string;
247
+ /**
248
+ * The reconnect schedule as pure arithmetic — `attempt` (consecutive failures,
249
+ * 0-based) → delay in milliseconds. Doubling from 1 s to the 60 s cap, jitter
250
+ * drawn from `rng` (a [0,1) source; the loop passes Math.random, tests a seeded
251
+ * stub). Attempt 0 is jittered from zero, so a hub deploy's reconnect storm
252
+ * spreads out instead of every bot re-registering in the same second. Exported
253
+ * so doubling, cap, and jitter bounds are a table test, not a property of a
254
+ * live loop; HubTransport's reconnect loop is its only production caller.
255
+ */
256
+ export declare function backoffDelay(attempt: number, rng: () => number): number;
257
+ /**
258
+ * The hub-asserted caller of the current tool call (§7, "Caller identity
259
+ * forwarding"). `principal` is `"user:<name>"` or `"sa:<slug>"`. `roles` is the
260
+ * caller's granted role names on this service exactly as granted — the built-in
261
+ * wildcard arrives literally as `"all"` (owners get `["all"]`), never expanded
262
+ * into declared names. Informational for the service's own branching: the hub's
263
+ * grant check has already run, and these are not secrets.
264
+ */
265
+ export type CallerIdentity = {
266
+ principal: string;
267
+ roles: readonly string[];
268
+ /** True when `roles` contains `role` or `"all"` — so owner and all-granted callers behave identically, and `all` can never collide with a declared role name (the hub rejects declaring it). */
269
+ hasRole(role: string): boolean;
270
+ };
271
+ /**
272
+ * Read the caller identity off a forwarded request's `_meta` (`hub/principal`,
273
+ * `hub/roles`). Trustworthy for fine-grained service-side checks: the hub strips
274
+ * consumer-supplied `hub/*` keys before injecting its own, so a consumer cannot
275
+ * forge these (§7). On a request that never passed through the hub (e.g. local
276
+ * testing), the fields are simply absent: principal is `""`, roles is empty, and
277
+ * hasRole() is uniformly false — no error to handle.
278
+ */
279
+ export declare function caller(meta: Record<string, unknown> | undefined): CallerIdentity;
280
+ /**
281
+ * Mark one schema node secret — the zod-style spelling of §7's sensitive-field
282
+ * declaration, usable wherever a field type goes: `secret(z.string())` inside a
283
+ * tool's INPUT shape or its OUTPUT shape alike. Returns a derived schema (the
284
+ * input is untouched) whose emitted JSON Schema carries `writeOnly: true` at
285
+ * that node — the hub reads the marker in both directions and strips it from
286
+ * outputSchemas served to consumers (§7). Schema-only: runtime values validate
287
+ * and serialize exactly as the wrapped schema does — real values cross the wire,
288
+ * and the HUB masks before anything is persisted or shown (§15). A value that is
289
+ * neither is a TypeError, exactly as in {@link sensitive}: returning it unmarked
290
+ * would ship the secret with nothing to see.
291
+ */
292
+ export declare function secret<S>(schema: S): S;
293
+ /**
294
+ * Mark schema properties sensitive by path — the hand-written-schema spelling of
295
+ * §7's sensitive-field declaration, for authors not using zod shapes. Works on an
296
+ * input schema or an output schema alike: returns a copy of `schema` — the
297
+ * original is not
298
+ * mutated — with `writeOnly: true` (the standard JSON Schema keyword; no invented
299
+ * syntax) set at each dot-path in `paths`, e.g. "password" or
300
+ * "credentials.token". A path naming no property in the schema is a TypeError: a
301
+ * silent typo here would quietly persist a secret. Marking is all this does —
302
+ * redaction itself happens in the hub, before anything is stored or shown.
303
+ */
304
+ export declare function sensitive<S extends Record<string, unknown>>(schema: S, paths: string[]): S;
package/dist/index.js ADDED
@@ -0,0 +1,581 @@
1
+ // @personal-mcps/client — the service-author library (spec §6, §11). A plain MCP
2
+ // server object goes in; this module keeps it reachable through the hub's reverse
3
+ // tunnel.
4
+ //
5
+ // OWNS the client side of the reverse-connection protocol: deriving
6
+ // wss://<host>/connect from the hub's https origin, the hub/register handshake
7
+ // (re-sent on every (re)connect), the split between hub/* control frames and MCP
8
+ // traffic, WebSocket protocol pings (~25 s, no application heartbeat), and the
9
+ // entire disconnect policy — the close-code vocabulary 4000–4004 and the upgrade
10
+ // statuses 401/403 map onto exactly three behaviors (`stop_fatal`, `stop_quiet`,
11
+ // `reconnect`), with a reconnecting case additionally carrying a schedule
12
+ // (`exponential` or `max_only`), decided in HubTransport and nowhere else.
13
+ //
14
+ // HIDES the wire completely: the author never sees a socket, a JSON-RPC frame, or
15
+ // a reconnect. One deliberate absence: the library never buffers traffic for an
16
+ // offline hub — while disconnected the hub is already failing consumer calls with
17
+ // -32000, and outbound notifications are dropped (the hub re-lists tools after
18
+ // every registration, so a dropped list_changed heals itself).
19
+ //
20
+ // ABSORBED from scripts/thin-serve.ts (D7's verified slice): the derived address, the
21
+ // register ceremony, the control/MCP frame split, and the close-code table proven against
22
+ // the live hub. What the slice could not do and this module must — the upgrade matrix's
23
+ // 401-vs-403 split — is why the socket here is `ws` (§4 names it) rather than the platform
24
+ // global: a refused handshake arrives with its HTTP STATUS on `unexpected-response`, and
25
+ // the global WebSocket reports the same refusal as a bare error event.
26
+ import { WebSocket } from "ws";
27
+ /**
28
+ * The `hub/*` control-frame method names (contracts/tunnel-frames.json `methods`, the
29
+ * hub's own `HUB_METHODS` export). Exported because it is exactly the set this transport
30
+ * CONSUMES: a `hub/` method outside it is ordinary traffic and reaches the SDK session
31
+ * untouched, so a new control frame cannot be swallowed silently.
32
+ */
33
+ export const HUB_METHODS = { register: "hub/register", replaced: "hub/replaced" };
34
+ /** The pinned MCP revision of the tunnel wire (contracts/tunnel-frames.json). */
35
+ export const PROTOCOL_VERSION = "2026-07-28";
36
+ /**
37
+ * The registration-time capability question (§6/§11, added §20) — plain MCP
38
+ * namespace, not `hub/`-prefixed, but answered by this library rather than
39
+ * bridged to the SDK session: no MCP SDK implements it, and this library is
40
+ * what knows which families the author actually registered.
41
+ */
42
+ const DISCOVER_METHOD = "server/discover";
43
+ /** What `clientVersion` reports on the register frame — a free string in the fixture. */
44
+ const CLIENT_VERSION = "@personal-mcps/client/0";
45
+ /** The wire id of the one request this library ever originates. */
46
+ const REGISTER_ID = "hub-register-1";
47
+ /** §6's reconnect schedule: doubling from 1 s to a 60 s cap, jittered from zero. */
48
+ const BACKOFF_BASE_MS = 1_000;
49
+ const BACKOFF_CAP_MS = 60_000;
50
+ /** The first attempt whose ceiling is clamped to the cap — the `max_only` schedule's whole
51
+ * content: the window stops doubling and stays at the cap (never a floor under the wait). */
52
+ const MAX_ONLY_ATTEMPT = 6;
53
+ /** §6 — liveness is WebSocket PROTOCOL pings at this cadence; there is no application heartbeat. */
54
+ const PING_INTERVAL_MS = 25_000;
55
+ /**
56
+ * The two seams the reconnect policy is otherwise unobservable through — the jitter draw
57
+ * and the wait itself — as MODULE state the loop calls by name, never as constructor
58
+ * options. Both are production concerns rather than test workarounds: the schedule is full
59
+ * jitter, so `max_only` and `exponential` overlap at every attempt and are told apart only
60
+ * at a FIXED draw, and a suite that waited out a real 60 s window is a suite nobody runs.
61
+ *
62
+ * Here rather than on the constructor because §11 promises `{url, token, roles}` and
63
+ * nothing else: an author reading `new HubTransport(…)` should not have to learn about
64
+ * jitter injection to use the three options that matter. This is the same spelling the
65
+ * Python twin uses (`pmcp_client._rng` / `._sleep`, replaced with monkeypatch), so the two
66
+ * libraries state one contract in one shape; a suite replaces these members and restores
67
+ * them afterwards.
68
+ */
69
+ export const seams = {
70
+ rng: () => Math.random(),
71
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
72
+ };
73
+ /**
74
+ * The credential is dead: 401 at upgrade or close 4001 after establishment —
75
+ * revoked/expired token, wrong token kind, or deleted service. Terminal; the
76
+ * library never retries a dead credential (§6's upgrade matrix).
77
+ */
78
+ export class CredentialsError extends Error {
79
+ }
80
+ /**
81
+ * The hub rejected the `hub/register` role declaration (bad role name,
82
+ * non-compiling pattern, over caps — §6). Terminal: identical input cannot start
83
+ * succeeding, so this is surfaced immediately instead of retried.
84
+ */
85
+ export class RegistrationError extends Error {
86
+ }
87
+ /**
88
+ * Run `server` as a tunneled hub service: dial, register the role declaration,
89
+ * and stay reachable until the hub says otherwise. The returned promise pends for
90
+ * the life of the service — hours to months; treat it as the bot's main loop.
91
+ *
92
+ * Terminal outcomes are the whole resolution contract:
93
+ * - resolves quietly when the hub replaces this connection with a newer one for
94
+ * the same service (`hub/replaced`, close 4000) — this copy steps aside and
95
+ * never reconnects (§6: two copies fighting for the slot is an operator error
96
+ * worth surfacing);
97
+ * - rejects with CredentialsError or RegistrationError (see those types);
98
+ * - every other failure — network drop, hub deploy, registration deadline (4004),
99
+ * archived service (403 at upgrade / close 4002) — reconnects forever and never
100
+ * resolves. The full policy lives on HubTransport.
101
+ *
102
+ * Reconnects re-register and re-warm the hub's tool cache automatically; nothing
103
+ * is buffered while offline (module header).
104
+ */
105
+ export async function serve(server, options) {
106
+ // deps: HubTransport · @modelcontextprotocol/server (Server.connect)
107
+ // Options resolve BEFORE any I/O: an empty token dialed anyway comes back as upgrade
108
+ // 401, turning a local config mistake into a revoked-token diagnosis (§10, §11).
109
+ const url = options?.url ?? process.env.PMCP_URL;
110
+ const token = options?.token ?? process.env.PMCP_SERVICE_TOKEN;
111
+ if (url === undefined || url === "")
112
+ throw new TypeError("no hub url: pass options.url or set PMCP_URL");
113
+ if (token === undefined || token === "") {
114
+ throw new TypeError("no service token: pass options.token or set PMCP_SERVICE_TOKEN");
115
+ }
116
+ const transport = new HubTransport({ url, token, roles: options?.roles, discover: () => probeCapabilities(server) });
117
+ // The SDK session owns the handshake and calls start() itself.
118
+ await server.connect(transport);
119
+ await transport.closed;
120
+ }
121
+ /**
122
+ * The author's declared capabilities, read the one way §11 sanctions: the SDK's
123
+ * own optional `getCapabilities()`, never guessed from what this library can
124
+ * carry. Absent — every service already in the field — is `undefined`, which
125
+ * HubTransport answers `server/discover` with a `-32601` for: "capabilities
126
+ * unknown", the hub's documented fallback (§6), not a fabricated empty set
127
+ * (§20.5: an empty *answer* is an undeclare and clears a catalog; a missing
128
+ * answer is not).
129
+ */
130
+ function probeCapabilities(server) {
131
+ return typeof server.getCapabilities === "function" ? server.getCapabilities() : undefined;
132
+ }
133
+ /**
134
+ * The Transport bridge: implements the MCP SDK `Transport` interface (from
135
+ * `@modelcontextprotocol/server` v2) over an outbound `ws` WebSocket to
136
+ * wss://<host>/connect. serve() is sugar over this; construct it directly only to
137
+ * wire the SDK session yourself.
138
+ *
139
+ * One transport is one service lifetime, not one socket: reconnects — jittered
140
+ * exponential backoff, 1 s → 60 s cap, forever (hub deploys sever every socket,
141
+ * so this is routine; the schedule itself is backoffDelay, exported pure) —
142
+ * happen inside, invisible to the SDK session, and
143
+ * `hub/register` is re-sent on every (re)connect. `onclose` fires once, at a
144
+ * terminal state only.
145
+ *
146
+ * The disconnect policy, decided here and nowhere else (§6):
147
+ * - 401 at upgrade, close 4001 — dead credential: terminal, CredentialsError.
148
+ * - 403 at upgrade, close 4002 — archived: keep retrying at max backoff, so
149
+ * unarchiving heals within a minute without touching the bot.
150
+ * - close 4000 (after `hub/replaced`) — a newer connection took the slot: stop
151
+ * quietly, never reconnect.
152
+ * - register rejected — terminal, RegistrationError.
153
+ * - everything else (network, deploy, close 4003/4004) — reconnect with backoff;
154
+ * a truly deleted service becomes 401 at the next upgrade, the fatal path.
155
+ *
156
+ * `hub/*` control frames never reach the SDK session; everything else is MCP
157
+ * traffic, one JSON-RPC message per WS text frame. Liveness is WebSocket protocol
158
+ * pings (~25 s); there is no application heartbeat.
159
+ */
160
+ export class HubTransport {
161
+ /**
162
+ * Settles when the transport reaches a terminal state: resolves after
163
+ * `hub/replaced` (close 4000) or a local close(); rejects with
164
+ * CredentialsError / RegistrationError. Never settles on an ordinary
165
+ * disconnect — those reconnect. serve() awaits exactly this.
166
+ */
167
+ closed;
168
+ /** Assigned by the SDK session (Transport contract): called once per inbound MCP message; hub/* control frames are consumed internally and never appear here. */
169
+ onmessage;
170
+ /** Assigned by the SDK session (Transport contract): fired once, at the terminal state only — reconnects are invisible. */
171
+ onclose;
172
+ /** Assigned by the SDK session (Transport contract): transport-level errors worth logging; every fatal one also settles `closed`. */
173
+ onerror;
174
+ address;
175
+ token;
176
+ roles;
177
+ /** Answers the hub's `server/discover` (§11/§6, §20). `undefined` when the
178
+ * caller gave none — every `server/discover` then gets `-32601`, the same
179
+ * "capabilities unknown" a library that predates this method sends. */
180
+ discover;
181
+ settleClosed;
182
+ started = deferred();
183
+ socket = null;
184
+ attempt = 0;
185
+ stopped = false;
186
+ running = false;
187
+ pinger = null;
188
+ /**
189
+ * `url` is the hub's https origin — a bare origin, no path; anything else is a
190
+ * TypeError here, before any I/O. `token` is the `pmcp_svc_` credential the
191
+ * whole connection authenticates as. No network happens until start().
192
+ *
193
+ * These three and nothing else (§11). The reconnect policy's two observation
194
+ * seams are the module-level {@link seams}, not options here.
195
+ */
196
+ constructor(options) {
197
+ // deps: none
198
+ this.address = connectAddress(options.url);
199
+ this.token = options.token;
200
+ this.roles = options.roles ?? {};
201
+ this.discover = options.discover ?? (() => undefined);
202
+ const closed = deferred();
203
+ this.closed = closed.promise;
204
+ this.settleClosed = { resolve: closed.resolve, reject: closed.reject };
205
+ // The terminal state is settled by the loop, not necessarily awaited by the caller:
206
+ // marking it handled keeps a fatal ending from becoming an unhandled rejection.
207
+ void this.closed.catch(() => { });
208
+ }
209
+ /**
210
+ * Open the connection: upgrade, `hub/register`, then MCP traffic (Transport
211
+ * contract — the SDK calls this once). Resolves after the first successful
212
+ * registration; rejects only on a terminal state reached before then. Later
213
+ * disconnects are handled internally per the class policy.
214
+ */
215
+ async start() {
216
+ // deps: ws
217
+ if (!this.running) {
218
+ this.running = true;
219
+ void this.loop();
220
+ }
221
+ return this.started.promise;
222
+ }
223
+ /**
224
+ * Send one MCP message to the hub (Transport contract). While the socket is
225
+ * down the message is dropped, never queued — the hub re-lists after every
226
+ * registration, so a dropped `notifications/tools/list_changed` heals itself,
227
+ * and responses to requests from a dead socket have no reader anyway.
228
+ */
229
+ async send(message) {
230
+ // deps: ws
231
+ const socket = this.socket;
232
+ if (socket === null || socket.readyState !== WebSocket.OPEN)
233
+ return;
234
+ try {
235
+ socket.send(JSON.stringify(message));
236
+ }
237
+ catch {
238
+ // A send onto a dying socket is not a failure of whatever was being answered.
239
+ }
240
+ }
241
+ /**
242
+ * Local, graceful shutdown (Transport contract; SIGTERM path): closes the
243
+ * socket, stops reconnecting, resolves `closed`. Idempotent.
244
+ */
245
+ async close() {
246
+ // deps: ws
247
+ this.finish();
248
+ }
249
+ // ── the connection lifetime ───────────────────────────────────────────────────────────
250
+ /** One transport is one service lifetime: this loop outlives every socket it opens. */
251
+ async loop() {
252
+ while (!this.stopped) {
253
+ const ending = await this.connectOnce();
254
+ if (this.stopped)
255
+ return;
256
+ if (ending.behavior === "stop_quiet")
257
+ return this.finish();
258
+ if (ending.behavior === "stop_fatal")
259
+ return this.fail(ending.error);
260
+ // A reconnecting ending never settles `closed` and never fires onclose — the SDK
261
+ // session must not learn that the socket flapped.
262
+ const attempt = ending.schedule === "max_only" ? MAX_ONLY_ATTEMPT : this.attempt++;
263
+ // By NAME through `seams`, so the member a test replaced is the one this call reads.
264
+ await seams.sleep(backoffDelay(attempt, seams.rng));
265
+ }
266
+ }
267
+ /** One dial, from the upgrade to the ending — the only place the wire is touched. */
268
+ connectOnce() {
269
+ return new Promise((resolve) => {
270
+ // The credential rides `Authorization: Bearer` and nowhere else: no query string,
271
+ // no subprotocol (§6, §18 d13).
272
+ const socket = new WebSocket(this.address, { headers: { Authorization: `Bearer ${this.token}` } });
273
+ this.socket = socket;
274
+ let settled = false;
275
+ const end = (ending) => {
276
+ if (settled)
277
+ return;
278
+ settled = true;
279
+ this.stopPings();
280
+ socket.removeAllListeners();
281
+ // Tearing down a socket that never established makes `ws` emit one more error;
282
+ // with no listener left, an EventEmitter turns that into an uncaught exception —
283
+ // so the last listener standing swallows it.
284
+ socket.on("error", () => { });
285
+ try {
286
+ socket.terminate();
287
+ }
288
+ catch {
289
+ // already gone
290
+ }
291
+ if (this.socket === socket)
292
+ this.socket = null;
293
+ resolve(ending);
294
+ };
295
+ // A refused upgrade is an HTTP STATUS, and it is the whole 401-vs-403 split: this
296
+ // event is the reason the library holds a `ws` socket instead of the global one.
297
+ socket.on("unexpected-response", (_request, response) => {
298
+ end(endingForUpgrade(response?.statusCode ?? 0));
299
+ });
300
+ socket.on("open", () => {
301
+ this.attempt = 0;
302
+ this.startPings(socket);
303
+ // `hub/register` is re-sent on EVERY (re)connect, and carries no service or slug:
304
+ // identity comes from the token alone.
305
+ void this.send({
306
+ jsonrpc: "2.0",
307
+ id: REGISTER_ID,
308
+ method: HUB_METHODS.register,
309
+ params: { clientVersion: CLIENT_VERSION, protocolVersion: PROTOCOL_VERSION, roles: this.roles },
310
+ });
311
+ });
312
+ socket.on("message", (data) => {
313
+ const frame = parseFrame(data);
314
+ if (frame === null)
315
+ return;
316
+ if (frame.id === REGISTER_ID && (frame.result !== undefined || frame.error !== undefined)) {
317
+ if (frame.error !== undefined) {
318
+ return end({
319
+ behavior: "stop_fatal",
320
+ error: new RegistrationError(`hub/register rejected: ${messageOf(frame.error)}`),
321
+ });
322
+ }
323
+ this.started.resolve();
324
+ return;
325
+ }
326
+ // The two control frames are consumed here; every other method — `hub/` prefixed
327
+ // or not — is ordinary MCP traffic for the session.
328
+ if (frame.method === HUB_METHODS.replaced)
329
+ return;
330
+ // §11/§6: the one MCP-namespace method this library answers itself. The author's
331
+ // SDK never sees it — no SDK implements it, and this library is what knows which
332
+ // families were actually registered.
333
+ if (frame.method === DISCOVER_METHOD)
334
+ return this.answerDiscover(frame);
335
+ this.onmessage?.(frame);
336
+ });
337
+ socket.on("error", (error) => {
338
+ this.onerror?.(new Error(`hub connection failed: ${error?.message ?? "unknown"}`));
339
+ });
340
+ socket.on("close", (code) => end(endingForClose(code)));
341
+ });
342
+ }
343
+ /**
344
+ * Answer the hub's registration-time `server/discover` (§6/§11/§20) — never
345
+ * forwarded to the SDK session. `undefined` capabilities means "unknown", the
346
+ * fallback that keeps every service predating this method warming tools only
347
+ * (§6); a real capability set is relayed exactly as the author's SDK reports
348
+ * it, in the same DiscoverResult shape the reverse direction (hub→consumer)
349
+ * uses, so both ends of the wire speak one envelope.
350
+ */
351
+ answerDiscover(frame) {
352
+ const capabilities = this.discover();
353
+ if (capabilities === undefined) {
354
+ void this.send({
355
+ jsonrpc: "2.0",
356
+ id: frame.id,
357
+ error: { code: -32601, message: "server/discover not implemented" },
358
+ });
359
+ return;
360
+ }
361
+ void this.send({
362
+ jsonrpc: "2.0",
363
+ id: frame.id,
364
+ result: { supportedVersions: [PROTOCOL_VERSION], capabilities, resultType: "complete" },
365
+ });
366
+ }
367
+ startPings(socket) {
368
+ this.stopPings();
369
+ this.pinger = setInterval(() => {
370
+ try {
371
+ socket.ping();
372
+ }
373
+ catch {
374
+ // the close handler owns a dead socket
375
+ }
376
+ }, PING_INTERVAL_MS);
377
+ }
378
+ stopPings() {
379
+ if (this.pinger !== null)
380
+ clearInterval(this.pinger);
381
+ this.pinger = null;
382
+ }
383
+ /** The quiet terminal state: replaced, or a local close(). Idempotent. */
384
+ finish() {
385
+ if (this.stopped)
386
+ return;
387
+ this.stopped = true;
388
+ this.stopPings();
389
+ try {
390
+ this.socket?.close(1000, "client shutdown");
391
+ }
392
+ catch {
393
+ // already closed
394
+ }
395
+ this.socket = null;
396
+ this.started.resolve();
397
+ this.settleClosed.resolve();
398
+ this.onclose?.();
399
+ }
400
+ /** The fatal terminal state: a dead credential or a refused declaration. */
401
+ fail(error) {
402
+ if (this.stopped)
403
+ return;
404
+ this.stopped = true;
405
+ this.stopPings();
406
+ this.socket = null;
407
+ this.onerror?.(error);
408
+ this.started.reject(error);
409
+ this.settleClosed.reject(error);
410
+ this.onclose?.();
411
+ }
412
+ }
413
+ /**
414
+ * wss://<host>/connect, DERIVED from the hub's origin — never passed in (§6). The scheme
415
+ * follows the origin's and is never downgraded: https → wss, and the http a local
416
+ * `wrangler dev` serves → ws. Anything but a bare origin is a TypeError before any I/O.
417
+ *
418
+ * Exported because the derivation is the one part of the handshake a pure test can see:
419
+ * a fixture hub speaks ws, so "https derives wss" is otherwise unobservable without TLS.
420
+ */
421
+ export function connectAddress(url) {
422
+ const origin = new URL(url);
423
+ if (origin.pathname !== "/" || origin.search !== "" || origin.hash !== "") {
424
+ throw new TypeError(`expected a bare hub origin, got ${url}`);
425
+ }
426
+ if (origin.protocol !== "https:" && origin.protocol !== "http:") {
427
+ throw new TypeError(`expected an http(s) hub origin, got ${url}`);
428
+ }
429
+ return `${origin.protocol === "https:" ? "wss:" : "ws:"}//${origin.host}/connect`;
430
+ }
431
+ /**
432
+ * A refused upgrade → its behavior. Only 401 is fatal; 403 is archived and heals by
433
+ * retrying; every other status (500 from an edge failure, and anything §6 never mentions)
434
+ * reconnects, so a transient outage never strands a fleet of bots.
435
+ */
436
+ function endingForUpgrade(status) {
437
+ if (status === 401) {
438
+ // The message names the status, never the credential (§15).
439
+ return { behavior: "stop_fatal", error: new CredentialsError("the hub refused the service credential (401)") };
440
+ }
441
+ if (status === 403)
442
+ return { behavior: "reconnect", schedule: "max_only" };
443
+ return { behavior: "reconnect", schedule: "exponential" };
444
+ }
445
+ /** A close code → its behavior. Unknown means reconnect — the safe default (§6). */
446
+ function endingForClose(code) {
447
+ if (code === 4000)
448
+ return { behavior: "stop_quiet" };
449
+ if (code === 4001) {
450
+ return { behavior: "stop_fatal", error: new CredentialsError("the hub severed the connection (close 4001)") };
451
+ }
452
+ if (code === 4002)
453
+ return { behavior: "reconnect", schedule: "max_only" };
454
+ return { behavior: "reconnect", schedule: "exponential" };
455
+ }
456
+ /** One text frame as a JSON object, or null for anything else (binary included). */
457
+ function parseFrame(data) {
458
+ try {
459
+ const parsed = JSON.parse(String(data));
460
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : null;
461
+ }
462
+ catch {
463
+ return null;
464
+ }
465
+ }
466
+ function messageOf(value) {
467
+ if (value instanceof Error)
468
+ return value.message;
469
+ if (typeof value === "object" && value !== null) {
470
+ const message = value.message;
471
+ if (typeof message === "string")
472
+ return message;
473
+ }
474
+ return JSON.stringify(value) ?? "unknown";
475
+ }
476
+ /** `Promise.withResolvers` in three lines — the ES2024 builtin is outside this repo's `lib`. */
477
+ function deferred() {
478
+ let resolve;
479
+ let reject;
480
+ const promise = new Promise((res, rej) => {
481
+ resolve = res;
482
+ reject = rej;
483
+ });
484
+ return { promise, resolve, reject };
485
+ }
486
+ /**
487
+ * The reconnect schedule as pure arithmetic — `attempt` (consecutive failures,
488
+ * 0-based) → delay in milliseconds. Doubling from 1 s to the 60 s cap, jitter
489
+ * drawn from `rng` (a [0,1) source; the loop passes Math.random, tests a seeded
490
+ * stub). Attempt 0 is jittered from zero, so a hub deploy's reconnect storm
491
+ * spreads out instead of every bot re-registering in the same second. Exported
492
+ * so doubling, cap, and jitter bounds are a table test, not a property of a
493
+ * live loop; HubTransport's reconnect loop is its only production caller.
494
+ */
495
+ export function backoffDelay(attempt, rng) {
496
+ // deps: none
497
+ // The cap applies to the CEILING, before the draw — so no delay can exceed it, and the
498
+ // window still starts at zero at every attempt.
499
+ return rng() * Math.min(BACKOFF_CAP_MS, BACKOFF_BASE_MS * 2 ** attempt);
500
+ }
501
+ /**
502
+ * Read the caller identity off a forwarded request's `_meta` (`hub/principal`,
503
+ * `hub/roles`). Trustworthy for fine-grained service-side checks: the hub strips
504
+ * consumer-supplied `hub/*` keys before injecting its own, so a consumer cannot
505
+ * forge these (§7). On a request that never passed through the hub (e.g. local
506
+ * testing), the fields are simply absent: principal is `""`, roles is empty, and
507
+ * hasRole() is uniformly false — no error to handle.
508
+ */
509
+ export function caller(meta) {
510
+ // deps: none
511
+ const principal = meta?.["hub/principal"];
512
+ const granted = meta?.["hub/roles"];
513
+ const roles = Array.isArray(granted) ? granted.filter((role) => typeof role === "string") : [];
514
+ return {
515
+ principal: typeof principal === "string" ? principal : "",
516
+ roles,
517
+ hasRole: (role) => roles.includes(role) || roles.includes("all"),
518
+ };
519
+ }
520
+ /**
521
+ * Mark one schema node secret — the zod-style spelling of §7's sensitive-field
522
+ * declaration, usable wherever a field type goes: `secret(z.string())` inside a
523
+ * tool's INPUT shape or its OUTPUT shape alike. Returns a derived schema (the
524
+ * input is untouched) whose emitted JSON Schema carries `writeOnly: true` at
525
+ * that node — the hub reads the marker in both directions and strips it from
526
+ * outputSchemas served to consumers (§7). Schema-only: runtime values validate
527
+ * and serialize exactly as the wrapped schema does — real values cross the wire,
528
+ * and the HUB masks before anything is persisted or shown (§15). A value that is
529
+ * neither is a TypeError, exactly as in {@link sensitive}: returning it unmarked
530
+ * would ship the secret with nothing to see.
531
+ */
532
+ export function secret(schema) {
533
+ // deps: none
534
+ // A zod schema carries `.describe()`/`.meta()`; a plain JSON Schema node is an object.
535
+ // Both are marked the same way — a DERIVED value with writeOnly at this node — because
536
+ // the hub reads the emitted JSON Schema either way.
537
+ const node = schema;
538
+ if (typeof node === "object" && node !== null) {
539
+ const zod = node;
540
+ if (typeof zod.meta === "function")
541
+ return zod.meta({ writeOnly: true });
542
+ return { ...node, writeOnly: true };
543
+ }
544
+ // The same failure posture as sensitive(), for the same reason: a value that cannot be
545
+ // marked, returned unchanged, ships the field unmarked and tells the author nothing.
546
+ throw new TypeError("secret(): cannot mark this value — expected a zod schema or a JSON Schema object");
547
+ }
548
+ /**
549
+ * Mark schema properties sensitive by path — the hand-written-schema spelling of
550
+ * §7's sensitive-field declaration, for authors not using zod shapes. Works on an
551
+ * input schema or an output schema alike: returns a copy of `schema` — the
552
+ * original is not
553
+ * mutated — with `writeOnly: true` (the standard JSON Schema keyword; no invented
554
+ * syntax) set at each dot-path in `paths`, e.g. "password" or
555
+ * "credentials.token". A path naming no property in the schema is a TypeError: a
556
+ * silent typo here would quietly persist a secret. Marking is all this does —
557
+ * redaction itself happens in the hub, before anything is stored or shown.
558
+ */
559
+ export function sensitive(schema, paths) {
560
+ // deps: none
561
+ const copy = structuredClone(schema);
562
+ for (const path of paths)
563
+ mark(copy, path.split("."), path);
564
+ return copy;
565
+ }
566
+ /** Sets `writeOnly` at one dot-path of a JSON Schema object, refusing an absent property. */
567
+ function mark(node, segments, path) {
568
+ const properties = node.properties;
569
+ if (typeof properties !== "object" || properties === null) {
570
+ throw new TypeError(`sensitive(): "${path}" names no property in this schema`);
571
+ }
572
+ const [head, ...rest] = segments;
573
+ const child = properties[head];
574
+ if (typeof child !== "object" || child === null) {
575
+ throw new TypeError(`sensitive(): "${path}" names no property in this schema`);
576
+ }
577
+ if (rest.length === 0)
578
+ child.writeOnly = true;
579
+ else
580
+ mark(child, rest, path);
581
+ }
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@ahrzb/personal-mcp-client",
3
+ "version": "0.1.0",
4
+ "description": "Keep an MCP server reachable through a personal-mcps hub's reverse tunnel.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/ahrzb/personal-mcps.git",
8
+ "directory": "clients/js"
9
+ },
10
+ "type": "module",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "default": "./dist/index.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist"
19
+ ],
20
+ "scripts": {
21
+ "prepublishOnly": "tsc -p tsconfig.build.json"
22
+ },
23
+ "dependencies": {
24
+ "ws": "^8.21.3"
25
+ },
26
+ "publishConfig": {
27
+ "access": "public"
28
+ }
29
+ }