ChatGPT app — MCP server

An MCP server over Streamable HTTP at /mcp, exposing the same three tools as SMS. ChatGPT calls them directly; there is no agent loop on our side.

Transport

Streamable HTTP, not SSE

The Apps SDK requires streamable HTTP for production. SSE is the older transport — the abandoned scaffold in PBN-V1/mcp-server uses it and is not a template to copy from.

The server runs stateless: a fresh McpServer and transport per request, with sessionIdGenerator: undefined. No session state to lose when Railway restarts or scales. GET and DELETE on /mcp return 405 deliberately — they belong to session mode, and must not 404.

Sample flow

  1. 1

    initialize

    POST /mcp
    Accept: application/json, text/event-stream
    
    {"jsonrpc":"2.0","id":1,"method":"initialize",
     "params":{"protocolVersion":"2025-06-18","capabilities":{},
               "clientInfo":{"name":"chatgpt","version":"1.0"}}}
    event: message
    data: {"result":{"protocolVersion":"2025-06-18",
      "capabilities":{"tools":{"listChanged":true}},
      "serverInfo":{"name":"ingredient-checker","version":"1.0.0"}},
      "jsonrpc":"2.0","id":1}
  2. 2

    tools/list

    Returns search_ingredients, get_ingredient_report, lookup_product with their Zod-derived input schemas and annotations.

  3. 3

    tools/call

    {"jsonrpc":"2.0","id":3,"method":"tools/call",
     "params":{"name":"search_ingredients",
               "arguments":{"query":"sucralose"}}}

    Every result carries both structuredContent (for the model to reason over) and content (text for narration):

    {
      "structuredContent": {
        "count": 1,
        "results": [{
          "name": "Sucralose",
          "processing_level": "10",
          "us_fda": "Food additive permitted for general use; ADI 5 mg/kg bw/day (FDA).",
          "eu_status": "Authorized as E955; EFSA 2017 … ADI 15 mg/kg bw/day.",
          "carcinogenicity": "EFSA (2017) found no evidence …",
          "alternatives": [ … 3 … ],
          "url": "https://ingredientchecker.app/full-report/sucralose"
        }]
      },
      "content": [{ "type": "text", "text": "Sucralose — …" }]
    }

Tools

ToolInputReturns
search_ingredientsquery: stringUp to 5 summarized reports. Exact-name match first, then substring
get_ingredient_reportslug: stringOne full report, or found:false
lookup_productquery: stringUp to 5 products with nutrition, additive count, allergens

All three are annotated readOnlyHint: true, openWorldHint: false, destructiveHint: false. Nothing here writes.

No widget — on purpose

The Apps SDK treats UI as optional: a tool returning structuredContent plus content is a complete app. Shipping data-first means the tool contracts get proven before any rendering work, and a widget can be layered on later without changing them.

Honest limits

Search only covers what is in the database

514 ingredients, scoped to what appears in protein bars. Common additives are missing — aspartame is not in the set. The tool correctly returns count: 0 rather than inventing an assessment, but from inside ChatGPT that reads as "this app does not know much".

Worth weighing before public submission: broaden coverage, or scope the app description so expectations match.

Installing it

ChatGPT → Settings → Connectors → add a custom connector pointing at:

https://api.ingredientchecker.app/mcp

Requires developer mode. Public directory submission is a separate OpenAI review, and needs the domain-verification token served at /.well-known/openai-apps-challenge — set OPENAI_APPS_CHALLENGE on Railway and redeploy before retrying verification.

Probing it by hand

curl -sN -X POST https://api.ingredientchecker.app/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'

The Accept header must include text/event-stream — responses come back as SSE frames (event: message / data: …) even in stateless mode.