Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions apps/daemon/src/agent-protocol/acp/session-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export interface AcpMcpServerInput {
command?: unknown;
args?: unknown;
env?: unknown;
url?: unknown;
headers?: unknown;
}
/**
* Options accepted by `buildAcpSessionNewParams` controlling optional MCP
Expand Down Expand Up @@ -52,6 +54,27 @@ export function buildAcpSessionNewParams(cwd: string, { mcpServers, envFormat =
// auto-install or mutate user/global MCP config; callers must pass an
// explicit per-session MCP descriptor when a compatible agent supports it.
mcpServers: servers.map((s) => {
const rawType = typeof s?.type === 'string' ? s.type : 'stdio';

// Non-stdio servers (sse/http): emit url + headers, no command/args/env.
if (rawType === 'sse' || rawType === 'http') {
const rawHeaders = s?.headers;
const headers = Array.isArray(rawHeaders)
? rawHeaders
: rawHeaders && typeof rawHeaders === 'object' && !Array.isArray(rawHeaders)
? Object.entries(rawHeaders as Record<string, string>).map(
([name, value]) => ({ name, value }),
)
: [];
return {
type: rawType,
name: typeof s?.name === 'string' ? s.name : '',
url: typeof s?.url === 'string' ? s.url : '',
headers,
};
}

// stdio servers: emit command/args/env as before.
const rawEnv = s?.env;
// Already a plain object — pass through in map mode, convert to
// array in array mode (e.g. live-artifacts MCP from
Expand All @@ -61,7 +84,7 @@ export function buildAcpSessionNewParams(cwd: string, { mcpServers, envFormat =
rawEnv && typeof rawEnv === 'object' && !Array.isArray(rawEnv);
if (wantsMap && isPlainObject) {
return {
type: typeof s?.type === 'string' ? s.type : 'stdio',
type: 'stdio',
name: typeof s?.name === 'string' ? s.name : '',
command: typeof s?.command === 'string' ? s.command : '',
args: Array.isArray(s?.args) ? s.args : [],
Expand All @@ -77,7 +100,7 @@ export function buildAcpSessionNewParams(cwd: string, { mcpServers, envFormat =
)
: envArr;
return {
type: typeof s?.type === 'string' ? s.type : 'stdio',
type: 'stdio',
name: typeof s?.name === 'string' ? s.name : '',
command: typeof s?.command === 'string' ? s.command : '',
args: Array.isArray(s?.args) ? s.args : [],
Expand Down
71 changes: 47 additions & 24 deletions apps/daemon/src/mcp-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,36 +371,59 @@ function mergeAuthHeader(

/**
* Convert user-configured external MCP servers into the ACP `mcpServers`
* shape that Hermes/Kimi accept (already in use by buildLiveArtifactsMcpServersForAgent).
* SSE/HTTP servers are dropped — ACP currently models stdio only — but we
* surface a warning so the UI can hint at it.
* shape (already in use by buildLiveArtifactsMcpServersForAgent).
*
* All three transports (stdio, sse, http) are forwarded. `tokens` is an
* optional map of `serverId → bearer access token`, populated by the
* daemon's OAuth flow. The bearer is injected as `Authorization: Bearer`
* via `mergeAuthHeader`, unless the user pinned a non-empty Authorization
* header in the server config.
*/
export interface AcpMcpServer {
type: 'stdio';
name: string;
command: string;
args: string[];
env: Array<{ name: string; value: string }>;
}
export type AcpMcpServer =
| { type: 'stdio'; name: string; command: string; args: string[]; env: Array<{ name: string; value: string }> }
| { type: 'sse' | 'http'; name: string; url: string; headers: Array<{ name: string; value: string }> };

