@saihm/mcp-server-pro 0.1.3 → 0.1.5

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/README.md CHANGED
@@ -17,13 +17,61 @@ Production thin-client for **SAIHM non-custodial memory**.
17
17
  npm install @saihm/mcp-server-pro
18
18
  ```
19
19
 
20
- ## Usage
20
+ ## Run as an MCP server
21
+
22
+ The package ships a stdio MCP server. Point your MCP host (Claude Desktop,
23
+ Claude Code, …) at it — paste this **once**:
24
+
25
+ ```jsonc
26
+ {
27
+ "mcpServers": {
28
+ "saihm": {
29
+ "command": "npx",
30
+ "args": ["-y", "@saihm/mcp-server-pro"],
31
+ "env": {
32
+ "SAIHM_ENDPOINT_URL": "https://saihm.coti.global/mcp",
33
+ "SAIHM_MASTER_SECRET_HEX": "<your 64+ hex master secret>",
34
+ "SAIHM_TIER": "PRO",
35
+ "SAIHM_PAYMENT_METHOD": "stripe"
36
+ }
37
+ }
38
+ }
39
+ }
40
+ ```
41
+
42
+ With no `SAIHM_AUTH_HEADER`, the server **self-onboards**: it mints and
43
+ auto-refreshes its own short-lived access token from your master secret, so
44
+ there is no token to paste or re-paste. Eight tools are exposed
45
+ (`saihm_remember`, `saihm_recall`, `saihm_forget`, `saihm_status`,
46
+ `saihm_share`, `saihm_revoke_share`, `saihm_governance_propose`,
47
+ `saihm_governance_vote`).
48
+
49
+ ### Self-serve join
50
+
51
+ To subscribe an identity from the command line instead of the website, run the
52
+ one-off `join` command with the same env:
53
+
54
+ ```sh
55
+ SAIHM_ENDPOINT_URL=https://saihm.coti.global/mcp \
56
+ SAIHM_MASTER_SECRET_HEX=<your 64+ hex master secret> \
57
+ SAIHM_TIER=PRO SAIHM_PAYMENT_METHOD=stripe \
58
+ npx -y @saihm/mcp-server-pro join
59
+ ```
60
+
61
+ It prints a Stripe checkout link bound to your identity. Pay in a browser, then
62
+ start the server normally (drop `join`) — it connects automatically. Keep
63
+ `SAIHM_MASTER_SECRET_HEX` safe: it is the only key to your memory and cannot be
64
+ recovered.
65
+
66
+ ## Use as a library
21
67
 
22
68
  ```ts
23
69
  import { SaihmProClient } from '@saihm/mcp-server-pro';
24
70
 
25
- // Boot from env: SAIHM_ENDPOINT_URL, SAIHM_AUTH_HEADER, SAIHM_MASTER_SECRET_HEX
26
- // (optional: SAIHM_TIER, SAIHM_SEQ_STATE_PATH)
71
+ // Boot from env: SAIHM_ENDPOINT_URL, SAIHM_MASTER_SECRET_HEX
72
+ // self-onboard (recommended): + SAIHM_PAYMENT_METHOD + SAIHM_TIER (omit SAIHM_AUTH_HEADER)
73
+ // static token (advanced): + SAIHM_AUTH_HEADER="Bearer <JWT>"
74
+ // (optional: SAIHM_SEQ_STATE_PATH)
27
75
  const saihm = SaihmProClient.bootFromEnv();
28
76
 
29
77
  // Store — encrypted before it leaves the process.
@@ -46,8 +94,8 @@ await saihm.forget(cellId);
46
94
  // out-of-band; the library rejects directory key-substitution.
47
95
  await saihm.share({
48
96
  cellId,
49
- recipientRecord, // the grantee's published identity record (hex)
50
- recipientPinnedAgentIdHashHex, // pinned out-of-band
97
+ recipientRecord, // the grantee's published identity record (hex)
98
+ recipientPinnedAgentIdHashHex, // pinned out-of-band
51
99
  });
52
100
  await saihm.revokeShare(cellId, recipientPinnedAgentIdHashHex);
53
101
 
@@ -55,8 +103,8 @@ await saihm.revokeShare(cellId, recipientPinnedAgentIdHashHex);
55
103
  // sharer's agentIdHash out-of-band; the library verifies the sharer's signature and
56
104
  // returns null when there is no live grant (e.g. revoked, or the sharer crypto-shredded it).
57
105
  const shared = await saihm.recallShared({
58
- sharerPinnedAgentIdHashHex, // the sharer's agentIdHash, pinned out-of-band
59
- sharerRecord, // the sharer's published identity record (hex)
106
+ sharerPinnedAgentIdHashHex, // the sharer's agentIdHash, pinned out-of-band
107
+ sharerRecord, // the sharer's published identity record (hex)
60
108
  cellId,
61
109
  });
62
110
  console.log(shared?.plaintext);
@@ -65,32 +113,39 @@ console.log(shared?.plaintext);
65
113
  const status = await saihm.status();
