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.
Files changed (91) hide show
  1. package/LICENSE +31 -0
  2. package/README.en.md +48 -0
  3. package/README.md +67 -2
  4. package/agents/dsh-agy-image-view.md +17 -0
  5. package/agents/dsh-agy-mcp-executor.md +32 -0
  6. package/agents/dsh-agy-read-only.md +20 -0
  7. package/agents/dsh-agy-tool-free.md +16 -0
  8. package/agents/dsh-agy-workspace-write.md +23 -0
  9. package/bin/dsh-agy-cli.js +9 -0
  10. package/bin/win32-x64/agy-launcher.exe +0 -0
  11. package/cordis.patch.yml +16 -0
  12. package/dist/client.js +4259 -0
  13. package/lib/agent-installer.d.ts +66 -0
  14. package/lib/agent-installer.js +147 -0
  15. package/lib/agent-presets.d.ts +42 -0
  16. package/lib/agent-presets.js +130 -0
  17. package/lib/agents-cli.d.ts +3 -0
  18. package/lib/agents-cli.js +110 -0
  19. package/lib/agy/diagnostics.d.ts +29 -0
  20. package/lib/agy/diagnostics.js +109 -0
  21. package/lib/agy/errors.d.ts +20 -0
  22. package/lib/agy/errors.js +77 -0
  23. package/lib/agy/experimental-transport.d.ts +79 -0
  24. package/lib/agy/experimental-transport.js +612 -0
  25. package/lib/agy/keepalive.d.ts +140 -0
  26. package/lib/agy/keepalive.js +445 -0
  27. package/lib/agy/limiter.d.ts +32 -0
  28. package/lib/agy/limiter.js +111 -0
  29. package/lib/agy/log.d.ts +214 -0
  30. package/lib/agy/log.js +503 -0
  31. package/lib/agy/models.d.ts +49 -0
  32. package/lib/agy/models.js +203 -0
  33. package/lib/agy/overrides.d.ts +17 -0
  34. package/lib/agy/overrides.js +73 -0
  35. package/lib/agy/parser.d.ts +102 -0
  36. package/lib/agy/parser.js +492 -0
  37. package/lib/agy/persistent-transport.d.ts +101 -0
  38. package/lib/agy/persistent-transport.js +753 -0
  39. package/lib/agy/process.d.ts +144 -0
  40. package/lib/agy/process.js +452 -0
  41. package/lib/agy/redact.d.ts +3 -0
  42. package/lib/agy/redact.js +24 -0
  43. package/lib/agy/stream-protocol.d.ts +50 -0
  44. package/lib/agy/stream-protocol.js +156 -0
  45. package/lib/agy/windows-launcher.d.ts +21 -0
  46. package/lib/agy/windows-launcher.js +76 -0
  47. package/lib/diagnostics.d.ts +78 -0
  48. package/lib/diagnostics.js +162 -0
  49. package/lib/doctor.d.ts +138 -0
  50. package/lib/doctor.js +940 -0
  51. package/lib/dsh/context.d.ts +133 -0
  52. package/lib/dsh/context.js +352 -0
  53. package/lib/dsh-home.d.ts +6 -0
  54. package/lib/dsh-home.js +11 -0
  55. package/lib/index.d.ts +54 -0
  56. package/lib/index.js +267 -0
  57. package/lib/mcp/agy-registration.d.ts +70 -0
  58. package/lib/mcp/agy-registration.js +245 -0
  59. package/lib/mcp/bridge.d.ts +73 -0
  60. package/lib/mcp/bridge.js +295 -0
  61. package/lib/mcp/endpoint.d.ts +41 -0
  62. package/lib/mcp/endpoint.js +142 -0
  63. package/lib/mcp/protocol.d.ts +107 -0
  64. package/lib/mcp/protocol.js +197 -0
  65. package/lib/mcp/tool-projection.d.ts +64 -0
  66. package/lib/mcp/tool-projection.js +168 -0
  67. package/lib/provider/agy.d.ts +153 -0
  68. package/lib/provider/agy.js +2159 -0
  69. package/lib/provider/config.d.ts +226 -0
  70. package/lib/provider/config.js +294 -0
  71. package/lib/provider/error-codes.d.ts +22 -0
  72. package/lib/provider/error-codes.js +29 -0
  73. package/lib/provider/image-bridge.d.ts +60 -0
  74. package/lib/provider/image-bridge.js +290 -0
  75. package/lib/provider/mock.d.ts +15 -0
  76. package/lib/provider/mock.js +78 -0
  77. package/lib/provider/prompt-budget.d.ts +45 -0
  78. package/lib/provider/prompt-budget.js +596 -0
  79. package/lib/provider/serialize.d.ts +15 -0
  80. package/lib/provider/serialize.js +82 -0
  81. package/lib/provider/tool-protocol.d.ts +80 -0
  82. package/lib/provider/tool-protocol.js +848 -0
  83. package/lib/provider/tool-schema-file.d.ts +11 -0
  84. package/lib/provider/tool-schema-file.js +27 -0
  85. package/lib/session/file-store.d.ts +56 -0
  86. package/lib/session/file-store.js +186 -0
  87. package/lib/session/store.d.ts +49 -0
  88. package/lib/session/store.js +62 -0
  89. package/lib/web/router.d.ts +19 -0
  90. package/lib/web/router.js +994 -0
  91. 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