@agdf/mcp-server 0.20.2 → 0.31.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 +143 -7
- package/package.json +8 -3
- package/src/generation-entry.js +3 -85
- package/src/generation-protocol.js +2 -44
- package/src/generation-runtime.js +103 -0
- package/src/generation-thread-entry.js +8 -0
- package/src/generation.js +64 -20
- package/src/main.js +43 -10
- package/src/next-action.js +227 -0
- package/src/prepared-call-transport.js +306 -0
- package/src/safe-read-continuation.js +62 -0
- package/src/server.js +159 -46
- package/src/stdio-output.js +42 -0
- package/src/worker-entry.js +2 -2
- package/src/worker.js +53 -7
- package/ui/cockpit.html +31 -31
- package/ui/decision-manifest.json +3 -3
- package/ui/decision.html +36 -36
- package/ui/manifest.json +4 -4
package/src/server.js
CHANGED
|
@@ -1,23 +1,53 @@
|
|
|
1
|
+
import { createPreparedCallTransport, preparedTransportSchema } from './prepared-call-transport.js';
|
|
2
|
+
import { appendSafeReadContinuation, nextActionOutputSchema } from './safe-read-continuation.js';
|
|
3
|
+
import { createNextActionProjection } from './next-action.js';
|
|
4
|
+
import { boundStdioOutput } from './stdio-output.js';
|
|
1
5
|
import { McpServer, ProtocolError, ProtocolErrorCode, fromJsonSchema } from "@modelcontextprotocol/server";
|
|
2
|
-
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
6
|
+
import { serveStdio, StdioServerTransport } from "@modelcontextprotocol/server/stdio";
|
|
3
7
|
import { AjvJsonSchemaValidator } from '@modelcontextprotocol/server/validators/ajv';
|
|
4
8
|
import { randomUUID } from 'node:crypto';
|
|
5
9
|
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import { DispatchExecutionError
|
|
10
|
+
import { DispatchExecutionError } from "./worker.js";
|
|
7
11
|
import { loadCockpitResource } from './cockpit-resource.js';
|
|
8
12
|
import { requestDeclaresMcpAppUi, requestMcpClientInfo, requestMcpClientCapabilities, requiresMcpAppUi,
|
|
9
13
|
missingMcpAppUi, capabilityTransport, modernToolUiDenial } from "./ui-capability.js";
|
|
10
14
|
|
|
11
15
|
export const SERVER_NAME = "agdf-mcp";
|
|
12
16
|
|
|
13
|
-
|
|
17
|
+
// A folded follow-up read is a convenience. Hosts move large tool output into
|
|
18
|
+
// files (Claude Code from roughly 80 KB of text), which costs the model more
|
|
19
|
+
// round trips than a separate read, so a read is inlined only below this size.
|
|
20
|
+
const INLINE_READ_TEXT_BYTES = 48 * 1024;
|
|
21
|
+
const byteLength = value => Buffer.byteLength(typeof value === 'string' ? value : JSON.stringify(value), 'utf8');
|
|
22
|
+
|
|
23
|
+
function omittedFollowup(nextRead) {
|
|
24
|
+
return { ...nextRead, result: null, failure: {
|
|
25
|
+
code: 'read_followup_output_limit', phase: 'wire_budget', original_effect_unchanged: true,
|
|
26
|
+
instruction: 'The original result and receipt are unchanged. Its follow-up read was not inlined to keep this response small; run only that supplied read call separately.',
|
|
27
|
+
} };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function toolResult(runtime, name, input, result, { serialize = runtime.serialize, consume = runtime.consumeToolResult,
|
|
31
|
+
provider, peer, facts, preparedCalls, actions, nextRead } = {}) {
|
|
14
32
|
// A generation must supply its own descriptor. Never reinterpret old results
|
|
15
33
|
// with the facade's current runtime. Local owners project after postprocessing.
|
|
16
|
-
const emit = value => {
|
|
34
|
+
const emit = (value, { followup, decorate = true } = {}) => {
|
|
17
35
|
const projected = Object.hasOwn(value._meta ?? {}, 'agdf/tool-result') || runtime.requiresProjectedResult ? value
|
|
18
36
|
: runtime.projectToolResult(name, input, value, facts);
|
|
19
|
-
const { domain, metadata, descriptor } =
|
|
20
|
-
|
|
37
|
+
const { domain, metadata, descriptor } = consume(name, projected);
|
|
38
|
+
// Validate and bound the canonical domain with its existing owner first.
|
|
39
|
+
// Additive MCP navigation has its own explicit wire projection; it is not
|
|
40
|
+
// passed back into a closed Core result serializer as canonical state.
|
|
41
|
+
const canonical = JSON.parse(serialize(domain));
|
|
42
|
+
const navigable = followup ? { ...canonical, next_read: followup } : canonical;
|
|
43
|
+
const decorated = decorate && preparedCalls ? preparedCalls.decorate(navigable) : navigable;
|
|
44
|
+
if (decorated.next_read?.result?.report?.material_transport?.instructions) {
|
|
45
|
+
const transport = decorated.next_read.result.report.material_transport;
|
|
46
|
+
transport.instructions = transport.instructions.replaceAll('response.structuredContent.report',
|
|
47
|
+
'response.structuredContent.next_read.result.report').replaceAll('JSON.parse(response.content[0].text).report',
|
|
48
|
+
'JSON.parse(response.content[0].text).next_read.result.report');
|
|
49
|
+
}
|
|
50
|
+
const text = JSON.stringify(actions ? actions.project(decorated) : decorated);
|
|
21
51
|
const response = {
|
|
22
52
|
content: [{ type: 'text', text: domain.presentation_delivery?.text ?? text },
|
|
23
53
|
...(descriptor.failed ? [{ type: 'text', text: descriptor.message }] : [])],
|
|
@@ -28,18 +58,29 @@ function toolResult(runtime, name, input, result, serialize = runtime.serialize,
|
|
|
28
58
|
};
|
|
29
59
|
return response;
|
|
30
60
|
};
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
61
|
+
const withinLimit = response => !runtime.responseLimit || byteLength(response) <= runtime.responseLimit;
|
|
62
|
+
// Optional additions are dropped before the result itself is touched: first the
|
|
63
|
+
// inlined read, then the transport decoration (whose recovery copies can double
|
|
64
|
+
// large forms). Only a canonical result that alone exceeds the limit becomes
|
|
65
|
+
// resource_limit, so navigation can never turn a committed write into unknown.
|
|
66
|
+
const variants = [
|
|
67
|
+
...(nextRead?.result ? [{ followup: nextRead, inlineRead: true }] : []),
|
|
68
|
+
...(nextRead ? [{ followup: nextRead.result ? omittedFollowup(nextRead) : nextRead }] : []),
|
|
69
|
+
{}, { decorate: false },
|
|
70
|
+
];
|
|
71
|
+
for (const { inlineRead, ...variant } of variants) {
|
|
72
|
+
const response = emit(result, variant);
|
|
73
|
+
if (withinLimit(response) && (!inlineRead || byteLength(response.content[0].text) <= INLINE_READ_TEXT_BYTES)) return response;
|
|
38
74
|
}
|
|
75
|
+
// The fallback itself is projected, checked and measured; no recursive call.
|
|
76
|
+
const limitFacts = { code: 'resource_limit', dispatch: 'unknown' };
|
|
77
|
+
const failure = runtime.projectToolResult(name, input, runtime.failure('resource_limit', name, input, limitFacts), limitFacts);
|
|
78
|
+
const response = emit(failure);
|
|
79
|
+
if (!withinLimit(response)) throw Error('tool_result_invalid');
|
|
39
80
|
return response;
|
|
40
81
|
}
|
|
41
82
|
|
|
42
|
-
export function buildAgdfServer({ runtime, executor, era } = {}) {
|
|
83
|
+
export function buildAgdfServer({ runtime, executor, era, preparedCallStore } = {}) {
|
|
43
84
|
if (!runtime?.definition || typeof runtime.serialize !== "function") {
|
|
44
85
|
throw new TypeError("AGDF MCP runtime is required");
|
|
45
86
|
}
|
|
@@ -47,6 +88,15 @@ export function buildAgdfServer({ runtime, executor, era } = {}) {
|
|
|
47
88
|
throw new TypeError("AGDF MCP owned runtime is required");
|
|
48
89
|
}
|
|
49
90
|
const tools = runtime.tools ?? [{ name: runtime.definition.name, definition: runtime.definition, parse: runtime.parse, execute: runtime.execute }];
|
|
91
|
+
// Discovery and retained reads share the original SDK validators. Compile
|
|
92
|
+
// lazily; constructing the transport never validates or executes a request.
|
|
93
|
+
const schemaProvider = new AjvJsonSchemaValidator();
|
|
94
|
+
const deferredSchemas = { getValidator(schema) {
|
|
95
|
+
let validate;
|
|
96
|
+
return value => (validate ??= schemaProvider.getValidator(schema))(value);
|
|
97
|
+
} };
|
|
98
|
+
const inspectTool = tools.find(tool => tool.name === 'agdf_inspect');
|
|
99
|
+
const validateInspect = inspectTool && deferredSchemas.getValidator(inspectTool.definition.inputSchema);
|
|
50
100
|
const dispatchExecutor = executor ?? {
|
|
51
101
|
execute: async (argumentsValue, { toolName, signal } = {}) => {
|
|
52
102
|
const tool = tools.find((entry) => entry.name === (toolName ?? runtime.definition.name));
|
|
@@ -58,7 +108,14 @@ export function buildAgdfServer({ runtime, executor, era } = {}) {
|
|
|
58
108
|
},
|
|
59
109
|
close: async () => runtime.close?.(),
|
|
60
110
|
};
|
|
61
|
-
const registrations = new Map()
|
|
111
|
+
const registrations = new Map(), preparedCalls = createPreparedCallTransport({
|
|
112
|
+
store: preparedCallStore,
|
|
113
|
+
// A read reference retains a supplied canonical request, never an inferred
|
|
114
|
+
// target. Validate the unwrapped arguments with the original SDK schema.
|
|
115
|
+
validateRead: call => inspectTool?.definition.annotations?.readOnlyHint === true
|
|
116
|
+
&& validateInspect(call.arguments).valid,
|
|
117
|
+
}),
|
|
118
|
+
actions = createNextActionProjection({ isAvailable: call => preparedCalls.isAvailable(call) });
|
|
62
119
|
class AgdfServer extends McpServer {
|
|
63
120
|
async connect(transport) {
|
|
64
121
|
return super.connect(capabilityTransport(transport, message => modernToolUiDenial(message,
|
|
@@ -67,7 +124,8 @@ export function buildAgdfServer({ runtime, executor, era } = {}) {
|
|
|
67
124
|
}
|
|
68
125
|
const server = new AgdfServer(
|
|
69
126
|
{ name: SERVER_NAME, version: runtime.trustedContext.expectedVersion },
|
|
70
|
-
{ capabilities: { tools: {}, ...(runtime.ui ? { resources: {} } : {}) }
|
|
127
|
+
{ capabilities: { tools: {}, ...(runtime.ui ? { resources: {} } : {}) },
|
|
128
|
+
instructions: 'Use the returned next_action.call as the normal path for reads and writes. Fill only its named inputs from actual authored content, review or a new deliberate reply; ready calls retain inputs:{} and are forwarded unchanged. The adapter retains the exact target, Run, revision, selectors and original operation identity. Do not reconstruct raw reads or status requests, retype references or use cloning helpers. Owned plugin references retain their original lifetime across reconnects at the same runtime; other connections use explicit recovery_only instructions and the saved original request. A safe inspect may already be included as next_read.result; consume it without repeating it. next_read.tool/arguments describe that executed read; next_read.call is its supplied retry only. A terminal action stops work. No reference or continuation grants authority or approval.' },
|
|
71
129
|
);
|
|
72
130
|
// Capability checks guard execution and resource access. Codex must first
|
|
73
131
|
// discover the plugin tool metadata before selecting its UI-capable route.
|
|
@@ -80,32 +138,76 @@ export function buildAgdfServer({ runtime, executor, era } = {}) {
|
|
|
80
138
|
connection_id: randomUUID(), server_module: fileURLToPath(import.meta.url),
|
|
81
139
|
version: runtime.trustedContext.expectedVersion,
|
|
82
140
|
}) : undefined;
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
141
|
+
// The handler validates a result before choosing its follow-up read; the
|
|
142
|
+
// response variants then reuse that validation instead of repeating it.
|
|
143
|
+
const consumed = new WeakMap();
|
|
144
|
+
const consume = (name, value) => {
|
|
145
|
+
if (!consumed.has(value)) consumed.set(value, runtime.consumeToolResult(name, value));
|
|
146
|
+
return consumed.get(value);
|
|
147
|
+
};
|
|
148
|
+
const respond = (name, input, result, serialize, context, facts, nextRead) => toolResult(runtime, name, input, result, {
|
|
149
|
+
serialize, consume, provider, facts, preparedCalls, actions, nextRead, peer: provider ? {
|
|
150
|
+
client_info: requestMcpClientInfo(server.server, context), capabilities: requestMcpClientCapabilities(server.server, context),
|
|
151
|
+
} : undefined });
|
|
152
|
+
// Companion events (SD SDD-006): only the process that executed a call journals its final result.
|
|
153
|
+
// Behind the generation facade (projected results) the generation has journaled every result it
|
|
154
|
+
// produced; a failure thrown here (lost connection, unknown response, unavailable generation,
|
|
155
|
+
// expired session) never reached a generation result, so the facade journals it itself.
|
|
156
|
+
const journaling = () => !runtime.requiresProjectedResult && typeof runtime.observeOutcome === 'function';
|
|
157
|
+
const journalingFailures = () => typeof runtime.observeOutcome === 'function';
|
|
158
|
+
const journal = (name, input, projected) => {
|
|
159
|
+
try { runtime.observeOutcome(name, input, projected); } catch { /* notification only */ }
|
|
160
|
+
};
|
|
86
161
|
const uiAllowed = (context) => !gated || requestDeclaresMcpAppUi(server.server, context, runtime.ui.mimeType);
|
|
87
162
|
const supportsUi = context => !!runtime.ui && requestDeclaresMcpAppUi(server.server, context, runtime.ui.mimeType);
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
163
|
+
const nextRead = async (result, context) => appendSafeReadContinuation(result, {
|
|
164
|
+
allowed: !context.signal?.aborted && !!inspectTool && inspectTool.definition.annotations?.readOnlyHint === true
|
|
165
|
+
&& uiAllowed(context) && (!inspectTool.requiresUi || supportsUi(context)),
|
|
166
|
+
validate: call => ['working_directory', 'presentation_language', 'primary_target', 'target_source']
|
|
167
|
+
.every(key => typeof call.arguments[key] === 'string' && call.arguments[key].length)
|
|
168
|
+
&& validateInspect(call.arguments).valid,
|
|
169
|
+
execute: async call => {
|
|
170
|
+
const name = call.tool;
|
|
171
|
+
const input = runtime.prepareInput?.(name, call.arguments) ?? call.arguments;
|
|
172
|
+
const parentOwned = typeof inspectTool.parentOwned === 'function' ? inspectTool.parentOwned(input) : inspectTool.parentOwned;
|
|
173
|
+
let observed = parentOwned
|
|
174
|
+
? await inspectTool.execute(inspectTool.parse(input), context.signal,
|
|
175
|
+
{ client: requestMcpClientInfo(server.server, context), uiSupported: supportsUi(context) })
|
|
176
|
+
: await dispatchExecutor.execute(input, { signal: context.signal, toolName: name,
|
|
177
|
+
trusted: runtime.prepareDispatch?.(name, input) });
|
|
178
|
+
if (runtime.postprocessToolResult) observed = await runtime.postprocessToolResult(name, input, observed,
|
|
179
|
+
{ uiSupported: supportsUi(context), client: requestMcpClientInfo(server.server, context) });
|
|
180
|
+
const projected = runtime.projectToolResult(name, input, observed);
|
|
181
|
+
const { domain } = runtime.consumeToolResult(name, projected);
|
|
182
|
+
return JSON.parse((inspectTool.serialize ?? runtime.serialize)(domain));
|
|
183
|
+
},
|
|
184
|
+
});
|
|
96
185
|
for (const tool of tools) {
|
|
97
186
|
const { name, definition, serialize } = tool;
|
|
98
|
-
const schema = fromJsonSchema(definition.inputSchema,
|
|
187
|
+
const schema = fromJsonSchema(preparedTransportSchema(name, definition.inputSchema), { getValidator(inputSchema) {
|
|
188
|
+
const validate = deferredSchemas.getValidator(inputSchema);
|
|
189
|
+
return input => {
|
|
190
|
+
const checked = validate(input);
|
|
191
|
+
if (checked.valid) return checked;
|
|
192
|
+
const preparedHint = preparedCalls.validationHint(name, input);
|
|
193
|
+
// Validation has already failed. The exact transport correction is more
|
|
194
|
+
// useful here than every unrelated alternative in the raw-command union.
|
|
195
|
+
if (preparedHint) return { ...checked, errorMessage: preparedHint };
|
|
196
|
+
const hint = runtime.inputValidationHint?.(name, input);
|
|
197
|
+
return hint ? { ...checked, errorMessage: `${hint} Schema: ${checked.errorMessage}` } : checked;
|
|
198
|
+
};
|
|
199
|
+
} });
|
|
99
200
|
const registered = server.registerTool(
|
|
100
201
|
name,
|
|
101
202
|
{
|
|
102
203
|
description: definition.description,
|
|
103
204
|
annotations: definition.annotations,
|
|
104
205
|
inputSchema: schema,
|
|
105
|
-
...(definition.outputSchema ? { outputSchema: fromJsonSchema(definition.outputSchema, deferredSchemas) } : {}),
|
|
206
|
+
...(definition.outputSchema ? { outputSchema: fromJsonSchema(nextActionOutputSchema(name, definition.outputSchema), deferredSchemas) } : {}),
|
|
106
207
|
...(definition._meta ? { _meta: definition._meta } : {}),
|
|
107
208
|
},
|
|
108
209
|
async (argumentsValue, context) => {
|
|
210
|
+
const started = performance.now();
|
|
109
211
|
const denied = !uiAllowed(context) ? runtime.unsupportedUiCode ?? 'resource_denied'
|
|
110
212
|
: tool.requiresUi && !supportsUi(context) ? 'host_ui_unsupported' : null;
|
|
111
213
|
if (denied) {
|
|
@@ -113,23 +215,40 @@ export function buildAgdfServer({ runtime, executor, era } = {}) {
|
|
|
113
215
|
const failure = runtime.projectToolResult(name, argumentsValue, runtime.failure(denied, name, argumentsValue, facts), facts);
|
|
114
216
|
return respond(name, argumentsValue, failure, serialize ?? runtime.serialize, context, facts);
|
|
115
217
|
}
|
|
116
|
-
let
|
|
218
|
+
let parentOwned;
|
|
219
|
+
let phase = 'parse';
|
|
117
220
|
try {
|
|
221
|
+
phase = 'parse';
|
|
222
|
+
argumentsValue = preparedCalls.resolve(name, argumentsValue);
|
|
223
|
+
parentOwned = typeof tool.parentOwned === 'function' ? tool.parentOwned(argumentsValue) : tool.parentOwned;
|
|
224
|
+
argumentsValue = runtime.prepareInput?.(name, argumentsValue) ?? argumentsValue;
|
|
225
|
+
phase = parentOwned ? 'parse' : 'execute';
|
|
118
226
|
let result;
|
|
119
|
-
if (
|
|
227
|
+
if (parentOwned) {
|
|
120
228
|
const parsed = tool.parse(argumentsValue); phase = 'execute';
|
|
121
229
|
result = await tool.execute(parsed, context.signal,
|
|
122
230
|
{ client: requestMcpClientInfo(server.server, context), uiSupported: supportsUi(context) });
|
|
123
|
-
} else result = await dispatchExecutor.execute(argumentsValue, { signal: context.signal, toolName: name
|
|
231
|
+
} else result = await dispatchExecutor.execute(argumentsValue, { signal: context.signal, toolName: name,
|
|
232
|
+
trusted: runtime.prepareDispatch?.(name, argumentsValue) });
|
|
124
233
|
if (runtime.postprocessToolResult) result = await runtime.postprocessToolResult(name, argumentsValue, result,
|
|
125
234
|
{ uiSupported: supportsUi(context), client: requestMcpClientInfo(server.server, context) });
|
|
126
|
-
|
|
235
|
+
// Validate the producer and its canonical domain before consuming any
|
|
236
|
+
// supplied continuation. Malformed output must not start another read.
|
|
237
|
+
const checked = Object.hasOwn(result._meta ?? {}, 'agdf/tool-result') || runtime.requiresProjectedResult ? result
|
|
238
|
+
: runtime.projectToolResult(name, argumentsValue, result);
|
|
239
|
+
const { domain } = consume(name, checked);
|
|
240
|
+
if (journaling()) journal(name, argumentsValue, checked);
|
|
241
|
+
const continued = await nextRead(domain, context);
|
|
242
|
+
return respond(name, argumentsValue, checked, serialize ?? runtime.serialize, context, undefined, continued.next_read);
|
|
127
243
|
} catch (error) {
|
|
128
244
|
const facts = runtime.normalizeFailure(error, { phase });
|
|
129
|
-
const
|
|
245
|
+
const help = phase === 'parse' ? preparedCalls.validationHint(name, argumentsValue) : '';
|
|
246
|
+
const failure = { ...runtime.failure(facts.code, name, argumentsValue, { ...facts, elapsed_ms: performance.now() - started }),
|
|
247
|
+
...(help ? { prepared_call_help: help } : {}) };
|
|
130
248
|
// A local execution/delivery failure is owned here, including malformed
|
|
131
249
|
// producer output. It never causes re-execution or a second write.
|
|
132
250
|
const projected = runtime.projectToolResult(name, argumentsValue, failure, facts);
|
|
251
|
+
if (journalingFailures()) journal(name, argumentsValue, projected);
|
|
133
252
|
return respond(name, argumentsValue, projected, serialize ?? runtime.serialize, context, facts);
|
|
134
253
|
}
|
|
135
254
|
},
|
|
@@ -151,7 +270,7 @@ export function buildAgdfServer({ runtime, executor, era } = {}) {
|
|
|
151
270
|
const listedTools = tools.map(({ name, definition }) => ({
|
|
152
271
|
name, description: definition.description, inputSchema: server.toolInputSchemaJson(name),
|
|
153
272
|
annotations: definition.annotations, ...(definition._meta ? { _meta: definition._meta } : {}),
|
|
154
|
-
...(definition.outputSchema ? { outputSchema: definition.outputSchema } : {}),
|
|
273
|
+
...(definition.outputSchema ? { outputSchema: nextActionOutputSchema(name, definition.outputSchema) } : {}),
|
|
155
274
|
}));
|
|
156
275
|
const listedResource = { uri: resource.uri, name: runtime.ui.label ?? 'AGDF Cockpit', mimeType: resource.mimeType, _meta: resource._meta };
|
|
157
276
|
server.server.removeRequestHandler("tools/list");
|
|
@@ -162,18 +281,19 @@ export function buildAgdfServer({ runtime, executor, era } = {}) {
|
|
|
162
281
|
}
|
|
163
282
|
}
|
|
164
283
|
return Object.assign(server, {
|
|
165
|
-
closeAgdfRuntime: async () => { await dispatchExecutor.close(); return runtime.close?.(); },
|
|
284
|
+
closeAgdfRuntime: async () => { preparedCalls.close(); actions.close(); await dispatchExecutor.close(); return runtime.close?.(); },
|
|
166
285
|
});
|
|
167
286
|
}
|
|
168
287
|
|
|
169
|
-
export function serveAgdfStdio({ runtime, executor, onerror } = {}) {
|
|
288
|
+
export function serveAgdfStdio({ runtime, executor, onerror, preparedCallStore } = {}) {
|
|
170
289
|
const instances = new Set();
|
|
171
290
|
const handle = serveStdio(({ era }) => {
|
|
172
|
-
const server = buildAgdfServer({ runtime, executor, era });
|
|
291
|
+
const server = buildAgdfServer({ runtime, executor, era, preparedCallStore });
|
|
173
292
|
instances.add(server);
|
|
174
293
|
return server;
|
|
175
294
|
}, {
|
|
176
295
|
legacy: "serve",
|
|
296
|
+
transport: boundStdioOutput(new StdioServerTransport()),
|
|
177
297
|
onerror: () => onerror?.("AGDF_MCP_TRANSPORT_ERROR"),
|
|
178
298
|
});
|
|
179
299
|
return Object.freeze({
|
|
@@ -190,11 +310,4 @@ export function serveAgdfStdio({ runtime, executor, onerror } = {}) {
|
|
|
190
310
|
});
|
|
191
311
|
}
|
|
192
312
|
|
|
193
|
-
export
|
|
194
|
-
return createWorkerDispatchExecutor({
|
|
195
|
-
surface: runtime.trustedContext.surface,
|
|
196
|
-
expectedVersion: runtime.trustedContext.expectedVersion,
|
|
197
|
-
commandGroups: runtime.trustedContext.commandGroups,
|
|
198
|
-
...options,
|
|
199
|
-
});
|
|
200
|
-
}
|
|
313
|
+
export { createAgdfWorkerExecutor } from './worker.js';
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { OWNER_LIMITS } from './generation-protocol.js';
|
|
2
|
+
|
|
3
|
+
// One entry-owned SDK wire, one active write. Concurrent SDK responses must not
|
|
4
|
+
// attach one drain/error listener per result to a blocked native Worker stream.
|
|
5
|
+
// Capture exact JSON at admission. Existing result/resource owners bound the
|
|
6
|
+
// one active message; the additional queue has its own strict byte/frame cap.
|
|
7
|
+
// A legal large UI response must still work without lowering its resource cap.
|
|
8
|
+
export function boundStdioOutput(transport) {
|
|
9
|
+
const originalSend = transport.send.bind(transport), originalClose = transport.close.bind(transport);
|
|
10
|
+
const queue = [];
|
|
11
|
+
let active, queuedBytes = 0, closed = false, closing;
|
|
12
|
+
const capacityError = () => Error('AGDF_MCP_STDIO_CAPACITY');
|
|
13
|
+
function pump() {
|
|
14
|
+
if (active || closed || !queue.length) return;
|
|
15
|
+
const entry = active = queue.shift(); queuedBytes -= entry.bytes;
|
|
16
|
+
Promise.resolve().then(() => originalSend(entry.message)).then(entry.resolve, entry.reject).finally(() => {
|
|
17
|
+
active = undefined; pump();
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
transport.send = message => {
|
|
21
|
+
if (closed) return Promise.reject(Error('AGDF_MCP_STDIO_CLOSED'));
|
|
22
|
+
let snapshot, size;
|
|
23
|
+
try {
|
|
24
|
+
const json = JSON.stringify(message); size = Buffer.byteLength(json) + 1;
|
|
25
|
+
if (queue.length + (active ? 1 : 0) >= OWNER_LIMITS.frames || active && queuedBytes + size > OWNER_LIMITS.bytes) return Promise.reject(capacityError());
|
|
26
|
+
snapshot = JSON.parse(json); // Preserve SDK JSON semantics and call-time bytes.
|
|
27
|
+
} catch (error) { return Promise.reject(error); }
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
queuedBytes += size; queue.push({ message: snapshot, bytes: size, resolve, reject }); pump();
|
|
30
|
+
});
|
|
31
|
+
};
|
|
32
|
+
transport.close = () => {
|
|
33
|
+
if (closing) return closing;
|
|
34
|
+
closed = true;
|
|
35
|
+
for (const entry of queue.splice(0)) { queuedBytes -= entry.bytes; entry.reject(Error('AGDF_MCP_STDIO_CLOSED')); }
|
|
36
|
+
// Close does not prove that an active write drained. Its SDK promise stays
|
|
37
|
+
// observed; genuine Worker exit is still required by the lifecycle owner.
|
|
38
|
+
active?.reject(Error('AGDF_MCP_STDIO_CLOSED'));
|
|
39
|
+
closing = Promise.resolve().then(originalClose); return closing;
|
|
40
|
+
};
|
|
41
|
+
return transport;
|
|
42
|
+
}
|
package/src/worker-entry.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { subscribe } from "node:diagnostics_channel";
|
|
2
2
|
import { parentPort, workerData } from "node:worker_threads";
|
|
3
|
-
import { OWNED_LOCK_CHANNEL, createMcpDispatchRuntime } from "
|
|
3
|
+
import { OWNED_LOCK_CHANNEL, createMcpDispatchRuntime } from "#agdf-dispatch-worker";
|
|
4
4
|
import { ownedLockForwarder } from "./worker.js";
|
|
5
5
|
|
|
6
6
|
function send(message) {
|
|
@@ -14,7 +14,7 @@ subscribe(OWNED_LOCK_CHANNEL, ownedLockForwarder(send));
|
|
|
14
14
|
let runtime, phase = 'setup';
|
|
15
15
|
try {
|
|
16
16
|
send({ type: 'phase', phase: 'runtime_provenance' });
|
|
17
|
-
runtime = createMcpDispatchRuntime({ surface: workerData.surface, ...(workerData.commandGroups !== undefined ? { commandGroups: workerData.commandGroups } : {}) });
|
|
17
|
+
runtime = createMcpDispatchRuntime({ surface: workerData.surface, materialGrants: workerData.materialGrants, inputContext: workerData.inputContext, ...(workerData.commandGroups !== undefined ? { commandGroups: workerData.commandGroups } : {}) });
|
|
18
18
|
if (runtime.trustedContext.provenanceStatus !== "matched") {
|
|
19
19
|
send({ type: "failure", code: "runtime_provenance_invalid", dispatch: "not_dispatched" });
|
|
20
20
|
} else if (runtime.trustedContext.expectedVersion !== workerData.expectedVersion) {
|
package/src/worker.js
CHANGED
|
@@ -30,21 +30,30 @@ export function createWorkerDispatchExecutor({
|
|
|
30
30
|
// Reserve up to one second instead of beginning shutdown at the deadline.
|
|
31
31
|
const terminationReserveMs = Math.min(1_000, Math.max(1, Math.floor(timeoutMs / 4)));
|
|
32
32
|
let active = null;
|
|
33
|
+
let closed = false;
|
|
34
|
+
const waiting = [];
|
|
35
|
+
const rejectWaiting = error => { for (const job of waiting.splice(0)) { job.cleanup(); job.reject(error); } };
|
|
36
|
+
const drain = () => {
|
|
37
|
+
if (active || closed || !waiting.length) return;
|
|
38
|
+
const job = waiting.shift(); job.cleanup();
|
|
39
|
+
execute(job.argumentsValue, { ...job.options, deadline: job.deadline }).then(job.resolve, job.reject).finally(drain);
|
|
40
|
+
};
|
|
33
41
|
const event = (phase, started) => {
|
|
34
42
|
try { onEvent({ phase, elapsed_ms: performance.now() - started }); } catch {}
|
|
35
43
|
};
|
|
36
|
-
|
|
37
|
-
async execute(argumentsValue, { signal, toolName } = {}) {
|
|
44
|
+
const execute = async (argumentsValue, { signal, toolName, trusted, deadline: queuedDeadline } = {}) => {
|
|
38
45
|
if (active) throw new DispatchExecutionError("dispatch_busy", 'not_dispatched');
|
|
39
46
|
if (signal?.aborted) throw new DispatchExecutionError("dispatch_cancelled", 'not_dispatched');
|
|
40
47
|
return new Promise((resolve, reject) => {
|
|
41
48
|
let settled = false, exited = false, completion, workerPhase = 'worker_start';
|
|
42
49
|
let confirmExit;
|
|
43
50
|
const actualExit = new Promise(resolve => { confirmExit = resolve; });
|
|
44
|
-
const started = performance.now(), deadline = started + timeoutMs;
|
|
51
|
+
const started = performance.now(), deadline = queuedDeadline ?? started + timeoutMs;
|
|
52
|
+
const budget = deadline - started;
|
|
53
|
+
if (budget <= 0) { reject(new DispatchExecutionError('dispatch_timeout', 'not_dispatched')); return; }
|
|
45
54
|
let worker;
|
|
46
55
|
try { worker = new WorkerClass(WORKER_URL, {
|
|
47
|
-
workerData: { surface, expectedVersion, commandGroups, argumentsValue, toolName: toolName ?? null },
|
|
56
|
+
workerData: { surface, expectedVersion, commandGroups, argumentsValue, toolName: toolName ?? null, materialGrants: trusted?.materialGrants, inputContext: trusted?.inputContext },
|
|
48
57
|
}); } catch { reject(new DispatchExecutionError('dispatch_worker_failed', 'not_dispatched')); return; }
|
|
49
58
|
event('worker_start', started);
|
|
50
59
|
const current = { worker, timer: null, cleanupSignal: null, cancel: () => finish(reject, new DispatchExecutionError("dispatch_cancelled")) };
|
|
@@ -62,7 +71,7 @@ export function createWorkerDispatchExecutor({
|
|
|
62
71
|
try { onEvent({ phase: 'locks_released', elapsed_ms: performance.now() - started, count: released }); } catch {}
|
|
63
72
|
};
|
|
64
73
|
// Called only after actual exit or confirmed termination: the thread no longer runs.
|
|
65
|
-
const release = () => { releaseHeldLocks(); if (active === current) active = null; };
|
|
74
|
+
const release = () => { releaseHeldLocks(); if (active === current) { active = null; queueMicrotask(drain); } };
|
|
66
75
|
const finish = (callback, value) => {
|
|
67
76
|
if (settled) return completion;
|
|
68
77
|
settled = true;
|
|
@@ -87,6 +96,8 @@ export function createWorkerDispatchExecutor({
|
|
|
87
96
|
} else {
|
|
88
97
|
// A late actual exit may release capacity; rejection alone never proves termination.
|
|
89
98
|
event('termination_unconfirmed', started);
|
|
99
|
+
current.unconfirmed = true;
|
|
100
|
+
rejectWaiting(new DispatchExecutionError('dispatch_busy', 'not_dispatched'));
|
|
90
101
|
reject(new DispatchExecutionError('dispatch_worker_termination_unconfirmed', 'unknown', undefined, {
|
|
91
102
|
...(callback === reject && value instanceof DispatchExecutionError ? { cause_code: value.code } : {}),
|
|
92
103
|
worker_phase: workerPhase,
|
|
@@ -100,7 +111,8 @@ export function createWorkerDispatchExecutor({
|
|
|
100
111
|
current.cleanupSignal = () => signal.removeEventListener("abort", cancel);
|
|
101
112
|
signal.addEventListener("abort", cancel, { once: true });
|
|
102
113
|
}
|
|
103
|
-
current.timer = setTimeout(() => finish(reject, new DispatchExecutionError("dispatch_timeout")),
|
|
114
|
+
current.timer = setTimeout(() => finish(reject, new DispatchExecutionError("dispatch_timeout")),
|
|
115
|
+
budget - Math.min(terminationReserveMs, Math.max(1, Math.floor(budget / 4))));
|
|
104
116
|
worker.on("message", message => {
|
|
105
117
|
// Lock messages also arrive after settlement while the worker drains.
|
|
106
118
|
if (message?.type === 'lock') {
|
|
@@ -127,8 +139,42 @@ export function createWorkerDispatchExecutor({
|
|
|
127
139
|
// Cover an abort racing listener installation.
|
|
128
140
|
if (signal?.aborted) cancel();
|
|
129
141
|
});
|
|
142
|
+
};
|
|
143
|
+
return Object.freeze({
|
|
144
|
+
execute(argumentsValue, options = {}) {
|
|
145
|
+
if (closed) return Promise.reject(new DispatchExecutionError(active ? 'dispatch_busy' : 'dispatch_cancelled', 'not_dispatched'));
|
|
146
|
+
if (options.signal?.aborted) return Promise.reject(new DispatchExecutionError('dispatch_cancelled', 'not_dispatched'));
|
|
147
|
+
const deadline = performance.now() + timeoutMs;
|
|
148
|
+
if (!active && !waiting.length) return execute(argumentsValue, { ...options, deadline });
|
|
149
|
+
// Only immutable Inspect requests wait. Commands still report contention;
|
|
150
|
+
// no write is replayed, parallelized or admitted after unknown termination.
|
|
151
|
+
if (options.toolName !== 'agdf_inspect' || active?.unconfirmed)
|
|
152
|
+
return Promise.reject(new DispatchExecutionError('dispatch_busy', 'not_dispatched'));
|
|
153
|
+
if (waiting.length >= 8) return Promise.reject(new DispatchExecutionError('resource_limit', 'not_dispatched'));
|
|
154
|
+
return new Promise((resolve, reject) => {
|
|
155
|
+
const job = { argumentsValue, options, deadline, resolve, reject };
|
|
156
|
+
const remove = error => {
|
|
157
|
+
const index = waiting.indexOf(job);
|
|
158
|
+
if (index < 0) return;
|
|
159
|
+
waiting.splice(index, 1); job.cleanup(); reject(error);
|
|
160
|
+
};
|
|
161
|
+
const abort = () => remove(new DispatchExecutionError('dispatch_cancelled', 'not_dispatched'));
|
|
162
|
+
const timer = setTimeout(() => remove(new DispatchExecutionError('dispatch_timeout', 'not_dispatched')), timeoutMs);
|
|
163
|
+
job.cleanup = () => { clearTimeout(timer); options.signal?.removeEventListener('abort', abort); };
|
|
164
|
+
waiting.push(job); options.signal?.addEventListener('abort', abort, { once: true });
|
|
165
|
+
if (options.signal?.aborted) abort();
|
|
166
|
+
});
|
|
130
167
|
},
|
|
131
|
-
async close() { await active?.cancel(); },
|
|
168
|
+
async close() { closed = true; rejectWaiting(new DispatchExecutionError('dispatch_cancelled', 'not_dispatched')); await active?.cancel(); },
|
|
132
169
|
get active() { return Boolean(active); },
|
|
133
170
|
});
|
|
134
171
|
}
|
|
172
|
+
|
|
173
|
+
export function createAgdfWorkerExecutor(runtime, options = {}) {
|
|
174
|
+
return createWorkerDispatchExecutor({
|
|
175
|
+
surface: runtime.trustedContext.surface,
|
|
176
|
+
expectedVersion: runtime.trustedContext.expectedVersion,
|
|
177
|
+
commandGroups: runtime.trustedContext.commandGroups,
|
|
178
|
+
...options,
|
|
179
|
+
});
|
|
180
|
+
}
|