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
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
tools/list
Returns
search_ingredients,get_ingredient_report,lookup_productwith their Zod-derived input schemas and annotations. - 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) andcontent(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
| Tool | Input | Returns |
|---|---|---|
search_ingredients | query: string | Up to 5 summarized reports. Exact-name match first, then substring |
get_ingredient_report | slug: string | One full report, or found:false |
lookup_product | query: string | Up 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/mcpRequires 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.