66
114
  ```
67
115
 
68
- The derived `saihm.agentIdHash` must match the `sub` of the JWT in `SAIHM_AUTH_HEADER`; publish `saihm.identityRecord` so other agents can share to you.
116
+ The derived `saihm.agentIdHash` is the `sub` the endpoint binds your tenant to — when self-onboarding the client proves it via ML-DSA; with a static `SAIHM_AUTH_HEADER` it must equal the JWT `sub`. Publish `saihm.identityRecord` so other agents can share to you.
69
117
 
70
118
  ## Configuration
71
119
 
72
- | Env | Required | Meaning |
73
- |---|---|---|
74
- | `SAIHM_ENDPOINT_URL` | yes | `https://…/mcp` (or `http://` only for `127.0.0.1`/`localhost`). |
75
- | `SAIHM_AUTH_HEADER` | yes | `Bearer <JWT>`; the endpoint binds your tenant from the JWT `sub`. |
76
- | `SAIHM_MASTER_SECRET_HEX` | yes | 64 hex chars ( 32 bytes), high-entropy, client-held; never sent. |
77
- | `SAIHM_TIER` | no | Tier label baked into sealed metadata; resolved via `status()` if unset. |
78
- | `SAIHM_SEQ_STATE_PATH` | no | Persists per-cell sequence high-water marks (mode 600) for cross-restart updates. |
120
+ | Env | Required | Meaning |
121
+ | ------------------------- | -------- | --------------------------------------------------------------------------------- |
122
+ | `SAIHM_ENDPOINT_URL` | yes | `https://…/mcp` (or `http://` only for `127.0.0.1`/`localhost`). |
123
+ | `SAIHM_AUTH_HEADER` | no | `Bearer <JWT>`, used verbatim. **Omit to self-onboard** (recommended): the client mints + auto-refreshes its own short-lived JWT from the master secret, so you paste one config once and never re-paste a token. |
124
+ | `SAIHM_PAYMENT_METHOD` | self-onboard only | Your entitlement rail (e.g. `stripe`). Required when `SAIHM_AUTH_HEADER` is unset; ignored otherwise. |
125
+ | `SAIHM_MASTER_SECRET_HEX` | yes\* | 64 hex chars (≥ 32 bytes), high-entropy, client-held; never sent. \*Provide this **or** `SAIHM_MASTER_SECRET_FILE`. |
126
+ | `SAIHM_MASTER_SECRET_FILE`| yes\* | Path to a **mode-600** file holding the hex master secret. Preferred for operators: keeps the root seed out of a synced/shared MCP config. Takes precedence over `SAIHM_MASTER_SECRET_HEX` when both are set. |
127
+ | `SAIHM_TIER` | self-onboard only | Tier label baked into sealed metadata. Required when self-onboarding; otherwise optional — resolved via `status()` if unset. |
128
+ | `SAIHM_SEQ_STATE_PATH` | no | Persists per-cell sequence high-water marks (mode 600) for cross-restart updates. |
129
+
130
+ > **Self-onboarding (paste once):** with `SAIHM_AUTH_HEADER` unset, the client proves
131
+ > control of your identity via the endpoint's ML-DSA challenge/response and mints its own
132
+ > token, refreshing transparently on expiry. Cancelling your subscription stops the next
133
+ > refresh, so access ends naturally.
79
134
 
80
135
  ## Errors
81
136
 
82
- Non-2xx responses throw `SaihmEndpointError` with `status` and a typed `code` (e.g. `BLIND_NO_FREE_TIER`, `BLIND_BAD_EXPIRY`, `governance_unavailable`). Branch on those rather than the message.
137
+ Non-2xx responses throw `SaihmEndpointError` with `status` and a typed `code` (e.g. `BLIND_BAD_EXPIRY`, `BLIND_STALE_SEQ`, `governance_unavailable`). Branch on those rather than the message.
83
138
 
84
139
  ## Security model
85
140
 
86
- | Property | Guarantee |
87
- |---|---|
88
- | Confidentiality vs the endpoint | The endpoint holds ciphertext + wrapped DEKs + public keys only; no key able to decrypt. |
89
- | Integrity / authenticity | Every cell is ML-DSA-65-signed over its contents, including the sequence number. |
90
- | Anti-replay | The signed monotonic sequence is rejected by the endpoint if not strictly increasing. |
91
- | Tenant isolation | Your `agentIdHash` (= the JWT `sub`) namespaces your state; a write whose signed identity differs from the JWT is rejected. |
92
- | Authenticated sharing | Grantee public keys are pinned out-of-band and verified before any secret is bound to them; on the recipient side, `recallShared` pins the sharer's key and verifies the cell signature before returning any plaintext. |
93
- | Erasure | Destroying the endpoint-side wrapped DEK crypto-shreds the cell. |
141
+ | Property | Guarantee |
142
+ | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
143
+ | Confidentiality vs the endpoint | The endpoint holds ciphertext + wrapped DEKs + public keys only; no key able to decrypt. |
144
+ | Integrity / authenticity | Every cell is ML-DSA-65-signed over its contents, including the sequence number. |
145
+ | Anti-replay | The signed monotonic sequence is rejected by the endpoint if not strictly increasing. |
146
+ | Tenant isolation | Your `agentIdHash` (= the JWT `sub`) namespaces your state; a write whose signed identity differs from the JWT is rejected. |
147
+ | Authenticated sharing | Grantee public keys are pinned out-of-band and verified before any secret is bound to them; on the recipient side, `recallShared` pins the sharer's key and verifies the cell signature before returning any plaintext. |
148
+ | Erasure | Destroying the endpoint-side wrapped DEK crypto-shreds the cell. |
94
149
 
