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 +179 -2
- package/dist/bin.d.ts +10 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +55 -0
- package/dist/bin.js.map +1 -0
- package/dist/config.d.ts +30 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +79 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/ledger.d.ts +27 -0
- package/dist/ledger.d.ts.map +1 -0
- package/dist/ledger.js +51 -0
- package/dist/ledger.js.map +1 -0
- package/dist/relay.d.ts +67 -0
- package/dist/relay.d.ts.map +1 -0
- package/dist/relay.js +188 -0
- package/dist/relay.js.map +1 -0
- package/package.json +55 -4
- package/src/bin.ts +62 -0
- package/src/config.ts +89 -0
- package/src/index.ts +12 -0
- package/src/ledger.ts +61 -0
- package/src/relay.ts +238 -0
package/dist/relay.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
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 { EnforcementPoint } from 'intent-envelope';
|
|
32
|
+
/** Implementation-defined JSON-RPC server error codes, per the JSON-RPC 2.0 spec. */
|
|
33
|
+
export const DENIED = -32000;
|
|
34
|
+
export const ESCALATION_REQUIRED = -32001;
|
|
35
|
+
/**
|
|
36
|
+
* Split a byte stream into the transport's newline-delimited messages.
|
|
37
|
+
*
|
|
38
|
+
* Messages must not contain embedded newlines, so a line is a message. A trailing
|
|
39
|
+
* fragment is held until its newline arrives rather than being guessed at.
|
|
40
|
+
*/
|
|
41
|
+
function lines(stream, onLine, onEnd) {
|
|
42
|
+
let buffer = '';
|
|
43
|
+
stream.setEncoding?.('utf8');
|
|
44
|
+
stream.on('data', (chunk) => {
|
|
45
|
+
buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
|
46
|
+
let index = buffer.indexOf('\n');
|
|
47
|
+
while (index !== -1) {
|
|
48
|
+
const line = buffer.slice(0, index);
|
|
49
|
+
buffer = buffer.slice(index + 1);
|
|
50
|
+
if (line.trim())
|
|
51
|
+
onLine(line);
|
|
52
|
+
index = buffer.indexOf('\n');
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
stream.on('end', () => {
|
|
56
|
+
if (buffer.trim())
|
|
57
|
+
onLine(buffer);
|
|
58
|
+
onEnd();
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
function parse(line) {
|
|
62
|
+
try {
|
|
63
|
+
const value = JSON.parse(line);
|
|
64
|
+
return value && typeof value === 'object' ? value : undefined;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** Every tool name the envelope grants, for the optional tools/list filter. */
|
|
71
|
+
function allowedNames(envelope) {
|
|
72
|
+
return new Set((envelope.tools?.allow ?? []).map((grant) => grant.name));
|
|
73
|
+
}
|
|
74
|
+
export class Relay {
|
|
75
|
+
point;
|
|
76
|
+
options;
|
|
77
|
+
allowed;
|
|
78
|
+
/** ids of tools/list requests in flight, so their replies can be filtered. */
|
|
79
|
+
listRequests = new Set();
|
|
80
|
+
closed = false;
|
|
81
|
+
constructor(options) {
|
|
82
|
+
this.options = options;
|
|
83
|
+
this.allowed = allowedNames(options.envelope);
|
|
84
|
+
this.point = new EnforcementPoint(options.envelope, {
|
|
85
|
+
onDecision: (decision) => options.ledger.record(decision),
|
|
86
|
+
});
|
|
87
|
+
// An operator who wrote on_no_response: allow asked for calls to proceed when a
|
|
88
|
+
// human does not answer in time. This process does not wait for a human at all, so
|
|
89
|
+
// honouring that would let a call through that nobody ever saw. It refuses instead,
|
|
90
|
+
// and says so, rather than being quietly stricter than the envelope asked.
|
|
91
|
+
if (options.envelope.on_no_response === 'allow') {
|
|
92
|
+
this.log('warning: this envelope sets on_no_response: allow, but this proxy does not ' +
|
|
93
|
+
'wait for a human, so escalated calls are refused rather than allowed.');
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
log(message) {
|
|
97
|
+
(this.options.log ?? ((m) => process.stderr.write(`[intent-envelope] ${m}\n`)))(message);
|
|
98
|
+
}
|
|
99
|
+
send(message) {
|
|
100
|
+
// One message per line, and nothing else on this stream, ever.
|
|
101
|
+
this.options.output.write(`${JSON.stringify(message)}\n`);
|
|
102
|
+
}
|
|
103
|
+
toUpstream(line) {
|
|
104
|
+
if (!this.closed)
|
|
105
|
+
this.options.upstream.stdin.write(`${line}\n`);
|
|
106
|
+
}
|
|
107
|
+
refuse(id, decision) {
|
|
108
|
+
const escalating = decision.result === 'escalate';
|
|
109
|
+
this.send({
|
|
110
|
+
jsonrpc: '2.0',
|
|
111
|
+
id,
|
|
112
|
+
error: {
|
|
113
|
+
code: escalating ? ESCALATION_REQUIRED : DENIED,
|
|
114
|
+
message: escalating
|
|
115
|
+
? `intent envelope: this call needs ${decision.escalateTo}: ${decision.detail}`
|
|
116
|
+
: `intent envelope: refused by ${decision.rule}: ${decision.detail}`,
|
|
117
|
+
// The decision record travels with the refusal so a client can log the same
|
|
118
|
+
// thing the ledger holds, rather than re-deriving it from prose.
|
|
119
|
+
data: { intent_envelope: decision.record() },
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
/** Returns true when the line was handled here and must not reach the upstream. */
|
|
124
|
+
interceptFromClient(line, message) {
|
|
125
|
+
if (message.method === 'tools/list' && message.id !== undefined && message.id !== null) {
|
|
126
|
+
this.listRequests.add(String(message.id));
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
if (message.method !== 'tools/call')
|
|
130
|
+
return false;
|
|
131
|
+
const tool = typeof message.params?.name === 'string' ? message.params.name : '';
|
|
132
|
+
const args = (message.params?.arguments ?? {});
|
|
133
|
+
// No argument an agent passes can widen the envelope. The envelope was read from
|
|
134
|
+
// disk before this process accepted a single message and is never re-read.
|
|
135
|
+
const decision = this.point.check(tool, args);
|
|
136
|
+
if (decision.result === 'allow')
|
|
137
|
+
return false;
|
|
138
|
+
if (message.id === undefined || message.id === null) {
|
|
139
|
+
// A tools/call without an id is not answerable. It is still recorded, and it is
|
|
140
|
+
// still not forwarded, which is the part that matters.
|
|
141
|
+
this.log(`refused an unanswerable ${tool} call: ${decision.rule}`);
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
this.refuse(message.id, decision);
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
/** Filter a tools/list reply down to what the envelope grants. */
|
|
148
|
+
interceptFromUpstream(line, message) {
|
|
149
|
+
const id = message.id === undefined || message.id === null ? '' : String(message.id);
|
|
150
|
+
if (!id || !this.listRequests.has(id))
|
|
151
|
+
return line;
|
|
152
|
+
this.listRequests.delete(id);
|
|
153
|
+
if (this.options.filterToolList === false)
|
|
154
|
+
return line;
|
|
155
|
+
const tools = message.result?.tools;
|
|
156
|
+
if (!Array.isArray(tools))
|
|
157
|
+
return line;
|
|
158
|
+
const kept = tools.filter((tool) => typeof tool?.name === 'string' && this.allowed.has(tool.name));
|
|
159
|
+
if (kept.length !== tools.length) {
|
|
160
|
+
this.log(`tools/list: offering ${kept.length} of ${tools.length} tools the server exposes`);
|
|
161
|
+
}
|
|
162
|
+
return JSON.stringify({ ...message, result: { ...message.result, tools: kept } });
|
|
163
|
+
}
|
|
164
|
+
start() {
|
|
165
|
+
const { upstream, input, output } = this.options;
|
|
166
|
+
lines(input, (line) => {
|
|
167
|
+
const message = parse(line);
|
|
168
|
+
// An unparseable line is the client's business, not this process's. Relay it.
|
|
169
|
+
if (!message || !this.interceptFromClient(line, message))
|
|
170
|
+
this.toUpstream(line);
|
|
171
|
+
}, () => {
|
|
172
|
+
// Closing the upstream's stdin is the portable graceful shutdown signal.
|
|
173
|
+
this.closed = true;
|
|
174
|
+
upstream.stdin.end();
|
|
175
|
+
});
|
|
176
|
+
lines(upstream.stdout, (line) => {
|
|
177
|
+
const message = parse(line);
|
|
178
|
+
output.write(`${message ? this.interceptFromUpstream(line, message) : line}\n`);
|
|
179
|
+
}, () => { });
|
|
180
|
+
// The specification tells clients not to read failure into a server's stderr, so
|
|
181
|
+
// the upstream's logging is passed along rather than swallowed or reinterpreted.
|
|
182
|
+
upstream.stderr.on('data', (chunk) => process.stderr.write(chunk));
|
|
183
|
+
}
|
|
184
|
+
summary() {
|
|
185
|
+
return this.point.summary();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
//# sourceMappingURL=relay.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"relay.js","sourceRoot":"","sources":["../src/relay.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,57 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "intent-envelope-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Enforce an intent envelope on an MCP server: refuse a tool call before it reaches the server.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"intent-envelope-mcp": "./dist/bin.js"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"./package.json": "./package.json"
|
|
16
|
+
},
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"main": "./dist/index.js",
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"src",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"keywords": [
|
|
25
|
+
"mcp",
|
|
26
|
+
"ai",
|
|
27
|
+
"agents",
|
|
28
|
+
"security",
|
|
29
|
+
"intent",
|
|
30
|
+
"guardrails",
|
|
31
|
+
"proxy"
|
|
32
|
+
],
|
|
33
|
+
"homepage": "https://securedai.org/agent-intent",
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/secured-ai/intent-envelope.git",
|
|
37
|
+
"directory": "packages/mcp-proxy"
|
|
38
|
+
},
|
|
39
|
+
"bugs": "https://github.com/secured-ai/intent-envelope/issues",
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=22.18"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"intent-envelope": ">=0.1.0 <1.0.0"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"build": "tsc -p tsconfig.build.json",
|
|
48
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
49
|
+
"test": "node --test test/*.test.ts",
|
|
50
|
+
"smoke": "node tools/consumer-smoke.mjs",
|
|
51
|
+
"prepack": "npm run build"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@types/node": "^22.19.1",
|
|
55
|
+
"typescript": "^5.9.3"
|
|
56
|
+
}
|
|
57
|
+
}
|
package/src/bin.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
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
|
+
|
|
10
|
+
import { spawn } from 'node:child_process';
|
|
11
|
+
|
|
12
|
+
import { ConfigError, parse } from './config.ts';
|
|
13
|
+
import { fileLedger, memoryLedger } from './ledger.ts';
|
|
14
|
+
import { Relay } from './relay.ts';
|
|
15
|
+
|
|
16
|
+
const log = (message: string) => process.stderr.write(`[intent-envelope] ${message}\n`);
|
|
17
|
+
|
|
18
|
+
function main(): void {
|
|
19
|
+
let config;
|
|
20
|
+
try {
|
|
21
|
+
config = parse(process.argv.slice(2), process.env);
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (error instanceof ConfigError) {
|
|
24
|
+
process.stderr.write(`${error.message}\n`);
|
|
25
|
+
process.exit(2);
|
|
26
|
+
}
|
|
27
|
+
// A schema failure arrives here. It names the field, which is the useful part.
|
|
28
|
+
process.stderr.write(`[intent-envelope] the envelope is not valid: ${(error as Error).message}\n`);
|
|
29
|
+
process.exit(2);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const { envelope, command, args } = config;
|
|
33
|
+
log(`enforcing ${envelope.id} revision ${envelope.revision ?? 1} on: ${command} ${args.join(' ')}`);
|
|
34
|
+
|
|
35
|
+
const upstream = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
36
|
+
|
|
37
|
+
upstream.on('error', (error) => {
|
|
38
|
+
log(`cannot start the server: ${error.message}`);
|
|
39
|
+
process.exit(2);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const ledger = config.ledgerPath ? fileLedger(config.ledgerPath, log) : memoryLedger();
|
|
43
|
+
const relay = new Relay({
|
|
44
|
+
envelope,
|
|
45
|
+
ledger,
|
|
46
|
+
upstream,
|
|
47
|
+
input: process.stdin,
|
|
48
|
+
output: process.stdout,
|
|
49
|
+
log,
|
|
50
|
+
filterToolList: config.filterToolList,
|
|
51
|
+
});
|
|
52
|
+
relay.start();
|
|
53
|
+
|
|
54
|
+
upstream.on('exit', (code, signal) => {
|
|
55
|
+
const counts = relay.summary();
|
|
56
|
+
log(`${counts.allow} allowed, ${counts.deny} refused, ${counts.escalate} escalated`);
|
|
57
|
+
if (config.ledgerPath) log(`ledger: ${config.ledgerPath}`);
|
|
58
|
+
process.exit(signal ? 1 : (code ?? 0));
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
main();
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
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
|
+
|
|
10
|
+
import { readFileSync } from 'node:fs';
|
|
11
|
+
import { resolve } from 'node:path';
|
|
12
|
+
import { validate, type Envelope } from 'intent-envelope';
|
|
13
|
+
|
|
14
|
+
export interface Config {
|
|
15
|
+
envelope: Envelope;
|
|
16
|
+
envelopePath: string;
|
|
17
|
+
command: string;
|
|
18
|
+
args: string[];
|
|
19
|
+
ledgerPath?: string;
|
|
20
|
+
filterToolList: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class ConfigError extends Error {
|
|
24
|
+
constructor(message: string) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = 'ConfigError';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const USAGE = `intent-envelope-mcp: enforce an intent envelope on an MCP server
|
|
31
|
+
|
|
32
|
+
intent-envelope-mcp --envelope <path> [options] -- <server-command> [server-args...]
|
|
33
|
+
|
|
34
|
+
Options
|
|
35
|
+
--envelope <path> the envelope to enforce (or INTENT_ENVELOPE)
|
|
36
|
+
--ledger <path> append decisions here as JSON lines (or INTENT_ENVELOPE_LEDGER)
|
|
37
|
+
--no-filter-tools advertise every upstream tool, refusing the ones not granted
|
|
38
|
+
--help
|
|
39
|
+
|
|
40
|
+
Everything after -- is the server to govern, run as a child process.
|
|
41
|
+
|
|
42
|
+
intent-envelope-mcp --envelope ./ap-agent.json -- npx -y @acme/invoice-mcp
|
|
43
|
+
|
|
44
|
+
Point your MCP client at this command instead of the server's own. Nothing else about
|
|
45
|
+
the client changes.`;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Parse argv in the shape `[options] -- command args...`.
|
|
49
|
+
*
|
|
50
|
+
* The separator is required. Guessing where options end and the server command begins
|
|
51
|
+
* invites a mistake in which an option meant for this process is handed to the server,
|
|
52
|
+
* or worse, the reverse.
|
|
53
|
+
*/
|
|
54
|
+
export function parse(argv: string[], env: Record<string, string | undefined> = {}): Config {
|
|
55
|
+
const separator = argv.indexOf('--');
|
|
56
|
+
const flags = separator === -1 ? argv : argv.slice(0, separator);
|
|
57
|
+
const rest = separator === -1 ? [] : argv.slice(separator + 1);
|
|
58
|
+
|
|
59
|
+
let envelopePath = env.INTENT_ENVELOPE;
|
|
60
|
+
let ledgerPath = env.INTENT_ENVELOPE_LEDGER;
|
|
61
|
+
let filterToolList = true;
|
|
62
|
+
|
|
63
|
+
for (let i = 0; i < flags.length; i += 1) {
|
|
64
|
+
const flag = flags[i];
|
|
65
|
+
if (flag === '--envelope') envelopePath = flags[++i];
|
|
66
|
+
else if (flag === '--ledger') ledgerPath = flags[++i];
|
|
67
|
+
else if (flag === '--no-filter-tools') filterToolList = false;
|
|
68
|
+
else if (flag === '--help' || flag === '-h') throw new ConfigError(USAGE);
|
|
69
|
+
else throw new ConfigError(`unknown option ${flag}\n\n${USAGE}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!envelopePath) throw new ConfigError(`no envelope given\n\n${USAGE}`);
|
|
73
|
+
if (rest.length === 0) throw new ConfigError(`no server command given after --\n\n${USAGE}`);
|
|
74
|
+
|
|
75
|
+
const path = resolve(envelopePath);
|
|
76
|
+
let envelope: Envelope;
|
|
77
|
+
try {
|
|
78
|
+
envelope = JSON.parse(readFileSync(path, 'utf8')) as Envelope;
|
|
79
|
+
} catch (error) {
|
|
80
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
81
|
+
throw new ConfigError(`cannot read the envelope at ${path}: ${reason}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Fail closed. An enforcement point that starts with an envelope it cannot understand
|
|
85
|
+
// is worse than one that does not start, because the client will assume it is running.
|
|
86
|
+
validate(envelope);
|
|
87
|
+
|
|
88
|
+
return { envelope, envelopePath: path, command: rest[0], args: rest.slice(1), ledgerPath, filterToolList };
|
|
89
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
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
|
+
|
|
8
|
+
export { ConfigError, USAGE, parse, type Config } from './config.ts';
|
|
9
|
+
export { fileLedger, memoryLedger, type Ledger } from './ledger.ts';
|
|
10
|
+
export { DENIED, ESCALATION_REQUIRED, Relay, type RelayOptions } from './relay.ts';
|
|
11
|
+
|
|
12
|
+
export const VERSION = '0.2.0';
|
package/src/ledger.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
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
|
+
|
|
12
|
+
import { appendFileSync } from 'node:fs';
|
|
13
|
+
import type { Decision, DecisionRecord } from 'intent-envelope';
|
|
14
|
+
|
|
15
|
+
export interface Ledger {
|
|
16
|
+
record(decision: Decision): void;
|
|
17
|
+
entries(): DecisionRecord[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Keeps decisions in memory only. Used by the tests and when no path is configured. */
|
|
21
|
+
export function memoryLedger(): Ledger {
|
|
22
|
+
const held: DecisionRecord[] = [];
|
|
23
|
+
return {
|
|
24
|
+
record: (decision) => {
|
|
25
|
+
held.push(decision.record());
|
|
26
|
+
},
|
|
27
|
+
entries: () => held.slice(),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Appends to a file as well as keeping them in memory.
|
|
33
|
+
*
|
|
34
|
+
* A failed write must not take the enforcement point down with it: refusing calls
|
|
35
|
+
* correctly while unable to log is a degraded state, but silently allowing them because
|
|
36
|
+
* a disk filled up is a breach. So a write failure is reported once, to stderr, and
|
|
37
|
+
* enforcement carries on.
|
|
38
|
+
*/
|
|
39
|
+
export function fileLedger(path: string, onError?: (message: string) => void): Ledger {
|
|
40
|
+
const memory = memoryLedger();
|
|
41
|
+
let warned = false;
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
record: (decision) => {
|
|
45
|
+
memory.record(decision);
|
|
46
|
+
try {
|
|
47
|
+
appendFileSync(path, `${JSON.stringify(decision.record())}\n`, 'utf8');
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (!warned) {
|
|
50
|
+
warned = true;
|
|
51
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
52
|
+
(onError ?? ((m: string) => process.stderr.write(`${m}\n`)))(
|
|
53
|
+
`[intent-envelope] cannot write the ledger to ${path}: ${reason}. ` +
|
|
54
|
+
'Enforcement continues; decisions are kept in memory only.',
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
entries: () => memory.entries(),
|
|
60
|
+
};
|
|
61
|
+
}
|