Skip to content

Repository files navigation

check24-chatgpt-app

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


Table of Contents


Task

Build a single public MCP proxy for ChatGPT that safely exposes tools from multiple private CHECK24 vertical MCPs.

Problem Understanding

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.

Architecture

Overview

┌─────────────────────────────────────────────────────────┐
│                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 mapping

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

Separation of concerns

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

Tech Stack

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

Mock Verticals

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).


Proxy Flow

Public tool naming

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.md uses 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.md is the problem-framing document; this README reflects the implementation.

Tool call routing

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.


Aggregation & Lifecycle

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.


Conformance & Feedback

Rule severity

Tier Behavior
error Withhold tool + emit actionable feedback.
warning Expose tool + flag in feedback log + dashboard badge.
info Expose silently, log only.

Implemented rule catalog

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.

Feedback channels

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.


Versioning

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.


Per-Vertical Scoped Testing

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.


Ambiguity Mitigation

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:

  1. Namespacing — collisions are resolved structurally.
  2. Description enrichment — the proxy prepends a short [CHECK24/<vertical> - ...] header to every public tool description, including explicit sibling hints for ambiguous tools.
  3. _meta routing hints per tool:
    {
      "x-check24-vertical": "flug",
      "x-check24-domain": "transportation",
      "x-check24-disambiguates-from": ["pauschalreise_searchFlight"],
      "x-check24-source-tool": "searchFlight"
    }
    _meta is the MCP spec's defined extension point; consumed today by the dashboard and any host that grows tool-pick _meta consumption later.

For example, flug_searchFlight and pauschalreise_searchFlight both expose _meta["x-check24-disambiguates-from"] pointing at the other public name.

Honest limits

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."


Dashboard

Server-rendered HTML at GET /dashboard, same Express app as the proxy. It shows:

  1. Public catalog version, endpoint, resources, and visible tool count.
  2. Vertical status with exposed/withheld counts.
  3. Version and endpoint rows.
  4. Public tool catalog with namespaced public names and annotation hints.
  5. Withheld tools with exact rule and fix text.
  6. Conformance findings and fix strings.
  7. Collision and ambiguity rows.
  8. Recent tool calls with trace ids, durations, and errors.
  9. Clearly labeled mocked funnel metrics.
  10. A hotel widget preview using mock data only.

Security Considerations

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.


Deployment

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.

MCP Inspector smoke check

npx @modelcontextprotocol/inspector
# point at the proxy URL (or http://localhost:3000/mcp locally)
# verify: initialize → tools/list → tools/call hotel_searchHotels

Reviewer demo calls

Use 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.


Implementation Status

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/call input/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.

About

My solution to Check24 GenDev Scholarship Task

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages