@nodatachat/mcp 0.4.1 → 0.7.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 -108
- package/dist/index.js +488 -56
- package/package.json +43 -38
package/README.md
CHANGED
|
@@ -1,108 +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
|
-
|
|
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.7.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,24 +121,63 @@ 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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
|
|
161
|
+
// Without a deadline a dead network turns a tool call into an indefinite hang
|
|
162
|
+
// inside the MCP client; 30s comfortably covers a slow decide/retrieve.
|
|
163
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
164
|
+
async function doFetch(url, init) {
|
|
165
|
+
let res;
|
|
166
|
+
try {
|
|
167
|
+
res = await fetch(url, { ...init, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
const timedOut = err instanceof Error && err.name === "TimeoutError";
|
|
171
|
+
return {
|
|
172
|
+
ok: false,
|
|
173
|
+
status: 0,
|
|
174
|
+
body: {
|
|
175
|
+
error: timedOut
|
|
176
|
+
? `Request timed out after ${REQUEST_TIMEOUT_MS / 1000}s: ${url}`
|
|
177
|
+
: `Network error reaching ${url}: ${err instanceof Error ? err.message : String(err)}`,
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
88
181
|
let parsed;
|
|
89
182
|
try {
|
|
90
183
|
parsed = await res.json();
|
|
@@ -94,24 +187,44 @@ async function apiPost(baseUrl, bearer, path, body) {
|
|
|
94
187
|
}
|
|
95
188
|
return { ok: res.ok, status: res.status, body: parsed };
|
|
96
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
|
+
}
|
|
202
|
+
async function apiPost(baseUrl, bearer, path, body) {
|
|
203
|
+
const r = await resolveBearer(bearer);
|
|
204
|
+
if ("error" in r)
|
|
205
|
+
return r.error;
|
|
206
|
+
return doFetch(`${baseUrl}${path}`, {
|
|
207
|
+
method: "POST",
|
|
208
|
+
headers: {
|
|
209
|
+
"Content-Type": "application/json",
|
|
210
|
+
Authorization: `Bearer ${r.token}`,
|
|
211
|
+
},
|
|
212
|
+
body: JSON.stringify(body),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
97
215
|
async function apiGet(baseUrl, bearer, path, params) {
|
|
216
|
+
const r = await resolveBearer(bearer);
|
|
217
|
+
if ("error" in r)
|
|
218
|
+
return r.error;
|
|
98
219
|
const url = new URL(`${baseUrl}${path}`);
|
|
99
220
|
for (const [key, val] of Object.entries(params)) {
|
|
100
221
|
if (val !== undefined && val !== "")
|
|
101
222
|
url.searchParams.set(key, val);
|
|
102
223
|
}
|
|
103
|
-
|
|
224
|
+
return doFetch(url.toString(), {
|
|
104
225
|
method: "GET",
|
|
105
|
-
headers: { Authorization: `Bearer ${
|
|
226
|
+
headers: { Authorization: `Bearer ${r.token}` },
|
|
106
227
|
});
|
|
107
|
-
let parsed;
|
|
108
|
-
try {
|
|
109
|
-
parsed = await res.json();
|
|
110
|
-
}
|
|
111
|
-
catch {
|
|
112
|
-
parsed = { error: `Non-JSON response (HTTP ${res.status})` };
|
|
113
|
-
}
|
|
114
|
-
return { ok: res.ok, status: res.status, body: parsed };
|
|
115
228
|
}
|
|
116
229
|
function formatResult(resp) {
|
|
117
230
|
const text = JSON.stringify(resp.body, null, 2);
|
|
@@ -138,6 +251,74 @@ function handleFromGrant(token) {
|
|
|
138
251
|
}
|
|
139
252
|
}
|
|
140
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
|
+
// ---------------------------------------------------------------------------
|
|
141
322
|
// Tool registration
|
|
142
323
|
// ---------------------------------------------------------------------------
|
|
143
324
|
function registerAdminTools(server, baseUrl, apiKey) {
|
|
@@ -261,6 +442,13 @@ function registerAdminTools(server, baseUrl, apiKey) {
|
|
|
261
442
|
}
|
|
262
443
|
function registerAgentTool(server, baseUrl, grantToken) {
|
|
263
444
|
const handle = handleFromGrant(grantToken);
|
|
445
|
+
if (!handle) {
|
|
446
|
+
// decide/retrieve are bearer-only and still work; read/use need the handle.
|
|
447
|
+
// Say so at startup instead of surprising the agent mid-conversation.
|
|
448
|
+
console.error("nodata-mcp: warning — could not derive an agent handle from the grant token " +
|
|
449
|
+
"(expected ndca-<jwt> with a 'handle' claim). nodata_decide/nodata_retrieve will work; " +
|
|
450
|
+
"nodata_read/nodata_use will fail until a valid grant token is provided.");
|
|
451
|
+
}
|
|
264
452
|
// ── Access Compute · the "may I?" oracle (P0) ───────────────────────
|
|
265
453
|
// Returns ONLY a decision + a signed proof, never any content. A planner
|
|
266
454
|
// calls this to learn what it can reach BEFORE moving data: allow, degrade
|
|
@@ -352,53 +540,297 @@ function registerAgentTool(server, baseUrl, grantToken) {
|
|
|
352
540
|
});
|
|
353
541
|
return formatResult(resp);
|
|
354
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
|
+
});
|
|
355
597
|
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
"
|
|
366
|
-
"
|
|
367
|
-
"
|
|
368
|
-
"
|
|
369
|
-
"
|
|
370
|
-
"
|
|
371
|
-
"
|
|
372
|
-
"
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
"
|
|
377
|
-
"
|
|
378
|
-
|
|
598
|
+
const START_ACHIEVEMENT = "WHAT YOU GET WITH NODATA — in one line:\n" +
|
|
599
|
+
" Give any agent real power over your data, without ever giving it the power to leak it.\n" +
|
|
600
|
+
" (Possession is not permission: an agent can hold the key and still not read what you didn't allow.)\n";
|
|
601
|
+
// Setup / admin readers are the developer wiring governance up — outcomes point
|
|
602
|
+
// at the admin verbs (register / grant / encrypt / deliver) and the no-setup web door.
|
|
603
|
+
const START_OUTCOMES_ADMIN = "PICK AN OUTCOME — the win first, the step under it:\n\n" +
|
|
604
|
+
"[1] An agent that cannot leak\n" +
|
|
605
|
+
" It sees only the classifications you allow; the rest never decrypts for it, and every\n" +
|
|
606
|
+
" disclosure returns a signed receipt.\n" +
|
|
607
|
+
" Start: tell me \"give <agent> public + internal, hide the rest\" — I register the data\n" +
|
|
608
|
+
" (nodata_register_table) and issue the grant (nodata_grant); nodata_revoke cuts it off anytime.\n\n" +
|
|
609
|
+
"[2] See what is already exposed — no setup\n" +
|
|
610
|
+
" Know exactly which sensitive fields your agent already received, before you change anything.\n" +
|
|
611
|
+
" Start: https://www.nodatacapsule.com/trace-inspector (runs in your browser, nothing uploaded).\n\n" +
|
|
612
|
+
"[3] A secret you hold that NoData cannot read\n" +
|
|
613
|
+
" Encrypt a value even NoData's server cannot open, then hand it over as a one-time link.\n" +
|
|
614
|
+
" Start: nodata_encrypt, then nodata_deliver.\n";
|
|
615
|
+
// The agent-scoped reader IS the governed agent: it holds only decide/retrieve/read/use,
|
|
616
|
+
// so its outcomes name those verbs (pointing it at admin tools it lacks would dead-end it).
|
|
617
|
+
const START_OUTCOMES_AGENT = "WHAT YOU CAN DO — scoped to your grant, the win first, the step under it:\n\n" +
|
|
618
|
+
"[1] Know before you read\n" +
|
|
619
|
+
" Check whether you MAY reach something without pulling any data — allow / degrade to the\n" +
|
|
620
|
+
" reachable subset / deny, with an access-distance cost and a signed proof.\n" +
|
|
621
|
+
" Start: nodata_decide.\n\n" +
|
|
622
|
+
"[2] Pull only what you're allowed\n" +
|
|
623
|
+
" Prompt-ready context filtered to exactly your permitted fields; denied fields never decrypt,\n" +
|
|
624
|
+
" and you get an accounting of what was withheld plus a receipt.\n" +
|
|
625
|
+
" Start: nodata_retrieve (or nodata_read for governed rows).\n\n" +
|
|
626
|
+
"[3] Use a secret you never see\n" +
|
|
627
|
+
" Invoke a pre-registered capability; the Capsule injects the sealed credential server-side and\n" +
|
|
628
|
+
" returns only the result. The secret never reaches you.\n" +
|
|
629
|
+
" Start: nodata_use.\n";
|
|
630
|
+
const START_UNSTUCK = "IF IT SEEMS STUCK — there is always a next step:\n" +
|
|
631
|
+
" - Don't see the nodata_* tools yet? They load when the MCP client starts. Restart Claude Code\n" +
|
|
632
|
+
" (or run /mcp). This is expected, not an error.\n" +
|
|
633
|
+
" - No MCP at all, or it won't connect? The same governance runs without it — one curl, or the\n" +
|
|
634
|
+
" guided page at https://www.nodatacapsule.com/developers/get-started\n";
|
|
635
|
+
function startHeader(mode) {
|
|
636
|
+
if (mode === "setup") {
|
|
637
|
+
return ("You're connected to NoData, but WITHOUT a credential yet, so the data tools are still off.\n" +
|
|
638
|
+
"Fastest unlock — nothing to retype: call nodata_connect. It opens the free signup in your\n" +
|
|
639
|
+
"browser (email + company, ~30s, with your recovery phrase + email verify), then you call it\n" +
|
|
640
|
+
"once more with the key it shows and it's SAVED — every later start comes up ready, and the\n" +
|
|
641
|
+
"only step left is one /mcp so the client reloads the tools.\n\n");
|
|
642
|
+
}
|
|
643
|
+
if (mode === "agent") {
|
|
644
|
+
return ("You're connected to NoData, scoped to one grant token: you can reach only what that grant\n" +
|
|
645
|
+
"allows, and denied fields never decrypt for you. Plan with nodata_decide, then nodata_retrieve.\n\n");
|
|
646
|
+
}
|
|
647
|
+
return "You're connected to NoData (admin). Everything below is one plain-language ask away.\n\n";
|
|
648
|
+
}
|
|
649
|
+
function getStartedText(mode) {
|
|
650
|
+
const outcomes = mode === "agent" ? START_OUTCOMES_AGENT : START_OUTCOMES_ADMIN;
|
|
651
|
+
return startHeader(mode) + START_ACHIEVEMENT + "\n" + outcomes + "\n" + START_UNSTUCK;
|
|
652
|
+
}
|
|
653
|
+
function registerGetStarted(server, mode) {
|
|
379
654
|
server.registerTool("nodata_get_started", {
|
|
380
|
-
description: "
|
|
655
|
+
description: "Call this FIRST (no arguments). NoData in one line: what you can achieve — an agent that " +
|
|
656
|
+
"cannot leak, a look at what is already exposed, a secret we cannot read — with the single " +
|
|
657
|
+
"next step for each, and how to recover if the tools or MCP seem stuck. If the user asks what " +
|
|
658
|
+
"NoData can do, how to start, or why the tools are missing, call this and relay it.",
|
|
381
659
|
inputSchema: {},
|
|
382
|
-
}, async () => ({ content: [{ type: "text", text:
|
|
660
|
+
}, async () => ({ content: [{ type: "text", text: getStartedText(mode) }] }));
|
|
661
|
+
}
|
|
662
|
+
// ---------------------------------------------------------------------------
|
|
663
|
+
// nodata_connect — setup-mode "get switched on without re-typing the command"
|
|
664
|
+
// ---------------------------------------------------------------------------
|
|
665
|
+
// "Registration ≠ login." Call once with no args → open the real signup in the
|
|
666
|
+
// browser (email + company, the 12-word recovery, the email verify — the parts
|
|
667
|
+
// a headless mint would skip). It shows the org key once; call again with that
|
|
668
|
+
// key → it is saved to ~/.nodata and every later start autoloads it. First run:
|
|
669
|
+
// register + one paste. Every run after: nothing. The only residual is one /mcp,
|
|
670
|
+
// because MCP clients read their tool list at startup (a client fact, not ours).
|
|
671
|
+
const REGISTER_URL_PATH = "/capsule-api/register";
|
|
672
|
+
function openInBrowser(url) {
|
|
673
|
+
const isWin = process.platform === "win32";
|
|
674
|
+
const cmd = process.platform === "darwin" ? "open" : isWin ? "cmd" : "xdg-open";
|
|
675
|
+
const args = isWin ? ["/c", "start", "", url] : [url];
|
|
676
|
+
try {
|
|
677
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
678
|
+
// A missing opener (headless box, no xdg-open) must never surface as a tool
|
|
679
|
+
// error — the URL is always printed too, so the flow proceeds regardless.
|
|
680
|
+
child.on("error", () => { });
|
|
681
|
+
child.unref();
|
|
682
|
+
return true;
|
|
683
|
+
}
|
|
684
|
+
catch {
|
|
685
|
+
return false;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
function registerConnectTool(server, baseUrl) {
|
|
689
|
+
server.registerTool("nodata_connect", {
|
|
690
|
+
description: "Switch NoData's data tools on. Call with NO arguments to open the free signup in a " +
|
|
691
|
+
"browser (email + company, ~30s — also where your recovery phrase + email verification " +
|
|
692
|
+
"happen). It shows an org key ONCE; call this tool again with that key as `api_key` and it " +
|
|
693
|
+
"is saved locally, so every later start comes up ready with nothing to retype. After saving, " +
|
|
694
|
+
"reconnect (/mcp) or restart so the client loads the tools.",
|
|
695
|
+
inputSchema: {
|
|
696
|
+
api_key: z
|
|
697
|
+
.string()
|
|
698
|
+
.optional()
|
|
699
|
+
.describe("The org key shown once at signup (ndp_… or sk_…). Omit on the first call to open signup."),
|
|
700
|
+
},
|
|
701
|
+
}, async ({ api_key }) => {
|
|
702
|
+
const key = (api_key ?? "").trim();
|
|
703
|
+
// First call (no key): open the real signup and tell them to come back with it.
|
|
704
|
+
if (!key) {
|
|
705
|
+
const url = baseUrl + REGISTER_URL_PATH;
|
|
706
|
+
const opened = openInBrowser(url);
|
|
707
|
+
return {
|
|
708
|
+
content: [
|
|
709
|
+
{
|
|
710
|
+
type: "text",
|
|
711
|
+
text: (opened ? "Opened the NoData signup in your browser:\n " : "Open the NoData signup in your browser:\n ") +
|
|
712
|
+
url +
|
|
713
|
+
"\n\n" +
|
|
714
|
+
"It takes ~30s (email + company; a personal email is fine for the sandbox). You'll also\n" +
|
|
715
|
+
"set a 12-word recovery phrase and verify your email there — keep the recovery phrase, it\n" +
|
|
716
|
+
"is the only way back into the org.\n\n" +
|
|
717
|
+
"The page shows an ORG KEY exactly once. Copy it, then call nodata_connect again with\n" +
|
|
718
|
+
' api_key: "<that key>"\n' +
|
|
719
|
+
"and I'll save it so you never paste it again.",
|
|
720
|
+
},
|
|
721
|
+
],
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
// Shape check — don't persist something that clearly isn't a NoData key.
|
|
725
|
+
if (!/^(ndp_|sk_)/.test(key)) {
|
|
726
|
+
return {
|
|
727
|
+
content: [
|
|
728
|
+
{
|
|
729
|
+
type: "text",
|
|
730
|
+
text: "That doesn't look like a NoData org key (it should start with ndp_ or sk_).\n" +
|
|
731
|
+
'Copy the key shown once on the signup page and try again: nodata_connect api_key: "ndp_…".',
|
|
732
|
+
},
|
|
733
|
+
],
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
// Preferred path: enroll a DEVICE KEY so no reusable key is stored. Generate
|
|
737
|
+
// a keypair, register its PUBLIC half under the org (authenticated by the key
|
|
738
|
+
// the user just pasted), keep only the PRIVATE half. Then every start attests.
|
|
739
|
+
const { publicKeyHex, privateKeyPkcs8B64 } = generateDeviceKeypair();
|
|
740
|
+
const handle = defaultDeviceHandle();
|
|
741
|
+
const label = hostname() || "this device";
|
|
742
|
+
const enrolled = await enrollDevice(baseUrl, key, handle, label, publicKeyHex);
|
|
743
|
+
if (enrolled.ok) {
|
|
744
|
+
try {
|
|
745
|
+
writeStoredCredential({
|
|
746
|
+
device: {
|
|
747
|
+
org_id: enrolled.orgId,
|
|
748
|
+
handle,
|
|
749
|
+
label,
|
|
750
|
+
private_key_pkcs8_b64: privateKeyPkcs8B64,
|
|
751
|
+
public_key_hex: publicKeyHex,
|
|
752
|
+
},
|
|
753
|
+
base_url: baseUrl,
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
catch (e) {
|
|
757
|
+
return {
|
|
758
|
+
content: [{ type: "text", text: "Enrolled the device, but couldn't save it locally: " + (e instanceof Error ? e.message : String(e)) }],
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
return {
|
|
762
|
+
content: [
|
|
763
|
+
{
|
|
764
|
+
type: "text",
|
|
765
|
+
text: `Done — this device is enrolled as "${label}" (${handle}), and only its PRIVATE key is saved\n` +
|
|
766
|
+
"at ~/.nodata/credentials.json (readable only by you). The org key you pasted was NOT stored:\n" +
|
|
767
|
+
"every start now proves possession by signing a fresh challenge and gets a 15-minute token.\n\n" +
|
|
768
|
+
"Reconnect (run /mcp, or restart Claude Code) and the admin tools will be there.",
|
|
769
|
+
},
|
|
770
|
+
],
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
// Fallback: a backend that predates the attest endpoints. Store the key at
|
|
774
|
+
// rest (the Part-2 path) so onboarding still works end to end.
|
|
775
|
+
const attestUnavailable = enrolled.status === 404 || enrolled.status === 0;
|
|
776
|
+
try {
|
|
777
|
+
writeStoredCredential({ api_key: key, key_prefix: key.slice(0, 12), base_url: baseUrl });
|
|
778
|
+
}
|
|
779
|
+
catch (e) {
|
|
780
|
+
return {
|
|
781
|
+
content: [
|
|
782
|
+
{
|
|
783
|
+
type: "text",
|
|
784
|
+
text: "Couldn't save locally: " +
|
|
785
|
+
(e instanceof Error ? e.message : String(e)) +
|
|
786
|
+
"\nAs a fallback, pass it on launch: claude mcp add nodata --env NODATA_API_KEY=<your key> -- npx @nodatachat/mcp",
|
|
787
|
+
},
|
|
788
|
+
],
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
return {
|
|
792
|
+
content: [
|
|
793
|
+
{
|
|
794
|
+
type: "text",
|
|
795
|
+
text: (attestUnavailable
|
|
796
|
+
? "Saved your key locally (device attestation isn't available on this server yet, so this is the\nkey-at-rest path). "
|
|
797
|
+
: `Saved your key locally (device enrollment didn't succeed: ${enrolled.error}). `) +
|
|
798
|
+
"Every future start loads it automatically — no re-register, no re-paste.\n\n" +
|
|
799
|
+
"Reconnect (run /mcp, or restart Claude Code) and the admin tools will be there.",
|
|
800
|
+
},
|
|
801
|
+
],
|
|
802
|
+
};
|
|
803
|
+
});
|
|
383
804
|
}
|
|
384
805
|
// ---------------------------------------------------------------------------
|
|
385
806
|
// Server setup
|
|
386
807
|
// ---------------------------------------------------------------------------
|
|
387
808
|
async function main() {
|
|
388
|
-
const { apiKey, grantToken, baseUrl } = parseArgs(process.argv.slice(2));
|
|
389
|
-
const mode = grantToken ? "agent" : apiKey ? "admin" : "setup";
|
|
809
|
+
const { apiKey, grantToken, baseUrl, credentialSource, device } = parseArgs(process.argv.slice(2));
|
|
810
|
+
const mode = grantToken ? "agent" : apiKey || device ? "admin" : "setup";
|
|
390
811
|
const server = new McpServer({
|
|
391
812
|
name: mode === "agent" ? "NoData (agent-scoped)" : mode === "setup" ? "NoData (setup)" : "NoData",
|
|
392
813
|
version: VERSION,
|
|
393
814
|
});
|
|
394
|
-
|
|
815
|
+
// A stored device (attest) is preferred: the admin tools get a token PROVIDER
|
|
816
|
+
// that signs a fresh challenge per session instead of a static key.
|
|
817
|
+
if (device)
|
|
818
|
+
registerAdminTools(server, baseUrl, deviceTokenProvider(baseUrl, device));
|
|
819
|
+
else if (apiKey)
|
|
395
820
|
registerAdminTools(server, baseUrl, apiKey);
|
|
396
821
|
if (grantToken)
|
|
397
822
|
registerAgentTool(server, baseUrl, grantToken);
|
|
823
|
+
registerGetStarted(server, mode);
|
|
398
824
|
if (mode === "setup") {
|
|
399
|
-
|
|
400
|
-
console.error("nodata-mcp: no credential given — running in setup mode (
|
|
401
|
-
"
|
|
825
|
+
registerConnectTool(server, baseUrl);
|
|
826
|
+
console.error("nodata-mcp: no credential given — running in setup mode (data tools disabled).\n" +
|
|
827
|
+
"Call nodata_connect (opens signup, enrolls this device), or pass --api-key / --grant-token. See --help.");
|
|
828
|
+
}
|
|
829
|
+
else if (device) {
|
|
830
|
+
console.error(`nodata-mcp: loaded device identity "${device.label}" (${device.handle}) — attest mode; tokens are minted per session.`);
|
|
831
|
+
}
|
|
832
|
+
else if (credentialSource === "stored") {
|
|
833
|
+
console.error("nodata-mcp: loaded saved credential from ~/.nodata/credentials.json (admin mode).");
|
|
402
834
|
}
|
|
403
835
|
const transport = new StdioServerTransport();
|
|
404
836
|
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": "
|
|
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.7.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
|
+
}
|