MCP Tool Calling
%%{init: {'look': 'handDrawn', 'theme': 'base', 'themeVariables': {'primaryColor': '#eef2ff', 'primaryBorderColor': '#1e40af', 'primaryTextColor': '#1e293b', 'lineColor': '#1e3a8a', 'edgeLabelBackground': '#ffffff', 'clusterBkg': '#fbfcff', 'clusterBorder': '#2563eb', 'fontFamily': '-apple-system, system-ui, Segoe UI, Roboto, Helvetica, Arial, sans-serif', 'fontSize': '15px'}, 'flowchart': {'nodeSpacing': 50, 'rankSpacing': 58, 'padding': 14, 'htmlLabels': true, 'curve': 'basis'}}}%%
flowchart LR
T(["Task"]) --> D("See which tools<br/>the server offers")
D --> M("Pick the right one")
M --> C("Call it")
C --> S("Summarize what<br/>came back")Outcome: discover what an MCP server actually exposes, strip mutating verbs deterministically, have a small model shortlist the five relevant tools, intersect that shortlist with what was really discovered, then let a capable model pick one — and verify that pick again before the call happens.
How it works
- Discover, don't hardcode. The tool list is read at runtime, so a renamed tool fails loudly instead of silently.
- Strip anything that writes. A plain filter drops delete/create/send-style tools before a model ever sees the list.
- A small model shortlists five, a bigger one picks. Fewer candidates means cheaper prompts and better choices.
- The workflow checks the pick, not the prompt. A tool that isn't on the shortlist can't be called.
Prerequisites
An OpenAI integration, and an MCP server. For a deterministic local one, use mcp-testkit, which ships 65 fixed tools:
It listens at http://localhost:3001/mcp. Its tools are all pure read-only helpers (get_weather, math_*, string_*, conversion_*, validation_*, encoding_*, datetime_*, collection_*), so the mutating-verb filter excludes none of them — which is what you want from a test server, and why the relevance shortlist is doing the real narrowing here.
Never put the MCP credential in workflow input. Pass it as a header sourced from your platform's secret store.
Runnable definition
Save this as mcp-tool-calling.json:
{
"name": "mcp_tool_calling",
"description": "Discovers MCP tools at runtime, strips mutating verbs deterministically, has a small model shortlist five relevant tools, intersects that shortlist with what was actually discovered, then lets a second model pick one and calls it.",
"version": 1,
"schemaVersion": 2,
"timeoutSeconds": 420,
"timeoutPolicy": "TIME_OUT_WF",
"inputParameters": [
"mcpServerUrl",
"task"
],
"tasks": [
{
"name": "discover_mcp_tools",
"taskReferenceName": "discover_tools",
"type": "LIST_MCP_TOOLS",
"inputParameters": {
"mcpServer": "${workflow.input.mcpServerUrl}"
}
},
{
"name": "strip_mutating_tools",
"taskReferenceName": "safe_catalog",
"type": "JSON_JQ_TRANSFORM",
"inputParameters": {
"tools": "${discover_tools.output.tools}",
"queryExpression": "(.tools // []) as $all | ($all | map(select(.name | test(\"^(delete_|drop_|remove_|write_|create_|update_|put_|post_|send_|pay_|charge_|refund_|deploy_|revoke_|grant_)\") | not))) as $safe | {catalog: ($safe | map({name: .name, description: ((.description // \"\")[0:160])})), safeNames: ($safe | map(.name)), discovered: ($all | length), excluded: (($all | length) - ($safe | length))}"
}
},
{
"name": "shortlist_relevant_tools",
"taskReferenceName": "shortlist_llm",
"type": "LLM_CHAT_COMPLETE",
"inputParameters": {
"llmProvider": "openai",
"model": "gpt-4o-mini",
"messages": [
{
"role": "system",
"message": "You narrow a large tool catalog down to the few tools that could plausibly help with a task. Return JSON: {\"shortlist\": [string]}. Include at most 5 names, most relevant first, copied verbatim from the catalog. Never invent a name. If nothing is relevant, return an empty list."
},
{
"role": "user",
"message": "Task: ${workflow.input.task}\nCatalog: ${safe_catalog.output.result.catalog}"
}
],
"temperature": 0.0,
"maxTokens": 300,
"jsonOutput": true
}
},
{
"name": "intersect_shortlist_with_catalog",
"taskReferenceName": "shortlist",
"type": "JSON_JQ_TRANSFORM",
"inputParameters": {
"proposed": "${shortlist_llm.output.result.shortlist}",
"safeNames": "${safe_catalog.output.result.safeNames}",
"catalog": "${safe_catalog.output.result.catalog}",
"queryExpression": ". as $r | (($r.proposed // []) | map(select(. as $n | ($r.safeNames // []) | index($n) != null))[:5]) as $allowed | {allowed: $allowed, allowedCount: ($allowed | length), candidates: (($r.catalog // []) | map(select(.name as $n | $allowed | index($n) != null))), rejected: ((($r.proposed // []) - $allowed))}"
}
},
{
"name": "require_candidate_tools",
"taskReferenceName": "require_candidates",
"type": "SWITCH",
"evaluatorType": "graaljs",
"expression": "$.count > 0 ? 'ready' : 'none'",
"inputParameters": {
"count": "${shortlist.output.result.allowedCount}"
},
"decisionCases": {
"none": [
{
"name": "terminate_no_candidate_tools",
"taskReferenceName": "terminate_no_tools",
"type": "TERMINATE",
"inputParameters": {
"terminationStatus": "FAILED",
"workflowOutput": {
"error": "no_relevant_tool_available",
"discovered": "${safe_catalog.output.result.discovered}"
}
}
}
]
},
"defaultCase": []
},
{
"name": "select_tool",
"taskReferenceName": "select_tool",
"type": "LLM_CHAT_COMPLETE",
"inputParameters": {
"llmProvider": "openai",
"model": "gpt-4o",
"messages": [
{
"role": "system",
"message": "Choose exactly one tool from the supplied candidates to accomplish the task, and build its arguments from the task text. Return JSON: {\"method\": string, \"arguments\": object, \"reason\": string}. The method MUST be one of the candidate names verbatim."
},
{
"role": "user",
"message": "Task: ${workflow.input.task}\nCandidate tools: ${shortlist.output.result.candidates}"
}
],
"temperature": 0.0,
"maxTokens": 500,
"jsonOutput": true
}
},
{
"name": "enforce_tool_allowlist",
"taskReferenceName": "enforce_allowlist",
"type": "JSON_JQ_TRANSFORM",
"inputParameters": {
"chosen": "${select_tool.output.result.method}",
"allowed": "${shortlist.output.result.allowed}",
"queryExpression": ". as $r | {method: $r.chosen, permitted: ((($r.allowed // []) | index($r.chosen)) != null)}"
}
},
{
"name": "route_on_allowlist",
"taskReferenceName": "route_allowlist",
"type": "SWITCH",
"evaluatorType": "value-param",
"expression": "permitted",
"inputParameters": {
"permitted": "${enforce_allowlist.output.result.permitted}"
},
"decisionCases": {
"true": [
{
"name": "call_selected_tool",
"taskReferenceName": "call_tool",
"type": "CALL_MCP_TOOL",
"inputParameters": {
"mcpServer": "${workflow.input.mcpServerUrl}",
"method": "${select_tool.output.result.method}",
"arguments": "${select_tool.output.result.arguments}"
}
},
{
"name": "summarize_tool_result",
"taskReferenceName": "summarize",
"type": "LLM_CHAT_COMPLETE",
"inputParameters": {
"llmProvider": "openai",
"model": "gpt-4o-mini",
"messages": [
{
"role": "system",
"message": "Summarize the tool result for the original task in at most three sentences. State only what the result contains. If the result does not answer the task, say so plainly."
},
{
"role": "user",
"message": "Task: ${workflow.input.task}\nTool: ${select_tool.output.result.method}\nResult: ${call_tool.output.content}"
}
],
"temperature": 0.1,
"maxTokens": 400
}
}
],
"false": [
{
"name": "terminate_tool_not_allowed",
"taskReferenceName": "terminate_not_allowed",
"type": "TERMINATE",
"inputParameters": {
"terminationStatus": "FAILED",
"workflowOutput": {
"error": "tool_not_in_allowlist",
"requested": "${select_tool.output.result.method}",
"allowed": "${shortlist.output.result.allowed}"
}
}
}
]
},
"defaultCase": []
}
],
"outputParameters": {
"summary": "${summarize.output.result}",
"tool": "${select_tool.output.result.method}",
"reason": "${select_tool.output.result.reason}",
"evidence": "${call_tool.output.content}",
"shortlist": "${shortlist.output.result}",
"catalogSize": "${safe_catalog.output.result.discovered}",
"excludedMutating": "${safe_catalog.output.result.excluded}"
}
}
Register and run
conductor workflow create mcp-tool-calling.json
conductor workflow start -w mcp_tool_calling --sync -i '{"mcpServerUrl":"http://localhost:3001/mcp","task":"What is the current weather in San Francisco?"}'
Open Executions in the Conductor UI and select the new execution to review the task graph, and each task's inputs and outputs.
On mcp-testkit this completes in about 20 seconds: 65 tools discovered, excludedMutating: 0, the shortlist narrowed to get_weather, and evidence carrying the tool's deterministic payload (77°F, sunny). Inspect shortlist to see what the model was offered and what was rejected, and select_tool for the reason it gave — together they are your audit trail for why a particular tool ran.
Production notes
- Reads are safe to retry. Writes are not. If you add a write tool, it needs an idempotency key and a check before retrying.
- Keep the raw tool result. The summary is model output and can't be audited; the raw result can.
- Tighten the filter for your server. Prefix matching is a convenience, not a guarantee — list the tools you actually allow.
- The summary is not a decision. Anything consequential belongs behind HITL approval.
- Secrets go in headers, never in workflow input. Source them from your secret store.