@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 +79 -24
- package/dist/client.d.ts +14 -2
- package/dist/client.js +211 -17
- package/dist/server.d.ts +2 -0
- package/dist/server.js +175 -0
- package/package.json +9 -4
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
|
-
##
|
|
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,
|
|
26
|
-
// (
|
|
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,
|
|
50
|
-
recipientPinnedAgentIdHashHex,
|
|
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,
|
|
59
|
-
sharerRecord,
|
|
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`
|
|
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
|
|
73
|
-
|
|
74
|
-
| `SAIHM_ENDPOINT_URL`
|
|
75
|
-
| `SAIHM_AUTH_HEADER`
|
|
76
|
-
| `
|
|
77
|
-
| `
|
|
78
|
-
| `
|
|
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. `
|
|
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
|
|
87
|
-
|
|
88
|
-
| Confidentiality vs the endpoint | The endpoint holds ciphertext + wrapped DEKs + public keys only; no key able to decrypt.
|
|
89
|
-
| Integrity / authenticity
|
|
90
|
-
| Anti-replay
|
|
91
|
-
| Tenant isolation
|
|
92
|
-
| Authenticated sharing
|
|
93
|
-
| Erasure
|
|
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
|
|
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
|
-
|
|
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:' &&
|
|
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
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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: {
|
|
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}` +
|
|
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 {
|
|
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', {
|
|
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 &&
|
|
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', {
|
|
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) ||
|
|
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 {
|
|
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);
|
package/dist/server.d.ts
ADDED
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
|
+
"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
|
-
"@
|
|
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",
|