@agdf/mcp-server 0.20.2 → 0.30.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 +119 -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 +94 -0
- package/src/generation-thread-entry.js +8 -0
- package/src/generation.js +64 -20
- package/src/main.js +42 -10
- package/src/server.js +24 -15
- 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/generation.js
CHANGED
|
@@ -8,7 +8,7 @@ import { DispatchExecutionError } from './worker.js';
|
|
|
8
8
|
// One live facade, three directly owned children, no process registry or PID
|
|
9
9
|
// discovery. Capacity includes startup and unconfirmed cleanup, not just current.
|
|
10
10
|
export async function createOwnedGenerationFacade({ runtime, publisher, createControlRuntime, surface, cockpitDir, cockpitMode,
|
|
11
|
-
Fork = fork, qualifyMs = L.qualify, closeMs = L.close, invokeMs = L.qualify, onEvent = () => {} } = {}) {
|
|
11
|
+
generationTransport, Fork = fork, qualifyMs = L.qualify, closeMs = L.close, invokeMs = L.qualify, onEvent = () => {} } = {}) {
|
|
12
12
|
if (![qualifyMs, closeMs, invokeMs].every(value => Number.isSafeInteger(value) && value > 0)
|
|
13
13
|
|| qualifyMs > L.qualify || closeMs > L.close || invokeMs > L.qualify) throw Error('generation_deadline_invalid');
|
|
14
14
|
const epoch = randomUUID(), slots = new Map(), routes = new Map(), presentations = new Map(), operations = new Map(), receipts = new Map();
|
|
@@ -64,10 +64,16 @@ export async function createOwnedGenerationFacade({ runtime, publisher, createCo
|
|
|
64
64
|
unknown: new Set(), inflight: 0, exited: false, closeAck: false, observed: null };
|
|
65
65
|
slots.set(row.id, row); event(row, 'reserved'); // Reservation precedes acquisition/load/spawn.
|
|
66
66
|
try {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
67
|
+
if (generationTransport) {
|
|
68
|
+
// This is a logical private connection. Actual Worker and claim ownership
|
|
69
|
+
// stay in the detached owner; no ChildProcess/Worker impersonation.
|
|
70
|
+
row.child = await generationTransport.open({ packageInfo, epoch, generation: row.id });
|
|
71
|
+
} else {
|
|
72
|
+
const claim = publisher.acquire(packageInfo, { ownerEpoch: epoch, generation: row.id });
|
|
73
|
+
row.child = Fork(join(packageInfo.packageRoot, 'src/generation-entry.js'), [epoch, String(row.id)],
|
|
74
|
+
{ stdio: ['ignore', 'ignore', 'ignore', 'ipc'], serialization: 'json', execArgv: [] });
|
|
75
|
+
claim.bindOwnedChild(row.child, { confirmedEnd: () => row.closeAck && !row.inflight && !row.unknown.size });
|
|
76
|
+
}
|
|
71
77
|
row.child.on('message', reply => {
|
|
72
78
|
if (!validGenerationMessage(reply, { epoch, generation: row.id, reply: true })) { event(row, 'invalid_reply'); return; }
|
|
73
79
|
const pending = row.pending.get(reply.request_id);
|
|
@@ -81,13 +87,18 @@ export async function createOwnedGenerationFacade({ runtime, publisher, createCo
|
|
|
81
87
|
pending.resolve(reply);
|
|
82
88
|
}
|
|
83
89
|
});
|
|
84
|
-
row.child.once('exit', () => {
|
|
90
|
+
row.child.once('exit', (code, signal) => {
|
|
85
91
|
row.exited = true;
|
|
86
92
|
for (const pending of row.pending.values()) { clearTimeout(pending.timer); pending.reject(new DispatchExecutionError('generation_connection_lost')); }
|
|
87
93
|
row.pending.clear();
|
|
88
|
-
if (row.closeAck && !row.inflight && !row.unknown.size) { slots.delete(row.id); row.state = 'closed'; event(row, 'confirmed_exit'); row.end?.(true); }
|
|
94
|
+
if (code === 0 && !signal && row.closeAck && !row.inflight && !row.unknown.size) { slots.delete(row.id); row.state = 'closed'; event(row, 'confirmed_exit'); row.end?.(true); }
|
|
89
95
|
else { row.state = 'close_unconfirmed'; event(row, 'unexpected_exit'); row.end?.(false); }
|
|
90
96
|
});
|
|
97
|
+
row.child.once('disconnect', () => {
|
|
98
|
+
row.state = 'close_unconfirmed'; event(row, 'connection_lost');
|
|
99
|
+
for (const pending of row.pending.values()) { clearTimeout(pending.timer); pending.reject(new DispatchExecutionError('generation_connection_lost')); }
|
|
100
|
+
row.pending.clear();
|
|
101
|
+
});
|
|
91
102
|
row.child.once('error', () => { row.state = 'close_unconfirmed'; event(row, 'spawn_failed'); });
|
|
92
103
|
row.state = 'qualifying'; event(row, 'qualifying');
|
|
93
104
|
const qualified = (await request(row, 'qualify', { surface, commandGroups: [...(runtime.trustedContext.commandGroups ?? [])],
|
|
@@ -105,6 +116,11 @@ export async function createOwnedGenerationFacade({ runtime, publisher, createCo
|
|
|
105
116
|
}
|
|
106
117
|
const control = {
|
|
107
118
|
status,
|
|
119
|
+
originalInput(input) {
|
|
120
|
+
const prior = receipts.get(input.operation_id);
|
|
121
|
+
if (!prior) return null;
|
|
122
|
+
return { known: true, resolution: prior.resolution ?? null };
|
|
123
|
+
},
|
|
108
124
|
async refresh() { await Promise.all([...slots.values()].filter(row => !row.exited).map(observe)); return status(); },
|
|
109
125
|
inspect(id, input) { const record = receipts.get(id);
|
|
110
126
|
if (record && (record.input.run_id !== input.run_id || record.input.primary_target !== input.primary_target
|
|
@@ -112,18 +128,23 @@ export async function createOwnedGenerationFacade({ runtime, publisher, createCo
|
|
|
112
128
|
return record ? { status: record.result ? 'recorded' : 'pending',
|
|
113
129
|
request_digest: record.digest, result: record.result, current: status(), authorizes: false }
|
|
114
130
|
: { status: 'unknown', reason: 'connection_control_record_unavailable', current: status(), authorizes: false }; },
|
|
115
|
-
async replace({ input, candidate, requestDigest, revalidate }) {
|
|
131
|
+
async replace({ input, candidate, requestDigest, resolution, revalidate }) {
|
|
116
132
|
const prior = receipts.get(input.operation_id);
|
|
117
133
|
if (prior) return prior.digest !== requestDigest ? failure('operation_payload_conflict')
|
|
118
134
|
: prior.result ? { ...prior.result, outcome: prior.result.outcome === 'accepted' ? 'already_applied' : prior.result.outcome, historical_effect: true, current: status() }
|
|
119
135
|
: { outcome: 'effect_unknown', reason: 'generation_replacement_pending', current: status(), authorizes: false };
|
|
120
136
|
if (receipts.size >= L.receipts) return failure('connection_control_capacity');
|
|
121
137
|
if (closed || replacing) return failure(closed ? 'connection_closed' : 'generation_replacement_busy');
|
|
122
|
-
const record = { digest: requestDigest, input, result: null }; receipts.set(input.operation_id, record); replacing = true;
|
|
138
|
+
const record = { digest: requestDigest, input, resolution, result: null }; receipts.set(input.operation_id, record); replacing = true;
|
|
123
139
|
let candidateRow;
|
|
124
140
|
try {
|
|
125
|
-
|
|
126
|
-
|
|
141
|
+
const recheck = () => {
|
|
142
|
+
if (closed) throw new DispatchExecutionError('connection_closed', 'not_dispatched');
|
|
143
|
+
controlRuntimeInstance?.verifyProducer();
|
|
144
|
+
revalidate();
|
|
145
|
+
};
|
|
146
|
+
recheck(); const packageInfo = publisher.resolve(candidate);
|
|
147
|
+
candidateRow = await reserve(packageInfo, actualContext); recheck();
|
|
127
148
|
const previous = current; current = candidateRow; current.state = 'current';
|
|
128
149
|
if (previous) previous.state = previous.exited ? 'close_unconfirmed' : 'draining';
|
|
129
150
|
event(current, 'admitted'); record.result = { outcome: 'accepted', reason: null, operation_id: input.operation_id,
|
|
@@ -135,44 +156,66 @@ export async function createOwnedGenerationFacade({ runtime, publisher, createCo
|
|
|
135
156
|
record.result.current = status(); return record.result;
|
|
136
157
|
},
|
|
137
158
|
};
|
|
138
|
-
|
|
159
|
+
// Ordinary requests belong to the fully qualified generation. Only the rare
|
|
160
|
+
// facade control branches need another Core instance; share one initialization.
|
|
161
|
+
let controlRuntimePromise, controlRuntimeInstance;
|
|
162
|
+
const getControlRuntime = () => controlRuntimePromise ??= Promise.resolve().then(() => createControlRuntime(control)).then(async created => {
|
|
163
|
+
const producer = created?.producerIdentity;
|
|
164
|
+
const code = created?.trustedContext?.expectedVersion !== runtime.trustedContext.expectedVersion ? 'runtime_version_mismatch'
|
|
165
|
+
: created?.trustedContext?.provenanceStatus !== 'matched'
|
|
166
|
+
|| !producer || producer.server_digest !== publisher.initial.digest || producer.dispatcher_digest !== publisher.initial.dispatcherDigest
|
|
167
|
+
|| producer.sdk_digest !== publisher.initial.sdkDigest || typeof created.verifyProducer !== 'function'
|
|
168
|
+
? 'runtime_provenance_invalid' : null;
|
|
169
|
+
if (code) {
|
|
170
|
+
await created?.close?.(); throw new DispatchExecutionError(code, 'not_dispatched');
|
|
171
|
+
}
|
|
172
|
+
controlRuntimeInstance = created;
|
|
173
|
+
return created;
|
|
174
|
+
}).catch(error => { throw new DispatchExecutionError(generationFailureReason(error), 'not_dispatched'); });
|
|
139
175
|
function operationKey(input, id = input.operation_id) { return JSON.stringify([input.primary_target, input.run_id, id]); }
|
|
140
176
|
async function invoke(name, args, host = {}, signal) {
|
|
141
177
|
if (signal?.aborted) throw new DispatchExecutionError('dispatch_cancelled', 'not_dispatched');
|
|
142
178
|
if (closed) throw new DispatchExecutionError('connection_closed', 'not_dispatched');
|
|
143
179
|
actualContext = { client: host.client, uiSupported: host.uiSupported === true };
|
|
180
|
+
const canonical = Boolean(args.operation_id) && !runtime.isEphemeralControlCommand?.(name,args);
|
|
144
181
|
if (args.operation_id && name === 'agdf_record' && args.command?.kind === 'runtime_generation.replace'
|
|
145
182
|
&& operations.has(operationKey(args))) throw new DispatchExecutionError('operation_payload_conflict', 'not_dispatched');
|
|
146
183
|
if (args.operation_id && args.command?.kind !== 'runtime_generation.replace' && receipts.has(args.operation_id))
|
|
147
184
|
throw new DispatchExecutionError('operation_payload_conflict', 'not_dispatched');
|
|
148
185
|
if (name === 'agdf_record' && args.command?.kind === 'runtime_generation.replace'
|
|
149
186
|
|| name === 'agdf_inspect' && args.operation === 'runtime-generations') {
|
|
187
|
+
const controlRuntime = await getControlRuntime();
|
|
188
|
+
if (closed || signal?.aborted) throw new DispatchExecutionError(closed ? 'connection_closed' : 'dispatch_cancelled', 'not_dispatched');
|
|
189
|
+
try { controlRuntime.verifyProducer(); } catch (error) { throw new DispatchExecutionError(generationFailureReason(error), 'not_dispatched'); }
|
|
150
190
|
const result = await controlRuntime.tool(name).execute(controlRuntime.tool(name).parse(args));
|
|
151
191
|
return controlRuntime.projectToolResult(name, args, name === 'agdf_inspect' && result.report?.available ? { ...result, report: await control.refresh() } : result);
|
|
152
192
|
}
|
|
153
193
|
if (name === 'agdf_inspect' && args.operation === 'operation-status' && receipts.has(args.module)) {
|
|
194
|
+
const controlRuntime = await getControlRuntime();
|
|
195
|
+
if (closed || signal?.aborted) throw new DispatchExecutionError(closed ? 'connection_closed' : 'dispatch_cancelled', 'not_dispatched');
|
|
196
|
+
try { controlRuntime.verifyProducer(); } catch (error) { throw new DispatchExecutionError(generationFailureReason(error), 'not_dispatched'); }
|
|
154
197
|
const validated = await controlRuntime.tool(name).execute(controlRuntime.tool(name).parse(args));
|
|
155
198
|
return controlRuntime.projectToolResult(name, args, validated.report?.status === 'unknown' || validated.report?.status === 'absent'
|
|
156
199
|
? { ...validated, report: control.inspect(args.module, args) } : validated);
|
|
157
200
|
}
|
|
158
201
|
const knownSession = args.session_id && routes.get(args.session_id);
|
|
159
202
|
if (args.session_id && !knownSession) throw new DispatchExecutionError('session_expired', 'not_dispatched');
|
|
160
|
-
const id = name === 'agdf_inspect' && args.operation === 'operation-status' ? args.module : args.operation_id;
|
|
203
|
+
const id = name === 'agdf_inspect' && args.operation === 'operation-status' ? args.module.split('/').at(-1) : canonical ? args.operation_id : null;
|
|
161
204
|
const key = id && operationKey(args, id), original = key && operations.get(key);
|
|
162
205
|
const presentationId = args.command?.payload?.presentation_id ?? args.presentation_id;
|
|
163
206
|
const presentationOwner = presentationId && presentations.get(operationKey(args, presentationId));
|
|
164
207
|
const row = knownSession ?? original ?? presentationOwner ?? current;
|
|
165
208
|
if (!row || row.exited) throw new DispatchExecutionError('generation_unavailable', 'not_dispatched');
|
|
166
|
-
if (
|
|
209
|
+
if (canonical && !original) {
|
|
167
210
|
if (operations.size >= L.routes) throw new DispatchExecutionError('generation_route_capacity', 'not_dispatched');
|
|
168
211
|
operations.set(key, row);
|
|
169
212
|
}
|
|
170
213
|
++row.inflight; event(row, 'invoke', name);
|
|
171
214
|
try {
|
|
172
|
-
const result = (await request(row, 'invoke', { name, arguments: args }, actualContext, args.operation_id
|
|
173
|
-
if (
|
|
215
|
+
const result = (await request(row, 'invoke', { name, arguments: args }, actualContext, canonical ? args.operation_id : null, invokeMs)).payload;
|
|
216
|
+
if (canonical && result.outcome === 'effect_unknown') row.unknown.add(key);
|
|
174
217
|
if (name === 'agdf_inspect' && args.operation === 'operation-status' && result.report?.status === 'recorded'
|
|
175
|
-
&& result.report.receipt?.operation_id
|
|
218
|
+
&& (result.report.receipt?.operation_id ?? result.report.receipt?.operation?.operation_id) === id) row.unknown.delete(key);
|
|
176
219
|
if (result.app_capability === 'available') nativeRequired = true;
|
|
177
220
|
if (result.family_result?.presentation_id && ['accepted', 'already_applied'].includes(result.outcome)) {
|
|
178
221
|
const presentationKey = operationKey(args, result.family_result.presentation_id);
|
|
@@ -185,7 +228,7 @@ export async function createOwnedGenerationFacade({ runtime, publisher, createCo
|
|
|
185
228
|
}
|
|
186
229
|
}
|
|
187
230
|
return result;
|
|
188
|
-
} catch (error) { if (
|
|
231
|
+
} catch (error) { if (canonical && error.dispatch !== 'not_dispatched') row.unknown.add(key); throw error; }
|
|
189
232
|
finally { --row.inflight; if (closed && !row.inflight && !row.unknown.size) void retire(row, true); }
|
|
190
233
|
}
|
|
191
234
|
current = await reserve(publisher.initial, {}); current.state = 'current'; event(current, 'admitted');
|
|
@@ -194,16 +237,17 @@ export async function createOwnedGenerationFacade({ runtime, publisher, createCo
|
|
|
194
237
|
// not wire requests; parsing them again in a child both breaks the contract
|
|
195
238
|
// and copies the entire locale registry into a bounded private message.
|
|
196
239
|
const tools = runtime.tools.map(tool => ({ ...tool, parentOwned: true,
|
|
197
|
-
parse: input => { tool.parse(input); return input; }, execute: input => invoke(tool.name, input) }));
|
|
240
|
+
parse: input => { if (runtime.validatePublicInput?.(tool.name, input) !== true) tool.parse(input); return input; }, execute: input => invoke(tool.name, input) }));
|
|
198
241
|
// The server supplies actual initialize/request context only at this seam.
|
|
199
242
|
for (const tool of tools) tool.execute = (input, signal, host) => invoke(tool.name, input, host, signal);
|
|
200
243
|
return Object.freeze({ ...runtime, tools, tool: name => tools.find(tool => tool.name === name),
|
|
201
|
-
requiresProjectedResult: true, postprocessToolResult: undefined, inspectOwnership: status, runtimeControl: control,
|
|
244
|
+
requiresProjectedResult: true, prepareInput: undefined, postprocessToolResult: undefined, inspectOwnership: status, runtimeControl: control,
|
|
202
245
|
close() {
|
|
203
246
|
return closing ??= (async () => {
|
|
204
247
|
closed = true;
|
|
205
248
|
const previous = current; current = null; if (previous && !previous.exited) previous.state = 'draining';
|
|
206
249
|
await Promise.allSettled([...slots.values()].map(row => retire(row, true)));
|
|
250
|
+
if (controlRuntimePromise) await controlRuntimePromise.then(controlRuntime => controlRuntime.close?.(), () => {});
|
|
207
251
|
return status();
|
|
208
252
|
})();
|
|
209
253
|
},
|
package/src/main.js
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import process from "node:process";
|
|
3
|
-
import { createAgdfWorkerExecutor, serveAgdfStdio } from "./server.js";
|
|
4
3
|
import { loadCockpitResource } from './cockpit-resource.js';
|
|
5
4
|
|
|
6
|
-
export async function runMcpServer({ surface, cockpitDir, cockpitMode, runtime, executor, ownedPublication, ownedPublisher, onClosed } = {}) {
|
|
5
|
+
export async function runMcpServer({ surface, cockpitDir, cockpitMode, runtime, executor, ownedPublication, ownedPublisher, generationTransport, onClosed } = {}) {
|
|
7
6
|
if (cockpitMode !== undefined && (cockpitMode !== 'plugin' || surface !== 'codex' || cockpitDir !== undefined)) throw Error('AGDF_COCKPIT_TARGET_INVALID');
|
|
8
7
|
const defaultRuntime = runtime === undefined && cockpitDir === undefined && cockpitMode === undefined;
|
|
9
8
|
// A supplied, bound runtime owns its resources. Do not load a second package
|
|
10
9
|
// composition merely to run an explicit adapter/protocol fixture.
|
|
11
|
-
const
|
|
12
|
-
|
|
10
|
+
const thin = defaultRuntime && !!ownedPublication;
|
|
11
|
+
const dispatcherRuntime = runtime === undefined ? await import(thin ? 'create-agdf/mcp-protocol-runtime' : 'create-agdf/mcp-dispatch-runtime') : null;
|
|
12
|
+
runtime ??= cockpitDir !== undefined || cockpitMode !== undefined ? await dispatcherRuntime.createMcpCockpitRuntime({ surface, cockpitDir, cockpitMode })
|
|
13
|
+
: thin ? dispatcherRuntime.createMcpTransportRuntime({ surface }) : dispatcherRuntime.createMcpDispatchRuntime({ surface });
|
|
13
14
|
const serverVersion = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
14
15
|
if (runtime.trustedContext.expectedVersion !== serverVersion) {
|
|
15
16
|
process.stderr.write("AGDF_MCP_VERSION_MISMATCH\n");
|
|
@@ -21,21 +22,52 @@ export async function runMcpServer({ surface, cockpitDir, cockpitMode, runtime,
|
|
|
21
22
|
process.exitCode = 1;
|
|
22
23
|
return null;
|
|
23
24
|
}
|
|
24
|
-
if (
|
|
25
|
+
if (ownedPublication) {
|
|
26
|
+
// The serving protocol factory freshly inspects all selected source and SDK
|
|
27
|
+
// bytes. Bind that actual producer to the parent's claimed expectation;
|
|
28
|
+
// never treat a cross-thread/async observation as fresh inspection evidence.
|
|
29
|
+
const producer = runtime.producerIdentity;
|
|
30
|
+
if (!producer || producer.server_digest !== ownedPublication.digest
|
|
31
|
+
|| producer.dispatcher_digest !== ownedPublication.dispatcherDigest || producer.sdk_digest !== ownedPublication.sdkDigest)
|
|
32
|
+
throw Error('AGDF_MCP_FACADE_IDENTITY_INVALID');
|
|
33
|
+
}
|
|
34
|
+
if (defaultRuntime && surface === 'codex') {
|
|
25
35
|
const ui = { uri: dispatcherRuntime.DECISION_UI_URI, mimeType: 'text/html;profile=mcp-app', limit: 4 * 1024 ** 2, kind: 'decision' };
|
|
26
|
-
|
|
36
|
+
// The thin front has no session/qualification consumer. Its facade loads
|
|
37
|
+
// the resource for the real generation manifest; full runtimes need it here.
|
|
38
|
+
if (!thin) ui.digest = loadCockpitResource(ui).digest;
|
|
27
39
|
// A shipped resource does not qualify native App-only mediation. Production
|
|
28
40
|
// actions remain disabled until exact package/host evidence is provided.
|
|
29
|
-
runtime = await dispatcherRuntime.
|
|
41
|
+
runtime = thin ? await dispatcherRuntime.createMcpDecisionTransportRuntime({ runtime, ui })
|
|
42
|
+
: await dispatcherRuntime.createMcpDecisionRuntime({ runtime, ui });
|
|
30
43
|
}
|
|
31
44
|
if (runtime.mode === 'cockpit') loadCockpitResource(runtime.ui);
|
|
45
|
+
let transport;
|
|
32
46
|
if (ownedPublication) {
|
|
33
47
|
const { createOwnedGenerationFacade } = await import('./generation.js');
|
|
34
48
|
if (!ownedPublisher) throw Error('runtime_publication_unowned');
|
|
35
49
|
const publisher = ownedPublisher;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
50
|
+
let beginTransport;
|
|
51
|
+
const claimed = new Promise(resolve => { beginTransport = resolve; });
|
|
52
|
+
const starting = createOwnedGenerationFacade({ runtime, publisher, generationTransport, surface, cockpitDir, cockpitMode,
|
|
53
|
+
onEvent: event => { if (event.kind === 'qualifying') beginTransport(); },
|
|
54
|
+
createControlRuntime: async runtimeControl => {
|
|
55
|
+
const full = thin ? await import('create-agdf/mcp-dispatch-runtime') : dispatcherRuntime;
|
|
56
|
+
return full.createMcpDispatchRuntime({ surface, runtimeControl });
|
|
57
|
+
} });
|
|
58
|
+
// Only a claimed private-generation start releases the SDK import. A
|
|
59
|
+
// pre-claim failure rejects starting without loading transport code. Its full
|
|
60
|
+
// qualification and the facade SDK must both finish before public service.
|
|
61
|
+
// Observe both promises so a transport failure cannot abandon a live child.
|
|
62
|
+
const transportStarting = Promise.race([claimed, starting]).then(() => import('./server.js'));
|
|
63
|
+
const [generation, loaded] = await Promise.allSettled([starting, transportStarting]);
|
|
64
|
+
if (generation.status === 'rejected' || loaded.status === 'rejected') {
|
|
65
|
+
if (generation.status === 'fulfilled') await generation.value.close();
|
|
66
|
+
throw generation.status === 'rejected' ? generation.reason : loaded.reason;
|
|
67
|
+
}
|
|
68
|
+
runtime = generation.value; transport = loaded.value;
|
|
69
|
+
} else transport = await import('./server.js');
|
|
70
|
+
const { createAgdfWorkerExecutor, serveAgdfStdio } = transport;
|
|
39
71
|
const dispatchExecutor = executor ?? (runtime.mode === 'cockpit' ? undefined
|
|
40
72
|
: createAgdfWorkerExecutor(runtime, { releaseOwnedLock: dispatcherRuntime?.releaseAbandonedOwnedLock ?? null }));
|
|
41
73
|
const handle = serveAgdfStdio({
|
package/src/server.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { boundStdioOutput } from './stdio-output.js';
|
|
1
2
|
import { McpServer, ProtocolError, ProtocolErrorCode, fromJsonSchema } from "@modelcontextprotocol/server";
|
|
2
|
-
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
3
|
+
import { serveStdio, StdioServerTransport } from "@modelcontextprotocol/server/stdio";
|
|
3
4
|
import { AjvJsonSchemaValidator } from '@modelcontextprotocol/server/validators/ajv';
|
|
4
5
|
import { randomUUID } from 'node:crypto';
|
|
5
6
|
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import { DispatchExecutionError
|
|
7
|
+
import { DispatchExecutionError } from "./worker.js";
|
|
7
8
|
import { loadCockpitResource } from './cockpit-resource.js';
|
|
8
9
|
import { requestDeclaresMcpAppUi, requestMcpClientInfo, requestMcpClientCapabilities, requiresMcpAppUi,
|
|
9
10
|
missingMcpAppUi, capabilityTransport, modernToolUiDenial } from "./ui-capability.js";
|
|
@@ -95,7 +96,15 @@ export function buildAgdfServer({ runtime, executor, era } = {}) {
|
|
|
95
96
|
} };
|
|
96
97
|
for (const tool of tools) {
|
|
97
98
|
const { name, definition, serialize } = tool;
|
|
98
|
-
const schema = fromJsonSchema(definition.inputSchema,
|
|
99
|
+
const schema = fromJsonSchema(definition.inputSchema, { getValidator(inputSchema) {
|
|
100
|
+
const validate = deferredSchemas.getValidator(inputSchema);
|
|
101
|
+
return input => {
|
|
102
|
+
const checked = validate(input);
|
|
103
|
+
if (checked.valid) return checked;
|
|
104
|
+
const hint = runtime.inputValidationHint?.(name, input);
|
|
105
|
+
return hint ? { ...checked, errorMessage: `${hint} Schema: ${checked.errorMessage}` } : checked;
|
|
106
|
+
};
|
|
107
|
+
} });
|
|
99
108
|
const registered = server.registerTool(
|
|
100
109
|
name,
|
|
101
110
|
{
|
|
@@ -106,6 +115,7 @@ export function buildAgdfServer({ runtime, executor, era } = {}) {
|
|
|
106
115
|
...(definition._meta ? { _meta: definition._meta } : {}),
|
|
107
116
|
},
|
|
108
117
|
async (argumentsValue, context) => {
|
|
118
|
+
const started = performance.now();
|
|
109
119
|
const denied = !uiAllowed(context) ? runtime.unsupportedUiCode ?? 'resource_denied'
|
|
110
120
|
: tool.requiresUi && !supportsUi(context) ? 'host_ui_unsupported' : null;
|
|
111
121
|
if (denied) {
|
|
@@ -113,20 +123,25 @@ export function buildAgdfServer({ runtime, executor, era } = {}) {
|
|
|
113
123
|
const failure = runtime.projectToolResult(name, argumentsValue, runtime.failure(denied, name, argumentsValue, facts), facts);
|
|
114
124
|
return respond(name, argumentsValue, failure, serialize ?? runtime.serialize, context, facts);
|
|
115
125
|
}
|
|
116
|
-
|
|
126
|
+
const parentOwned = typeof tool.parentOwned === 'function' ? tool.parentOwned(argumentsValue) : tool.parentOwned;
|
|
127
|
+
let phase = parentOwned ? 'parse' : 'execute';
|
|
117
128
|
try {
|
|
129
|
+
phase = 'parse';
|
|
130
|
+
argumentsValue = runtime.prepareInput?.(name, argumentsValue) ?? argumentsValue;
|
|
131
|
+
phase = parentOwned ? 'parse' : 'execute';
|
|
118
132
|
let result;
|
|
119
|
-
if (
|
|
133
|
+
if (parentOwned) {
|
|
120
134
|
const parsed = tool.parse(argumentsValue); phase = 'execute';
|
|
121
135
|
result = await tool.execute(parsed, context.signal,
|
|
122
136
|
{ client: requestMcpClientInfo(server.server, context), uiSupported: supportsUi(context) });
|
|
123
|
-
} else result = await dispatchExecutor.execute(argumentsValue, { signal: context.signal, toolName: name
|
|
137
|
+
} else result = await dispatchExecutor.execute(argumentsValue, { signal: context.signal, toolName: name,
|
|
138
|
+
trusted: runtime.prepareDispatch?.(name, argumentsValue) });
|
|
124
139
|
if (runtime.postprocessToolResult) result = await runtime.postprocessToolResult(name, argumentsValue, result,
|
|
125
140
|
{ uiSupported: supportsUi(context), client: requestMcpClientInfo(server.server, context) });
|
|
126
141
|
return respond(name, argumentsValue, result, serialize ?? runtime.serialize, context);
|
|
127
142
|
} catch (error) {
|
|
128
143
|
const facts = runtime.normalizeFailure(error, { phase });
|
|
129
|
-
const failure = runtime.failure(facts.code, name, argumentsValue, facts);
|
|
144
|
+
const failure = runtime.failure(facts.code, name, argumentsValue, { ...facts, elapsed_ms: performance.now() - started });
|
|
130
145
|
// A local execution/delivery failure is owned here, including malformed
|
|
131
146
|
// producer output. It never causes re-execution or a second write.
|
|
132
147
|
const projected = runtime.projectToolResult(name, argumentsValue, failure, facts);
|
|
@@ -174,6 +189,7 @@ export function serveAgdfStdio({ runtime, executor, onerror } = {}) {
|
|
|
174
189
|
return server;
|
|
175
190
|
}, {
|
|
176
191
|
legacy: "serve",
|
|
192
|
+
transport: boundStdioOutput(new StdioServerTransport()),
|
|
177
193
|
onerror: () => onerror?.("AGDF_MCP_TRANSPORT_ERROR"),
|
|
178
194
|
});
|
|
179
195
|
return Object.freeze({
|
|
@@ -190,11 +206,4 @@ export function serveAgdfStdio({ runtime, executor, onerror } = {}) {
|
|
|
190
206
|
});
|
|
191
207
|
}
|
|
192
208
|
|
|
193
|
-
export
|
|
194
|
-
return createWorkerDispatchExecutor({
|
|
195
|
-
surface: runtime.trustedContext.surface,
|
|
196
|
-
expectedVersion: runtime.trustedContext.expectedVersion,
|
|
197
|
-
commandGroups: runtime.trustedContext.commandGroups,
|
|
198
|
-
...options,
|
|
199
|
-
});
|
|
200
|
-
}
|
|
209
|
+
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
|
+
}
|