@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.
- package/dist/index.d.ts +304 -0
- package/dist/index.js +581 -0
- package/package.json +29 -0
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|