DEV Community

edhiblemeer
edhiblemeer

Posted on • Originally published at zenn.dev

How we made our niche-industry SaaS MCP-ready (and watched ChatGPT call our dispatch tools)

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:

  1. Discovery path mismatch. ChatGPT expected {% raw %}.well-known/oauth-protected-resource at server root; we published under /v1/api/staff/mcp/....
  2. Transport mismatch. ChatGPT expects Streamable HTTP (POST /sse with JSON-RPC directly). We started on the legacy SSEServerTransport. QA log: POST /sse/1 → 404.
  3. 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.
  4. Guard mismatch (the big one). OAuth tokens are signed with the OAuth secret; the existing StaffJwtGuard validates the staff-login JWT (different secret). 401 on every /sse/1 POST. Fix: new OAuthAccessTokenGuard with RS256 + HS256 fallback.
  5. undefined.trim(). Tool schema declared date optional, ChatGPT called with no args, our handler did naturalText.trim(). Crash → tool result error. Fix: (naturalText || "today").trim().
  6. The "looks connected but no tools" stage. tools/list was returning an empty payload because the transport handshake never fully completed under the wrong Guard. Once Guard fix landed + cache cleared, tools/list returned all three.
  7. 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.
  8. Naming convention guess. ChatGPT speculated the tool would be named get_available_drivers (OpenAI convention) when ours is list_available_drivers. Worked once tools/list actually loaded — name conventions were never the problem.
  9. shop_id not surfaced in inputSchema. ChatGPT can't pass arguments it can't see. list_assignable_casts falls back to a server-side default shop. Pending B3.
  10. 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. resolveBusinessDate reads Company.changeDateTime.
  11. Multi-tenant in one SSE process. One process, N companies. We key the McpServer instance by companyId from the validated token claim, so cross-tenant leakage is structurally impossible.
  12. "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-mcp UA 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/list empty ≠ 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) in inputSchema so 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)