purra-mcp 1.0.0 → 1.0.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # purra-mcp (TypeScript)
2
2
 
3
- Development package for explicitly authorized, read-only MCP tools. Requires
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. Write tools are
92
- outside this adapter's support.
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, policy: Object.freeze({ mode: "read", title: binding.policy.title.trim(), riskLevel: "read" }) }));
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: Object.freeze({ mode: "read", title: binding.policy.title, riskLevel: "read" }), concurrencySafe: binding.concurrencySafe ?? false });
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.0",
3
+ "version": "1.0.4",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
- "description": "Host-owned, read-only MCP tools for PurrA",
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.0"
22
+ "purra": "1.0.4"
23
23
  },
24
24
  "devDependencies": {
25
25
  "purra": "file:../../../typescript",