dsh-agy-cli 0.0.0-stage → 0.12.1
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 +31 -0
- package/README.en.md +48 -0
- package/README.md +67 -2
- package/agents/dsh-agy-image-view.md +17 -0
- package/agents/dsh-agy-mcp-executor.md +32 -0
- package/agents/dsh-agy-read-only.md +20 -0
- package/agents/dsh-agy-tool-free.md +16 -0
- package/agents/dsh-agy-workspace-write.md +23 -0
- package/bin/dsh-agy-cli.js +9 -0
- package/bin/win32-x64/agy-launcher.exe +0 -0
- package/cordis.patch.yml +16 -0
- package/dist/client.js +4259 -0
- package/lib/agent-installer.d.ts +66 -0
- package/lib/agent-installer.js +147 -0
- package/lib/agent-presets.d.ts +42 -0
- package/lib/agent-presets.js +130 -0
- package/lib/agents-cli.d.ts +3 -0
- package/lib/agents-cli.js +110 -0
- package/lib/agy/diagnostics.d.ts +29 -0
- package/lib/agy/diagnostics.js +109 -0
- package/lib/agy/errors.d.ts +20 -0
- package/lib/agy/errors.js +77 -0
- package/lib/agy/experimental-transport.d.ts +79 -0
- package/lib/agy/experimental-transport.js +612 -0
- package/lib/agy/keepalive.d.ts +140 -0
- package/lib/agy/keepalive.js +445 -0
- package/lib/agy/limiter.d.ts +32 -0
- package/lib/agy/limiter.js +111 -0
- package/lib/agy/log.d.ts +214 -0
- package/lib/agy/log.js +503 -0
- package/lib/agy/models.d.ts +49 -0
- package/lib/agy/models.js +203 -0
- package/lib/agy/overrides.d.ts +17 -0
- package/lib/agy/overrides.js +73 -0
- package/lib/agy/parser.d.ts +102 -0
- package/lib/agy/parser.js +492 -0
- package/lib/agy/persistent-transport.d.ts +101 -0
- package/lib/agy/persistent-transport.js +753 -0
- package/lib/agy/process.d.ts +144 -0
- package/lib/agy/process.js +452 -0
- package/lib/agy/redact.d.ts +3 -0
- package/lib/agy/redact.js +24 -0
- package/lib/agy/stream-protocol.d.ts +50 -0
- package/lib/agy/stream-protocol.js +156 -0
- package/lib/agy/windows-launcher.d.ts +21 -0
- package/lib/agy/windows-launcher.js +76 -0
- package/lib/diagnostics.d.ts +78 -0
- package/lib/diagnostics.js +162 -0
- package/lib/doctor.d.ts +138 -0
- package/lib/doctor.js +940 -0
- package/lib/dsh/context.d.ts +133 -0
- package/lib/dsh/context.js +352 -0
- package/lib/dsh-home.d.ts +6 -0
- package/lib/dsh-home.js +11 -0
- package/lib/index.d.ts +54 -0
- package/lib/index.js +267 -0
- package/lib/mcp/agy-registration.d.ts +70 -0
- package/lib/mcp/agy-registration.js +245 -0
- package/lib/mcp/bridge.d.ts +73 -0
- package/lib/mcp/bridge.js +295 -0
- package/lib/mcp/endpoint.d.ts +41 -0
- package/lib/mcp/endpoint.js +142 -0
- package/lib/mcp/protocol.d.ts +107 -0
- package/lib/mcp/protocol.js +197 -0
- package/lib/mcp/tool-projection.d.ts +64 -0
- package/lib/mcp/tool-projection.js +168 -0
- package/lib/provider/agy.d.ts +153 -0
- package/lib/provider/agy.js +2159 -0
- package/lib/provider/config.d.ts +226 -0
- package/lib/provider/config.js +294 -0
- package/lib/provider/error-codes.d.ts +22 -0
- package/lib/provider/error-codes.js +29 -0
- package/lib/provider/image-bridge.d.ts +60 -0
- package/lib/provider/image-bridge.js +290 -0
- package/lib/provider/mock.d.ts +15 -0
- package/lib/provider/mock.js +78 -0
- package/lib/provider/prompt-budget.d.ts +45 -0
- package/lib/provider/prompt-budget.js +596 -0
- package/lib/provider/serialize.d.ts +15 -0
- package/lib/provider/serialize.js +82 -0
- package/lib/provider/tool-protocol.d.ts +80 -0
- package/lib/provider/tool-protocol.js +848 -0
- package/lib/provider/tool-schema-file.d.ts +11 -0
- package/lib/provider/tool-schema-file.js +27 -0
- package/lib/session/file-store.d.ts +56 -0
- package/lib/session/file-store.js +186 -0
- package/lib/session/store.d.ts +49 -0
- package/lib/session/store.js +62 -0
- package/lib/web/router.d.ts +19 -0
- package/lib/web/router.js +994 -0
- package/package.json +115 -4
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The runtime half of the MCP bridge.
|
|
3
|
+
*
|
|
4
|
+
* `agy-registration.ts` owns the AGY config-file side (write on activate,
|
|
5
|
+
* remove on dispose); this module owns the live half: the projected tool set,
|
|
6
|
+
* the loopback HTTP endpoint on the host's `webServer` carrier, and the
|
|
7
|
+
* `tools/call` round-trip back into the DSH tool pipeline.
|
|
8
|
+
*
|
|
9
|
+
* One deliberate asymmetry: listing tools is dynamic (the DSH registry can
|
|
10
|
+
* change between turns), while execution always goes through
|
|
11
|
+
* `tools.execute()` so the host's guard chain and approval service see every
|
|
12
|
+
* call. The call carries the owning session's live Agent, resolved through
|
|
13
|
+
* `sessionController.resolveAgent()`, because the DSH approval service denies
|
|
14
|
+
* any call that has no agent to route through.
|
|
15
|
+
*/
|
|
16
|
+
import { randomUUID } from 'node:crypto';
|
|
17
|
+
import { mountMcpEndpoint } from './endpoint.js';
|
|
18
|
+
import { agyConfigRoot, agySettingsRoot, registerAgyMcpServer, unregisterAgyMcpServer, AGY_MCP_SERVER_NAME, } from './agy-registration.js';
|
|
19
|
+
import { projectDshTools, validateToolArguments, NEVER_PROJECTED_TOOLS, } from './tool-projection.js';
|
|
20
|
+
const INSTRUCTIONS = [
|
|
21
|
+
'These tools are executed by the DSH host, not by this environment.',
|
|
22
|
+
'Call a tool by its name; the DSH host validates arguments, applies its own',
|
|
23
|
+
'permission policy, and returns the result. A result with isError=true means',
|
|
24
|
+
'the host refused or failed the call — report it instead of retrying blindly.',
|
|
25
|
+
].join(' ');
|
|
26
|
+
function textOf(block) {
|
|
27
|
+
if (typeof block !== 'object' || block === null)
|
|
28
|
+
return undefined;
|
|
29
|
+
const record = block;
|
|
30
|
+
if (record.type !== 'text' || typeof record.text !== 'string')
|
|
31
|
+
return undefined;
|
|
32
|
+
return record.text;
|
|
33
|
+
}
|
|
34
|
+
function resultToCallResult(result) {
|
|
35
|
+
if (result.isError) {
|
|
36
|
+
const message = typeof result.error?.message === 'string' ? result.error.message : 'tool execution failed';
|
|
37
|
+
const detail = Array.isArray(result.content)
|
|
38
|
+
? result.content.map(textOf).filter((part) => part !== undefined).join('\n')
|
|
39
|
+
: '';
|
|
40
|
+
return { content: [{ type: 'text', text: detail.length > 0 ? `${message}\n${detail}` : message }], isError: true };
|
|
41
|
+
}
|
|
42
|
+
const blocks = [];
|
|
43
|
+
if (Array.isArray(result.content)) {
|
|
44
|
+
for (const block of result.content) {
|
|
45
|
+
const text = textOf(block);
|
|
46
|
+
blocks.push({ type: 'text', text: text ?? JSON.stringify(block) });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (blocks.length === 0) {
|
|
50
|
+
blocks.push({ type: 'text', text: result.value === undefined ? '' : JSON.stringify(result.value) });
|
|
51
|
+
}
|
|
52
|
+
return { content: blocks, isError: false };
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Bring the bridge up. Every failure mode resolves to `active: false` with a
|
|
56
|
+
* reason instead of throwing, so the provider can fall back to the prompt
|
|
57
|
+
* contract path when the carrier or the tool registry is not available.
|
|
58
|
+
*/
|
|
59
|
+
export async function startDshMcpBridge(options) {
|
|
60
|
+
const inactive = (reason) => {
|
|
61
|
+
options.logger?.(`dsh mcp bridge inactive: ${reason}`);
|
|
62
|
+
return {
|
|
63
|
+
active: false,
|
|
64
|
+
inactiveReason: reason,
|
|
65
|
+
endpoint: undefined,
|
|
66
|
+
url: undefined,
|
|
67
|
+
setSession: () => { },
|
|
68
|
+
dispose: async () => { },
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
if (options.webServer === undefined || typeof options.webServer.register !== 'function') {
|
|
72
|
+
return inactive('host webServer service is unavailable');
|
|
73
|
+
}
|
|
74
|
+
if (typeof options.tools?.schemas !== 'function' || typeof options.tools?.execute !== 'function') {
|
|
75
|
+
return inactive('host tools service is unavailable');
|
|
76
|
+
}
|
|
77
|
+
let endpoint;
|
|
78
|
+
try {
|
|
79
|
+
// Project lazily on every `tools/list`: the bridge starts during plugin
|
|
80
|
+
// load, but the file, shell, and web tools only join `ctx.tools` once the
|
|
81
|
+
// agent presets and the remaining services finish mounting. Snapshotting
|
|
82
|
+
// here froze a host that had 18 tools and never exposed the rest.
|
|
83
|
+
if (projectDshTools(options.tools, {
|
|
84
|
+
...(options.toolAllowlist === undefined ? {} : { allowlist: options.toolAllowlist }),
|
|
85
|
+
}).tools.length === 0) {
|
|
86
|
+
return inactive('host tools service exposed no tools');
|
|
87
|
+
}
|
|
88
|
+
endpoint = mountMcpEndpoint(options.webServer, {
|
|
89
|
+
serverName: AGY_MCP_SERVER_NAME,
|
|
90
|
+
serverVersion: options.serverVersion,
|
|
91
|
+
instructions: INSTRUCTIONS,
|
|
92
|
+
listTools: async (sessionHandle) => {
|
|
93
|
+
const agent = await agentOf(options, sessionHandle);
|
|
94
|
+
const projection = projectVisibleTools(options, agent);
|
|
95
|
+
const dropped = projection.excluded.filter(name => !NEVER_PROJECTED_TOOLS.has(name));
|
|
96
|
+
options.logger?.(`dsh mcp bridge: listTools session="${sessionHandle}" agent=${agent === undefined ? 'none' : 'resolved'} tools=${projection.tools.length}${dropped.length === 0 ? '' : ` dropped=${dropped.join(',')}`}`);
|
|
97
|
+
return projection.tools;
|
|
98
|
+
},
|
|
99
|
+
callTool: (sessionHandle, name, args) => executeDshTool(options, sessionHandle, name, args),
|
|
100
|
+
...(options.logger === undefined ? {} : { logger: options.logger }),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
return inactive(`endpoint mount failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
105
|
+
}
|
|
106
|
+
if (endpoint.url === undefined) {
|
|
107
|
+
endpoint.dispose();
|
|
108
|
+
return inactive('host webServer has not reported a listening port');
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
await registerAgyMcpServer({
|
|
112
|
+
url: endpoint.url,
|
|
113
|
+
token: endpoint.token,
|
|
114
|
+
// No tool list travels here on purpose: the allow rule this bridge
|
|
115
|
+
// needs is the server-wide wildcard `mcp(dsh/*)`, so a DSH tool that
|
|
116
|
+
// appears later is covered the moment it shows up in `tools/list`
|
|
117
|
+
// without rewriting the AGY settings file. The per-turn projection in
|
|
118
|
+
// `listTools` is the scoped one that matters.
|
|
119
|
+
}, {
|
|
120
|
+
...(options.agyConfigRoot === undefined ? {} : { configRoot: options.agyConfigRoot }),
|
|
121
|
+
...(options.agySettingsRoot === undefined ? {} : { settingsRoot: options.agySettingsRoot }),
|
|
122
|
+
...(options.logger === undefined ? {} : { logger: options.logger }),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
endpoint.dispose();
|
|
127
|
+
return inactive(`AGY mcp_config.json update failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
active: true,
|
|
131
|
+
inactiveReason: undefined,
|
|
132
|
+
endpoint,
|
|
133
|
+
url: endpoint.url,
|
|
134
|
+
setSession: handle => endpoint.setSession(handle),
|
|
135
|
+
dispose: async () => {
|
|
136
|
+
endpoint.dispose();
|
|
137
|
+
await unregisterAgyMcpServer({
|
|
138
|
+
...(options.agyConfigRoot === undefined ? {} : { configRoot: options.agyConfigRoot }),
|
|
139
|
+
...(options.agySettingsRoot === undefined ? {} : { settingsRoot: options.agySettingsRoot }),
|
|
140
|
+
...(options.logger === undefined ? {} : { logger: options.logger }),
|
|
141
|
+
}).catch(() => { });
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Resolve the live Agent for the session this bridge currently serves.
|
|
147
|
+
*
|
|
148
|
+
* The adapter re-points the endpoint at the DSH session before every AGY turn
|
|
149
|
+
* (see the `setSession` call in the provider), and that handle survives across
|
|
150
|
+
* turns, so a direct lookup is enough. Getting the Agent matters twice over: the
|
|
151
|
+
* approval service denies any execution with no agent to route through, and the
|
|
152
|
+
* tool registry answers a *scoped* query with the agent-plane tools (file and
|
|
153
|
+
* shell) that its global view does not contain.
|
|
154
|
+
*
|
|
155
|
+
* A failure to resolve is reported through the bridge logger instead of being
|
|
156
|
+
* swallowed: "the session has no agent" and "the lookup threw" look identical
|
|
157
|
+
* from the outside, and that ambiguity is what made this bug cost a restart.
|
|
158
|
+
*/
|
|
159
|
+
async function agentOf(options, sessionHandle) {
|
|
160
|
+
// The lookup reads a Cordis service that a sibling bundle mounts later;
|
|
161
|
+
// cordis throws on property access while it is not mounted. Degrade to the
|
|
162
|
+
// static controller instead of failing every MCP request with a 500.
|
|
163
|
+
let controller;
|
|
164
|
+
try {
|
|
165
|
+
controller = options.sessionControllerLookup?.();
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
controller = undefined;
|
|
169
|
+
}
|
|
170
|
+
controller ??= options.sessionController;
|
|
171
|
+
if (controller === undefined) {
|
|
172
|
+
options.logger?.('dsh mcp bridge: the host sessionController service is unavailable');
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
if (sessionHandle.length === 0)
|
|
176
|
+
return undefined;
|
|
177
|
+
const resolved = await controller.resolveAgent(sessionHandle).catch((error) => {
|
|
178
|
+
const rejection = subagentRejectionIn(error);
|
|
179
|
+
options.logger?.(rejection === undefined
|
|
180
|
+
? `dsh mcp bridge: resolveAgent("${sessionHandle}") threw ${String(error)}`
|
|
181
|
+
: `dsh mcp bridge: session "${sessionHandle}" is owned by subagent routing (${rejection}); the host refuses an agent handle for a child session by design, so this turn sees the global tool view`);
|
|
182
|
+
return undefined;
|
|
183
|
+
});
|
|
184
|
+
const agent = agentIn(resolved);
|
|
185
|
+
if (agent === undefined) {
|
|
186
|
+
options.logger?.(`dsh mcp bridge: no agent resolved for session "${sessionHandle}"`);
|
|
187
|
+
}
|
|
188
|
+
return agent;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Read the host's subagent-ownership rejection out of a thrown value.
|
|
192
|
+
*
|
|
193
|
+
* `sessionController.resolveAgent()` answers `RemoteError('session/agent-busy')`
|
|
194
|
+
* with `reason: 'use subagent delivery for this child session'` for any session
|
|
195
|
+
* whose header carries `origin: 'subagent'` — the host deliberately refuses to
|
|
196
|
+
* hand out an agent handle for a child session. That is a designed boundary, not
|
|
197
|
+
* a bridge defect, so the log says so instead of leaving a bare
|
|
198
|
+
* "no agent resolved" line that reads like a bug every time a subagent runs.
|
|
199
|
+
*/
|
|
200
|
+
function subagentRejectionIn(error) {
|
|
201
|
+
if (typeof error !== 'object' || error === null)
|
|
202
|
+
return undefined;
|
|
203
|
+
const record = error;
|
|
204
|
+
const details = typeof record.details === 'object' && record.details !== null
|
|
205
|
+
? record.details
|
|
206
|
+
: undefined;
|
|
207
|
+
const reason = typeof details?.reason === 'string' ? details.reason : undefined;
|
|
208
|
+
if (reason !== undefined && /subagent delivery/i.test(reason))
|
|
209
|
+
return reason;
|
|
210
|
+
const code = typeof record.code === 'string' ? record.code : undefined;
|
|
211
|
+
const message = typeof record.message === 'string' ? record.message : undefined;
|
|
212
|
+
if (code === 'session/agent-busy' || (message !== undefined && /subagent routing/i.test(message))) {
|
|
213
|
+
return message ?? code ?? 'session/agent-busy';
|
|
214
|
+
}
|
|
215
|
+
return undefined;
|
|
216
|
+
}
|
|
217
|
+
function agentIn(resolved) {
|
|
218
|
+
if (resolved === undefined || resolved === null)
|
|
219
|
+
return undefined;
|
|
220
|
+
// The host's controller already unwraps: `sessionController.resolveAgent()`
|
|
221
|
+
// returns the Agent itself (it throws `ApiSessionAgentResult.error` instead of
|
|
222
|
+
// handing back a wrapper). Older and mocked shapes carry `{ agent }`, so accept
|
|
223
|
+
// both — but never mistake a thrown-error wrapper for a live agent.
|
|
224
|
+
if (typeof resolved === 'object') {
|
|
225
|
+
const record = resolved;
|
|
226
|
+
if ('agent' in record)
|
|
227
|
+
return record.agent;
|
|
228
|
+
// A rejection wrapper is not an agent; the host normally throws instead of
|
|
229
|
+
// returning one, but an older build may hand it back as a value.
|
|
230
|
+
if ('error' in record)
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
return resolved;
|
|
234
|
+
}
|
|
235
|
+
async function executeDshTool(options, sessionHandle, name, args) {
|
|
236
|
+
// Bridge-level gates first, independent of agent resolution: a tool the
|
|
237
|
+
// projection layer refuses to show must never execute through this bridge.
|
|
238
|
+
// Without this, a session whose agent could not be resolved (subagent
|
|
239
|
+
// routing, controller not mounted) degenerates to the global view, and a
|
|
240
|
+
// never-projected or allowlisted-out name would reach `tools.execute`
|
|
241
|
+
// directly — bypassing the promise tool-projection.ts makes.
|
|
242
|
+
if (NEVER_PROJECTED_TOOLS.has(name)) {
|
|
243
|
+
return { content: [{ type: 'text', text: `unknown tool "${name}"` }], isError: true };
|
|
244
|
+
}
|
|
245
|
+
if (options.toolAllowlist !== undefined && options.toolAllowlist.length > 0
|
|
246
|
+
&& !options.toolAllowlist.includes(name)) {
|
|
247
|
+
return { content: [{ type: 'text', text: `unknown tool "${name}"` }], isError: true };
|
|
248
|
+
}
|
|
249
|
+
// No resolvable agent means fail closed, never silently open: the call still
|
|
250
|
+
// goes through `tools.execute()` so the host guard chain sees it, but the host
|
|
251
|
+
// itself decides what an unscoped call may do.
|
|
252
|
+
const agent = await agentOf(options, sessionHandle);
|
|
253
|
+
const projected = projectVisibleTools(options, agent).tools;
|
|
254
|
+
const descriptor = projected.find(entry => entry.name === name);
|
|
255
|
+
if (descriptor === undefined) {
|
|
256
|
+
// Only assert "unknown tool" when the projection is the one AGY was shown.
|
|
257
|
+
// With no resolvable agent the scoped list degenerates to the global view,
|
|
258
|
+
// so a legitimate file tool can be missing from it; that call still has to
|
|
259
|
+
// reach the host, which denies it on its own terms instead of us inventing a
|
|
260
|
+
// rejection for a tool we merely failed to see.
|
|
261
|
+
if (agent !== undefined) {
|
|
262
|
+
return { content: [{ type: 'text', text: `unknown tool "${name}"` }], isError: true };
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
const problems = validateToolArguments(descriptor.inputSchema, args);
|
|
267
|
+
if (problems.length > 0) {
|
|
268
|
+
// Wording mirrors the host's own rejection so AGY sees one consistent
|
|
269
|
+
// failure shape whether the bridge or DSH catches the call.
|
|
270
|
+
return { content: [{ type: 'text', text: `invalid arguments: ${problems.join('; ')}` }], isError: true };
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const controller = new AbortController();
|
|
274
|
+
const result = await options.tools.execute({
|
|
275
|
+
callId: randomUUID(),
|
|
276
|
+
name,
|
|
277
|
+
arguments: args,
|
|
278
|
+
...(agent === undefined ? {} : { agent }),
|
|
279
|
+
signal: controller.signal,
|
|
280
|
+
});
|
|
281
|
+
return resultToCallResult(result);
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* The same projection `tools/list` advertises, used to pre-check a call.
|
|
285
|
+
* A registry failure yields an empty list, which reads as "unknown tool" —
|
|
286
|
+
* the correct fail-closed direction for a bridge that cannot see what it runs.
|
|
287
|
+
*/
|
|
288
|
+
function projectVisibleTools(options, agent) {
|
|
289
|
+
return projectDshTools(options.tools, {
|
|
290
|
+
...(options.toolAllowlist === undefined ? {} : { allowlist: options.toolAllowlist }),
|
|
291
|
+
scope: agent,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
export { AGY_MCP_SERVER_NAME, agyConfigRoot, agySettingsRoot };
|
|
295
|
+
//# sourceMappingURL=bridge.js.map
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { type McpCallResult, type McpToolDescriptor } from './protocol.js';
|
|
3
|
+
/** The host's HTTP carrier service (`ctx.webServer`). */
|
|
4
|
+
export interface DshWebServerLike {
|
|
5
|
+
/** The listening port; `undefined` before the carrier finishes listening. */
|
|
6
|
+
readonly port?: number;
|
|
7
|
+
readonly host?: string;
|
|
8
|
+
register(route: DshWebRoute): () => void;
|
|
9
|
+
}
|
|
10
|
+
export interface DshWebRoute {
|
|
11
|
+
readonly kind: 'exact' | 'prefix';
|
|
12
|
+
readonly path: string;
|
|
13
|
+
readonly handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
export interface McpEndpointOptions {
|
|
16
|
+
readonly serverName: string;
|
|
17
|
+
readonly serverVersion: string;
|
|
18
|
+
readonly instructions: string;
|
|
19
|
+
readonly listTools: (sessionId: string) => Promise<readonly McpToolDescriptor[]>;
|
|
20
|
+
readonly callTool: (sessionId: string, name: string, args: Record<string, unknown>) => Promise<McpCallResult>;
|
|
21
|
+
readonly logger?: (message: string) => void;
|
|
22
|
+
}
|
|
23
|
+
export interface McpEndpoint {
|
|
24
|
+
/** Full loopback URL including the secret path segment, or undefined if the
|
|
25
|
+
* carrier has not reported a port yet. */
|
|
26
|
+
readonly url: string | undefined;
|
|
27
|
+
/** Bearer token AGY must send. */
|
|
28
|
+
readonly token: string;
|
|
29
|
+
/** Point the endpoint at the DSH session the next AGY turn belongs to. */
|
|
30
|
+
readonly setSession: (sessionId: string) => void;
|
|
31
|
+
readonly dispose: () => void;
|
|
32
|
+
}
|
|
33
|
+
/** Loopback-only, shared with the settings panel routes. */
|
|
34
|
+
export declare function isLoopback(address: string | undefined): boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Claim one MCP route on the host carrier. The returned `dispose` releases the
|
|
37
|
+
* route; callers must invoke it from an effect so a plugin reload cannot leak
|
|
38
|
+
* the registration.
|
|
39
|
+
*/
|
|
40
|
+
export declare function mountMcpEndpoint(webServer: DshWebServerLike, options: McpEndpointOptions): McpEndpoint;
|
|
41
|
+
//# sourceMappingURL=endpoint.d.ts.map
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The MCP endpoint AGY connects to.
|
|
3
|
+
*
|
|
4
|
+
* Mounted on the host's existing HTTP carrier (`ctx.webServer`) rather than a
|
|
5
|
+
* private `node:http` listener: the carrier already owns the loopback bind, the
|
|
6
|
+
* port and the TLS story, and route registration is scoped to the plugin fiber
|
|
7
|
+
* so disabling the plugin removes the endpoint.
|
|
8
|
+
*
|
|
9
|
+
* Security model, in one place:
|
|
10
|
+
* - the route is claimed under a random path segment, so the URL itself is not
|
|
11
|
+
* guessable;
|
|
12
|
+
* - every request must carry the random bearer token minted at mount time;
|
|
13
|
+
* - only `127.0.0.1`/`::1` peers are served, so a forwarded or reverse-proxied
|
|
14
|
+
* port cannot reach the endpoint even if the token leaks;
|
|
15
|
+
* - only POST is served;
|
|
16
|
+
* - DSH tool execution still passes the normal tool pipeline, so the host's own
|
|
17
|
+
* approval and guard rules decide what actually runs.
|
|
18
|
+
*/
|
|
19
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
20
|
+
import { encodeMcpFrame, McpServerSession, } from './protocol.js';
|
|
21
|
+
const MAX_BODY_BYTES = 4 * 1024 * 1024;
|
|
22
|
+
/** Loopback-only, shared with the settings panel routes. */
|
|
23
|
+
export function isLoopback(address) {
|
|
24
|
+
if (address === undefined)
|
|
25
|
+
return false;
|
|
26
|
+
return address === '127.0.0.1'
|
|
27
|
+
|| address === '::1'
|
|
28
|
+
|| address === '::ffff:127.0.0.1';
|
|
29
|
+
}
|
|
30
|
+
function constantTimeEquals(left, right) {
|
|
31
|
+
const a = Buffer.from(left, 'utf8');
|
|
32
|
+
const b = Buffer.from(right, 'utf8');
|
|
33
|
+
if (a.length !== b.length)
|
|
34
|
+
return false;
|
|
35
|
+
return timingSafeEqual(a, b);
|
|
36
|
+
}
|
|
37
|
+
async function readBody(req) {
|
|
38
|
+
const chunks = [];
|
|
39
|
+
let total = 0;
|
|
40
|
+
for await (const chunk of req) {
|
|
41
|
+
const buffer = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk;
|
|
42
|
+
total += buffer.length;
|
|
43
|
+
if (total > MAX_BODY_BYTES)
|
|
44
|
+
throw new Error('MCP request body exceeds the bounded limit');
|
|
45
|
+
chunks.push(buffer);
|
|
46
|
+
}
|
|
47
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
48
|
+
}
|
|
49
|
+
function send(res, status, body, contentType) {
|
|
50
|
+
res.writeHead(status, {
|
|
51
|
+
'content-type': contentType,
|
|
52
|
+
'content-length': Buffer.byteLength(body, 'utf8'),
|
|
53
|
+
'cache-control': 'no-store',
|
|
54
|
+
});
|
|
55
|
+
res.end(body);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Claim one MCP route on the host carrier. The returned `dispose` releases the
|
|
59
|
+
* route; callers must invoke it from an effect so a plugin reload cannot leak
|
|
60
|
+
* the registration.
|
|
61
|
+
*/
|
|
62
|
+
export function mountMcpEndpoint(webServer, options) {
|
|
63
|
+
const token = randomBytes(32).toString('hex');
|
|
64
|
+
const routeSegment = randomBytes(16).toString('hex');
|
|
65
|
+
const path = `/api/dsh-agy-cli/mcp/${routeSegment}`;
|
|
66
|
+
// The AGY conversation this bridge currently serves. AGY sends its own
|
|
67
|
+
// session id when it has one; otherwise the transport handle is this value,
|
|
68
|
+
// which the adapter re-points at the DSH session before each AGY turn.
|
|
69
|
+
let sessionHandle = randomBytes(16).toString('hex');
|
|
70
|
+
const session = new McpServerSession({
|
|
71
|
+
serverName: options.serverName,
|
|
72
|
+
serverVersion: options.serverVersion,
|
|
73
|
+
instructions: options.instructions,
|
|
74
|
+
listTools: sessionHandle => options.listTools(sessionHandle),
|
|
75
|
+
callTool: (sessionHandle, name, args) => options.callTool(sessionHandle, name, args),
|
|
76
|
+
});
|
|
77
|
+
const handler = async (req, res) => {
|
|
78
|
+
try {
|
|
79
|
+
if (!isLoopback(req.socket?.remoteAddress)) {
|
|
80
|
+
send(res, 403, 'forbidden', 'text/plain; charset=utf-8');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (req.method !== 'POST') {
|
|
84
|
+
res.writeHead(405, { allow: 'POST', 'content-length': 0 });
|
|
85
|
+
res.end();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const authorization = req.headers.authorization ?? '';
|
|
89
|
+
const presented = authorization.startsWith('Bearer ') ? authorization.slice(7) : '';
|
|
90
|
+
if (!constantTimeEquals(presented, token)) {
|
|
91
|
+
send(res, 401, 'unauthorized', 'text/plain; charset=utf-8');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
// Snapshot the session handle before any await: setSession() re-points
|
|
95
|
+
// the closure variable when the next AGY turn starts, and a request
|
|
96
|
+
// suspended inside readBody must not wake up holding another session's
|
|
97
|
+
// agent scope.
|
|
98
|
+
const requestedSession = sessionHandle;
|
|
99
|
+
const body = await readBody(req);
|
|
100
|
+
if (body.trim().length === 0) {
|
|
101
|
+
send(res, 400, 'empty request body', 'text/plain; charset=utf-8');
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const response = await session.handleMessage(body, requestedSession);
|
|
105
|
+
if (response === undefined) {
|
|
106
|
+
// Notification: MCP expects 202 with no body.
|
|
107
|
+
res.writeHead(202, { 'content-length': 0 });
|
|
108
|
+
res.end();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
send(res, 200, encodeMcpFrame(response), 'application/json; charset=utf-8');
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
115
|
+
options.logger?.(`mcp endpoint error: ${message}`);
|
|
116
|
+
if (!res.headersSent) {
|
|
117
|
+
send(res, 500, message, 'text/plain; charset=utf-8');
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
res.end();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
const release = webServer.register({ kind: 'exact', path, handler });
|
|
125
|
+
// Read the port lazily: the carrier reports it only after its own listen
|
|
126
|
+
// callback, which may be later than this registration.
|
|
127
|
+
const port = webServer.port;
|
|
128
|
+
// A wildcard or IPv6 bind still reaches the bridge over loopback; a literal
|
|
129
|
+
// `::` in the URL is not connectable, so every non-IPv4 bind normalizes.
|
|
130
|
+
const host = webServer.host === '0.0.0.0' || webServer.host === '::' || (webServer.host ?? '').includes(':')
|
|
131
|
+
? '127.0.0.1'
|
|
132
|
+
: (webServer.host ?? '127.0.0.1');
|
|
133
|
+
return {
|
|
134
|
+
url: typeof port === 'number' && port > 0 ? `http://${host}:${port}${path}` : undefined,
|
|
135
|
+
token,
|
|
136
|
+
dispose: release,
|
|
137
|
+
setSession: (handle) => {
|
|
138
|
+
sessionHandle = handle;
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
//# sourceMappingURL=endpoint.js.map
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal MCP (Model Context Protocol) server-side JSON-RPC layer.
|
|
3
|
+
*
|
|
4
|
+
* DSH ships `@modelcontextprotocol/core`, but that package exports only Zod
|
|
5
|
+
* schemas — it has no `Server` class and no transport. The framing and the
|
|
6
|
+
* three methods AGY actually needs are implemented here, deliberately small:
|
|
7
|
+
* a session-based server (one session per AGY conversation) over newline
|
|
8
|
+
* delimited JSON-RPC 2.0.
|
|
9
|
+
*
|
|
10
|
+
* Wire compatibility notes learned from AGY 1.3.2:
|
|
11
|
+
* - the negotiated protocol version is echoed back from the client, because
|
|
12
|
+
* AGY rejects a server that answers with a version it did not offer;
|
|
13
|
+
* - a JSON-RPC *notification* (no `id`) must never be answered;
|
|
14
|
+
* - tool failures travel as a normal `tools/call` result with `isError: true`
|
|
15
|
+
* so the model can react to them instead of seeing a transport error.
|
|
16
|
+
*/
|
|
17
|
+
export declare const MCP_PROTOCOL_VERSION = "2025-06-18";
|
|
18
|
+
export declare const MCP_SUPPORTED_PROTOCOL_VERSIONS: readonly ["2025-06-18", "2025-03-26", "2024-11-05"];
|
|
19
|
+
export declare const MCP_ERROR_CODES: Readonly<{
|
|
20
|
+
parseError: -32700;
|
|
21
|
+
invalidRequest: -32600;
|
|
22
|
+
methodNotFound: -32601;
|
|
23
|
+
invalidParams: -32602;
|
|
24
|
+
internalError: -32603;
|
|
25
|
+
}>;
|
|
26
|
+
export interface McpTextContent {
|
|
27
|
+
readonly type: 'text';
|
|
28
|
+
readonly text: string;
|
|
29
|
+
}
|
|
30
|
+
export interface McpToolDescriptor {
|
|
31
|
+
readonly name: string;
|
|
32
|
+
readonly description: string;
|
|
33
|
+
/** JSON Schema for the tool arguments; already sanitized by the caller. */
|
|
34
|
+
readonly inputSchema: Record<string, unknown>;
|
|
35
|
+
}
|
|
36
|
+
export interface McpCallResult {
|
|
37
|
+
readonly content: readonly McpTextContent[];
|
|
38
|
+
readonly isError: boolean;
|
|
39
|
+
}
|
|
40
|
+
export interface McpJsonRpcError {
|
|
41
|
+
readonly code: number;
|
|
42
|
+
readonly message: string;
|
|
43
|
+
readonly data?: unknown;
|
|
44
|
+
}
|
|
45
|
+
export interface McpJsonRpcResponse {
|
|
46
|
+
readonly jsonrpc: '2.0';
|
|
47
|
+
/** `null` when the request had no recoverable id (JSON-RPC 2.0 spec). */
|
|
48
|
+
readonly id: string | number | null;
|
|
49
|
+
readonly result?: unknown;
|
|
50
|
+
readonly error?: McpJsonRpcError;
|
|
51
|
+
}
|
|
52
|
+
export interface McpInitializeResult {
|
|
53
|
+
readonly protocolVersion: string;
|
|
54
|
+
readonly capabilities: Record<string, unknown>;
|
|
55
|
+
readonly serverInfo: {
|
|
56
|
+
readonly name: string;
|
|
57
|
+
readonly version: string;
|
|
58
|
+
};
|
|
59
|
+
readonly instructions: string;
|
|
60
|
+
}
|
|
61
|
+
export interface McpServerHandlers {
|
|
62
|
+
readonly serverName: string;
|
|
63
|
+
readonly serverVersion: string;
|
|
64
|
+
readonly instructions: string;
|
|
65
|
+
/** Tools exposed for one session; re-read on every `tools/list`. */
|
|
66
|
+
listTools(sessionId: string): Promise<readonly McpToolDescriptor[]>;
|
|
67
|
+
/** Execute one tool call for a session. Rejections become `isError` results. */
|
|
68
|
+
callTool(sessionId: string, name: string, args: Record<string, unknown>): Promise<McpCallResult>;
|
|
69
|
+
}
|
|
70
|
+
/** Pick the highest protocol version both sides understand. */
|
|
71
|
+
export declare function negotiateProtocolVersion(requested: unknown): string;
|
|
72
|
+
/**
|
|
73
|
+
* One MCP server instance. It is transport agnostic: `handleMessage` takes one
|
|
74
|
+
* decoded JSON-RPC message and returns the response to write back, or
|
|
75
|
+
* `undefined` for notifications.
|
|
76
|
+
*/
|
|
77
|
+
export declare class McpServerSession {
|
|
78
|
+
private readonly handlers;
|
|
79
|
+
private initialized;
|
|
80
|
+
private clientInfo;
|
|
81
|
+
private sessionHandle;
|
|
82
|
+
constructor(handlers: McpServerHandlers);
|
|
83
|
+
get isInitialized(): boolean;
|
|
84
|
+
get client(): {
|
|
85
|
+
name?: string;
|
|
86
|
+
version?: string;
|
|
87
|
+
} | undefined;
|
|
88
|
+
handleMessage(raw: string, sessionHandle?: string): Promise<McpJsonRpcResponse | undefined>;
|
|
89
|
+
private dispatch;
|
|
90
|
+
private initialize;
|
|
91
|
+
private callTool;
|
|
92
|
+
/**
|
|
93
|
+
* Resolve which AGY conversation a request belongs to. The transport-level
|
|
94
|
+
* handle (the secret path segment) wins, because it is what the endpoint
|
|
95
|
+
* minted for this bridge; `_meta.sessionId` is the client's own hint and is
|
|
96
|
+
* used only when the transport supplies nothing.
|
|
97
|
+
*/
|
|
98
|
+
private resolveSession;
|
|
99
|
+
}
|
|
100
|
+
export declare class McpRpcError extends Error {
|
|
101
|
+
readonly code: number;
|
|
102
|
+
readonly data: unknown;
|
|
103
|
+
constructor(code: number, message: string, data?: unknown);
|
|
104
|
+
}
|
|
105
|
+
/** Serialize one response as a single newline-terminated JSON-RPC frame. */
|
|
106
|
+
export declare function encodeMcpFrame(response: McpJsonRpcResponse): string;
|
|
107
|
+
//# sourceMappingURL=protocol.d.ts.map
|