agentxchain 2.4.0 → 2.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,7 +17,7 @@
17
17
  * - local_cli: implemented via subprocess dispatch + staged turn result
18
18
  * - api_proxy: implemented for synchronous review-only turns and stages
19
19
  * provider-backed JSON before validation/acceptance
20
- * - mcp: implemented for synchronous MCP stdio tool dispatch
20
+ * - mcp: implemented for synchronous MCP stdio or streamable_http tool dispatch
21
21
  */
22
22
 
23
23
  import chalk from 'chalk';
@@ -46,7 +46,7 @@ import {
46
46
  saveDispatchLogs,
47
47
  resolvePromptTransport,
48
48
  } from '../lib/adapters/local-cli-adapter.js';
49
- import { dispatchMcp } from '../lib/adapters/mcp-adapter.js';
49
+ import { describeMcpRuntimeTarget, dispatchMcp, resolveMcpTransport } from '../lib/adapters/mcp-adapter.js';
50
50
  import {
51
51
  getDispatchAssignmentPath,
52
52
  getDispatchContextPath,
@@ -426,7 +426,8 @@ export async function stepCommand(opts) {
426
426
  }
427
427
  console.log('');
428
428
  } else if (runtimeType === 'mcp') {
429
- console.log(chalk.cyan(`Dispatching to MCP stdio: ${runtime?.command || '(unknown)'}`));
429
+ const mcpTransport = resolveMcpTransport(runtime);
430
+ console.log(chalk.cyan(`Dispatching to MCP ${mcpTransport}: ${describeMcpRuntimeTarget(runtime)}`));
430
431
  console.log(chalk.dim(`Turn: ${turn.turn_id} Role: ${roleId} Phase: ${state.phase} Tool: ${runtime?.tool_name || 'agentxchain_turn'}`));
431
432
 
432
433
  const mcpResult = await dispatchMcp(root, state, config, {
@@ -2,6 +2,7 @@ import { mkdirSync, existsSync, readFileSync, writeFileSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
4
4
  import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
5
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
5
6
  import {
6
7
  getDispatchAssignmentPath,
7
8
  getDispatchContextPath,
@@ -13,12 +14,13 @@ import {
13
14
  import { verifyDispatchManifestForAdapter } from '../dispatch-manifest.js';
14
15
 
15
16
  export const DEFAULT_MCP_TOOL_NAME = 'agentxchain_turn';
17
+ export const DEFAULT_MCP_TRANSPORT = 'stdio';
16
18
 
17
19
  /**
18
- * Dispatch a governed turn to an MCP server over stdio.
20
+ * Dispatch a governed turn to an MCP server.
19
21
  *
20
- * v1 scope:
21
- * - stdio transport only
22
+ * Current scope:
23
+ * - stdio or streamable_http transport
22
24
  * - single tool call per turn
23
25
  * - required governed-turn tool contract
24
26
  * - synchronous dispatch/wait flow (like api_proxy)
@@ -55,7 +57,8 @@ export async function dispatchMcp(root, state, config, options = {}) {
55
57
  const prompt = readFileSync(promptPath, 'utf8');
56
58
  const context = existsSync(contextPath) ? readFileSync(contextPath, 'utf8') : '';
57
59
  const { command, args } = resolveMcpCommand(runtime);
58
- if (!command) {
60
+ const transportType = resolveMcpTransport(runtime);
61
+ if (transportType === 'stdio' && !command) {
59
62
  return { ok: false, error: `Cannot resolve MCP command for runtime "${runtimeId}". Expected "command" field in runtime config.` };
60
63
  }
61
64
 
@@ -68,20 +71,16 @@ export async function dispatchMcp(root, state, config, options = {}) {
68
71
  const stagingDir = join(root, getTurnStagingDir(turn.turn_id));
69
72
  mkdirSync(stagingDir, { recursive: true });
70
73
 
71
- const transport = new StdioClientTransport({
74
+ const transport = buildMcpClientTransport({
75
+ root,
76
+ runtime,
72
77
  command,
73
78
  args,
74
- cwd: runtime.cwd ? join(root, runtime.cwd) : root,
75
- env: buildTransportEnv(process.env),
76
- stderr: 'pipe',
79
+ logs,
80
+ onStderr,
77
81
  });
78
-
79
- if (transport.stderr) {
80
- transport.stderr.on('data', (chunk) => {
81
- const text = chunk.toString();
82
- logs.push(`[stderr] ${text}`);
83
- if (onStderr) onStderr(text);
84
- });
82
+ if (!transport.ok) {
83
+ return { ok: false, error: transport.error, logs };
85
84
  }
86
85
 
87
86
  const client = new Client({
@@ -100,8 +99,8 @@ export async function dispatchMcp(root, state, config, options = {}) {
100
99
  return { ok: false, aborted: true, logs };
101
100
  }
102
101
 
103
- onStatus?.(`Connecting to MCP stdio server (${command})`);
104
- await client.connect(transport);
102
+ onStatus?.(`Connecting to MCP ${transportType} server (${describeMcpRuntimeTarget(runtime)})`);
103
+ await client.connect(transport.transport);
105
104
 
106
105
  if (signal?.aborted) {
107
106
  return { ok: false, aborted: true, logs };
@@ -182,7 +181,7 @@ export async function dispatchMcp(root, state, config, options = {}) {
182
181
  logs,
183
182
  };
184
183
  } finally {
185
- await safeCloseClient(client, transport);
184
+ await safeCloseClient(client, transport.transport);
186
185
  }
187
186
  }
188
187
 
@@ -206,6 +205,18 @@ export function resolveMcpToolName(runtime) {
206
205
  : DEFAULT_MCP_TOOL_NAME;
207
206
  }
208
207
 
208
+ export function resolveMcpTransport(runtime) {
209
+ return typeof runtime?.transport === 'string' && runtime.transport.trim()
210
+ ? runtime.transport.trim()
211
+ : DEFAULT_MCP_TRANSPORT;
212
+ }
213
+
214
+ export function describeMcpRuntimeTarget(runtime) {
215
+ return resolveMcpTransport(runtime) === 'streamable_http'
216
+ ? runtime?.url || '(unknown)'
217
+ : resolveMcpCommand(runtime).command || '(unknown)';
218
+ }
219
+
209
220
  export function extractTurnResultFromMcpToolResult(toolResult) {
210
221
  const directCandidates = [
211
222
  toolResult?.structuredContent,
@@ -268,6 +279,58 @@ function buildTransportEnv(env) {
268
279
  return result;
269
280
  }
270
281
 
282
+ function buildMcpClientTransport({ root, runtime, command, args, logs, onStderr }) {
283
+ if (resolveMcpTransport(runtime) === 'streamable_http') {
284
+ try {
285
+ const requestHeaders = buildRequestHeaders(runtime?.headers);
286
+ return {
287
+ ok: true,
288
+ transport: new StreamableHTTPClientTransport(new URL(runtime.url), {
289
+ requestInit: requestHeaders ? { headers: requestHeaders } : undefined,
290
+ }),
291
+ };
292
+ } catch (error) {
293
+ logs.push(`[transport-error] ${error.message}`);
294
+ return {
295
+ ok: false,
296
+ error: `Cannot resolve MCP streamable_http runtime: ${error.message}`,
297
+ };
298
+ }
299
+ }
300
+
301
+ const transport = new StdioClientTransport({
302
+ command,
303
+ args,
304
+ cwd: runtime.cwd ? join(root, runtime.cwd) : root,
305
+ env: buildTransportEnv(process.env),
306
+ stderr: 'pipe',
307
+ });
308
+
309
+ if (transport.stderr) {
310
+ transport.stderr.on('data', (chunk) => {
311
+ const text = chunk.toString();
312
+ logs.push(`[stderr] ${text}`);
313
+ if (onStderr) onStderr(text);
314
+ });
315
+ }
316
+
317
+ return { ok: true, transport };
318
+ }
319
+
320
+ function buildRequestHeaders(headers) {
321
+ if (!headers || typeof headers !== 'object' || Array.isArray(headers)) {
322
+ return null;
323
+ }
324
+
325
+ const result = {};
326
+ for (const [key, value] of Object.entries(headers)) {
327
+ if (typeof value === 'string') {
328
+ result[key] = value;
329
+ }
330
+ }
331
+ return Object.keys(result).length > 0 ? result : null;
332
+ }
333
+
271
334
  function isPlainObject(value) {
272
335
  return !!value && typeof value === 'object' && !Array.isArray(value);
273
336
  }
@@ -19,6 +19,7 @@ const VALID_WRITE_AUTHORITIES = ['authoritative', 'proposed', 'review_only'];
19
19
  const VALID_RUNTIME_TYPES = ['manual', 'local_cli', 'api_proxy', 'mcp'];
20
20
  const VALID_API_PROXY_PROVIDERS = ['anthropic', 'openai'];
21
21
  const VALID_PROMPT_TRANSPORTS = ['argv', 'stdin', 'dispatch_bundle_only'];
22
+ const VALID_MCP_TRANSPORTS = ['stdio', 'streamable_http'];
22
23
  const VALID_PHASES = ['planning', 'implementation', 'qa'];
23
24
  const VALID_API_PROXY_RETRY_JITTER = ['none', 'full'];
24
25
  const VALID_API_PROXY_RETRY_CLASSES = [
@@ -47,8 +48,60 @@ const VALID_API_PROXY_PREFLIGHT_FIELDS = [
47
48
  ];
48
49
 
49
50
  function validateMcpRuntime(runtimeId, runtime, errors) {
51
+ const transport = typeof runtime?.transport === 'string' && runtime.transport.trim()
52
+ ? runtime.transport.trim()
53
+ : 'stdio';
50
54
  const command = runtime?.command;
51
55
 
56
+ if (!VALID_MCP_TRANSPORTS.includes(transport)) {
57
+ errors.push(`Runtime "${runtimeId}": mcp transport must be one of: ${VALID_MCP_TRANSPORTS.join(', ')}`);
58
+ }
59
+
60
+ if ('tool_name' in runtime && (typeof runtime.tool_name !== 'string' || !runtime.tool_name.trim())) {
61
+ errors.push(`Runtime "${runtimeId}": mcp tool_name must be a non-empty string`);
62
+ }
63
+
64
+ if (transport === 'streamable_http') {
65
+ if (typeof runtime?.url !== 'string' || !runtime.url.trim()) {
66
+ errors.push(`Runtime "${runtimeId}": mcp streamable_http requires "url"`);
67
+ } else {
68
+ try {
69
+ const parsed = new URL(runtime.url);
70
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
71
+ errors.push(`Runtime "${runtimeId}": mcp url must use http or https`);
72
+ }
73
+ } catch {
74
+ errors.push(`Runtime "${runtimeId}": mcp url must be a valid absolute URL`);
75
+ }
76
+ }
77
+
78
+ if ('headers' in runtime) {
79
+ if (!runtime.headers || typeof runtime.headers !== 'object' || Array.isArray(runtime.headers)) {
80
+ errors.push(`Runtime "${runtimeId}": mcp headers must be an object of string values`);
81
+ } else {
82
+ for (const [key, value] of Object.entries(runtime.headers)) {
83
+ if (typeof value !== 'string') {
84
+ errors.push(`Runtime "${runtimeId}": mcp headers["${key}"] must be a string`);
85
+ }
86
+ }
87
+ }
88
+ }
89
+
90
+ if ('command' in runtime) {
91
+ errors.push(`Runtime "${runtimeId}": mcp streamable_http does not accept "command"`);
92
+ }
93
+
94
+ if ('args' in runtime) {
95
+ errors.push(`Runtime "${runtimeId}": mcp streamable_http does not accept "args"`);
96
+ }
97
+
98
+ if ('cwd' in runtime) {
99
+ errors.push(`Runtime "${runtimeId}": mcp streamable_http does not accept "cwd"`);
100
+ }
101
+
102
+ return;
103
+ }
104
+
52
105
  if (typeof command === 'string') {
53
106
  if (!command.trim()) {
54
107
  errors.push(`Runtime "${runtimeId}": mcp command must be a non-empty string`);
@@ -67,13 +120,17 @@ function validateMcpRuntime(runtimeId, runtime, errors) {
67
120
  }
68
121
  }
69
122
 
70
- if ('tool_name' in runtime && (typeof runtime.tool_name !== 'string' || !runtime.tool_name.trim())) {
71
- errors.push(`Runtime "${runtimeId}": mcp tool_name must be a non-empty string`);
72
- }
73
-
74
123
  if ('cwd' in runtime && (typeof runtime.cwd !== 'string' || !runtime.cwd.trim())) {
75
124
  errors.push(`Runtime "${runtimeId}": mcp cwd must be a non-empty string`);
76
125
  }
126
+
127
+ if ('url' in runtime) {
128
+ errors.push(`Runtime "${runtimeId}": mcp stdio does not accept "url"`);
129
+ }
130
+
131
+ if ('headers' in runtime) {
132
+ errors.push(`Runtime "${runtimeId}": mcp stdio does not accept "headers"`);
133
+ }
77
134
  }
78
135
 
79
136
  function validateApiProxyRetryPolicy(runtimeId, retryPolicy, errors) {