Skip to content

feat(mocks): add OctoTrip Flights mock MCP server - #28

Merged
Pierre Malarme (pmalarme) merged 6 commits into
mainfrom
pmalarme-octotrip-flights-mock-mcp
Aug 17, 2026
Merged

feat(mocks): add OctoTrip Flights mock MCP server#28
Pierre Malarme (pmalarme) merged 6 commits into
mainfrom
pmalarme-octotrip-flights-mock-mcp

Conversation

@pmalarme

@pmalarme Pierre Malarme (pmalarme) commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

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 same search tool, 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:

  • Real airport coordinates (60+ airports) give real great-circle distances, so durations and local departure/arrival times — including next-day arrivals across time zones — come out right. FRA→JFK returns a 506-minute direct departing 07:15 and landing 09:41 local.
  • Connections come from a global hub list, preferring hubs near the route midpoint. Short hops are non-stop or nothing; nothing non-stop beyond 13 500 km.
  • Prices scale with distance, stops, cabin, one-way vs round trip, passenger mix, and currency.
  • Reproducible. The RNG seed is a SHA-256 of the request, so a demo replays identically — but change the date, cabin, or passenger count and the results move with it.

Data is honestly fake: invented airlines (OctoAir, Mockingbird Airways…), booking links on the reserved .invalid TLD, "mock": true plus a mock_notice on 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 every destinations.json under .workshop/, the other reads the City (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.py Dependency-free http.server. make mock-mcphttp://127.0.0.1:8931/mcp
function_app.py Azure Functions app using @app.mcp_tool / @app.mcp_tool_property/runtime/webhooks/mcp

The Functions host isn't optional polish. client.get_mcp_tool(...) registers a hosted MCP tool — Foundry calls the URL from its own network, so http://localhost is never reachable from the agent.

Three sharp edges in the @app.mcp_tool decorators, handled and commented in the code:

  • mcp_tool_property defaults is_required=True, and that override beats what the signature implies — so every optional parameter passes is_required=False explicitly, or all ten become required.
  • @app.mcp_tool must sit on top; the property decorators run first to populate the metadata it reads.
  • from __future__ import annotations would collapse every parameter to "string", because inspect.signature doesn'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:

  • Dev tunnel — the normal inner loop. devtunnel host --port-number 8931 --allow-anonymous gives a public HTTPS URL for the local server. Deliberately a temporary tunnel: devtunnel create <id> takes a globally unique ID, so a shared name like octotrip-mock would fail for everyone after the first participant.
  • Azure Functions Flex Consumption — the README carries the full az sequence 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. azd reads its own environment, not the repo's .env, so skipping it leaves the agent quietly calling the real OctoTrip server.

Security

  • No keys, no secrets, nothing committed. Deployment uses the host's managed identity for storage (AzureWebJobsStorage__accountName, Storage Blob Data Owner scoped to the one storage account, connection string deleted, --assignee-object-id to skip the Graph lookup as Step 2 does).
  • host.json sets extensions.mcp.system.webhookAuthorizationLevel: "Anonymous" — the system nesting 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.
  • The README shows how to verify anonymity with an unauthenticated initialize, and what a 401 means: a Functions host older than 4.1045.0 ignoring Anonymous (azure-functions-mcp-extension#138). The fix is to redeploy onto a current host or use the tunnel — explicitly not to wire the mcp_extension system key into the agent, which would put a shared secret in the environment and manifest.
  • Unknown IATA-shaped codes return airport_not_found rather 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 set pair. .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 what tool.py promises.

  • python -m pytest .workshop/scripts/tests197 passed
  • python .workshop/scripts/lint_steps.py0 failures
  • Verified live over HTTP against serve_local.pyinitialize / tools/list / tools/call, Lisbon→Marrakech returning offers, airport_not_found with isError: true, and a malformed Content-Length answered 400 without taking the server down
  • Functions handler verified end-to-end in a venv with azure-functions 1.25.0
  • README.md and .workshop_instance/ untouched

Review

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-existent server_label=/server_url= kwargs), plain --assignee, the synthesized-airport footgun, an OverflowError on far-future dates, 1.9 adults truncating to 1, connections offered on 275 km hops, and a negative Content-Length hanging a request thread. All fixed in 32a8a41.

A second, full rubber-duck pass over all four commits found no blocking issues and two real bugs, both fixed in e405377:

  • Time zones came from 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.
  • currency was 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 overnight tag 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.py notes that the Functions trigger drops enum/minimum/maximum from the advertised schema while _normalize still 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 existing MCP_SERVER_URL doesn't 404.

A third pass found no blocking issues and three more realism bugs, fixed in 09853fb:

  • Same-day round trips sold a return that departed before the outbound landed — 15 of 30 offers on FRA↔MAD. The inbound now leaves a turnaround after the outbound arrives, and a day trip that physically can't get home (Lisbon→Sydney and back) returns no_results instead of nonsense.
  • Connections were scored one hub at a time against the direct route, which says nothing about how a pair combines. Sydney→Auckland routed via Hong Kong and Bangkok — an 8.6× detour on a 2 150 km hop. The chosen hub set is now measured end to end against a cap and drops a stop when it doesn't fit. Worst detour across 720 searches is now 1.96× — Lisbon→Madrid→Marrakech, which is how you'd really fly it.
  • total_available was 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 equals total.

Smaller: HEAD /health returned 501 locally, 405 on Functions and 200 from the shared handler — all three now agree and Allow lists what's served; an explicit "id": null is 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. Only MCP_SERVER_URL differs, 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.

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
@pmalarme
Pierre Malarme (pmalarme) merged commit 0d68922 into main Aug 17, 2026
9 checks passed
@pmalarme
Pierre Malarme (pmalarme) deleted the pmalarme-octotrip-flights-mock-mcp branch August 17, 2026 20:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant