@saihm/mcp-server-pro 0.1.3 → 0.1.6

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
@@ -11,19 +11,72 @@ Production thin-client for **SAIHM non-custodial memory**.
11
11
 
12
12
  > **Key loss is unrecoverable by design.** If you lose your master secret you lose your KEK, and no one — including SAIHM — can open your cells. Back it up securely.
13
13
 
14
+ ## See it run
15
+
16
+ - **Live cross-model demos** — offline, ~1 min each, no account: <https://citw2.github.io/saihm-demos/>. Ground a memory you own in Claude, GPT, DeepSeek, Qwen, Kimi, or GLM, then prove you can erase it. `demo-claude-code` runs a stdio MCP server exactly like this one for Claude Code and Cursor.
17
+ - **Token benchmark** — recalling a bounded set of memory cells instead of re-sending the transcript cut input tokens by **62.8%–85.9%** (up to ~86%) across a realistic multi-session task; open, offline, reproducible: <https://github.com/citw2/saihm-token-benchmark>.
18
+
14
19
  ## Install
15
20
 
16
21
  ```sh
17
22
  npm install @saihm/mcp-server-pro
18
23
  ```
19
24
 
20
- ## Usage
25
+ ## Run as an MCP server
26
+
27
+ The package ships a stdio MCP server. Point your MCP host (Claude Desktop,
28
+ Claude Code, …) at it — paste this **once**:
29
+
30
+ ```jsonc
31
+ {
32
+ "mcpServers": {
33
+ "saihm": {
34
+ "command": "npx",
35
+ "args": ["-y", "@saihm/mcp-server-pro"],
36
+ "env": {
37
+ "SAIHM_ENDPOINT_URL": "https://saihm.coti.global/mcp",
38
+ "SAIHM_MASTER_SECRET_HEX": "<your 64+ hex master secret>",
39
+ "SAIHM_TIER": "PRO",
40
+ "SAIHM_PAYMENT_METHOD": "stripe",
41
+ },
42
+ },
43
+ },
44
+ }
45
+ ```
46
+
47
+ With no `SAIHM_AUTH_HEADER`, the server **self-onboards**: it mints and
48
+ auto-refreshes its own short-lived access token from your master secret, so
49
+ there is no token to paste or re-paste. Eight tools are exposed
50
+ (`saihm_remember`, `saihm_recall`, `saihm_forget`, `saihm_status`,
51
+ `saihm_share`, `saihm_revoke_share`, `saihm_governance_propose`,
52
+ `saihm_governance_vote`).
53
+
54
+ ### Self-serve join
55
+
56
+ To subscribe an identity from the command line instead of the website, run the
57
+ one-off `join` command with the same env:
58
+
59
+ ```sh
60
+ SAIHM_ENDPOINT_URL=https://saihm.coti.global/mcp \
61
+ SAIHM_MASTER_SECRET_HEX=<your 64+ hex master secret> \
62
+ SAIHM_TIER=PRO SAIHM_PAYMENT_METHOD=stripe \
63
+ npx -y @saihm/mcp-server-pro join
64
+ ```
65
+
66
+ It prints a Stripe checkout link bound to your identity. Pay in a browser, then
67
+ start the server normally (drop `join`) — it connects automatically. Keep
68
+ `SAIHM_MASTER_SECRET_HEX` safe: it is the only key to your memory and cannot be
69
+ recovered.
70
+
71
+ ## Use as a library
21
72
 
22
73
  ```ts
23
74
  import { SaihmProClient } from '@saihm/mcp-server-pro';
24
75
 
25
- // Boot from env: SAIHM_ENDPOINT_URL, SAIHM_AUTH_HEADER, SAIHM_MASTER_SECRET_HEX
26
- // (optional: SAIHM_TIER, SAIHM_SEQ_STATE_PATH)
76
+ // Boot from env: SAIHM_ENDPOINT_URL, SAIHM_MASTER_SECRET_HEX
77
+ // self-onboard (recommended): + SAIHM_PAYMENT_METHOD + SAIHM_TIER (omit SAIHM_AUTH_HEADER)
78
+ // static token (advanced): + SAIHM_AUTH_HEADER="Bearer <JWT>"
79
+ // (optional: SAIHM_SEQ_STATE_PATH)
27
80
  const saihm = SaihmProClient.bootFromEnv();
28
81
 
29
82
  // Store — encrypted before it leaves the process.
@@ -46,8 +99,8 @@ await saihm.forget(cellId);
46
99
  // out-of-band; the library rejects directory key-substitution.
47
100
  await saihm.share({
48
101
  cellId,
49
- recipientRecord, // the grantee's published identity record (hex)
50
- recipientPinnedAgentIdHashHex, // pinned out-of-band
102
+ recipientRecord, // the grantee's published identity record (hex)
103
+ recipientPinnedAgentIdHashHex, // pinned out-of-band
51
104
  });
52
105
  await saihm.revokeShare(cellId, recipientPinnedAgentIdHashHex);
53
106
 
@@ -55,8 +108,8 @@ await saihm.revokeShare(cellId, recipientPinnedAgentIdHashHex);
55
108
  // sharer's agentIdHash out-of-band; the library verifies the sharer's signature and
56
109
  // returns null when there is no live grant (e.g. revoked, or the sharer crypto-shredded it).
57
110
  const shared = await saihm.recallShared({
58
- sharerPinnedAgentIdHashHex, // the sharer's agentIdHash, pinned out-of-band
59
- sharerRecord, // the sharer's published identity record (hex)
111
+ sharerPinnedAgentIdHashHex, // the sharer's agentIdHash, pinned out-of-band
112
+ sharerRecord, // the sharer's published identity record (hex)
60
113
  cellId,
61
114
  });
62
115
  console.log(shared?.plaintext);
@@ -65,32 +118,39 @@ console.log(shared?.plaintext);
65
118
  const status = await saihm.status();
66
119
  ```
67
120
 
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.
121
+ 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
122
 
70
123
  ## Configuration
71
124
 
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. |
125
+ | Env | Required | Meaning |
126
+ | -------------------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
127
+ | `SAIHM_ENDPOINT_URL` | yes | `https://…/mcp` (or `http://` only for `127.0.0.1`/`localhost`). |
128
+ | `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. |
129
+ | `SAIHM_PAYMENT_METHOD` | self-onboard only | Your entitlement rail (e.g. `stripe`). Required when `SAIHM_AUTH_HEADER` is unset; ignored otherwise. |
130
+ | `SAIHM_MASTER_SECRET_HEX` | yes\* | 64 hex chars (≥ 32 bytes), high-entropy, client-held; never sent. \*Provide this **or** `SAIHM_MASTER_SECRET_FILE`. |
131
+ | `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. |
132
+ | `SAIHM_TIER` | self-onboard only | Tier label baked into sealed metadata. Required when self-onboarding; otherwise optional — resolved via `status()` if unset. |
133
+ | `SAIHM_SEQ_STATE_PATH` | no | Persists per-cell sequence high-water marks (mode 600) for cross-restart updates. |
134
+
135
+ > **Self-onboarding (paste once):** with `SAIHM_AUTH_HEADER` unset, the client proves
136
+ > control of your identity via the endpoint's ML-DSA challenge/response and mints its own
137
+ > token, refreshing transparently on expiry. Cancelling your subscription stops the next
138
+ > refresh, so access ends naturally.
79
139
 
80
140
  ## Errors
81
141
 
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.
142
+ 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
143
 
84
144
  ## Security model
85
145
 
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. |
146
+ | Property | Guarantee |
147
+ | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
148
+ | Confidentiality vs the endpoint | The endpoint holds ciphertext + wrapped DEKs + public keys only; no key able to decrypt. |
149
+ | Integrity / authenticity | Every cell is ML-DSA-65-signed over its contents, including the sequence number. |
150
+ | Anti-replay | The signed monotonic sequence is rejected by the endpoint if not strictly increasing. |
151
+ | Tenant isolation | Your `agentIdHash` (= the JWT `sub`) namespaces your state; a write whose signed identity differs from the JWT is rejected. |
152
+ | 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. |
153
+ | Erasure | Destroying the endpoint-side wrapped DEK crypto-shreds the cell. |
94
154
 
95
155
  ## Where sealed cells are stored
96
156
 
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,51 @@ 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()
164
+ ? authHeader
165
+ : undefined;
166
+ if (trimmedAuth) {
167
+ this.staticAuthHeader = trimmedAuth;
168
+ }
169
+ else {
170
+ if (!opts.paymentMethod) {
171
+ throw new Error('self-onboarding requires a paymentMethod (set SAIHM_PAYMENT_METHOD) when no auth header is supplied');
172
+ }
173
+ if (this.tier === undefined) {
174
+ throw new Error('self-onboarding requires a tier (set SAIHM_TIER) when no auth header is supplied');
175
+ }
176
+ this.paymentMethod = opts.paymentMethod;
177
+ }
140
178
  }
141
179
  static bootFromEnv() {
142
180
  const endpoint = process.env.SAIHM_ENDPOINT_URL;
143
181
  const auth = process.env.SAIHM_AUTH_HEADER;
144
- const secretHex = process.env.SAIHM_MASTER_SECRET_HEX;
145
182
  if (!endpoint)
146
183
  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>')");
184
+ const secretFile = process.env.SAIHM_MASTER_SECRET_FILE;
185
+ let secretHex;
186
+ if (secretFile) {
187
+ try {
188
+ secretHex = readFileSync(secretFile, 'utf-8');
189
+ }
190
+ catch {
191
+ throw new Error(`SAIHM_MASTER_SECRET_FILE could not be read: ${secretFile}`);
192
+ }
193
+ try {
194
+ if (process.platform !== 'win32' &&
195
+ (statSync(secretFile).mode & 0o077) !== 0) {
196
+ process.stderr.write(`warning: SAIHM_MASTER_SECRET_FILE ${secretFile} is group/world-accessible; chmod 600 it.\n`);
197
+ }
198
+ }
199
+ catch {
200
+ }
201
+ }
202
+ else {
203
+ secretHex = process.env.SAIHM_MASTER_SECRET_HEX;
204
+ }
149
205
  if (!secretHex)
150
- throw new Error('SAIHM_MASTER_SECRET_HEX env var required (>= 64 hex chars)');
206
+ throw new Error('SAIHM_MASTER_SECRET_HEX (or SAIHM_MASTER_SECRET_FILE) env var required (>= 64 hex chars)');
151
207
  let master;
152
208
  try {
153
209
  master = fromHex(secretHex.trim());
@@ -161,11 +217,14 @@ export class SaihmProClient {
161
217
  }
162
218
  const optTier = process.env.SAIHM_TIER;
163
219
  const optSeqPath = process.env.SAIHM_SEQ_STATE_PATH;
220
+ const optPaymentMethod = process.env.SAIHM_PAYMENT_METHOD;
164
221
  const opts = {};
165
222
  if (optTier)
166
223
  opts.tier = optTier;
167
224
  if (optSeqPath)
168
225
  opts.seqStatePath = optSeqPath;
226
+ if (optPaymentMethod)
227
+ opts.paymentMethod = optPaymentMethod;
169
228
  try {
170
229
  return new SaihmProClient(endpoint, auth, master, opts);
171
230
  }
@@ -179,13 +238,136 @@ export class SaihmProClient {
179
238
  get identityRecord() {
180
239
  return encodeIdentityRecord(this.identity.identityRecord);
181
240
  }
241
+ async currentAuthHeader() {
242
+ if (this.staticAuthHeader)
243
+ return this.staticAuthHeader;
244
+ if (this.cachedJwt && Date.now() < this.cachedJwtRefreshAtMs) {
245
+ return 'Bearer ' + this.cachedJwt;
246
+ }
247
+ if (!this.authInFlight) {
248
+ this.authInFlight = this.onboard().finally(() => {
249
+ this.authInFlight = null;
250
+ });
251
+ }
252
+ return 'Bearer ' + (await this.authInFlight);
253
+ }
254
+ async onboard() {
255
+ const base = this.onboardBase;
256
+ const ch = await this.onboardFetch(base + '/api/onboard/challenge', { method: 'GET' });
257
+ const nonce = ch.nonce;
258
+ if (typeof nonce !== 'string' || nonce.length === 0) {
259
+ throw new SaihmEndpointError(502, 'onboard_no_nonce', 'onboard challenge returned no nonce');
260
+ }
261
+ let nonceBytes;
262
+ try {
263
+ nonceBytes = fromHex(nonce);
264
+ }
265
+ catch {
266
+ throw new SaihmEndpointError(502, 'onboard_bad_nonce', 'onboard challenge nonce is not hex');
267
+ }
268
+ const signature = toHex(signChallenge(this.identity.mldsaSecretKey, nonceBytes));
269
+ const out = await this.onboardFetch(base + '/api/onboard', {
270
+ method: 'POST',
271
+ headers: { 'content-type': 'application/json' },
272
+ body: JSON.stringify({
273
+ pubkey: toHex(this.identity.mldsaPubKey),
274
+ nonce,
275
+ signature,
276
+ tier: this.tier,
277
+ paymentMethod: this.paymentMethod,
278
+ }),
279
+ });
280
+ if (typeof out.jwt !== 'string' || out.jwt.length === 0) {
281
+ throw new SaihmEndpointError(502, 'onboard_no_jwt', 'onboard did not return a JWT');
282
+ }
283
+ this.cachedJwt = out.jwt;
284
+ const now = Date.now();
285
+ const expMs = jwtExpMs(out.jwt) ?? now + OPAQUE_TOKEN_TTL_MS;
286
+ const skew = Math.min(JWT_REFRESH_SKEW_MS, Math.max(0, Math.floor((expMs - now) / 2)));
287
+ this.cachedJwtRefreshAtMs = expMs - skew;
288
+ return out.jwt;
289
+ }
290
+ async requestCheckoutUrl() {
291
+ if (this.tier === undefined) {
292
+ throw new SaihmEndpointError(0, 'no_tier', 'join requires a tier (set SAIHM_TIER)');
293
+ }
294
+ const out = await this.onboardFetch(this.onboardBase + '/api/stripe/checkout', {
295
+ method: 'POST',
296
+ headers: { 'content-type': 'application/json' },
297
+ body: JSON.stringify({
298
+ tier: this.tier,
299
+ mldsaPubKey: toHex(this.identity.mldsaPubKey),
300
+ uiMode: 'hosted',
301
+ }),
302
+ });
303
+ if (typeof out.url !== 'string' || !out.url.startsWith('https://')) {
304
+ throw new SaihmEndpointError(502, 'checkout_no_url', 'checkout did not return a hosted URL');
305
+ }
306
+ return out.url;
307
+ }
308
+ async onboardFetch(url, init) {
309
+ const ctrl = new AbortController();
310
+ const timer = setTimeout(() => ctrl.abort(), this.requestTimeoutMs);
311
+ try {
312
+ const res = await fetch(url, { ...init, signal: ctrl.signal });
313
+ const text = await readBodyCapped(res, MAX_RESPONSE_BYTES, 'onboard');
314
+ if (!res.ok) {
315
+ let code;
316
+ try {
317
+ const j = JSON.parse(text);
318
+ if (typeof j.error === 'string')
319
+ code = j.error;
320
+ }
321
+ catch {
322
+ }
323
+ throw new SaihmEndpointError(res.status, code, `SAIHM onboard failed: ${res.status} ${res.statusText}` +
324
+ (code ? ` (${code})` : ''));
325
+ }
326
+ try {
327
+ return JSON.parse(text);
328
+ }
329
+ catch {
330
+ throw new SaihmEndpointError(res.status, 'malformed_json', 'SAIHM onboard returned a non-JSON 2xx response');
331
+ }
332
+ }
333
+ catch (e) {
334
+ if (e instanceof SaihmEndpointError)
335
+ throw e;
336
+ if (e instanceof Error && e.name === 'AbortError') {
337
+ throw new SaihmEndpointError(408, 'timeout', `SAIHM onboard timed out after ${this.requestTimeoutMs}ms`);
338
+ }
339
+ throw new SaihmEndpointError(0, 'network', 'SAIHM onboard transport error');
340
+ }
341
+ finally {
342
+ clearTimeout(timer);
343
+ }
344
+ }
182
345
  async call(method, params) {
346
+ const header = await this.currentAuthHeader();
347
+ try {
348
+ return await this.doCall(method, params, header);
349
+ }
350
+ catch (e) {
351
+ if (!this.staticAuthHeader &&
352
+ e instanceof SaihmEndpointError &&
353
+ e.status === 401) {
354
+ this.cachedJwt = undefined;
355
+ const fresh = await this.currentAuthHeader();
356
+ return await this.doCall(method, params, fresh);
357
+ }
358
+ throw e;
359
+ }
360
+ }
361
+ async doCall(method, params, authHeader) {
183
362
  const ctrl = new AbortController();
184
363
  const timer = setTimeout(() => ctrl.abort(), this.requestTimeoutMs);
185
364
  try {
186
365
  const res = await fetch(this.endpoint, {
187
366
  method: 'POST',
188
- headers: { 'content-type': 'application/json', authorization: this.authHeader },
367
+ headers: {
368
+ 'content-type': 'application/json',
369
+ authorization: authHeader,
370
+ },
189
371
  body: JSON.stringify({ method, params }),
190
372
  signal: ctrl.signal,
191
373
  });
@@ -199,7 +381,8 @@ export class SaihmProClient {
199
381
  }
200
382
  catch {
201
383
  }
202
- throw new SaihmEndpointError(res.status, code, `SAIHM endpoint ${method} failed: ${res.status} ${res.statusText}` + (code ? ` (${code})` : ''));
384
+ throw new SaihmEndpointError(res.status, code, `SAIHM endpoint ${method} failed: ${res.status} ${res.statusText}` +
385
+ (code ? ` (${code})` : ''));
203
386
  }
204
387
  try {
205
388
  return JSON.parse(text);
@@ -246,7 +429,12 @@ export class SaihmProClient {
246
429
  throw new SaihmEndpointError(502, 'undecryptable', `cell '${env.cellId}' could not be opened with this identity's key`);
247
430
  }
248
431
  this.seq.observe(env.cellId, env.seq);
249
- return { cellId: env.cellId, plaintext, seq: env.seq.toString(10), commitmentHash: toHex(env.publicMeta.commitmentHash) };
432
+ return {
433
+ cellId: env.cellId,
434
+ plaintext,
435
+ seq: env.seq.toString(10),
436
+ commitmentHash: toHex(env.publicMeta.commitmentHash),
437
+ };
250
438
  }
251
439
  async resolveTier() {
252
440
  if (this.tier !== undefined)
@@ -278,7 +466,9 @@ export class SaihmProClient {
278
466
  seq,
279
467
  tier,
280
468
  });
281
- const r = await this.call('saihm_remember', { wire: encodeEnvelope(env) });
469
+ const r = await this.call('saihm_remember', {
470
+ wire: encodeEnvelope(env),
471
+ });
282
472
  this.seq.observe(cellId, seq);
283
473
  return r;
284
474
  }
@@ -309,7 +499,8 @@ export class SaihmProClient {
309
499
  throw new SaihmEndpointError(502, 'malformed_response', `endpoint returned cell '${cell.cellId}' more than once in a recall-all response`);
310
500
  }
311
501
  seen.add(cell.cellId);
312
- if (needle !== undefined && !cell.plaintext.toLowerCase().includes(needle))
502
+ if (needle !== undefined &&
503
+ !cell.plaintext.toLowerCase().includes(needle))
313
504
  continue;
314
505
  out.push(cell);
315
506
  }
@@ -390,7 +581,10 @@ export class SaihmProClient {
390
581
  }
391
582
  verifyIdentityRecord(sharerRecord, sharerPinned);
392
583
  const sharerHex = toHex(sharerPinned);
393
- const r = await this.call('saihm_recall', { sharer: sharerHex, cellId: grant.cellId });
584
+ const r = await this.call('saihm_recall', {
585
+ sharer: sharerHex,
586
+ cellId: grant.cellId,
587
+ });
394
588
  if (typeof r !== 'object' || r === null || Array.isArray(r)) {
395
589
  throw new SaihmEndpointError(502, 'malformed_response', 'endpoint returned a malformed shared-recall response');
396
590
  }
@@ -407,7 +601,8 @@ export class SaihmProClient {
407
601
  if (!ctEqual(share.recipientAgentIdHash, this.identity.agentIdHash)) {
408
602
  throw new SaihmEndpointError(502, 'foreign_share', 'endpoint returned a share addressed to a different recipient');
409
603
  }
410
- if (!ctEqual(share.sharerAgentIdHash, sharerPinned) || share.cellId !== grant.cellId) {
604
+ if (!ctEqual(share.sharerAgentIdHash, sharerPinned) ||
605
+ share.cellId !== grant.cellId) {
411
606
  throw new SaihmEndpointError(502, 'share_mismatch', `endpoint returned a share for the wrong sharer/cell`);
412
607
  }
413
608
  if (!verifyShareSig(share, sharerRecord.mldsaPubKey)) {
@@ -449,7 +644,12 @@ export class SaihmProClient {
449
644
  catch {
450
645
  throw new SaihmEndpointError(502, 'undecryptable', `shared cell '${grant.cellId}' could not be opened with the unwrapped DEK`);
451
646
  }
452
- return { cellId: env.cellId, plaintext, seq: env.seq.toString(10), commitmentHash: toHex(env.publicMeta.commitmentHash) };
647
+ return {
648
+ cellId: env.cellId,
649
+ plaintext,
650
+ seq: env.seq.toString(10),
651
+ commitmentHash: toHex(env.publicMeta.commitmentHash),
652
+ };
453
653
  }
454
654
  finally {
455
655
  dek.fill(0);
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/server.js ADDED
@@ -0,0 +1,185 @@
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
86
+ .enum(['read', 'write', 'readwrite'])
87
+ .optional()
88
+ .describe('Access scope (default read)'),
89
+ expiryEpoch: z
90
+ .string()
91
+ .regex(/^[0-9]+$/, 'expiryEpoch must be a decimal UNIX-epoch count')
92
+ .optional()
93
+ .describe('Optional expiry as a UNIX-epoch count (decimal string)'),
94
+ }, async ({ cellId, recipientRecord, recipientPinnedAgentIdHashHex, scope, expiryEpoch, }) => {
95
+ try {
96
+ const r = await getClient().share({
97
+ cellId,
98
+ recipientRecord,
99
+ recipientPinnedAgentIdHashHex,
100
+ ...(scope ? { scope } : {}),
101
+ ...(expiryEpoch ? { expiryEpoch: BigInt(expiryEpoch) } : {}),
102
+ });
103
+ return ok(`SHARED cell=${r.cellId} sharer=${r.sharer.slice(0, 16)}… recipient=${r.recipient.slice(0, 16)}…`);
104
+ }
105
+ catch (e) {
106
+ return fail(e);
107
+ }
108
+ });
109
+ server.tool('saihm_revoke_share', 'Revoke a prior share grant to a recipient for a cell.', {
110
+ cellId: z.string().describe('The shared cell id'),
111
+ recipientHex: z
112
+ .string()
113
+ .describe("The grantee's agentIdHash (hex) to revoke"),
114
+ }, async ({ cellId, recipientHex }) => {
115
+ try {
116
+ const r = await getClient().revokeShare(cellId, recipientHex);
117
+ return ok(`REVOKED cell=${r.cellId} recipient=${r.recipient.slice(0, 16)}… revoked=${r.revoked}`);
118
+ }
119
+ catch (e) {
120
+ return fail(e);
121
+ }
122
+ });
123
+ server.tool('saihm_governance_propose', "Submit a gSAIHM governance proposal. Scope MUST be 'emission_param' or 'protocol_upgrade'.", {
124
+ scope: z
125
+ .enum(['emission_param', 'protocol_upgrade'])
126
+ .describe('Governable scope'),
127
+ paramKey: z
128
+ .string()
129
+ .optional()
130
+ .describe('Parameter key (when scope=emission_param)'),
131
+ proposedValue: z.string().optional().describe('Proposed value as string'),
132
+ }, async ({ scope, paramKey, proposedValue }) => {
133
+ try {
134
+ await getClient().governancePropose({
135
+ scope,
136
+ paramKey: paramKey ?? null,
137
+ proposedValue: proposedValue ?? null,
138
+ });
139
+ return ok('PROPOSED');
140
+ }
141
+ catch (e) {
142
+ return fail(e);
143
+ }
144
+ });
145
+ server.tool('saihm_governance_vote', 'Cast a vote on an open gSAIHM governance proposal.', {
146
+ proposalId: z.string().describe('Hex proposalId'),
147
+ approve: z.boolean().describe('true = approve, false = reject'),
148
+ }, async ({ proposalId, approve }) => {
149
+ try {
150
+ await getClient().governanceVote({ proposalId, approve });
151
+ return ok('VOTED');
152
+ }
153
+ catch (e) {
154
+ return fail(e);
155
+ }
156
+ });
157
+ async function runJoin() {
158
+ const c = SaihmProClient.bootFromEnv();
159
+ const url = await c.requestCheckoutUrl();
160
+ process.stdout.write([
161
+ '',
162
+ 'SAIHM — subscribe this identity to activate your memory:',
163
+ '',
164
+ ' ' + url,
165
+ '',
166
+ ` identity (agentIdHash): ${c.agentIdHash}`,
167
+ '',
168
+ ' Open the link above in a browser and pay. Keep SAIHM_MASTER_SECRET_HEX safe — it is',
169
+ ' the only key to your memory and cannot be recovered. After payment, start the server',
170
+ ' normally (drop the "join" argument) and it connects automatically.',
171
+ '',
172
+ ].join('\n'));
173
+ }
174
+ async function main() {
175
+ if (process.argv[2] === 'join') {
176
+ await runJoin();
177
+ return;
178
+ }
179
+ const transport = new StdioServerTransport();
180
+ await server.connect(transport);
181
+ }
182
+ main().catch((e) => {
183
+ process.stderr.write(String(e instanceof Error ? e.message : e) + '\n');
184
+ process.exit(1);
185
+ });
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.6",
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/smoke.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",