Skip to content

Split into stateless MCP shim + daemon; fix Telnyx v5 SDK usage - #7

Open
sns45 wants to merge 1 commit into
mainfrom
fix/stateless-mcp-daemon-telnyx-v5
Open

Split into stateless MCP shim + daemon; fix Telnyx v5 SDK usage#7
sns45 wants to merge 1 commit into
mainfrom
fix/stateless-mcp-daemon-telnyx-v5

Conversation

@sns45

@sns45 sns45 commented Aug 27, 2026

Copy link
Copy Markdown
Owner

Coming back to this project after a while, three things were broken. All three are fixed here.

1. Every project got a stray data/ folder

baileysAuthDir defaulted to the relative path "data/baileys-auth", which resolved against whatever directory Claude Code launched the MCP server from. So opening any project created a data/ folder in it.
State now lives in ~/.better-call-claude/ (honours $XDG_STATE_HOME, override with BETTERCALLCLAUDE_STATE_DIR), resolved through a new src/paths.ts that cannot produce a relative path. An existing ./data/baileys-auth is copied, never moved, so the WhatsApp pairing survives the upgrade.

2. MCP is stateless, the server was not

The MCP server was itself the stateful daemon: one process held the webhook HTTP server, the Baileys WhatsApp socket, every conversation, and the spawned Claude processes, and it died with its stdio pipe. Each Claude Code session started its own copy, so sessions fought over port 3333 and stole the WhatsApp connection from one another.

Claude Code session A ─stdio─┐
Claude Code session B ─stdio─┼→ [stateless MCP shim] ─loopback HTTP→ [bcc daemon]
Claude Code session C ─stdio─┘                                       ├─ Hono webhooks
                                                                     ├─ Baileys WS
                                                                     └─ conversations

src/index.ts is now a shim that holds no state, binds no port, answers tools/list from a static table, and forwards each call to the daemon. src/daemon.ts owns everything long lived, starts itself on first tool call, and outlives the session so inbound calls and messages still arrive. New CLI: --status, --stop, --daemon, --pair.

3. Telnyx usage was broken, not merely dated

telnyx@5 is a ground up rewrite and every call site failed to typecheck:

Old (v2) New (v5)
new Telnyx(apiKeyString) new Telnyx({ apiKey })
calls.create(...) calls.dial(...)
calls.speak({call_control_id, ...}) calls.actions.speak(id, {...})
calls.gather_using_speak(...) calls.actions.gatherUsingSpeak(id, {...})
calls.hangup({call_control_id}) calls.actions.hangup(id, {})
Deeper than the names: the webhook handlers returned TwiML to Telnyx, which ignores response bodies entirely, so Telnyx voice never worked at all. Replaced with a real Call Control event router (answer, speak, gather, transcription, hangup). Interim transcripts are ignored so a task is not spawned from half a sentence, and outbound greetings are queued until call.answered because dial cannot carry one (the greeting was previously dropped silently).

Security

  • /api/* was unauthenticated while a Tailscale Funnel published the whole server, so anyone with the URL could place calls and send messages as the user. Now bearer token protected (0600 token in the state dir), bound to 127.0.0.1, and /health no longer leaks message content unauthenticated.
  • Telnyx webhook verification was a stub that returned true after checking only the timestamp. It now uses the SDK's Ed25519 verifier and defaults on whenever a key is present.

Other bugs found and fixed

  • The published bundle contained var {serve} = globalThis.Bun while engines claimed node >= 18, so npm installs crashed immediately on Node. Serving is now runtime agnostic.
  • Phone normalization prefixed +1 to any number without a +, corrupting international numbers (919876543210 became +1919876543210).
  • Baileys reconnect recursed without tearing down the old socket, piling up listeners. Now single flight with capped backoff.
  • receive_inbound_message with channel: "any" could consume a voice call and then report that no message arrived.
  • /api/call never recorded the provider call id, so every later speak or hangup on that conversation targeted nothing.
  • MCP advertised version 2.0.0 while the package was 3.1.3.
  • get_call_status was documented in the README but did not exist.
  • Tailscale is now optional and non interactive: it never runs tailscale up, opens the app, or waits on a browser, which a background daemon should not do. Added BETTERCALLCLAUDE_PUBLIC_URL for a self managed tunnel.
  • bun test matched the vendored servers/ suite and reported its 13 failures as ours; scoped via bunfig.toml.
  • tools.json is now generated from src/tools.ts (bun run tools:sync) with a test that fails on drift.

Verification

  • tsc --noEmit clean (it previously reported 7 errors, all Telnyx).
  • 303 tests pass, up from 234. New coverage for the daemon lifecycle, the auth boundary, Telnyx v5 SDK usage, state paths and phone normalization.
  • Built package runs end to end under both Bun and Node.
  • Three shims launched simultaneously from cold: two lost the port race and exited cleanly, one daemon served all three.
  • Shim run from a clean temp directory: zero files created there.
    Not verified: live Telnyx and Twilio call flows, since there are no accounts on them. Those paths are covered by unit tests against a mocked SDK and checked against the SDK's typed surface, not against the real API. WhatsApp pairing also needs a live QR scan.

The MCP server was a stateful daemon: one process held the webhook HTTP
server, the Baileys WhatsApp socket, all conversations, and the spawned
Claude processes, and it died with its stdio pipe. Every Claude Code
session started its own copy, so they fought over port 3333 and stole the
WhatsApp connection from each other.

src/index.ts is now a stateless shim that holds no state, binds no port,
answers tools/list from a static table, and forwards each call to the
daemon over loopback HTTP. src/daemon.ts owns everything long lived,
starts itself on first tool call, and outlives the session so inbound
calls and messages still arrive. New CLI: --status, --stop, --daemon,
--pair.

Stray data/ directories

baileysAuthDir defaulted to the relative path "data/baileys-auth", which
resolved against whatever project Claude Code was launched from, so every
project got a data/ folder. State now lives in ~/.better-call-claude
(honours XDG_STATE_HOME, override with BETTERCALLCLAUDE_STATE_DIR) via a
new paths module that cannot produce a relative path. An existing
./data/baileys-auth is copied, never moved, so the WhatsApp pairing
survives the upgrade.

Telnyx

telnyx@5 is a ground up rewrite and every call site failed to typecheck:
new Telnyx(apiKeyString) is now new Telnyx({apiKey}), calls.create is
calls.dial, and speak/gather_using_speak/hangup moved to calls.actions.*
with the id as first argument. Worse, the webhook handlers returned TwiML
to Telnyx, which ignores response bodies, so Telnyx voice never worked.
Replaced with a real Call Control event router. Interim transcripts are
ignored so a task is not spawned from half a sentence, and outbound
greetings are queued until call.answered because dial cannot carry one
(previously the greeting was silently dropped).

Security

/api/* was unauthenticated while a Tailscale Funnel published the whole
server, so anyone with the URL could place calls and send messages as the
user. Now bearer token protected (0600 token in the state dir), bound to
127.0.0.1, and /health no longer leaks message content unauthenticated.
Telnyx webhook verification was a stub returning true after a timestamp
check; it now uses the SDK's Ed25519 verifier and defaults on whenever a
key is present.

Other fixes

* The published bundle contained `var {serve} = globalThis.Bun` while
  engines claimed node >= 18, so npm installs crashed on Node. Serving is
  now runtime agnostic.
* Phone normalization prefixed +1 to any number without a +, corrupting
  international numbers (919876543210 became +1919876543210).
* Baileys reconnect recursed without tearing down the old socket, piling
  up listeners. Now single flight with capped backoff.
* receive_inbound_message with channel "any" could consume a voice call
  and then report that no message arrived.
* /api/call never recorded the provider call id, so every later speak or
  hangup on that conversation targeted nothing.
* MCP advertised version 2.0.0 while the package was 3.1.3.
* get_call_status was documented but did not exist.
* Tailscale is optional and no longer interactive: it never runs
  tailscale up, opens the app, or waits on a browser. Added
  BETTERCALLCLAUDE_PUBLIC_URL for a self managed tunnel.
* bun test matched the vendored servers/ suite and reported its failures
  as ours; scoped via bunfig.toml.
* tools.json is generated from src/tools.ts (bun run tools:sync) with a
  test that fails on drift.

Verified locally: tsc clean, 303 tests pass, build works, and the built
package runs end to end under both Bun and Node. Live Telnyx and Twilio
call flows are covered by unit tests against a mocked SDK rather than
real accounts.
@sns45
sns45 force-pushed the fix/stateless-mcp-daemon-telnyx-v5 branch from 547ca3b to 145220d Compare August 28, 2026 00:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant