Note: This is an English digest of the original Zenn post (Japanese). Read there for the full timeline and commit-level trace.
TL;DR
- We ship tasteck, a B2B SaaS for the Japanese night-leisure industry (dispatch + cast shift management). 8 years of operational data, ~100 venues live.
- Two days after the MCP design post, ChatGPT Plus can call our tools live: "Who's available tonight?" → MCP
list_available_drivers→ JSON → natural-language reply. - Estimated B2 OAuth sprint = 2 weeks (6/16–7/1). Actual = 1 day, by reading the spec carefully before touching code.
- We hit 12 distinct traps between "OAuth issuance works" and "ChatGPT actually invokes the tool." The QA logs caught every one.
What we shipped
3 read tools (B1):
-
list_available_drivers— drivers free tonight -
list_cast_shifts— today's cast shift roster -
list_assignable_casts— joined resolution:roster ∧ stage-name set ∧ shop match
Natural-language date helper: resolveBusinessDate(naturalText, company) — handles "today / tomorrow / day-after-tomorrow" and the per-tenant business-day boundary (e.g. day flips at 04:00 or 05:00, configured per Company.changeDateTime).
MCP SDK Server + SSE transport: @modelcontextprotocol/sdk wired into a NestJS controller. One SSE connection = one McpServer instance, company-scoped, with a session_id Map routing POST /messages.
OAuth flow (B2, finished in one day across 7 steps)
| Step | What | Commit |
|---|---|---|
| 1 | Protected Resource Metadata endpoint (RFC 9728) | d6f05ff6 |
| 2 |
/authorize + consent screen + PKCE start |
107edbcb |
| 3 |
/token + PKCE verify + JWT issue + resource (RFC 8707) |
ffd0468c |
| 4 |
OAuthAccessTokenGuard (RS256 + HS256 fallback, extracts companyId / staffId) |
f2c9bed4 |
| 5 | Streamable HTTP transport (SSE → POST /sse/:companyId for JSON-RPC) |
3a28d92f |
| 6 |
resolveBusinessDate undefined fallback (`(naturalText |
|
| 7 | QA redeploy + ChatGPT live demo | — |
The 12 traps (compressed)
The full timeline is in the Japanese post; the abridged list:
-
Discovery path mismatch. ChatGPT expected {% raw %}
.well-known/oauth-protected-resourceat server root; we published under/v1/api/staff/mcp/.... -
Transport mismatch. ChatGPT expects Streamable HTTP (POST
/ssewith JSON-RPC directly). We started on the legacySSEServerTransport. QA log: POST/sse/1→ 404. - Cache illusion. ChatGPT cached "this connector has no tools" across our fixes. We thought it was a server bug. Required: disconnect → reconnect through the OAuth flow.
-
Guard mismatch (the big one). OAuth tokens are signed with the OAuth secret; the existing
StaffJwtGuardvalidates the staff-login JWT (different secret). 401 on every/sse/1POST. Fix: newOAuthAccessTokenGuardwith RS256 + HS256 fallback. -
undefined.trim(). Tool schema declareddateoptional, ChatGPT called with no args, our handler didnaturalText.trim(). Crash → tool result error. Fix:(naturalText || "today").trim(). -
The "looks connected but no tools" stage.
tools/listwas returning an empty payload because the transport handshake never fully completed under the wrong Guard. Once Guard fix landed + cache cleared,tools/listreturned all three. - Cache + Guard same-time confusion. I initially "ruled out" cache. The QA log proved both were real and simultaneous — single-cause reasoning is the trap, not either issue.
-
Naming convention guess. ChatGPT speculated the tool would be named
get_available_drivers(OpenAI convention) when ours islist_available_drivers. Worked oncetools/listactually loaded — name conventions were never the problem. -
shop_idnot surfaced ininputSchema. ChatGPT can't pass arguments it can't see.list_assignable_castsfalls back to a server-side default shop. Pending B3. -
Per-tenant business-day boundary. Hardcoding
new Date().toISOString().slice(0,10)would have been wrong for any tenant whose business day rolls at 04:00–05:00.resolveBusinessDatereadsCompany.changeDateTime. -
Multi-tenant in one SSE process. One process, N companies. We key the
McpServerinstance bycompanyIdfrom the validated token claim, so cross-tenant leakage is structurally impossible. - "It works on curl" ≠ "it works on ChatGPT". Our handshake script returned 200 OK on all three tools long before ChatGPT could invoke any of them. Curl doesn't model OAuth state, cache, or discovery — only a live demo proves the loop.
The moment it worked
After the Guard fix, the cache clear, and the undefined fallback all landed:
ChatGPT: "Tools have been called ✓. Available drivers for 2026-06-10: 0 people. The returned payload is
drivers: [], so there are no assignable drivers for tonight."
Then list_cast_shifts and list_assignable_casts both fired clean on the same chat. 3/3 tools, real data path, real natural-language synthesis.
What we'd tell anyone shipping MCP OAuth tomorrow
- Read the spec twice before opening your editor. The 1-week-ahead-of-schedule was specification reading, not coding speed.
- Don't conflate transport and discovery. They fail in different ways and the logs are different. Treat them as separate problems.
-
Log every
openai-mcpUA hit. The presence/absence of those requests is your fastest cache-vs-server diagnostic. - Plan for two Guards from day one. Staff JWT and OAuth access token are different signing materials. Don't reuse.
-
tools/listempty ≠ tools broken. Check the transport handshake completed first. Empty tools is usually a symptom three layers up.
Where this goes next
- B3: surface
shop_id(and other args) ininputSchemaso the LLM can target a specific shop. - Write tools (B4): same OAuth path, but consent + audit shape changes for
assign_*mutations. - Industry-side: this is the first MCP-ready B2B SaaS in the Japanese night-leisure vertical. Build-in-public continues.
Full Japanese write-up with commit hashes and screenshot timeline: Zenn.
Top comments (0)