@chamade/mcp-server 1.1.7 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -7,7 +7,14 @@
7
7
  * and receive live transcripts via resource subscriptions.
8
8
  *
9
9
  * Two modes:
10
- * - Standard MCP (default): agent polls chamade_status for transcripts
10
+ * - Standard MCP (default): agent polls chamade_call_status for transcripts
11
11
  * - Channel mode (--channel): transcripts and events pushed via notifications
12
+ *
13
+ * Tools: 13 total
14
+ * DM: chamade_dm_chat, chamade_dm_typing
15
+ * Call: chamade_call_join, chamade_call_accept, chamade_call_refuse,
16
+ * chamade_call_say, chamade_call_chat, chamade_call_typing,
17
+ * chamade_call_status, chamade_call_leave, chamade_call_list
18
+ * Other: chamade_inbox, chamade_account
12
19
  */
13
20
  export {};
package/dist/index.js CHANGED
@@ -7,8 +7,15 @@
7
7
  * and receive live transcripts via resource subscriptions.
8
8
  *
9
9
  * Two modes:
10
- * - Standard MCP (default): agent polls chamade_status for transcripts
10
+ * - Standard MCP (default): agent polls chamade_call_status for transcripts
11
11
  * - Channel mode (--channel): transcripts and events pushed via notifications
12
+ *
13
+ * Tools: 13 total
14
+ * DM: chamade_dm_chat, chamade_dm_typing
15
+ * Call: chamade_call_join, chamade_call_accept, chamade_call_refuse,
16
+ * chamade_call_say, chamade_call_chat, chamade_call_typing,
17
+ * chamade_call_status, chamade_call_leave, chamade_call_list
18
+ * Other: chamade_inbox, chamade_account
12
19
  */
13
20
  import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
14
21
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -83,59 +90,60 @@ const CHANNEL_PREAMBLE = [
83
90
  "## Channel mode — events arrive automatically",
84
91
  "",
85
92
  "Transcripts, messages, and calls are pushed to you in real-time.",
86
- "Do NOT poll chamade_status in a loop — you will be notified automatically.",
87
- "When you receive transcript lines, respond using chamade_say (TTS) or chamade_chat (text).",
88
- "When you receive a message, reply using chamade_send.",
89
- "When you receive an incoming call, use chamade_answer to pick up.",
90
- "You can still call chamade_status manually to catch up on missed transcript.",
93
+ "Do NOT poll chamade_call_status in a loop — you will be notified automatically.",
94
+ "When you receive transcript lines, respond using chamade_call_say (TTS) or chamade_call_chat (text).",
95
+ "When you receive a message, reply using chamade_dm_chat.",
96
+ "When you receive an incoming call, use chamade_call_accept to pick up.",
97
+ "You can still call chamade_call_status manually to catch up on missed transcript.",
91
98
  "",
92
99
  ].join("\n");
