Skip to content
Draft
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
9 changes: 9 additions & 0 deletions lib/agents/middleware/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,16 @@ export default async function buildMiddleware(srv, options = {}) {
const { statusUpdateMiddleware } = await import("./status-update.js")
const { humanInTheLoopMiddleware } = await import("./hitl.js")
const { toolSelectionMiddleware } = await import("./tool-selection.js")

const hasDynamicMcp = tools?.some((t) => t._mcpDynamic) ?? false
let dynamicMcpMiddlewares = []
if (hasDynamicMcp) {
const { remoteMcpMiddleware } = await import("./remote-mcp.js")
dynamicMcpMiddlewares = [remoteMcpMiddleware()]
}

return [
...dynamicMcpMiddlewares,
...(await quotaEnforcerMiddleware()),
await contentFilterMiddleware(model),
await agentActionsMiddleware(),
Expand Down
63 changes: 63 additions & 0 deletions lib/agents/middleware/remote-mcp.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import cds from "@sap/cds"
import { createMiddleware } from "langchain"
import { ToolMessage } from "@langchain/core/messages"
import { MultiServerMCPClient } from "@langchain/mcp-adapters"
import { toolName } from "../../utils/utils.js"

const LOG = cds.log("agents:mcp")

/**
* Middleware that resolves remote MCP tools per-request using the current user's auth headers.
* Tools are cached on cds.context.__mcpDynamicTools keyed by mcpUrl for the lifetime of the
* request so multi-turn ReAct loops don't issue a new tools/list call on every model invocation.
*/
export function remoteMcpMiddleware() {
return createMiddleware({
name: "RemoteMcpMiddleware",

wrapModelCall: async (request, handler) => {
const cache = (cds.context.__mcpDynamicTools ??= {})
const placeholders = (request.tools ?? []).filter((t) => t._mcpDynamic)
await Promise.all(
placeholders.map(async ({ mcpUrl, resolveHeaders }) => {
if (cache[mcpUrl]) return
const headers = await resolveHeaders()
const client = new MultiServerMCPClient({
mcpServers: { default: { transport: "http", url: mcpUrl, headers } },
})
const raw = await client.getTools()
const prefix = toolName(`${mcpUrl}_`)
for (const t of raw) t.name = `${prefix}${t.name}`
cache[mcpUrl] = raw
LOG.info(
`Got ${raw.length} MCP tools from ${mcpUrl}: ${raw.map((t) => t.name).join(", ")}`,
)
}),
)
const resolved = placeholders.flatMap(({ mcpUrl }) => cache[mcpUrl] ?? [])
const staticTools = (request.tools ?? []).filter((t) => !t._mcpDynamic)
return handler({ ...request, tools: [...staticTools, ...resolved] })
},

wrapToolCall: async (request, handler) => {
const allCached = Object.values(cds.context.__mcpDynamicTools ?? {}).flat()
const tool = allCached.find((t) => t.name === request.toolCall.name)
if (!tool) return handler(request)
try {
const output = await tool.invoke(request.toolCall.args)
return new ToolMessage({
name: request.toolCall.name,
content: typeof output === "string" ? output : JSON.stringify(output),
tool_call_id: request.toolCall.id,
})
} catch (err) {
LOG.warn(`MCP tool "${request.toolCall.name}" error: ${err.message}`)
return new ToolMessage({
name: request.toolCall.name,
content: `Error: ${err.message}`,
tool_call_id: request.toolCall.id,
})
}
},
})
}
50 changes: 6 additions & 44 deletions srv/handlers/mcp-tools.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import cds from "@sap/cds"
import { MultiServerMCPClient } from "@langchain/mcp-adapters"
import { generateTools } from "./tools.js"
import { toolName } from "../../lib/utils/utils.js"

Expand Down Expand Up @@ -42,29 +41,6 @@ async function resolveDestination(destinationName, dest, localUrl) {
}
}

/**
* Wrap MCP tool invocations to convert errors into plain string results.
* deepagents' wrapToolCall middleware marks errors thrown inside it as
* "middleware errors", which LangChain's ToolNode re-throws rather than
* converting to a ToolMessage (see ToolNode.js: isMiddlewareError check).
* MCP tool schema validation errors therefore crash the graph instead of
* being fed back to the LLM as recoverable feedback.
*/
function wrapToolsWithErrorHandling(tools, serviceName) {
return tools.map((tool) => {
const original = tool.invoke.bind(tool)
tool.invoke = async (args, config) => {
try {
return await original(args, config)
} catch (err) {
LOG.warn(`MCP tool "${tool.name}" error: ${err.message}`, { service: serviceName })
return `Error: ${err.message}`
}
}
return tool
})
}

export async function buildMcpToolsLocally(serviceName) {
const srv = cds.services[serviceName]
const tools = generateTools(srv)
Expand All @@ -78,12 +54,13 @@ export async function buildMcpToolsLocally(serviceName) {
}

/**
* Build LangChain tools from a CAP MCP connection.
* Resolves the destination URL and auth headers, connects to the MCP server,
* and returns the tools wrapped with error handling for use in agent graphs.
* Build a dynamic MCP placeholder from a CAP MCP connection.
* Resolves the destination URL and auth-header factory; the actual tools/list
* call is deferred to remoteMcpMiddleware which runs per-request with the
* current user's credentials.
*
* @param {string} serviceName - cds.requires service key
* @returns {Promise<import("@langchain/core/tools").StructuredTool[]>}
* @returns {Promise<{ _mcpDynamic: true, mcpUrl: string, resolveHeaders: () => Promise<object> }>}
*/
export async function buildMcpToolsFromConnection(serviceName) {
let endpoints = cds.service.endpoints4({
Expand Down Expand Up @@ -134,22 +111,7 @@ export async function buildMcpToolsFromConnection(serviceName) {
return token ? { Authorization: `Bearer ${token}` } : {}
}

const client = new MultiServerMCPClient({
mcpServers: {
[serviceName]: { url: mcpUrl },
},
beforeToolCall: async () => ({ headers: await resolveHeaders() }),
})

const tools = await client.getTools()
const prefix = toolName(`${serviceName}_`)
for (const tool of tools) tool.name = `${prefix}${tool.name}`

LOG.info(
`Got ${tools.length} MCP tools from ${serviceName}: ${tools.map((t) => t.name).join(", ")}`,
)

return wrapToolsWithErrorHandling(tools, serviceName)
return { _mcpDynamic: true, mcpUrl, resolveHeaders }
}

export async function buildMcpTools(serviceName) {
Expand Down
Loading