1. Nothing crashed
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.
2. The bridge is the caller now
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.
3. Partial answers have a silence cost
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.
4. The bridge is admin surface
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.
5. The contract the edge should enforce
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.
6. Reject almost-right input
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.
7. Catching the next one
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 Β· Google Play Β· All platforms
π¬ Watch the visual overviews on YouTube
π Read the full 13-part series
Top comments (0)