Problem
buildMcpToolsFromConnection() authenticates MCP tool calls but not the handshake that discovers them. Against any MCP server that requires an authenticated user, client.getTools() is answered with 401, so the agent starts up with zero tools from that connection.
The failure is silent in the common case: buildTools catches the error, logs a warning, and the agent runs — it just can never use the service. Nothing in the A2A response indicates that half its capabilities are missing.
Reproduction
Any CAP service annotated @mcp plus @requires: 'authenticated-user', consumed by an agent via @agent.connect, with a valid token forwarded through a destination. Result:
MCPClientError: Authentication failed for HTTP server "bookshop_mcp"
{"jsonrpc":"2.0","error":{"code":-32001,
"message":"Authorization error (401): Not authorized to access BooksService."}}
(The SSE fallback then also fails with Non-200 status code (404), since @cap-js/mcp only serves streamable HTTP.)
I verified that the request is otherwise sound: the identical URL and token succeed when the same headers are passed in mcpServers.
Root cause
Introduced by #211 (feat: downstream @mcp and @agent services), which moved header resolution from connection setup to beforeToolCall:
const client = new MultiServerMCPClient({
mcpServers: {
- [serviceName]: {
- url: mcpUrl,
- ...(Object.keys(headers).length ? { headers } : {}),
- },
+ [serviceName]: { url: mcpUrl },
},
+ beforeToolCall: async () => ({ headers: await resolveHeaders() }),
})
Resolving headers per call is right — cached graphs outlive the tokens baked into them. But beforeToolCall is only consulted from _callTool() in @langchain/mcp-adapters (dist/tools.js:367). getTools() goes through initializeConnections(), where headers come from customTransportOptions (dist/client.js:316), which is undefined here. So the handshake goes out unauthenticated and no tool ever reaches the point where beforeToolCall would apply.
The same commit also changed the destructuring at mcp-tools.js:114 to const { url } = …, discarding the headers that resolveDestination() still returns.
Second, latent issue
Once the handshake is fixed, a follow-up surfaces. mcpServers entries omit transport. That is fine for the initial connection, because client.js:394 passes the string literal "http" into createClient() when the type is null:
if (transportType === "http" || transportType == null) try {
const client = await this.#clientConnections.createClient("http", serverName, connection)
The inferred type is never written back into the stored connection options. When beforeToolCall returns headers, tools.js:374-376 forks the client, and the fork reads the type back out of that stored config (connection.js:104):
const type = connection.transportOptions.type ?? connection.transportOptions.transport
Both fields are undefined, and createClient() validates strictly (connection.js:31), so:
ToolException: Error calling tool list_books: Error: Invalid transport type: undefined
Discovery succeeds, so the agent looks healthy until someone actually calls a tool — at which point the model receives the exception as a tool error. Before #211 there was no fork, so this could not surface.
Proposed fix
In srv/handlers/mcp-tools.js:
const initialHeaders = await resolveHeaders()
const client = new MultiServerMCPClient({
mcpServers: {
[serviceName]: { transport: "http", url: mcpUrl, headers: initialHeaders },
},
beforeToolCall: async () => ({ headers: await resolveHeaders() }),
})
sub-agent-tools.js already does exactly this — const initialHeaders = await resolveHeaders() at line 277, used for the agent-card fetch at line 280, with fresh headers per request at line 294. Only the MCP path was left behind.
I'd also suggest an integration test with an MCP server behind @requires. The existing ones use xflights, which is unsecured, and destination-guide-service is in-process — neither exercises the remote + authenticated combination, which is why both defects went unnoticed.
Environment
@cap-js/agents 0.9.1 · @langchain/mcp-adapters 1.1.4 · @modelcontextprotocol/sdk 1.30.0 · @sap/cds 10.0.6
Server: @cap-js/mcp 1.4.3 (also reproduces on 1.2.0), SAP BTP Cloud Foundry, IAS auth
Reported per CONTRIBUTING.md; happy to provide further details if useful.
Problem
buildMcpToolsFromConnection()authenticates MCP tool calls but not the handshake that discovers them. Against any MCP server that requires an authenticated user,client.getTools()is answered with 401, so the agent starts up with zero tools from that connection.The failure is silent in the common case:
buildToolscatches the error, logs a warning, and the agent runs — it just can never use the service. Nothing in the A2A response indicates that half its capabilities are missing.Reproduction
Any CAP service annotated
@mcpplus@requires: 'authenticated-user', consumed by an agent via@agent.connect, with a valid token forwarded through a destination. Result:(The SSE fallback then also fails with
Non-200 status code (404), since@cap-js/mcponly serves streamable HTTP.)I verified that the request is otherwise sound: the identical URL and token succeed when the same headers are passed in
mcpServers.Root cause
Introduced by #211 (
feat: downstream @mcp and @agent services), which moved header resolution from connection setup tobeforeToolCall:const client = new MultiServerMCPClient({ mcpServers: { - [serviceName]: { - url: mcpUrl, - ...(Object.keys(headers).length ? { headers } : {}), - }, + [serviceName]: { url: mcpUrl }, }, + beforeToolCall: async () => ({ headers: await resolveHeaders() }), })Resolving headers per call is right — cached graphs outlive the tokens baked into them. But
beforeToolCallis only consulted from_callTool()in@langchain/mcp-adapters(dist/tools.js:367).getTools()goes throughinitializeConnections(), where headers come fromcustomTransportOptions(dist/client.js:316), which isundefinedhere. So the handshake goes out unauthenticated and no tool ever reaches the point wherebeforeToolCallwould apply.The same commit also changed the destructuring at
mcp-tools.js:114toconst { url } = …, discarding theheadersthatresolveDestination()still returns.Second, latent issue
Once the handshake is fixed, a follow-up surfaces.
mcpServersentries omittransport. That is fine for the initial connection, becauseclient.js:394passes the string literal"http"intocreateClient()when the type isnull:The inferred type is never written back into the stored connection options. When
beforeToolCallreturns headers,tools.js:374-376forks the client, and the fork reads the type back out of that stored config (connection.js:104):Both fields are
undefined, andcreateClient()validates strictly (connection.js:31), so:Discovery succeeds, so the agent looks healthy until someone actually calls a tool — at which point the model receives the exception as a tool error. Before #211 there was no fork, so this could not surface.
Proposed fix
In
srv/handlers/mcp-tools.js:sub-agent-tools.jsalready does exactly this —const initialHeaders = await resolveHeaders()at line 277, used for the agent-card fetch at line 280, with fresh headers per request at line 294. Only the MCP path was left behind.I'd also suggest an integration test with an MCP server behind
@requires. The existing ones usexflights, which is unsecured, anddestination-guide-serviceis in-process — neither exercises the remote + authenticated combination, which is why both defects went unnoticed.Environment
@cap-js/agents0.9.1 ·@langchain/mcp-adapters1.1.4 ·@modelcontextprotocol/sdk1.30.0 ·@sap/cds10.0.6Server:
@cap-js/mcp1.4.3 (also reproduces on 1.2.0), SAP BTP Cloud Foundry, IAS authReported per CONTRIBUTING.md; happy to provide further details if useful.