purra-mcp 1.0.0 → 1.0.1
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 +82 -3
- package/dist/index.d.ts +23 -1
- package/dist/index.js +31 -7
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# purra-mcp (TypeScript)
|
|
2
2
|
|
|
3
|
-
Development package for
|
|
3
|
+
Development package for host-authorized MCP read tools and durable approval-gated writes. Requires
|
|
4
4
|
Node.js 22+, matching `purra`, and the pinned official
|
|
5
5
|
`@modelcontextprotocol/sdk@1.30.0` (v1 line). Core has no MCP dependency.
|
|
6
6
|
Build Core and this package, then install both in the consuming application.
|
|
@@ -88,8 +88,8 @@ rediscovery or hidden model calls occur.
|
|
|
88
88
|
An AbortSignal stops local waiting; the official SDK sends a best-effort
|
|
89
89
|
cancellation notification. It does not prove the remote task stopped. Late
|
|
90
90
|
results are discarded. `effectState: "not_started"` describes the declared
|
|
91
|
-
absence of host writes, not whether a network request was sent.
|
|
92
|
-
|
|
91
|
+
absence of host writes for read bindings, not whether a network request was sent.
|
|
92
|
+
The separate write entry point below classifies uncertain effects conservatively.
|
|
93
93
|
|
|
94
94
|
Shared fixtures run against the official local client/server protocol, with a
|
|
95
95
|
JSON serialization boundary. The installed smoke also launches and closes a
|
|
@@ -126,3 +126,82 @@ HTTP connection failures (including `fetch` transport `TypeError`) return
|
|
|
126
126
|
configured request timeout. The host must close the monitor when discarding a
|
|
127
127
|
failed session, then initialize a new session and explicitly rediscover tools
|
|
128
128
|
before resuming work. Transport recovery never authorizes new tools.
|
|
129
|
+
|
|
130
|
+
## Durable writes (1.0 development)
|
|
131
|
+
|
|
132
|
+
The separate write discovery entry point requires explicit host authorization:
|
|
133
|
+
confirm policy, matching `write` or `destructive` effect, a callable scope validator,
|
|
134
|
+
and nonempty binding/scope IDs and revisions. Server annotations never grant this
|
|
135
|
+
permission. Read discovery keeps its 1.0 contract and schema-1 snapshot. Write
|
|
136
|
+
snapshots use schema 2 and include the host authorization identity; concurrency
|
|
137
|
+
is disabled for these tools.
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
import { discoverMcpWriteTools } from "purra-mcp";
|
|
141
|
+
|
|
142
|
+
const writes = await discoverMcpWriteTools(client, "documents", {
|
|
143
|
+
"remote.update": {
|
|
144
|
+
localName: "update_document",
|
|
145
|
+
policy: { mode: "confirm", title: "Update document", riskLevel: "write" },
|
|
146
|
+
scope: validateCurrentDocumentScope,
|
|
147
|
+
bindingId: "document-service", bindingRevision: "host-config-1",
|
|
148
|
+
scopeId: "sandbox-documents", scopeRevision: "scope-1", effect: "write",
|
|
149
|
+
},
|
|
150
|
+
}, { monitor });
|
|
151
|
+
const identity = writes.registrations[0]!.approvalBinding!;
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
In `toolCheckpointHandler`, spread the current `identity` into `ApprovalIntent.create`
|
|
155
|
+
alongside the actual checkpoint call and current Run preset fingerprint, then call
|
|
156
|
+
`approvals.prepare`. Configure Agent with `approval: approvals.gateway()`,
|
|
157
|
+
`idempotency: storage.idempotency`, and `toolCheckpointNames: ["update_document"]`.
|
|
158
|
+
|
|
159
|
+
The registration's effective binding revision is the complete write catalog digest,
|
|
160
|
+
which includes the original host binding revision, server identity, selected schemas,
|
|
161
|
+
and scope identity. Do not substitute the original host revision or reuse old binding
|
|
162
|
+
metadata after rediscovery. Core requires a durable approval gateway and its exact
|
|
163
|
+
receipt store, and the SQLite gateway checks the current registration identity against
|
|
164
|
+
the approved intent before dispatch. A live in-memory approval gateway is insufficient.
|
|
165
|
+
The handler function is an adapter implementation; applications execute it through
|
|
166
|
+
Core's tool catalog, never as a substitute for approval/claim checks.
|
|
167
|
+
|
|
168
|
+
A validated success response records `committed`. Local argument/scope/catalog
|
|
169
|
+
rejection before sending records `not_started`. Once a request is submitted,
|
|
170
|
+
timeout, cancellation, disconnect, catalog changes, RPC errors, `isError` responses,
|
|
171
|
+
and invalid/unsupported output record `unknown`. An error response cannot prove
|
|
172
|
+
that no remote write occurred. Unknown effects retain their durable claim; the
|
|
173
|
+
adapter never retries or automatically reconciles them. Host reconciliation requires
|
|
174
|
+
independent evidence. A successful remote response is protocol completion evidence,
|
|
175
|
+
not a read-back audit of the business data.
|
|
176
|
+
|
|
177
|
+
The protocol tests include SQLite approval restart, current-binding mismatch,
|
|
178
|
+
scope revocation, committed receipts and retained unknown claims against a synthetic
|
|
179
|
+
SDK server. Build the sibling Core and SQLite packages before TypeScript tests; include
|
|
180
|
+
Core, MCP and SQLite source directories on `PYTHONPATH` for Python tests. These are
|
|
181
|
+
deterministic local tests, separate from real Provider/MCP service and downstream
|
|
182
|
+
acceptance. See [durable approval](../../../conformance/durable-approval.md).
|
|
183
|
+
|
|
184
|
+
## Independent write-server acceptance
|
|
185
|
+
|
|
186
|
+
The repository's installed-consumer check now runs five scenarios against the
|
|
187
|
+
separate Python SDK process in `integrations/mcp/fixtures/write_server.py`:
|
|
188
|
+
success, lost response, exit before write, exit after write, and an error response
|
|
189
|
+
after write. The server accepts only a fixed synthetic value, writes inside a
|
|
190
|
+
fresh temporary directory, and fsyncs an independent event ledger. The host waits
|
|
191
|
+
for durable approval, reopens SQLite before approving, then checks both the local
|
|
192
|
+
receipt/unknown claim and the remote file. Reopening after completion/failure must
|
|
193
|
+
not dispatch again. Every scenario verifies that the server process has exited.
|
|
194
|
+
|
|
195
|
+
Copy `scripts/check-installed-write.mjs` into an independent npm consumer
|
|
196
|
+
containing matching Core, SQLite and MCP tarballs. Use a Python environment
|
|
197
|
+
containing the pinned `mcp` SDK for the fixture server:
|
|
198
|
+
|
|
199
|
+
```sh
|
|
200
|
+
node check-installed-write.mjs /absolute/path/to/write_server.py /absolute/path/to/python
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
These are controlled independent-service fault checks, using a scripted model.
|
|
204
|
+
They do not prove real Provider capability or third-party business-service
|
|
205
|
+
acceptance. The remote ledger is verification evidence, never an approval or
|
|
206
|
+
automatic reconciliation command. The CI workflow includes both consumers;
|
|
207
|
+
local success does not claim that remote CI has executed.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
2
|
import { type ServerNotification } from "@modelcontextprotocol/sdk/types.js";
|
|
3
|
-
import { AgentError, type JsonValue, type ToolDefinition, type ToolPolicy } from "purra";
|
|
3
|
+
import { AgentError, type ToolApprovalBinding, type JsonValue, type ToolDefinition, type ToolPolicy } from "purra";
|
|
4
4
|
export declare class McpAdapterError extends AgentError {
|
|
5
5
|
constructor(code: string);
|
|
6
6
|
}
|
|
@@ -30,6 +30,27 @@ export interface McpToolBinding {
|
|
|
30
30
|
readonly scope: NonNullable<ToolDefinition["scope"]> | null;
|
|
31
31
|
readonly concurrencySafe?: boolean;
|
|
32
32
|
}
|
|
33
|
+
export interface McpWriteToolBinding extends ToolApprovalBinding {
|
|
34
|
+
readonly localName: string;
|
|
35
|
+
readonly policy: Readonly<{
|
|
36
|
+
mode: "confirm";
|
|
37
|
+
title: string;
|
|
38
|
+
riskLevel: "write" | "destructive";
|
|
39
|
+
}>;
|
|
40
|
+
readonly scope: NonNullable<ToolDefinition["scope"]>;
|
|
41
|
+
readonly concurrencySafe?: false;
|
|
42
|
+
}
|
|
43
|
+
export interface McpWriteToolEntry extends Omit<McpToolEntry, "policy"> {
|
|
44
|
+
readonly policy: McpWriteToolBinding["policy"];
|
|
45
|
+
readonly authorization: ToolApprovalBinding;
|
|
46
|
+
}
|
|
47
|
+
export interface McpWriteToolSnapshot extends Omit<McpToolSnapshot, "schemaVersion" | "entries"> {
|
|
48
|
+
readonly schemaVersion: 2;
|
|
49
|
+
readonly entries: readonly McpWriteToolEntry[];
|
|
50
|
+
}
|
|
51
|
+
export interface McpWriteToolCatalog extends Omit<McpToolCatalog, "snapshot"> {
|
|
52
|
+
readonly snapshot: McpWriteToolSnapshot;
|
|
53
|
+
}
|
|
33
54
|
export interface McpToolEntry {
|
|
34
55
|
readonly remoteName: string;
|
|
35
56
|
readonly localName: string;
|
|
@@ -61,3 +82,4 @@ export interface McpDiscoveryOptions {
|
|
|
61
82
|
readonly signal?: AbortSignal;
|
|
62
83
|
}
|
|
63
84
|
export declare function discoverMcpTools(client: Client, serverId: string, bindings: Readonly<Record<string, McpToolBinding>>, options: McpDiscoveryOptions): Promise<McpToolCatalog>;
|
|
85
|
+
export declare function discoverMcpWriteTools(client: Client, serverId: string, bindings: Readonly<Record<string, McpWriteToolBinding>>, options: McpDiscoveryOptions): Promise<McpWriteToolCatalog>;
|
package/dist/index.js
CHANGED
|
@@ -116,6 +116,12 @@ function resultValue(raw, output, envelope, limits) {
|
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
118
|
export async function discoverMcpTools(client, serverId, bindings, options) {
|
|
119
|
+
return await discover(client, serverId, bindings, options, false);
|
|
120
|
+
}
|
|
121
|
+
export async function discoverMcpWriteTools(client, serverId, bindings, options) {
|
|
122
|
+
return await discover(client, serverId, bindings, options, true);
|
|
123
|
+
}
|
|
124
|
+
async function discover(client, serverId, bindings, options, writing) {
|
|
119
125
|
const monitor = options.monitor;
|
|
120
126
|
const limits = limitsOf(options.limits);
|
|
121
127
|
if (!(client instanceof Client) || !(monitor instanceof McpCatalogMonitor)
|
|
@@ -126,16 +132,28 @@ export async function discoverMcpTools(client, serverId, bindings, options) {
|
|
|
126
132
|
const localNames = new Set();
|
|
127
133
|
for (const [remote, binding] of Object.entries(bindings)) {
|
|
128
134
|
if (!/^[A-Za-z0-9_.-]{1,128}$/.test(remote) || !binding || typeof binding.localName !== "string"
|
|
129
|
-
|| !/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(binding.localName) || binding.policy?.mode !== "read"
|
|
130
|
-
|| binding.policy.riskLevel !== undefined && binding.policy.riskLevel !== "read"
|
|
135
|
+
|| !/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(binding.localName) || binding.policy?.mode !== (writing ? "confirm" : "read")
|
|
136
|
+
|| !writing && binding.policy.riskLevel !== undefined && binding.policy.riskLevel !== "read"
|
|
131
137
|
|| typeof binding.policy.title !== "string" || !binding.policy.title.trim()
|
|
132
138
|
|| binding.scope !== null && typeof binding.scope !== "function"
|
|
133
139
|
|| binding.concurrencySafe !== undefined && typeof binding.concurrencySafe !== "boolean")
|
|
134
140
|
fail("mcp_binding_invalid");
|
|
141
|
+
let authorization;
|
|
142
|
+
if (writing) {
|
|
143
|
+
const write = binding;
|
|
144
|
+
if (!["write", "destructive"].includes(write.effect) || write.policy.riskLevel !== write.effect
|
|
145
|
+
|| typeof write.scope !== "function" || write.concurrencySafe !== undefined && write.concurrencySafe !== false
|
|
146
|
+
|| ![write.bindingId, write.bindingRevision, write.scopeId, write.scopeRevision]
|
|
147
|
+
.every(text => typeof text === "string" && text.length > 0 && [...text].length <= 1024 && text.trim() === text))
|
|
148
|
+
fail("mcp_binding_invalid");
|
|
149
|
+
authorization = Object.freeze({ bindingId: write.bindingId, bindingRevision: write.bindingRevision,
|
|
150
|
+
scopeId: write.scopeId, scopeRevision: write.scopeRevision, effect: write.effect });
|
|
151
|
+
}
|
|
135
152
|
if (localNames.has(binding.localName))
|
|
136
153
|
fail("mcp_name_conflict");
|
|
137
154
|
localNames.add(binding.localName);
|
|
138
|
-
bound.set(remote, Object.freeze({ ...binding,
|
|
155
|
+
bound.set(remote, Object.freeze({ ...binding, ...(authorization === undefined ? {} : { authorization }),
|
|
156
|
+
policy: Object.freeze({ mode: writing ? "confirm" : "read", title: binding.policy.title.trim(), riskLevel: authorization?.effect ?? "read" }) }));
|
|
139
157
|
}
|
|
140
158
|
if (bound.size === 0 || bound.size > limits.maxTools)
|
|
141
159
|
fail("mcp_binding_invalid");
|
|
@@ -174,7 +192,8 @@ export async function discoverMcpTools(client, serverId, bindings, options) {
|
|
|
174
192
|
const input = inputContract.compiled, output = outputContract?.compiled;
|
|
175
193
|
const entry = Object.freeze({ remoteName: tool.name, localName: binding.localName, description,
|
|
176
194
|
inputSchema: inputContract.snapshot, outputSchema: outputContract?.snapshot ?? null,
|
|
177
|
-
policy:
|
|
195
|
+
policy: binding.policy, concurrencySafe: binding.concurrencySafe ?? false,
|
|
196
|
+
...(binding.authorization === undefined ? {} : { authorization: binding.authorization }) });
|
|
178
197
|
found.set(tool.name, { entry, binding, input, output });
|
|
179
198
|
}
|
|
180
199
|
cursor = result.nextCursor;
|
|
@@ -191,7 +210,7 @@ export async function discoverMcpTools(client, serverId, bindings, options) {
|
|
|
191
210
|
if (found.size !== bound.size)
|
|
192
211
|
fail("mcp_binding_missing");
|
|
193
212
|
const ordered = [...found.entries()].sort((a, b) => a[1].entry.localName < b[1].entry.localName ? -1 : 1);
|
|
194
|
-
const identity = Object.freeze({ schemaVersion: 1, serverId, protocolVersion: monitor.protocolVersion,
|
|
213
|
+
const identity = Object.freeze({ schemaVersion: writing ? 2 : 1, serverId, protocolVersion: monitor.protocolVersion,
|
|
195
214
|
entries: Object.freeze(ordered.map(([, row]) => row.entry)) });
|
|
196
215
|
let revisionDigest;
|
|
197
216
|
try {
|
|
@@ -209,7 +228,9 @@ export async function discoverMcpTools(client, serverId, bindings, options) {
|
|
|
209
228
|
};
|
|
210
229
|
return Object.freeze({ name: row.entry.localName, description: row.entry.description || row.binding.policy.title,
|
|
211
230
|
inputSchema: row.input.schema, argumentContract: row.input, policy: row.binding.policy, concurrencySafe: row.entry.concurrencySafe,
|
|
231
|
+
...(row.binding.authorization === undefined ? {} : { approvalBinding: Object.freeze({ ...row.binding.authorization, bindingRevision: revisionDigest }) }),
|
|
212
232
|
scope, async run(input, context) {
|
|
233
|
+
let dispatched = false;
|
|
213
234
|
try {
|
|
214
235
|
monitor.check(revision);
|
|
215
236
|
let value;
|
|
@@ -224,13 +245,16 @@ export async function discoverMcpTools(client, serverId, bindings, options) {
|
|
|
224
245
|
fail("mcp_scope_denied");
|
|
225
246
|
monitor.check(revision);
|
|
226
247
|
stopped(context.signal);
|
|
248
|
+
dispatched = true;
|
|
227
249
|
const result = await request(() => client.request({ method: "tools/call", params: { name: remote, arguments: value } }, CallToolResultSchema, { timeout: limits.timeoutMs, maxTotalTimeout: limits.timeoutMs, ...(context.signal === undefined ? {} : { signal: context.signal }) }), context.signal);
|
|
228
250
|
monitor.check(revision);
|
|
229
|
-
return { content: resultValue(result, row.output, envelope, limits), effectState: "not_started" };
|
|
251
|
+
return { content: resultValue(result, row.output, envelope, limits), effectState: writing ? "committed" : "not_started" };
|
|
230
252
|
}
|
|
231
253
|
catch (error) {
|
|
232
254
|
if (error instanceof McpAdapterError)
|
|
233
|
-
return { content: { text: [], structured: null }, effectState: "not_started", errorCode: error.code };
|
|
255
|
+
return { content: { text: [], structured: null }, effectState: writing && dispatched ? "unknown" : "not_started", errorCode: error.code };
|
|
256
|
+
if (writing && dispatched && error instanceof AgentCanceledError)
|
|
257
|
+
return { content: { text: [], structured: null }, effectState: "unknown", errorCode: "mcp_canceled" };
|
|
234
258
|
throw error;
|
|
235
259
|
}
|
|
236
260
|
},
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "purra-mcp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
|
-
"description": "Host-owned
|
|
6
|
+
"description": "Host-owned MCP tools with durable write approvals for PurrA",
|
|
7
7
|
"files": [
|
|
8
8
|
"dist",
|
|
9
9
|
"README.md",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"@modelcontextprotocol/sdk": "1.30.0"
|
|
20
20
|
},
|
|
21
21
|
"peerDependencies": {
|
|
22
|
-
"purra": "1.0.
|
|
22
|
+
"purra": "1.0.1"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
25
|
"purra": "file:../../../typescript",
|