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/relay.ts
CHANGED
|
@@ -1,238 +1,238 @@
|
|
|
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
|
-
|
|
32
|
-
import type { ChildProcessWithoutNullStreams } from 'node:child_process';
|
|
33
|
-
import { EnforcementPoint, type Decision, type Envelope } from 'intent-envelope';
|
|
34
|
-
|
|
35
|
-
import type { Ledger } from './ledger.ts';
|
|
36
|
-
|
|
37
|
-
/** Implementation-defined JSON-RPC server error codes, per the JSON-RPC 2.0 spec. */
|
|
38
|
-
export const DENIED = -32000;
|
|
39
|
-
export const ESCALATION_REQUIRED = -32001;
|
|
40
|
-
|
|
41
|
-
interface Rpc {
|
|
42
|
-
jsonrpc?: string;
|
|
43
|
-
id?: string | number | null;
|
|
44
|
-
method?: string;
|
|
45
|
-
params?: { name?: string; arguments?: Record<string, unknown>; [key: string]: unknown };
|
|
46
|
-
result?: { tools?: Array<{ name?: string }>; [key: string]: unknown };
|
|
47
|
-
error?: unknown;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export interface RelayOptions {
|
|
51
|
-
envelope: Envelope;
|
|
52
|
-
ledger: Ledger;
|
|
53
|
-
upstream: ChildProcessWithoutNullStreams;
|
|
54
|
-
/** The client's side: what this process reads requests from and writes replies to. */
|
|
55
|
-
input: NodeJS.ReadableStream;
|
|
56
|
-
output: NodeJS.WritableStream;
|
|
57
|
-
log?: (message: string) => void;
|
|
58
|
-
/** Hide tools the envelope does not allow from tools/list. Defence in depth only. */
|
|
59
|
-
filterToolList?: boolean;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Split a byte stream into the transport's newline-delimited messages.
|
|
64
|
-
*
|
|
65
|
-
* Messages must not contain embedded newlines, so a line is a message. A trailing
|
|
66
|
-
* fragment is held until its newline arrives rather than being guessed at.
|
|
67
|
-
*/
|
|
68
|
-
function lines(stream: NodeJS.ReadableStream, onLine: (line: string) => void, onEnd: () => void): void {
|
|
69
|
-
let buffer = '';
|
|
70
|
-
stream.setEncoding?.('utf8');
|
|
71
|
-
stream.on('data', (chunk: string | Buffer) => {
|
|
72
|
-
buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
|
73
|
-
let index = buffer.indexOf('\n');
|
|
74
|
-
while (index !== -1) {
|
|
75
|
-
const line = buffer.slice(0, index);
|
|
76
|
-
buffer = buffer.slice(index + 1);
|
|
77
|
-
if (line.trim()) onLine(line);
|
|
78
|
-
index = buffer.indexOf('\n');
|
|
79
|
-
}
|
|
80
|
-
});
|
|
81
|
-
stream.on('end', () => {
|
|
82
|
-
if (buffer.trim()) onLine(buffer);
|
|
83
|
-
onEnd();
|
|
84
|
-
});
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function parse(line: string): Rpc | undefined {
|
|
88
|
-
try {
|
|
89
|
-
const value = JSON.parse(line);
|
|
90
|
-
return value && typeof value === 'object' ? (value as Rpc) : undefined;
|
|
91
|
-
} catch {
|
|
92
|
-
return undefined;
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/** Every tool name the envelope grants, for the optional tools/list filter. */
|
|
97
|
-
function allowedNames(envelope: Envelope): Set<string> {
|
|
98
|
-
return new Set((envelope.tools?.allow ?? []).map((grant) => grant.name));
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
export class Relay {
|
|
102
|
-
private readonly point: EnforcementPoint;
|
|
103
|
-
private readonly options: RelayOptions;
|
|
104
|
-
private readonly allowed: Set<string>;
|
|
105
|
-
/** ids of tools/list requests in flight, so their replies can be filtered. */
|
|
106
|
-
private readonly listRequests = new Set<string>();
|
|
107
|
-
private closed = false;
|
|
108
|
-
|
|
109
|
-
constructor(options: RelayOptions) {
|
|
110
|
-
this.options = options;
|
|
111
|
-
this.allowed = allowedNames(options.envelope);
|
|
112
|
-
this.point = new EnforcementPoint(options.envelope, {
|
|
113
|
-
onDecision: (decision) => options.ledger.record(decision),
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
// An operator who wrote on_no_response: allow asked for calls to proceed when a
|
|
117
|
-
// human does not answer in time. This process does not wait for a human at all, so
|
|
118
|
-
// honouring that would let a call through that nobody ever saw. It refuses instead,
|
|
119
|
-
// and says so, rather than being quietly stricter than the envelope asked.
|
|
120
|
-
if (options.envelope.on_no_response === 'allow') {
|
|
121
|
-
this.log(
|
|
122
|
-
'warning: this envelope sets on_no_response: allow, but this proxy does not ' +
|
|
123
|
-
'wait for a human, so escalated calls are refused rather than allowed.',
|
|
124
|
-
);
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
private log(message: string): void {
|
|
129
|
-
(this.options.log ?? ((m: string) => process.stderr.write(`[intent-envelope] ${m}\n`)))(message);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
private send(message: unknown): void {
|
|
133
|
-
// One message per line, and nothing else on this stream, ever.
|
|
134
|
-
this.options.output.write(`${JSON.stringify(message)}\n`);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
private toUpstream(line: string): void {
|
|
138
|
-
if (!this.closed) this.options.upstream.stdin.write(`${line}\n`);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
private refuse(id: string | number | null, decision: Decision): void {
|
|
142
|
-
const escalating = decision.result === 'escalate';
|
|
143
|
-
this.send({
|
|
144
|
-
jsonrpc: '2.0',
|
|
145
|
-
id,
|
|
146
|
-
error: {
|
|
147
|
-
code: escalating ? ESCALATION_REQUIRED : DENIED,
|
|
148
|
-
message: escalating
|
|
149
|
-
? `intent envelope: this call needs ${decision.escalateTo}: ${decision.detail}`
|
|
150
|
-
: `intent envelope: refused by ${decision.rule}: ${decision.detail}`,
|
|
151
|
-
// The decision record travels with the refusal so a client can log the same
|
|
152
|
-
// thing the ledger holds, rather than re-deriving it from prose.
|
|
153
|
-
data: { intent_envelope: decision.record() },
|
|
154
|
-
},
|
|
155
|
-
});
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/** Returns true when the line was handled here and must not reach the upstream. */
|
|
159
|
-
private interceptFromClient(line: string, message: Rpc): boolean {
|
|
160
|
-
if (message.method === 'tools/list' && message.id !== undefined && message.id !== null) {
|
|
161
|
-
this.listRequests.add(String(message.id));
|
|
162
|
-
return false;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
if (message.method !== 'tools/call') return false;
|
|
166
|
-
|
|
167
|
-
const tool = typeof message.params?.name === 'string' ? message.params.name : '';
|
|
168
|
-
const args = (message.params?.arguments ?? {}) as Record<string, unknown>;
|
|
169
|
-
|
|
170
|
-
// No argument an agent passes can widen the envelope. The envelope was read from
|
|
171
|
-
// disk before this process accepted a single message and is never re-read.
|
|
172
|
-
const decision = this.point.check(tool, args);
|
|
173
|
-
|
|
174
|
-
if (decision.result === 'allow') return false;
|
|
175
|
-
|
|
176
|
-
if (message.id === undefined || message.id === null) {
|
|
177
|
-
// A tools/call without an id is not answerable. It is still recorded, and it is
|
|
178
|
-
// still not forwarded, which is the part that matters.
|
|
179
|
-
this.log(`refused an unanswerable ${tool} call: ${decision.rule}`);
|
|
180
|
-
return true;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
this.refuse(message.id, decision);
|
|
184
|
-
return true;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/** Filter a tools/list reply down to what the envelope grants. */
|
|
188
|
-
private interceptFromUpstream(line: string, message: Rpc): string {
|
|
189
|
-
const id = message.id === undefined || message.id === null ? '' : String(message.id);
|
|
190
|
-
if (!id || !this.listRequests.has(id)) return line;
|
|
191
|
-
this.listRequests.delete(id);
|
|
192
|
-
|
|
193
|
-
if (this.options.filterToolList === false) return line;
|
|
194
|
-
const tools = message.result?.tools;
|
|
195
|
-
if (!Array.isArray(tools)) return line;
|
|
196
|
-
|
|
197
|
-
const kept = tools.filter((tool) => typeof tool?.name === 'string' && this.allowed.has(tool.name));
|
|
198
|
-
if (kept.length !== tools.length) {
|
|
199
|
-
this.log(`tools/list: offering ${kept.length} of ${tools.length} tools the server exposes`);
|
|
200
|
-
}
|
|
201
|
-
return JSON.stringify({ ...message, result: { ...message.result, tools: kept } });
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
start(): void {
|
|
205
|
-
const { upstream, input, output } = this.options;
|
|
206
|
-
|
|
207
|
-
lines(
|
|
208
|
-
input,
|
|
209
|
-
(line) => {
|
|
210
|
-
const message = parse(line);
|
|
211
|
-
// An unparseable line is the client's business, not this process's. Relay it.
|
|
212
|
-
if (!message || !this.interceptFromClient(line, message)) this.toUpstream(line);
|
|
213
|
-
},
|
|
214
|
-
() => {
|
|
215
|
-
// Closing the upstream's stdin is the portable graceful shutdown signal.
|
|
216
|
-
this.closed = true;
|
|
217
|
-
upstream.stdin.end();
|
|
218
|
-
},
|
|
219
|
-
);
|
|
220
|
-
|
|
221
|
-
lines(
|
|
222
|
-
upstream.stdout,
|
|
223
|
-
(line) => {
|
|
224
|
-
const message = parse(line);
|
|
225
|
-
output.write(`${message ? this.interceptFromUpstream(line, message) : line}\n`);
|
|
226
|
-
},
|
|
227
|
-
() => {},
|
|
228
|
-
);
|
|
229
|
-
|
|
230
|
-
// The specification tells clients not to read failure into a server's stderr, so
|
|
231
|
-
// the upstream's logging is passed along rather than swallowed or reinterpreted.
|
|
232
|
-
upstream.stderr.on('data', (chunk: Buffer) => process.stderr.write(chunk));
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
summary(): Record<string, number> {
|
|
236
|
-
return this.point.summary();
|
|
237
|
-
}
|
|
238
|
-
}
|
|
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
|
+
|
|
32
|
+
import type { ChildProcessWithoutNullStreams } from 'node:child_process';
|
|
33
|
+
import { EnforcementPoint, type Decision, type Envelope } from 'intent-envelope';
|
|
34
|
+
|
|
35
|
+
import type { Ledger } from './ledger.ts';
|
|
36
|
+
|
|
37
|
+
/** Implementation-defined JSON-RPC server error codes, per the JSON-RPC 2.0 spec. */
|
|
38
|
+
export const DENIED = -32000;
|
|
39
|
+
export const ESCALATION_REQUIRED = -32001;
|
|
40
|
+
|
|
41
|
+
interface Rpc {
|
|
42
|
+
jsonrpc?: string;
|
|
43
|
+
id?: string | number | null;
|
|
44
|
+
method?: string;
|
|
45
|
+
params?: { name?: string; arguments?: Record<string, unknown>; [key: string]: unknown };
|
|
46
|
+
result?: { tools?: Array<{ name?: string }>; [key: string]: unknown };
|
|
47
|
+
error?: unknown;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface RelayOptions {
|
|
51
|
+
envelope: Envelope;
|
|
52
|
+
ledger: Ledger;
|
|
53
|
+
upstream: ChildProcessWithoutNullStreams;
|
|
54
|
+
/** The client's side: what this process reads requests from and writes replies to. */
|
|
55
|
+
input: NodeJS.ReadableStream;
|
|
56
|
+
output: NodeJS.WritableStream;
|
|
57
|
+
log?: (message: string) => void;
|
|
58
|
+
/** Hide tools the envelope does not allow from tools/list. Defence in depth only. */
|
|
59
|
+
filterToolList?: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Split a byte stream into the transport's newline-delimited messages.
|
|
64
|
+
*
|
|
65
|
+
* Messages must not contain embedded newlines, so a line is a message. A trailing
|
|
66
|
+
* fragment is held until its newline arrives rather than being guessed at.
|
|
67
|
+
*/
|
|
68
|
+
function lines(stream: NodeJS.ReadableStream, onLine: (line: string) => void, onEnd: () => void): void {
|
|
69
|
+
let buffer = '';
|
|
70
|
+
stream.setEncoding?.('utf8');
|
|
71
|
+
stream.on('data', (chunk: string | Buffer) => {
|
|
72
|
+
buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
|
73
|
+
let index = buffer.indexOf('\n');
|
|
74
|
+
while (index !== -1) {
|
|
75
|
+
const line = buffer.slice(0, index);
|
|
76
|
+
buffer = buffer.slice(index + 1);
|
|
77
|
+
if (line.trim()) onLine(line);
|
|
78
|
+
index = buffer.indexOf('\n');
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
stream.on('end', () => {
|
|
82
|
+
if (buffer.trim()) onLine(buffer);
|
|
83
|
+
onEnd();
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parse(line: string): Rpc | undefined {
|
|
88
|
+
try {
|
|
89
|
+
const value = JSON.parse(line);
|
|
90
|
+
return value && typeof value === 'object' ? (value as Rpc) : undefined;
|
|
91
|
+
} catch {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Every tool name the envelope grants, for the optional tools/list filter. */
|
|
97
|
+
function allowedNames(envelope: Envelope): Set<string> {
|
|
98
|
+
return new Set((envelope.tools?.allow ?? []).map((grant) => grant.name));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export class Relay {
|
|
102
|
+
private readonly point: EnforcementPoint;
|
|
103
|
+
private readonly options: RelayOptions;
|
|
104
|
+
private readonly allowed: Set<string>;
|
|
105
|
+
/** ids of tools/list requests in flight, so their replies can be filtered. */
|
|
106
|
+
private readonly listRequests = new Set<string>();
|
|
107
|
+
private closed = false;
|
|
108
|
+
|
|
109
|
+
constructor(options: RelayOptions) {
|
|
110
|
+
this.options = options;
|
|
111
|
+
this.allowed = allowedNames(options.envelope);
|
|
112
|
+
this.point = new EnforcementPoint(options.envelope, {
|
|
113
|
+
onDecision: (decision) => options.ledger.record(decision),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// An operator who wrote on_no_response: allow asked for calls to proceed when a
|
|
117
|
+
// human does not answer in time. This process does not wait for a human at all, so
|
|
118
|
+
// honouring that would let a call through that nobody ever saw. It refuses instead,
|
|
119
|
+
// and says so, rather than being quietly stricter than the envelope asked.
|
|
120
|
+
if (options.envelope.on_no_response === 'allow') {
|
|
121
|
+
this.log(
|
|
122
|
+
'warning: this envelope sets on_no_response: allow, but this proxy does not ' +
|
|
123
|
+
'wait for a human, so escalated calls are refused rather than allowed.',
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private log(message: string): void {
|
|
129
|
+
(this.options.log ?? ((m: string) => process.stderr.write(`[intent-envelope] ${m}\n`)))(message);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private send(message: unknown): void {
|
|
133
|
+
// One message per line, and nothing else on this stream, ever.
|
|
134
|
+
this.options.output.write(`${JSON.stringify(message)}\n`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private toUpstream(line: string): void {
|
|
138
|
+
if (!this.closed) this.options.upstream.stdin.write(`${line}\n`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private refuse(id: string | number | null, decision: Decision): void {
|
|
142
|
+
const escalating = decision.result === 'escalate';
|
|
143
|
+
this.send({
|
|
144
|
+
jsonrpc: '2.0',
|
|
145
|
+
id,
|
|
146
|
+
error: {
|
|
147
|
+
code: escalating ? ESCALATION_REQUIRED : DENIED,
|
|
148
|
+
message: escalating
|
|
149
|
+
? `intent envelope: this call needs ${decision.escalateTo}: ${decision.detail}`
|
|
150
|
+
: `intent envelope: refused by ${decision.rule}: ${decision.detail}`,
|
|
151
|
+
// The decision record travels with the refusal so a client can log the same
|
|
152
|
+
// thing the ledger holds, rather than re-deriving it from prose.
|
|
153
|
+
data: { intent_envelope: decision.record() },
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Returns true when the line was handled here and must not reach the upstream. */
|
|
159
|
+
private interceptFromClient(line: string, message: Rpc): boolean {
|
|
160
|
+
if (message.method === 'tools/list' && message.id !== undefined && message.id !== null) {
|
|
161
|
+
this.listRequests.add(String(message.id));
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (message.method !== 'tools/call') return false;
|
|
166
|
+
|
|
167
|
+
const tool = typeof message.params?.name === 'string' ? message.params.name : '';
|
|
168
|
+
const args = (message.params?.arguments ?? {}) as Record<string, unknown>;
|
|
169
|
+
|
|
170
|
+
// No argument an agent passes can widen the envelope. The envelope was read from
|
|
171
|
+
// disk before this process accepted a single message and is never re-read.
|
|
172
|
+
const decision = this.point.check(tool, args);
|
|
173
|
+
|
|
174
|
+
if (decision.result === 'allow') return false;
|
|
175
|
+
|
|
176
|
+
if (message.id === undefined || message.id === null) {
|
|
177
|
+
// A tools/call without an id is not answerable. It is still recorded, and it is
|
|
178
|
+
// still not forwarded, which is the part that matters.
|
|
179
|
+
this.log(`refused an unanswerable ${tool} call: ${decision.rule}`);
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
this.refuse(message.id, decision);
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Filter a tools/list reply down to what the envelope grants. */
|
|
188
|
+
private interceptFromUpstream(line: string, message: Rpc): string {
|
|
189
|
+
const id = message.id === undefined || message.id === null ? '' : String(message.id);
|
|
190
|
+
if (!id || !this.listRequests.has(id)) return line;
|
|
191
|
+
this.listRequests.delete(id);
|
|
192
|
+
|
|
193
|
+
if (this.options.filterToolList === false) return line;
|
|
194
|
+
const tools = message.result?.tools;
|
|
195
|
+
if (!Array.isArray(tools)) return line;
|
|
196
|
+
|
|
197
|
+
const kept = tools.filter((tool) => typeof tool?.name === 'string' && this.allowed.has(tool.name));
|
|
198
|
+
if (kept.length !== tools.length) {
|
|
199
|
+
this.log(`tools/list: offering ${kept.length} of ${tools.length} tools the server exposes`);
|
|
200
|
+
}
|
|
201
|
+
return JSON.stringify({ ...message, result: { ...message.result, tools: kept } });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
start(): void {
|
|
205
|
+
const { upstream, input, output } = this.options;
|
|
206
|
+
|
|
207
|
+
lines(
|
|
208
|
+
input,
|
|
209
|
+
(line) => {
|
|
210
|
+
const message = parse(line);
|
|
211
|
+
// An unparseable line is the client's business, not this process's. Relay it.
|
|
212
|
+
if (!message || !this.interceptFromClient(line, message)) this.toUpstream(line);
|
|
213
|
+
},
|
|
214
|
+
() => {
|
|
215
|
+
// Closing the upstream's stdin is the portable graceful shutdown signal.
|
|
216
|
+
this.closed = true;
|
|
217
|
+
upstream.stdin.end();
|
|
218
|
+
},
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
lines(
|
|
222
|
+
upstream.stdout,
|
|
223
|
+
(line) => {
|
|
224
|
+
const message = parse(line);
|
|
225
|
+
output.write(`${message ? this.interceptFromUpstream(line, message) : line}\n`);
|
|
226
|
+
},
|
|
227
|
+
() => {},
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
// The specification tells clients not to read failure into a server's stderr, so
|
|
231
|
+
// the upstream's logging is passed along rather than swallowed or reinterpreted.
|
|
232
|
+
upstream.stderr.on('data', (chunk: Buffer) => process.stderr.write(chunk));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
summary(): Record<string, number> {
|
|
236
|
+
return this.point.summary();
|
|
237
|
+
}
|
|
238
|
+
}
|