export function buildAcpMcpServers(servers: McpServerConfig[]): AcpMcpServer[] {
const enabled = servers.filter((s) => s.enabled && s.transport === 'stdio');
export function buildAcpMcpServers(
servers: McpServerConfig[],
tokens: Record<string, string> = {},
): AcpMcpServer[] {
const enabled = servers.filter((s) => s.enabled);
const out: AcpMcpServer[] = [];
for (const s of enabled) {
const envEntries: Array<{ name: string; value: string }> = [];
if (s.env) {
for (const [name, value] of Object.entries(s.env)) {
if (typeof value !== 'string') continue;
envEntries.push({ name, value });
if (s.transport === 'stdio') {
const envEntries: Array<{ name: string; value: string }> = [];
if (s.env) {
for (const [name, value] of Object.entries(s.env)) {
if (typeof value !== 'string') continue;
envEntries.push({ name, value });
}
}
out.push({
type: 'stdio',
name: s.id,
command: s.command ?? '',
args: Array.isArray(s.args) ? [...s.args] : [],
env: envEntries,
});
} else {
// sse | http
const headers = mergeAuthHeader(
s.headers,
effectiveMcpAuthMode(s) === 'oauth' ? tokens[s.id] : undefined,
);
const headerEntries: Array<{ name: string; value: string }> = [];
if (headers) {
for (const [name, value] of Object.entries(headers)) {
headerEntries.push({ name, value });
}
}
out.push({
type: s.transport,
name: s.id,
url: s.url ?? '',
headers: headerEntries,
});
}
out.push({
type: 'stdio',
name: s.id,
command: s.command ?? '',
args: Array.isArray(s.args) ? [...s.args] : [],
env: envEntries,
});
}
return out;
}
Expand Down
1 change: 1 addition & 0 deletions apps/daemon/src/runtimes/defs/hermes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,5 +48,6 @@ export const hermesAgentDef = {
buildArgs: () => ['acp', '--accept-hooks'],
streamFormat: 'acp-json-rpc',
mcpDiscovery: 'mature-acp',
acpMcpTransports: ['stdio', 'sse', 'http'],
externalMcpInjection: 'acp-merge',
} satisfies RuntimeAgentDef;
1 change: 1 addition & 0 deletions apps/daemon/src/runtimes/defs/kimi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,6 @@ export const kimiAgentDef = {
buildArgs: () => ['acp'],
streamFormat: 'acp-json-rpc',
mcpDiscovery: 'mature-acp',
acpMcpTransports: ['stdio'],
externalMcpInjection: 'acp-merge',
} satisfies RuntimeAgentDef;
1 change: 1 addition & 0 deletions apps/daemon/src/runtimes/defs/reasonix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export const reasonixAgentDef = {
buildArgs: () => ['acp'],
streamFormat: 'acp-json-rpc',
mcpDiscovery: 'mature-acp',
acpMcpTransports: ['stdio'],
externalMcpInjection: 'acp-merge',
acpMcpEnvFormat: 'map',
env: {
Expand Down
1 change: 1 addition & 0 deletions apps/daemon/src/runtimes/defs/trae-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,6 @@ export const traeCliAgentDef = {
buildArgs: () => ['acp', 'serve', '--yolo'],
streamFormat: 'acp-json-rpc',
mcpDiscovery: 'mature-acp',
acpMcpTransports: ['stdio'],
externalMcpInjection: 'acp-merge',
} satisfies RuntimeAgentDef;
8 changes: 8 additions & 0 deletions apps/daemon/src/runtimes/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,14 @@ export type RuntimeAgentDef = {
supportsImagePaths?: boolean;
maxPromptArgBytes?: number;
mcpDiscovery?: string;
/**
* MCP transports this ACP runtime can receive at spawn time. When present,
* the daemon gates HTTP/SSE forwarding on this list instead of
* `mcpDiscovery`. Runtimes without this field fall back to stdio-only.
* Set only on runtimes whose installed implementation is verified to
* accept `McpServerHttp` / `McpServerSse` descriptors via ACP.
*/
acpMcpTransports?: ('stdio' | 'sse' | 'http')[];
// How the daemon forwards the user's `.od/mcp-config.json` external MCP
// servers to this runtime at spawn time. The shape of the injection
// is one of three strategies, each of which the server.ts spawn
Expand Down
9 changes: 8 additions & 1 deletion apps/daemon/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11375,7 +11375,14 @@ export async function startServer({
enabledExternalMcp.length > 0 &&
def.externalMcpInjection === 'acp-merge'
) {
const acpExternal = buildAcpMcpServers(enabledExternalMcp);
// Gate forwarding on `acpMcpTransports` — filter each server by
// whether its transport is in the runtime's capability list.
// Runtimes without this field fall back to stdio-only.
const supportedTransports = def.acpMcpTransports ?? ['stdio'];
const acpExternal = buildAcpMcpServers(
enabledExternalMcp.filter((s) => supportedTransports.includes(s.transport)),
oauthTokensForSpawn,
);
mcpServers.push(...acpExternal);
}
// OpenCode: serialise enabled MCP servers into its `mcp` config schema
Expand Down
10 changes: 7 additions & 3 deletions apps/daemon/tests/acp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,17 +47,21 @@ test('ACP session params normalize explicit MCP servers to ACP stdio shape', ()
});
});

test('ACP session params preserve caller-provided type and env fields', () => {
test('ACP session params preserve caller-provided type and url/headers for http servers', () => {
const mcpServers = [
{ type: 'http', name: 'http-server', url: 'http://localhost:3000', headers: {}, env: [{ key: 'TOKEN', value: 'secret' }] },
{ type: 'http', name: 'http-server', url: 'http://localhost:3000/mcp', headers: [{ name: 'Authorization', value: 'Bearer tok' }] },
];

const result = buildAcpSessionNewParams('/tmp/od-project', { mcpServers });
const server = result.mcpServers[0];
assert.ok(server);
assert.equal(server.type, 'http');
assert.equal(server.name, 'http-server');
assert.deepEqual(server.env, [{ key: 'TOKEN', value: 'secret' }]);
assert.equal((server as any).url, 'http://localhost:3000/mcp');
assert.deepEqual((server as any).headers, [{ name: 'Authorization', value: 'Bearer tok' }]);
// HTTP servers must not carry stdio-only fields
assert.equal((server as any).command, undefined);
assert.equal((server as any).env, undefined);
});

test('ACP model normalization prefers session configOptions models', () => {
Expand Down
Loading
Loading