Skip to content

Remote MCP servers behind @requires never yield tools — auth headers are not sent on the tools/list handshake #48

Description

@TimZechmeister-NTTDATA

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.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions