When an Agent Tool Call Becomes a Real Request A developer at Azure AI Foundry identified a bug in the Model Context Protocol (MCP) bridge that silently drops date filters from agent tool calls, causing the assistant to return inaccurate search results. The issue arises when the model emits an OData filter string that the bridge's regex fails to parse, leaving the date field null while the raw filter still indicates a constraint. The developer warns that such translation errors can go unnoticed because the request body remains well-formed and valid. A recruiter asked for candidates published since a date. The assistant answered with confidence, and the count it gave looked plausible. That was the bug. The search ran, results came back, and nothing crashed. The date constraint never survived the trip from the model’s tool call to the database query, so the assistant counted the wrong universe and spoke as if it had done the exact request. This is production code, not a prototype. The path runs through Azure Artificial Intelligence AI Foundry, then a Model Context Protocol MCP bridge, then my application service over Hypertext Transfer Protocol HTTP . The bridge turns a model tool call into an Application Programming Interface API request. The boundary accepts whatever the model emitted and translates it into the service contract. When translation changes the meaning, it should stop. The path is short. Foundry asks for a tool. The MCP bridge receives the call, parses JavaScript Object Notation JSON , switches on the tool name, builds a request body, calls the application, then returns tool output through submitToolOutputs . The model did not emit typed parameters for the date. It emitted an Open Data Protocol OData filter string. The application endpoint does not speak OData. flowchart TD recruiter Recruiter agent Azure AI Foundry Agent run Foundry Run toolCall Tool Call With OData Filter String bridge MCP Bridge parsedArgs Parsed Arguments And Extracted Date api Application API typedRequest HTTP Request With Typed Parameters results Search Results And Count toolOutputs Submit Tool Outputs answer Answer With Count recruiter -- agent agent -- run run -- toolCall toolCall -- bridge bridge -- parsedArgs parsedArgs -- typedRequest typedRequest -- api api -- results results -- bridge bridge -- toolOutputs toolOutputs -- run run -- agent agent -- answer answer -- recruiter The cause sits in mcp-servers/azure-agent-mcp/src/services/azure-agent-client.ts , inside executeToolCall . For the candidate search tool, the bridge tries to rescue the date with this regex: args.filter.match /date published\s+ge\s+ \d{4}-\d{2}-\d{2} / . If it matches, dateFilter = dateMatch 1 . Then the bridge sends a body shaped for the application: query defaults to , location falls back from one argument name to another, limit is capped with Math.min Number args.top || Number args.limit || 20, 50 , the raw filter is passed through, and the extracted date goes out as a typed field when parsing worked. This is the normal shape of the bug. The regex is a parser for a query language the model was never constrained to emit correctly. If the model phrases the filter differently, the match misses. dateFilter stays null . The request still has query text, a limit, count inclusion, maybe a raw filter string, and enough shape to look legitimate. This is what goes over the wire. Values anonymized, structure untouched: { "query": "wealth advisor", "location": null, "designations": null, "min experience": null, "min aum": null, "min production": null, "remote only": null, "limit": 20, "include count": true, "filter": "date published gt 2026-01-14T00:00:00Z", "date from": null } Nothing about that body looks wrong. It is well formed. It validates. Most of those nulls are honest, because nobody asked for a location or a minimum book of business. Read the last two fields together, though. filter says a date bound was requested. date from says none was applied. The pattern accepts ge and the model wrote gt , so a single comparison operator is the entire distance between a bounded search and an unbounded one. Quoting the date, or writing date published/gt , or putting the clause second in a compound filter, all land in the same place. Those two fields contradict each other inside one request body, and nothing on either side compares them. A dropped filter does not throw. It answers. The same shape appears inside app/agents/orchestrator.py . AgentOrchestrator.process routes the query, runs the primary agent, starts extra agents in parallel, then combines whatever comes back. It also carries a timing dictionary with router ms , agent ms , and total ms . The parallel branch uses asyncio.gather secondary tasks, return exceptions=True . Only successful AgentResponse objects enter secondary results through secondary results.append resp.to dict . return exceptions=True buys a partial answer instead of no answer. It costs silence: nothing logs the exception, so an agent that fails every time looks identical to one that had nothing to add. A secondary agent starts raising on every call. Responses keep arriving, so no alert fires. total ms gets better , because a task that raises immediately finishes faster than one that does real work. The system looks healthier as it loses coverage. So you go check the stats. get agent stats reports configured , and for each agent a name, a tools count , and a model. That is the configuration, all of it. Nothing counts invocations. Nothing counts failures. An agent that has not returned a usable response in a week reports exactly what it reported the day it worked. The filter hides one more distinction. isinstance resp, AgentResponse and resp.success discards a raised exception and a returned-but-unsuccessful response into the same place. A crash and a considered "I have nothing useful here" are the same event downstream. That is the same bug as the dropped date. Partial execution is fine when the system can say which part was partial. Without that, the fallback hides the failure. The MCP server in mcp-servers/azure-agent-mcp/src/index.ts registers 19 tools. They cover agent lifecycle, thread lifecycle, and index inspection. A connected client can administer agents, inspect search infrastructure, execute workflows, and ask questions through the same bridge. That changes how I read tool schemas. They are policy, not help text. Fallbacks make agents feel forgiving. args.location || args.state || null is a small decision about equivalence. Some of those choices are harmless. Others erase meaning. The date case is the one that changes which rows come back. A user asked for a bounded set. If parsing fails, widening the set answers a different question from the one asked. Two kinds of argument arrive at that switch statement, and they deserve different treatment. Typed fields the application already understands: location , min experience , limit , remote only . Each has a contract. min experience is a number or it is absent, and both states are unambiguous. Then the free-form string. filter is a sentence the model composed in a query language nothing on my side implements. It has no contract at all. The bridge tries to recover intent from it with a regular expression. A regular expression is a guess about what the model will write. State the rule plainly. A constraint that changes which rows come back must arrive in a typed field the application understands. If it arrives only as a string, and that string cannot be parsed, the request stops. An error tool output works. A clarification back to the model works. Sending the query anyway, minus the constraint, does not. What decides whether a rule applies is whether the constraint changes the result set. Misparse a sort order and the same rows come back in a worse sequence. Somebody reports that inside a day. Misparse a date bound and different rows come back under a count that still reads as reasonable, and the report never arrives, because from the outside there is nothing to report. The bridge already has everything it needs to make that call. args.filter being present is the signal that a scoped request exists. The moment extraction fails to produce a typed value from it, the request body is known to be wrong before it is sent. The fix I take from this bug is narrow. Strings are allowed at the edge when the upstream interface allowed strings. A string that encodes a result-changing constraint needs visible parse failure. For this date constraint, the bridge already knows a scoped request exists because args.filter exists. If extraction cannot produce the typed date, the safe behavior is an error tool output or a clarification request. That is less magical. Good. Strict parsing annoys users sooner. It also keeps scope honest. For a date-bounded count, a visible stop beats a wide query dressed as precision. Start with the log line that is already there: if dateMatch { dateFilter = dateMatch 1 ; console.error AzureAgent Extracted date filter: ${dateFilter} ; } There is no else . The parse that worked writes a line. The parse that failed writes nothing. That is exactly backwards, and it is why this ran in production without leaving a trace. A successful parse is the one outcome nobody needs told, and it is the only one on record. Three things I want at that boundary. Log the miss, and log the input that caused it. When args.filter is present and the pattern does not match, write the filter string verbatim. Do that for a week and you stop guessing at which dialects the model emits, because you have the list. My regex was built for ge and the wild data was going to include gt whether I planned for it or not. Count the unconstrained fallbacks. One counter, incremented whenever a request goes out with a filter string present and its corresponding typed field null. Flat at zero means the parser is keeping up. Climbing means the assistant is answering a wider question than the one it was asked, and the number tells you how often. This is the signal that finds the bug before a person does. Assert on the shape of the request. filter present with date from null is a contradiction sitting in the body the bridge just built, catchable with one conditional before the call goes out. Checking the answer instead is hopeless here, because plausible output is the entire failure mode. Then look for the same pattern elsewhere in the switch. A few cases down the same switch, get recent candidates builds a search from query , limit , and include count , and carries this comment: // Note: date filtering would need to be added to the backend A tool whose name promises recency, sending no date at all. Somebody saw it, wrote it down, and shipped it anyway. That comment is a detection mechanism that only fires when a human happens to open the file. When a model calls your API, you have a new caller that does not read documentation, does not honor types, and produces syntactically valid arguments that mean the wrong thing. 🎧 Listen to the audiobook — Spotify https://open.spotify.com/show/4ABVd5yDVfbX9HlV5JjT7D · Google Play https://play.google.com/store/audiobooks/details/How to Architect an Enterprise AI System And Why t?id=AQAAAECafz8 tM&hl=en · All platforms https://www.craftedbydaniel.com/audiobook 🎬 Watch the visual overviews on YouTube https://youtube.com/playlist?list=PLRteDbGJPYDb9XNjecvHplGlgW7tIv q6 📖 Read the full 13-part series https://www.craftedbydaniel.com/blog/series/how-to-architect-an-enterprise-ai-system-and-why-the-engineer-still-matters