intent-envelope-mcp 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,180 @@
1
- # Temporary Holding Version
1
+ # intent-envelope-mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Enforce an intent envelope on an MCP server. A tool call outside the envelope is refused
4
+ before it reaches the server.
5
+
6
+ Your MCP client launches this instead of the server it wants. Every tool call crosses
7
+ this process, is decided against the envelope, and is forwarded only if the envelope
8
+ permits it. Nothing about the client changes, and no code in your agent changes.
9
+
10
+ ```
11
+ your agent ──► intent-envelope-mcp ──► the MCP server
12
+ (decides, then forwards or refuses)
13
+ ```
14
+
15
+ Implements [Intent Envelope 0.1](../../spec/v0.1.md). Apache-2.0.
16
+
17
+ ## Why a separate process
18
+
19
+ The component that decides what an agent may do is not the component an attacker is
20
+ talking to. An agent can be argued into anything; it cannot argue with a process that
21
+ does not read its reasoning. The envelope is read from disk before the first message is
22
+ accepted and is never re-read, so there is no tool call, argument, document or
23
+ notification that changes what is permitted.
24
+
25
+ ## Install
26
+
27
+ ```sh
28
+ npm install -g intent-envelope-mcp
29
+ ```
30
+
31
+ Requires Node 22.18 or newer. The only dependency is
32
+ [`intent-envelope`](../typescript) itself.
33
+
34
+ ## Use
35
+
36
+ ```sh
37
+ intent-envelope-mcp --envelope ./ap-agent.json -- npx -y @acme/invoice-mcp
38
+ ```
39
+
40
+ Everything after `--` is the server to govern. In a client config, wrap what you already
41
+ had:
42
+
43
+ ```json
44
+ {
45
+ "mcpServers": {
46
+ "invoices": {
47
+ "command": "intent-envelope-mcp",
48
+ "args": [
49
+ "--envelope", "/etc/intent-envelopes/ap-agent.json",
50
+ "--ledger", "/var/log/ap-agent-decisions.jsonl",
51
+ "--", "npx", "-y", "@acme/invoice-mcp"
52
+ ]
53
+ }
54
+ }
55
+ }
56
+ ```
57
+
58
+ | Option | Meaning |
59
+ | --- | --- |
60
+ | `--envelope <path>` | the envelope to enforce, or `INTENT_ENVELOPE` |
61
+ | `--ledger <path>` | append decisions as JSON lines, or `INTENT_ENVELOPE_LEDGER` |
62
+ | `--no-filter-tools` | advertise every upstream tool, refusing the ones not granted |
63
+
64
+ An envelope that fails validation stops the process with exit code 2 before it accepts a
65
+ message. An enforcement point that starts with an envelope it cannot understand is worse
66
+ than one that does not start, because the client assumes it is running.
67
+
68
+ ## What a refusal looks like
69
+
70
+ A refused call returns a JSON-RPC error rather than a tool result. The tool never ran, so
71
+ reporting it as a result that failed would be a lie the agent then reasons from.
72
+
73
+ ```json
74
+ {
75
+ "jsonrpc": "2.0",
76
+ "id": 4,
77
+ "error": {
78
+ "code": -32000,
79
+ "message": "intent envelope: refused by tools.deny: issue_payment is on tools.deny, which no later grant overrides",
80
+ "data": {
81
+ "intent_envelope": {
82
+ "envelope_id": "invoice-reconciler",
83
+ "revision": 3,
84
+ "agent_identity": "invoice-reconciler",
85
+ "tool": "issue_payment",
86
+ "result": "deny",
87
+ "rule": "tools.deny",
88
+ "decided_at": "2026-11-03T16:09:44.812Z"
89
+ }
90
+ }
91
+ }
92
+ }
93
+ ```
94
+
95
+ Two codes, because collapsing them would turn "a named person must decide this" into
96
+ "no":
97
+
98
+ | Code | Meaning |
99
+ | --- | --- |
100
+ | `-32000` | refused by the envelope |
101
+ | `-32001` | a named human must approve this call |
102
+
103
+ ## The ledger
104
+
105
+ With `--ledger`, every decision is appended as one JSON object per line, flushed as it is
106
+ made. Refusals are recorded, not only successes: a log holding what succeeded cannot
107
+ answer the question an incident asks, which is what the agent tried to do.
108
+
109
+ Each record names the envelope, its revision, the agent identity, the tool, the result,
110
+ the rule that decided and when, which is enough to reconstruct a run months later against
111
+ the exact envelope revision in force at the time.
112
+
113
+ If the ledger cannot be written, enforcement continues and the failure is reported once
114
+ on stderr. Refusing correctly while unable to log is degraded; allowing calls because a
115
+ disk filled up would be a breach.
116
+
117
+ ## See it work
118
+
119
+ ```sh
120
+ node demo/poisoned-invoice.mjs
121
+ ```
122
+
123
+ An agent reads a supplier invoice containing instructions, believes every word, and
124
+ carries them out in order. Nothing detects the injection, no prompt asks the model to be
125
+ careful, and there is no classifier. The exfiltration and the payment are refused anyway.
126
+ The invoice server's own stderr shows it ran only the two calls the envelope permitted.
127
+
128
+ The document also tells the agent not to involve a human. That instruction has nothing to
129
+ enforce it, so the mismatch escalates regardless.
130
+
131
+ ## What this does not do
132
+
133
+ Worth being explicit, because a guardrail people misunderstand is worse than none.
134
+
135
+ - **It does not wait for a human.** An escalation is recorded as an escalation and the
136
+ call is refused with the name of who must approve it. There is no approval channel yet,
137
+ so `on_no_response: allow` is **not** honoured: escalated calls are refused rather than
138
+ allowed, and the process says so on startup if your envelope sets it. Being stricter
139
+ than the envelope asked is the safe direction, but it is a difference and you should
140
+ know about it.
141
+ - **Tool filtering is not the gate.** `tools/list` is filtered to what the envelope
142
+ grants, which reduces what the model is tempted by, but a client can call any name it
143
+ likes. The guarantee comes from intercepting `tools/call`, which happens whether or not
144
+ the tool was ever advertised. `--no-filter-tools` turns the cosmetic half off and
145
+ changes nothing about enforcement.
146
+ - **It governs one server.** Run one of these per server you want governed, which is how
147
+ MCP client configs are shaped anyway. Calls that reach a server by some other route are
148
+ not governed by it, so the envelope is only as good as the chokepoint you put it in.
149
+ - **It does not inspect prompts or model output.** It decides tool calls. An agent talked
150
+ into something that stays inside its envelope is not something this notices.
151
+ - **`max_runtime_seconds` is not enforced**, because this process does not own your run
152
+ loop and cannot tell a slow tool from a long think.
153
+
154
+ ## Why a line relay rather than the MCP SDK
155
+
156
+ This understands exactly two methods and relays every other line byte for byte.
157
+
158
+ The protocol has two eras in circulation, one using `server/discover` with inline `_meta`
159
+ and one using the `initialize` handshake, and it keeps growing: `subscriptions/listen`,
160
+ `notifications/cancelled`, `InputRequiredResult`. A proxy built on a protocol library
161
+ would have to model all of it and would break on the next addition. A relay that forwards
162
+ what it does not recognise works across both eras and survives additions it has never
163
+ heard of. An unparseable line is passed along untouched, because this process is not the
164
+ arbiter of protocol validity; it is the arbiter of one question.
165
+
166
+ It also keeps the dependency count at one.
167
+
168
+ ## Development
169
+
170
+ ```sh
171
+ npm test # end to end over real pipes, against a server that records calls
172
+ npm run typecheck
173
+ npm run smoke # pack, install the tarball, run the installed binary
174
+ npm run build
175
+ ```
176
+
177
+ The suite's central assertion is not that the client saw an error. It is that the refused
178
+ call is **absent from the upstream server's own record**. Breaking the interception so
179
+ calls are recorded and then forwarded anyway fails eight of the sixteen tests, which is
180
+ the behaviour a logger-pretending-to-be-a-guardrail would have.
package/dist/bin.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The command an MCP client launches in place of the server it wants governed.
4
+ *
5
+ * Exit codes: 0 if the upstream exited cleanly, the upstream's own code if it did not,
6
+ * and 2 for a configuration problem here. A client that cannot tell the difference
7
+ * between "the server failed" and "the envelope was unreadable" cannot be debugged.
8
+ */
9
+ export {};
10
+ //# sourceMappingURL=bin.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bin.d.ts","sourceRoot":"","sources":["../src/bin.ts"],"names":[],"mappings":";AACA;;;;;;GAMG"}
package/dist/bin.js ADDED
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The command an MCP client launches in place of the server it wants governed.
4
+ *
5
+ * Exit codes: 0 if the upstream exited cleanly, the upstream's own code if it did not,
6
+ * and 2 for a configuration problem here. A client that cannot tell the difference
7
+ * between "the server failed" and "the envelope was unreadable" cannot be debugged.
8
+ */
9
+ import { spawn } from 'node:child_process';
10
+ import { ConfigError, parse } from "./config.js";
11
+ import { fileLedger, memoryLedger } from "./ledger.js";
12
+ import { Relay } from "./relay.js";
13
+ const log = (message) => process.stderr.write(`[intent-envelope] ${message}\n`);
14
+ function main() {
15
+ let config;
16
+ try {
17
+ config = parse(process.argv.slice(2), process.env);
18
+ }
19
+ catch (error) {
20
+ if (error instanceof ConfigError) {
21
+ process.stderr.write(`${error.message}\n`);
22
+ process.exit(2);
23
+ }
24
+ // A schema failure arrives here. It names the field, which is the useful part.
25
+ process.stderr.write(`[intent-envelope] the envelope is not valid: ${error.message}\n`);
26
+ process.exit(2);
27
+ }
28
+ const { envelope, command, args } = config;
29
+ log(`enforcing ${envelope.id} revision ${envelope.revision ?? 1} on: ${command} ${args.join(' ')}`);
30
+ const upstream = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] });
31
+ upstream.on('error', (error) => {
32
+ log(`cannot start the server: ${error.message}`);
33
+ process.exit(2);
34
+ });
35
+ const ledger = config.ledgerPath ? fileLedger(config.ledgerPath, log) : memoryLedger();
36
+ const relay = new Relay({
37
+ envelope,
38
+ ledger,
39
+ upstream,
40
+ input: process.stdin,
41
+ output: process.stdout,
42
+ log,
43
+ filterToolList: config.filterToolList,
44
+ });
45
+ relay.start();
46
+ upstream.on('exit', (code, signal) => {
47
+ const counts = relay.summary();
48
+ log(`${counts.allow} allowed, ${counts.deny} refused, ${counts.escalate} escalated`);
49
+ if (config.ledgerPath)
50
+ log(`ledger: ${config.ledgerPath}`);
51
+ process.exit(signal ? 1 : (code ?? 0));
52
+ });
53
+ }
54
+ main();
55
+ //# sourceMappingURL=bin.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bin.js","sourceRoot":"","sources":["../src/bin.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Where the envelope and the upstream server come from.
3
+ *
4
+ * Both are supplied by whoever launches this process, never by the agent running
5
+ * behind it and never by anything the upstream server says. The envelope is read from
6
+ * disk once, before the first message is accepted, and is not re-read. There is no
7
+ * message an agent can send that changes what it is allowed to do.
8
+ */
9
+ import { type Envelope } from 'intent-envelope';
10
+ export interface Config {
11
+ envelope: Envelope;
12
+ envelopePath: string;
13
+ command: string;
14
+ args: string[];
15
+ ledgerPath?: string;
16
+ filterToolList: boolean;
17
+ }
18
+ export declare class ConfigError extends Error {
19
+ constructor(message: string);
20
+ }
21
+ export declare const USAGE = "intent-envelope-mcp: enforce an intent envelope on an MCP server\n\n intent-envelope-mcp --envelope <path> [options] -- <server-command> [server-args...]\n\nOptions\n --envelope <path> the envelope to enforce (or INTENT_ENVELOPE)\n --ledger <path> append decisions here as JSON lines (or INTENT_ENVELOPE_LEDGER)\n --no-filter-tools advertise every upstream tool, refusing the ones not granted\n --help\n\nEverything after -- is the server to govern, run as a child process.\n\n intent-envelope-mcp --envelope ./ap-agent.json -- npx -y @acme/invoice-mcp\n\nPoint your MCP client at this command instead of the server's own. Nothing else about\nthe client changes.";
22
+ /**
23
+ * Parse argv in the shape `[options] -- command args...`.
24
+ *
25
+ * The separator is required. Guessing where options end and the server command begins
26
+ * invites a mistake in which an option meant for this process is handed to the server,
27
+ * or worse, the reverse.
28
+ */
29
+ export declare function parse(argv: string[], env?: Record<string, string | undefined>): Config;
30
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH,OAAO,EAAY,KAAK,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAE1D,MAAM,WAAW,MAAM;IACrB,QAAQ,EAAE,QAAQ,CAAC;IACnB,YAAY,EAAE,MAAM,CAAC;IACrB,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,OAAO,CAAC;CACzB;AAED,qBAAa,WAAY,SAAQ,KAAK;gBACxB,OAAO,EAAE,MAAM;CAI5B;AAED,eAAO,MAAM,KAAK,4qBAeE,CAAC;AAErB;;;;;;GAMG;AACH,wBAAgB,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,GAAG,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAM,GAAG,MAAM,CAmC1F"}
package/dist/config.js ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Where the envelope and the upstream server come from.
3
+ *
4
+ * Both are supplied by whoever launches this process, never by the agent running
5
+ * behind it and never by anything the upstream server says. The envelope is read from
6
+ * disk once, before the first message is accepted, and is not re-read. There is no
7
+ * message an agent can send that changes what it is allowed to do.
8
+ */
9
+ import { readFileSync } from 'node:fs';
10
+ import { resolve } from 'node:path';
11
+ import { validate } from 'intent-envelope';
12
+ export class ConfigError extends Error {
13
+ constructor(message) {
14
+ super(message);
15
+ this.name = 'ConfigError';
16
+ }
17
+ }
18
+ export const USAGE = `intent-envelope-mcp: enforce an intent envelope on an MCP server
19
+
20
+ intent-envelope-mcp --envelope <path> [options] -- <server-command> [server-args...]
21
+
22
+ Options
23
+ --envelope <path> the envelope to enforce (or INTENT_ENVELOPE)
24
+ --ledger <path> append decisions here as JSON lines (or INTENT_ENVELOPE_LEDGER)
25
+ --no-filter-tools advertise every upstream tool, refusing the ones not granted
26
+ --help
27
+
28
+ Everything after -- is the server to govern, run as a child process.
29
+
30
+ intent-envelope-mcp --envelope ./ap-agent.json -- npx -y @acme/invoice-mcp
31
+
32
+ Point your MCP client at this command instead of the server's own. Nothing else about
33
+ the client changes.`;
34
+ /**
35
+ * Parse argv in the shape `[options] -- command args...`.
36
+ *
37
+ * The separator is required. Guessing where options end and the server command begins
38
+ * invites a mistake in which an option meant for this process is handed to the server,
39
+ * or worse, the reverse.
40
+ */
41
+ export function parse(argv, env = {}) {
42
+ const separator = argv.indexOf('--');
43
+ const flags = separator === -1 ? argv : argv.slice(0, separator);
44
+ const rest = separator === -1 ? [] : argv.slice(separator + 1);
45
+ let envelopePath = env.INTENT_ENVELOPE;
46
+ let ledgerPath = env.INTENT_ENVELOPE_LEDGER;
47
+ let filterToolList = true;
48
+ for (let i = 0; i < flags.length; i += 1) {
49
+ const flag = flags[i];
50
+ if (flag === '--envelope')
51
+ envelopePath = flags[++i];
52
+ else if (flag === '--ledger')
53
+ ledgerPath = flags[++i];
54
+ else if (flag === '--no-filter-tools')
55
+ filterToolList = false;
56
+ else if (flag === '--help' || flag === '-h')
57
+ throw new ConfigError(USAGE);
58
+ else
59
+ throw new ConfigError(`unknown option ${flag}\n\n${USAGE}`);
60
+ }
61
+ if (!envelopePath)
62
+ throw new ConfigError(`no envelope given\n\n${USAGE}`);
63
+ if (rest.length === 0)
64
+ throw new ConfigError(`no server command given after --\n\n${USAGE}`);
65
+ const path = resolve(envelopePath);
66
+ let envelope;
67
+ try {
68
+ envelope = JSON.parse(readFileSync(path, 'utf8'));
69
+ }
70
+ catch (error) {
71
+ const reason = error instanceof Error ? error.message : String(error);
72
+ throw new ConfigError(`cannot read the envelope at ${path}: ${reason}`);
73
+ }
74
+ // Fail closed. An enforcement point that starts with an envelope it cannot understand
75
+ // is worse than one that does not start, because the client will assume it is running.
76
+ validate(envelope);
77
+ return { envelope, envelopePath: path, command: rest[0], args: rest.slice(1), ledgerPath, filterToolList };
78
+ }
79
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Enforce an intent envelope on an MCP server, in the wire protocol itself.
3
+ *
4
+ * The command in bin.ts is the usual way to use this. These exports are for embedding
5
+ * the relay in a host that already owns its own process and streams.
6
+ */
7
+ export { ConfigError, USAGE, parse, type Config } from './config.ts';
8
+ export { fileLedger, memoryLedger, type Ledger } from './ledger.ts';
9
+ export { DENIED, ESCALATION_REQUIRED, Relay, type RelayOptions } from './relay.ts';
10
+ export declare const VERSION = "0.2.0";
11
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,WAAW,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,MAAM,EAAE,MAAM,aAAa,CAAC;AACrE,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,KAAK,MAAM,EAAE,MAAM,aAAa,CAAC;AACpE,OAAO,EAAE,MAAM,EAAE,mBAAmB,EAAE,KAAK,EAAE,KAAK,YAAY,EAAE,MAAM,YAAY,CAAC;AAEnF,eAAO,MAAM,OAAO,UAAU,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Enforce an intent envelope on an MCP server, in the wire protocol itself.
3
+ *
4
+ * The command in bin.ts is the usual way to use this. These exports are for embedding
5
+ * the relay in a host that already owns its own process and streams.
6
+ */
7
+ export { ConfigError, USAGE, parse } from "./config.js";
8
+ export { fileLedger, memoryLedger } from "./ledger.js";
9
+ export { DENIED, ESCALATION_REQUIRED, Relay } from "./relay.js";
10
+ export const VERSION = '0.2.0';
11
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,WAAW,EAAE,KAAK,EAAE,KAAK,EAAe,MAAM,aAAa,CAAC;AACrE,OAAO,EAAE,UAAU,EAAE,YAAY,EAAe,MAAM,aAAa,CAAC;AACpE,OAAO,EAAE,MAAM,EAAE,mBAAmB,EAAE,KAAK,EAAqB,MAAM,YAAY,CAAC;AAEnF,MAAM,CAAC,MAAM,OAAO,GAAG,OAAO,CAAC"}
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Every decision, appended as it is made.
3
+ *
4
+ * Refusals are recorded, not only successes. A log holding what succeeded cannot answer
5
+ * the question an incident asks, which is what the agent tried to do.
6
+ *
7
+ * One JSON object per line, append only, flushed per decision. A ledger that buffers
8
+ * loses exactly the records an operator wants when a process is killed mid-run, and the
9
+ * run where that happens is the interesting one.
10
+ */
11
+ import type { Decision, DecisionRecord } from 'intent-envelope';
12
+ export interface Ledger {
13
+ record(decision: Decision): void;
14
+ entries(): DecisionRecord[];
15
+ }
16
+ /** Keeps decisions in memory only. Used by the tests and when no path is configured. */
17
+ export declare function memoryLedger(): Ledger;
18
+ /**
19
+ * Appends to a file as well as keeping them in memory.
20
+ *
21
+ * A failed write must not take the enforcement point down with it: refusing calls
22
+ * correctly while unable to log is a degraded state, but silently allowing them because
23
+ * a disk filled up is a breach. So a write failure is reported once, to stderr, and
24
+ * enforcement carries on.
25
+ */
26
+ export declare function fileLedger(path: string, onError?: (message: string) => void): Ledger;
27
+ //# sourceMappingURL=ledger.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ledger.d.ts","sourceRoot":"","sources":["../src/ledger.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAGH,OAAO,KAAK,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEhE,MAAM,WAAW,MAAM;IACrB,MAAM,CAAC,QAAQ,EAAE,QAAQ,GAAG,IAAI,CAAC;IACjC,OAAO,IAAI,cAAc,EAAE,CAAC;CAC7B;AAED,wFAAwF;AACxF,wBAAgB,YAAY,IAAI,MAAM,CAQrC;AAED;;;;;;;GAOG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,GAAG,MAAM,CAsBpF"}
package/dist/ledger.js ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Every decision, appended as it is made.
3
+ *
4
+ * Refusals are recorded, not only successes. A log holding what succeeded cannot answer
5
+ * the question an incident asks, which is what the agent tried to do.
6
+ *
7
+ * One JSON object per line, append only, flushed per decision. A ledger that buffers
8
+ * loses exactly the records an operator wants when a process is killed mid-run, and the
9
+ * run where that happens is the interesting one.
10
+ */
11
+ import { appendFileSync } from 'node:fs';
12
+ /** Keeps decisions in memory only. Used by the tests and when no path is configured. */
13
+ export function memoryLedger() {
14
+ const held = [];
15
+ return {
16
+ record: (decision) => {
17
+ held.push(decision.record());
18
+ },
19
+ entries: () => held.slice(),
20
+ };
21
+ }
22
+ /**
23
+ * Appends to a file as well as keeping them in memory.
24
+ *
25
+ * A failed write must not take the enforcement point down with it: refusing calls
26
+ * correctly while unable to log is a degraded state, but silently allowing them because
27
+ * a disk filled up is a breach. So a write failure is reported once, to stderr, and
28
+ * enforcement carries on.
29
+ */
30
+ export function fileLedger(path, onError) {
31
+ const memory = memoryLedger();
32
+ let warned = false;
33
+ return {
34
+ record: (decision) => {
35
+ memory.record(decision);
36
+ try {
37
+ appendFileSync(path, `${JSON.stringify(decision.record())}\n`, 'utf8');
38
+ }
39
+ catch (error) {
40
+ if (!warned) {
41
+ warned = true;
42
+ const reason = error instanceof Error ? error.message : String(error);
43
+ (onError ?? ((m) => process.stderr.write(`${m}\n`)))(`[intent-envelope] cannot write the ledger to ${path}: ${reason}. ` +
44
+ 'Enforcement continues; decisions are kept in memory only.');
45
+ }
46
+ }
47
+ },
48
+ entries: () => memory.entries(),
49
+ };
50
+ }
51
+ //# sourceMappingURL=ledger.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ledger.js","sourceRoot":"","sources":["../src/ledger.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAQzC,wFAAwF;AACxF,MAAM,UAAU,YAAY;IAC1B,MAAM,IAAI,GAAqB,EAAE,CAAC;IAClC,OAAO;QACL,MAAM,EAAE,CAAC,QAAQ,EAAE,EAAE;YACnB,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,MAAM,EAAE,CAAC,CAAC;QAC/B,CAAC;QACD,OAAO,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,KAAK,EAAE;KAC5B,CAAC;AACJ,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,UAAU,CAAC,IAAY,EAAE,OAAmC;IAC1E,MAAM,MAAM,GAAG,YAAY,EAAE,CAAC;IAC9B,IAAI,MAAM,GAAG,KAAK,CAAC;IAEnB,OAAO;QACL,MAAM,EAAE,CAAC,QAAQ,EAAE,EAAE;YACnB,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;YACxB,IAAI,CAAC;gBACH,cAAc,CAAC,IAAI,EAAE,GAAG,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;YACzE,CAAC;YAAC,OAAO,KAAK,EAAE,CAAC;gBACf,IAAI,CAAC,MAAM,EAAE,CAAC;oBACZ,MAAM,GAAG,IAAI,CAAC;oBACd,MAAM,MAAM,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;oBACtE,CAAC,OAAO,IAAI,CAAC,CAAC,CAAS,EAAE,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,CAC1D,gDAAgD,IAAI,KAAK,MAAM,IAAI;wBACjE,2DAA2D,CAC9D,CAAC;gBACJ,CAAC;YACH,CAAC;QACH,CAAC;QACD,OAAO,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,OAAO,EAAE;KAChC,CAAC;AACJ,CAAC"}
@@ -0,0 +1,67 @@
1
+ /**
2
+ * An enforcement point that sits in the MCP wire protocol itself.
3
+ *
4
+ * The client launches this instead of the server it wants. Every tool call crosses this
5
+ * process, is decided against the envelope, and is forwarded only if the envelope
6
+ * permits it. A refused call is never written to the upstream server's stdin, which is
7
+ * the only property that matters: a decision made after the call has already run is a
8
+ * report, not an enforcement point.
9
+ *
10
+ * Why a line relay rather than the MCP SDK
11
+ * ----------------------------------------
12
+ * This understands exactly two methods, `tools/call` and `tools/list`, and relays
13
+ * every other line byte for byte. That is deliberate. The protocol has two eras in
14
+ * circulation, one using `server/discover` with inline `_meta` and one using the
15
+ * `initialize` handshake, and it keeps growing: `subscriptions/listen`,
16
+ * `notifications/cancelled`, `InputRequiredResult`. A proxy that modelled the whole
17
+ * protocol would need to track all of it and would break on the next addition. A relay
18
+ * that forwards what it does not recognise works across both eras and survives
19
+ * additions it has never heard of.
20
+ *
21
+ * It also means an unparseable line is passed along untouched rather than dropped. This
22
+ * process is not the arbiter of protocol validity; it is the arbiter of one question,
23
+ * which is whether a tool call is inside the envelope.
24
+ *
25
+ * stdout discipline
26
+ * -----------------
27
+ * The transport requires that nothing but valid MCP messages reach stdout. Every log,
28
+ * warning and summary this process emits goes to stderr, which the specification
29
+ * explicitly permits and tells clients not to treat as failure.
30
+ */
31
+ import type { ChildProcessWithoutNullStreams } from 'node:child_process';
32
+ import { type Envelope } from 'intent-envelope';
33
+ import type { Ledger } from './ledger.ts';
34
+ /** Implementation-defined JSON-RPC server error codes, per the JSON-RPC 2.0 spec. */
35
+ export declare const DENIED = -32000;
36
+ export declare const ESCALATION_REQUIRED = -32001;
37
+ export interface RelayOptions {
38
+ envelope: Envelope;
39
+ ledger: Ledger;
40
+ upstream: ChildProcessWithoutNullStreams;
41
+ /** The client's side: what this process reads requests from and writes replies to. */
42
+ input: NodeJS.ReadableStream;
43
+ output: NodeJS.WritableStream;
44
+ log?: (message: string) => void;
45
+ /** Hide tools the envelope does not allow from tools/list. Defence in depth only. */
46
+ filterToolList?: boolean;
47
+ }
48
+ export declare class Relay {
49
+ private readonly point;
50
+ private readonly options;
51
+ private readonly allowed;
52
+ /** ids of tools/list requests in flight, so their replies can be filtered. */
53
+ private readonly listRequests;
54
+ private closed;
55
+ constructor(options: RelayOptions);
56
+ private log;
57
+ private send;
58
+ private toUpstream;
59
+ private refuse;
60
+ /** Returns true when the line was handled here and must not reach the upstream. */
61
+ private interceptFromClient;
62
+ /** Filter a tools/list reply down to what the envelope grants. */
63
+ private interceptFromUpstream;
64
+ start(): void;
65
+ summary(): Record<string, number>;
66
+ }
67
+ //# sourceMappingURL=relay.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"relay.d.ts","sourceRoot":"","sources":["../src/relay.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH,OAAO,KAAK,EAAE,8BAA8B,EAAE,MAAM,oBAAoB,CAAC;AACzE,OAAO,EAAmC,KAAK,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAEjF,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAE1C,qFAAqF;AACrF,eAAO,MAAM,MAAM,SAAS,CAAC;AAC7B,eAAO,MAAM,mBAAmB,SAAS,CAAC;AAW1C,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,QAAQ,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,8BAA8B,CAAC;IACzC,sFAAsF;IACtF,KAAK,EAAE,MAAM,CAAC,cAAc,CAAC;IAC7B,MAAM,EAAE,MAAM,CAAC,cAAc,CAAC;IAC9B,GAAG,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,qFAAqF;IACrF,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAyCD,qBAAa,KAAK;IAChB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAmB;IACzC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAe;IACvC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAc;IACtC,8EAA8E;IAC9E,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAqB;IAClD,OAAO,CAAC,MAAM,CAAS;gBAEX,OAAO,EAAE,YAAY;IAmBjC,OAAO,CAAC,GAAG;IAIX,OAAO,CAAC,IAAI;IAKZ,OAAO,CAAC,UAAU;IAIlB,OAAO,CAAC,MAAM;IAiBd,mFAAmF;IACnF,OAAO,CAAC,mBAAmB;IA4B3B,kEAAkE;IAClE,OAAO,CAAC,qBAAqB;IAgB7B,KAAK,IAAI,IAAI;IA+Bb,OAAO,IAAI,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC;CAGlC"}