93
100
  const BASE_INSTRUCTIONS = [
94
101
  "Chamade is a voice gateway for AI agents. It lets you join voice meetings (Discord, Teams, Meet, Telegram, SIP) and interact via speech and text.",
95
102
  "",
96
103
  "## Voice call workflow",
97
- "1. Use chamade_join to enter a meeting — returns a call_id and capabilities.",
98
- "2. Poll chamade_status in a loop to read new transcript lines (delta pattern — only new lines since last call).",
99
- "3. When you see new transcript, respond to the CONTENT using chamade_say (TTS) or chamade_chat (text).",
104
+ "1. Use chamade_call_join to enter a meeting — returns a call_id and capabilities.",
105
+ "2. Poll chamade_call_status in a loop to read new transcript lines (delta pattern — only new lines since last call).",
106
+ "3. When you see new transcript, respond to the CONTENT using chamade_call_say (TTS) or chamade_call_chat (text).",
100
107
  "4. Repeat step 2-3. Keep the call active — do NOT leave between exchanges.",
101
- "5. Use chamade_leave only when explicitly asked or when the meeting ends.",
108
+ "5. Use chamade_call_leave only when explicitly asked or when the meeting ends.",
102
109
  "",
103
110
  "## SIP phone calls",
104
111
  "SIP supports both outbound and inbound calls. Setup depends on the tier:",
105
- "- Outbound (Pro plan only): call chamade_join with platform='sip' and meeting_url='sip:+33...@provider'. Uses the user's own SIP trunk (configured in the dashboard).",
106
- "- Inbound (répondeur): user activates a phone number (DID) in the dashboard. Incoming calls appear as 'ringing' in chamade_list_calls.",
112
+ "- Outbound (Pro plan only): call chamade_call_join with platform='sip' and meeting_url='sip:+33...@provider'. Uses the user's own SIP trunk (configured in the dashboard).",
113
+ "- Inbound (répondeur): user activates a phone number (DID) in the dashboard. Incoming calls appear as 'ringing' in chamade_call_list.",
107
114
  "- Inbound (BYOT): user connects their own SIP trunk + DIDs in the dashboard. Same inbound flow.",
108
115
  "",
109
116
  "Inbound call workflow:",
110
- "1. Use chamade_list_calls to see ringing calls (poll regularly).",
111
- "2. Use chamade_answer to pick up the call.",
112
- "3. Use chamade_say to speak and chamade_status to read transcript (same as outbound).",
117
+ "1. Use chamade_call_list to see ringing calls (poll regularly).",
118
+ "2. Use chamade_call_accept to pick up the call.",
119
+ "3. Use chamade_call_say to speak and chamade_call_status to read transcript (same as outbound).",
113
120
  "4. If auto_answer is enabled, calls go straight to 'active' — no need to answer.",
121
+ "5. Use chamade_call_refuse to reject a ringing call.",
114
122
  "",
115
123
  "## Recovery from disconnection",
116
124
  "If the bridge restarts or connection is lost, the call state becomes 'disconnected'.",
117
- "chamade_status will show instructions to reconnect.",
118
- "To recover: call chamade_join again with the same platform and meeting_url.",
125
+ "chamade_call_status will show instructions to reconnect.",
126
+ "To recover: call chamade_call_join again with the same platform and meeting_url.",
119
127
  "A new call_id is issued. Previous transcript is lost.",
120
128
  "",
121
129
  "## DM / Messaging workflow",
122
130
  "1. Use chamade_inbox to check for incoming DM conversations.",
123
- "2. Before composing a reply, use chamade_typing to show the user you are working on it.",
124
- "3. Use chamade_send to reply to a conversation.",
125
- "4. When a conversation has a call_id (voice_started), use chamade_say/chamade_status on it.",
131
+ "2. Before composing a reply, use chamade_dm_typing to show the user you are working on it.",
132
+ "3. Use chamade_dm_chat to reply specify the platform (e.g. 'teams', 'discord').",
133
+ "4. When a call_invite event has a call_id, use chamade_call_say/chamade_call_status on that call.",
126
134
  "",
127
135
  "## Capabilities",
128
136
  "Each call and conversation returns a capabilities array. Meaning:",
129
137
  "- listen: receive audio transcripts (STT)",
130
- "- speak: send audio via TTS (chamade_say)",
138
+ "- speak: send audio via TTS (chamade_call_say)",
131
139
  "- read: receive text chat messages",
132
- "- write: send text chat messages (chamade_chat)",
133
- "- typing: typing indicator supported (chamade_typing)",
140
+ "- write: send text chat messages (chamade_call_chat)",
141
+ "- typing: typing indicator supported",
134
142
  "- files: file sharing supported",
135
143
  "",
136
144
  "Per-platform defaults:",
137
145
  "- discord/teams: listen, speak, read, write, typing, files (full voice + chat)",
138
- "- meet: listen, read, write (no TTS — use chamade_chat instead). Note: chat (read/write) only works for calendar-scheduled meetings. Ad-hoc Meet links have no chat API access. IMPORTANT: when the bot joins a Meet call, a participant in the meeting must manually accept it (Google shows a 'Someone wants to join' popup). Tell the user to accept the bot in the Meet UI.",
146
+ "- meet: listen, read, write (no TTS — use chamade_call_chat instead). Note: chat (read/write) only works for calendar-scheduled meetings. Ad-hoc Meet links have no chat API access. IMPORTANT: when the bot joins a Meet call, a participant in the meeting must manually accept it (Google shows a 'Someone wants to join' popup). Tell the user to accept the bot in the Meet UI.",
139
147
  "- zoom: listen, speak, read, write, files",
140
148
  "- telegram: read, write, typing, files (text DM only, no voice join)",
141
149
  "- whatsapp: read, write (text DM only, no voice join, no typing indicator)",
@@ -143,7 +151,7 @@ const BASE_INSTRUCTIONS = [
143
151
  "- nctalk: listen, speak, read, write, typing",
144
152
  "- slack: read, write, files (text only, no voice — Huddles have no API)",
145
153
  "",
146
- "If 'speak' is not in capabilities, use chamade_chat to send text instead of chamade_say.",
154
+ "If 'speak' is not in capabilities, use chamade_call_chat to send text instead of chamade_call_say.",
147
155
  "",
148
156
  "## Platform requirements",
149
157
  "Each platform requires specific setup. Use chamade_account to check what is already configured.",
@@ -173,8 +181,8 @@ const BASE_INSTRUCTIONS = [
173
181
  "- \"Voice quota exceeded\" → monthly voice minutes used up (free plan: 10 min/month)",
174
182
  "- \"SIP not available on free plan\" → user needs to upgrade to Pro for SIP calls",
175
183
  "- \"No SIP trunk configured\" → user needs to connect a SIP trunk in the dashboard for outbound SIP",
176
- "- \"Direct audio requires a Pro plan\" → direct audio streaming (raw PCM via WebSocket) is Pro only. Free users can still use chamade_say (TTS) within their voice quota",
177
- "- \"Slack does not support voice calls\" → use chamade_inbox/chamade_send for Slack messaging instead",
184
+ "- \"Direct audio requires a Pro plan\" → direct audio streaming (raw PCM via WebSocket) is Pro only. Free users can still use chamade_call_say (TTS) within their voice quota",
185
+ "- \"Slack does not support voice calls\" → use chamade_inbox/chamade_dm_chat for Slack messaging instead",
178
186
  "",
179
187
  "## Account management",
180
188
  `Chamade is fully bot-friendly. All account management (API keys, bot tokens, platform connections, SIP, billing) is available via the REST API at the same endpoints the dashboard uses. See ${CHAMADE_URL}/llms.txt for the full machine-readable documentation. This MCP server focuses on runtime operations (calls + messaging); use the REST API directly for account setup and configuration.`,
@@ -196,15 +204,18 @@ const INSTRUCTIONS = CHANNEL_MODE
196
204
  // ---------------------------------------------------------------------------
197
205
  const server = new McpServer({
198
206
  name: "chamade",
199
- version: "1.1.0",
207
+ version: "2.0.0",
200
208
  }, {
201
209
  capabilities: CHANNEL_MODE
202
210
  ? { experimental: { "claude/channel": {} } }
203
211
  : undefined,
204
212
  instructions: INSTRUCTIONS,
205
213
  });
206
- // -- Tool: join -------------------------------------------------------------
207
- server.tool("chamade_join", "Join a voice meeting on any platform (Discord, Teams, Meet, Telegram, SIP, WhatsApp). Returns a call_id. After joining, poll chamade_status regularly to read new transcript lines. Use chamade_say to speak (TTS) and chamade_leave to hang up. Keep the call active — do NOT leave between exchanges.", {
214
+ // ===========================================================================
215
+ // CALL TOOLS
216
+ // ===========================================================================
217
+ // -- Tool: call_join ----------------------------------------------------------
218
+ server.tool("chamade_call_join", "Join a voice meeting on any platform (Discord, Teams, Meet, Telegram, SIP, WhatsApp). Returns a call_id. After joining, poll chamade_call_status regularly to read new transcript lines. Use chamade_call_say to speak (TTS) and chamade_call_leave to hang up. Keep the call active — do NOT leave between exchanges.", {
208
219
  platform: z
209
220
  .enum(["discord", "teams", "meet", "telegram", "sip", "nctalk", "zoom", "whatsapp"])
210
221
  .describe("Meeting platform"),
@@ -245,13 +256,13 @@ server.tool("chamade_join", "Join a voice meeting on any platform (Discord, Team
245
256
  lines.push(`Audio: ${audio.sample_rate}Hz ${audio.format} (${audio.frame_duration_ms}ms frames, ${audio.frame_bytes} bytes/frame)`);
246
257
  }
247
258
  if (!capabilities.includes("speak")) {
248
- lines.push(`Note: TTS (speak) is not supported on ${platform}. Use chamade_chat to send text instead.`);
259
+ lines.push(`Note: TTS (speak) is not supported on ${platform}. Use chamade_call_chat to send text instead.`);
249
260
  }
250
261
  if (CHANNEL_MODE) {
251
- lines.push(``, `Transcript will be pushed automatically. Use chamade_say to speak, chamade_chat to send text, chamade_leave to hang up.`);
262
+ lines.push(``, `Transcript will be pushed automatically. Use chamade_call_say to speak, chamade_call_chat to send text, chamade_call_leave to hang up.`);
252
263
  }
253
264
  else {
254
- lines.push(`Transcript: chamade://calls/${callId}/transcript`, ``, `Use chamade_say to speak, chamade_chat to send text, chamade_status to check transcript, chamade_leave to hang up.`);
265
+ lines.push(`Transcript: chamade://calls/${callId}/transcript`, ``, `Use chamade_call_say to speak, chamade_call_chat to send text, chamade_call_status to check transcript, chamade_call_leave to hang up.`);
255
266
  }
256
267
  return {
257
268
  content: [
@@ -262,9 +273,59 @@ server.tool("chamade_join", "Join a voice meeting on any platform (Discord, Team
262
273
  ],
263
274
  };
264
275
  });
265
- // -- Tool: say --------------------------------------------------------------
266
- server.tool("chamade_say", "Speak text aloud in the meeting via TTS. Write naturally and concisely avoid markdown, code blocks, or long lists as the text is converted to speech. Respond to the CONTENT of what people say, do not echo or repeat their words. Use punctuation to shape delivery: '...' for hesitation, '!' for energy, '—' for a pause. Use CAPS sparingly for emphasis.", {
267
- call_id: z.string().describe("Call ID from chamade_join"),
276
+ // -- Tool: call_accept --------------------------------------------------------
277
+ server.tool("chamade_call_accept", "Answer a ringing inbound call. Use this when chamade_call_list or a call_invite event shows a call in 'ringing' state (incoming phone call). The call transitions to 'active' and you can start speaking.", {
278
+ call_id: z.string().describe("Call ID of the ringing call"),
279
+ }, async ({ call_id }) => {
280
+ await chamadePost(`/api/call/${call_id}/accept`, {});
281
+ // Fetch call details to get real capabilities and platform
282
+ const data = (await chamadeGet(`/api/call/${call_id}`));
283
+ const platform = data.platform || "sip";
284
+ const capabilities = data.capabilities ?? ["listen", "speak"];
285
+ // Track the call locally
286
+ calls.set(call_id, {
287
+ callId: call_id,
288
+ platform,
289
+ meetingUrl: data.meeting_url || "",
290
+ capabilities,
291
+ lastTranscriptLength: 0,
292
+ });
293
+ // Channel mode: auto-watch for transcript push
294
+ if (CHANNEL_MODE) {
295
+ channelWatchCall(call_id);
296
+ }
297
+ const lines = [
298
+ `Answered call ${call_id}.`,
299
+ `State: active`,
300
+ `Capabilities: ${capabilities.join(", ")}`,
301
+ ``,
302
+ `Use chamade_call_say to speak, chamade_call_status to check transcript, chamade_call_leave to hang up.`,
303
+ ];
304
+ if (!capabilities.includes("speak")) {
305
+ lines.push(`Note: TTS (speak) is not supported on ${platform}. Use chamade_call_chat to send text instead.`);
306
+ }
307
+ return {
308
+ content: [
309
+ {
310
+ type: "text",
311
+ text: lines.join("\n"),
312
+ },
313
+ ],
314
+ };
315
+ });
316
+ // -- Tool: call_refuse --------------------------------------------------------
317
+ server.tool("chamade_call_refuse", "Refuse (reject) a ringing inbound call. The call is ended and the caller hears a disconnect.", {
318
+ call_id: z.string().describe("Call ID of the ringing call"),
319
+ }, async ({ call_id }) => {
320
+ await chamadePost(`/api/call/${call_id}/refuse`, {});
321
+ calls.delete(call_id);
322
+ return {
323
+ content: [{ type: "text", text: `Refused call ${call_id}.` }],
324
+ };
325
+ });
326
+ // -- Tool: call_say -----------------------------------------------------------
327
+ server.tool("chamade_call_say", "Speak text aloud in the meeting via TTS. Write naturally and concisely — avoid markdown, code blocks, or long lists as the text is converted to speech. Respond to the CONTENT of what people say, do not echo or repeat their words. Use punctuation to shape delivery: '...' for hesitation, '!' for energy, '—' for a pause. Use CAPS sparingly for emphasis.", {
328
+ call_id: z.string().describe("Call ID from chamade_call_join"),
268
329
  text: z.string().describe("Text to speak aloud"),
269
330
  }, async ({ call_id, text }) => {
270
331
  await chamadePost(`/api/call/${call_id}/say`, { text });
@@ -272,19 +333,32 @@ server.tool("chamade_say", "Speak text aloud in the meeting via TTS. Write natur
272
333
  content: [{ type: "text", text: `Speaking: "${text}"` }],
273
334
  };
274
335
  });
275
- // -- Tool: chat -------------------------------------------------------------
276
- server.tool("chamade_chat", "Send a text chat message in the meeting.", {
277
- call_id: z.string().describe("Call ID from chamade_join"),
336
+ // -- Tool: call_chat ----------------------------------------------------------
337
+ server.tool("chamade_call_chat", "Send a text chat message in the meeting. Set sender_name to identify yourself (e.g. your name or 'AI Assistant').", {
338
+ call_id: z.string().describe("Call ID from chamade_call_join"),
278
339
  text: z.string().describe("Chat message to send"),
279
- }, async ({ call_id, text }) => {
280
- await chamadePost(`/api/call/${call_id}/chat`, { text });
340
+ sender_name: z.string().optional().describe("Display name shown in the chat (e.g. 'Claude', 'AI Assistant')"),
341
+ }, async ({ call_id, text, sender_name }) => {
342
+ const body = { text };
343
+ if (sender_name)
344
+ body.sender_name = sender_name;
345
+ await chamadePost(`/api/call/${call_id}/chat`, body);
281
346
  return {
282
347
  content: [{ type: "text", text: `Chat sent: "${text}"` }],
283
348
  };
284
349
  });
285
- // -- Tool: status -----------------------------------------------------------
286
- server.tool("chamade_status", "Get call status and new transcript lines since last check. Poll this regularly to listen to what people are saying. Returns only new lines since the previous call (delta pattern).", {
287
- call_id: z.string().describe("Call ID from chamade_join"),
350
+ // -- Tool: call_typing --------------------------------------------------------
351
+ server.tool("chamade_call_typing", "Send a typing indicator in the call's chat. Only works on platforms with 'typing' in capabilities.", {
352
+ call_id: z.string().describe("Call ID from chamade_call_join"),
353
+ }, async ({ call_id }) => {
354
+ await chamadePost(`/api/call/${call_id}/typing`, {});
355
+ return {
356
+ content: [{ type: "text", text: "Typing indicator sent." }],
357
+ };
358
+ });
359
+ // -- Tool: call_status --------------------------------------------------------
360
+ server.tool("chamade_call_status", "Get call status and new transcript lines since last check. Poll this regularly to listen to what people are saying. Returns only new lines since the previous call (delta pattern).", {
361
+ call_id: z.string().describe("Call ID from chamade_call_join"),
288
362
  }, async ({ call_id }) => {
289
363
  const state = calls.get(call_id);
290
364
  const since = state?.lastTranscriptLength ?? 0;
@@ -317,15 +391,15 @@ server.tool("chamade_status", "Get call status and new transcript lines since la
317
391
  data.ended_at ? `Ended: ${data.ended_at}` : "",
318
392
  ];
319
393
  if (data.state === "ringing") {
320
- statusLines.push(``, `Incoming call! Use chamade_answer to pick up, or chamade_leave to reject.`);
394
+ statusLines.push(``, `Incoming call! Use chamade_call_accept to pick up, or chamade_call_refuse to reject.`);
321
395
  }
322
396
  if (data.state === "disconnected") {
323
397
  const meetingUrl = state?.meetingUrl || data.meeting_url || "";
324
398
  const platform = state?.platform || data.platform || "";
325
- statusLines.push(``, `⚠ Bridge connection lost. The voice session has been interrupted.`, `To reconnect, call chamade_join again with platform="${platform}" and meeting_url="${meetingUrl}".`, `A new call_id will be issued. Previous transcript is lost.`);
399
+ statusLines.push(``, `Bridge connection lost. The voice session has been interrupted.`, `To reconnect, call chamade_call_join again with platform="${platform}" and meeting_url="${meetingUrl}".`, `A new call_id will be issued. Previous transcript is lost.`);
326
400
  }
327
401
  if (!capabilities.includes("speak")) {
328
- statusLines.push(`Note: TTS (speak) is not supported on ${data.platform}. Use chamade_chat to send text instead.`);
402
+ statusLines.push(`Note: TTS (speak) is not supported on ${data.platform}. Use chamade_call_chat to send text instead.`);
329
403
  }
330
404
  return {
331
405
  content: [
@@ -338,73 +412,9 @@ server.tool("chamade_status", "Get call status and new transcript lines since la
338
412
  ],
339
413
  };
340
414
  });
341
- // -- Tool: answer -----------------------------------------------------------
342
- server.tool("chamade_answer", "Answer a ringing inbound call. Use this when chamade_list_calls or chamade_status shows a call in 'ringing' state (incoming phone call). The call transitions to 'active' and you can start speaking.", {
343
- call_id: z.string().describe("Call ID of the ringing call"),
344
- }, async ({ call_id }) => {
345
- await chamadePost(`/api/call/${call_id}/answer`, {});
346
- // Fetch call details to get real capabilities and platform
347
- const data = (await chamadeGet(`/api/call/${call_id}`));
348
- const platform = data.platform || "sip";
349
- const capabilities = data.capabilities ?? ["listen", "speak"];
350
- // Track the call locally
351
- calls.set(call_id, {
352
- callId: call_id,
353
- platform,
354
- meetingUrl: data.meeting_url || "",
355
- capabilities,
356
- lastTranscriptLength: 0,
357
- });
358
- // Channel mode: auto-watch for transcript push
359
- if (CHANNEL_MODE) {
360
- channelWatchCall(call_id);
361
- }
362
- const lines = [
363
- `Answered call ${call_id}.`,
364
- `State: active`,
365
- `Capabilities: ${capabilities.join(", ")}`,
366
- ``,
367
- `Use chamade_say to speak, chamade_status to check transcript, chamade_leave to hang up.`,
368
- ];
369
- if (!capabilities.includes("speak")) {
370
- lines.push(`Note: TTS (speak) is not supported on ${platform}. Use chamade_chat to send text instead.`);
371
- }
372
- return {
373
- content: [
374
- {
375
- type: "text",
376
- text: lines.join("\n"),
377
- },
378
- ],
379
- };
380
- });
381
- // -- Tool: typing -----------------------------------------------------------
382
- server.tool("chamade_typing", "Send a typing indicator. Use this before composing a reply so the user sees you are working on it. Only works on platforms with 'typing' in capabilities (Discord, Teams, Telegram, NC Talk).", {
383
- call_id: z
384
- .string()
385
- .optional()
386
- .describe("Call ID (for typing in a call's chat)"),
387
- conversation_id: z
388
- .string()
389
- .optional()
390
- .describe("Conversation ID (for typing in a DM)"),
391
- }, async ({ call_id, conversation_id }) => {
392
- if (call_id) {
393
- await chamadePost(`/api/call/${call_id}/typing`, {});
394
- }
395
- else if (conversation_id) {
396
- await chamadePost("/api/typing", { conversation_id });
397
- }
398
- else {
399
- throw new Error("Either call_id or conversation_id is required");
400
- }
401
- return {
402
- content: [{ type: "text", text: "Typing indicator sent." }],
403
- };
404
- });
405
- // -- Tool: leave ------------------------------------------------------------
406
- server.tool("chamade_leave", "Leave the meeting and hang up. Only use when explicitly asked to leave or when the meeting is over.", {
407
- call_id: z.string().describe("Call ID from chamade_join"),
415
+ // -- Tool: call_leave ---------------------------------------------------------
416
+ server.tool("chamade_call_leave", "Leave the meeting and hang up. Only use when explicitly asked to leave or when the meeting is over.", {
417
+ call_id: z.string().describe("Call ID from chamade_call_join"),
408
418
  }, async ({ call_id }) => {
409
419
  calls.delete(call_id);
410
420
  // Channel mode: stop watching
@@ -416,8 +426,8 @@ server.tool("chamade_leave", "Leave the meeting and hang up. Only use when expli
416
426
  content: [{ type: "text", text: `Left call ${call_id}.` }],
417
427
  };
418
428
  });
419
- // -- Tool: list_calls -------------------------------------------------------
420
- server.tool("chamade_list_calls", "List all active calls.", {}, async () => {
429
+ // -- Tool: call_list ----------------------------------------------------------
430
+ server.tool("chamade_call_list", "List all active calls.", {}, async () => {
421
431
  const data = (await chamadeGet("/api/calls"));
422
432
  if (data.calls.length === 0) {
423
433
  return {
@@ -434,57 +444,63 @@ server.tool("chamade_list_calls", "List all active calls.", {}, async () => {
434
444
  content: [{ type: "text", text: lines.join("\n") }],
435
445
  };
436
446
  });
437
- // -- Tool: account ----------------------------------------------------------
438
- server.tool("chamade_account", "Check account status: plan, credit/quota remaining, and per-platform readiness (ok, not_configured, error). Use this to diagnose setup issues or check remaining credit before making calls.", {}, async () => {
439
- const data = (await chamadeGet("/api/account"));
440
- const lines = [`Plan: ${data.plan}`];
441
- // Credit or voice quota
442
- const credit = data.credit;
443
- const voice = data.voice_quota;
444
- if (credit) {
445
- lines.push(`Credit: ${credit.remaining_eur.toFixed(2)} EUR remaining (${credit.used_eur.toFixed(2)} / ${credit.total_eur.toFixed(2)} EUR used)`);
446
- }
447
- else if (voice) {
448
- lines.push(`Voice quota: ${voice.used_minutes.toFixed(1)} / ${voice.limit_minutes} min used`);
449
- }
450
- const concurrent = data.concurrent_calls;
451
- if (concurrent) {
452
- const limitStr = concurrent.limit === 0 ? "unlimited" : String(concurrent.limit);
453
- lines.push(`Active calls: ${concurrent.active} / ${limitStr}`);
454
- }
455
- // Platform statuses
456
- const platforms = data.platforms;
457
- if (platforms) {
458
- lines.push("", "Platforms:");
459
- for (const [name, status] of Object.entries(platforms)) {
460
- const icon = status.startsWith("ok") ? "+" : status.startsWith("error") ? "!" : "-";
461
- lines.push(` ${icon} ${name}: ${status}`);
462
- }
463
- }
447
+ // ===========================================================================
448
+ // DM TOOLS
449
+ // ===========================================================================
450
+ // -- Tool: dm_chat ------------------------------------------------------------
451
+ server.tool("chamade_dm_chat", "Send a message to the active DM conversation on a platform. The message will be delivered to the user on their platform (Discord, Telegram, Teams, etc.).", {
452
+ platform: z
453
+ .enum(["discord", "teams", "telegram", "whatsapp", "slack", "nctalk"])
454
+ .describe("Platform where the DM conversation is active"),
455
+ text: z.string().describe("Message text to send"),
456
+ }, async ({ platform, text }) => {
457
+ channelStopTyping(platform);
458
+ const data = (await chamadePost("/api/dm/chat", {
459
+ platform,
460
+ text,
461
+ }));
464
462
  return {
465
- content: [{ type: "text", text: lines.join("\n") }],
463
+ content: [
464
+ {
465
+ type: "text",
466
+ text: `Message sent to ${platform} (${data.delivery || "async"}): "${text}"`,
467
+ },
468
+ ],
466
469
  };
467
470
  });
468
- // -- Tool: inbox ------------------------------------------------------------
469
- server.tool("chamade_inbox", "Check inbox for DM conversations. Without conversation_id, lists all active conversations. With conversation_id, returns details and message history. When a voice_started event appears, use chamade_say/chamade_status with the provided call_id.", {
470
- conversation_id: z
471
- .string()
471
+ // -- Tool: dm_typing ----------------------------------------------------------
472
+ server.tool("chamade_dm_typing", "Send a typing indicator on a DM conversation. Use this before composing a reply so the user sees you are working on it. Only works on platforms with 'typing' in capabilities (Discord, Teams, Telegram, NC Talk).", {
473
+ platform: z
474
+ .enum(["discord", "teams", "telegram", "whatsapp", "slack", "nctalk"])
475
+ .describe("Platform where the DM conversation is active"),
476
+ }, async ({ platform }) => {
477
+ await chamadePost("/api/dm/typing", { platform });
478
+ return {
479
+ content: [{ type: "text", text: `Typing indicator sent on ${platform}.` }],
480
+ };
481
+ });
482
+ // ===========================================================================
483
+ // OTHER TOOLS
484
+ // ===========================================================================
485
+ // -- Tool: inbox --------------------------------------------------------------
486
+ server.tool("chamade_inbox", "Check inbox for DM conversations. Without a platform, lists all active conversations. With a platform, returns the active DM conversation details and message history for that platform.", {
487
+ platform: z
488
+ .enum(["discord", "teams", "telegram", "whatsapp", "slack", "nctalk"])
472
489
  .optional()
473
- .describe("Optional conversation ID to get details and messages"),
490
+ .describe("Optional: get messages for a specific platform's DM"),
474
491
  limit: z
475
492
  .number()
476
493
  .default(20)
477
494
  .describe("Max messages to return (when viewing a conversation)"),
478
- }, async ({ conversation_id, limit }) => {
479
- if (conversation_id) {
480
- const data = (await chamadeGet(`/api/inbox/${conversation_id}?limit=${limit}`));
495
+ }, async ({ platform, limit }) => {
496
+ if (platform) {
497
+ const data = (await chamadeGet(`/api/inbox?platform=${platform}&limit=${limit}`));
481
498
  const conv = data.conversation;
482
499
  const messages = data.messages;
483
500
  const capabilities = conv.capabilities ?? [];
484
501
  const lines = [
485
- `Conversation: ${conv.id}`,
486
502
  `Platform: ${conv.platform}`,
487
- `Contact: ${conv.remote_name || conv.remote_id}`,
503
+ `Contact: ${conv.remote_name || "unknown"}`,
488
504
  `Type: ${conv.channel_type}`,
489
505
  `Capabilities: ${capabilities.join(", ") || "text"}`,
490
506
  `Messages: ${conv.message_count}`,
@@ -517,32 +533,44 @@ server.tool("chamade_inbox", "Check inbox for DM conversations. Without conversa
517
533
  const capabilities = c.capabilities ?? [];
518
534
  const cap = capabilities.length > 0 ? ` [${capabilities.join(",")}]` : "";
519
535
  const callInfo = c.call_id ? ` (voice: ${c.call_id})` : "";
520
- return `- ${c.id} (${c.platform}) — ${c.remote_name || c.remote_id} — ${c.message_count} msgs${cap}${callInfo}`;
536
+ return `- ${c.platform} — ${c.remote_name || "unknown"} — ${c.message_count} msgs${cap}${callInfo}`;
521
537
  });
522
538
  return {
523
539
  content: [{ type: "text", text: lines.join("\n") }],
524
540
  };
525
541
  });
526
- // -- Tool: send -------------------------------------------------------------
527
- server.tool("chamade_send", "Send a message to a DM conversation. The message will be delivered to the user on their platform (Discord, Telegram, etc.).", {
528
- conversation_id: z.string().describe("Conversation ID from chamade_inbox"),
529
- text: z.string().describe("Message text to send"),
530
- }, async ({ conversation_id, text }) => {
531
- channelStopTyping(conversation_id);
532
- const data = (await chamadePost("/api/send", {
533
- conversation_id,
534
- text,
535
- }));
542
+ // -- Tool: account ------------------------------------------------------------
543
+ server.tool("chamade_account", "Check account status: plan, credit/quota remaining, and per-platform readiness (ok, not_configured, error). Use this to diagnose setup issues or check remaining credit before making calls.", {}, async () => {
544
+ const data = (await chamadeGet("/api/account"));
545
+ const lines = [`Plan: ${data.plan}`];
546
+ // Credit or voice quota
547
+ const credit = data.credit;
548
+ const voice = data.voice_quota;
549
+ if (credit) {
550
+ lines.push(`Credit: ${credit.remaining_eur.toFixed(2)} EUR remaining (${credit.used_eur.toFixed(2)} / ${credit.total_eur.toFixed(2)} EUR used)`);
551
+ }
552
+ else if (voice) {
553
+ lines.push(`Voice quota: ${voice.used_minutes.toFixed(1)} / ${voice.limit_minutes} min used`);
554
+ }
555
+ const concurrent = data.concurrent_calls;
556
+ if (concurrent) {
557
+ const limitStr = concurrent.limit === 0 ? "unlimited" : String(concurrent.limit);
558
+ lines.push(`Active calls: ${concurrent.active} / ${limitStr}`);
559
+ }
560
+ // Platform statuses
561
+ const platforms = data.platforms;
562
+ if (platforms) {
563
+ lines.push("", "Platforms:");
564
+ for (const [name, status] of Object.entries(platforms)) {
565
+ const icon = status.startsWith("ok") ? "+" : status.startsWith("error") ? "!" : "-";
566
+ lines.push(` ${icon} ${name}: ${status}`);
567
+ }
568
+ }
536
569
  return {
537
- content: [
538
- {
539
- type: "text",
540
- text: `Message sent (${data.delivery || "async"}): "${text}"`,
541
- },
542
- ],
570
+ content: [{ type: "text", text: lines.join("\n") }],
543
571
  };
544
572
  });
545
- // -- Resource: transcript ---------------------------------------------------
573
+ // -- Resource: transcript -----------------------------------------------------
546
574
  const transcriptTemplate = new ResourceTemplate("chamade://calls/{call_id}/transcript", {
547
575
  list: async () => {
548
576
  return {
@@ -599,18 +627,18 @@ const _callWatchers = new Map();
599
627
  const _closedWatchers = new Set();
600
628
  const _typingActive = new Set();
601
629
  /**
602
- * Start typing indicator for a conversation.
630
+ * Start typing indicator for a DM conversation (keyed by platform).
603
631
  * Sends a single POST — Maquisard manages the repeat loop internally
604
632
  * and stops it automatically when a message is sent.
605
633
  */
606
- function channelStartTyping(conversationId) {
607
- if (_typingActive.has(conversationId))
634
+ function channelStartTyping(platform) {
635
+ if (_typingActive.has(platform))
608
636
  return;
609
- _typingActive.add(conversationId);
610
- chamadePost("/api/typing", { conversation_id: conversationId }).catch(() => { });
637
+ _typingActive.add(platform);
638
+ chamadePost("/api/dm/typing", { platform }).catch(() => { });
611
639
  }
612
- function channelStopTyping(conversationId) {
613
- _typingActive.delete(conversationId);
640
+ function channelStopTyping(platform) {
641
+ _typingActive.delete(platform);
614
642
  }
615
643
  /**
616
644
  * Push a channel notification to Claude Code.
@@ -635,6 +663,12 @@ async function channelPush(content, meta = {}) {
635
663
  /**
636
664
  * Watch a call's transcript via WebSocket push.
637
665
  * Opens a WS to /api/call/{id}/stream and pushes each event.
666
+ *
667
+ * Events from server (already remapped by Chamade backend):
668
+ * - call_transcript: speaker + text
669
+ * - call_chat: sender + text
670
+ * - call_state: state changes
671
+ * - call_error: errors
638
672
  */
639
673
  function channelWatchCall(callId) {
640
674
  if (_callWatchers.has(callId) || _closedWatchers.has(callId))
@@ -656,28 +690,27 @@ function channelWatchCall(callId) {
656
690
  try {
657
691
  const data = JSON.parse(raw.toString());
658
692
  switch (data.type) {
659
- case "transcript":
693
+ case "call_transcript":
660
694
  retryCount = 0; // Real data — reset retry counter
661
695
  if (data.is_final !== false) {
662
- await channelPush(`[${data.speaker || "?"}] ${data.text || ""}`, { type: "transcript", call_id: callId });
696
+ await channelPush(`[${data.speaker || "?"}] ${data.text || ""}`, { type: "call_transcript", call_id: callId });
663
697
  }
664
698
  break;
665
- case "chat":
666
- case "chat_received":
667
- await channelPush(`[chat:${data.sender || "?"}] ${data.text || ""}`, { type: "chat", call_id: callId });
699
+ case "call_chat":
700
+ await channelPush(`[chat:${data.sender || "?"}] ${data.text || ""}`, { type: "call_chat", call_id: callId });
668
701
  break;
669
- case "state":
670
- await channelPush(`Call state: ${data.state}`, { type: "state", call_id: callId });
702
+ case "call_state":
703
+ await channelPush(`Call state: ${data.state}`, { type: "call_state", call_id: callId });
671
704
  if (data.state === "ended" || data.state === "error") {
672
705
  channelUnwatchCall(callId);
673
706
  }
674
707
  break;
675
- case "error":
676
- await channelPush(`Call error: ${data.message || data.code || "unknown"}`, { type: "error", call_id: callId });
708
+ case "call_error":
709
+ await channelPush(`Call error: ${data.message || data.code || "unknown"}`, { type: "call_error", call_id: callId });
677
710
  // Stop watching on any terminal error
678
711
  channelUnwatchCall(callId);
679
712
  if (data.code === "bridge_disconnected") {
680
- await channelPush(`Bridge connection lost for call ${callId}. Use chamade_join with the same meeting_url to reconnect.`, { type: "disconnected", call_id: callId });
713
+ await channelPush(`Bridge connection lost for call ${callId}. Use chamade_call_join with the same meeting_url to reconnect.`, { type: "call_state", call_id: callId });
681
714
  }
682
715
  break;
683
716
  }
@@ -696,7 +729,7 @@ function channelWatchCall(callId) {
696
729
  else if (!_closedWatchers.has(callId)) {
697
730
  console.error(`[chamade-channel] Call ${callId}: max retries (${MAX_RETRIES}) reached, giving up`);
698
731
  channelUnwatchCall(callId);
699
- channelPush(`Call ${callId} ended (connection lost). Use chamade_leave to clean up, or chamade_join to reconnect.`, { type: "call_ended", call_id: callId });
732
+ channelPush(`Call ${callId} ended (connection lost). Use chamade_call_leave to clean up, or chamade_call_join to reconnect.`, { type: "call_state", call_id: callId });
700
733
  }
701
734
  });
702
735
  ws.on("error", () => {
@@ -722,6 +755,13 @@ function channelUnwatchCall(callId) {
722
755
  /**
723
756
  * Watch inbox for DMs, incoming calls, and conversation events.
724
757
  * Reconnects automatically on disconnect.
758
+ *
759
+ * Events from server (already remapped by Chamade backend):
760
+ * - dm_chat: new DM message
761
+ * - dm_started: new DM conversation
762
+ * - dm_ended: DM conversation closed
763
+ * - call_invite: incoming call (SIP or DM voice)
764
+ * - call_state: call state change (disconnected, etc.)
725
765
  */
726
766
  function channelWatchInbox() {
727
767
  let retryDelay = 1000;
@@ -747,46 +787,34 @@ function channelWatchInbox() {
747
787
  try {
748
788
  const data = JSON.parse(raw.toString());
749
789
  switch (data.type) {
750
- case "message":
751
- // Auto-send repeating typing indicator until chamade_send replies
752
- if (data.conversation_id) {
753
- channelStartTyping(data.conversation_id);
790
+ case "dm_chat":
791
+ // Auto-send typing indicator by platform
792
+ if (data.platform) {
793
+ channelStartTyping(data.platform);
754
794
  }
755
795
  await channelPush(`New message from ${data.sender_name || "unknown"} on ${data.platform}: "${data.text}"`, {
756
- type: "message",
757
- conversation_id: data.conversation_id || "",
796
+ type: "dm_chat",
758
797
  platform: data.platform || "",
759
798
  });
760
799
  break;
761
- case "incoming_call":
762
- await channelPush(`Incoming call from ${data.caller || "unknown"}${data.did ? ` on ${data.did}` : ""}. Call ID: ${data.call_id}. Use chamade_answer to pick up.`, { type: "incoming_call", call_id: data.call_id || "" });
763
- // Auto-watch if already active (auto_answer)
800
+ case "call_invite":
801
+ await channelPush(`Incoming call${data.caller ? ` from ${data.caller}` : ""} on ${data.platform || "sip"}. Call ID: ${data.call_id}. State: ${data.state || "ringing"}. Use chamade_call_accept to pick up.`, { type: "call_invite", call_id: data.call_id || "" });
802
+ // Auto-watch if already active (auto_answer or DM voice)
764
803
  if (data.state === "active" && data.call_id) {
765
804
  channelWatchCall(data.call_id);
766
805
  }
767
806
  break;
768
- case "voice_started":
769
- await channelPush(`Voice started. Call ID: ${data.call_id}. Platform: ${data.platform}.`, {
770
- type: "voice_started",
771
- call_id: data.call_id || "",
772
- conversation_id: data.conversation_id || "",
773
- });
774
- if (data.call_id) {
775
- channelWatchCall(data.call_id);
776
- }
777
- break;
778
- case "conversation_started":
807
+ case "dm_started":
779
808
  await channelPush(`New conversation from ${data.remote_name || "unknown"} on ${data.platform}.`, {
780
- type: "conversation",
781
- conversation_id: data.conversation_id || "",
809
+ type: "dm_started",
782
810
  platform: data.platform || "",
783
811
  });
784
812
  break;
785
- case "conversation_ended":
786
- await channelPush(`Conversation ended.`, { type: "conversation_ended", conversation_id: data.conversation_id || "" });
813
+ case "dm_ended":
814
+ await channelPush(`Conversation ended on ${data.platform || "unknown"}.`, { type: "dm_ended", platform: data.platform || "" });
787
815
  break;
788
- case "call_disconnected":
789
- await channelPush(`Call disconnected: ${data.message || "connection lost"}. Call ID: ${data.call_id}.`, { type: "call_disconnected", call_id: data.call_id || "" });
816
+ case "call_state":
817
+ await channelPush(`Call ${data.call_id}: ${data.state}${data.message ? ` ${data.message}` : ""}`, { type: "call_state", call_id: data.call_id || "" });
790
818
  break;
791
819
  }
792
820
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chamade/mcp-server",
3
- "version": "1.1.7",
3
+ "version": "2.0.0",
4
4
  "description": "MCP server for Chamade — voice gateway for AI agents. Join Discord, Teams, Meet, Telegram, SIP, Zoom meetings and interact via speech and text. Supports Claude Code channel mode for push events.",
5
5
  "type": "module",
6
6
  "bin": {