@idemcore/mcp 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +13 -0
- package/README.md +136 -2
- package/dist/api.js +63 -0
- package/dist/audit.js +61 -0
- package/dist/bytes.js +42 -0
- package/dist/canonical.js +34 -0
- package/dist/config.js +57 -0
- package/dist/default-keys.js +9 -0
- package/dist/hash.js +78 -0
- package/dist/index.js +11 -0
- package/dist/keys.js +31 -0
- package/dist/receipt.js +136 -0
- package/dist/server.js +252 -0
- package/package.json +49 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Copyright 2026 Don Wick
|
|
2
|
+
|
|
3
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
you may not use this file except in compliance with the License.
|
|
5
|
+
You may obtain a copy of the License at
|
|
6
|
+
|
|
7
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
|
|
9
|
+
Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
See the License for the specific language governing permissions and
|
|
13
|
+
limitations under the License.
|
package/README.md
CHANGED
|
@@ -1,3 +1,137 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Idemcore MCP server
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Give any MCP-capable assistant or agent (Claude Desktop, Claude Code, Cursor, VS Code, Windsurf, Codex and others) two abilities:
|
|
4
|
+
|
|
5
|
+
1. **Chat with a receipt.** Call an open model running on Idemcore and get back the answer together with a signed receipt that records which model, weights, inputs and settings produced it.
|
|
6
|
+
2. **Verify a receipt.** Check any Idemcore receipt locally, with no network access and no account.
|
|
7
|
+
|
|
8
|
+
Status: 0.1.0. Published as `@idemcore/mcp`. Verifies receipt formats r1 and r2.
|
|
9
|
+
|
|
10
|
+
## Tools
|
|
11
|
+
|
|
12
|
+
| Tool | Network | What it does |
|
|
13
|
+
|---|---|---|
|
|
14
|
+
| `idemcore_chat` | API key | Sends an OpenAI-compatible chat request, returns the answer, the receipt and a local signature check. Options: `privacy` (`hash_only` / `full`), `token_log` (`none` / `per_token`), `seed`, `model`, `max_tokens`, `temperature`. |
|
|
15
|
+
| `idemcore_verify_receipt` | none | **L1**: format and Ed25519 signature against trusted Idemcore keys. **L2**: recomputes the chain digests from a public token log (`log_text` or `log_path`; one `pi j h hc` line per position plus `ALL <fnv>`). **Disclosure**: checks a prompt, output token ids or the displayed answer text against the receipt's hashes (salt needed for `hash_only`). |
|
|
16
|
+
| `idemcore_get_receipt` | API key | Fetches a stored receipt (optionally with its per-token log) and verifies it. |
|
|
17
|
+
| `idemcore_export_audit` | API key | Exports receipts and audit events for a time range, saves the bundle locally and checks the event hash chain and the signed daily Merkle roots. |
|
|
18
|
+
| `idemcore_verify_audit` | none | Checks a saved audit bundle offline. |
|
|
19
|
+
| `idemcore_trusted_keys` | optional | Lists the public keys used for signature checks and where each came from. |
|
|
20
|
+
|
|
21
|
+
Verdicts: `VERIFIED` (all requested checks passed), `FAILED` (a signature, hash or chain does not match: the receipt or data was altered), `UNVERIFIED` (the signing key is not trusted or no key set could be loaded).
|
|
22
|
+
|
|
23
|
+
## Configuration (environment)
|
|
24
|
+
|
|
25
|
+
| Variable | Default | Meaning |
|
|
26
|
+
|---|---|---|
|
|
27
|
+
| `IDEMCORE_API_KEY` | (none) | Your Idemcore API key. Needed only for chat, get and export. Sent only in the `Authorization` header, never echoed in tool output. |
|
|
28
|
+
| `IDEMCORE_BASE_URL` | `https://api.idemcore.net` | API base URL (use your self-hosted gateway if you run Idemcore on-prem). |
|
|
29
|
+
| `IDEMCORE_MODEL` | (server default) | Default model id for `idemcore_chat`. |
|
|
30
|
+
| `IDEMCORE_TRUSTED_KEYS` | (none) | Path to a JWK / JWKS file of trusted signing keys. Use this for fully offline verification or for a self-hosted signer. |
|
|
31
|
+
| `IDEMCORE_DEFAULT_KEYS` | `on` | The Idemcore production public key (`ed25519:2fab59995ac55e4f`, public half only) is bundled in `src/default-keys.ts` and trusted by default. Set `off` to trust only your file / `IDEMCORE_KEYS_URL`. |
|
|
32
|
+
| `IDEMCORE_KEYS_URL` | `<base>/v1/keys` | Where to fetch the issuer's published keys. Set to `off` to disable. |
|
|
33
|
+
| `IDEMCORE_EXPORT_DIR` | `./idemcore-exports` | Where audit exports are saved. |
|
|
34
|
+
| `IDEMCORE_TIMEOUT_MS` | `120000` | Request timeout. |
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
Requires Node.js 20 or newer. Fastest path:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npx -y @idemcore/mcp
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Or clone and build locally: `npm install && npm run build` (produces `dist/index.js`).
|
|
45
|
+
|
|
46
|
+
### Claude Desktop (`claude_desktop_config.json`)
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{
|
|
50
|
+
"mcpServers": {
|
|
51
|
+
"idemcore": {
|
|
52
|
+
"command": "npx",
|
|
53
|
+
"args": ["-y", "@idemcore/mcp"],
|
|
54
|
+
"env": { "IDEMCORE_API_KEY": "ick_..." }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Claude Code
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
claude mcp add idemcore -e IDEMCORE_API_KEY=ick_... -- npx -y @idemcore/mcp
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Cursor (`~/.cursor/mcp.json` or `.cursor/mcp.json`)
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
{
|
|
70
|
+
"mcpServers": {
|
|
71
|
+
"idemcore": {
|
|
72
|
+
"command": "npx",
|
|
73
|
+
"args": ["-y", "@idemcore/mcp"],
|
|
74
|
+
"env": { "IDEMCORE_API_KEY": "ick_..." }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### VS Code (`.vscode/mcp.json`)
|
|
81
|
+
|
|
82
|
+
```json
|
|
83
|
+
{
|
|
84
|
+
"inputs": [{ "type": "promptString", "id": "idemcore-key", "description": "Idemcore API key", "password": true }],
|
|
85
|
+
"servers": {
|
|
86
|
+
"idemcore": {
|
|
87
|
+
"type": "stdio",
|
|
88
|
+
"command": "npx",
|
|
89
|
+
"args": ["-y", "@idemcore/mcp"],
|
|
90
|
+
"env": { "IDEMCORE_API_KEY": "${input:idemcore-key}" }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Windsurf (`~/.codeium/windsurf/mcp_config.json`)
|
|
97
|
+
|
|
98
|
+
Same shape as Cursor (`mcpServers` → `idemcore`).
|
|
99
|
+
|
|
100
|
+
### Codex CLI (`~/.codex/config.toml`)
|
|
101
|
+
|
|
102
|
+
```toml
|
|
103
|
+
[mcp_servers.idemcore]
|
|
104
|
+
command = "node"
|
|
105
|
+
args = ["/path/to/idemcore-mcp/dist/index.js"]
|
|
106
|
+
env = { IDEMCORE_API_KEY = "ick_..." }
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Verify-only (no account)
|
|
110
|
+
|
|
111
|
+
Leave out `IDEMCORE_API_KEY`. `idemcore_verify_receipt` and `idemcore_verify_audit` still work; set `IDEMCORE_KEYS_URL=off` to run with no network at all (the bundled production key is still trusted; add `IDEMCORE_TRUSTED_KEYS=/path/to/idemcore-keys.json` for other signers).
|
|
112
|
+
|
|
113
|
+
## Example prompts
|
|
114
|
+
|
|
115
|
+
- "Ask Idemcore: summarise this contract clause, and keep the receipt."
|
|
116
|
+
- "Verify this receipt and tell me whether the answer text matches it."
|
|
117
|
+
- "Export last week's audit bundle and check it."
|
|
118
|
+
|
|
119
|
+
## Receipt API assumptions
|
|
120
|
+
|
|
121
|
+
The server reads the receipt from the response body field `idemcore_receipt`, or from the `Idemcore-Receipt` header (JSON or base64url JSON). For `hash_only` receipts it uses `idemcore_salt` (base64url) from the response, when present, to check the answer locally. Request options go in the `idemcore` object of the chat request.
|
|
122
|
+
|
|
123
|
+
Displayed-text checks use the optional receipt field `output_text_hash` (proposed for r1): a tagged SHA-256 of the answer text after normalisation (Unicode NFC, every run of whitespace replaced by one space, leading and trailing space removed). The same rule is used by the Idemcore browser extension, so layout differences on a web page do not cause false alarms while any change of wording does.
|
|
124
|
+
|
|
125
|
+
## Development
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
npm test # vitest: reference vectors + MCP tools against a local mock API + stdio smoke test
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`vectors/receipts-r1.json` is generated by `scripts/gen_vectors.py` from the reference receipts library with an ephemeral signing key that is discarded after generation; only the public key is stored.
|
|
132
|
+
|
|
133
|
+
A remote (Streamable HTTP) variant for Cloudflare Workers can reuse `createServer()` from `src/server.ts`; it is not deployed yet.
|
|
134
|
+
|
|
135
|
+
## License
|
|
136
|
+
|
|
137
|
+
Apache-2.0. Copyright 2026 Don Wick. Contact: don.wick@idemcore.net
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export class ApiError extends Error {
|
|
2
|
+
status;
|
|
3
|
+
constructor(status, message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.status = status;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
export class IdemcoreApi {
|
|
9
|
+
cfg;
|
|
10
|
+
constructor(cfg) {
|
|
11
|
+
this.cfg = cfg;
|
|
12
|
+
}
|
|
13
|
+
headers(json = true) {
|
|
14
|
+
if (!this.cfg.apiKey)
|
|
15
|
+
throw new ApiError(0, "IDEMCORE_API_KEY is not set; add it to the MCP server env");
|
|
16
|
+
const h = { authorization: `Bearer ${this.cfg.apiKey}`, "user-agent": "idemcore-mcp/0.1.0" };
|
|
17
|
+
if (json)
|
|
18
|
+
h["content-type"] = "application/json";
|
|
19
|
+
return h;
|
|
20
|
+
}
|
|
21
|
+
async call(method, path, body) {
|
|
22
|
+
const r = await this.cfg.fetchImpl(this.cfg.baseUrl + path, {
|
|
23
|
+
method, headers: this.headers(body !== undefined), body: body === undefined ? undefined : JSON.stringify(body),
|
|
24
|
+
signal: AbortSignal.timeout(this.cfg.timeoutMs),
|
|
25
|
+
});
|
|
26
|
+
const text = await r.text();
|
|
27
|
+
let data = null;
|
|
28
|
+
try {
|
|
29
|
+
data = text ? JSON.parse(text) : null;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
data = { raw: text.slice(0, 2000) };
|
|
33
|
+
}
|
|
34
|
+
if (!r.ok) {
|
|
35
|
+
const msg = data?.error?.message ?? data?.error ?? data?.message ?? text.slice(0, 300);
|
|
36
|
+
throw new ApiError(r.status, `Idemcore API ${method} ${path} failed: HTTP ${r.status} ${typeof msg === "string" ? msg : JSON.stringify(msg)}`);
|
|
37
|
+
}
|
|
38
|
+
return { data, headers: r.headers };
|
|
39
|
+
}
|
|
40
|
+
chat(body) { return this.call("POST", "/v1/chat/completions", body); }
|
|
41
|
+
getReceipt(id, includeLog) {
|
|
42
|
+
return this.call("GET", `/v1/receipts/${encodeURIComponent(id)}${includeLog ? "?include_log=1" : ""}`);
|
|
43
|
+
}
|
|
44
|
+
exportAudit(body) { return this.call("POST", "/v1/audit/export", body); }
|
|
45
|
+
}
|
|
46
|
+
/** Receipt from the body field `idemcore_receipt` or the `Idemcore-Receipt` header (JSON or base64url JSON). */
|
|
47
|
+
export function extractReceipt(data, headers) {
|
|
48
|
+
if (data && typeof data.idemcore_receipt === "object" && data.idemcore_receipt)
|
|
49
|
+
return data.idemcore_receipt;
|
|
50
|
+
const h = headers.get("idemcore-receipt");
|
|
51
|
+
if (!h)
|
|
52
|
+
return null;
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(h);
|
|
55
|
+
}
|
|
56
|
+
catch { /* fall through */ }
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(Buffer.from(h, "base64url").toString("utf8"));
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
package/dist/audit.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Offline checks for audit exports: hash-chained events and signed daily Merkle roots.
|
|
2
|
+
import { canonicalBytes } from "./canonical.js";
|
|
3
|
+
import { b64uDec, concat, hex, sha256, unhex, utf8 } from "./bytes.js";
|
|
4
|
+
const GENESIS = "0".repeat(64);
|
|
5
|
+
const EVENT_FIELDS = ["tenant_id", "seq", "ts", "event_type", "receipt_id", "actor", "payload", "prev_hash"];
|
|
6
|
+
const ROOT_DOMAIN = utf8("idemcore-audit-root/r1\n");
|
|
7
|
+
export async function eventHash(ev) {
|
|
8
|
+
const body = {};
|
|
9
|
+
for (const k of EVENT_FIELDS)
|
|
10
|
+
body[k] = ev[k] === undefined ? null : ev[k];
|
|
11
|
+
return hex(await sha256(concat(utf8("idemcore/audit/r1\0"), canonicalBytes(body))));
|
|
12
|
+
}
|
|
13
|
+
export async function verifyEventChain(events) {
|
|
14
|
+
const errs = [];
|
|
15
|
+
let prev = GENESIS, seq = null;
|
|
16
|
+
for (const ev of events) {
|
|
17
|
+
if (seq !== null && ev.seq !== seq + 1)
|
|
18
|
+
errs.push(`seq gap at ${ev.seq}`);
|
|
19
|
+
if (ev.prev_hash !== prev)
|
|
20
|
+
errs.push(`prev_hash mismatch at seq ${ev.seq}`);
|
|
21
|
+
if ((await eventHash(ev)) !== ev.event_hash)
|
|
22
|
+
errs.push(`event_hash mismatch at seq ${ev.seq}`);
|
|
23
|
+
prev = ev.event_hash;
|
|
24
|
+
seq = ev.seq;
|
|
25
|
+
}
|
|
26
|
+
return errs;
|
|
27
|
+
}
|
|
28
|
+
async function mth(items) {
|
|
29
|
+
if (items.length === 1)
|
|
30
|
+
return sha256(concat(new Uint8Array([0]), items[0]));
|
|
31
|
+
let k = 1;
|
|
32
|
+
while (k * 2 < items.length)
|
|
33
|
+
k *= 2;
|
|
34
|
+
return sha256(concat(new Uint8Array([1]), await mth(items.slice(0, k)), await mth(items.slice(k))));
|
|
35
|
+
}
|
|
36
|
+
export async function merkleRoot(hashesHex) {
|
|
37
|
+
if (!hashesHex.length)
|
|
38
|
+
return hex(await sha256(new Uint8Array(0)));
|
|
39
|
+
return hex(await mth(hashesHex.map(unhex)));
|
|
40
|
+
}
|
|
41
|
+
export async function verifyRoot(root, events, keys) {
|
|
42
|
+
const errs = [];
|
|
43
|
+
const pk = keys.find((k) => k.keyId === root.key_id);
|
|
44
|
+
const { signature, ...body } = root;
|
|
45
|
+
if (!pk)
|
|
46
|
+
errs.push(`unknown root key_id ${String(root.key_id)}`);
|
|
47
|
+
else {
|
|
48
|
+
let ok = false;
|
|
49
|
+
try {
|
|
50
|
+
ok = await pk.verify(b64uDec(String(signature)), concat(ROOT_DOMAIN, canonicalBytes(body)));
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
ok = false;
|
|
54
|
+
}
|
|
55
|
+
if (!ok)
|
|
56
|
+
errs.push("root signature does not verify");
|
|
57
|
+
}
|
|
58
|
+
if ((await merkleRoot(events.map((e) => e.event_hash))) !== root.merkle_root)
|
|
59
|
+
errs.push("merkle_root mismatch");
|
|
60
|
+
return errs;
|
|
61
|
+
}
|
package/dist/bytes.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const te = new TextEncoder();
|
|
2
|
+
export const utf8 = (s) => te.encode(s);
|
|
3
|
+
export function hex(b) {
|
|
4
|
+
const u = b instanceof Uint8Array ? b : new Uint8Array(b);
|
|
5
|
+
let s = "";
|
|
6
|
+
for (const x of u)
|
|
7
|
+
s += x.toString(16).padStart(2, "0");
|
|
8
|
+
return s;
|
|
9
|
+
}
|
|
10
|
+
export function unhex(s) {
|
|
11
|
+
if (s.length % 2)
|
|
12
|
+
throw new Error("odd hex length");
|
|
13
|
+
const out = new Uint8Array(s.length / 2);
|
|
14
|
+
for (let i = 0; i < out.length; i++)
|
|
15
|
+
out[i] = parseInt(s.slice(2 * i, 2 * i + 2), 16);
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
export function concat(...parts) {
|
|
19
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
20
|
+
let o = 0;
|
|
21
|
+
for (const p of parts) {
|
|
22
|
+
out.set(p, o);
|
|
23
|
+
o += p.length;
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
export function b64u(b) {
|
|
28
|
+
return Buffer.from(b).toString("base64url");
|
|
29
|
+
}
|
|
30
|
+
export function b64uDec(s) {
|
|
31
|
+
if (!/^[A-Za-z0-9_-]*$/.test(s))
|
|
32
|
+
throw new Error("not base64url");
|
|
33
|
+
return new Uint8Array(Buffer.from(s, "base64url"));
|
|
34
|
+
}
|
|
35
|
+
export async function sha256(data) {
|
|
36
|
+
return new Uint8Array(await crypto.subtle.digest("SHA-256", data));
|
|
37
|
+
}
|
|
38
|
+
export function le64(n) {
|
|
39
|
+
const b = new Uint8Array(8);
|
|
40
|
+
new DataView(b.buffer).setBigUint64(0, BigInt.asUintN(64, n), true);
|
|
41
|
+
return b;
|
|
42
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Canonical JSON for receipts r1: keys sorted by code point, no whitespace,
|
|
2
|
+
// UTF-8, non-ASCII literal, integers only (no floats).
|
|
3
|
+
function cmp(a, b) {
|
|
4
|
+
const A = Array.from(a), B = Array.from(b);
|
|
5
|
+
for (let i = 0; i < Math.min(A.length, B.length); i++) {
|
|
6
|
+
const d = A[i].codePointAt(0) - B[i].codePointAt(0);
|
|
7
|
+
if (d)
|
|
8
|
+
return d;
|
|
9
|
+
}
|
|
10
|
+
return A.length - B.length;
|
|
11
|
+
}
|
|
12
|
+
function enc(v, path) {
|
|
13
|
+
if (v === null)
|
|
14
|
+
return "null";
|
|
15
|
+
if (typeof v === "boolean")
|
|
16
|
+
return v ? "true" : "false";
|
|
17
|
+
if (typeof v === "string")
|
|
18
|
+
return JSON.stringify(v);
|
|
19
|
+
if (typeof v === "number") {
|
|
20
|
+
if (!Number.isSafeInteger(v))
|
|
21
|
+
throw new Error(`${path}: only integers are allowed`);
|
|
22
|
+
return String(v);
|
|
23
|
+
}
|
|
24
|
+
if (Array.isArray(v))
|
|
25
|
+
return "[" + v.map((x, i) => enc(x, `${path}[${i}]`)).join(",") + "]";
|
|
26
|
+
if (typeof v === "object") {
|
|
27
|
+
const o = v;
|
|
28
|
+
const keys = Object.keys(o).filter((k) => o[k] !== undefined).sort(cmp);
|
|
29
|
+
return "{" + keys.map((k) => JSON.stringify(k) + ":" + enc(o[k], `${path}.${k}`)).join(",") + "}";
|
|
30
|
+
}
|
|
31
|
+
throw new Error(`${path}: unsupported type`);
|
|
32
|
+
}
|
|
33
|
+
export const canonicalString = (v) => enc(v, "$");
|
|
34
|
+
export const canonicalBytes = (v) => new TextEncoder().encode(canonicalString(v));
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { importKeySet } from "./keys.js";
|
|
3
|
+
import { DEFAULT_TRUSTED_KEYS } from "./default-keys.js";
|
|
4
|
+
export function configFromEnv(env = process.env) {
|
|
5
|
+
const baseUrl = (env.IDEMCORE_BASE_URL || "https://api.idemcore.net").replace(/\/+$/, "");
|
|
6
|
+
return {
|
|
7
|
+
baseUrl,
|
|
8
|
+
apiKey: env.IDEMCORE_API_KEY || undefined,
|
|
9
|
+
keysUrl: env.IDEMCORE_KEYS_URL === "off" ? undefined : (env.IDEMCORE_KEYS_URL || `${baseUrl}/v1/keys`),
|
|
10
|
+
trustedKeysFile: env.IDEMCORE_TRUSTED_KEYS || undefined,
|
|
11
|
+
defaultKeys: env.IDEMCORE_DEFAULT_KEYS !== "off",
|
|
12
|
+
exportDir: env.IDEMCORE_EXPORT_DIR || "./idemcore-exports",
|
|
13
|
+
timeoutMs: Number(env.IDEMCORE_TIMEOUT_MS || 120000),
|
|
14
|
+
fetchImpl: fetch,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/** Trusted key store: bundled production keys, a local file, then the issuer's published key set (cached). */
|
|
18
|
+
export class KeyStore {
|
|
19
|
+
cfg;
|
|
20
|
+
cache = null;
|
|
21
|
+
constructor(cfg) {
|
|
22
|
+
this.cfg = cfg;
|
|
23
|
+
}
|
|
24
|
+
async get() {
|
|
25
|
+
if (this.cache)
|
|
26
|
+
return { keys: this.cache, warnings: [] };
|
|
27
|
+
const keys = [], warnings = [];
|
|
28
|
+
if (this.cfg.defaultKeys !== false)
|
|
29
|
+
keys.push(...await importKeySet(DEFAULT_TRUSTED_KEYS, "bundled:idemcore-production"));
|
|
30
|
+
if (this.cfg.trustedKeysFile) {
|
|
31
|
+
try {
|
|
32
|
+
for (const k of await importKeySet(JSON.parse(readFileSync(this.cfg.trustedKeysFile, "utf8")), `file:${this.cfg.trustedKeysFile}`))
|
|
33
|
+
if (!keys.some((x) => x.keyId === k.keyId))
|
|
34
|
+
keys.push(k);
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
warnings.push(`could not load trusted keys file: ${e.message}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (this.cfg.keysUrl) {
|
|
41
|
+
try {
|
|
42
|
+
const r = await this.cfg.fetchImpl(this.cfg.keysUrl, { signal: AbortSignal.timeout(this.cfg.timeoutMs) });
|
|
43
|
+
if (!r.ok)
|
|
44
|
+
throw new Error(`HTTP ${r.status}`);
|
|
45
|
+
for (const k of await importKeySet(await r.json(), this.cfg.keysUrl))
|
|
46
|
+
if (!keys.some((x) => x.keyId === k.keyId))
|
|
47
|
+
keys.push(k);
|
|
48
|
+
}
|
|
49
|
+
catch (e) {
|
|
50
|
+
warnings.push(`could not fetch issuer keys from ${this.cfg.keysUrl}: ${e.message}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (keys.length)
|
|
54
|
+
this.cache = keys;
|
|
55
|
+
return { keys, warnings };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Idemcore production receipt-signing public keys (public halves only), bundled so that
|
|
3
|
+
* receipts verify offline without trusting the network. Rotations add entries here; the
|
|
4
|
+
* live set is also published at GET https://api.idemcore.net/v1/keys.
|
|
5
|
+
* Disable with IDEMCORE_DEFAULT_KEYS=off.
|
|
6
|
+
*/
|
|
7
|
+
export const DEFAULT_TRUSTED_KEYS = [
|
|
8
|
+
{ kty: "OKP", crv: "Ed25519", x: "AdQX-eipjpmqi4GDvqYHIep2Vb7l5Vas4xncNN453OY", kid: "ed25519:2fab59995ac55e4f" },
|
|
9
|
+
];
|
package/dist/hash.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Domain-separated hashes and token-log chain digests (receipts r1).
|
|
2
|
+
import { canonicalBytes } from "./canonical.js";
|
|
3
|
+
import { concat, hex, le64, sha256, utf8 } from "./bytes.js";
|
|
4
|
+
export async function taggedHash(tag, ...parts) {
|
|
5
|
+
const chunks = [utf8(`idemcore/${tag}/r1`), new Uint8Array([0])];
|
|
6
|
+
for (const p of parts)
|
|
7
|
+
chunks.push(le64(BigInt(p.length)), p);
|
|
8
|
+
return hex(await sha256(concat(...chunks)));
|
|
9
|
+
}
|
|
10
|
+
export function tokenBytes(ids) {
|
|
11
|
+
const b = new Uint8Array(ids.length * 4);
|
|
12
|
+
const dv = new DataView(b.buffer);
|
|
13
|
+
ids.forEach((id, i) => {
|
|
14
|
+
if (!Number.isInteger(id) || id < 0 || id > 0xffffffff)
|
|
15
|
+
throw new Error(`token id out of range at ${i}`);
|
|
16
|
+
dv.setUint32(i * 4, id, true);
|
|
17
|
+
});
|
|
18
|
+
return b;
|
|
19
|
+
}
|
|
20
|
+
export const promptHash = (messages, salt) => taggedHash("prompt", salt, canonicalBytes(messages));
|
|
21
|
+
export const tokenHash = (ids, salt) => taggedHash("tokens", salt, tokenBytes(ids));
|
|
22
|
+
// Display-text normalisation: NFC, every whitespace run becomes one U+0020, trimmed.
|
|
23
|
+
const WS = /[\t\n\v\f\r \u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]+/g;
|
|
24
|
+
export const normalizeText = (s) => s.normalize("NFC").replace(WS, " ").replace(/^ | $/g, "");
|
|
25
|
+
/** Proposed optional field `output_text_hash`: binds the displayed answer text. */
|
|
26
|
+
export const textHash = (text, salt) => taggedHash("text", salt, utf8(normalizeText(text)));
|
|
27
|
+
export function parseLog(text) {
|
|
28
|
+
const lines = [];
|
|
29
|
+
let all = null;
|
|
30
|
+
text.split(/\r?\n/).forEach((raw, n) => {
|
|
31
|
+
const s = raw.trim();
|
|
32
|
+
if (!s || s.startsWith("#"))
|
|
33
|
+
return;
|
|
34
|
+
const f = s.split(/\s+/);
|
|
35
|
+
if (f[0] === "ALL") {
|
|
36
|
+
if (f.length !== 2 || !/^[0-9a-fA-F]{1,16}$/.test(f[1]))
|
|
37
|
+
throw new Error(`log line ${n + 1}: malformed ALL line`);
|
|
38
|
+
all = f[1].toLowerCase();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (!/^\d+$/.test(f[0]))
|
|
42
|
+
return; // reserved keyword lines
|
|
43
|
+
if (f.length !== 4)
|
|
44
|
+
throw new Error(`log line ${n + 1}: not a public log line (expected 4 fields: pi j h hc)`);
|
|
45
|
+
if (!/^\d+$/.test(f[1]) || !/^[0-9a-fA-F]{1,16}$/.test(f[2]) || !/^[0-9a-fA-F]{1,16}$/.test(f[3]))
|
|
46
|
+
throw new Error(`log line ${n + 1}: malformed field`);
|
|
47
|
+
const ln = { pi: BigInt(f[0]), j: BigInt(f[1]), h: BigInt("0x" + f[2]), hc: BigInt("0x" + f[3]) };
|
|
48
|
+
if (ln.pi >= 2n ** 64n || ln.j >= 2n ** 64n)
|
|
49
|
+
throw new Error(`log line ${n + 1}: index out of range`);
|
|
50
|
+
lines.push(ln);
|
|
51
|
+
});
|
|
52
|
+
return { lines, all };
|
|
53
|
+
}
|
|
54
|
+
const FNV_OFFSET = 0xcbf29ce484222325n, FNV_PRIME = 0x100000001b3n, MASK = (1n << 64n) - 1n;
|
|
55
|
+
function fnv(data, start) {
|
|
56
|
+
let h = start;
|
|
57
|
+
for (const b of data)
|
|
58
|
+
h = ((h ^ BigInt(b)) * FNV_PRIME) & MASK;
|
|
59
|
+
return h;
|
|
60
|
+
}
|
|
61
|
+
export function chainFnv(lines) {
|
|
62
|
+
let a = FNV_OFFSET;
|
|
63
|
+
for (const l of lines) {
|
|
64
|
+
a = fnv(le64(l.h), a);
|
|
65
|
+
a = fnv(le64(l.hc), a);
|
|
66
|
+
}
|
|
67
|
+
return a.toString(16).padStart(16, "0");
|
|
68
|
+
}
|
|
69
|
+
export async function chainDigest(lines) {
|
|
70
|
+
let c = await sha256(utf8("idemcore/chain/r1\0"));
|
|
71
|
+
for (const l of lines)
|
|
72
|
+
c = await sha256(concat(c, le64(l.pi), le64(l.j), le64(l.h), le64(l.hc)));
|
|
73
|
+
return hex(c);
|
|
74
|
+
}
|
|
75
|
+
export async function logDigests(text) {
|
|
76
|
+
const p = parseLog(text);
|
|
77
|
+
return { positions: p.lines.length, chain_digest: await chainDigest(p.lines), chain_fnv: chainFnv(p.lines), all_line: p.all };
|
|
78
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { configFromEnv } from "./config.js";
|
|
4
|
+
import { createServer, VERSION } from "./server.js";
|
|
5
|
+
if (process.argv.includes("--version")) {
|
|
6
|
+
console.log(VERSION);
|
|
7
|
+
process.exit(0);
|
|
8
|
+
}
|
|
9
|
+
const server = createServer(configFromEnv());
|
|
10
|
+
await server.connect(new StdioServerTransport());
|
|
11
|
+
console.error(`idemcore-mcp ${VERSION} ready on stdio`);
|
package/dist/keys.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Trusted Ed25519 public keys (JWK) for receipt signatures.
|
|
2
|
+
import { b64uDec, hex, sha256 } from "./bytes.js";
|
|
3
|
+
export async function keyIdFor(raw) {
|
|
4
|
+
return "ed25519:" + hex((await sha256(raw)).slice(0, 8));
|
|
5
|
+
}
|
|
6
|
+
export async function importJwk(jwk, source) {
|
|
7
|
+
if (jwk?.kty !== "OKP" || jwk?.crv !== "Ed25519" || typeof jwk.x !== "string")
|
|
8
|
+
throw new Error("not an Ed25519 public JWK");
|
|
9
|
+
const raw = b64uDec(jwk.x);
|
|
10
|
+
if (raw.length !== 32)
|
|
11
|
+
throw new Error("Ed25519 public key must be 32 bytes");
|
|
12
|
+
const keyId = await keyIdFor(raw);
|
|
13
|
+
if (jwk.kid && jwk.kid !== keyId)
|
|
14
|
+
throw new Error(`kid ${jwk.kid} does not match key bytes (${keyId})`);
|
|
15
|
+
const pk = await crypto.subtle.importKey("raw", raw, { name: "Ed25519" }, false, ["verify"]);
|
|
16
|
+
return {
|
|
17
|
+
keyId, jwk: { kty: "OKP", crv: "Ed25519", x: jwk.x, kid: keyId }, source,
|
|
18
|
+
verify: async (sig, msg) => { try {
|
|
19
|
+
return await crypto.subtle.verify({ name: "Ed25519" }, pk, sig, msg);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return false;
|
|
23
|
+
} },
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/** Accepts a JWK, an array of JWKs, or a JWKS ({ keys: [...] }). */
|
|
27
|
+
export async function importKeySet(data, source) {
|
|
28
|
+
const list = Array.isArray(data) ? data : (data && typeof data === "object" && Array.isArray(data.keys))
|
|
29
|
+
? data.keys : [data];
|
|
30
|
+
return Promise.all(list.map((j) => importJwk(j, source)));
|
|
31
|
+
}
|
package/dist/receipt.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Offline verification of Idemcore receipts r1 and r2 (L1 signature, L2 chain, disclosure).
|
|
2
|
+
// r2 only renames seed→variant, chain_fnv→chain_check, kernel_version→engine_version; signing is identical.
|
|
3
|
+
import { canonicalBytes, canonicalString } from "./canonical.js";
|
|
4
|
+
import { b64uDec, concat, utf8 } from "./bytes.js";
|
|
5
|
+
import { logDigests, promptHash, textHash, tokenHash } from "./hash.js";
|
|
6
|
+
export const RECEIPT_VERSION = "r2";
|
|
7
|
+
export const RECEIPT_VERSIONS = ["r1", "r2"];
|
|
8
|
+
const R2_RENAMES = { seed: "variant", chain_fnv: "chain_check", kernel_version: "engine_version" };
|
|
9
|
+
const SIG_DOMAIN = utf8("idemcore-receipt/r1\n");
|
|
10
|
+
const HEX64 = /^[0-9a-f]{64}$/, HEX16 = /^[0-9a-f]{16}$/;
|
|
11
|
+
const REQUIRED_R1 = {
|
|
12
|
+
receipt_version: "str", receipt_id: "str", issued_at: "str", tenant_id: "str", model_id: "str",
|
|
13
|
+
weights_hash: "str", kernel_version: "str", engine_build: "str", privacy: "str", prompt_hash: "str",
|
|
14
|
+
params_hash: "str", seed: "int", decoding: "str", prompt_tokens: "int", output_tokens: "int",
|
|
15
|
+
output_token_hash: "str", chain_digest: "str", chain_fnv: "str", log_ref: "str", key_id: "str", signature: "str",
|
|
16
|
+
};
|
|
17
|
+
const REQUIRED_R2 = Object.fromEntries(Object.entries(REQUIRED_R1).map(([k, t]) => [R2_RENAMES[k] ?? k, t]));
|
|
18
|
+
/** Name of the 16-hex chain check field for this receipt's version. */
|
|
19
|
+
export const chainCheckField = (r) => (r.receipt_version === "r2" ? "chain_check" : "chain_fnv");
|
|
20
|
+
// output_text_hash is a proposed optional field (see EMBED_SPEC in the extension repo).
|
|
21
|
+
const OPTIONAL = { finish_reason: "str", request_id: "str", output_text_hash: "str" };
|
|
22
|
+
const HASH_FIELDS = ["weights_hash", "prompt_hash", "params_hash", "output_token_hash", "chain_digest"];
|
|
23
|
+
const result = (level, errors, extra = {}) => ({ level, ok: errors.length === 0, errors, ...extra });
|
|
24
|
+
export function signingPayload(r) {
|
|
25
|
+
const { signature: _s, ...body } = r;
|
|
26
|
+
return concat(SIG_DOMAIN, canonicalBytes(body));
|
|
27
|
+
}
|
|
28
|
+
export function checkFormat(r) {
|
|
29
|
+
if (typeof r !== "object" || r === null || Array.isArray(r))
|
|
30
|
+
return ["receipt is not an object"];
|
|
31
|
+
const o = r, errs = [];
|
|
32
|
+
if (!RECEIPT_VERSIONS.includes(o.receipt_version))
|
|
33
|
+
return [`unsupported receipt_version ${String(o.receipt_version)}`];
|
|
34
|
+
const REQUIRED = o.receipt_version === "r2" ? REQUIRED_R2 : REQUIRED_R1, CHK = chainCheckField(o);
|
|
35
|
+
for (const [k, t] of Object.entries(REQUIRED)) {
|
|
36
|
+
if (!(k in o))
|
|
37
|
+
errs.push(`missing field ${k}`);
|
|
38
|
+
else if (t === "int" ? !Number.isSafeInteger(o[k]) : typeof o[k] !== "string")
|
|
39
|
+
errs.push(`field ${k} must be ${t}`);
|
|
40
|
+
}
|
|
41
|
+
for (const k of Object.keys(o))
|
|
42
|
+
if (!(k in REQUIRED) && !(k in OPTIONAL))
|
|
43
|
+
errs.push(`unknown field ${k}`);
|
|
44
|
+
for (const k of Object.keys(OPTIONAL))
|
|
45
|
+
if (k in o && typeof o[k] !== "string")
|
|
46
|
+
errs.push(`field ${k} must be str`);
|
|
47
|
+
if (errs.length)
|
|
48
|
+
return errs;
|
|
49
|
+
for (const k of HASH_FIELDS)
|
|
50
|
+
if (!HEX64.test(o[k]))
|
|
51
|
+
errs.push(`${k} must be 64 lowercase hex`);
|
|
52
|
+
if ("output_text_hash" in o && !HEX64.test(o.output_text_hash))
|
|
53
|
+
errs.push("output_text_hash must be 64 lowercase hex");
|
|
54
|
+
if (!HEX16.test(o[CHK]))
|
|
55
|
+
errs.push(`${CHK} must be 16 lowercase hex`);
|
|
56
|
+
if (o.privacy !== "hash_only" && o.privacy !== "full")
|
|
57
|
+
errs.push("privacy must be hash_only or full");
|
|
58
|
+
if (o.prompt_tokens < 1 || o.output_tokens < 0)
|
|
59
|
+
errs.push("token counts out of range");
|
|
60
|
+
if (!/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d{1,6})?Z$/.test(o.issued_at))
|
|
61
|
+
errs.push("issued_at must be RFC 3339 UTC (Z)");
|
|
62
|
+
try {
|
|
63
|
+
canonicalString(o);
|
|
64
|
+
}
|
|
65
|
+
catch (e) {
|
|
66
|
+
errs.push(e.message);
|
|
67
|
+
}
|
|
68
|
+
return errs;
|
|
69
|
+
}
|
|
70
|
+
export async function verifyL1(r, keys) {
|
|
71
|
+
const errs = checkFormat(r);
|
|
72
|
+
if (errs.length)
|
|
73
|
+
return result("L1", errs);
|
|
74
|
+
const rc = r;
|
|
75
|
+
const pk = keys.find((k) => k.keyId === rc.key_id);
|
|
76
|
+
if (!pk)
|
|
77
|
+
return result("L1", [`unknown key_id ${String(rc.key_id)} (not in the trusted key set)`]);
|
|
78
|
+
let sig;
|
|
79
|
+
try {
|
|
80
|
+
sig = b64uDec(rc.signature);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return result("L1", ["signature is not base64url"]);
|
|
84
|
+
}
|
|
85
|
+
if (sig.length !== 64 || !(await pk.verify(sig, signingPayload(rc))))
|
|
86
|
+
return result("L1", ["signature does not verify"]);
|
|
87
|
+
return result("L1", [], { key_id: pk.keyId, key_source: pk.source });
|
|
88
|
+
}
|
|
89
|
+
export async function verifyL2(r, logText) {
|
|
90
|
+
let d;
|
|
91
|
+
try {
|
|
92
|
+
d = await logDigests(logText);
|
|
93
|
+
}
|
|
94
|
+
catch (e) {
|
|
95
|
+
return result("L2", [`log parse error: ${e.message}`]);
|
|
96
|
+
}
|
|
97
|
+
const errs = [];
|
|
98
|
+
if (d.chain_digest !== r.chain_digest)
|
|
99
|
+
errs.push("chain_digest mismatch");
|
|
100
|
+
const CHK = chainCheckField(r);
|
|
101
|
+
if (d.chain_fnv !== r[CHK])
|
|
102
|
+
errs.push(`${CHK} mismatch`);
|
|
103
|
+
if (d.all_line !== null && d.all_line !== d.chain_fnv)
|
|
104
|
+
errs.push("log ALL line inconsistent with its own lines");
|
|
105
|
+
const want = Number(r.prompt_tokens ?? 0) + Number(r.output_tokens ?? 0);
|
|
106
|
+
if (d.positions !== want)
|
|
107
|
+
errs.push(`log covers ${d.positions} positions, receipt says ${want}`);
|
|
108
|
+
return result("L2", errs, { positions: d.positions });
|
|
109
|
+
}
|
|
110
|
+
export async function verifyDisclosure(r, d) {
|
|
111
|
+
const salt = r.privacy === "full" ? new Uint8Array(0) : d.salt;
|
|
112
|
+
if (salt == null)
|
|
113
|
+
return result("disclosure", ["salt required for hash_only receipts"]);
|
|
114
|
+
const errs = [], checked = [];
|
|
115
|
+
if (d.messages !== undefined) {
|
|
116
|
+
checked.push("prompt_hash");
|
|
117
|
+
if ((await promptHash(d.messages, salt)) !== r.prompt_hash)
|
|
118
|
+
errs.push("prompt_hash mismatch");
|
|
119
|
+
}
|
|
120
|
+
if (d.outputIds !== undefined) {
|
|
121
|
+
checked.push("output_token_hash");
|
|
122
|
+
if ((await tokenHash(d.outputIds, salt)) !== r.output_token_hash)
|
|
123
|
+
errs.push("output_token_hash mismatch");
|
|
124
|
+
}
|
|
125
|
+
if (d.outputText !== undefined) {
|
|
126
|
+
if (typeof r.output_text_hash !== "string") {
|
|
127
|
+
errs.push("receipt has no output_text_hash; displayed text cannot be bound (use output token ids instead)");
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
checked.push("output_text_hash");
|
|
131
|
+
if ((await textHash(d.outputText, salt)) !== r.output_text_hash)
|
|
132
|
+
errs.push("output_text_hash mismatch");
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return result("disclosure", errs, { checked });
|
|
136
|
+
}
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { readFileSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { ApiError, extractReceipt, IdemcoreApi } from "./api.js";
|
|
6
|
+
import { KeyStore } from "./config.js";
|
|
7
|
+
import { importKeySet } from "./keys.js";
|
|
8
|
+
import { b64uDec } from "./bytes.js";
|
|
9
|
+
import { verifyDisclosure, verifyL1, verifyL2 } from "./receipt.js";
|
|
10
|
+
import { verifyEventChain, verifyRoot } from "./audit.js";
|
|
11
|
+
export const VERSION = "0.1.0";
|
|
12
|
+
const out = (summary, data, isError = false) => ({ content: [{ type: "text", text: `${summary}\n\n${JSON.stringify(data, null, 2)}` }], ...(isError ? { isError } : {}) });
|
|
13
|
+
const fail = (e) => out(`Error: ${e.message}`, { error: e.message, status: e instanceof ApiError ? e.status : undefined }, true);
|
|
14
|
+
function parseReceipt(x) {
|
|
15
|
+
if (typeof x === "string") {
|
|
16
|
+
const s = x.trim();
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(s);
|
|
19
|
+
}
|
|
20
|
+
catch { /* maybe base64url */ }
|
|
21
|
+
return JSON.parse(Buffer.from(s, "base64url").toString("utf8"));
|
|
22
|
+
}
|
|
23
|
+
if (x && typeof x === "object" && !Array.isArray(x)) {
|
|
24
|
+
const o = x;
|
|
25
|
+
if (o.receipt && typeof o.receipt === "object")
|
|
26
|
+
return o.receipt; // embed envelope
|
|
27
|
+
return o;
|
|
28
|
+
}
|
|
29
|
+
throw new Error("receipt must be a JSON object or string");
|
|
30
|
+
}
|
|
31
|
+
function verdict(parts) {
|
|
32
|
+
if (parts.some((p) => !p.ok && !String(p.errors[0] ?? "").startsWith("unknown key_id")))
|
|
33
|
+
return "FAILED";
|
|
34
|
+
if (parts.some((p) => !p.ok))
|
|
35
|
+
return "UNVERIFIED";
|
|
36
|
+
return "VERIFIED";
|
|
37
|
+
}
|
|
38
|
+
export async function verifyAll(input, keyStore) {
|
|
39
|
+
const receipt = parseReceipt(input.receipt);
|
|
40
|
+
const { keys, warnings } = await keyStore.get();
|
|
41
|
+
let all = keys;
|
|
42
|
+
if (input.extra_keys !== undefined)
|
|
43
|
+
all = [...keys, ...await importKeySet(input.extra_keys, "caller-supplied")];
|
|
44
|
+
const checks = [await verifyL1(receipt, all)];
|
|
45
|
+
const logText = input.log_text ?? (input.log_path ? readFileSync(input.log_path, "utf8") : undefined);
|
|
46
|
+
if (logText !== undefined)
|
|
47
|
+
checks.push(await verifyL2(receipt, logText));
|
|
48
|
+
if (input.messages !== undefined || input.output_ids !== undefined || input.output_text !== undefined) {
|
|
49
|
+
checks.push(await verifyDisclosure(receipt, {
|
|
50
|
+
messages: input.messages, outputIds: input.output_ids, outputText: input.output_text,
|
|
51
|
+
salt: input.salt_b64u !== undefined ? b64uDec(input.salt_b64u) : null,
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
const v = verdict(checks);
|
|
55
|
+
if (checks[0].ok && checks[0].key_source === "caller-supplied")
|
|
56
|
+
warnings.push("signature checked against a caller-supplied key, not an Idemcore-published key");
|
|
57
|
+
return {
|
|
58
|
+
verdict: v,
|
|
59
|
+
receipt_id: receipt.receipt_id, model_id: receipt.model_id, issued_at: receipt.issued_at, privacy: receipt.privacy,
|
|
60
|
+
checks, warnings,
|
|
61
|
+
note: logText === undefined ? "L2 not run: pass log_text or log_path to recompute the token-log chain" : undefined,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const message = z.object({ role: z.enum(["system", "user", "assistant"]), content: z.string() });
|
|
65
|
+
export function createServer(cfg) {
|
|
66
|
+
const api = new IdemcoreApi(cfg);
|
|
67
|
+
const keyStore = new KeyStore(cfg);
|
|
68
|
+
const server = new McpServer({ name: "idemcore", version: VERSION });
|
|
69
|
+
server.registerTool("idemcore_chat", {
|
|
70
|
+
title: "Chat with a signed receipt",
|
|
71
|
+
description: "Send a chat request to an open model running on Idemcore (OpenAI-compatible API). Returns the answer plus a signed receipt that proves which model, inputs and settings produced it, and checks the receipt signature locally.",
|
|
72
|
+
inputSchema: {
|
|
73
|
+
prompt: z.string().optional().describe("User message. Use this or `messages`."),
|
|
74
|
+
messages: z.array(message).optional().describe("Full chat history in OpenAI format."),
|
|
75
|
+
system: z.string().optional().describe("Optional system message, prepended when using `prompt`."),
|
|
76
|
+
model: z.string().optional().describe("Model id; defaults to IDEMCORE_MODEL or the server default."),
|
|
77
|
+
max_tokens: z.number().int().positive().max(32768).optional(),
|
|
78
|
+
temperature: z.number().min(0).max(2).optional(),
|
|
79
|
+
seed: z.number().int().optional().describe("Sampling seed recorded in the receipt."),
|
|
80
|
+
privacy: z.enum(["hash_only", "full"]).optional().describe("hash_only (default on the API) keeps only salted hashes."),
|
|
81
|
+
token_log: z.enum(["none", "per_token"]).optional().describe("per_token keeps a per-token log so L2 can be checked later."),
|
|
82
|
+
verify: z.boolean().default(true).describe("Check the receipt signature locally (offline)."),
|
|
83
|
+
},
|
|
84
|
+
}, async (a) => {
|
|
85
|
+
try {
|
|
86
|
+
const messages = a.messages ?? [...(a.system ? [{ role: "system", content: a.system }] : []), ...(a.prompt !== undefined ? [{ role: "user", content: a.prompt }] : [])];
|
|
87
|
+
if (!messages.length)
|
|
88
|
+
throw new Error("pass `prompt` or `messages`");
|
|
89
|
+
const model = a.model ?? process.env.IDEMCORE_MODEL;
|
|
90
|
+
const idem = {};
|
|
91
|
+
if (a.privacy)
|
|
92
|
+
idem.privacy = a.privacy;
|
|
93
|
+
if (a.token_log)
|
|
94
|
+
idem.token_log = a.token_log;
|
|
95
|
+
if (a.seed !== undefined)
|
|
96
|
+
idem.seed = a.seed;
|
|
97
|
+
const body = { messages, ...(model ? { model } : {}), stream: false };
|
|
98
|
+
if (a.max_tokens !== undefined)
|
|
99
|
+
body.max_tokens = a.max_tokens;
|
|
100
|
+
if (a.temperature !== undefined)
|
|
101
|
+
body.temperature = a.temperature;
|
|
102
|
+
if (a.seed !== undefined)
|
|
103
|
+
body.seed = a.seed;
|
|
104
|
+
if (Object.keys(idem).length)
|
|
105
|
+
body.idemcore = idem;
|
|
106
|
+
const { data, headers } = await api.chat(body);
|
|
107
|
+
const answer = data?.choices?.[0]?.message?.content ?? "";
|
|
108
|
+
const receipt = extractReceipt(data, headers);
|
|
109
|
+
let verification = receipt ? undefined : { verdict: "UNVERIFIED", checks: [], warnings: ["the API response carried no receipt"] };
|
|
110
|
+
if (receipt && a.verify) {
|
|
111
|
+
const salt = typeof data?.idemcore_salt === "string" ? data.idemcore_salt : undefined;
|
|
112
|
+
verification = await verifyAll({
|
|
113
|
+
receipt, messages,
|
|
114
|
+
...(typeof receipt.output_text_hash === "string" && (receipt.privacy === "full" || salt) ? { output_text: answer, salt_b64u: salt } : {}),
|
|
115
|
+
}, keyStore);
|
|
116
|
+
}
|
|
117
|
+
const v = verification?.verdict ?? "NOT_CHECKED";
|
|
118
|
+
return out(`${answer}\n\n— Idemcore receipt ${String(receipt?.receipt_id ?? "missing")}: ${v}`, {
|
|
119
|
+
answer, model: data?.model, finish_reason: data?.choices?.[0]?.finish_reason, usage: data?.usage,
|
|
120
|
+
receipt, verification,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
catch (e) {
|
|
124
|
+
return fail(e);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
server.registerTool("idemcore_verify_receipt", {
|
|
128
|
+
title: "Verify a receipt offline",
|
|
129
|
+
description: "Verify an Idemcore receipt without calling the API: L1 checks format and the Ed25519 signature against trusted Idemcore keys; L2 recomputes the token-log chain digests from a log; optional disclosure checks that a prompt, token ids or displayed text match the receipt's hashes.",
|
|
130
|
+
inputSchema: {
|
|
131
|
+
receipt: z.union([z.string(), z.record(z.string(), z.any())]).describe("Receipt JSON (object, JSON string, base64url JSON, or an embed envelope {receipt, ...})."),
|
|
132
|
+
log_text: z.string().optional().describe("Per-token log text for L2."),
|
|
133
|
+
log_path: z.string().optional().describe("Path to a per-token log file for L2."),
|
|
134
|
+
messages: z.array(message).optional().describe("Disclosed prompt messages to check against prompt_hash."),
|
|
135
|
+
output_ids: z.array(z.number().int().nonnegative()).optional().describe("Disclosed output token ids to check against output_token_hash."),
|
|
136
|
+
output_text: z.string().optional().describe("Displayed answer text to check against output_text_hash (if the receipt carries it)."),
|
|
137
|
+
salt_b64u: z.string().optional().describe("Salt (base64url) for hash_only receipts; not needed for privacy=full."),
|
|
138
|
+
extra_keys: z.any().optional().describe("Extra public JWK / JWKS to trust for this call (e.g. a self-hosted signer)."),
|
|
139
|
+
},
|
|
140
|
+
}, async (a) => {
|
|
141
|
+
try {
|
|
142
|
+
const r = await verifyAll(a, keyStore);
|
|
143
|
+
return out(`Receipt ${String(r.receipt_id)}: ${r.verdict}`, r, r.verdict === "FAILED");
|
|
144
|
+
}
|
|
145
|
+
catch (e) {
|
|
146
|
+
return fail(e);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
server.registerTool("idemcore_get_receipt", {
|
|
150
|
+
title: "Fetch a stored receipt",
|
|
151
|
+
description: "Fetch a receipt (and optionally its per-token log) from the tenant's Idemcore receipt store, then verify it locally.",
|
|
152
|
+
inputSchema: {
|
|
153
|
+
receipt_id: z.string().regex(/^[A-Za-z0-9_.:-]{1,128}$/),
|
|
154
|
+
include_log: z.boolean().default(false),
|
|
155
|
+
verify: z.boolean().default(true),
|
|
156
|
+
},
|
|
157
|
+
}, async (a) => {
|
|
158
|
+
try {
|
|
159
|
+
const { data, headers } = await api.getReceipt(a.receipt_id, a.include_log);
|
|
160
|
+
const receipt = (data?.receipt && typeof data.receipt === "object") ? data.receipt : (extractReceipt(data, headers) ?? data);
|
|
161
|
+
const logText = typeof data?.log_text === "string" ? data.log_text : undefined;
|
|
162
|
+
const verification = a.verify ? await verifyAll({ receipt, log_text: logText }, keyStore) : undefined;
|
|
163
|
+
return out(`Receipt ${a.receipt_id}: ${verification?.verdict ?? "NOT_CHECKED"}`, { receipt, has_log: logText !== undefined, verification });
|
|
164
|
+
}
|
|
165
|
+
catch (e) {
|
|
166
|
+
return fail(e);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
server.registerTool("idemcore_export_audit", {
|
|
170
|
+
title: "Export an audit bundle",
|
|
171
|
+
description: "Export the tenant's receipts and audit events for a time range, save them to a local file, and check the event hash chain and signed daily Merkle roots offline.",
|
|
172
|
+
inputSchema: {
|
|
173
|
+
from: z.string().describe("Start (RFC 3339 or YYYY-MM-DD, UTC)."),
|
|
174
|
+
to: z.string().describe("End (RFC 3339 or YYYY-MM-DD, UTC)."),
|
|
175
|
+
include_logs: z.boolean().default(false),
|
|
176
|
+
save: z.boolean().default(true).describe("Write the bundle under IDEMCORE_EXPORT_DIR."),
|
|
177
|
+
},
|
|
178
|
+
}, async (a) => {
|
|
179
|
+
try {
|
|
180
|
+
const { data } = await api.exportAudit({ from: a.from, to: a.to, include_logs: a.include_logs, format: "json" });
|
|
181
|
+
const check = await checkBundle(data, keyStore);
|
|
182
|
+
let saved;
|
|
183
|
+
if (a.save) {
|
|
184
|
+
const dir = resolve(cfg.exportDir);
|
|
185
|
+
mkdirSync(dir, { recursive: true });
|
|
186
|
+
saved = join(dir, `idemcore-audit-${a.from}_${a.to}.json`.replace(/[^A-Za-z0-9_.-]/g, "_").replace(/^_+/, ""));
|
|
187
|
+
saved = join(dir, saved.split("/").pop());
|
|
188
|
+
writeFileSync(saved, JSON.stringify(data, null, 1));
|
|
189
|
+
}
|
|
190
|
+
return out(`Audit export ${a.from} → ${a.to}: ${check.verdict}`, { saved_to: saved, ...check });
|
|
191
|
+
}
|
|
192
|
+
catch (e) {
|
|
193
|
+
return fail(e);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
server.registerTool("idemcore_verify_audit", {
|
|
197
|
+
title: "Verify an audit bundle offline",
|
|
198
|
+
description: "Check an exported audit bundle (JSON file with `events`, `roots` and optional `receipts`) offline: event hash chain, signed Merkle roots and every receipt signature.",
|
|
199
|
+
inputSchema: { path: z.string().describe("Path to the exported bundle JSON.") },
|
|
200
|
+
}, async (a) => {
|
|
201
|
+
try {
|
|
202
|
+
const check = await checkBundle(JSON.parse(readFileSync(a.path, "utf8")), keyStore);
|
|
203
|
+
return out(`Audit bundle ${a.path}: ${check.verdict}`, check, check.verdict === "FAILED");
|
|
204
|
+
}
|
|
205
|
+
catch (e) {
|
|
206
|
+
return fail(e);
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
server.registerTool("idemcore_trusted_keys", {
|
|
210
|
+
title: "List trusted signing keys",
|
|
211
|
+
description: "Show which Idemcore public keys this server trusts for receipt signatures and where each came from.",
|
|
212
|
+
inputSchema: {},
|
|
213
|
+
}, async () => {
|
|
214
|
+
const { keys, warnings } = await keyStore.get();
|
|
215
|
+
return out(`${keys.length} trusted key(s)`, { keys: keys.map((k) => ({ key_id: k.keyId, source: k.source, jwk: k.jwk })), warnings, base_url: cfg.baseUrl });
|
|
216
|
+
});
|
|
217
|
+
return server;
|
|
218
|
+
}
|
|
219
|
+
export async function checkBundle(data, keyStore) {
|
|
220
|
+
const { keys, warnings } = await keyStore.get();
|
|
221
|
+
const events = Array.isArray(data?.events) ? data.events : [];
|
|
222
|
+
const roots = Array.isArray(data?.roots) ? data.roots : [];
|
|
223
|
+
const receipts = Array.isArray(data?.receipts) ? data.receipts : [];
|
|
224
|
+
const errors = [];
|
|
225
|
+
const byTenant = new Map();
|
|
226
|
+
for (const e of events) {
|
|
227
|
+
const t = String(e.tenant_id);
|
|
228
|
+
if (!byTenant.has(t))
|
|
229
|
+
byTenant.set(t, []);
|
|
230
|
+
byTenant.get(t).push(e);
|
|
231
|
+
}
|
|
232
|
+
for (const [t, evs] of byTenant)
|
|
233
|
+
for (const er of await verifyEventChain(evs.sort((x, y) => x.seq - y.seq)))
|
|
234
|
+
errors.push(`tenant ${t}: ${er}`);
|
|
235
|
+
for (const root of roots) {
|
|
236
|
+
const evs = events.filter((e) => e.tenant_id === root.tenant_id && e.seq >= Number(root.first_seq) && e.seq <= Number(root.last_seq));
|
|
237
|
+
for (const er of await verifyRoot(root, evs, keys))
|
|
238
|
+
errors.push(`root ${String(root.day)}: ${er}`);
|
|
239
|
+
}
|
|
240
|
+
let receiptsOk = 0;
|
|
241
|
+
for (const r of receipts) {
|
|
242
|
+
const res = await verifyL1(r, keys);
|
|
243
|
+
if (res.ok)
|
|
244
|
+
receiptsOk++;
|
|
245
|
+
else
|
|
246
|
+
errors.push(`receipt ${String(r.receipt_id)}: ${res.errors.join("; ")}`);
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
verdict: (errors.length ? "FAILED" : "VERIFIED"),
|
|
250
|
+
events: events.length, roots: roots.length, receipts: receipts.length, receipts_ok: receiptsOk, errors, warnings,
|
|
251
|
+
};
|
|
252
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,51 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@idemcore/mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server for Idemcore: chat with signed receipts and verify them offline.",
|
|
5
|
+
"author": "Don Wick <don.wick@idemcore.net>",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"bin": {
|
|
9
|
+
"idemcore-mcp": "dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"main": "dist/index.js",
|
|
12
|
+
"files": [
|
|
13
|
+
"dist",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=20"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsc -p tsconfig.json",
|
|
21
|
+
"test": "vitest run",
|
|
22
|
+
"start": "node dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@modelcontextprotocol/sdk": "^1.32.2",
|
|
26
|
+
"zod": "^4.6.7"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/node": "^26.6.6",
|
|
30
|
+
"typescript": "^5.9.3",
|
|
31
|
+
"vitest": "^5.0.5"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/idemcore/idemcore-mcp.git"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://github.com/idemcore/idemcore-mcp#readme",
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/idemcore/idemcore-mcp/issues"
|
|
43
|
+
},
|
|
44
|
+
"keywords": [
|
|
45
|
+
"idemcore",
|
|
46
|
+
"mcp",
|
|
47
|
+
"model-context-protocol",
|
|
48
|
+
"receipts",
|
|
49
|
+
"verification"
|
|
50
|
+
]
|
|
51
|
+
}
|