intent-envelope-mcp 0.0.0 → 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 +180 -180
- package/dist/config.js +15 -15
- package/package.json +57 -57
- package/src/bin.ts +62 -62
- package/src/config.ts +89 -89
- package/src/index.ts +12 -12
- package/src/ledger.ts +61 -61
- package/src/relay.ts +238 -238
package/src/config.ts
CHANGED
|
@@ -1,89 +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
|
-
}
|
|
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
CHANGED
|
@@ -1,12 +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';
|
|
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
CHANGED
|
@@ -1,61 +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
|
-
}
|
|
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
|
+
}
|