synartesis 0.1.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/LICENSE +21 -0
- package/README.md +501 -0
- package/dist/chunk-K3QIPVBY.js +85 -0
- package/dist/chunk-K3QIPVBY.js.map +1 -0
- package/dist/chunk-X4VQNEP5.js +1383 -0
- package/dist/chunk-X4VQNEP5.js.map +1 -0
- package/dist/cli.js +1708 -0
- package/dist/cli.js.map +1 -0
- package/dist/demo-agent.js +67 -0
- package/dist/demo-agent.js.map +1 -0
- package/dist/proxy.js +913 -0
- package/dist/proxy.js.map +1 -0
- package/dist/toy-crm.js +293 -0
- package/dist/toy-crm.js.map +1 -0
- package/manifests/filesystem.yaml +97 -0
- package/manifests/git.yaml +77 -0
- package/manifests/github.yaml +175 -0
- package/manifests/memory.yaml +96 -0
- package/manifests/toy-crm.yaml +66 -0
- package/package.json +67 -0
package/dist/proxy.js
ADDED
|
@@ -0,0 +1,913 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
cliCommandFrom,
|
|
4
|
+
connectStdioUpstream,
|
|
5
|
+
createPolicyResolver,
|
|
6
|
+
createRouter,
|
|
7
|
+
findJournal,
|
|
8
|
+
findManifest,
|
|
9
|
+
isDisconnected,
|
|
10
|
+
loadManifest,
|
|
11
|
+
mark,
|
|
12
|
+
mayHaveArrived,
|
|
13
|
+
observeState,
|
|
14
|
+
openJournal,
|
|
15
|
+
planInverse,
|
|
16
|
+
planRead,
|
|
17
|
+
qualify,
|
|
18
|
+
refusal,
|
|
19
|
+
runRead,
|
|
20
|
+
toPayload,
|
|
21
|
+
verifyAgainstServers
|
|
22
|
+
} from "./chunk-X4VQNEP5.js";
|
|
23
|
+
import {
|
|
24
|
+
SnapshotError,
|
|
25
|
+
UpstreamError,
|
|
26
|
+
describe
|
|
27
|
+
} from "./chunk-K3QIPVBY.js";
|
|
28
|
+
|
|
29
|
+
// src/proxy/stdio.ts
|
|
30
|
+
import { resolve } from "path";
|
|
31
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
32
|
+
|
|
33
|
+
// src/gate/gate.ts
|
|
34
|
+
var DEFAULT_GATE_TIMEOUT_MS = 3e5;
|
|
35
|
+
var DEFAULT_HINT = (actionId) => `synartesis approve ${actionId.slice(0, 8)}`;
|
|
36
|
+
function createRetryGate(journal, approveHint = DEFAULT_HINT) {
|
|
37
|
+
return {
|
|
38
|
+
decide(request) {
|
|
39
|
+
journal.markGated(request.actionId, request.why);
|
|
40
|
+
return Promise.resolve({
|
|
41
|
+
approved: false,
|
|
42
|
+
awaiting: true,
|
|
43
|
+
reason: "it is waiting for a person to approve it. Ask them to run: " + approveHint(request.actionId) + " --- then make this exact call again."
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// src/logging.ts
|
|
50
|
+
import pino from "pino";
|
|
51
|
+
var LOG_LEVELS = ["trace", "debug", "info", "warn", "error", "silent"];
|
|
52
|
+
function isLogLevel(value) {
|
|
53
|
+
return LOG_LEVELS.some((level) => level === value);
|
|
54
|
+
}
|
|
55
|
+
function createLogger(level) {
|
|
56
|
+
return pino(
|
|
57
|
+
{ level, base: { name: "synartesis" } },
|
|
58
|
+
pino.destination({ dest: 2, sync: true })
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// src/proxy/proxy.ts
|
|
63
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
64
|
+
import {
|
|
65
|
+
CallToolRequestSchema,
|
|
66
|
+
CompleteRequestSchema,
|
|
67
|
+
ErrorCode,
|
|
68
|
+
GetPromptRequestSchema,
|
|
69
|
+
ListPromptsRequestSchema,
|
|
70
|
+
ListResourceTemplatesRequestSchema,
|
|
71
|
+
ListResourcesRequestSchema,
|
|
72
|
+
ListToolsRequestSchema,
|
|
73
|
+
McpError,
|
|
74
|
+
ReadResourceRequestSchema,
|
|
75
|
+
SetLevelRequestSchema,
|
|
76
|
+
SubscribeRequestSchema,
|
|
77
|
+
UnsubscribeRequestSchema
|
|
78
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
79
|
+
import { z } from "zod";
|
|
80
|
+
|
|
81
|
+
// src/gate/heuristic.ts
|
|
82
|
+
var READ_ONLY = /^(select|with|show|explain|describe|desc|values|table)\b/;
|
|
83
|
+
function isReadOnlyStatement(text) {
|
|
84
|
+
const stripped = text.replace(/--[^\n]*/g, " ").replace(/\/\*[\s\S]*?\*\//g, " ").trim();
|
|
85
|
+
if (!READ_ONLY.test(stripped.toLowerCase())) {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
return stripped.replace(/;\s*$/, "").indexOf(";") === -1;
|
|
89
|
+
}
|
|
90
|
+
function shouldGateOnWrite(args) {
|
|
91
|
+
if (typeof args !== "object" || args === null) {
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
const strings = Object.values(args).filter(
|
|
95
|
+
(value) => typeof value === "string"
|
|
96
|
+
);
|
|
97
|
+
if (strings.length === 0) {
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
return !strings.every(isReadOnlyStatement);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// src/proxy/proxy.ts
|
|
104
|
+
var APPROVAL_WINDOW_MS = 60 * 60 * 1e3;
|
|
105
|
+
var PassthroughResult = z.looseObject({});
|
|
106
|
+
var ToolList = z.looseObject({
|
|
107
|
+
tools: z.array(z.looseObject({ name: z.string() })),
|
|
108
|
+
nextCursor: z.string().optional()
|
|
109
|
+
});
|
|
110
|
+
var PromptList = z.looseObject({
|
|
111
|
+
prompts: z.array(z.looseObject({ name: z.string() })),
|
|
112
|
+
nextCursor: z.string().optional()
|
|
113
|
+
});
|
|
114
|
+
var ResourceList = z.looseObject({
|
|
115
|
+
resources: z.array(z.looseObject({ uri: z.string() })),
|
|
116
|
+
nextCursor: z.string().optional()
|
|
117
|
+
});
|
|
118
|
+
var TemplateList = z.looseObject({
|
|
119
|
+
resourceTemplates: z.array(z.looseObject({ uriTemplate: z.string() })),
|
|
120
|
+
nextCursor: z.string().optional()
|
|
121
|
+
});
|
|
122
|
+
function unwrap(error) {
|
|
123
|
+
const prefix = `MCP error ${String(error.code)}: `;
|
|
124
|
+
return error.message.startsWith(prefix) ? error.message.slice(prefix.length) : error.message;
|
|
125
|
+
}
|
|
126
|
+
function rethrow(server, operation, error) {
|
|
127
|
+
if (error instanceof McpError) {
|
|
128
|
+
throw new McpError(error.code, unwrap(error), error.data);
|
|
129
|
+
}
|
|
130
|
+
throw new UpstreamError(server, operation, error);
|
|
131
|
+
}
|
|
132
|
+
function isRecord(value) {
|
|
133
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
134
|
+
}
|
|
135
|
+
function mergeCapabilities(all) {
|
|
136
|
+
const merged = {};
|
|
137
|
+
for (const capabilities of all) {
|
|
138
|
+
for (const [key, value] of Object.entries(capabilities)) {
|
|
139
|
+
const existing = merged[key];
|
|
140
|
+
merged[key] = isRecord(existing) && isRecord(value) ? { ...existing, ...value } : value;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return merged;
|
|
144
|
+
}
|
|
145
|
+
function identityFor(router) {
|
|
146
|
+
const only = router.upstreams[0];
|
|
147
|
+
if (!router.prefixed && only !== void 0) {
|
|
148
|
+
const upstream = only.client.getServerVersion();
|
|
149
|
+
if (upstream !== void 0) {
|
|
150
|
+
return upstream;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return { name: "synartesis", version: "0.0.0" };
|
|
154
|
+
}
|
|
155
|
+
var SYNARTESIS_INSTRUCTIONS = [
|
|
156
|
+
"These tools are guarded by Synartesis, which records every change so it can be undone later.",
|
|
157
|
+
"",
|
|
158
|
+
"Some actions cannot be undone. Those are held until a person approves them, and the call",
|
|
159
|
+
'will fail with a message beginning "Synartesis is holding this call for approval".',
|
|
160
|
+
"When that happens:",
|
|
161
|
+
" 1. Tell the user plainly that you are asking Synartesis for approval, and what for.",
|
|
162
|
+
" 2. Give them the exact `synartesis approve ...` command from the error.",
|
|
163
|
+
" 3. Once they say they have approved it, make the same call again. It will go through.",
|
|
164
|
+
"Do not try to work around a held call by using a different tool to achieve the same thing."
|
|
165
|
+
].join("\n");
|
|
166
|
+
function instructionsFor(router) {
|
|
167
|
+
const sections = router.upstreams.map((upstream2) => ({
|
|
168
|
+
name: upstream2.name,
|
|
169
|
+
text: upstream2.client.getInstructions()
|
|
170
|
+
})).filter(
|
|
171
|
+
(section) => section.text !== void 0
|
|
172
|
+
);
|
|
173
|
+
const upstream = router.prefixed ? sections.map((section) => `Tools prefixed ${section.name}__:
|
|
174
|
+
${section.text}`).join("\n\n") : sections[0]?.text ?? "";
|
|
175
|
+
return upstream === "" ? SYNARTESIS_INSTRUCTIONS : `${SYNARTESIS_INSTRUCTIONS}
|
|
176
|
+
|
|
177
|
+
${upstream}`;
|
|
178
|
+
}
|
|
179
|
+
async function drain(fetch) {
|
|
180
|
+
const collected = [];
|
|
181
|
+
let cursor;
|
|
182
|
+
do {
|
|
183
|
+
const page = await fetch(cursor);
|
|
184
|
+
collected.push(...page.items);
|
|
185
|
+
cursor = page.nextCursor;
|
|
186
|
+
} while (cursor !== void 0);
|
|
187
|
+
return collected;
|
|
188
|
+
}
|
|
189
|
+
function createProxyServer(options) {
|
|
190
|
+
const { upstreams, manifest, journal } = options;
|
|
191
|
+
const router = createRouter(upstreams, manifest);
|
|
192
|
+
const policies = createPolicyResolver(manifest);
|
|
193
|
+
const log = options.logger;
|
|
194
|
+
const gate = options.gate ?? createRetryGate(journal, options.approveHint);
|
|
195
|
+
const capabilities = mergeCapabilities(
|
|
196
|
+
upstreams.map((upstream) => upstream.client.getServerCapabilities() ?? {})
|
|
197
|
+
);
|
|
198
|
+
const instructions = instructionsFor(router);
|
|
199
|
+
const wrapper = new McpServer(identityFor(router), { capabilities, instructions });
|
|
200
|
+
const server = wrapper.server;
|
|
201
|
+
let runId;
|
|
202
|
+
let resolveReady = () => void 0;
|
|
203
|
+
const ready = new Promise((resolve2) => {
|
|
204
|
+
resolveReady = resolve2;
|
|
205
|
+
});
|
|
206
|
+
let inflight = 0;
|
|
207
|
+
const idle = [];
|
|
208
|
+
const enter = () => {
|
|
209
|
+
inflight += 1;
|
|
210
|
+
};
|
|
211
|
+
const leave = () => {
|
|
212
|
+
inflight -= 1;
|
|
213
|
+
if (inflight === 0) {
|
|
214
|
+
for (const resolve2 of idle.splice(0)) {
|
|
215
|
+
resolve2();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
const whenIdle = async () => {
|
|
220
|
+
if (inflight === 0) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
await new Promise((resolve2) => idle.push(resolve2));
|
|
224
|
+
};
|
|
225
|
+
let labelled = false;
|
|
226
|
+
const ensureLabel = () => {
|
|
227
|
+
if (labelled || runId === void 0) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const name = server.getClientVersion()?.name;
|
|
231
|
+
if (name !== void 0) {
|
|
232
|
+
journal.setRunLabel(runId, name);
|
|
233
|
+
labelled = true;
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
const supports = (upstream, key) => upstream.client.getServerCapabilities()?.[key] !== void 0;
|
|
237
|
+
const ask = async (upstream, request, signal) => {
|
|
238
|
+
try {
|
|
239
|
+
return await upstream.client.request(request, PassthroughResult, {
|
|
240
|
+
signal
|
|
241
|
+
});
|
|
242
|
+
} catch (error) {
|
|
243
|
+
return rethrow(upstream.name, request.method, error);
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
let owners;
|
|
247
|
+
let schemes;
|
|
248
|
+
let conflict;
|
|
249
|
+
const refreshResources = async (signal) => {
|
|
250
|
+
const nextOwners = /* @__PURE__ */ new Map();
|
|
251
|
+
const nextSchemes = /* @__PURE__ */ new Map();
|
|
252
|
+
let nextConflict;
|
|
253
|
+
for (const upstream of router.upstreams) {
|
|
254
|
+
if (!supports(upstream, "resources")) {
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
const resources = await drain(async (cursor) => {
|
|
258
|
+
const raw = await ask(
|
|
259
|
+
upstream,
|
|
260
|
+
{
|
|
261
|
+
method: "resources/list",
|
|
262
|
+
params: cursor === void 0 ? {} : { cursor }
|
|
263
|
+
},
|
|
264
|
+
signal
|
|
265
|
+
);
|
|
266
|
+
const page = ResourceList.parse(raw);
|
|
267
|
+
return { items: page.resources, nextCursor: page.nextCursor };
|
|
268
|
+
});
|
|
269
|
+
for (const resource of resources) {
|
|
270
|
+
const existing = nextOwners.get(resource.uri);
|
|
271
|
+
if (existing !== void 0 && existing !== upstream.name) {
|
|
272
|
+
nextConflict ??= `resource ${resource.uri} is advertised by both ${existing} and ${upstream.name}; a uri cannot be namespaced, so one of them must stop exposing it`;
|
|
273
|
+
}
|
|
274
|
+
nextOwners.set(resource.uri, existing ?? upstream.name);
|
|
275
|
+
const scheme = resource.uri.split(":")[0] ?? "";
|
|
276
|
+
if (scheme !== "" && !nextSchemes.has(scheme)) {
|
|
277
|
+
nextSchemes.set(scheme, upstream.name);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const templates = await drain(async (cursor) => {
|
|
281
|
+
const raw = await ask(
|
|
282
|
+
upstream,
|
|
283
|
+
{
|
|
284
|
+
method: "resources/templates/list",
|
|
285
|
+
params: cursor === void 0 ? {} : { cursor }
|
|
286
|
+
},
|
|
287
|
+
signal
|
|
288
|
+
);
|
|
289
|
+
const page = TemplateList.parse(raw);
|
|
290
|
+
return { items: page.resourceTemplates, nextCursor: page.nextCursor };
|
|
291
|
+
});
|
|
292
|
+
for (const template of templates) {
|
|
293
|
+
const scheme = template.uriTemplate.split(":")[0] ?? "";
|
|
294
|
+
if (scheme !== "" && !nextSchemes.has(scheme)) {
|
|
295
|
+
nextSchemes.set(scheme, upstream.name);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
owners = nextOwners;
|
|
300
|
+
schemes = nextSchemes;
|
|
301
|
+
conflict = nextConflict;
|
|
302
|
+
};
|
|
303
|
+
const ensureResources = async (signal) => {
|
|
304
|
+
if (owners === void 0) {
|
|
305
|
+
await refreshResources(signal);
|
|
306
|
+
}
|
|
307
|
+
if (conflict !== void 0) {
|
|
308
|
+
throw new McpError(ErrorCode.InternalError, conflict);
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
const ownerOf = async (uri, signal) => {
|
|
312
|
+
await ensureResources(signal);
|
|
313
|
+
const direct = owners?.get(uri);
|
|
314
|
+
const scheme = uri.split(":")[0] ?? "";
|
|
315
|
+
const name = direct ?? schemes?.get(scheme);
|
|
316
|
+
const upstream = name === void 0 ? void 0 : router.byName(name);
|
|
317
|
+
if (upstream === void 0) {
|
|
318
|
+
throw new McpError(
|
|
319
|
+
ErrorCode.InvalidParams,
|
|
320
|
+
`no configured server provides ${uri}`
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
return upstream;
|
|
324
|
+
};
|
|
325
|
+
if (capabilities.tools !== void 0) {
|
|
326
|
+
server.setRequestHandler(
|
|
327
|
+
ListToolsRequestSchema,
|
|
328
|
+
async (_request, extra) => {
|
|
329
|
+
const tools = [];
|
|
330
|
+
for (const upstream of router.upstreams) {
|
|
331
|
+
if (!supports(upstream, "tools")) {
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
const items = await drain(async (cursor) => {
|
|
335
|
+
const raw = await ask(
|
|
336
|
+
upstream,
|
|
337
|
+
{
|
|
338
|
+
method: "tools/list",
|
|
339
|
+
params: cursor === void 0 ? {} : { cursor }
|
|
340
|
+
},
|
|
341
|
+
extra.signal
|
|
342
|
+
);
|
|
343
|
+
const page = ToolList.parse(raw);
|
|
344
|
+
return { items: page.tools, nextCursor: page.nextCursor };
|
|
345
|
+
});
|
|
346
|
+
for (const tool of items) {
|
|
347
|
+
tools.push({
|
|
348
|
+
...tool,
|
|
349
|
+
name: router.expose(upstream.name, tool.name)
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return { tools };
|
|
354
|
+
}
|
|
355
|
+
);
|
|
356
|
+
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
357
|
+
if (runId === void 0) {
|
|
358
|
+
throw new UpstreamError("proxy", "tools/call", "no active run");
|
|
359
|
+
}
|
|
360
|
+
const activeRun = runId;
|
|
361
|
+
ensureLabel();
|
|
362
|
+
const route = router.route(request.params.name);
|
|
363
|
+
if (route === void 0) {
|
|
364
|
+
throw new McpError(
|
|
365
|
+
ErrorCode.InvalidParams,
|
|
366
|
+
`no configured server provides tool ${request.params.name}`
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
const { policy } = policies.resolve(qualify(route.upstream.name, route.tool));
|
|
370
|
+
const args = request.params.arguments ?? {};
|
|
371
|
+
enter();
|
|
372
|
+
try {
|
|
373
|
+
const wantsGate = policy.gate === "always" || policy.gate === "on_write" && shouldGateOnWrite(args);
|
|
374
|
+
const granted = wantsGate ? journal.findApproval({
|
|
375
|
+
server: route.upstream.name,
|
|
376
|
+
tool: route.tool,
|
|
377
|
+
args,
|
|
378
|
+
notBefore: new Date(Date.now() - APPROVAL_WINDOW_MS).toISOString()
|
|
379
|
+
}) : void 0;
|
|
380
|
+
const inherited = granted !== void 0 && granted.runId !== activeRun ? granted : void 0;
|
|
381
|
+
const waiting = granted === void 0 && wantsGate ? journal.findGated({
|
|
382
|
+
runId: activeRun,
|
|
383
|
+
server: route.upstream.name,
|
|
384
|
+
tool: route.tool,
|
|
385
|
+
args
|
|
386
|
+
}) : void 0;
|
|
387
|
+
const reusable = waiting ?? (inherited === void 0 ? granted : void 0);
|
|
388
|
+
const pending = reusable === void 0 ? journal.recordPending({
|
|
389
|
+
runId: activeRun,
|
|
390
|
+
server: route.upstream.name,
|
|
391
|
+
tool: route.tool,
|
|
392
|
+
args,
|
|
393
|
+
class: policy.class
|
|
394
|
+
}) : {
|
|
395
|
+
actionId: reusable.id,
|
|
396
|
+
seq: reusable.seq,
|
|
397
|
+
idempotencyKey: reusable.idempotencyKey
|
|
398
|
+
};
|
|
399
|
+
if (inherited !== void 0) {
|
|
400
|
+
journal.adoptApproval(pending.actionId, inherited);
|
|
401
|
+
} else if (granted !== void 0 && waiting === void 0) {
|
|
402
|
+
journal.markInFlight(granted.id);
|
|
403
|
+
}
|
|
404
|
+
if (granted !== void 0) {
|
|
405
|
+
log?.info(
|
|
406
|
+
{ action: pending.actionId, by: granted.approvedBy, from: granted.runId },
|
|
407
|
+
"proceeding on a standing approval"
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
const decide = async (why) => {
|
|
411
|
+
leave();
|
|
412
|
+
let decision;
|
|
413
|
+
try {
|
|
414
|
+
decision = await gate.decide({
|
|
415
|
+
actionId: pending.actionId,
|
|
416
|
+
runId: activeRun,
|
|
417
|
+
seq: pending.seq,
|
|
418
|
+
server: route.upstream.name,
|
|
419
|
+
tool: route.tool,
|
|
420
|
+
args,
|
|
421
|
+
why,
|
|
422
|
+
signal: extra.signal
|
|
423
|
+
});
|
|
424
|
+
} finally {
|
|
425
|
+
enter();
|
|
426
|
+
}
|
|
427
|
+
log?.info(
|
|
428
|
+
{ action: pending.actionId, approved: decision.approved },
|
|
429
|
+
decision.approved ? "approved" : "denied"
|
|
430
|
+
);
|
|
431
|
+
if (decision.approved && extra.signal.aborted) {
|
|
432
|
+
journal.settleAsDenied(
|
|
433
|
+
pending.actionId,
|
|
434
|
+
decision.by,
|
|
435
|
+
"approved, but the client had already stopped waiting, so it was not sent"
|
|
436
|
+
);
|
|
437
|
+
throw new McpError(
|
|
438
|
+
ErrorCode.InvalidRequest,
|
|
439
|
+
`synartesis blocked ${request.params.name}: it was approved after the client stopped waiting, so it was not sent. Ask the agent to try again.`
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
if (!decision.approved) {
|
|
443
|
+
if (decision.awaiting === true) {
|
|
444
|
+
log?.warn(
|
|
445
|
+
{
|
|
446
|
+
action: pending.actionId,
|
|
447
|
+
tool: `${route.upstream.name}.${route.tool}`,
|
|
448
|
+
approve: options.approveHint?.(pending.actionId) ?? pending.actionId
|
|
449
|
+
},
|
|
450
|
+
"awaiting approval"
|
|
451
|
+
);
|
|
452
|
+
throw new McpError(
|
|
453
|
+
ErrorCode.InvalidRequest,
|
|
454
|
+
`Synartesis is holding this call for approval, because ${why}. ${decision.reason}`
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
const who = decision.by === void 0 ? "" : ` by ${decision.by}`;
|
|
458
|
+
throw new McpError(
|
|
459
|
+
ErrorCode.InvalidRequest,
|
|
460
|
+
`synartesis blocked ${request.params.name}: ${why} and was denied${who}. ${decision.reason}`
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
};
|
|
464
|
+
const askedAlready = wantsGate;
|
|
465
|
+
if (wantsGate && granted === void 0) {
|
|
466
|
+
await decide("this action cannot be undone");
|
|
467
|
+
}
|
|
468
|
+
let snapshot;
|
|
469
|
+
let verify;
|
|
470
|
+
let missingPriorState;
|
|
471
|
+
if (policy.snapshot !== void 0) {
|
|
472
|
+
try {
|
|
473
|
+
verify = planRead(policy.snapshot, { args });
|
|
474
|
+
snapshot = await runRead(router, verify, extra.signal);
|
|
475
|
+
journal.attachSnapshot(pending.actionId, snapshot);
|
|
476
|
+
} catch (error) {
|
|
477
|
+
const reason = describe(error);
|
|
478
|
+
if (error instanceof SnapshotError && error.absent) {
|
|
479
|
+
missingPriorState = reason;
|
|
480
|
+
verify = void 0;
|
|
481
|
+
} else {
|
|
482
|
+
journal.markFailed(pending.actionId, reason);
|
|
483
|
+
log?.error(
|
|
484
|
+
{ seq: pending.seq, tool: route.tool, reason },
|
|
485
|
+
"write blocked: snapshot failed"
|
|
486
|
+
);
|
|
487
|
+
throw new McpError(
|
|
488
|
+
ErrorCode.InternalError,
|
|
489
|
+
`synartesis blocked ${request.params.name}: ${reason}`
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
if (missingPriorState !== void 0 && !askedAlready) {
|
|
495
|
+
const standing = journal.findApproval({
|
|
496
|
+
server: route.upstream.name,
|
|
497
|
+
tool: route.tool,
|
|
498
|
+
args,
|
|
499
|
+
notBefore: new Date(Date.now() - APPROVAL_WINDOW_MS).toISOString()
|
|
500
|
+
});
|
|
501
|
+
if (standing === void 0) {
|
|
502
|
+
await decide(
|
|
503
|
+
`nothing was captured to restore, so this cannot be undone \u2014 the read said: ${missingPriorState}`
|
|
504
|
+
);
|
|
505
|
+
} else {
|
|
506
|
+
journal.adoptApproval(pending.actionId, standing);
|
|
507
|
+
log?.info(
|
|
508
|
+
{ action: pending.actionId, by: standing.approvedBy, from: standing.runId },
|
|
509
|
+
"proceeding on a standing approval"
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
const forwarded = {
|
|
514
|
+
method: "tools/call",
|
|
515
|
+
params: { ...request.params, name: route.tool }
|
|
516
|
+
};
|
|
517
|
+
try {
|
|
518
|
+
const result = await route.upstream.client.request(forwarded, PassthroughResult, {
|
|
519
|
+
signal: extra.signal
|
|
520
|
+
});
|
|
521
|
+
const refused = refusal(result);
|
|
522
|
+
if (refused !== void 0) {
|
|
523
|
+
journal.markFailed(pending.actionId, `the upstream refused the call: ${refused}`);
|
|
524
|
+
log?.debug(
|
|
525
|
+
{ seq: pending.seq, tool: route.tool, reason: refused },
|
|
526
|
+
"refused by the upstream"
|
|
527
|
+
);
|
|
528
|
+
return result;
|
|
529
|
+
}
|
|
530
|
+
const context = { args, snapshot, result: toPayload(result) };
|
|
531
|
+
const warnings = [];
|
|
532
|
+
if (missingPriorState !== void 0) {
|
|
533
|
+
warnings.push(
|
|
534
|
+
`no prior state existed, so there is nothing to restore: ${missingPriorState}`
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
let inverse;
|
|
538
|
+
if (policy.inverse !== void 0 && missingPriorState === void 0) {
|
|
539
|
+
try {
|
|
540
|
+
inverse = planInverse(policy.inverse, context);
|
|
541
|
+
} catch (error) {
|
|
542
|
+
warnings.push(`inverse could not be resolved: ${describe(error)}`);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
let postSnapshot;
|
|
546
|
+
if (verify !== void 0) {
|
|
547
|
+
try {
|
|
548
|
+
postSnapshot = await observeState(router, verify, extra.signal);
|
|
549
|
+
} catch (error) {
|
|
550
|
+
warnings.push(`post-state could not be captured: ${describe(error)}`);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
if (warnings.length > 0) {
|
|
554
|
+
log?.warn({ seq: pending.seq, tool: route.tool, warnings }, "applied with reservations");
|
|
555
|
+
}
|
|
556
|
+
log?.debug(
|
|
557
|
+
{ seq: pending.seq, server: route.upstream.name, tool: route.tool, class: policy.class },
|
|
558
|
+
"applied"
|
|
559
|
+
);
|
|
560
|
+
journal.markApplied(pending.actionId, {
|
|
561
|
+
result,
|
|
562
|
+
...inverse === void 0 ? {} : { inverse },
|
|
563
|
+
...verify === void 0 ? {} : { verify },
|
|
564
|
+
...postSnapshot === void 0 ? {} : { postSnapshot },
|
|
565
|
+
...warnings.length === 0 ? {} : { warning: warnings.join("; ") }
|
|
566
|
+
});
|
|
567
|
+
return result;
|
|
568
|
+
} catch (error) {
|
|
569
|
+
const disconnected = isDisconnected(error);
|
|
570
|
+
if (extra.signal.aborted || mayHaveArrived(error)) {
|
|
571
|
+
journal.markUnknown(pending.actionId, describe(error));
|
|
572
|
+
} else {
|
|
573
|
+
journal.markFailed(pending.actionId, describe(error));
|
|
574
|
+
}
|
|
575
|
+
if (disconnected && route.upstream.reconnect !== void 0) {
|
|
576
|
+
await route.upstream.reconnect().catch(() => void 0);
|
|
577
|
+
}
|
|
578
|
+
return rethrow(route.upstream.name, "tools/call", error);
|
|
579
|
+
}
|
|
580
|
+
} finally {
|
|
581
|
+
leave();
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
if (capabilities.resources !== void 0) {
|
|
586
|
+
server.setRequestHandler(
|
|
587
|
+
ListResourcesRequestSchema,
|
|
588
|
+
async (_request, extra) => {
|
|
589
|
+
await refreshResources(extra.signal);
|
|
590
|
+
await ensureResources(extra.signal);
|
|
591
|
+
const resources = [];
|
|
592
|
+
for (const upstream of router.upstreams) {
|
|
593
|
+
if (!supports(upstream, "resources")) {
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
const items = await drain(async (cursor) => {
|
|
597
|
+
const raw = await ask(
|
|
598
|
+
upstream,
|
|
599
|
+
{
|
|
600
|
+
method: "resources/list",
|
|
601
|
+
params: cursor === void 0 ? {} : { cursor }
|
|
602
|
+
},
|
|
603
|
+
extra.signal
|
|
604
|
+
);
|
|
605
|
+
const page = ResourceList.parse(raw);
|
|
606
|
+
return { items: page.resources, nextCursor: page.nextCursor };
|
|
607
|
+
});
|
|
608
|
+
resources.push(...items);
|
|
609
|
+
}
|
|
610
|
+
return { resources };
|
|
611
|
+
}
|
|
612
|
+
);
|
|
613
|
+
server.setRequestHandler(
|
|
614
|
+
ListResourceTemplatesRequestSchema,
|
|
615
|
+
async (_request, extra) => {
|
|
616
|
+
const resourceTemplates = [];
|
|
617
|
+
for (const upstream of router.upstreams) {
|
|
618
|
+
if (!supports(upstream, "resources")) {
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
const items = await drain(async (cursor) => {
|
|
622
|
+
const raw = await ask(
|
|
623
|
+
upstream,
|
|
624
|
+
{
|
|
625
|
+
method: "resources/templates/list",
|
|
626
|
+
params: cursor === void 0 ? {} : { cursor }
|
|
627
|
+
},
|
|
628
|
+
extra.signal
|
|
629
|
+
);
|
|
630
|
+
const page = TemplateList.parse(raw);
|
|
631
|
+
return {
|
|
632
|
+
items: page.resourceTemplates,
|
|
633
|
+
nextCursor: page.nextCursor
|
|
634
|
+
};
|
|
635
|
+
});
|
|
636
|
+
resourceTemplates.push(...items);
|
|
637
|
+
}
|
|
638
|
+
return { resourceTemplates };
|
|
639
|
+
}
|
|
640
|
+
);
|
|
641
|
+
server.setRequestHandler(
|
|
642
|
+
ReadResourceRequestSchema,
|
|
643
|
+
async (request, extra) => {
|
|
644
|
+
const upstream = await ownerOf(request.params.uri, extra.signal);
|
|
645
|
+
return ask(upstream, request, extra.signal);
|
|
646
|
+
}
|
|
647
|
+
);
|
|
648
|
+
if (capabilities.resources.subscribe === true) {
|
|
649
|
+
for (const schema of [SubscribeRequestSchema, UnsubscribeRequestSchema]) {
|
|
650
|
+
server.setRequestHandler(schema, async (request, extra) => {
|
|
651
|
+
const upstream = await ownerOf(request.params.uri, extra.signal);
|
|
652
|
+
return ask(upstream, request, extra.signal);
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
if (capabilities.prompts !== void 0) {
|
|
658
|
+
server.setRequestHandler(
|
|
659
|
+
ListPromptsRequestSchema,
|
|
660
|
+
async (_request, extra) => {
|
|
661
|
+
const prompts = [];
|
|
662
|
+
for (const upstream of router.upstreams) {
|
|
663
|
+
if (!supports(upstream, "prompts")) {
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
const items = await drain(async (cursor) => {
|
|
667
|
+
const raw = await ask(
|
|
668
|
+
upstream,
|
|
669
|
+
{
|
|
670
|
+
method: "prompts/list",
|
|
671
|
+
params: cursor === void 0 ? {} : { cursor }
|
|
672
|
+
},
|
|
673
|
+
extra.signal
|
|
674
|
+
);
|
|
675
|
+
const page = PromptList.parse(raw);
|
|
676
|
+
return { items: page.prompts, nextCursor: page.nextCursor };
|
|
677
|
+
});
|
|
678
|
+
for (const prompt of items) {
|
|
679
|
+
prompts.push({
|
|
680
|
+
...prompt,
|
|
681
|
+
name: router.expose(upstream.name, prompt.name)
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
return { prompts };
|
|
686
|
+
}
|
|
687
|
+
);
|
|
688
|
+
server.setRequestHandler(GetPromptRequestSchema, async (request, extra) => {
|
|
689
|
+
const route = router.route(request.params.name);
|
|
690
|
+
if (route === void 0) {
|
|
691
|
+
throw new McpError(
|
|
692
|
+
ErrorCode.InvalidParams,
|
|
693
|
+
`no configured server provides prompt ${request.params.name}`
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
return ask(
|
|
697
|
+
route.upstream,
|
|
698
|
+
{
|
|
699
|
+
method: "prompts/get",
|
|
700
|
+
params: { ...request.params, name: route.tool }
|
|
701
|
+
},
|
|
702
|
+
extra.signal
|
|
703
|
+
);
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
if (capabilities.completions !== void 0) {
|
|
707
|
+
server.setRequestHandler(CompleteRequestSchema, async (request, extra) => {
|
|
708
|
+
const reference = request.params.ref;
|
|
709
|
+
if (reference.type === "ref/prompt") {
|
|
710
|
+
const route = router.route(reference.name);
|
|
711
|
+
if (route === void 0) {
|
|
712
|
+
throw new McpError(
|
|
713
|
+
ErrorCode.InvalidParams,
|
|
714
|
+
`unknown prompt ${reference.name}`
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
return ask(
|
|
718
|
+
route.upstream,
|
|
719
|
+
{
|
|
720
|
+
method: "completion/complete",
|
|
721
|
+
params: {
|
|
722
|
+
...request.params,
|
|
723
|
+
ref: { ...reference, name: route.tool }
|
|
724
|
+
}
|
|
725
|
+
},
|
|
726
|
+
extra.signal
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
const upstream = await ownerOf(reference.uri, extra.signal);
|
|
730
|
+
return ask(upstream, request, extra.signal);
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
if (capabilities.logging !== void 0) {
|
|
734
|
+
server.setRequestHandler(SetLevelRequestSchema, async (request, extra) => {
|
|
735
|
+
for (const upstream of router.upstreams) {
|
|
736
|
+
if (supports(upstream, "logging")) {
|
|
737
|
+
await ask(upstream, request, extra.signal);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
return {};
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
let connected = false;
|
|
744
|
+
for (const upstream of router.upstreams) {
|
|
745
|
+
upstream.client.fallbackNotificationHandler = async (notification) => {
|
|
746
|
+
if (notification.method.endsWith("list_changed")) {
|
|
747
|
+
owners = void 0;
|
|
748
|
+
schemes = void 0;
|
|
749
|
+
conflict = void 0;
|
|
750
|
+
}
|
|
751
|
+
if (connected) {
|
|
752
|
+
await server.notification(notification);
|
|
753
|
+
}
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
server.oninitialized = () => {
|
|
757
|
+
connected = true;
|
|
758
|
+
const name = server.getClientVersion()?.name;
|
|
759
|
+
const id = journal.beginRun(name);
|
|
760
|
+
runId = id;
|
|
761
|
+
labelled = name !== void 0;
|
|
762
|
+
resolveReady(id);
|
|
763
|
+
};
|
|
764
|
+
const previousOnClose = server.onclose;
|
|
765
|
+
server.onclose = () => {
|
|
766
|
+
connected = false;
|
|
767
|
+
if (runId !== void 0) {
|
|
768
|
+
journal.endRun(runId, "complete");
|
|
769
|
+
runId = void 0;
|
|
770
|
+
}
|
|
771
|
+
previousOnClose?.();
|
|
772
|
+
};
|
|
773
|
+
return {
|
|
774
|
+
server: wrapper,
|
|
775
|
+
ready,
|
|
776
|
+
whenIdle,
|
|
777
|
+
busy: () => inflight > 0,
|
|
778
|
+
get runId() {
|
|
779
|
+
return runId;
|
|
780
|
+
}
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// src/proxy/stdio.ts
|
|
785
|
+
var NODE_MAJOR = Number(process.versions.node.split(".")[0]);
|
|
786
|
+
if (NODE_MAJOR < 22) {
|
|
787
|
+
process.stderr.write(
|
|
788
|
+
`synartesis: needs Node 22 or newer, and this is ${process.version}.
|
|
789
|
+
`
|
|
790
|
+
);
|
|
791
|
+
process.exit(2);
|
|
792
|
+
}
|
|
793
|
+
function parseArgv(argv) {
|
|
794
|
+
const read = (flag) => {
|
|
795
|
+
const at = argv.indexOf(flag);
|
|
796
|
+
return at === -1 ? void 0 : argv[at + 1];
|
|
797
|
+
};
|
|
798
|
+
const known = ["--manifest", "--journal", "--gate-timeout", "--log-level"];
|
|
799
|
+
const unknown = argv.find((token) => token.startsWith("--") && !known.includes(token));
|
|
800
|
+
if (unknown !== void 0) {
|
|
801
|
+
throw new Error(`unknown flag ${unknown}; expected one of ${known.join(", ")}`);
|
|
802
|
+
}
|
|
803
|
+
const rawTimeout = read("--gate-timeout");
|
|
804
|
+
const seconds = rawTimeout === void 0 ? void 0 : Number(rawTimeout);
|
|
805
|
+
if (seconds !== void 0 && (!Number.isFinite(seconds) || seconds <= 0)) {
|
|
806
|
+
throw new Error("--gate-timeout needs a positive number of seconds");
|
|
807
|
+
}
|
|
808
|
+
const level = read("--log-level") ?? "info";
|
|
809
|
+
if (!isLogLevel(level)) {
|
|
810
|
+
throw new Error(`--log-level must be one of ${LOG_LEVELS.join(", ")}`);
|
|
811
|
+
}
|
|
812
|
+
const manifest = findManifest(read("--manifest"));
|
|
813
|
+
return {
|
|
814
|
+
manifest,
|
|
815
|
+
journal: findJournal(read("--journal"), manifest),
|
|
816
|
+
gateTimeoutMs: seconds === void 0 ? DEFAULT_GATE_TIMEOUT_MS : seconds * 1e3,
|
|
817
|
+
logLevel: level
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
async function main() {
|
|
821
|
+
const argv = parseArgv(process.argv.slice(2));
|
|
822
|
+
const log = createLogger(argv.logLevel);
|
|
823
|
+
if (process.stderr.isTTY) {
|
|
824
|
+
process.stderr.write(mark());
|
|
825
|
+
}
|
|
826
|
+
const manifest = loadManifest(argv.manifest);
|
|
827
|
+
const journal = openJournal(argv.journal);
|
|
828
|
+
const upstreams = [];
|
|
829
|
+
for (const [name, spec] of Object.entries(manifest.servers)) {
|
|
830
|
+
upstreams.push(
|
|
831
|
+
await connectStdioUpstream({
|
|
832
|
+
name,
|
|
833
|
+
command: spec.command,
|
|
834
|
+
args: spec.args,
|
|
835
|
+
...spec.env === void 0 ? {} : { env: spec.env }
|
|
836
|
+
})
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
await verifyAgainstServers(upstreams, manifest);
|
|
840
|
+
log.info(
|
|
841
|
+
{
|
|
842
|
+
manifest: argv.manifest,
|
|
843
|
+
journal: argv.journal,
|
|
844
|
+
servers: upstreams.map((upstream) => upstream.name),
|
|
845
|
+
policies: manifest.tools.length
|
|
846
|
+
},
|
|
847
|
+
"proxy ready"
|
|
848
|
+
);
|
|
849
|
+
const proxy = createProxyServer({
|
|
850
|
+
upstreams,
|
|
851
|
+
manifest,
|
|
852
|
+
journal,
|
|
853
|
+
gateTimeoutMs: argv.gateTimeoutMs,
|
|
854
|
+
logger: log,
|
|
855
|
+
// Absolute, because whoever approves may be in any directory at all.
|
|
856
|
+
approveHint: (actionId) => `${cliCommandFrom(import.meta.url)} approve ${actionId.slice(0, 8)} --journal ${resolve(argv.journal)}`
|
|
857
|
+
});
|
|
858
|
+
let shuttingDown = false;
|
|
859
|
+
const shutdown = (code) => {
|
|
860
|
+
if (shuttingDown) {
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
shuttingDown = true;
|
|
864
|
+
void (async () => {
|
|
865
|
+
await Promise.race([
|
|
866
|
+
proxy.whenIdle(),
|
|
867
|
+
new Promise((resolve2) => setTimeout(resolve2, 5e3).unref())
|
|
868
|
+
]);
|
|
869
|
+
await proxy.server.close();
|
|
870
|
+
for (const upstream of upstreams) {
|
|
871
|
+
await upstream.close();
|
|
872
|
+
}
|
|
873
|
+
journal.close();
|
|
874
|
+
process.exit(code);
|
|
875
|
+
})();
|
|
876
|
+
};
|
|
877
|
+
process.on("SIGINT", () => {
|
|
878
|
+
shutdown(0);
|
|
879
|
+
});
|
|
880
|
+
process.on("SIGTERM", () => {
|
|
881
|
+
shutdown(0);
|
|
882
|
+
});
|
|
883
|
+
const pipeClosed = () => {
|
|
884
|
+
const giveUpAt = Date.now() + 5e3;
|
|
885
|
+
let quiet = 0;
|
|
886
|
+
const settle = () => {
|
|
887
|
+
quiet = proxy.busy() ? 0 : quiet + 1;
|
|
888
|
+
if (quiet >= 10 || Date.now() > giveUpAt) {
|
|
889
|
+
shutdown(0);
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
setImmediate(settle);
|
|
893
|
+
};
|
|
894
|
+
setImmediate(settle);
|
|
895
|
+
};
|
|
896
|
+
process.stdin.on("end", pipeClosed);
|
|
897
|
+
process.stdin.on("close", pipeClosed);
|
|
898
|
+
const inner = proxy.server.server;
|
|
899
|
+
const onclose = inner.onclose;
|
|
900
|
+
inner.onclose = () => {
|
|
901
|
+
onclose?.();
|
|
902
|
+
shutdown(0);
|
|
903
|
+
};
|
|
904
|
+
await proxy.server.connect(new StdioServerTransport());
|
|
905
|
+
}
|
|
906
|
+
try {
|
|
907
|
+
await main();
|
|
908
|
+
} catch (error) {
|
|
909
|
+
process.stderr.write(`synartesis: ${describe(error)}
|
|
910
|
+
`);
|
|
911
|
+
process.exit(1);
|
|
912
|
+
}
|
|
913
|
+
//# sourceMappingURL=proxy.js.map
|