@nodatachat/mcp 0.4.2 → 0.8.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 +151 -116
- package/dist/index.js +453 -42
- package/package.json +43 -38
package/README.md
CHANGED
|
@@ -1,116 +1,151 @@
|
|
|
1
|
-
# @nodatachat/mcp
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
1
|
+
# @nodatachat/mcp
|
|
2
|
+
|
|
3
|
+
**Give an AI agent real power over your data — without the power to leak it.**
|
|
4
|
+
|
|
5
|
+
One line connects any MCP client (Claude Code, an agent runtime) to NoData's governed‑access layer. Your agent asks *"may I reach this?"* and only ever sees the columns you granted. Denied columns are **never decrypted on the server** — there is nothing to leak — and every access leaves a signed, verifiable receipt.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
claude mcp add nodata -- npx @nodatachat/mcp
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
That's it — **no flags**. Self‑serve: registering opens an instant free **sandbox key** (no approval wall) and a **12‑word recovery phrase only you hold**. No card. **10,000 governed decisions per month free**, then $0.25 per 1,000.
|
|
12
|
+
|
|
13
|
+
> **The one idea:** *API access ≠ data access.* A successful call is a request for a **decision**, not a handoff of data. **`no key = no plaintext`**, and every decision carries a proof.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Get started in one command
|
|
18
|
+
|
|
19
|
+
Run the line above with no credential. The server still connects (it never fails the MCP handshake) and exposes two setup tools:
|
|
20
|
+
|
|
21
|
+
- **`nodata_get_started`** — call it first. It explains what you get and the single next step.
|
|
22
|
+
- **`nodata_connect`** — register once, autoload forever:
|
|
23
|
+
1. `nodata_connect` (no args) → opens the free signup in your browser (work email + company, ~30s). Your **12‑word recovery phrase** is generated **in your browser — NoData never sees it**. The page shows your org key **once**.
|
|
24
|
+
2. `nodata_connect` with `api_key: "<that key>"` → saved to `~/.nodata/credentials.json` (chmod 600).
|
|
25
|
+
3. Restart, or run `/mcp` → every later start loads your identity with nothing to retype. *Registration ≠ login.*
|
|
26
|
+
|
|
27
|
+
Keep the 12‑word phrase somewhere safe — it is the only way back into your org, and NoData cannot reissue it.
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## One journey, five verbs, two ways in
|
|
32
|
+
|
|
33
|
+
The whole product is one primitive shown five ways — **Classify · Protect · Route · Retrieve · Prove** — entered from **a table** (govern the DB columns an agent reads) **or a folder/document** (seal into a Capsule). Same journey, same proof.
|
|
34
|
+
|
|
35
|
+
| Verb | What you do | Tool |
|
|
36
|
+
|------|-------------|------|
|
|
37
|
+
| **Classify** | connect a table (or seal a document) | `nodata_register_table` |
|
|
38
|
+
| **Protect** | grant an agent only the columns you name | `nodata_grant` → `ndca-` token |
|
|
39
|
+
| **Route** | *"may this agent reach X?"* — plan, no data moves | `nodata_decide` |
|
|
40
|
+
| **Retrieve** | pull only the authorized columns into the prompt | `nodata_retrieve` / `nodata_read` |
|
|
41
|
+
| **Prove** | a signed receipt on every read **and** every deny | `nodata_evidence` · `/decisions` |
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Try it — the "wow" in four calls
|
|
46
|
+
|
|
47
|
+
After `nodata_connect`, ask your agent in plain language (*"grant a support bot first_name and last_name on agent_demo_records, then read it"*), or run the raw API with your key (`export NDP=ndp_test_…`):
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# 1 · grant an agent two columns of the built-in demo table (auto-seeds sample rows)
|
|
51
|
+
curl -s -X POST https://www.nodatacapsule.com/api/v1/governance/grant \
|
|
52
|
+
-H "Authorization: Bearer $NDP" -H 'content-type: application/json' \
|
|
53
|
+
-d '{"agent":"support-copilot","table":"agent_demo_records","columns":["first_name","last_name"]}'
|
|
54
|
+
# → { "grant_token": "ndca-…", "handle": "…" }
|
|
55
|
+
|
|
56
|
+
# 2 · the agent reads what it's allowed — RELEASED
|
|
57
|
+
curl -s -X POST https://www.nodatacapsule.com/api/agents/<handle>/read \
|
|
58
|
+
-H "Authorization: Bearer ndca-…" -H 'content-type: application/json' \
|
|
59
|
+
-d '{"table":"agent_demo_records","columns":["first_name","last_name"],"limit":5}'
|
|
60
|
+
# → 200 { "rows": [ { "first_name": "…", "last_name": "…" }, … ] }
|
|
61
|
+
|
|
62
|
+
# 3 · the agent asks for a column it was NOT granted — WITHHELD
|
|
63
|
+
curl -s -X POST https://www.nodatacapsule.com/api/agents/<handle>/read \
|
|
64
|
+
-H "Authorization: Bearer ndca-…" -H 'content-type: application/json' \
|
|
65
|
+
-d '{"table":"agent_demo_records","columns":["email"]}'
|
|
66
|
+
# → 403 { "error": "scope_violation", "denied_columns": ["email"] } ← the key for email is never derived
|
|
67
|
+
|
|
68
|
+
# 4 · prove it — signed receipts for the allow AND the deny, verifiable without the data:
|
|
69
|
+
# https://www.nodatacapsule.com/decisions
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`email` came back **absent, not blanked** — there is no decrypt‑all‑then‑filter path. That is *"you decide what the AI sees,"* as math, not a promise.
|
|
73
|
+
|
|
74
|
+
No terminal? Same idea in the browser, zero install: **https://www.nodatacapsule.com/ai-folder** — drop a folder, see exactly what an AI would be allowed to read (nothing uploads).
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Two modes — the credential decides what the server can do
|
|
79
|
+
|
|
80
|
+
**Agent mode (`--grant-token ndca-…`)** — *permission management for language models.* Hand an AI agent a grant token; the server exposes only tools hard‑scoped to that grant. The agent may request **only** the columns its grant allows — denied columns never decrypt server‑side, and every action emits a receipt. Point any MCP client at it and it **physically cannot exceed its claims**.
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
claude mcp add nodata -- npx @nodatachat/mcp --grant-token ndca-YOUR_GRANT
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**Admin mode (`--api-key ndp_… / sk_live_…`)** — the owner seat: register tables, issue and revoke grants, and use blind relay (encrypt / decrypt / deliver / evidence) from inside any MCP client.
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
claude mcp add nodata -- npx @nodatachat/mcp --api-key YOUR_API_KEY
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Credential precedence: `--api-key` / `--grant-token` › `NODATA_API_KEY` / `NODATA_GRANT_TOKEN` › the saved `~/.nodata` file. An explicit flag or env always wins.
|
|
93
|
+
|
|
94
|
+
On **Windows**, native (non‑WSL) `npx` needs the `cmd /c` wrapper: `claude mcp add nodata -- cmd /c npx @nodatachat/mcp …`. After changing the credential, **restart** — the running server keeps its old arguments.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Available tools
|
|
99
|
+
|
|
100
|
+
**Agent mode** (`--grant-token`):
|
|
101
|
+
|
|
102
|
+
| Tool | What it does |
|
|
103
|
+
|------|--------------|
|
|
104
|
+
| `nodata_decide` | May the agent reach a resource? allow / degrade (reachable subset) / deny + an access‑distance cost + a signed proof. No data. Plan before you read. |
|
|
105
|
+
| `nodata_retrieve` | Prompt‑ready authorized context, filtered to allowed columns, with an accounting of what was withheld + a proof. |
|
|
106
|
+
| `nodata_read` | Read governed rows within the grant; denied columns never decrypt; every read is receipted. |
|
|
107
|
+
| `nodata_use` | Invoke a pre‑registered secret‑blind capability — the Capsule injects the sealed credential server‑side and returns only the result. The secret never reaches the agent. |
|
|
108
|
+
|
|
109
|
+
**Admin mode** (`--api-key`):
|
|
110
|
+
|
|
111
|
+
| Tool | What it does |
|
|
112
|
+
|------|--------------|
|
|
113
|
+
| `nodata_register_table` | Register one of your tables as AI‑agent‑readable ("connect your data"). |
|
|
114
|
+
| `nodata_grant` | Issue an agent a claim‑scoped grant; returns a `grant_token` (shown once). |
|
|
115
|
+
| `nodata_revoke` | Revoke a grant by handle or jti — access stops at once. |
|
|
116
|
+
| `nodata_encrypt` / `nodata_decrypt` | Field‑level AES‑256‑GCM; NoData stores nothing of the plaintext. |
|
|
117
|
+
| `nodata_deliver` | Create a burn‑after‑read secure link with OTP. |
|
|
118
|
+
| `nodata_evidence` | Retrieve the audit trail — metadata only, never values. |
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## Security & privacy model
|
|
123
|
+
|
|
124
|
+
- **Content‑blind.** For governed reads, denied columns are **never decrypted on the server** — the key release is scoped to the grant, not "we promise not to look."
|
|
125
|
+
- **Every action is a receipt.** Reads, grants, revokes and denials mint a signed, hash‑chained decision receipt (Ed25519, publicly verifiable), written **before** the release it authorizes — if the receipt can't be written, nothing is served.
|
|
126
|
+
- **Structural isolation.** A key is bound to its org; every governance query is scoped to that tenant, so a key cannot aim at another org's data.
|
|
127
|
+
- **The honest boundary.** This governs data the agent reaches **through the Capsule**. It does not sandbox a process already executing as you on your own machine.
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## Configuration
|
|
132
|
+
|
|
133
|
+
| Option | Flag | Env var | Default |
|
|
134
|
+
|--------|------|---------|---------|
|
|
135
|
+
| API key (admin) | `--api-key` | `NODATA_API_KEY` | — |
|
|
136
|
+
| Grant token (agent) | `--grant-token` | `NODATA_GRANT_TOKEN` | — |
|
|
137
|
+
| Base URL | `--base-url` | `NODATA_BASE_URL` | `https://www.nodatacapsule.com` |
|
|
138
|
+
|
|
139
|
+
Get a key free at **https://www.nodatacapsule.com/capsule-api/register** (issues `ndp_test_…` instantly). `--help` and `--version` are also available.
|
|
140
|
+
|
|
141
|
+
## Development
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
cd packages/mcp-server
|
|
145
|
+
npm install
|
|
146
|
+
npm run dev -- --api-key ndp_test_your_key # or: npm run build
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## License
|
|
150
|
+
|
|
151
|
+
MIT
|
package/dist/index.js
CHANGED
|
@@ -31,7 +31,61 @@
|
|
|
31
31
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
32
32
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
33
33
|
import { z } from "zod";
|
|
34
|
-
|
|
34
|
+
import { homedir, hostname } from "node:os";
|
|
35
|
+
import { join } from "node:path";
|
|
36
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
|
|
37
|
+
import { spawn } from "node:child_process";
|
|
38
|
+
import { generateKeyPairSync, createPrivateKey, sign, randomBytes } from "node:crypto";
|
|
39
|
+
const VERSION = "0.8.0";
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Local credential store — "register once, autoload forever"
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// This is the apply-side of "registration ≠ login": you register ONCE (in the
|
|
44
|
+
// browser, where the recovery phrase + email verify happen), nodata_connect
|
|
45
|
+
// saves an identity here, and every later start reads it back — no re-register,
|
|
46
|
+
// no re-typing the launch command. The file is chmod 600.
|
|
47
|
+
//
|
|
48
|
+
// Two identity shapes live here:
|
|
49
|
+
// · device — a DEVICE PRIVATE KEY (the preferred, hardened path). No reusable
|
|
50
|
+
// bearer is stored; each start proves possession by signing a fresh nonce
|
|
51
|
+
// (POST /api/v1/attest) and gets a 15-min token. A stolen file is a
|
|
52
|
+
// revocable device key, not a replayable credential.
|
|
53
|
+
// · api_key — the org key at rest (the pre-attest fallback). Still supported
|
|
54
|
+
// for a backend that predates the attest endpoints; it is the standard
|
|
55
|
+
// CLI-login posture (gh/npm/vercel store a token the same way).
|
|
56
|
+
// nodata_connect writes `device` when enrollment succeeds and falls back to
|
|
57
|
+
// `api_key` otherwise. See docs/ATTEST-FABRIC-IDENTITY-SPEC.md.
|
|
58
|
+
const CRED_DIR = join(homedir(), ".nodata");
|
|
59
|
+
const CRED_FILE = join(CRED_DIR, "credentials.json");
|
|
60
|
+
function readStoredCredential() {
|
|
61
|
+
try {
|
|
62
|
+
if (!existsSync(CRED_FILE))
|
|
63
|
+
return null;
|
|
64
|
+
const parsed = JSON.parse(readFileSync(CRED_FILE, "utf8"));
|
|
65
|
+
if (!parsed)
|
|
66
|
+
return null;
|
|
67
|
+
const hasDevice = parsed.device && typeof parsed.device.private_key_pkcs8_b64 === "string";
|
|
68
|
+
const hasKey = typeof parsed.api_key === "string" && parsed.api_key;
|
|
69
|
+
return hasDevice || hasKey ? parsed : null;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// A corrupt or unreadable store must never crash startup — fall through to
|
|
73
|
+
// setup mode, where nodata_connect can rewrite it.
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function writeStoredCredential(cred) {
|
|
78
|
+
mkdirSync(CRED_DIR, { recursive: true });
|
|
79
|
+
const payload = JSON.stringify({ ...cred, saved_at: new Date().toISOString() }, null, 2);
|
|
80
|
+
writeFileSync(CRED_FILE, payload, { mode: 0o600 });
|
|
81
|
+
// writeFileSync's mode is ignored if the file already existed; enforce it.
|
|
82
|
+
try {
|
|
83
|
+
chmodSync(CRED_FILE, 0o600);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
/* platforms without POSIX perms (Windows) — best effort */
|
|
87
|
+
}
|
|
88
|
+
}
|
|
35
89
|
const USAGE = `nodata-mcp v${VERSION} — NoData MCP server\n\n` +
|
|
36
90
|
" ADMIN mode: nodata-mcp --api-key sk_live_...\n" +
|
|
37
91
|
" AGENT mode: nodata-mcp --grant-token ndca-...\n" +
|
|
@@ -67,14 +121,42 @@ function parseArgs(argv) {
|
|
|
67
121
|
console.error(`nodata-mcp: unknown option '${argv[i]}' (see --help)`);
|
|
68
122
|
}
|
|
69
123
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
124
|
+
let credentialSource = apiKey || grantToken ? "flag" : "none";
|
|
125
|
+
if (!apiKey && process.env.NODATA_API_KEY) {
|
|
126
|
+
apiKey = process.env.NODATA_API_KEY;
|
|
127
|
+
credentialSource = "env";
|
|
128
|
+
}
|
|
129
|
+
if (!grantToken && process.env.NODATA_GRANT_TOKEN) {
|
|
130
|
+
grantToken = process.env.NODATA_GRANT_TOKEN;
|
|
131
|
+
if (credentialSource === "none")
|
|
132
|
+
credentialSource = "env";
|
|
133
|
+
}
|
|
74
134
|
if (process.env.NODATA_BASE_URL)
|
|
75
135
|
baseUrl = process.env.NODATA_BASE_URL;
|
|
136
|
+
// Last resort: what nodata_connect saved. An explicit flag or env always wins,
|
|
137
|
+
// so this only fires on a bare `npx @nodatachat/mcp` — the "autoload forever"
|
|
138
|
+
// path after a one-time connect. A stored DEVICE (attest) is preferred over a
|
|
139
|
+
// stored api_key.
|
|
140
|
+
let device;
|
|
141
|
+
if (!apiKey && !grantToken) {
|
|
142
|
+
const stored = readStoredCredential();
|
|
143
|
+
if (stored) {
|
|
144
|
+
// Honor a stored base_url only if nothing more explicit set one.
|
|
145
|
+
if (!process.env.NODATA_BASE_URL && baseUrl === "https://www.nodatacapsule.com" && stored.base_url) {
|
|
146
|
+
baseUrl = stored.base_url;
|
|
147
|
+
}
|
|
148
|
+
if (stored.device) {
|
|
149
|
+
device = stored.device;
|
|
150
|
+
credentialSource = "stored";
|
|
151
|
+
}
|
|
152
|
+
else if (stored.api_key) {
|
|
153
|
+
apiKey = stored.api_key;
|
|
154
|
+
credentialSource = "stored";
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
76
158
|
baseUrl = baseUrl.replace(/\/+$/, "");
|
|
77
|
-
return { apiKey, grantToken, baseUrl };
|
|
159
|
+
return { apiKey, grantToken, baseUrl, credentialSource, device };
|
|
78
160
|
}
|
|
79
161
|
// Without a deadline a dead network turns a tool call into an indefinite hang
|
|
80
162
|
// inside the MCP client; 30s comfortably covers a slow decide/retrieve.
|
|
@@ -105,17 +187,35 @@ async function doFetch(url, init) {
|
|
|
105
187
|
}
|
|
106
188
|
return { ok: res.ok, status: res.status, body: parsed };
|
|
107
189
|
}
|
|
190
|
+
async function resolveBearer(bearer) {
|
|
191
|
+
if (typeof bearer === "string")
|
|
192
|
+
return { token: bearer };
|
|
193
|
+
try {
|
|
194
|
+
return { token: await bearer() };
|
|
195
|
+
}
|
|
196
|
+
catch (err) {
|
|
197
|
+
return {
|
|
198
|
+
error: { ok: false, status: 0, body: { error: `Could not obtain an access token: ${err instanceof Error ? err.message : String(err)}` } },
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
}
|
|
108
202
|
async function apiPost(baseUrl, bearer, path, body) {
|
|
203
|
+
const r = await resolveBearer(bearer);
|
|
204
|
+
if ("error" in r)
|
|
205
|
+
return r.error;
|
|
109
206
|
return doFetch(`${baseUrl}${path}`, {
|
|
110
207
|
method: "POST",
|
|
111
208
|
headers: {
|
|
112
209
|
"Content-Type": "application/json",
|
|
113
|
-
Authorization: `Bearer ${
|
|
210
|
+
Authorization: `Bearer ${r.token}`,
|
|
114
211
|
},
|
|
115
212
|
body: JSON.stringify(body),
|
|
116
213
|
});
|
|
117
214
|
}
|
|
118
215
|
async function apiGet(baseUrl, bearer, path, params) {
|
|
216
|
+
const r = await resolveBearer(bearer);
|
|
217
|
+
if ("error" in r)
|
|
218
|
+
return r.error;
|
|
119
219
|
const url = new URL(`${baseUrl}${path}`);
|
|
120
220
|
for (const [key, val] of Object.entries(params)) {
|
|
121
221
|
if (val !== undefined && val !== "")
|
|
@@ -123,7 +223,7 @@ async function apiGet(baseUrl, bearer, path, params) {
|
|
|
123
223
|
}
|
|
124
224
|
return doFetch(url.toString(), {
|
|
125
225
|
method: "GET",
|
|
126
|
-
headers: { Authorization: `Bearer ${
|
|
226
|
+
headers: { Authorization: `Bearer ${r.token}` },
|
|
127
227
|
});
|
|
128
228
|
}
|
|
129
229
|
function formatResult(resp) {
|
|
@@ -151,6 +251,74 @@ function handleFromGrant(token) {
|
|
|
151
251
|
}
|
|
152
252
|
}
|
|
153
253
|
// ---------------------------------------------------------------------------
|
|
254
|
+
// Device attestation — the hardened credential path ("registration ≠ login")
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
// The client holds an Ed25519 PRIVATE key that never leaves this machine. Each
|
|
257
|
+
// session it signs a fresh server nonce (POST /api/v1/attest) and gets a 15-min
|
|
258
|
+
// token the governance surface already accepts — no reusable key at rest.
|
|
259
|
+
// Reuses the shipped enrolled-agent challenge model server-side.
|
|
260
|
+
const ATTEST_REFRESH_BUFFER_MS = 5 * 60_000; // refresh 5m before the 15m token expires
|
|
261
|
+
function generateDeviceKeypair() {
|
|
262
|
+
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
263
|
+
const spki = publicKey.export({ format: "der", type: "spki" });
|
|
264
|
+
const pkcs8 = privateKey.export({ format: "der", type: "pkcs8" });
|
|
265
|
+
return { publicKeyHex: spki.subarray(-32).toString("hex"), privateKeyPkcs8B64: pkcs8.toString("base64") };
|
|
266
|
+
}
|
|
267
|
+
function signChallenge(privateKeyPkcs8B64, challenge) {
|
|
268
|
+
const key = createPrivateKey({ key: Buffer.from(privateKeyPkcs8B64, "base64"), format: "der", type: "pkcs8" });
|
|
269
|
+
return sign(null, Buffer.from(challenge, "utf8"), key).toString("base64");
|
|
270
|
+
}
|
|
271
|
+
/** A stable, valid device handle derived from the machine (host + short random). */
|
|
272
|
+
function defaultDeviceHandle() {
|
|
273
|
+
const host = hostname().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 20) || "device";
|
|
274
|
+
const head = /^[a-z]/.test(host) ? host : `d-${host}`;
|
|
275
|
+
return `${head}-${randomBytes(3).toString("hex")}`.slice(0, 32);
|
|
276
|
+
}
|
|
277
|
+
/** Enroll a device public key under the org that owns `apiKey`; returns its org_id. */
|
|
278
|
+
async function enrollDevice(baseUrl, apiKey, handle, label, publicKeyHex) {
|
|
279
|
+
const resp = await apiPost(baseUrl, apiKey, "/api/v1/attest/devices", {
|
|
280
|
+
handle,
|
|
281
|
+
label,
|
|
282
|
+
ed25519_pubkey_hex: publicKeyHex,
|
|
283
|
+
});
|
|
284
|
+
if (resp.ok) {
|
|
285
|
+
const orgId = resp.body.org_id ?? "";
|
|
286
|
+
return orgId ? { ok: true, orgId } : { ok: false, status: resp.status, error: "enroll response missing org_id" };
|
|
287
|
+
}
|
|
288
|
+
return { ok: false, status: resp.status, error: resp.body?.error ?? `HTTP ${resp.status}` };
|
|
289
|
+
}
|
|
290
|
+
/** One attest round: challenge → sign → token. Throws with a clear message on failure. */
|
|
291
|
+
async function attestOnce(baseUrl, device) {
|
|
292
|
+
// Both attest endpoints are public (the signature is the auth), so the bearer is unused.
|
|
293
|
+
const ch = await apiPost(baseUrl, "", "/api/v1/attest/challenge", { org_id: device.org_id, handle: device.handle });
|
|
294
|
+
if (!ch.ok)
|
|
295
|
+
throw new Error(`challenge: ${ch.body?.error ?? `HTTP ${ch.status}`}`);
|
|
296
|
+
const { challenge, nonce } = ch.body;
|
|
297
|
+
if (!challenge || !nonce)
|
|
298
|
+
throw new Error("challenge response malformed");
|
|
299
|
+
const signature = signChallenge(device.private_key_pkcs8_b64, challenge);
|
|
300
|
+
const at = await apiPost(baseUrl, "", "/api/v1/attest", { org_id: device.org_id, handle: device.handle, nonce, signature });
|
|
301
|
+
if (!at.ok)
|
|
302
|
+
throw new Error(`attest: ${at.body?.error ?? `HTTP ${at.status}`}`);
|
|
303
|
+
const body = at.body;
|
|
304
|
+
if (!body.allowed || !body.access_token)
|
|
305
|
+
throw new Error(`attestation rejected: ${body.reason ?? "denied"}`);
|
|
306
|
+
return {
|
|
307
|
+
token: body.access_token,
|
|
308
|
+
expiresAtMs: body.expires_at ? Date.parse(body.expires_at) : Date.now() + 15 * 60_000,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
/** A Bearer that attests lazily and refreshes before the token lapses. */
|
|
312
|
+
function deviceTokenProvider(baseUrl, device) {
|
|
313
|
+
let cached = null;
|
|
314
|
+
return async () => {
|
|
315
|
+
if (!cached || Date.now() >= cached.expiresAtMs - ATTEST_REFRESH_BUFFER_MS) {
|
|
316
|
+
cached = await attestOnce(baseUrl, device);
|
|
317
|
+
}
|
|
318
|
+
return cached.token;
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
154
322
|
// Tool registration
|
|
155
323
|
// ---------------------------------------------------------------------------
|
|
156
324
|
function registerAdminTools(server, baseUrl, apiKey) {
|
|
@@ -372,56 +540,299 @@ function registerAgentTool(server, baseUrl, grantToken) {
|
|
|
372
540
|
});
|
|
373
541
|
return formatResult(resp);
|
|
374
542
|
});
|
|
543
|
+
// ── Code plane · fix code without exposure, over the Fabric ─────────
|
|
544
|
+
// The agent drives the blind advisor on a code capsule. It sees only a
|
|
545
|
+
// content-blind projection (or, on an in-boundary model, a scoped slice that
|
|
546
|
+
// never egresses); the human sees only the changed hunks. Every turn returns
|
|
547
|
+
// an exposure proof + a receipt. Authorised by the capsule owner listing this
|
|
548
|
+
// agent's kid in scope.fabric_developers; deny-by-default.
|
|
549
|
+
server.registerTool("nodata_code_advise", {
|
|
550
|
+
description: "Ask the blind code advisor to diagnose/fix a bug in a code capsule WITHOUT seeing its source. You receive guidance + a targeted diff + an exposure proof (source never left the boundary); the source is never returned to you. Requires the capsule owner to have granted this agent (scope.fabric_developers). Hard-scoped to this server's grant token.",
|
|
551
|
+
inputSchema: {
|
|
552
|
+
capsule_id: z.string().describe("The code capsule to advise on (you must be granted on it)"),
|
|
553
|
+
question: z.string().optional().describe("What to fix / improve, in natural language"),
|
|
554
|
+
note: z.string().optional().describe("Extra context for the advisor"),
|
|
555
|
+
error: z
|
|
556
|
+
.object({
|
|
557
|
+
message: z.string().optional(),
|
|
558
|
+
stack: z.string().optional(),
|
|
559
|
+
kind: z.string().optional(),
|
|
560
|
+
})
|
|
561
|
+
.optional()
|
|
562
|
+
.describe("An error the code produced, to guide the fix"),
|
|
563
|
+
steer: z.string().optional().describe("A steer on a refine turn (e.g. 'no, round half-up')"),
|
|
564
|
+
prior_diff: z.string().optional().describe("The previously proposed diff, to refine"),
|
|
565
|
+
},
|
|
566
|
+
}, async ({ capsule_id, question, note, error, steer, prior_diff }) => {
|
|
567
|
+
if (!handle) {
|
|
568
|
+
return {
|
|
569
|
+
content: [{ type: "text", text: "Error: could not derive agent handle from grant token." }],
|
|
570
|
+
isError: true,
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
const resp = await apiPost(baseUrl, grantToken, `/api/agents/${encodeURIComponent(handle)}/code-capsule/${encodeURIComponent(capsule_id)}/advise`, { question, note, error, steer, prior_diff });
|
|
574
|
+
return formatResult(resp);
|
|
575
|
+
});
|
|
576
|
+
server.registerTool("nodata_code_patch", {
|
|
577
|
+
description: "Submit a fix to a code capsule as a SIGNED, queued patch WITHOUT seeing its source. Provide a raw unified `diff`, or a whole-file `replace` ({path, content}) that is diffed server-side against the sealed source. The source is never persisted or returned; you get back a patch id, the diff hash, and a receipt. Requires a capsule grant (scope.fabric_developers). Hard-scoped to this server's grant token.",
|
|
578
|
+
inputSchema: {
|
|
579
|
+
capsule_id: z.string().describe("The code capsule to patch (you must be granted on it)"),
|
|
580
|
+
summary: z.string().optional().describe("Short human-readable summary of the fix"),
|
|
581
|
+
diff: z.string().optional().describe("A raw unified diff (advanced)"),
|
|
582
|
+
replace: z
|
|
583
|
+
.object({ path: z.string(), content: z.string() })
|
|
584
|
+
.optional()
|
|
585
|
+
.describe("Whole-file replacement; the diff is synthesized server-side against sealed source"),
|
|
586
|
+
},
|
|
587
|
+
}, async ({ capsule_id, summary, diff, replace }) => {
|
|
588
|
+
if (!handle) {
|
|
589
|
+
return {
|
|
590
|
+
content: [{ type: "text", text: "Error: could not derive agent handle from grant token." }],
|
|
591
|
+
isError: true,
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
const resp = await apiPost(baseUrl, grantToken, `/api/agents/${encodeURIComponent(handle)}/code-capsule/${encodeURIComponent(capsule_id)}/patch`, { summary, diff, replace });
|
|
595
|
+
return formatResult(resp);
|
|
596
|
+
});
|
|
375
597
|
}
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
// The
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
//
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
"
|
|
386
|
-
"
|
|
387
|
-
"
|
|
388
|
-
"
|
|
389
|
-
"
|
|
390
|
-
"
|
|
391
|
-
"
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
"
|
|
396
|
-
"
|
|
397
|
-
"
|
|
398
|
-
"
|
|
399
|
-
"
|
|
400
|
-
"
|
|
401
|
-
|
|
598
|
+
const START_ACHIEVEMENT = "WHAT NODATA DOES — in one line:\n" +
|
|
599
|
+
" Give any agent real power over your data, without the power to leak it.\n" +
|
|
600
|
+
" (Possession is not permission: it can hold the key and still not read what you didn't allow.)\n";
|
|
601
|
+
// Setup mode: NOT connected yet. The one job here is to switch the tools on, so
|
|
602
|
+
// this is a numbered do-this-exactly list — three steps, not a catalogue. The
|
|
603
|
+
// last line answers the two questions everyone asks: "how do I activate?" (/mcp)
|
|
604
|
+
// and "what about next time?" (nothing).
|
|
605
|
+
const SETUP_STEPS = "TURN THE DATA TOOLS ON — 3 steps, about a minute, once:\n\n" +
|
|
606
|
+
" 1. GET A KEY call nodata_connect (no arguments)\n" +
|
|
607
|
+
" -> opens the signup in your browser: email + company (~30s), a 12-word\n" +
|
|
608
|
+
" recovery phrase to keep, and an ORG KEY shown ONCE. Copy that key.\n\n" +
|
|
609
|
+
" 2. SAVE IT call nodata_connect again, this time with\n" +
|
|
610
|
+
" api_key: \"<the key you copied>\"\n" +
|
|
611
|
+
" -> saved on this machine; you never paste it again.\n\n" +
|
|
612
|
+
" 3. ACTIVATE type /mcp (or restart Claude Code) so the tools load.\n\n" +
|
|
613
|
+
"COMING BACK LATER? Nothing to redo — it starts ready. If the tools look missing, just /mcp.\n";
|
|
614
|
+
// Admin mode: connected. Frame the whole product as the developer's real journey
|
|
615
|
+
// — SEE -> CHOOSE -> PROVE — each step a plain-language ask mapped to a tool.
|
|
616
|
+
const START_OUTCOMES_ADMIN = "NOW — TURN YOUR DATA INTO AN AGENT THAT CAN'T LEAK, in 3 asks (just say them in plain words):\n\n" +
|
|
617
|
+
" 1. SEE what's sensitive open https://www.nodatacapsule.com/ai-folder\n" +
|
|
618
|
+
" -> pick a folder; it shows what an AI could read. Nothing uploads.\n\n" +
|
|
619
|
+
" 2. CHOOSE what to release say \"let <agent> read name + plan on <table>, hide the rest\"\n" +
|
|
620
|
+
" -> I run nodata_register_table + nodata_grant; the agent gets a token.\n\n" +
|
|
621
|
+
" 3. PROVE & control the agent now reads ONLY those columns — the rest never decrypt.\n" +
|
|
622
|
+
" \"what did it see?\" -> nodata_evidence · \"cut it off\" -> nodata_revoke\n\n" +
|
|
623
|
+
"Also: a secret even NoData can't read -> nodata_encrypt, then hand it over once -> nodata_deliver.\n";
|
|
624
|
+
// The agent-scoped reader IS the governed agent: it holds only decide/retrieve/read/use,
|
|
625
|
+
// so its steps name those verbs (pointing it at admin tools it lacks would dead-end it).
|
|
626
|
+
const START_OUTCOMES_AGENT = "WHAT YOU CAN DO — scoped to your grant; you reach only what it allows, the rest never decrypts:\n\n" +
|
|
627
|
+
" 1. PLAN nodata_decide \"may I reach X?\" -> allow / partial / deny + a signed proof. No data moves.\n" +
|
|
628
|
+
" 2. PULL nodata_retrieve prompt-ready context, allowed fields only, with a receipt.\n" +
|
|
629
|
+
" (nodata_read for raw governed rows.)\n" +
|
|
630
|
+
" 3. ACT nodata_use run a capability with a secret injected server-side — you never see it.\n";
|
|
631
|
+
const START_UNSTUCK = "STUCK? There's always a next step:\n" +
|
|
632
|
+
" - Don't see the nodata_* tools? They load when the client starts — type /mcp or restart.\n" +
|
|
633
|
+
" (Expected, not an error.)\n" +
|
|
634
|
+
" - No MCP at all? The same governance runs from one curl, or the guided page:\n" +
|
|
635
|
+
" https://www.nodatacapsule.com/developers/get-started\n";
|
|
636
|
+
function startHeader(mode) {
|
|
637
|
+
if (mode === "setup") {
|
|
638
|
+
return "You're connected to NoData — but there's NO key yet, so the data tools are still off.\n\n";
|
|
639
|
+
}
|
|
640
|
+
if (mode === "agent") {
|
|
641
|
+
return "You're connected to NoData, scoped to one grant token.\n\n";
|
|
642
|
+
}
|
|
643
|
+
return "You're connected to NoData (admin mode). You don't memorize commands — just ask in plain words.\n\n";
|
|
644
|
+
}
|
|
645
|
+
function getStartedText(mode) {
|
|
646
|
+
// Setup leads with the 3 connect steps (the only thing that matters when off);
|
|
647
|
+
// admin/agent lead with the journey they can act on right now.
|
|
648
|
+
if (mode === "setup") {
|
|
649
|
+
return startHeader(mode) + START_ACHIEVEMENT + "\n" + SETUP_STEPS + "\n" + START_UNSTUCK;
|
|
650
|
+
}
|
|
651
|
+
const outcomes = mode === "agent" ? START_OUTCOMES_AGENT : START_OUTCOMES_ADMIN;
|
|
652
|
+
return startHeader(mode) + START_ACHIEVEMENT + "\n" + outcomes + "\n" + START_UNSTUCK;
|
|
653
|
+
}
|
|
654
|
+
function registerGetStarted(server, mode) {
|
|
402
655
|
server.registerTool("nodata_get_started", {
|
|
403
|
-
description: "
|
|
656
|
+
description: "Call this FIRST (no arguments). NoData in one line: what you can achieve — an agent that " +
|
|
657
|
+
"cannot leak, a look at what is already exposed, a secret we cannot read — with the single " +
|
|
658
|
+
"next step for each, and how to recover if the tools or MCP seem stuck. If the user asks what " +
|
|
659
|
+
"NoData can do, how to start, or why the tools are missing, call this and relay it.",
|
|
404
660
|
inputSchema: {},
|
|
405
|
-
}, async () => ({ content: [{ type: "text", text:
|
|
661
|
+
}, async () => ({ content: [{ type: "text", text: getStartedText(mode) }] }));
|
|
662
|
+
}
|
|
663
|
+
// ---------------------------------------------------------------------------
|
|
664
|
+
// nodata_connect — setup-mode "get switched on without re-typing the command"
|
|
665
|
+
// ---------------------------------------------------------------------------
|
|
666
|
+
// "Registration ≠ login." Call once with no args → open the real signup in the
|
|
667
|
+
// browser (email + company, the 12-word recovery, the email verify — the parts
|
|
668
|
+
// a headless mint would skip). It shows the org key once; call again with that
|
|
669
|
+
// key → it is saved to ~/.nodata and every later start autoloads it. First run:
|
|
670
|
+
// register + one paste. Every run after: nothing. The only residual is one /mcp,
|
|
671
|
+
// because MCP clients read their tool list at startup (a client fact, not ours).
|
|
672
|
+
const REGISTER_URL_PATH = "/capsule-api/register";
|
|
673
|
+
function openInBrowser(url) {
|
|
674
|
+
const isWin = process.platform === "win32";
|
|
675
|
+
const cmd = process.platform === "darwin" ? "open" : isWin ? "cmd" : "xdg-open";
|
|
676
|
+
const args = isWin ? ["/c", "start", "", url] : [url];
|
|
677
|
+
try {
|
|
678
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
679
|
+
// A missing opener (headless box, no xdg-open) must never surface as a tool
|
|
680
|
+
// error — the URL is always printed too, so the flow proceeds regardless.
|
|
681
|
+
child.on("error", () => { });
|
|
682
|
+
child.unref();
|
|
683
|
+
return true;
|
|
684
|
+
}
|
|
685
|
+
catch {
|
|
686
|
+
return false;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
function registerConnectTool(server, baseUrl) {
|
|
690
|
+
server.registerTool("nodata_connect", {
|
|
691
|
+
description: "Switch NoData's data tools on. Step 1: call with NO arguments to open the free signup in a " +
|
|
692
|
+
"browser (email + company, ~30s — also where your 12-word recovery phrase + email verification " +
|
|
693
|
+
"happen); it shows an ORG KEY once. Step 2: call this tool again with that key as `api_key` and " +
|
|
694
|
+
"it is saved on this machine, so every later start comes up ready with nothing to retype. " +
|
|
695
|
+
"Step 3: type /mcp (or restart) so the client loads the tools.",
|
|
696
|
+
inputSchema: {
|
|
697
|
+
api_key: z
|
|
698
|
+
.string()
|
|
699
|
+
.optional()
|
|
700
|
+
.describe("The org key shown once at signup (ndp_… or sk_…). Omit on the first call to open signup."),
|
|
701
|
+
},
|
|
702
|
+
}, async ({ api_key }) => {
|
|
703
|
+
const key = (api_key ?? "").trim();
|
|
704
|
+
// First call (no key): open the real signup and tell them to come back with it.
|
|
705
|
+
if (!key) {
|
|
706
|
+
const url = baseUrl + REGISTER_URL_PATH;
|
|
707
|
+
const opened = openInBrowser(url);
|
|
708
|
+
return {
|
|
709
|
+
content: [
|
|
710
|
+
{
|
|
711
|
+
type: "text",
|
|
712
|
+
text: (opened ? "STEP 1 of 3 — opened the NoData signup in your browser:\n " : "STEP 1 of 3 — open the NoData signup in your browser:\n ") +
|
|
713
|
+
url +
|
|
714
|
+
"\n\n" +
|
|
715
|
+
"There, about a minute:\n" +
|
|
716
|
+
" - email + company (a personal email is fine for the sandbox)\n" +
|
|
717
|
+
" - a 12-WORD RECOVERY PHRASE — save it; it is the only way back into your org\n" +
|
|
718
|
+
" - your ORG KEY, shown ONCE — copy it\n\n" +
|
|
719
|
+
"STEP 2 — come back and call this tool again with the key:\n" +
|
|
720
|
+
' nodata_connect api_key: "<the key you copied>"\n' +
|
|
721
|
+
"and I'll save it so you never paste it again. (Then STEP 3: type /mcp to activate.)",
|
|
722
|
+
},
|
|
723
|
+
],
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
// Shape check — don't persist something that clearly isn't a NoData key.
|
|
727
|
+
if (!/^(ndp_|sk_)/.test(key)) {
|
|
728
|
+
return {
|
|
729
|
+
content: [
|
|
730
|
+
{
|
|
731
|
+
type: "text",
|
|
732
|
+
text: "That doesn't look like a NoData org key (it should start with ndp_ or sk_).\n" +
|
|
733
|
+
'Copy the key shown once on the signup page and try again: nodata_connect api_key: "ndp_…".',
|
|
734
|
+
},
|
|
735
|
+
],
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
// Preferred path: enroll a DEVICE KEY so no reusable key is stored. Generate
|
|
739
|
+
// a keypair, register its PUBLIC half under the org (authenticated by the key
|
|
740
|
+
// the user just pasted), keep only the PRIVATE half. Then every start attests.
|
|
741
|
+
const { publicKeyHex, privateKeyPkcs8B64 } = generateDeviceKeypair();
|
|
742
|
+
const handle = defaultDeviceHandle();
|
|
743
|
+
const label = hostname() || "this device";
|
|
744
|
+
const enrolled = await enrollDevice(baseUrl, key, handle, label, publicKeyHex);
|
|
745
|
+
if (enrolled.ok) {
|
|
746
|
+
try {
|
|
747
|
+
writeStoredCredential({
|
|
748
|
+
device: {
|
|
749
|
+
org_id: enrolled.orgId,
|
|
750
|
+
handle,
|
|
751
|
+
label,
|
|
752
|
+
private_key_pkcs8_b64: privateKeyPkcs8B64,
|
|
753
|
+
public_key_hex: publicKeyHex,
|
|
754
|
+
},
|
|
755
|
+
base_url: baseUrl,
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
catch (e) {
|
|
759
|
+
return {
|
|
760
|
+
content: [{ type: "text", text: "Enrolled the device, but couldn't save it locally: " + (e instanceof Error ? e.message : String(e)) }],
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
return {
|
|
764
|
+
content: [
|
|
765
|
+
{
|
|
766
|
+
type: "text",
|
|
767
|
+
text: `STEP 2 done — this device is enrolled as "${label}" (${handle}), and only its PRIVATE key is\n` +
|
|
768
|
+
"saved at ~/.nodata/credentials.json (readable only by you). The org key you pasted was NOT\n" +
|
|
769
|
+
"stored: every start now proves possession by signing a fresh challenge and gets a 15-minute token.\n\n" +
|
|
770
|
+
"STEP 3 — type /mcp (or restart Claude Code) and the admin tools appear. Next time: nothing to do.",
|
|
771
|
+
},
|
|
772
|
+
],
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
// Fallback: a backend that predates the attest endpoints. Store the key at
|
|
776
|
+
// rest (the Part-2 path) so onboarding still works end to end.
|
|
777
|
+
const attestUnavailable = enrolled.status === 404 || enrolled.status === 0;
|
|
778
|
+
try {
|
|
779
|
+
writeStoredCredential({ api_key: key, key_prefix: key.slice(0, 12), base_url: baseUrl });
|
|
780
|
+
}
|
|
781
|
+
catch (e) {
|
|
782
|
+
return {
|
|
783
|
+
content: [
|
|
784
|
+
{
|
|
785
|
+
type: "text",
|
|
786
|
+
text: "Couldn't save locally: " +
|
|
787
|
+
(e instanceof Error ? e.message : String(e)) +
|
|
788
|
+
"\nAs a fallback, pass it on launch: claude mcp add nodata --env NODATA_API_KEY=<your key> -- npx @nodatachat/mcp",
|
|
789
|
+
},
|
|
790
|
+
],
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
return {
|
|
794
|
+
content: [
|
|
795
|
+
{
|
|
796
|
+
type: "text",
|
|
797
|
+
text: (attestUnavailable
|
|
798
|
+
? "STEP 2 done — saved your key on this machine (device attestation isn't available on this\nserver yet, so this is the key-at-rest path). "
|
|
799
|
+
: `STEP 2 done — saved your key on this machine (device enrollment didn't succeed: ${enrolled.error}). `) +
|
|
800
|
+
"Every future start loads it automatically — no re-register, no re-paste.\n\n" +
|
|
801
|
+
"STEP 3 — type /mcp (or restart Claude Code) and the admin tools appear. Next time: nothing to do.",
|
|
802
|
+
},
|
|
803
|
+
],
|
|
804
|
+
};
|
|
805
|
+
});
|
|
406
806
|
}
|
|
407
807
|
// ---------------------------------------------------------------------------
|
|
408
808
|
// Server setup
|
|
409
809
|
// ---------------------------------------------------------------------------
|
|
410
810
|
async function main() {
|
|
411
|
-
const { apiKey, grantToken, baseUrl } = parseArgs(process.argv.slice(2));
|
|
412
|
-
const mode = grantToken ? "agent" : apiKey ? "admin" : "setup";
|
|
811
|
+
const { apiKey, grantToken, baseUrl, credentialSource, device } = parseArgs(process.argv.slice(2));
|
|
812
|
+
const mode = grantToken ? "agent" : apiKey || device ? "admin" : "setup";
|
|
413
813
|
const server = new McpServer({
|
|
414
814
|
name: mode === "agent" ? "NoData (agent-scoped)" : mode === "setup" ? "NoData (setup)" : "NoData",
|
|
415
815
|
version: VERSION,
|
|
416
816
|
});
|
|
417
|
-
|
|
817
|
+
// A stored device (attest) is preferred: the admin tools get a token PROVIDER
|
|
818
|
+
// that signs a fresh challenge per session instead of a static key.
|
|
819
|
+
if (device)
|
|
820
|
+
registerAdminTools(server, baseUrl, deviceTokenProvider(baseUrl, device));
|
|
821
|
+
else if (apiKey)
|
|
418
822
|
registerAdminTools(server, baseUrl, apiKey);
|
|
419
823
|
if (grantToken)
|
|
420
824
|
registerAgentTool(server, baseUrl, grantToken);
|
|
825
|
+
registerGetStarted(server, mode);
|
|
421
826
|
if (mode === "setup") {
|
|
422
|
-
|
|
423
|
-
console.error("nodata-mcp: no credential given — running in setup mode (
|
|
424
|
-
"
|
|
827
|
+
registerConnectTool(server, baseUrl);
|
|
828
|
+
console.error("nodata-mcp: no credential given — running in setup mode (data tools disabled).\n" +
|
|
829
|
+
"Call nodata_connect (opens signup, enrolls this device), or pass --api-key / --grant-token. See --help.");
|
|
830
|
+
}
|
|
831
|
+
else if (device) {
|
|
832
|
+
console.error(`nodata-mcp: loaded device identity "${device.label}" (${device.handle}) — attest mode; tokens are minted per session.`);
|
|
833
|
+
}
|
|
834
|
+
else if (credentialSource === "stored") {
|
|
835
|
+
console.error("nodata-mcp: loaded saved credential from ~/.nodata/credentials.json (admin mode).");
|
|
425
836
|
}
|
|
426
837
|
const transport = new StdioServerTransport();
|
|
427
838
|
await server.connect(transport);
|
package/package.json
CHANGED
|
@@ -1,38 +1,43 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@nodatachat/mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "NoData MCP Server — govern data for AI agents: decide (may I?) + retrieve (authorized context) + read + secret-blind invoke, plus admin governance/encrypt/decrypt/deliver",
|
|
5
|
-
"bin": {
|
|
6
|
-
"nodata-mcp": "dist/index.js"
|
|
7
|
-
},
|
|
8
|
-
"type": "module",
|
|
9
|
-
"scripts": {
|
|
10
|
-
"build": "tsc",
|
|
11
|
-
"dev": "tsx src/index.ts"
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@nodatachat/mcp",
|
|
3
|
+
"version": "0.8.0",
|
|
4
|
+
"description": "NoData MCP Server — govern data AND code for AI agents: decide (may I?) + retrieve (authorized context) + read + secret-blind invoke + blind code advise/patch (fix code without exposure), plus admin governance/encrypt/decrypt/deliver",
|
|
5
|
+
"bin": {
|
|
6
|
+
"nodata-mcp": "dist/index.js"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"dev": "tsx src/index.ts",
|
|
12
|
+
"prepublishOnly": "npm run build"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
16
|
+
"zod": "^4.0.0"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/node": "^22.0.0",
|
|
20
|
+
"tsx": "^4.0.0",
|
|
21
|
+
"typescript": "^5.7.0"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=18.0.0"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist"
|
|
28
|
+
],
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://www.nodatacapsule.com/developers",
|
|
33
|
+
"keywords": [
|
|
34
|
+
"mcp",
|
|
35
|
+
"model-context-protocol",
|
|
36
|
+
"ai-agents",
|
|
37
|
+
"data-governance",
|
|
38
|
+
"code-capsule",
|
|
39
|
+
"nodata",
|
|
40
|
+
"access-control"
|
|
41
|
+
],
|
|
42
|
+
"license": "MIT"
|
|
43
|
+
}
|