@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 +84 -24
- package/dist/client.d.ts +14 -2
- package/dist/client.js +217 -17
- package/dist/server.d.ts +2 -0
- package/dist/server.js +185 -0
- package/package.json +9 -4
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
|
-
##
|
|
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,
|
|
26
|
-
// (
|
|
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,
|
|
50
|
-
recipientPinnedAgentIdHashHex,
|
|
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,
|
|
59
|
-
sharerRecord,
|
|
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`
|
|
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
|
|
73
|
-
|
|
74
|
-
| `SAIHM_ENDPOINT_URL`
|
|
75
|
-
| `SAIHM_AUTH_HEADER`
|
|
76
|
-
| `
|
|
77
|
-
| `
|
|
78
|
-
| `
|
|
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. `
|
|
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
|
|
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
|
|
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
|
|
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,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
|
-
|
|
148
|
-
|
|
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: {
|
|
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}` +
|
|
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 {
|
|
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', {
|
|
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 &&
|
|
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', {
|
|
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) ||
|
|
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 {
|
|
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);
|
package/dist/server.d.ts
ADDED
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
|
+
"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/
|
|
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
|
-
"@
|
|
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",
|