95
150
  ## Where sealed cells are stored
96
151
 
package/dist/client.d.ts CHANGED
@@ -68,21 +68,33 @@ export interface SharedReadGrant {
68
68
  export interface SaihmProClientOpts {
69
69
  tier?: string;
70
70
  seqStatePath?: string;
71
+ paymentMethod?: string;
72
+ onboardBaseUrl?: string;
71
73
  requestTimeoutMs?: number;
72
74
  }
73
75
  export declare class SaihmProClient {
74
76
  private readonly endpoint;
75
- private readonly authHeader;
77
+ private readonly staticAuthHeader;
78
+ private readonly paymentMethod;
79
+ private readonly onboardBase;
76
80
  private readonly identity;
77
81
  private readonly agentIdHashHex;
78
82
  private readonly seq;
79
83
  private readonly requestTimeoutMs;
80
84
  private tier;
81
- constructor(endpoint: string, authHeader: string, masterSecret: Uint8Array, opts?: SaihmProClientOpts);
85
+ private cachedJwt;
86
+ private cachedJwtRefreshAtMs;
87
+ private authInFlight;
88
+ constructor(endpoint: string, authHeader: string | undefined, masterSecret: Uint8Array, opts?: SaihmProClientOpts);
82
89
  static bootFromEnv(): SaihmProClient;
83
90
  get agentIdHash(): string;
84
91
  get identityRecord(): WireIdentityRecord;
92
+ private currentAuthHeader;
93
+ private onboard;
94
+ requestCheckoutUrl(): Promise<string>;
95
+ private onboardFetch;
85
96
  private call;
97
+ private doCall;
86
98
  private openRow;
87
99
  private resolveTier;
88
100
  remember(content: string, opts?: RememberOpts): Promise<RememberResult>;
package/dist/client.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { randomBytes } from 'node:crypto';
2
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
3
3
  import { dirname } from 'node:path';
4
- import { deriveIdentity, sealCell, openCell, shareCell, decodeShareEnvelope, unwrapSharedDek, verifyShareSig, openCellWithDek, verifyEnvelope, verifyIdentityRecord, encodeEnvelope, decodeEnvelope, encodeShareEnvelope, encodeIdentityRecord, decodeIdentityRecord, fromHex, toHex, utf8, fromUtf8, ctEqual, SeqHighWaterMark, } from '@saihm/client-pro';
4
+ import { deriveIdentity, signChallenge, sealCell, openCell, shareCell, decodeShareEnvelope, unwrapSharedDek, verifyShareSig, openCellWithDek, verifyEnvelope, verifyIdentityRecord, encodeEnvelope, decodeEnvelope, encodeShareEnvelope, encodeIdentityRecord, decodeIdentityRecord, fromHex, toHex, utf8, fromUtf8, ctEqual, SeqHighWaterMark, } from '@saihm/client-pro';
5
5
  const REQUEST_TIMEOUT_MS = 30_000;
6
6
  const MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
7
7
  const MAX_SEQ = (1n << 64n) - 1n;
@@ -15,11 +15,29 @@ function assertEndpointUrl(endpoint) {
15
15
  }
16
16
  if (url.protocol === 'https:')
17
17
  return;
18
- if (url.protocol === 'http:' && (url.hostname === '127.0.0.1' || url.hostname === 'localhost'))
18
+ if (url.protocol === 'http:' &&
19
+ (url.hostname === '127.0.0.1' || url.hostname === 'localhost'))
19
20
  return;
20
21
  throw new Error(`SAIHM_ENDPOINT_URL must use https:// (got ${url.protocol}//). ` +
21
22
  `Plain http:// is only allowed for 127.0.0.1 or localhost (dev).`);
22
23
  }
24
+ const JWT_REFRESH_SKEW_MS = 60_000;
25
+ const OPAQUE_TOKEN_TTL_MS = 5 * 60_000;
26
+ function jwtExpMs(jwt) {
27
+ const parts = jwt.split('.');
28
+ const payloadB64 = parts[1];
29
+ if (parts.length !== 3 || !payloadB64)
30
+ return undefined;
31
+ try {
32
+ const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf-8'));
33
+ if (typeof payload.exp === 'number' && Number.isFinite(payload.exp)) {
34
+ return payload.exp * 1000;
35
+ }
36
+ }
37
+ catch {
38
+ }
39
+ return undefined;
40
+ }
23
41
  export class SaihmEndpointError extends Error {
24
42
  status;
25
43
  code;
@@ -119,16 +137,20 @@ class SeqState {
119
137
  }
120
138
  export class SaihmProClient {
121
139
  endpoint;
122
- authHeader;
140
+ staticAuthHeader;
141
+ paymentMethod;
142
+ onboardBase;
123
143
  identity;
124
144
  agentIdHashHex;
125
145
  seq;
126
146
  requestTimeoutMs;
127
147
  tier;
148
+ cachedJwt;
149
+ cachedJwtRefreshAtMs = 0;
150
+ authInFlight = null;
128
151
  constructor(endpoint, authHeader, masterSecret, opts = {}) {
129
152
  assertEndpointUrl(endpoint);
130
153
  this.endpoint = endpoint;
131
- this.authHeader = authHeader;
132
154
  this.identity = deriveIdentity(masterSecret);
133
155
  this.agentIdHashHex = toHex(this.identity.agentIdHash);
134
156
  this.tier = opts.tier;
@@ -137,17 +159,48 @@ export class SaihmProClient {
137
159
  typeof opts.requestTimeoutMs === 'number' && opts.requestTimeoutMs > 0
138
160
  ? opts.requestTimeoutMs
139
161
  : REQUEST_TIMEOUT_MS;
162
+ this.onboardBase = (opts.onboardBaseUrl ?? new URL(endpoint).origin).replace(/\/+$/, '');
163
+ const trimmedAuth = typeof authHeader === 'string' && authHeader.trim() ? authHeader : undefined;
164
+ if (trimmedAuth) {
165
+ this.staticAuthHeader = trimmedAuth;
166
+ }
167
+ else {
168
+ if (!opts.paymentMethod) {
169
+ throw new Error('self-onboarding requires a paymentMethod (set SAIHM_PAYMENT_METHOD) when no auth header is supplied');
170
+ }
171
+ if (this.tier === undefined) {
172
+ throw new Error('self-onboarding requires a tier (set SAIHM_TIER) when no auth header is supplied');
173
+ }
174
+ this.paymentMethod = opts.paymentMethod;
175
+ }
140
176
  }
141
177
  static bootFromEnv() {
142
178
  const endpoint = process.env.SAIHM_ENDPOINT_URL;
143
179
  const auth = process.env.SAIHM_AUTH_HEADER;
144
- const secretHex = process.env.SAIHM_MASTER_SECRET_HEX;
145
180
  if (!endpoint)
146
181
  throw new Error('SAIHM_ENDPOINT_URL env var required');
147
- if (!auth)
148
- throw new Error("SAIHM_AUTH_HEADER env var required (e.g. 'Bearer <JWT>')");
182
+ const secretFile = process.env.SAIHM_MASTER_SECRET_FILE;
183
+ let secretHex;
184
+ if (secretFile) {
185
+ try {
186
+ secretHex = readFileSync(secretFile, 'utf-8');
187
+ }
188
+ catch {
189
+ throw new Error(`SAIHM_MASTER_SECRET_FILE could not be read: ${secretFile}`);
190
+ }
191
+ try {
192
+ if (process.platform !== 'win32' && (statSync(secretFile).mode & 0o077) !== 0) {
193
+ process.stderr.write(`warning: SAIHM_MASTER_SECRET_FILE ${secretFile} is group/world-accessible; chmod 600 it.\n`);
194
+ }
195
+ }
196
+ catch {
197
+ }
198
+ }
199
+ else {
200
+ secretHex = process.env.SAIHM_MASTER_SECRET_HEX;
201
+ }
149
202
  if (!secretHex)
150
- throw new Error('SAIHM_MASTER_SECRET_HEX env var required (>= 64 hex chars)');
203
+ throw new Error('SAIHM_MASTER_SECRET_HEX (or SAIHM_MASTER_SECRET_FILE) env var required (>= 64 hex chars)');
151
204
  let master;
152
205
  try {
153
206
  master = fromHex(secretHex.trim());
@@ -161,11 +214,14 @@ export class SaihmProClient {
161
214
  }
162
215
  const optTier = process.env.SAIHM_TIER;
163
216
  const optSeqPath = process.env.SAIHM_SEQ_STATE_PATH;
217
+ const optPaymentMethod = process.env.SAIHM_PAYMENT_METHOD;
164
218
  const opts = {};
165
219
  if (optTier)
166
220
  opts.tier = optTier;
167
221
  if (optSeqPath)
168
222
  opts.seqStatePath = optSeqPath;
223
+ if (optPaymentMethod)
224
+ opts.paymentMethod = optPaymentMethod;
169
225
  try {
170
226
  return new SaihmProClient(endpoint, auth, master, opts);
171
227
  }
@@ -179,13 +235,133 @@ export class SaihmProClient {
179
235
  get identityRecord() {
180
236
  return encodeIdentityRecord(this.identity.identityRecord);
181
237
  }
238
+ async currentAuthHeader() {
239
+ if (this.staticAuthHeader)
240
+ return this.staticAuthHeader;
241
+ if (this.cachedJwt && Date.now() < this.cachedJwtRefreshAtMs) {
242
+ return 'Bearer ' + this.cachedJwt;
243
+ }
244
+ if (!this.authInFlight) {
245
+ this.authInFlight = this.onboard().finally(() => {
246
+ this.authInFlight = null;
247
+ });
248
+ }
249
+ return 'Bearer ' + (await this.authInFlight);
250
+ }
251
+ async onboard() {
252
+ const base = this.onboardBase;
253
+ const ch = await this.onboardFetch(base + '/api/onboard/challenge', { method: 'GET' });
254
+ const nonce = ch.nonce;
255
+ if (typeof nonce !== 'string' || nonce.length === 0) {
256
+ throw new SaihmEndpointError(502, 'onboard_no_nonce', 'onboard challenge returned no nonce');
257
+ }
258
+ let nonceBytes;
259
+ try {
260
+ nonceBytes = fromHex(nonce);
261
+ }
262
+ catch {
263
+ throw new SaihmEndpointError(502, 'onboard_bad_nonce', 'onboard challenge nonce is not hex');
264
+ }
265
+ const signature = toHex(signChallenge(this.identity.mldsaSecretKey, nonceBytes));
266
+ const out = await this.onboardFetch(base + '/api/onboard', {
267
+ method: 'POST',
268
+ headers: { 'content-type': 'application/json' },
269
+ body: JSON.stringify({
270
+ pubkey: toHex(this.identity.mldsaPubKey),
271
+ nonce,
272
+ signature,
273
+ tier: this.tier,
274
+ paymentMethod: this.paymentMethod,
275
+ }),
276
+ });
277
+ if (typeof out.jwt !== 'string' || out.jwt.length === 0) {
278
+ throw new SaihmEndpointError(502, 'onboard_no_jwt', 'onboard did not return a JWT');
279
+ }
280
+ this.cachedJwt = out.jwt;
281
+ const now = Date.now();
282
+ const expMs = jwtExpMs(out.jwt) ?? now + OPAQUE_TOKEN_TTL_MS;
283
+ const skew = Math.min(JWT_REFRESH_SKEW_MS, Math.max(0, Math.floor((expMs - now) / 2)));
284
+ this.cachedJwtRefreshAtMs = expMs - skew;
285
+ return out.jwt;
286
+ }
287
+ async requestCheckoutUrl() {
288
+ if (this.tier === undefined) {
289
+ throw new SaihmEndpointError(0, 'no_tier', 'join requires a tier (set SAIHM_TIER)');
290
+ }
291
+ const out = await this.onboardFetch(this.onboardBase + '/api/stripe/checkout', {
292
+ method: 'POST',
293
+ headers: { 'content-type': 'application/json' },
294
+ body: JSON.stringify({
295
+ tier: this.tier,
296
+ mldsaPubKey: toHex(this.identity.mldsaPubKey),
297
+ uiMode: 'hosted',
298
+ }),
299
+ });
300
+ if (typeof out.url !== 'string' || !out.url.startsWith('https://')) {
301
+ throw new SaihmEndpointError(502, 'checkout_no_url', 'checkout did not return a hosted URL');
302
+ }
303
+ return out.url;
304
+ }
305
+ async onboardFetch(url, init) {
306
+ const ctrl = new AbortController();
307
+ const timer = setTimeout(() => ctrl.abort(), this.requestTimeoutMs);
308
+ try {
309
+ const res = await fetch(url, { ...init, signal: ctrl.signal });
310
+ const text = await readBodyCapped(res, MAX_RESPONSE_BYTES, 'onboard');
311
+ if (!res.ok) {
312
+ let code;
313
+ try {
314
+ const j = JSON.parse(text);
315
+ if (typeof j.error === 'string')
316
+ code = j.error;
317
+ }
318
+ catch {
319
+ }
320
+ throw new SaihmEndpointError(res.status, code, `SAIHM onboard failed: ${res.status} ${res.statusText}` + (code ? ` (${code})` : ''));
321
+ }
322
+ try {
323
+ return JSON.parse(text);
324
+ }
325
+ catch {
326
+ throw new SaihmEndpointError(res.status, 'malformed_json', 'SAIHM onboard returned a non-JSON 2xx response');
327
+ }
328
+ }
329
+ catch (e) {
330
+ if (e instanceof SaihmEndpointError)
331
+ throw e;
332
+ if (e instanceof Error && e.name === 'AbortError') {
333
+ throw new SaihmEndpointError(408, 'timeout', `SAIHM onboard timed out after ${this.requestTimeoutMs}ms`);
334
+ }
335
+ throw new SaihmEndpointError(0, 'network', 'SAIHM onboard transport error');
336
+ }
337
+ finally {
338
+ clearTimeout(timer);
339
+ }
340
+ }
182
341
  async call(method, params) {
342
+ const header = await this.currentAuthHeader();
343
+ try {
344
+ return await this.doCall(method, params, header);
345
+ }
346
+ catch (e) {
347
+ if (!this.staticAuthHeader && e instanceof SaihmEndpointError && e.status === 401) {
348
+ this.cachedJwt = undefined;
349
+ const fresh = await this.currentAuthHeader();
350
+ return await this.doCall(method, params, fresh);
351
+ }
352
+ throw e;
353
+ }
354
+ }
355
+ async doCall(method, params, authHeader) {
183
356
  const ctrl = new AbortController();
184
357
  const timer = setTimeout(() => ctrl.abort(), this.requestTimeoutMs);
185
358
  try {
186
359
  const res = await fetch(this.endpoint, {
187
360
  method: 'POST',
188
- headers: { 'content-type': 'application/json', authorization: this.authHeader },
361
+ headers: {
362
+ 'content-type': 'application/json',
363
+ authorization: authHeader,
364
+ },
189
365
  body: JSON.stringify({ method, params }),
190
366
  signal: ctrl.signal,
191
367
  });
@@ -199,7 +375,8 @@ export class SaihmProClient {
199
375
  }
200
376
  catch {
201
377
  }
202
- throw new SaihmEndpointError(res.status, code, `SAIHM endpoint ${method} failed: ${res.status} ${res.statusText}` + (code ? ` (${code})` : ''));
378
+ throw new SaihmEndpointError(res.status, code, `SAIHM endpoint ${method} failed: ${res.status} ${res.statusText}` +
379
+ (code ? ` (${code})` : ''));
203
380
  }
204
381
  try {
205
382
  return JSON.parse(text);
@@ -246,7 +423,12 @@ export class SaihmProClient {
246
423
  throw new SaihmEndpointError(502, 'undecryptable', `cell '${env.cellId}' could not be opened with this identity's key`);
247
424
  }
248
425
  this.seq.observe(env.cellId, env.seq);
249
- return { cellId: env.cellId, plaintext, seq: env.seq.toString(10), commitmentHash: toHex(env.publicMeta.commitmentHash) };
426
+ return {
427
+ cellId: env.cellId,
428
+ plaintext,
429
+ seq: env.seq.toString(10),
430
+ commitmentHash: toHex(env.publicMeta.commitmentHash),
431
+ };
250
432
  }
251
433
  async resolveTier() {
252
434
  if (this.tier !== undefined)
@@ -278,7 +460,9 @@ export class SaihmProClient {
278
460
  seq,
279
461
  tier,
280
462
  });
281
- const r = await this.call('saihm_remember', { wire: encodeEnvelope(env) });
463
+ const r = await this.call('saihm_remember', {
464
+ wire: encodeEnvelope(env),
465
+ });
282
466
  this.seq.observe(cellId, seq);
283
467
  return r;
284
468
  }
@@ -309,7 +493,8 @@ export class SaihmProClient {
309
493
  throw new SaihmEndpointError(502, 'malformed_response', `endpoint returned cell '${cell.cellId}' more than once in a recall-all response`);
310
494
  }
311
495
  seen.add(cell.cellId);
312
- if (needle !== undefined && !cell.plaintext.toLowerCase().includes(needle))
496
+ if (needle !== undefined &&
497
+ !cell.plaintext.toLowerCase().includes(needle))
313
498
  continue;
314
499
  out.push(cell);
315
500
  }
@@ -390,7 +575,10 @@ export class SaihmProClient {
390
575
  }
391
576
  verifyIdentityRecord(sharerRecord, sharerPinned);
392
577
  const sharerHex = toHex(sharerPinned);
393
- const r = await this.call('saihm_recall', { sharer: sharerHex, cellId: grant.cellId });
578
+ const r = await this.call('saihm_recall', {
579
+ sharer: sharerHex,
580
+ cellId: grant.cellId,
581
+ });
394
582
  if (typeof r !== 'object' || r === null || Array.isArray(r)) {
395
583
  throw new SaihmEndpointError(502, 'malformed_response', 'endpoint returned a malformed shared-recall response');
396
584
  }
@@ -407,7 +595,8 @@ export class SaihmProClient {
407
595
  if (!ctEqual(share.recipientAgentIdHash, this.identity.agentIdHash)) {
408
596
  throw new SaihmEndpointError(502, 'foreign_share', 'endpoint returned a share addressed to a different recipient');
409
597
  }
410
- if (!ctEqual(share.sharerAgentIdHash, sharerPinned) || share.cellId !== grant.cellId) {
598
+ if (!ctEqual(share.sharerAgentIdHash, sharerPinned) ||
599
+ share.cellId !== grant.cellId) {
411
600
  throw new SaihmEndpointError(502, 'share_mismatch', `endpoint returned a share for the wrong sharer/cell`);
412
601
  }
413
602
  if (!verifyShareSig(share, sharerRecord.mldsaPubKey)) {
@@ -449,7 +638,12 @@ export class SaihmProClient {
449
638
  catch {
450
639
  throw new SaihmEndpointError(502, 'undecryptable', `shared cell '${grant.cellId}' could not be opened with the unwrapped DEK`);
451
640
  }
452
- return { cellId: env.cellId, plaintext, seq: env.seq.toString(10), commitmentHash: toHex(env.publicMeta.commitmentHash) };
641
+ return {
642
+ cellId: env.cellId,
643
+ plaintext,
644
+ seq: env.seq.toString(10),
645
+ commitmentHash: toHex(env.publicMeta.commitmentHash),
646
+ };
453
647
  }
454
648
  finally {
455
649
  dek.fill(0);
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/server.js ADDED
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { dirname, join as pathJoin } from 'node:path';
5
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
+ import { z } from 'zod';
8
+ import { SaihmProClient, SaihmEndpointError } from './client.js';
9
+ const PACKAGE_VERSION = JSON.parse(readFileSync(pathJoin(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf-8')).version;
10
+ const server = new McpServer({ name: 'saihm', version: PACKAGE_VERSION }, { capabilities: { tools: {} } });
11
+ let client = null;
12
+ function getClient() {
13
+ if (!client)
14
+ client = SaihmProClient.bootFromEnv();
15
+ return client;
16
+ }
17
+ const ok = (text) => ({ content: [{ type: 'text', text }] });
18
+ function fail(e) {
19
+ const text = e instanceof SaihmEndpointError
20
+ ? `SAIHM error [${e.code}] (status ${e.status}): ${e.message}`
21
+ : e instanceof Error
22
+ ? e.message
23
+ : String(e);
24
+ return { content: [{ type: 'text', text }], isError: true };
25
+ }
26
+ server.tool('saihm_remember', 'Store information to SAIHM persistent encrypted memory (sealed client-side). Pass an existing cellId to update it.', {
27
+ content: z.string().describe('Information to remember'),
28
+ cellId: z
29
+ .string()
30
+ .optional()
31
+ .describe('Existing cell id (hex) to update; omit to create a new cell'),
32
+ }, async ({ content, cellId }) => {
33
+ try {
34
+ const r = await getClient().remember(content, cellId ? { cellId } : {});
35
+ return ok(`REMEMBERED [${r.cellId}] seq=${r.seq} shard=${r.shardId} commit=${r.commitmentHash.slice(0, 16)}…`);
36
+ }
37
+ catch (e) {
38
+ return fail(e);
39
+ }
40
+ });
41
+ server.tool('saihm_recall', 'Retrieve and decrypt your memories (opened client-side). Optional keyword filter.', { query: z.string().optional().describe('Filter by keyword (empty = all)') }, async ({ query }) => {
42
+ try {
43
+ const cells = await getClient().recall(query);
44
+ if (cells.length === 0)
45
+ return ok('No memories stored.');
46
+ const lines = [`RECALL ${cells.length} memories`];
47
+ for (const c of cells)
48
+ lines.push(` [${c.cellId}] seq=${c.seq} | ${c.plaintext}`);
49
+ return ok(lines.join('\n'));
50
+ }
51
+ catch (e) {
52
+ return fail(e);
53
+ }
54
+ });
55
+ server.tool('saihm_forget', 'Cryptographically erase a memory (GDPR Art. 17): destroys the endpoint-side wrapped DEK so the cell can never be decrypted again.', { id: z.string().describe('Memory cell id (hex) to erase') }, async ({ id }) => {
56
+ try {
57
+ const r = await getClient().forget(id);
58
+ return ok(`FORGOTTEN [${r.cellId}] complete=${r.complete} sharesPurged=${r.sharesPurged} epoch=${r.epoch}`);
59
+ }
60
+ catch (e) {
61
+ return fail(e);
62
+ }
63
+ });
64
+ server.tool('saihm_status', 'Show operator-observable session status (no plaintext): tier, shards, sharing, BFSI, custody.', {}, async () => {
65
+ try {
66
+ const d = await getClient().status();
67
+ return ok(`SAIHM Session\n agent=${d.agentIdHashHex.slice(0, 16)}… tier=${d.tier} custody=${d.custody}\n shards=${d.activeShardCount} sharing=${d.activeSharingContracts} bfsi=${d.bfsi.toFixed(3)} (R=${d.bfsi_R} M=${d.bfsi_M}) epoch=${d.snapshotEpoch}`);
68
+ }
69
+ catch (e) {
70
+ return fail(e);
71
+ }
72
+ });
73
+ server.tool('saihm_share', "Share a cell with another agent, end-to-end authenticated. Pin the grantee's agentIdHash out-of-band.", {
74
+ cellId: z.string().describe('The cell to share'),
75
+ recipientRecord: z
76
+ .object({
77
+ mldsaPubKey: z.string(),
78
+ mlkemPubKey: z.string(),
79
+ mlkemPubKeySelfSig: z.string(),
80
+ })
81
+ .describe("The grantee's published identity record (hex fields)"),
82
+ recipientPinnedAgentIdHashHex: z
83
+ .string()
84
+ .describe("The grantee's agentIdHash (hex), pinned out-of-band"),
85
+ scope: z.enum(['read', 'write', 'readwrite']).optional().describe('Access scope (default read)'),
86
+ expiryEpoch: z
87
+ .string()
88
+ .regex(/^[0-9]+$/, 'expiryEpoch must be a decimal UNIX-epoch count')
89
+ .optional()
90
+ .describe('Optional expiry as a UNIX-epoch count (decimal string)'),
91
+ }, async ({ cellId, recipientRecord, recipientPinnedAgentIdHashHex, scope, expiryEpoch }) => {
92
+ try {
93
+ const r = await getClient().share({
94
+ cellId,
95
+ recipientRecord,
96
+ recipientPinnedAgentIdHashHex,
97
+ ...(scope ? { scope } : {}),
98
+ ...(expiryEpoch ? { expiryEpoch: BigInt(expiryEpoch) } : {}),
99
+ });
100
+ return ok(`SHARED cell=${r.cellId} sharer=${r.sharer.slice(0, 16)}… recipient=${r.recipient.slice(0, 16)}…`);
101
+ }
102
+ catch (e) {
103
+ return fail(e);
104
+ }
105
+ });
106
+ server.tool('saihm_revoke_share', 'Revoke a prior share grant to a recipient for a cell.', {
107
+ cellId: z.string().describe('The shared cell id'),
108
+ recipientHex: z.string().describe("The grantee's agentIdHash (hex) to revoke"),
109
+ }, async ({ cellId, recipientHex }) => {
110
+ try {
111
+ const r = await getClient().revokeShare(cellId, recipientHex);
112
+ return ok(`REVOKED cell=${r.cellId} recipient=${r.recipient.slice(0, 16)}… revoked=${r.revoked}`);
113
+ }
114
+ catch (e) {
115
+ return fail(e);
116
+ }
117
+ });
118
+ server.tool('saihm_governance_propose', "Submit a gSAIHM governance proposal. Scope MUST be 'emission_param' or 'protocol_upgrade'.", {
119
+ scope: z.enum(['emission_param', 'protocol_upgrade']).describe('Governable scope'),
120
+ paramKey: z.string().optional().describe('Parameter key (when scope=emission_param)'),
121
+ proposedValue: z.string().optional().describe('Proposed value as string'),
122
+ }, async ({ scope, paramKey, proposedValue }) => {
123
+ try {
124
+ await getClient().governancePropose({
125
+ scope,
126
+ paramKey: paramKey ?? null,
127
+ proposedValue: proposedValue ?? null,
128
+ });
129
+ return ok('PROPOSED');
130
+ }
131
+ catch (e) {
132
+ return fail(e);
133
+ }
134
+ });
135
+ server.tool('saihm_governance_vote', 'Cast a vote on an open gSAIHM governance proposal.', {
136
+ proposalId: z.string().describe('Hex proposalId'),
137
+ approve: z.boolean().describe('true = approve, false = reject'),
138
+ }, async ({ proposalId, approve }) => {
139
+ try {
140
+ await getClient().governanceVote({ proposalId, approve });
141
+ return ok('VOTED');
142
+ }
143
+ catch (e) {
144
+ return fail(e);
145
+ }
146
+ });
147
+ async function runJoin() {
148
+ const c = SaihmProClient.bootFromEnv();
149
+ const url = await c.requestCheckoutUrl();
150
+ process.stdout.write([
151
+ '',
152
+ 'SAIHM — subscribe this identity to activate your memory:',
153
+ '',
154
+ ' ' + url,
155
+ '',
156
+ ` identity (agentIdHash): ${c.agentIdHash}`,
157
+ '',
158
+ ' Open the link above in a browser and pay. Keep SAIHM_MASTER_SECRET_HEX safe — it is',
159
+ ' the only key to your memory and cannot be recovered. After payment, start the server',
160
+ ' normally (drop the "join" argument) and it connects automatically.',
161
+ '',
162
+ ].join('\n'));
163
+ }
164
+ async function main() {
165
+ if (process.argv[2] === 'join') {
166
+ await runJoin();
167
+ return;
168
+ }
169
+ const transport = new StdioServerTransport();
170
+ await server.connect(transport);
171
+ }
172
+ main().catch((e) => {
173
+ process.stderr.write(String(e instanceof Error ? e.message : e) + '\n');
174
+ process.exit(1);
175
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saihm/mcp-server-pro",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "SAIHM production thin-client. Seals client-side via @saihm/client-pro (ML-DSA-65 identity, per-cell AES-256-GCM DEK wrapped under a client KEK, ML-KEM-768 authenticated sharing) and POSTs opaque ciphertext to the blind, non-custodial SAIHM /mcp endpoint. The master secret, KEK, and plaintext never leave this process. Apache-2.0.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -11,11 +11,14 @@
11
11
  "import": "./dist/index.js"
12
12
  }
13
13
  },
14
+ "bin": {
15
+ "saihm-mcp-server-pro": "dist/server.js"
16
+ },
14
17
  "scripts": {
15
18
  "clean": "rm -rf dist coverage",
16
- "build": "npm run clean && tsc -p .",
19
+ "build": "npm run clean && tsc -p . && chmod +x dist/server.js",
17
20
  "typecheck": "tsc -p tsconfig.test.json",
18
- "test": "tsx --test tests/client_pro.test.ts",
21
+ "test": "tsx --test tests/client_pro.test.ts tests/server.test.ts",
19
22
  "prepublishOnly": "npm run build && npm run typecheck && npm test"
20
23
  },
21
24
  "files": [
@@ -45,7 +48,9 @@
45
48
  "node": ">=20"
46
49
  },
47
50
  "dependencies": {
48
- "@saihm/client-pro": "^0.1.1"
51
+ "@modelcontextprotocol/sdk": "^1.0.0",
52
+ "@saihm/client-pro": "^0.1.2",
53
+ "zod": "^4.4.3"
49
54
  },
50
55
  "devDependencies": {
51
56
  "@types/node": "^25.9.0",