feat(mocks): add OctoTrip Flights mock MCP server - #28
Merged
Pierre Malarme (pmalarme) merged 6 commits intoAug 17, 2026
Conversation
Step 3 depends on the public OctoTrip Flights MCP server, which is rate limited (~1 req/s) and occasionally unavailable. Add a stand-in that speaks the same MCP protocol and exposes the same `search` tool, so the step still works when the real server does not. The mock generates every answer from the request rather than replaying a fixture: real airport coordinates drive distances, durations, and local arrival times (including next-day arrivals), connections are picked from a global hub list near the route midpoint, and prices scale with distance, stops, cabin, trip type, passengers, and currency. A SHA-256 seed of the request makes results reproducible for demos. Errors mirror the real server (`airport_not_found`, `disambiguation_needed`, `invalid_date`, `no_results`). Data is honestly fake: invented airlines, `.invalid` booking links, and `"mock": true` on every payload, so no real brand is attributed a made-up fare. Two hosts share one tool contract in `octotrip_mock/tool.py`: - `serve_local.py`, a dependency-free `http.server` host, for local MCP clients (`make mock-mcp`); - `function_app.py`, an Azure Functions app using `@app.mcp_tool` and `@app.mcp_tool_property`, since `get_mcp_tool()` registers a *hosted* MCP tool that Foundry calls from its own network and so can never reach localhost. `host.json` sets `webhookAuthorizationLevel` to `Anonymous` to match the public server it replaces; the app holds no data and reads nothing. Deployment stays keyless, with the host reaching storage through its managed identity. Documented in the Step 3 troubleshooting section, with 24 tests covering the protocol surface, the generator, and the Functions tool contract. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1ad1b68b-4e7f-45e9-82a4-ead6cd5a2914
… loop Add Porto and Marrakech so the mock can fly to every city in the RAG index, and give multi-airport metros a primary airport so "Tokyo", "London" and "Paris" resolve instead of asking to disambiguate. New York stays ambiguous on purpose, since it is the example the step doc uses. Two data-driven tests keep this honest: one walks every destinations.json under .workshop/, the other reads the City (CODE) pairs out of the step docs and checks they agree with the mock's airport table. Document the local + dev tunnel inner loop, and spell out the anonymous access story: how to verify no key is needed, and how to fall back to the mcp_extension system key on Functions hosts that ignore Anonymous. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1ad1b68b-4e7f-45e9-82a4-ead6cd5a2914
Correctness: - Reject unknown IATA-shaped codes with airport_not_found instead of synthesizing an airport. Answering a typo with a confident fake route is worse than saying no, and every workshop airport is mapped anyway. - Cap the booking horizon at 400 days. "9999-12-31" parsed fine and then raised OverflowError when a next-day arrival pushed past date.max. - Reject fractional passenger counts; int() was silently turning 1.9 into one adult. - Stop offering connections under 600 km, so Lisbon-Porto no longer routes through Frankfurt. - serve_local: reject negative or malformed Content-Length (read(-1) read until EOF and hung the thread), add a socket timeout, and use daemon threads. This matters more now that the docs tell you to tunnel it. Docs: - Use a temporary dev tunnel (devtunnel host --port-number 8931 --allow-anonymous). Tunnel IDs are globally unique, so the previous `devtunnel create octotrip-mock` would have failed for everyone but the first participant. - Add the azd env set pair. azd reads its own environment, not the repo .env, so without it the agent keeps calling the real OctoTrip server. - Drop the x-functions-key fallback. It contradicted the workshop's keyless rule, and its snippet used non-existent server_label/server_url kwargs; point at the dev tunnel while an old Functions host is ignoring Anonymous. - Use --assignee-object-id with --assignee-principal-type, matching Step 2 and skipping the Graph lookup that plain --assignee needs. - Use "tomorrow" in the curl example so it doesn't expire, and qualify the determinism and local-time claims. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1ad1b68b-4e7f-45e9-82a4-ead6cd5a2914
…RAG index The mock documents two anonymous paths -- an --allow-anonymous dev tunnel and the Anonymous Functions setting -- and a participant who has just made one work is one copy-paste away from doing the same to the Step 5 Search MCP. Spell out that anonymous is bounded by what the server is: this mock holds no data, has no managed identity, and invents every answer, so there is nothing to read or borrow. The Search MCP holds Search Index Data Reader over a real index, so the same flag publishes every document to anyone with the URL, unauthenticated and unaudited -- a confused deputy, which is the failure Step 5 already warns about. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1ad1b68b-4e7f-45e9-82a4-ead6cd5a2914
…huffling Follow-up on the second rubber-duck review of the OctoTrip mock. - Airports now carry a real standard-time UTC offset instead of round(longitude / 15), which put Reykjavik and Marrakech two hours out and most of western Europe one hour out. Both are RAG destinations and Lisbon-Marrakech is the headline example, so the clocks were visibly wrong. - The "overnight" tag now needs a later arrival date, not just a different one: eastbound trans-Pacific lands on an earlier date and is not overnight. - Currency left the RNG fingerprint, so switching currency reprices the same itinerary rather than returning a different set of flights. Flight numbers break price ties so rounding cannot reorder the results either. - The by-name resolver matches whole words, so a fragment cannot quietly resolve to the one airport name that contains it. - The local server also answers on /flights/mcp, the real server's path, so swapping only the host of an existing MCP_SERVER_URL works. - Note in tool.py that the Functions trigger drops enum/minimum/maximum from the advertised schema; _normalize still enforces them server-side. - Drop a duplicated __all__ entry. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1ad1b68b-4e7f-45e9-82a4-ead6cd5a2914
Third rubber-duck pass on the OctoTrip mock. - Same-day round trips sold a return that departed before the outbound landed, on half the offers. The inbound now leaves a turnaround after the outbound arrives, and a day trip that cannot physically get home comes back as no_results instead of as nonsense. - Connections were scored one hub at a time against the direct route, which said nothing about how a pair of them combined: Sydney to Auckland routed via Hong Kong *and* Bangkok, an 8.6x detour. The chosen hub set is now measured end to end against a cap, and drops a stop when it does not fit. Worst detour across 720 searches is now 1.96x -- Lisbon to Marrakech via Madrid, which is how you would really fly it. - total_available was a random 180-1450, so every answer implied hundreds of withheld flights the mock cannot produce. It now equals total. - HEAD on /health returned 501 locally, 405 on Functions and 200 from the shared handler. All three now agree, and Allow lists what is served. - JSON-RPC: an explicit "id": null is a request, not a notification, and an empty batch is -32600 rather than a silent 202. - Switching to the mock no longer changes MCP_SERVER_LABEL. Only the URL differs, so there is one thing to change and one thing to undo. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1ad1b68b-4e7f-45e9-82a4-ead6cd5a2914
Pierre Malarme (pmalarme)
deleted the
pmalarme-octotrip-flights-mock-mcp
branch
August 17, 2026 20:05
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Step 3 teaches MCP against the public OctoTrip Flights MCP server. It's anonymous and free, but rate limited to roughly one request per second and occasionally unavailable — which turns a teaching step into a dead end at exactly the wrong moment.
This adds a stand-in so Step 3 keeps working when the real server doesn't.
What it does
.workshop/mocks/octotrip_flights_mcp/speaks the same MCP streamable-HTTP protocol and exposes the samesearchtool, with the same parameters, the same response shape, and the same structured errors (airport_not_found,disambiguation_needed,invalid_date,no_results).It generates every answer from the request rather than replaying a fixture:
FRA→JFKreturns a 506-minute direct departing 07:15 and landing 09:41 local.Data is honestly fake: invented airlines (OctoAir, Mockingbird Airways…), booking links on the reserved
.invalidTLD,"mock": trueplus amock_noticeon every payload, and "THIS IS A MOCK" in the tool description. No real airline gets a made-up fare attached to its name.Covers every airport the workshop uses
All ten cities in the RAG destinations index resolve, as do the IATA codes the step docs tell participants to type. Multi-airport metros pick their main airport (
Tokyo→HND,London→LHR,Paris→CDG) instead of stalling on a disambiguation prompt; New York stays ambiguous on purpose, because that's the example Step 3 uses to show the error path. Two data-driven tests keep this from drifting — one walks everydestinations.jsonunder.workshop/, the other reads theCity (CODE)pairs straight out of the step docs.Two hosts, one contract
The tool contract lives once in
octotrip_mock/tool.py; both hosts read from it.serve_local.pyhttp.server.make mock-mcp→http://127.0.0.1:8931/mcpfunction_app.py@app.mcp_tool/@app.mcp_tool_property→/runtime/webhooks/mcpThe Functions host isn't optional polish.
client.get_mcp_tool(...)registers a hosted MCP tool — Foundry calls the URL from its own network, sohttp://localhostis never reachable from the agent.Three sharp edges in the
@app.mcp_tooldecorators, handled and commented in the code:mcp_tool_propertydefaultsis_required=True, and that override beats what the signature implies — so every optional parameter passesis_required=Falseexplicitly, or all ten become required.@app.mcp_toolmust sit on top; the property decorators run first to populate the metadata it reads.from __future__ import annotationswould collapse every parameter to"string", becauseinspect.signaturedoesn't evaluate string annotations. Deliberately absent.These decorators need
azure-functions>=1.25.0(1.24.0 has neither).Getting Foundry to reach it
Two documented paths, both anonymous, no key anywhere:
devtunnel host --port-number 8931 --allow-anonymousgives a public HTTPS URL for the local server. Deliberately a temporary tunnel:devtunnel create <id>takes a globally unique ID, so a shared name likeoctotrip-mockwould fail for everyone after the first participant.azsequence for something that outlives your terminal.Either way the URL goes in three places, and the third is the one people miss:
.env,azd env set, and the manifest.azdreads its own environment, not the repo's.env, so skipping it leaves the agent quietly calling the real OctoTrip server.Security
AzureWebJobsStorage__accountName,Storage Blob Data Ownerscoped to the one storage account, connection string deleted,--assignee-object-idto skip the Graph lookup as Step 2 does).host.jsonsetsextensions.mcp.system.webhookAuthorizationLevel: "Anonymous"— thesystemnesting is required, a flat one is silently ignored — so the endpoint matches the anonymous public server it replaces. That's deliberate and safe here (the app holds no data and reads nothing), and the README says not to copy the setting into an app that does anything real.initialize, and what a 401 means: a Functions host older than 4.1045.0 ignoringAnonymous(azure-functions-mcp-extension#138). The fix is to redeploy onto a current host or use the tunnel — explicitly not to wire themcp_extensionsystem key into the agent, which would put a shared secret in the environment and manifest.airport_not_foundrather than a synthesized airport, so a typo can't come back as a confident fake route.Docs
A "Use the mock when OctoTrip is unavailable" block in Step 3's Troubleshooting, covering the local run, the dev tunnel, the localhost trap, and the
azd env setpair..workshop/mocks/added to the "Where things live" list in the authoring instructions.Testing
.workshop/scripts/tests/test_mock_octotrip.py— 43 tests covering the MCP protocol surface, the generator's determinism and realism, every error path, workshop airport coverage, and that the Functions app advertises exactly whattool.pypromises.python -m pytest .workshop/scripts/tests→ 197 passedpython .workshop/scripts/lint_steps.py→ 0 failuresserve_local.py—initialize/tools/list/tools/call, Lisbon→Marrakech returning offers,airport_not_foundwithisError: true, and a malformedContent-Lengthanswered 400 without taking the server downazure-functions1.25.0README.mdand.workshop_instance/untouchedReview
Rubber-ducked after the first push; that pass caught the globally-unique tunnel ID, the missing
azd env set, the key fallback contradicting the keyless rule (its snippet also used non-existentserver_label=/server_url=kwargs), plain--assignee, the synthesized-airport footgun, anOverflowErroron far-future dates,1.9adults truncating to1, connections offered on 275 km hops, and a negativeContent-Lengthhanging a request thread. All fixed in32a8a41.A second, full rubber-duck pass over all four commits found no blocking issues and two real bugs, both fixed in
e405377:round(longitude / 15). That put Reykjavik and Marrakech two hours out and most of western Europe one hour out — and KEF and RAK are both RAG destinations while Lisbon→Marrakech is the headline example, so the wrong clocks were on screen. Airports now carry a real standard-time UTC offset (whole hours, no DST, both stated in the README). A sanity test asserts every offset is within 3.5 h of its own solar time, which catches a typo across the 63 rows.currencywas in the RNG fingerprint, so an EUR search and a USD search returned different flights rather than the same flights repriced — and that made the currency test fail on ~31% of calendar days, because the carrier price jitter could swamp the 1.09× rate. Currency is now presentation-only. Flight numbers break price ties too, so rounding two equal fares apart at the second decimal can't reorder the results. Simulated across 400 departure dates: 0 failures.Also from that pass: the
overnighttag now requires a later arrival date (eastbound trans-Pacific lands on an earlier one), the by-name resolver matches whole words so a fragment can't quietly resolve,tool.pynotes that the Functions trigger dropsenum/minimum/maximumfrom the advertised schema while_normalizestill enforces them server-side, and a duplicated__all__entry is gone.One thing the review didn't ask for but the smoke test exposed: the local server now also answers on
/flights/mcp, the real server's path, so swapping only the host of an existingMCP_SERVER_URLdoesn't 404.A third pass found no blocking issues and three more realism bugs, fixed in
09853fb:no_resultsinstead of nonsense.total_availablewas a random 180–1450, so every answer implied hundreds of withheld flights the mock can't produce and invited the model to say so. It now equalstotal.Smaller:
HEAD /healthreturned 501 locally, 405 on Functions and 200 from the shared handler — all three now agree andAllowlists what's served; an explicit"id": nullis a request rather than a swallowed notification and an empty batch is-32600; and the overnight-tag test no longer passes vacuously when no offer earns the tag.One doc simplification from the same pass: switching to the mock no longer changes
MCP_SERVER_LABEL. OnlyMCP_SERVER_URLdiffers, so there's one thing to change and one thing to undo — and the manifest needs no edit at all, since Step 3 already wired both names in.