Build1 publisher3 min readPublished
A dropped date filter does not throw, it answers: the MCP bridge is a contract boundary
A recruiter's date-bounded search lost its date between an Azure AI Foundry tool call and the API, and the agent reported a count anyway. Translation that changes meaning has to fail loudly.
The Engineer · Build desk
Drafted by a language model from the sources cited here and checked against its claim ledger before publication. How we use AISend a correction
What happened
- A recruiter asked for candidates published since a date; the assistant answered with confidence and the count it gave looked plausible.
- 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.
- The search ran, results came back, and nothing crashed.
- The author states this is production code, not a prototype.
- The path runs through Azure AI Foundry, then a Model Context Protocol (MCP) bridge, then the author's application service over HTTP; the bridge turns a model tool call into an API request.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
A recruiter asked for candidates published since a given date, and the assistant came back with a confident, plausible-looking count [1]. The date constraint never survived the trip from the model's tool call to the database query, so the count described a different universe than the one requested, and nothing crashed on the way [2][3].
This is production code, not a prototype, according to the engineer who wrote it up on dev.to [4]. The path is short: Azure AI Foundry asks for a tool, an MCP bridge receives the call, parses the JSON, switches on the tool name, builds a request body, calls the application service over HTTP, and returns tool output through submitToolOutputs [5][6]. The failure is entirely inside the third step. The model did not emit a typed date parameter; it emitted an OData filter string, and the application endpoint does not speak OData [7].
So the bridge tries to rescue the date with a regular expression. In mcp-servers/azure-agent-mcp/src/services/azure-agent-client.ts, inside executeToolCall, it matches on `date_published\s+ge\s+(\d{4}-\d{2}-\d{2})` and, on a hit, assigns the captured value to dateFilter [8]. The author's own framing is the useful part: that regex is a parser for a query language the model was never constrained to emit correctly [15]. Around it, the bridge fills in defaults - query falls back to `*`, location falls back from one argument name to another, and limit resolves through Math.min(Number(args.top) || Number(args.limit) || 20, 50), which means a default of 20 and a hard ceiling of 50 [9][21]. The raw filter is passed through, and the extracted date goes out as a typed field only when parsing worked [9].
Here is the body that went over the wire, per the writeup: query "wealth advisor", limit 20, include_count true, filter "date_published gt 2026-01-14T00:00:00Z", date_from null, and a row of honest nulls for location, designations and the rest [10]. Eleven fields, two of which contradict each other [20]. The filter says a date bound was requested. date_from says none was applied [11]. The pattern accepts `ge` and the model wrote `gt`, so one comparison operator is the whole distance between a bounded search and an unbounded one [11]. Quoting the date, writing `date_published/gt`, or putting the clause second in a compound filter all land in the same place [12]. Nothing on either side of the boundary compares those two fields [13].
That is the shape of the class, not just this bug. The boundary accepts whatever the model emitted and translates it into the service contract, and the author's argument is that when translation changes meaning it should stop [16]. A dropped filter does not throw; it answers [14].
The same silence shows up one layer in. In app/agents/orchestrator.py, AgentOrchestrator.process routes the query, runs the primary agent, starts extra agents in parallel, combines what comes back, and carries a timing dictionary of router_ms, agent_ms and total_ms [17]. The parallel branch uses asyncio.gather with return_exceptions=True, and only successful AgentResponse objects reach secondary_results [18]. That flag buys a partial answer instead of no answer, and it costs silence, because nothing logs the exception [19].
Two things to watch in your own bridges. First, whether any component reconciles the raw filter against the typed fields derived from it, since in this case neither side did [13]. Second, what your timing dictionary does not contain: router_ms, agent_ms and total_ms tell you a run was fast, not that every agent in it returned [17][19].