codeep 1.3.41 → 2.0.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 +208 -0
- package/dist/acp/commands.js +770 -7
- package/dist/acp/protocol.d.ts +11 -2
- package/dist/acp/server.js +179 -11
- package/dist/acp/session.d.ts +3 -0
- package/dist/acp/session.js +5 -0
- package/dist/api/index.js +39 -6
- package/dist/config/index.d.ts +13 -0
- package/dist/config/index.js +46 -1
- package/dist/config/providers.js +76 -1
- package/dist/renderer/App.d.ts +12 -0
- package/dist/renderer/App.js +96 -4
- package/dist/renderer/agentExecution.js +5 -0
- package/dist/renderer/commands.js +348 -2
- package/dist/renderer/components/Login.d.ts +1 -0
- package/dist/renderer/components/Login.js +24 -9
- package/dist/renderer/handlers.d.ts +11 -1
- package/dist/renderer/handlers.js +30 -0
- package/dist/renderer/main.js +73 -0
- package/dist/utils/agent.d.ts +17 -0
- package/dist/utils/agent.js +91 -7
- package/dist/utils/agentChat.d.ts +10 -2
- package/dist/utils/agentChat.js +48 -9
- package/dist/utils/agentStream.js +6 -2
- package/dist/utils/checkpoints.d.ts +93 -0
- package/dist/utils/checkpoints.js +205 -0
- package/dist/utils/context.d.ts +24 -0
- package/dist/utils/context.js +57 -0
- package/dist/utils/customCommands.d.ts +62 -0
- package/dist/utils/customCommands.js +201 -0
- package/dist/utils/hooks.d.ts +97 -0
- package/dist/utils/hooks.js +223 -0
- package/dist/utils/mcpClient.d.ts +229 -0
- package/dist/utils/mcpClient.js +497 -0
- package/dist/utils/mcpConfig.d.ts +55 -0
- package/dist/utils/mcpConfig.js +177 -0
- package/dist/utils/mcpMarketplace.d.ts +49 -0
- package/dist/utils/mcpMarketplace.js +175 -0
- package/dist/utils/mcpRegistry.d.ts +129 -0
- package/dist/utils/mcpRegistry.js +427 -0
- package/dist/utils/mcpSamplingBridge.d.ts +32 -0
- package/dist/utils/mcpSamplingBridge.js +88 -0
- package/dist/utils/mcpStreamableHttp.d.ts +65 -0
- package/dist/utils/mcpStreamableHttp.js +207 -0
- package/dist/utils/openrouterPrefs.d.ts +36 -0
- package/dist/utils/openrouterPrefs.js +83 -0
- package/dist/utils/skillBundles.d.ts +84 -0
- package/dist/utils/skillBundles.js +257 -0
- package/dist/utils/skillBundlesCloud.d.ts +66 -0
- package/dist/utils/skillBundlesCloud.js +196 -0
- package/dist/utils/tokenTracker.d.ts +14 -2
- package/dist/utils/tokenTracker.js +59 -45
- package/dist/utils/toolExecution.d.ts +17 -1
- package/dist/utils/toolExecution.js +184 -6
- package/dist/utils/tools.d.ts +22 -6
- package/dist/utils/tools.js +83 -8
- package/package.json +3 -2
- package/bin/codeep-macos-arm64 +0 -0
- package/bin/codeep-macos-x64 +0 -0
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session registry of MCP servers.
|
|
3
|
+
*
|
|
4
|
+
* `acp/server.ts handleSessionNew/Load/Resume` receives an optional
|
|
5
|
+
* `mcpServers: McpServer[]` from the ACP client. We spawn each one via
|
|
6
|
+
* `McpClient`, list its tools, and surface them under a flat namespace so
|
|
7
|
+
* the agent can call them like any built-in tool.
|
|
8
|
+
*
|
|
9
|
+
* Naming: tools are surfaced as `<serverName>__<toolName>` to avoid
|
|
10
|
+
* collisions across servers and with built-in agent tools. Underscores are
|
|
11
|
+
* legal in MCP tool names; `__` is rare enough in practice to serve as a
|
|
12
|
+
* delimiter without escaping.
|
|
13
|
+
*/
|
|
14
|
+
import { McpClient } from './mcpClient.js';
|
|
15
|
+
const NAMESPACE_DELIM = '__';
|
|
16
|
+
/**
|
|
17
|
+
* Virtual-tool suffixes for the resource/prompt wrappers we generate per
|
|
18
|
+
* server. Keep these in lockstep with the dispatch switch in
|
|
19
|
+
* toolExecution.ts — adding a new wrapper requires both ends.
|
|
20
|
+
*/
|
|
21
|
+
export const VIRTUAL_TOOL_SUFFIXES = {
|
|
22
|
+
resourceList: 'resource_list',
|
|
23
|
+
resourceRead: 'resource_read',
|
|
24
|
+
promptList: 'prompt_list',
|
|
25
|
+
promptGet: 'prompt_get',
|
|
26
|
+
};
|
|
27
|
+
/** Map of ACP session id → list of clients started for that session. */
|
|
28
|
+
const sessionClients = new Map();
|
|
29
|
+
/**
|
|
30
|
+
* In-progress registration promises. `callSessionTool` awaits these so a
|
|
31
|
+
* tool call that lands between session/new and "all servers spawned" waits
|
|
32
|
+
* for registration to finish instead of erroring with "no servers".
|
|
33
|
+
*/
|
|
34
|
+
const registrationInProgress = new Map();
|
|
35
|
+
/** Per-session spawn errors, kept around so `/mcp` can surface them. */
|
|
36
|
+
const sessionErrors = new Map();
|
|
37
|
+
/**
|
|
38
|
+
* Per-session catalog dirty flags. The agent loop polls and clears these
|
|
39
|
+
* between iterations so a `tools/list_changed` notification mid-run causes
|
|
40
|
+
* a fresh re-list before the next tool dispatch.
|
|
41
|
+
*/
|
|
42
|
+
const sessionCatalogDirty = new Map();
|
|
43
|
+
/**
|
|
44
|
+
* Spawn the given servers and discover their tools. Failures on individual
|
|
45
|
+
* servers are reported in the result but don't abort the whole registration
|
|
46
|
+
* (one broken server shouldn't kill the rest).
|
|
47
|
+
*
|
|
48
|
+
* Idempotent: calling twice for the same sessionId disposes the prior set
|
|
49
|
+
* first.
|
|
50
|
+
*/
|
|
51
|
+
export async function registerSessionServers(sessionId, servers, opts = {}) {
|
|
52
|
+
await disposeSession(sessionId);
|
|
53
|
+
if (servers.length === 0) {
|
|
54
|
+
sessionClients.set(sessionId, []);
|
|
55
|
+
sessionErrors.set(sessionId, []);
|
|
56
|
+
return { registered: [], errors: [] };
|
|
57
|
+
}
|
|
58
|
+
// Build the registration as a Promise so concurrent `callSessionTool`
|
|
59
|
+
// calls can `awaitSessionReady` instead of seeing an empty registry.
|
|
60
|
+
const registrationPromise = (async () => {
|
|
61
|
+
const clients = [];
|
|
62
|
+
const registered = [];
|
|
63
|
+
const errors = [];
|
|
64
|
+
// Start in parallel — server startup tends to dominate session/new
|
|
65
|
+
// latency, and the work is independent per server.
|
|
66
|
+
await Promise.all(servers.map(async (cfg) => {
|
|
67
|
+
const client = new McpClient(cfg, {
|
|
68
|
+
workspaceRoot: opts.workspaceRoot,
|
|
69
|
+
onSamplingRequest: opts.onSamplingRequest
|
|
70
|
+
? (params) => opts.onSamplingRequest(params, cfg.name)
|
|
71
|
+
: undefined,
|
|
72
|
+
});
|
|
73
|
+
// Catalog-changed signals from the server flip a dirty bit the agent
|
|
74
|
+
// loop reads between iterations. Plus restart already invalidates
|
|
75
|
+
// tools cache on the client side — we flip the same bit so the agent
|
|
76
|
+
// re-fetches even though the registry doesn't directly know about
|
|
77
|
+
// the restart.
|
|
78
|
+
client.onRestart = () => {
|
|
79
|
+
const dirty = sessionCatalogDirty.get(sessionId) ?? new Set();
|
|
80
|
+
dirty.add('tools');
|
|
81
|
+
dirty.add('resources');
|
|
82
|
+
dirty.add('prompts');
|
|
83
|
+
sessionCatalogDirty.set(sessionId, dirty);
|
|
84
|
+
};
|
|
85
|
+
client.onCatalogChanged = (kind) => {
|
|
86
|
+
const dirty = sessionCatalogDirty.get(sessionId) ?? new Set();
|
|
87
|
+
dirty.add(kind);
|
|
88
|
+
sessionCatalogDirty.set(sessionId, dirty);
|
|
89
|
+
};
|
|
90
|
+
client.onGaveUp = (reason) => {
|
|
91
|
+
const list = sessionErrors.get(sessionId) ?? [];
|
|
92
|
+
list.push({ server: cfg.name, error: `gave up auto-restart (${reason})` });
|
|
93
|
+
sessionErrors.set(sessionId, list);
|
|
94
|
+
};
|
|
95
|
+
try {
|
|
96
|
+
await client.start();
|
|
97
|
+
const tools = await client.listTools();
|
|
98
|
+
for (const t of tools) {
|
|
99
|
+
registered.push(toRegistered(cfg.name, t));
|
|
100
|
+
}
|
|
101
|
+
clients.push(client);
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
errors.push({ server: cfg.name, error: err.message });
|
|
105
|
+
await client.stop().catch(() => { });
|
|
106
|
+
}
|
|
107
|
+
}));
|
|
108
|
+
sessionClients.set(sessionId, clients);
|
|
109
|
+
sessionErrors.set(sessionId, errors);
|
|
110
|
+
return { registered, errors };
|
|
111
|
+
})();
|
|
112
|
+
registrationInProgress.set(sessionId, registrationPromise.then(() => { }, () => { }));
|
|
113
|
+
try {
|
|
114
|
+
return await registrationPromise;
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
registrationInProgress.delete(sessionId);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Wait for any in-flight `registerSessionServers` for this session to
|
|
122
|
+
* finish. No-op once registration has completed. Lets `callSessionTool`
|
|
123
|
+
* be tolerant of the race between session/new returning and the user
|
|
124
|
+
* sending their first prompt.
|
|
125
|
+
*/
|
|
126
|
+
export async function awaitSessionReady(sessionId) {
|
|
127
|
+
const pending = registrationInProgress.get(sessionId);
|
|
128
|
+
if (pending)
|
|
129
|
+
await pending;
|
|
130
|
+
}
|
|
131
|
+
/** Return spawn errors recorded for a session, for the /mcp inspector. */
|
|
132
|
+
export function getSessionRegistrationErrors(sessionId) {
|
|
133
|
+
return sessionErrors.get(sessionId) ?? [];
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Atomically read + clear the catalog-dirty flags for a session.
|
|
137
|
+
* Called by the agent loop between iterations: if any flag is set,
|
|
138
|
+
* the relevant cached lists (tools / resources / prompts) should be
|
|
139
|
+
* re-fetched before the next dispatch. Returns the set of dirty kinds.
|
|
140
|
+
*/
|
|
141
|
+
export function consumeSessionCatalogChanges(sessionId) {
|
|
142
|
+
const dirty = sessionCatalogDirty.get(sessionId);
|
|
143
|
+
if (!dirty || dirty.size === 0)
|
|
144
|
+
return new Set();
|
|
145
|
+
sessionCatalogDirty.delete(sessionId);
|
|
146
|
+
// Also blow away client-side tool caches so the next listTools() actually
|
|
147
|
+
// re-queries the server (the per-client cache is what
|
|
148
|
+
// `getSessionTools` reads from).
|
|
149
|
+
if (dirty.has('tools')) {
|
|
150
|
+
const clients = sessionClients.get(sessionId);
|
|
151
|
+
if (clients) {
|
|
152
|
+
for (const c of clients) {
|
|
153
|
+
// Reach in to clear the cache — purposely loose typing so we don't
|
|
154
|
+
// export a "clear cache" method that callers might mis-use.
|
|
155
|
+
c.toolsCache = null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return dirty;
|
|
160
|
+
}
|
|
161
|
+
function toRegistered(serverName, tool) {
|
|
162
|
+
return {
|
|
163
|
+
agentName: `${serverName}${NAMESPACE_DELIM}${tool.name}`,
|
|
164
|
+
toolName: tool.name,
|
|
165
|
+
serverName,
|
|
166
|
+
description: tool.description,
|
|
167
|
+
inputSchema: tool.inputSchema,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
/** Return all tools registered for a session (built from cached server tool lists). */
|
|
171
|
+
export async function getSessionTools(sessionId) {
|
|
172
|
+
const clients = sessionClients.get(sessionId);
|
|
173
|
+
if (!clients?.length)
|
|
174
|
+
return [];
|
|
175
|
+
const all = [];
|
|
176
|
+
for (const client of clients) {
|
|
177
|
+
try {
|
|
178
|
+
const tools = await client.listTools();
|
|
179
|
+
for (const t of tools)
|
|
180
|
+
all.push(toRegistered(client.server.name, t));
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
// Skip a flaky server rather than fail the entire list.
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return all;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Build the set of virtual tools that wrap the resource/prompt primitives
|
|
190
|
+
* for every server in the session. The agent calls these like normal
|
|
191
|
+
* `<server>__<tool>` entries; toolExecution.ts dispatches them through
|
|
192
|
+
* `callSessionResourceTool` below.
|
|
193
|
+
*
|
|
194
|
+
* We only emit a wrapper when the server actually exposes resources or
|
|
195
|
+
* prompts (probed via `listResources`/`listPrompts`) — no point teaching
|
|
196
|
+
* the model about a `read_resource` tool that always returns "no such
|
|
197
|
+
* resource".
|
|
198
|
+
*/
|
|
199
|
+
export async function getSessionVirtualTools(sessionId) {
|
|
200
|
+
const clients = sessionClients.get(sessionId);
|
|
201
|
+
if (!clients?.length)
|
|
202
|
+
return [];
|
|
203
|
+
const out = [];
|
|
204
|
+
for (const client of clients) {
|
|
205
|
+
const serverName = client.server.name;
|
|
206
|
+
// Resources
|
|
207
|
+
try {
|
|
208
|
+
const resources = await client.listResources();
|
|
209
|
+
if (resources.length > 0) {
|
|
210
|
+
out.push({
|
|
211
|
+
agentName: `${serverName}${NAMESPACE_DELIM}${VIRTUAL_TOOL_SUFFIXES.resourceList}`,
|
|
212
|
+
toolName: VIRTUAL_TOOL_SUFFIXES.resourceList,
|
|
213
|
+
serverName,
|
|
214
|
+
description: `List MCP resources exposed by the "${serverName}" server. Returns the URIs you can pass to ${serverName}__${VIRTUAL_TOOL_SUFFIXES.resourceRead}.`,
|
|
215
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
216
|
+
});
|
|
217
|
+
out.push({
|
|
218
|
+
agentName: `${serverName}${NAMESPACE_DELIM}${VIRTUAL_TOOL_SUFFIXES.resourceRead}`,
|
|
219
|
+
toolName: VIRTUAL_TOOL_SUFFIXES.resourceRead,
|
|
220
|
+
serverName,
|
|
221
|
+
description: `Read the contents of an MCP resource from the "${serverName}" server by its URI.`,
|
|
222
|
+
inputSchema: {
|
|
223
|
+
type: 'object',
|
|
224
|
+
properties: { uri: { type: 'string', description: 'Resource URI (use resource_list to discover available URIs).' } },
|
|
225
|
+
required: ['uri'],
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
catch { /* server doesn't support resources — skip */ }
|
|
231
|
+
// Prompts
|
|
232
|
+
try {
|
|
233
|
+
const prompts = await client.listPrompts();
|
|
234
|
+
if (prompts.length > 0) {
|
|
235
|
+
out.push({
|
|
236
|
+
agentName: `${serverName}${NAMESPACE_DELIM}${VIRTUAL_TOOL_SUFFIXES.promptList}`,
|
|
237
|
+
toolName: VIRTUAL_TOOL_SUFFIXES.promptList,
|
|
238
|
+
serverName,
|
|
239
|
+
description: `List prompt templates exposed by the "${serverName}" MCP server.`,
|
|
240
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
241
|
+
});
|
|
242
|
+
out.push({
|
|
243
|
+
agentName: `${serverName}${NAMESPACE_DELIM}${VIRTUAL_TOOL_SUFFIXES.promptGet}`,
|
|
244
|
+
toolName: VIRTUAL_TOOL_SUFFIXES.promptGet,
|
|
245
|
+
serverName,
|
|
246
|
+
description: `Materialise an MCP prompt template from the "${serverName}" server.`,
|
|
247
|
+
inputSchema: {
|
|
248
|
+
type: 'object',
|
|
249
|
+
properties: {
|
|
250
|
+
name: { type: 'string', description: 'Prompt name (use prompt_list to discover).' },
|
|
251
|
+
arguments: { type: 'object', additionalProperties: true, description: 'Key/value arguments per the prompt template.' },
|
|
252
|
+
},
|
|
253
|
+
required: ['name'],
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
catch { /* server doesn't support prompts — skip */ }
|
|
259
|
+
}
|
|
260
|
+
return out;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Dispatch a virtual MCP tool (resource_list, resource_read, prompt_list,
|
|
264
|
+
* prompt_get) and return a JSON-serialised string the agent can consume.
|
|
265
|
+
* Throws if the tool name doesn't match a virtual wrapper.
|
|
266
|
+
*/
|
|
267
|
+
export async function callSessionVirtualTool(sessionId, agentName, args) {
|
|
268
|
+
const clients = sessionClients.get(sessionId);
|
|
269
|
+
if (!clients?.length)
|
|
270
|
+
throw new Error(`No MCP servers registered for session ${sessionId}`);
|
|
271
|
+
const idx = agentName.indexOf(NAMESPACE_DELIM);
|
|
272
|
+
if (idx < 0)
|
|
273
|
+
throw new Error(`"${agentName}" is not a namespaced MCP tool`);
|
|
274
|
+
const serverName = agentName.slice(0, idx);
|
|
275
|
+
const suffix = agentName.slice(idx + NAMESPACE_DELIM.length);
|
|
276
|
+
const client = clients.find(c => c.server.name === serverName);
|
|
277
|
+
if (!client)
|
|
278
|
+
throw new Error(`No MCP server named "${serverName}" in this session`);
|
|
279
|
+
if (suffix === VIRTUAL_TOOL_SUFFIXES.resourceList) {
|
|
280
|
+
const resources = await client.listResources();
|
|
281
|
+
return JSON.stringify(resources, null, 2);
|
|
282
|
+
}
|
|
283
|
+
if (suffix === VIRTUAL_TOOL_SUFFIXES.resourceRead) {
|
|
284
|
+
const uri = String(args.uri ?? '');
|
|
285
|
+
if (!uri)
|
|
286
|
+
throw new Error('resource_read requires a `uri` argument');
|
|
287
|
+
const contents = await client.readResource(uri);
|
|
288
|
+
// Inline text content directly; surface blob with a placeholder so the
|
|
289
|
+
// model knows there was binary data without us blowing up the context.
|
|
290
|
+
return contents.map(c => c.text ?? `[binary ${c.mimeType ?? 'blob'}, ${c.blob?.length ?? 0} chars]`).join('\n\n');
|
|
291
|
+
}
|
|
292
|
+
if (suffix === VIRTUAL_TOOL_SUFFIXES.promptList) {
|
|
293
|
+
const prompts = await client.listPrompts();
|
|
294
|
+
return JSON.stringify(prompts, null, 2);
|
|
295
|
+
}
|
|
296
|
+
if (suffix === VIRTUAL_TOOL_SUFFIXES.promptGet) {
|
|
297
|
+
const name = String(args.name ?? '');
|
|
298
|
+
if (!name)
|
|
299
|
+
throw new Error('prompt_get requires a `name` argument');
|
|
300
|
+
const promptArgs = (args.arguments && typeof args.arguments === 'object') ? args.arguments : {};
|
|
301
|
+
const result = await client.getPrompt(name, promptArgs);
|
|
302
|
+
return JSON.stringify(result, null, 2);
|
|
303
|
+
}
|
|
304
|
+
throw new Error(`Unknown virtual MCP tool suffix: ${suffix}`);
|
|
305
|
+
}
|
|
306
|
+
/** Return true if a tool name matches one of the four virtual wrappers. */
|
|
307
|
+
export function isVirtualMcpToolName(name) {
|
|
308
|
+
const idx = name.indexOf(NAMESPACE_DELIM);
|
|
309
|
+
if (idx < 0)
|
|
310
|
+
return false;
|
|
311
|
+
const suffix = name.slice(idx + NAMESPACE_DELIM.length);
|
|
312
|
+
return Object.values(VIRTUAL_TOOL_SUFFIXES).includes(suffix);
|
|
313
|
+
}
|
|
314
|
+
/** Return resources advertised by all session servers, grouped by server. */
|
|
315
|
+
export async function getSessionResources(sessionId) {
|
|
316
|
+
const clients = sessionClients.get(sessionId);
|
|
317
|
+
if (!clients?.length)
|
|
318
|
+
return [];
|
|
319
|
+
const out = [];
|
|
320
|
+
for (const client of clients) {
|
|
321
|
+
try {
|
|
322
|
+
const resources = await client.listResources();
|
|
323
|
+
if (resources.length > 0)
|
|
324
|
+
out.push({ serverName: client.server.name, resources });
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
// Per-server failures should not poison the whole list.
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return out;
|
|
331
|
+
}
|
|
332
|
+
/** Read a resource URI from any session server that advertises it. */
|
|
333
|
+
export async function readSessionResource(sessionId, uri) {
|
|
334
|
+
const clients = sessionClients.get(sessionId);
|
|
335
|
+
if (!clients?.length)
|
|
336
|
+
throw new Error(`No MCP servers registered for session ${sessionId}`);
|
|
337
|
+
// Try each server in registration order; first success wins. Most URI
|
|
338
|
+
// schemes are server-specific so this rarely needs more than one try.
|
|
339
|
+
let lastError = null;
|
|
340
|
+
for (const client of clients) {
|
|
341
|
+
try {
|
|
342
|
+
const contents = await client.readResource(uri);
|
|
343
|
+
if (contents.length > 0)
|
|
344
|
+
return contents;
|
|
345
|
+
}
|
|
346
|
+
catch (err) {
|
|
347
|
+
lastError = err;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
throw lastError ?? new Error(`No MCP server returned content for ${uri}`);
|
|
351
|
+
}
|
|
352
|
+
/** Return prompts advertised by all session servers, grouped by server. */
|
|
353
|
+
export async function getSessionPrompts(sessionId) {
|
|
354
|
+
const clients = sessionClients.get(sessionId);
|
|
355
|
+
if (!clients?.length)
|
|
356
|
+
return [];
|
|
357
|
+
const out = [];
|
|
358
|
+
for (const client of clients) {
|
|
359
|
+
try {
|
|
360
|
+
const prompts = await client.listPrompts();
|
|
361
|
+
if (prompts.length > 0)
|
|
362
|
+
out.push({ serverName: client.server.name, prompts });
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
// Skip servers that don't expose prompts.
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return out;
|
|
369
|
+
}
|
|
370
|
+
/** Resolve a `<server>__<name>` prompt reference, returning the materialised messages. */
|
|
371
|
+
export async function getSessionPrompt(sessionId, serverName, name, args = {}) {
|
|
372
|
+
const clients = sessionClients.get(sessionId);
|
|
373
|
+
if (!clients?.length)
|
|
374
|
+
throw new Error(`No MCP servers registered for session ${sessionId}`);
|
|
375
|
+
const client = clients.find(c => c.server.name === serverName);
|
|
376
|
+
if (!client)
|
|
377
|
+
throw new Error(`No MCP server named "${serverName}" in this session`);
|
|
378
|
+
return client.getPrompt(name, args);
|
|
379
|
+
}
|
|
380
|
+
/** Forward a tool call to the right MCP server. Returns the tool's text output. */
|
|
381
|
+
export async function callSessionTool(sessionId, agentName, args) {
|
|
382
|
+
// Wait for any pending session registration before checking the registry.
|
|
383
|
+
// Avoids the "spawn race" where the agent fires a tool call between
|
|
384
|
+
// session/new returning and the MCP server child processes finishing
|
|
385
|
+
// their initialize handshake.
|
|
386
|
+
await awaitSessionReady(sessionId);
|
|
387
|
+
const clients = sessionClients.get(sessionId);
|
|
388
|
+
if (!clients?.length)
|
|
389
|
+
throw new Error(`No MCP servers registered for session ${sessionId}`);
|
|
390
|
+
const idx = agentName.indexOf(NAMESPACE_DELIM);
|
|
391
|
+
if (idx < 0)
|
|
392
|
+
throw new Error(`Tool name "${agentName}" is not in the expected "server__tool" form`);
|
|
393
|
+
const serverName = agentName.slice(0, idx);
|
|
394
|
+
const toolName = agentName.slice(idx + NAMESPACE_DELIM.length);
|
|
395
|
+
const client = clients.find(c => c.server.name === serverName);
|
|
396
|
+
if (!client)
|
|
397
|
+
throw new Error(`No MCP server named "${serverName}" in this session`);
|
|
398
|
+
return client.callTool(toolName, args);
|
|
399
|
+
}
|
|
400
|
+
/** True when an agent tool name looks like an MCP-prefixed tool. */
|
|
401
|
+
export function isMcpToolName(name) {
|
|
402
|
+
return name.includes(NAMESPACE_DELIM);
|
|
403
|
+
}
|
|
404
|
+
/** Stop all MCP clients for a session and drop the registry entry. */
|
|
405
|
+
export async function disposeSession(sessionId) {
|
|
406
|
+
sessionErrors.delete(sessionId);
|
|
407
|
+
sessionCatalogDirty.delete(sessionId);
|
|
408
|
+
const clients = sessionClients.get(sessionId);
|
|
409
|
+
if (!clients?.length) {
|
|
410
|
+
sessionClients.delete(sessionId);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
await Promise.all(clients.map(c => c.stop().catch(() => { })));
|
|
414
|
+
sessionClients.delete(sessionId);
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Tear down every active MCP session. Wired up as a SIGINT/SIGTERM handler
|
|
418
|
+
* in `acp/server.ts` so killing the CLI doesn't leave orphan child processes.
|
|
419
|
+
*/
|
|
420
|
+
export async function disposeAllSessions() {
|
|
421
|
+
const ids = [...sessionClients.keys()];
|
|
422
|
+
await Promise.all(ids.map(id => disposeSession(id)));
|
|
423
|
+
}
|
|
424
|
+
/** For tests / debugging — how many sessions have active MCP clients. */
|
|
425
|
+
export function sessionCount() {
|
|
426
|
+
return sessionClients.size;
|
|
427
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridges MCP `sampling/createMessage` server requests to Codeep's host LLM.
|
|
3
|
+
*
|
|
4
|
+
* MCP servers that opt into the `sampling` capability send the host
|
|
5
|
+
* (Codeep) a request to generate a completion on their behalf — usually
|
|
6
|
+
* because they want LLM reasoning without their own provider keys. Per
|
|
7
|
+
* spec, the host is free to refuse, swap models, or strip context; we
|
|
8
|
+
* forward the messages to the active provider via `chat()` and return
|
|
9
|
+
* just the assistant text.
|
|
10
|
+
*
|
|
11
|
+
* Notes on the bridge surface:
|
|
12
|
+
* - We strip image content (provider matrix varies; safer to skip than
|
|
13
|
+
* surprise the model). A future iteration can route images through
|
|
14
|
+
* the vision integration in mcpIntegration.ts.
|
|
15
|
+
* - We respect `params.modelPreferences.hints[].name` as an *advisory*
|
|
16
|
+
* model override — only if the user has the provider for it
|
|
17
|
+
* configured; otherwise we stay on the active model.
|
|
18
|
+
* - We honour `temperature`, `maxTokens`, `stopSequences` only where
|
|
19
|
+
* the underlying chat() path supports them (today: none — chat() uses
|
|
20
|
+
* the agent's configured temperature/maxTokens). Pass-through hooks
|
|
21
|
+
* are wired so the spec contract is honoured if/when chat() grows
|
|
22
|
+
* those knobs.
|
|
23
|
+
*
|
|
24
|
+
* Cost guard: every sampling request bills the user's active provider, so
|
|
25
|
+
* a misbehaving server can drain credits. We enforce a per-server rate
|
|
26
|
+
* limit (≥1 s spacing) and a per-process cap, and surface every accepted
|
|
27
|
+
* request on stderr so the user can see what's happening.
|
|
28
|
+
*/
|
|
29
|
+
import type { SamplingCreateMessageParams, SamplingCreateMessageResult } from './mcpClient.js';
|
|
30
|
+
/** Reset the per-server counters. Called on session boundaries. */
|
|
31
|
+
export declare function resetSamplingBudget(): void;
|
|
32
|
+
export declare function handleMcpSamplingRequest(params: SamplingCreateMessageParams, serverName?: string): Promise<SamplingCreateMessageResult>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridges MCP `sampling/createMessage` server requests to Codeep's host LLM.
|
|
3
|
+
*
|
|
4
|
+
* MCP servers that opt into the `sampling` capability send the host
|
|
5
|
+
* (Codeep) a request to generate a completion on their behalf — usually
|
|
6
|
+
* because they want LLM reasoning without their own provider keys. Per
|
|
7
|
+
* spec, the host is free to refuse, swap models, or strip context; we
|
|
8
|
+
* forward the messages to the active provider via `chat()` and return
|
|
9
|
+
* just the assistant text.
|
|
10
|
+
*
|
|
11
|
+
* Notes on the bridge surface:
|
|
12
|
+
* - We strip image content (provider matrix varies; safer to skip than
|
|
13
|
+
* surprise the model). A future iteration can route images through
|
|
14
|
+
* the vision integration in mcpIntegration.ts.
|
|
15
|
+
* - We respect `params.modelPreferences.hints[].name` as an *advisory*
|
|
16
|
+
* model override — only if the user has the provider for it
|
|
17
|
+
* configured; otherwise we stay on the active model.
|
|
18
|
+
* - We honour `temperature`, `maxTokens`, `stopSequences` only where
|
|
19
|
+
* the underlying chat() path supports them (today: none — chat() uses
|
|
20
|
+
* the agent's configured temperature/maxTokens). Pass-through hooks
|
|
21
|
+
* are wired so the spec contract is honoured if/when chat() grows
|
|
22
|
+
* those knobs.
|
|
23
|
+
*
|
|
24
|
+
* Cost guard: every sampling request bills the user's active provider, so
|
|
25
|
+
* a misbehaving server can drain credits. We enforce a per-server rate
|
|
26
|
+
* limit (≥1 s spacing) and a per-process cap, and surface every accepted
|
|
27
|
+
* request on stderr so the user can see what's happening.
|
|
28
|
+
*/
|
|
29
|
+
const MIN_INTERVAL_MS = 1000;
|
|
30
|
+
const MAX_PER_SERVER = 100;
|
|
31
|
+
const lastRequestAt = new Map();
|
|
32
|
+
const requestCount = new Map();
|
|
33
|
+
/** Reset the per-server counters. Called on session boundaries. */
|
|
34
|
+
export function resetSamplingBudget() {
|
|
35
|
+
lastRequestAt.clear();
|
|
36
|
+
requestCount.clear();
|
|
37
|
+
}
|
|
38
|
+
export async function handleMcpSamplingRequest(params, serverName = 'unknown') {
|
|
39
|
+
const now = Date.now();
|
|
40
|
+
const count = (requestCount.get(serverName) ?? 0) + 1;
|
|
41
|
+
if (count > MAX_PER_SERVER) {
|
|
42
|
+
process.stderr.write(`[codeep] mcp:${serverName} sampling refused — exceeded ${MAX_PER_SERVER}/process cap; restart codeep to reset\n`);
|
|
43
|
+
throw new Error(`sampling budget exceeded for "${serverName}" (${MAX_PER_SERVER}/process)`);
|
|
44
|
+
}
|
|
45
|
+
const last = lastRequestAt.get(serverName) ?? 0;
|
|
46
|
+
if (now - last < MIN_INTERVAL_MS) {
|
|
47
|
+
process.stderr.write(`[codeep] mcp:${serverName} sampling refused — exceeds 1/sec rate limit\n`);
|
|
48
|
+
throw new Error(`sampling rate limit for "${serverName}" (max 1/sec)`);
|
|
49
|
+
}
|
|
50
|
+
lastRequestAt.set(serverName, now);
|
|
51
|
+
requestCount.set(serverName, count);
|
|
52
|
+
process.stderr.write(`[codeep] mcp:${serverName} sampling/createMessage (${count}/${MAX_PER_SERVER} this session)\n`);
|
|
53
|
+
// Collapse text-content messages into a normalised Message[] for chat().
|
|
54
|
+
// Images dropped per the surface note above.
|
|
55
|
+
const history = [];
|
|
56
|
+
let lastUserText = '';
|
|
57
|
+
for (const m of params.messages) {
|
|
58
|
+
if (m.content.type !== 'text')
|
|
59
|
+
continue;
|
|
60
|
+
if (m.role === 'assistant') {
|
|
61
|
+
history.push({ role: 'assistant', content: m.content.text });
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
history.push({ role: 'user', content: m.content.text });
|
|
65
|
+
lastUserText = m.content.text;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// chat() takes (message, history, ...) — last user turn becomes the
|
|
69
|
+
// "message" arg and the rest becomes the prior history.
|
|
70
|
+
const message = lastUserText || (history[history.length - 1]?.content ?? '');
|
|
71
|
+
const prior = history.slice(0, -1);
|
|
72
|
+
// System prompt: server-provided overrides our agent default for this
|
|
73
|
+
// single call. We don't wire it through chat() (no parameter slot), so
|
|
74
|
+
// we prepend a synthetic system turn to history. chat() collapses
|
|
75
|
+
// duplicate system messages, so this is safe.
|
|
76
|
+
if (params.systemPrompt) {
|
|
77
|
+
prior.unshift({ role: 'system', content: params.systemPrompt });
|
|
78
|
+
}
|
|
79
|
+
const { chat } = await import('../api/index.js');
|
|
80
|
+
const { config } = await import('../config/index.js');
|
|
81
|
+
const text = await chat(message, prior);
|
|
82
|
+
return {
|
|
83
|
+
role: 'assistant',
|
|
84
|
+
content: { type: 'text', text },
|
|
85
|
+
model: config.get('model'),
|
|
86
|
+
stopReason: 'endTurn',
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Streamable HTTP transport — the spec successor to the original
|
|
3
|
+
* HTTP+SSE transport, used by cloud-hosted MCP servers (Anthropic remote
|
|
4
|
+
* servers, internal HTTP wrappers, etc.).
|
|
5
|
+
*
|
|
6
|
+
* Per the 2025-03 spec, a single URL endpoint accepts both:
|
|
7
|
+
* - POST { jsonrpc, id, method, params } → JSON response, or
|
|
8
|
+
* text/event-stream of one-or-more JSON-RPC frames.
|
|
9
|
+
* - GET → text/event-stream channel
|
|
10
|
+
* for server-initiated notifications and requests (sampling, etc.).
|
|
11
|
+
*
|
|
12
|
+
* This client opens the GET stream lazily on the first message the server
|
|
13
|
+
* tells us to expect — many simple HTTP servers never send anything
|
|
14
|
+
* unsolicited, so we don't burn a TCP connection waiting.
|
|
15
|
+
*
|
|
16
|
+
* Session continuity uses the `mcp-session-id` header. The server sets it
|
|
17
|
+
* on the initialize response; we echo it on every subsequent request.
|
|
18
|
+
*/
|
|
19
|
+
export interface StreamableHttpOptions {
|
|
20
|
+
/** Endpoint URL of the MCP server. */
|
|
21
|
+
url: string;
|
|
22
|
+
/** Optional headers (Authorization, custom auth, etc.). */
|
|
23
|
+
headers?: Record<string, string>;
|
|
24
|
+
/** Called for every JSON-RPC frame the server sends, from either channel. */
|
|
25
|
+
onFrame: (msg: unknown) => void;
|
|
26
|
+
/** Called when the server's notification stream errors or closes unexpectedly. */
|
|
27
|
+
onError?: (err: Error) => void;
|
|
28
|
+
}
|
|
29
|
+
export declare class StreamableHttpClient {
|
|
30
|
+
private readonly opts;
|
|
31
|
+
private sessionId;
|
|
32
|
+
private notificationAbort;
|
|
33
|
+
private stopped;
|
|
34
|
+
/** True after the server has set a session id (i.e. it tracks state). */
|
|
35
|
+
private get hasServerSession();
|
|
36
|
+
constructor(opts: StreamableHttpOptions);
|
|
37
|
+
/**
|
|
38
|
+
* Issue a JSON-RPC frame as POST. Reply may be a single JSON response
|
|
39
|
+
* (synchronous tools/call), or an SSE stream of one-or-more responses
|
|
40
|
+
* + notifications. The transport invokes `onFrame` for every message
|
|
41
|
+
* it sees on the response, regardless of shape.
|
|
42
|
+
*/
|
|
43
|
+
send(frame: object): Promise<void>;
|
|
44
|
+
/**
|
|
45
|
+
* Open the server-push SSE channel. Idempotent — won't open twice if
|
|
46
|
+
* already streaming. Errors are surfaced via `onError`, not thrown, so
|
|
47
|
+
* a transient network blip doesn't crash the agent loop.
|
|
48
|
+
*/
|
|
49
|
+
private openNotificationStream;
|
|
50
|
+
/**
|
|
51
|
+
* Parse a `text/event-stream` body, invoking `onFrame` for each JSON
|
|
52
|
+
* payload. SSE framing is intentionally permissive — we treat any line
|
|
53
|
+
* starting with `data:` as one event's payload and join multi-line
|
|
54
|
+
* data: blocks until a blank line.
|
|
55
|
+
*
|
|
56
|
+
* Bounded by `MAX_SSE_BYTES`: a misbehaving or malicious remote server
|
|
57
|
+
* can push unbounded data on an SSE channel — without a cap, the
|
|
58
|
+
* accumulating buffer would OOM the agent. We track cumulative bytes
|
|
59
|
+
* read and bail with `onError` past the cap.
|
|
60
|
+
*/
|
|
61
|
+
private consumeSseBody;
|
|
62
|
+
private dispatchSseEvent;
|
|
63
|
+
/** Tear down the notification stream and refuse further sends. */
|
|
64
|
+
stop(): Promise<void>;
|
|
65
|
+
}
|