A proxy MCP for the Check24 GenDev Scholarship task: one ChatGPT-facing endpoint aggregating multiple Check24 vertical MCPs.
Demo video: https://drive.google.com/file/d/14V_i991F5Y9_6V8oK8Yc3wzb6wnGsNWJ/view?usp=sharing Live proxy URL: https://check24proxy-production-c3da.up.railway.app/mcp
- Task
- Problem Understanding
- Architecture
- Tech Stack
- Mock Verticals
- Proxy Flow
- Aggregation & Lifecycle
- Conformance & Feedback
- Versioning
- Per-Vertical Scoped Testing
- Ambiguity Mitigation
- Dashboard
- Security Considerations
- Deployment
- Implementation Status
- Submission Guide
Build a single public MCP proxy for ChatGPT that safely exposes tools from multiple private CHECK24 vertical MCPs.
CHECK24 appears as one brand to customers, but internally it is made of independent product verticals such as hotel, flights, package holidays, and insurance. ChatGPT should not connect to each vertical separately; it needs one coherent connector with a stable and safe tool catalog.
The proxy is therefore not just a routing layer. It is the control point that turns many vertical-owned MCP surfaces into one ChatGPT-facing surface. It handles tool aggregation, namespacing, conformance checks, versioning, scoped vertical QA, and actionable feedback when a vertical tool cannot be exposed safely.
┌─────────────────────────────────────────────────────────┐
│ ChatGPT (Host / Apps SDK) │
│ - acts as the MCP Host │
│ - one MCP Client per registered connector │
└───────────────────────┬─────────────────────────────────┘
│ Streamable HTTP (POST /mcp)
│ JSON-RPC 2.0
▼
┌─────────────────────────────────────────────────────────┐
│ Proxy MCP (single public endpoint) │
│ │
│ ┌──────────────┐ ┌───────────────┐ ┌─────────────┐ │
│ │ Aggregator │ │ Conformance │ │ Router │ │
│ │ (catalog + │ │ Checker │ │ (public → │ │
│ │ diffing) │ │ + Feedback │ │ vertical) │ │
│ └──────────────┘ └───────────────┘ └─────────────┘ │
│ │
│ In-process mocked vertical adapters │
└────────┬────────────────┬──────────────────┬────────────┘
private private private
│ │ │
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────┐
│ hotel │ │ flug │ ... │versicherung│
│ MCP server │ │ MCP server │ │ MCP server │
└────────────┘ └────────────┘ └────────────┘
(private — never registered with ChatGPT)
| MCP role | Component |
|---|---|
| Host | ChatGPT (Apps SDK) |
| Client | ChatGPT's internal MCP client (one per connector) |
| Server (public) | Proxy MCP — the only registered connector |
| Server (private) | Each vertical's MCP server (behind the proxy) |
| Adapter (proxy-side) | Proxy's private in-process adapter for each mocked vertical |
The proxy is structured so the generic MCP aggregation/routing/conformance core is independent of the ChatGPT/Apps SDK-specific glue. Another MCP-capable host (e.g. Claude) could reuse the core by swapping the host-facing transport layer.
packages/
proxy/ # the public ChatGPT-facing MCP + dashboard + feedback API
vertical-hotel/ # one MCP server per vertical, each owns its own tools/schemas
vertical-flug/
vertical-pauschalreise/
vertical-versicherung/
shared/ # tsconfig base, conformance types, namespacing util, vertical-blurbs
| Concern | Choice |
|---|---|
| Language / runtime | TypeScript · Node.js 22 LTS |
| HTTP framework | Express |
| MCP SDK | @modelcontextprotocol/sdk@1.29.0 |
| Transport: ChatGPT → proxy | Streamable HTTP at /mcp (Apps SDK requirement) |
| Transport: proxy → verticals | In-process by default; private HTTP /mcp endpoints in Docker Compose |
| Schema validation | AJV compile checks during catalog assembly; AJV input/output validation on tool calls |
| Workspace tool | pnpm workspaces |
| Test runner | Vitest |
| Containerization | Docker + Docker Compose |
| Deployment | Railway |
Four mocked vertical MCPs, each owning its own tools and schemas. Each carries a deliberate, distinct demo role.
| Vertical | Tools (mock) | What it demonstrates |
|---|---|---|
| hotel | searchHotels, getHotelDetails, bookHotel |
Matches the brief's screenshot. bookHotel is destructive → demo of safety annotations. Hosts the illustrative widget. |
| flug | searchFlights, getFlightDetails, searchFlight |
searchFlight collides with pauschalreise's searchFlight → ambiguity demo named in the brief. |
| pauschalreise | searchPackages, searchFlight, getPackageDetails |
Other half of the ambiguity demo; "life situation, not product" user intent. |
| versicherung | getInsuranceQuote, compareInsurance, one deliberately broken tool (missing inputSchema) |
Non-travel domain → proves proxy is general-purpose. Broken tool drives the conformance-feedback demo. |
The verticals do not contain real Check24 product logic or data. They are credible mocks that exercise proxy concerns (aggregation, routing, ambiguity, conformance, versioning).
Public tools surface to ChatGPT as <vertical>_<verbatim original tool name>. Underscore is the only separator legal across all MCP/Apps SDK regexes; verbatim preserves vertical ownership of names (the brief: "tool names stay owned by each vertical"); the first _ is the reversible split between vertical and original tool.
Examples: hotel_searchHotels, flug_searchFlight, pauschalreise_searchFlight, versicherung_getInsuranceQuote.
Note:
PROBLEM_UNDERSTANDING.mduses dot notation (hotel.searchHotels) in its illustration; the implementation uses underscore because dots are outside the allowed character set for MCP/Apps SDK tool names. Both files are intentionally not reconciled —PROBLEM_UNDERSTANDING.mdis the problem-framing document; this README reflects the implementation.
ChatGPT calls hotel_searchHotels → proxy resolves the public-name mapping → validates arguments with AJV → calls the private hotel adapter's original searchHotels tool → validates declared structured output if present → returns the mocked result. Tool routing itself is stateless.
At boot the proxy loads all four mocked vertical MCP surfaces, runs conformance checks over their tool definitions, withholds invalid tools, and assembles one in-memory public catalog. Local single-process mode uses in-process adapters. Docker Compose sets PROXY_VERTICAL_TRANSPORT=http, so the proxy talks to each private vertical service over its /mcp endpoint.
Current MVP behavior is intentionally simple:
| Scenario | Proxy action |
|---|---|
| Valid vertical tool | Expose it under <vertical>_<toolName> and keep a private mapping to the original tool. |
| Invalid vertical tool | Withhold it and emit actionable feedback. |
| Unknown scoped vertical | Return 404 with valid scope names. |
| Invalid tool arguments | Reject at the proxy boundary with an MCP tool error and trace id. |
| Vertical call timeout | Return a standardized tool error and record the timeout in recent calls. |
The proxy catalog, dashboard, and /feedback are generated from the same in-memory catalog at startup. Host-side refresh timing is still controlled by ChatGPT, not by this proxy.
| Tier | Behavior |
|---|---|
| error | Withhold tool + emit actionable feedback. |
| warning | Expose tool + flag in feedback log + dashboard badge. |
| info | Expose silently, log only. |
The proxy currently enforces the core demo checks:
| Rule | Behavior |
|---|---|
description_missing |
Withhold tool. |
inputSchema_missing |
Withhold tool. |
inputSchema_not_object |
Withhold tool. |
inputSchema_invalid_json_schema |
Withhold tool. |
outputSchema_invalid |
Withhold tool. |
annotations_required_hints_missing |
Withhold tool. |
annotation_behavior_mismatch |
Withhold tool. |
public_name_collision |
Withhold colliding tools until the public mapping is one-to-one. |
requires_oauth |
Withhold tool because ChatGPT OAuth is out of scope for this demo. |
name_pattern_invalid |
Withhold tool. |
name_too_long_after_namespacing |
Withhold tool. |
description_too_short |
Expose with warning. |
annotation_needs_review |
Expose with warning. |
ambiguous_description |
Expose with warning and add disambiguation hints. |
unusual_annotation_value |
Expose with info finding. |
very_long_description |
Expose with info finding. |
vertical_unreachable |
Emit feedback if an adapter cannot load. |
Every finding carries { id, vertical, toolName, publicName, rule, severity, message, fix }. The proxy strict-withholds on errors and does not silently mask semantically meaningful problems.
Public tools must declare boolean readOnlyHint, destructiveHint, and openWorldHint annotations. Read/search tools use explicit safe hints. hotel_bookHotel is the safety demo: it is either withheld as unsafe, or exposed only as a mocked dry-run/write action with readOnlyHint: false, destructiveHint: true, and openWorldHint: false.
| Channel | For |
|---|---|
HTTP JSON GET /feedback and GET /feedback/:vertical |
Machine-readable conformance findings for reviewers, vertical teams, or future CI checks. |
Server-rendered dashboard GET /dashboard |
Human-readable inspection for reviewers and demos. |
Both read from the same in-memory FindingStore. Findings have deterministic ids.
The public catalog has its own version (0.1.0) separate from each mocked vertical's serverInfo.version (1.0.0). The catalog also records the adapter compatibility range per vertical. MCP resource proxy://meta/versions exposes the same version data.
In this submission the proxy serves a single live public catalog version at /mcp. Multi-version public URLs and adapter files are planned extensions, not implemented in this MVP.
The proxy serves two catalog shapes, both via the same proxy code and validation logic:
| URL | Catalog ChatGPT sees |
|---|---|
POST /mcp |
Full merged MCP catalog (all 4 verticals). The single customer/reviewer ChatGPT connector. |
POST /mcp/scope/<vertical> |
One vertical only through the same MCP proxy code. |
GET /mcp/scope/<vertical> |
JSON QA summary for demos and quick browser/curl checks. |
A vertical team registers their own QA connector in ChatGPT pointing at /mcp/scope/hotel to test their tools end-to-end inside ChatGPT, without every other vertical's tools polluting the session. ChatGPT still only ever talks to the proxy — never to a vertical MCP directly.
Scoped paths are not public vertical MCPs. They are proxy-owned QA views served by the same proxy code, validation logic, and routing boundary as /mcp.
Path-based (not header or query-string) because: paths are never stripped or normalized by intermediaries; missing scope = clean 404 (not silent fallthrough to the full catalog); middleware can set req.scope once for all downstream code.
Conformance and withholding behavior is identical to the full catalog so vertical-team QA exercises the same gatekeeping a customer session would. MCP resource proxy://meta/scope declares the active scope.
The brief's named problem: two verticals can ship tools whose names or descriptions overlap (e.g. flug_searchFlight vs pauschalreise_searchFlight). The proxy mitigates this on three fronts:
- Namespacing — collisions are resolved structurally.
- Description enrichment — the proxy prepends a short
[CHECK24/<vertical> - ...]header to every public tool description, including explicit sibling hints for ambiguous tools. _metarouting hints per tool:{ "x-check24-vertical": "flug", "x-check24-domain": "transportation", "x-check24-disambiguates-from": ["pauschalreise_searchFlight"], "x-check24-source-tool": "searchFlight" }_metais the MCP spec's defined extension point; consumed today by the dashboard and any host that grows tool-pick_metaconsumption later.
For example, flug_searchFlight and pauschalreise_searchFlight both expose _meta["x-check24-disambiguates-from"] pointing at the other public name.
Proxy-side mitigation reduces but cannot eliminate wrong-tool picks. ChatGPT's tool-picking is ultimately the host's responsibility. The proxy's job is to give the model the cleanest possible signal (namespaced names, enriched descriptions, _meta hints) and give vertical teams the cleanest possible feedback when ambiguity is detected. The brief is explicit on this: "this probably cannot be avoided 100% of the time."
Server-rendered HTML at GET /dashboard, same Express app as the proxy. It shows:
- Public catalog version, endpoint, resources, and visible tool count.
- Vertical status with exposed/withheld counts.
- Version and endpoint rows.
- Public tool catalog with namespaced public names and annotation hints.
- Withheld tools with exact rule and fix text.
- Conformance findings and fix strings.
- Collision and ambiguity rows.
- Recent tool calls with trace ids, durations, and errors.
- Clearly labeled mocked funnel metrics.
- A hotel widget preview using mock data only.
| Surface | What we do | What we'd harden in production |
|---|---|---|
ChatGPT → proxy (/mcp) |
No auth; OAuth is out of scope per brief. | OAuth via Apps SDK; rate-limiting per session. |
| Proxy → vertical | In-process by default; private HTTP in Docker Compose. Vertical HTTP ports are not exposed to ChatGPT. | mTLS or private service networking for real verticals. |
| Tool input | Exposed tools must declare object inputSchema; AJV validates every tools/call before forwarding. |
Same, plus richer field-level UX. |
| Tool output | Pass-through unless a vertical declares outputSchema; then AJV validates before returning to ChatGPT. _internal_* keys are stripped. |
Output-schema validation for all tools, PII scrubbing, and size caps. |
| Secrets in tool responses | Documented rule: vertical mocks never include credentials/tokens/PII. Active scanning is future work. | Active secret-pattern scanning; quarantine on detection. |
Dashboard / /feedback JSON |
No auth in v1 (single demo deploy). | Auth required (SSO or basic auth at minimum). |
OAuth in ChatGPT and production-grade security hardening are out of scope per the brief.
The ChatGPT-facing proxy MCP is intended to deploy as a single Railway service. Vertical mocks run in-process inside the same container and are not publicly exposed to ChatGPT.
| Surface | Method / path | Live URL |
|---|---|---|
| ChatGPT-facing MCP proxy | POST /mcp |
https://check24proxy-production-c3da.up.railway.app/mcp |
| Dashboard | GET /dashboard |
https://check24proxy-production-c3da.up.railway.app/dashboard |
| Scoped vertical MCP QA | POST /mcp/scope/<vertical> |
https://check24proxy-production-c3da.up.railway.app/mcp/scope/hotel |
| Scoped vertical QA summary | GET /mcp/scope/<vertical> |
https://check24proxy-production-c3da.up.railway.app/mcp/scope/hotel |
| Feedback JSON | GET /feedback |
https://check24proxy-production-c3da.up.railway.app/feedback |
| Per-vertical feedback JSON | GET /feedback/:vertical |
https://check24proxy-production-c3da.up.railway.app/feedback/versicherung |
| Hotel widget preview | GET /widgets/preview/hotel-results |
https://check24proxy-production-c3da.up.railway.app/widgets/preview/hotel-results |
| Health check | GET /health |
https://check24proxy-production-c3da.up.railway.app/health |
Valid scoped verticals are hotel, flug, pauschalreise, and versicherung.
Railway was chosen for: single-container Node deploy, long-lived HTTP stream support (Streamable HTTP), GitHub auto-deploy on push to main, generous starter credits. Fly.io and Render are viable fallbacks if Railway terms change — documented here for posterity.
Demo state:
| Check | Expected result |
|---|---|
/dashboard |
Shows one public /mcp endpoint, vertical health, public tools, withheld tools, findings, recent calls, mocked funnel metrics, and the hotel widget preview. |
/feedback |
Returns JSON with summary, withheldCount, withheldTools, and findings. |
/feedback/versicherung |
Shows the deliberately broken insurance tool withheld for inputSchema_missing. |
/mcp/scope/hotel |
Shows only hotel tools through the proxy-owned scoped QA path. |
/widgets/preview/hotel-results |
Renders mocked hotel cards and labels them as demo data. |
npx @modelcontextprotocol/inspector
# point at the proxy URL (or http://localhost:3000/mcp locally)
# verify: initialize → tools/list → tools/call hotel_searchHotelsUse MCP Inspector with Streamable HTTP at https://check24proxy-production-c3da.up.railway.app/mcp.
Valid hotel search:
Tool: hotel_searchHotels
{
"destination": "Berlin",
"checkIn": "2026-06-01",
"checkOut": "2026-06-04",
"adults": 2,
"rooms": 1
}Proxy validation error:
Tool: hotel_searchHotels
{
"destination": "Berlin",
"checkIn": "2026-06-01",
"adults": 2
}Expected result: the proxy rejects the call before routing because checkOut is missing.
Cross-vertical ambiguity example:
Tool: flug_searchFlight
{
"route": "BER to PMI",
"departureDate": "2026-07-10"
}Tool: pauschalreise_searchFlight
{
"packageId": "pauschal_demo_1",
"departureAirport": "MUC",
"destination": "Mallorca"
}Expected result: both tools remain distinct because they represent different product intents.
Standalone flight search:
Tool: flug_searchFlights
{
"originAirport": "BER",
"destinationAirport": "PMI",
"departureDate": "2026-07-10",
"returnDate": "2026-07-17",
"passengers": 2
}Package holiday search:
Tool: pauschalreise_searchPackages
{
"destination": "Mallorca",
"departureAirport": "MUC",
"earliestDepartureDate": "2026-07-10",
"nights": 7,
"travelers": 2
}Insurance quote:
Tool: versicherung_getInsuranceQuote
{
"productType": "travel",
"postalCode": "80331",
"coverageLevel": "comfort"
}Invalid insurance enum:
Tool: versicherung_getInsuranceQuote
{
"productType": "pet",
"coverageLevel": "comfort"
}Expected result: the proxy rejects the call before routing because productType is outside the allowed enum.
Withheld-tool feedback:
versicherung_estimateInsuranceRisk should not appear in tools/list. Open https://check24proxy-production-c3da.up.railway.app/feedback/versicherung to see the inputSchema_missing finding and suggested fix.
Implemented:
- Mocked vertical adapters for hotel, flug, pauschalreise, and versicherung.
- Private mocked vertical MCP HTTP endpoints at each vertical service's
/mcp. - Real Streamable HTTP MCP proxy at
POST /mcp. - Scoped QA MCP paths at
POST /mcp/scope/<vertical>. - Browser/curl QA summaries at
GET /mcp/scope/<vertical>. - Public namespaced tool mapping.
- Tool-call routing through the proxy.
- AJV catalog schema checks and
tools/callinput/output validation. - MCP metadata resources:
proxy://meta/versions,proxy://meta/scope,proxy://catalog/public,proxy://widgets/hotel-results. - Basic tracing ring buffer and recent-call dashboard.
- Collision warnings and description enrichment for overlapping tools.
- Conformance feedback and withheld-tool handling for invalid, unsafe, or unsupported tools.
- Server-rendered dashboard with versions, public tools, withheld tools, findings, collisions, recent calls, and demo metrics.
- Hotel widget resource and standalone preview at
/widgets/preview/hotel-results. - Railway deployment URL.
Not yet implemented:
- Tool-list changed notifications and live catalog refresh.
- Production hardening for auth, rate limits, and PII scanning.