@touchstone-cv/mcp 0.1.0 → 0.2.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/README.md +14 -0
- package/package.json +33 -9
- package/touchstone-mcp.mjs +51 -5
package/README.md
CHANGED
|
@@ -72,6 +72,20 @@ self-provision one with their own Colony token (OAuth Token Exchange, RFC 8693),
|
|
|
72
72
|
|
|
73
73
|
Only `touchstone_record` uses your signing key; the rest proxy to the remote service over your API key.
|
|
74
74
|
|
|
75
|
+
### Selective field disclosure
|
|
76
|
+
|
|
77
|
+
Call `touchstone_record({ event_type, payload, selective_disclosure: true })` to commit each
|
|
78
|
+
payload field separately — the client computes a salted-field Merkle root locally and signs
|
|
79
|
+
*that* as `payload_hash`, storing the per-field salts. Later you can reveal only a subset:
|
|
80
|
+
|
|
81
|
+
```js
|
|
82
|
+
touchstone_disclose({ seqs: [n], reveal: { n: ["field_a", "field_b"] } })
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Revealed fields ship with Merkle proofs against `payload_hash` (which your signature already
|
|
86
|
+
covers); withheld fields are salt-bound and their values never appear in the disclosure. The
|
|
87
|
+
root computation matches the server and the verifiers byte-for-byte.
|
|
88
|
+
|
|
75
89
|
## Verifying the log
|
|
76
90
|
|
|
77
91
|
A disclosure can be checked by anyone, with no trust in Touchstone — in the
|
package/package.json
CHANGED
|
@@ -1,15 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@touchstone-cv/mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Local MCP server for Touchstone
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Local MCP server for Touchstone \u2014 record agent actions into a tamper-evident, externally-anchored log. Signs locally; your Ed25519 key never leaves your machine.",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
|
|
6
|
+
"bin": {
|
|
7
|
+
"touchstone-mcp": "./touchstone-mcp.mjs"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=18"
|
|
11
|
+
},
|
|
8
12
|
"license": "Apache-2.0",
|
|
9
|
-
"publishConfig": {
|
|
10
|
-
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/Touchstone-CV/touchstone-mcp.git"
|
|
19
|
+
},
|
|
11
20
|
"homepage": "https://touchstone.cv/developers",
|
|
12
|
-
"bugs": {
|
|
13
|
-
|
|
14
|
-
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/Touchstone-CV/touchstone-mcp/issues"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"mcp",
|
|
26
|
+
"model-context-protocol",
|
|
27
|
+
"touchstone",
|
|
28
|
+
"audit-log",
|
|
29
|
+
"tamper-evident",
|
|
30
|
+
"ed25519",
|
|
31
|
+
"provenance",
|
|
32
|
+
"agent"
|
|
33
|
+
],
|
|
34
|
+
"files": [
|
|
35
|
+
"touchstone-mcp.mjs",
|
|
36
|
+
"README.md",
|
|
37
|
+
"LICENSE"
|
|
38
|
+
]
|
|
15
39
|
}
|
package/touchstone-mcp.mjs
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
// Canonical source: https://github.com/Touchstone-CV/touchstone-mcp (Apache-2.0)
|
|
21
21
|
// Service: https://touchstone.cv/developers
|
|
22
22
|
|
|
23
|
-
import { createHash, createPrivateKey, sign as edSign } from 'node:crypto';
|
|
23
|
+
import { createHash, createPrivateKey, sign as edSign, randomBytes } from 'node:crypto';
|
|
24
24
|
import { readFileSync } from 'node:fs';
|
|
25
25
|
import { createInterface } from 'node:readline';
|
|
26
26
|
|
|
@@ -49,6 +49,32 @@ function canon(v) {
|
|
|
49
49
|
}
|
|
50
50
|
const sha256hex = (s) => createHash('sha256').update(s, 'utf8').digest('hex');
|
|
51
51
|
|
|
52
|
+
// ── Selective disclosure: payload_hash = salted-field Merkle root (matches
|
|
53
|
+
// src/Service/SelectiveDisclosure.php + MerkleService + verifier.js byte-for-byte).
|
|
54
|
+
// Lets you reveal a subset of fields later without exposing the rest. ──
|
|
55
|
+
const _shaBuf = (buf) => createHash('sha256').update(buf).digest('hex');
|
|
56
|
+
const _hex = (h) => Buffer.from(h, 'hex');
|
|
57
|
+
const mLeaf = (h) => _shaBuf(Buffer.concat([Buffer.from([0]), _hex(h)]));
|
|
58
|
+
const mNode = (l, r) => _shaBuf(Buffer.concat([Buffer.from([1]), _hex(l), _hex(r)]));
|
|
59
|
+
const fieldLeaf = (k, v, salt) => sha256hex('tsd:field:v1\n' + canon([k, v, salt]));
|
|
60
|
+
function merkleRoot(leafHexes) {
|
|
61
|
+
let level = leafHexes.map(mLeaf);
|
|
62
|
+
while (level.length > 1) {
|
|
63
|
+
const next = [];
|
|
64
|
+
for (let i = 0; i < level.length; i += 2) next.push(i + 1 < level.length ? mNode(level[i], level[i + 1]) : level[i]);
|
|
65
|
+
level = next;
|
|
66
|
+
}
|
|
67
|
+
return level[0];
|
|
68
|
+
}
|
|
69
|
+
// Commit a payload object: fresh per-field salts + the Merkle root to sign as payload_hash.
|
|
70
|
+
function sdCommit(payload) {
|
|
71
|
+
const keys = Object.keys(payload).sort();
|
|
72
|
+
const salts = {};
|
|
73
|
+
for (const k of keys) salts[k] = randomBytes(16).toString('hex');
|
|
74
|
+
const root = merkleRoot(keys.map(k => fieldLeaf(k, payload[k], salts[k])));
|
|
75
|
+
return { root, salts };
|
|
76
|
+
}
|
|
77
|
+
|
|
52
78
|
// ── Ed25519 detached signature (base64), key held locally ──
|
|
53
79
|
function loadKey() {
|
|
54
80
|
if (!CFG.seedB64) return null;
|
|
@@ -93,7 +119,21 @@ async function recordEntry(a) {
|
|
|
93
119
|
if (eventType === '' || a.payload === undefined) throw new Error('event_type and payload are required');
|
|
94
120
|
const cp = a.counterparty_sub ?? null;
|
|
95
121
|
const clientTs = a.client_ts ?? null;
|
|
96
|
-
|
|
122
|
+
|
|
123
|
+
// Selective-disclosure mode: commit a salted-field Merkle root locally so a later
|
|
124
|
+
// disclosure can reveal a subset of fields and withhold the rest, provably.
|
|
125
|
+
let payloadHash, sdSalts = null;
|
|
126
|
+
if (a.selective_disclosure) {
|
|
127
|
+
if (!a.payload || typeof a.payload !== 'object' || Array.isArray(a.payload)) {
|
|
128
|
+
throw new Error('selective_disclosure requires payload to be a JSON object');
|
|
129
|
+
}
|
|
130
|
+
const c = sdCommit(a.payload);
|
|
131
|
+
payloadHash = c.root;
|
|
132
|
+
sdSalts = c.salts;
|
|
133
|
+
} else {
|
|
134
|
+
payloadHash = sha256hex(canon(a.payload));
|
|
135
|
+
}
|
|
136
|
+
|
|
97
137
|
const signedContent = canon({
|
|
98
138
|
v: 1, recorder_id: CFG.recorder, event_type: eventType, actor_sub: CFG.subject,
|
|
99
139
|
counterparty_sub: cp, payload_hash: payloadHash, client_ts: clientTs,
|
|
@@ -102,7 +142,10 @@ async function recordEntry(a) {
|
|
|
102
142
|
const body = { event_type: eventType, payload_hash: payloadHash, actor_sig: actorSig };
|
|
103
143
|
if (cp) body.counterparty_sub = cp;
|
|
104
144
|
if (clientTs) body.client_ts = clientTs;
|
|
105
|
-
|
|
145
|
+
// SD entries must store the payload (server builds field proofs from it + salts;
|
|
146
|
+
// withheld values are only ever omitted at disclosure time, never the whole payload).
|
|
147
|
+
if (sdSalts) { body.sd_salts = sdSalts; body.body_enc = JSON.stringify(a.payload); }
|
|
148
|
+
else if (a.store_payload !== false) body.body_enc = JSON.stringify(a.payload);
|
|
106
149
|
const res = await rest(`/api/v1/recorders/${CFG.recorder}/entries`, body);
|
|
107
150
|
if (!res.ok) throw new Error('append failed (' + res.status + '): ' + (res.data.error || ''));
|
|
108
151
|
return res.data; // { seq, prev_hash, entry_hash, server_ts }
|
|
@@ -115,12 +158,15 @@ const TOOLS = [
|
|
|
115
158
|
payload: { type: 'object', description: 'arbitrary JSON describing what happened' },
|
|
116
159
|
counterparty_sub: { type: 'string', description: 'optional: the other party\'s Colony sub' },
|
|
117
160
|
client_ts: { type: 'string', description: 'optional ISO-8601 UTC' },
|
|
118
|
-
store_payload: { type: 'boolean', description: 'default true; false keeps only the hash' }
|
|
161
|
+
store_payload: { type: 'boolean', description: 'default true; false keeps only the hash' },
|
|
162
|
+
selective_disclosure: { type: 'boolean', description: 'commit each field separately (salted Merkle root) so a later disclosure can reveal a subset of fields and withhold the rest' } } } },
|
|
119
163
|
{ name: 'touchstone_recorder_info', description: 'Your recorder: id, subject, trust tier, head sequence.',
|
|
120
164
|
inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
|
|
121
165
|
{ name: 'touchstone_disclose', description: 'Create a shareable, independently-verifiable disclosure of selected entries.',
|
|
122
166
|
inputSchema: { type: 'object', required: ['seqs'], additionalProperties: false,
|
|
123
|
-
properties: {
|
|
167
|
+
properties: {
|
|
168
|
+
seqs: { type: 'array', items: { type: 'integer' } },
|
|
169
|
+
reveal: { type: 'object', description: 'optional selective-disclosure map {seq: [field keys to reveal]}; unlisted fields stay withheld' } } } },
|
|
124
170
|
{ name: 'touchstone_verify', description: 'Independently verify a disclosure (token or bundle).',
|
|
125
171
|
inputSchema: { type: 'object', additionalProperties: false,
|
|
126
172
|
properties: { token: { type: 'string' }, bundle: { type: 'object' } } } },
|