@agdf/mcp-server 0.30.0 → 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/src/server.js CHANGED
@@ -1,3 +1,6 @@
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';
1
4
  import { boundStdioOutput } from './stdio-output.js';
2
5
  import { McpServer, ProtocolError, ProtocolErrorCode, fromJsonSchema } from "@modelcontextprotocol/server";
3
6
  import { serveStdio, StdioServerTransport } from "@modelcontextprotocol/server/stdio";
@@ -11,14 +14,40 @@ import { requestDeclaresMcpAppUi, requestMcpClientInfo, requestMcpClientCapabili
11
14
 
12
15
  export const SERVER_NAME = "agdf-mcp";
13
16
 
14
- function toolResult(runtime, name, input, result, serialize = runtime.serialize, provider, peer, facts) {
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 } = {}) {
15
32
  // A generation must supply its own descriptor. Never reinterpret old results
16
33
  // with the facade's current runtime. Local owners project after postprocessing.
17
- const emit = value => {
34
+ const emit = (value, { followup, decorate = true } = {}) => {
18
35
  const projected = Object.hasOwn(value._meta ?? {}, 'agdf/tool-result') || runtime.requiresProjectedResult ? value
19
36
  : runtime.projectToolResult(name, input, value, facts);
20
- const { domain, metadata, descriptor } = runtime.consumeToolResult(name, projected);
21
- const text = serialize(domain);
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);
22
51
  const response = {
23
52
  content: [{ type: 'text', text: domain.presentation_delivery?.text ?? text },
24
53
  ...(descriptor.failed ? [{ type: 'text', text: descriptor.message }] : [])],
@@ -29,18 +58,29 @@ function toolResult(runtime, name, input, result, serialize = runtime.serialize,
29
58
  };
30
59
  return response;
31
60
  };
32
- let response = emit(result);
33
- if (runtime.responseLimit && Buffer.byteLength(JSON.stringify(response), 'utf8') > runtime.responseLimit) {
34
- // The fallback itself is projected, checked and measured; no recursive call.
35
- const limitFacts = { code: 'resource_limit', dispatch: 'unknown' };
36
- const failure = runtime.projectToolResult(name, input, runtime.failure('resource_limit', name, input, limitFacts), limitFacts);
37
- response = emit(failure);
38
- if (Buffer.byteLength(JSON.stringify(response), 'utf8') > runtime.responseLimit) throw Error('tool_result_invalid');
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;
39
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');
40
80
  return response;
41
81
  }
42
82
 
43
- export function buildAgdfServer({ runtime, executor, era } = {}) {
83
+ export function buildAgdfServer({ runtime, executor, era, preparedCallStore } = {}) {
44
84
  if (!runtime?.definition || typeof runtime.serialize !== "function") {
45
85
  throw new TypeError("AGDF MCP runtime is required");
46
86
  }
@@ -48,6 +88,15 @@ export function buildAgdfServer({ runtime, executor, era } = {}) {
48
88
  throw new TypeError("AGDF MCP owned runtime is required");
49
89
  }
50
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);
51
100
  const dispatchExecutor = executor ?? {
52
101
  execute: async (argumentsValue, { toolName, signal } = {}) => {
53
102
  const tool = tools.find((entry) => entry.name === (toolName ?? runtime.definition.name));
@@ -59,7 +108,14 @@ export function buildAgdfServer({ runtime, executor, era } = {}) {
59
108
  },
60
109
  close: async () => runtime.close?.(),
61
110
  };
62
- 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) });
63
119
  class AgdfServer extends McpServer {
64
120
  async connect(transport) {
65
121
  return super.connect(capabilityTransport(transport, message => modernToolUiDenial(message,
@@ -68,7 +124,8 @@ export function buildAgdfServer({ runtime, executor, era } = {}) {
68
124
  }
69
125
  const server = new AgdfServer(
70
126
  { name: SERVER_NAME, version: runtime.trustedContext.expectedVersion },
71
- { 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.' },
72
129
  );
73
130
  // Capability checks guard execution and resource access. Codex must first
74
131
  // discover the plugin tool metadata before selecting its UI-capable route.
@@ -81,26 +138,61 @@ export function buildAgdfServer({ runtime, executor, era } = {}) {
81
138
  connection_id: randomUUID(), server_module: fileURLToPath(import.meta.url),
82
139
  version: runtime.trustedContext.expectedVersion,
83
140
  }) : undefined;
84
- const respond = (name, input, result, serialize, context, facts) => toolResult(runtime, name, input, result, serialize, provider, provider ? {
85
- client_info: requestMcpClientInfo(server.server, context), capabilities: requestMcpClientCapabilities(server.server, context),
86
- } : undefined, facts);
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
+ };
87
161
  const uiAllowed = (context) => !gated || requestDeclaresMcpAppUi(server.server, context, runtime.ui.mimeType);
88
162
  const supportsUi = context => !!runtime.ui && requestDeclaresMcpAppUi(server.server, context, runtime.ui.mimeType);
89
- // Discovery needs the exact schemas, but does not execute any tool. Compile
90
- // each validator on its first validation, using the same SDK provider/options.
91
- // Input validation still precedes execution; successful output is still checked.
92
- const schemaProvider = new AjvJsonSchemaValidator();
93
- const deferredSchemas = { getValidator(schema) {
94
- let validate;
95
- return value => (validate ??= schemaProvider.getValidator(schema))(value);
96
- } };
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
+ });
97
185
  for (const tool of tools) {
98
186
  const { name, definition, serialize } = tool;
99
- const schema = fromJsonSchema(definition.inputSchema, { getValidator(inputSchema) {
187
+ const schema = fromJsonSchema(preparedTransportSchema(name, definition.inputSchema), { getValidator(inputSchema) {
100
188
  const validate = deferredSchemas.getValidator(inputSchema);
101
189
  return input => {
102
190
  const checked = validate(input);
103
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 };
104
196
  const hint = runtime.inputValidationHint?.(name, input);
105
197
  return hint ? { ...checked, errorMessage: `${hint} Schema: ${checked.errorMessage}` } : checked;
106
198
  };
@@ -111,7 +203,7 @@ export function buildAgdfServer({ runtime, executor, era } = {}) {
111
203
  description: definition.description,
112
204
  annotations: definition.annotations,
113
205
  inputSchema: schema,
114
- ...(definition.outputSchema ? { outputSchema: fromJsonSchema(definition.outputSchema, deferredSchemas) } : {}),
206
+ ...(definition.outputSchema ? { outputSchema: fromJsonSchema(nextActionOutputSchema(name, definition.outputSchema), deferredSchemas) } : {}),
115
207
  ...(definition._meta ? { _meta: definition._meta } : {}),
116
208
  },
117
209
  async (argumentsValue, context) => {
@@ -123,10 +215,12 @@ export function buildAgdfServer({ runtime, executor, era } = {}) {
123
215
  const failure = runtime.projectToolResult(name, argumentsValue, runtime.failure(denied, name, argumentsValue, facts), facts);
124
216
  return respond(name, argumentsValue, failure, serialize ?? runtime.serialize, context, facts);
125
217
  }
126
- const parentOwned = typeof tool.parentOwned === 'function' ? tool.parentOwned(argumentsValue) : tool.parentOwned;
127
- let phase = parentOwned ? 'parse' : 'execute';
218
+ let parentOwned;
219
+ let phase = 'parse';
128
220
  try {
129
221
  phase = 'parse';
222
+ argumentsValue = preparedCalls.resolve(name, argumentsValue);
223
+ parentOwned = typeof tool.parentOwned === 'function' ? tool.parentOwned(argumentsValue) : tool.parentOwned;
130
224
  argumentsValue = runtime.prepareInput?.(name, argumentsValue) ?? argumentsValue;
131
225
  phase = parentOwned ? 'parse' : 'execute';
132
226
  let result;
@@ -138,13 +232,23 @@ export function buildAgdfServer({ runtime, executor, era } = {}) {
138
232
  trusted: runtime.prepareDispatch?.(name, argumentsValue) });
139
233
  if (runtime.postprocessToolResult) result = await runtime.postprocessToolResult(name, argumentsValue, result,
140
234
  { uiSupported: supportsUi(context), client: requestMcpClientInfo(server.server, context) });
141
- return respond(name, argumentsValue, result, serialize ?? runtime.serialize, context);
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);
142
243
  } catch (error) {
143
244
  const facts = runtime.normalizeFailure(error, { phase });
144
- const failure = runtime.failure(facts.code, name, argumentsValue, { ...facts, elapsed_ms: performance.now() - started });
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 } : {}) };
145
248
  // A local execution/delivery failure is owned here, including malformed
146
249
  // producer output. It never causes re-execution or a second write.
147
250
  const projected = runtime.projectToolResult(name, argumentsValue, failure, facts);
251
+ if (journalingFailures()) journal(name, argumentsValue, projected);
148
252
  return respond(name, argumentsValue, projected, serialize ?? runtime.serialize, context, facts);
149
253
  }
150
254
  },
@@ -166,7 +270,7 @@ export function buildAgdfServer({ runtime, executor, era } = {}) {
166
270
  const listedTools = tools.map(({ name, definition }) => ({
167
271
  name, description: definition.description, inputSchema: server.toolInputSchemaJson(name),
168
272
  annotations: definition.annotations, ...(definition._meta ? { _meta: definition._meta } : {}),
169
- ...(definition.outputSchema ? { outputSchema: definition.outputSchema } : {}),
273
+ ...(definition.outputSchema ? { outputSchema: nextActionOutputSchema(name, definition.outputSchema) } : {}),
170
274
  }));
171
275
  const listedResource = { uri: resource.uri, name: runtime.ui.label ?? 'AGDF Cockpit', mimeType: resource.mimeType, _meta: resource._meta };
172
276
  server.server.removeRequestHandler("tools/list");
@@ -177,14 +281,14 @@ export function buildAgdfServer({ runtime, executor, era } = {}) {
177
281
  }
178
282
  }
179
283
  return Object.assign(server, {
180
- closeAgdfRuntime: async () => { await dispatchExecutor.close(); return runtime.close?.(); },
284
+ closeAgdfRuntime: async () => { preparedCalls.close(); actions.close(); await dispatchExecutor.close(); return runtime.close?.(); },
181
285
  });
182
286
  }
183
287
 
184
- export function serveAgdfStdio({ runtime, executor, onerror } = {}) {
288
+ export function serveAgdfStdio({ runtime, executor, onerror, preparedCallStore } = {}) {
185
289
  const instances = new Set();
186
290
  const handle = serveStdio(({ era }) => {
187
- const server = buildAgdfServer({ runtime, executor, era });
291
+ const server = buildAgdfServer({ runtime, executor, era, preparedCallStore });
188
292
  instances.add(server);
189
293
  return server;
190
294
  }, {