@chamade/mcp-server 1.1.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/README.md +127 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +826 -0
- package/package.json +54 -0
package/README.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# @chamade/mcp-server
|
|
2
|
+
|
|
3
|
+
MCP (Model Context Protocol) server for Chamade — voice gateway for AI agents.
|
|
4
|
+
|
|
5
|
+
Your agent gets tools to join voice meetings on Discord, Teams, Meet, Telegram, SIP and Nextcloud Talk. Chamade handles STT, TTS and the meeting connection. The agent only sees text.
|
|
6
|
+
|
|
7
|
+
## Quick start
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx @chamade/mcp-server
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Requires `CHAMADE_API_KEY` env var. Get one at https://chamade.io/dashboard.
|
|
14
|
+
|
|
15
|
+
## Configuration
|
|
16
|
+
|
|
17
|
+
### OpenClaw
|
|
18
|
+
|
|
19
|
+
```json
|
|
20
|
+
{
|
|
21
|
+
"agents": {
|
|
22
|
+
"list": [{
|
|
23
|
+
"id": "main",
|
|
24
|
+
"mcp": {
|
|
25
|
+
"servers": [{
|
|
26
|
+
"name": "chamade",
|
|
27
|
+
"command": "npx",
|
|
28
|
+
"args": ["-y", "@chamade/mcp-server"],
|
|
29
|
+
"env": {
|
|
30
|
+
"CHAMADE_API_KEY": "chmd_..."
|
|
31
|
+
}
|
|
32
|
+
}]
|
|
33
|
+
}
|
|
34
|
+
}]
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Claude Desktop
|
|
40
|
+
|
|
41
|
+
`~/Library/Application Support/Claude/claude_desktop_config.json`:
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"mcpServers": {
|
|
46
|
+
"chamade": {
|
|
47
|
+
"command": "npx",
|
|
48
|
+
"args": ["-y", "@chamade/mcp-server"],
|
|
49
|
+
"env": {
|
|
50
|
+
"CHAMADE_API_KEY": "chmd_..."
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Cursor / Windsurf
|
|
58
|
+
|
|
59
|
+
`.cursor/mcp.json` or `.windsurf/mcp.json` — same format as Claude Desktop.
|
|
60
|
+
|
|
61
|
+
## Tools
|
|
62
|
+
|
|
63
|
+
| Tool | Description |
|
|
64
|
+
|------|-------------|
|
|
65
|
+
| `chamade_join` | Join a voice meeting (platform + meeting_url). |
|
|
66
|
+
| `chamade_say` | Speak text aloud via TTS in the meeting. |
|
|
67
|
+
| `chamade_chat` | Send a text chat message in the meeting. |
|
|
68
|
+
| `chamade_status` | Get call state and new transcript lines (delta pattern). |
|
|
69
|
+
| `chamade_answer` | Answer a ringing inbound call (SIP, etc.). |
|
|
70
|
+
| `chamade_typing` | Send a typing indicator in chat or DM. |
|
|
71
|
+
| `chamade_leave` | Hang up and leave the meeting. |
|
|
72
|
+
| `chamade_list_calls` | List all active calls. |
|
|
73
|
+
| `chamade_inbox` | Check DM conversations (Discord, Telegram, Teams, etc.). |
|
|
74
|
+
| `chamade_send` | Send a DM message to a conversation. |
|
|
75
|
+
| `chamade_account` | Check account status, plan, credit/quota, and platform readiness. |
|
|
76
|
+
|
|
77
|
+
## Resources
|
|
78
|
+
|
|
79
|
+
| URI template | Description |
|
|
80
|
+
|--------------|-------------|
|
|
81
|
+
| `chamade://calls/{call_id}/transcript` | Live transcript. Subscribe for `notifications/resources/updated` push on each new line. Each read returns only new lines since last read. |
|
|
82
|
+
|
|
83
|
+
## Environment variables
|
|
84
|
+
|
|
85
|
+
| Variable | Required | Default | Description |
|
|
86
|
+
|----------|----------|---------|-------------|
|
|
87
|
+
| `CHAMADE_API_KEY` | Yes | — | API key (`chmd_*`) from chamade.io/dashboard |
|
|
88
|
+
| `CHAMADE_URL` | No | `https://chamade.io` | Chamade API base URL |
|
|
89
|
+
|
|
90
|
+
## Supported platforms
|
|
91
|
+
|
|
92
|
+
- **Discord** — voice channels (no OAuth needed, pass channel link)
|
|
93
|
+
- **Microsoft Teams** — requires OAuth connection on dashboard
|
|
94
|
+
- **Google Meet** — requires OAuth connection on dashboard
|
|
95
|
+
- **Telegram** — voice chats (pass invite link)
|
|
96
|
+
- **Zoom** — pass meeting URL
|
|
97
|
+
- **SIP / Phone** — pass phone number or SIP URI
|
|
98
|
+
- **Nextcloud Talk** — pass meeting URL
|
|
99
|
+
- **WhatsApp** — text DM only (pass phone number)
|
|
100
|
+
|
|
101
|
+
## How it works
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
Agent (OpenClaw, Claude, ...)
|
|
105
|
+
│ MCP (stdio JSON-RPC)
|
|
106
|
+
▼
|
|
107
|
+
@chamade/mcp-server (runs locally)
|
|
108
|
+
│ HTTPS + WSS
|
|
109
|
+
▼
|
|
110
|
+
Chamade API (chamade.io)
|
|
111
|
+
│ HTTP + WS
|
|
112
|
+
▼
|
|
113
|
+
Maquisard bridge (handles audio, STT/TTS)
|
|
114
|
+
│
|
|
115
|
+
▼
|
|
116
|
+
Discord / Teams / Meet / ...
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The MCP server is a thin client. It calls the Chamade REST API to create/manage calls and poll transcripts. Transcripts are exposed as an MCP resource with a delta-read pattern (only new lines since last read).
|
|
120
|
+
|
|
121
|
+
## Development
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
npm install
|
|
125
|
+
npm run dev # runs with tsx (hot reload)
|
|
126
|
+
npm run build # compiles to dist/
|
|
127
|
+
```
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Chamade MCP Server
|
|
4
|
+
*
|
|
5
|
+
* Exposes Chamade voice gateway as MCP tools + resources.
|
|
6
|
+
* Agents can join meetings on Discord, Teams, Meet, Telegram, SIP
|
|
7
|
+
* and receive live transcripts via resource subscriptions.
|
|
8
|
+
*
|
|
9
|
+
* Two modes:
|
|
10
|
+
* - Standard MCP (default): agent polls chamade_status for transcripts
|
|
11
|
+
* - Channel mode (--channel): transcripts and events pushed via notifications
|
|
12
|
+
*/
|
|
13
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,826 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Chamade MCP Server
|
|
4
|
+
*
|
|
5
|
+
* Exposes Chamade voice gateway as MCP tools + resources.
|
|
6
|
+
* Agents can join meetings on Discord, Teams, Meet, Telegram, SIP
|
|
7
|
+
* and receive live transcripts via resource subscriptions.
|
|
8
|
+
*
|
|
9
|
+
* Two modes:
|
|
10
|
+
* - Standard MCP (default): agent polls chamade_status for transcripts
|
|
11
|
+
* - Channel mode (--channel): transcripts and events pushed via notifications
|
|
12
|
+
*/
|
|
13
|
+
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
14
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
import WebSocket from "ws";
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Config
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
const CHAMADE_URL = (process.env.CHAMADE_URL || "https://chamade.io").replace(/\/$/, "");
|
|
21
|
+
const API_KEY = process.env.CHAMADE_API_KEY || "";
|
|
22
|
+
if (!API_KEY) {
|
|
23
|
+
console.error("[chamade-mcp] CHAMADE_API_KEY is required. Get one at https://chamade.io/dashboard");
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
const headers = {
|
|
27
|
+
"X-API-Key": API_KEY,
|
|
28
|
+
"Content-Type": "application/json",
|
|
29
|
+
};
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Channel mode
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
const CHANNEL_MODE = process.env.CHAMADE_CHANNEL === "1" || process.argv.includes("--channel");
|
|
34
|
+
const CHAMADE_WS_URL = CHAMADE_URL.replace(/^http/, "ws");
|
|
35
|
+
const calls = new Map();
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Helpers
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Ensure path ends with / before query string to avoid Cloudflare 301 redirect
|
|
40
|
+
function _url(path) {
|
|
41
|
+
const qIdx = path.indexOf("?");
|
|
42
|
+
if (qIdx === -1) {
|
|
43
|
+
return `${CHAMADE_URL}${path.endsWith("/") ? path : path + "/"}`;
|
|
44
|
+
}
|
|
45
|
+
const base = path.slice(0, qIdx);
|
|
46
|
+
const query = path.slice(qIdx);
|
|
47
|
+
return `${CHAMADE_URL}${base.endsWith("/") ? base : base + "/"}${query}`;
|
|
48
|
+
}
|
|
49
|
+
async function chamadePost(path, body) {
|
|
50
|
+
const res = await fetch(_url(path), {
|
|
51
|
+
method: "POST",
|
|
52
|
+
headers,
|
|
53
|
+
body: JSON.stringify(body),
|
|
54
|
+
});
|
|
55
|
+
if (!res.ok) {
|
|
56
|
+
const text = await res.text();
|
|
57
|
+
throw new Error(`Chamade ${path} ${res.status}: ${text}`);
|
|
58
|
+
}
|
|
59
|
+
return res.json();
|
|
60
|
+
}
|
|
61
|
+
async function chamadeDelete(path) {
|
|
62
|
+
const res = await fetch(_url(path), {
|
|
63
|
+
method: "DELETE",
|
|
64
|
+
headers,
|
|
65
|
+
});
|
|
66
|
+
if (!res.ok && res.status !== 404) {
|
|
67
|
+
const text = await res.text();
|
|
68
|
+
throw new Error(`Chamade ${path} ${res.status}: ${text}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async function chamadeGet(path) {
|
|
72
|
+
const res = await fetch(_url(path), { headers });
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
const text = await res.text();
|
|
75
|
+
throw new Error(`Chamade ${path} ${res.status}: ${text}`);
|
|
76
|
+
}
|
|
77
|
+
return res.json();
|
|
78
|
+
}
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Instructions
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
const CHANNEL_PREAMBLE = [
|
|
83
|
+
"## Channel mode — events arrive automatically",
|
|
84
|
+
"",
|
|
85
|
+
"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.",
|
|
91
|
+
"",
|
|
92
|
+
].join("\n");
|
|
93
|
+
const BASE_INSTRUCTIONS = [
|
|
94
|
+
"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
|
+
"",
|
|
96
|
+
"## 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).",
|
|
100
|
+
"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.",
|
|
102
|
+
"",
|
|
103
|
+
"## SIP phone calls",
|
|
104
|
+
"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.",
|
|
107
|
+
"- Inbound (BYOT): user connects their own SIP trunk + DIDs in the dashboard. Same inbound flow.",
|
|
108
|
+
"",
|
|
109
|
+
"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).",
|
|
113
|
+
"4. If auto_answer is enabled, calls go straight to 'active' — no need to answer.",
|
|
114
|
+
"",
|
|
115
|
+
"## Recovery from disconnection",
|
|
116
|
+
"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.",
|
|
119
|
+
"A new call_id is issued. Previous transcript is lost.",
|
|
120
|
+
"",
|
|
121
|
+
"## DM / Messaging workflow",
|
|
122
|
+
"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.",
|
|
126
|
+
"",
|
|
127
|
+
"## Capabilities",
|
|
128
|
+
"Each call and conversation returns a capabilities array. Meaning:",
|
|
129
|
+
"- listen: receive audio transcripts (STT)",
|
|
130
|
+
"- speak: send audio via TTS (chamade_say)",
|
|
131
|
+
"- read: receive text chat messages",
|
|
132
|
+
"- write: send text chat messages (chamade_chat)",
|
|
133
|
+
"- typing: typing indicator supported (chamade_typing)",
|
|
134
|
+
"- files: file sharing supported",
|
|
135
|
+
"",
|
|
136
|
+
"Per-platform defaults:",
|
|
137
|
+
"- 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.",
|
|
139
|
+
"- zoom: listen, speak, read, write, files",
|
|
140
|
+
"- telegram: read, write, typing, files (text DM only, no voice join)",
|
|
141
|
+
"- whatsapp: read, write, typing (text DM only, no voice join)",
|
|
142
|
+
"- sip: listen, speak (voice only, no chat)",
|
|
143
|
+
"- nctalk: listen, speak, read, write, typing",
|
|
144
|
+
"",
|
|
145
|
+
"If 'speak' is not in capabilities, use chamade_chat to send text instead of chamade_say.",
|
|
146
|
+
"",
|
|
147
|
+
"## Platform requirements",
|
|
148
|
+
"Each platform requires specific setup by the user in the Chamade dashboard (https://chamade.io/dashboard):",
|
|
149
|
+
"- teams: Microsoft OAuth connection (for OnlineMeetings access)",
|
|
150
|
+
"- meet: Google OAuth connection (for Calendar API access)",
|
|
151
|
+
"- discord: a Discord bot token registered in the dashboard",
|
|
152
|
+
"- telegram: a Telegram bot token registered in the dashboard",
|
|
153
|
+
"- sip outbound: a SIP trunk connected in the dashboard (Pro plan required)",
|
|
154
|
+
"- sip inbound: a phone number (DID) activated in the dashboard — either répondeur (pool) or BYOT (own trunk)",
|
|
155
|
+
"- zoom/nctalk/whatsapp: just a meeting_url, no setup needed",
|
|
156
|
+
"",
|
|
157
|
+
"## Account status",
|
|
158
|
+
"Use chamade_account to check platform readiness and remaining credit/quota before making calls.",
|
|
159
|
+
"Each platform shows: ok, not_configured (needs dashboard setup), or error (connection problem).",
|
|
160
|
+
"",
|
|
161
|
+
"## Common errors and what to tell the user",
|
|
162
|
+
"- \"No discord bot registered\" → user needs to add a Discord bot token in the dashboard",
|
|
163
|
+
"- \"No telegram bot registered\" → user needs to add a Telegram bot token in the dashboard",
|
|
164
|
+
"- \"meeting_url required, or connect your account\" → user needs to connect their Microsoft/Google account via OAuth in the dashboard, or provide a meeting URL",
|
|
165
|
+
"- \"Concurrent call limit reached\" → end an active call first (free plan: 1 concurrent call)",
|
|
166
|
+
"- \"Voice quota exceeded\" → monthly voice minutes used up (free plan: 10 min/month)",
|
|
167
|
+
"- \"SIP not available on free plan\" → user needs to upgrade to Pro for SIP calls",
|
|
168
|
+
"- \"No SIP trunk configured\" → user needs to connect a SIP trunk in the dashboard for outbound SIP",
|
|
169
|
+
"- \"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",
|
|
170
|
+
"",
|
|
171
|
+
"## Speech style",
|
|
172
|
+
"- Speak naturally and concisely — your text is converted to speech.",
|
|
173
|
+
"- Avoid markdown, code blocks, bullet lists, or long enumerations.",
|
|
174
|
+
"- Do NOT echo or repeat what people say — respond to the content.",
|
|
175
|
+
"- Use punctuation to shape delivery: '...' for hesitation, '!' for energy, '—' for a dramatic pause.",
|
|
176
|
+
"- Use CAPS sparingly for emphasis on key words (e.g. 'That is VERY important').",
|
|
177
|
+
"- Short sentences feel snappy and urgent. Longer, flowing sentences slow the pace.",
|
|
178
|
+
"- One call per Discord server (bot limitation).",
|
|
179
|
+
].join("\n");
|
|
180
|
+
const INSTRUCTIONS = CHANNEL_MODE
|
|
181
|
+
? CHANNEL_PREAMBLE + BASE_INSTRUCTIONS
|
|
182
|
+
: BASE_INSTRUCTIONS;
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// MCP Server
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
const server = new McpServer({
|
|
187
|
+
name: "chamade",
|
|
188
|
+
version: "1.1.0",
|
|
189
|
+
}, {
|
|
190
|
+
capabilities: CHANNEL_MODE
|
|
191
|
+
? { experimental: { "claude/channel": {} } }
|
|
192
|
+
: undefined,
|
|
193
|
+
instructions: INSTRUCTIONS,
|
|
194
|
+
});
|
|
195
|
+
// -- Tool: join -------------------------------------------------------------
|
|
196
|
+
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.", {
|
|
197
|
+
platform: z
|
|
198
|
+
.enum(["discord", "teams", "meet", "telegram", "sip", "nctalk", "zoom", "whatsapp"])
|
|
199
|
+
.describe("Meeting platform"),
|
|
200
|
+
meeting_url: z
|
|
201
|
+
.string()
|
|
202
|
+
.describe("Meeting URL (e.g. Discord channel link, Teams join link, phone number for SIP)"),
|
|
203
|
+
agent_name: z
|
|
204
|
+
.string()
|
|
205
|
+
.default("AI Agent")
|
|
206
|
+
.describe("Display name in the meeting"),
|
|
207
|
+
}, async ({ platform, meeting_url, agent_name }) => {
|
|
208
|
+
const data = (await chamadePost("/api/call", {
|
|
209
|
+
platform,
|
|
210
|
+
meeting_url,
|
|
211
|
+
agent_name,
|
|
212
|
+
}));
|
|
213
|
+
const callId = data.call_id;
|
|
214
|
+
const capabilities = data.capabilities ?? [];
|
|
215
|
+
const audio = data.audio;
|
|
216
|
+
calls.set(callId, {
|
|
217
|
+
callId,
|
|
218
|
+
platform,
|
|
219
|
+
meetingUrl: meeting_url,
|
|
220
|
+
capabilities,
|
|
221
|
+
lastTranscriptLength: 0,
|
|
222
|
+
});
|
|
223
|
+
// Channel mode: auto-watch for transcript push
|
|
224
|
+
if (CHANNEL_MODE) {
|
|
225
|
+
channelWatchCall(callId);
|
|
226
|
+
}
|
|
227
|
+
const lines = [
|
|
228
|
+
`Joined ${platform} meeting.`,
|
|
229
|
+
`Call ID: ${callId}`,
|
|
230
|
+
`State: ${data.state}`,
|
|
231
|
+
`Capabilities: ${capabilities.join(", ") || "unknown"}`,
|
|
232
|
+
];
|
|
233
|
+
if (audio) {
|
|
234
|
+
lines.push(`Audio: ${audio.sample_rate}Hz ${audio.format} (${audio.frame_duration_ms}ms frames, ${audio.frame_bytes} bytes/frame)`);
|
|
235
|
+
}
|
|
236
|
+
if (!capabilities.includes("speak")) {
|
|
237
|
+
lines.push(`Note: TTS (speak) is not supported on ${platform}. Use chamade_chat to send text instead.`);
|
|
238
|
+
}
|
|
239
|
+
if (CHANNEL_MODE) {
|
|
240
|
+
lines.push(``, `Transcript will be pushed automatically. Use chamade_say to speak, chamade_chat to send text, chamade_leave to hang up.`);
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
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.`);
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
content: [
|
|
247
|
+
{
|
|
248
|
+
type: "text",
|
|
249
|
+
text: lines.join("\n"),
|
|
250
|
+
},
|
|
251
|
+
],
|
|
252
|
+
};
|
|
253
|
+
});
|
|
254
|
+
// -- Tool: say --------------------------------------------------------------
|
|
255
|
+
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.", {
|
|
256
|
+
call_id: z.string().describe("Call ID from chamade_join"),
|
|
257
|
+
text: z.string().describe("Text to speak aloud"),
|
|
258
|
+
}, async ({ call_id, text }) => {
|
|
259
|
+
await chamadePost(`/api/call/${call_id}/say`, { text });
|
|
260
|
+
return {
|
|
261
|
+
content: [{ type: "text", text: `Speaking: "${text}"` }],
|
|
262
|
+
};
|
|
263
|
+
});
|
|
264
|
+
// -- Tool: chat -------------------------------------------------------------
|
|
265
|
+
server.tool("chamade_chat", "Send a text chat message in the meeting.", {
|
|
266
|
+
call_id: z.string().describe("Call ID from chamade_join"),
|
|
267
|
+
text: z.string().describe("Chat message to send"),
|
|
268
|
+
}, async ({ call_id, text }) => {
|
|
269
|
+
await chamadePost(`/api/call/${call_id}/chat`, { text });
|
|
270
|
+
return {
|
|
271
|
+
content: [{ type: "text", text: `Chat sent: "${text}"` }],
|
|
272
|
+
};
|
|
273
|
+
});
|
|
274
|
+
// -- Tool: status -----------------------------------------------------------
|
|
275
|
+
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).", {
|
|
276
|
+
call_id: z.string().describe("Call ID from chamade_join"),
|
|
277
|
+
}, async ({ call_id }) => {
|
|
278
|
+
const state = calls.get(call_id);
|
|
279
|
+
const since = state?.lastTranscriptLength ?? 0;
|
|
280
|
+
const data = (await chamadeGet(`/api/call/${call_id}?since=${since}`));
|
|
281
|
+
const transcriptLength = data.transcript_length ?? 0;
|
|
282
|
+
if (state) {
|
|
283
|
+
state.lastTranscriptLength = transcriptLength;
|
|
284
|
+
}
|
|
285
|
+
const lines = data.transcript ?? [];
|
|
286
|
+
let transcript = "";
|
|
287
|
+
if (lines.length > 0) {
|
|
288
|
+
transcript = "\n\nNew transcript:\n" + lines.join("\n");
|
|
289
|
+
}
|
|
290
|
+
const capabilities = data.capabilities ?? [];
|
|
291
|
+
// Update local state with capabilities from server
|
|
292
|
+
if (state && capabilities.length > 0) {
|
|
293
|
+
state.capabilities = capabilities;
|
|
294
|
+
}
|
|
295
|
+
const direction = data.direction || "outbound";
|
|
296
|
+
const caller = data.caller || "";
|
|
297
|
+
const statusLines = [
|
|
298
|
+
`Call: ${data.call_id}`,
|
|
299
|
+
`Platform: ${data.platform}`,
|
|
300
|
+
`State: ${data.state}`,
|
|
301
|
+
direction === "inbound" ? `Direction: inbound` : "",
|
|
302
|
+
caller ? `Caller: ${caller}` : "",
|
|
303
|
+
`Capabilities: ${capabilities.join(", ") || "unknown"}`,
|
|
304
|
+
`Agent: ${data.agent_name}`,
|
|
305
|
+
`Created: ${data.created_at}`,
|
|
306
|
+
data.ended_at ? `Ended: ${data.ended_at}` : "",
|
|
307
|
+
];
|
|
308
|
+
if (data.state === "ringing") {
|
|
309
|
+
statusLines.push(``, `Incoming call! Use chamade_answer to pick up, or chamade_leave to reject.`);
|
|
310
|
+
}
|
|
311
|
+
if (data.state === "disconnected") {
|
|
312
|
+
const meetingUrl = state?.meetingUrl || data.meeting_url || "";
|
|
313
|
+
const platform = state?.platform || data.platform || "";
|
|
314
|
+
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.`);
|
|
315
|
+
}
|
|
316
|
+
if (!capabilities.includes("speak")) {
|
|
317
|
+
statusLines.push(`Note: TTS (speak) is not supported on ${data.platform}. Use chamade_chat to send text instead.`);
|
|
318
|
+
}
|
|
319
|
+
return {
|
|
320
|
+
content: [
|
|
321
|
+
{
|
|
322
|
+
type: "text",
|
|
323
|
+
text: statusLines
|
|
324
|
+
.filter(Boolean)
|
|
325
|
+
.join("\n") + transcript,
|
|
326
|
+
},
|
|
327
|
+
],
|
|
328
|
+
};
|
|
329
|
+
});
|
|
330
|
+
// -- Tool: answer -----------------------------------------------------------
|
|
331
|
+
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.", {
|
|
332
|
+
call_id: z.string().describe("Call ID of the ringing call"),
|
|
333
|
+
}, async ({ call_id }) => {
|
|
334
|
+
await chamadePost(`/api/call/${call_id}/answer`, {});
|
|
335
|
+
// Fetch call details to get real capabilities and platform
|
|
336
|
+
const data = (await chamadeGet(`/api/call/${call_id}`));
|
|
337
|
+
const platform = data.platform || "sip";
|
|
338
|
+
const capabilities = data.capabilities ?? ["listen", "speak"];
|
|
339
|
+
// Track the call locally
|
|
340
|
+
calls.set(call_id, {
|
|
341
|
+
callId: call_id,
|
|
342
|
+
platform,
|
|
343
|
+
meetingUrl: data.meeting_url || "",
|
|
344
|
+
capabilities,
|
|
345
|
+
lastTranscriptLength: 0,
|
|
346
|
+
});
|
|
347
|
+
// Channel mode: auto-watch for transcript push
|
|
348
|
+
if (CHANNEL_MODE) {
|
|
349
|
+
channelWatchCall(call_id);
|
|
350
|
+
}
|
|
351
|
+
const lines = [
|
|
352
|
+
`Answered call ${call_id}.`,
|
|
353
|
+
`State: active`,
|
|
354
|
+
`Capabilities: ${capabilities.join(", ")}`,
|
|
355
|
+
``,
|
|
356
|
+
`Use chamade_say to speak, chamade_status to check transcript, chamade_leave to hang up.`,
|
|
357
|
+
];
|
|
358
|
+
if (!capabilities.includes("speak")) {
|
|
359
|
+
lines.push(`Note: TTS (speak) is not supported on ${platform}. Use chamade_chat to send text instead.`);
|
|
360
|
+
}
|
|
361
|
+
return {
|
|
362
|
+
content: [
|
|
363
|
+
{
|
|
364
|
+
type: "text",
|
|
365
|
+
text: lines.join("\n"),
|
|
366
|
+
},
|
|
367
|
+
],
|
|
368
|
+
};
|
|
369
|
+
});
|
|
370
|
+
// -- Tool: typing -----------------------------------------------------------
|
|
371
|
+
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).", {
|
|
372
|
+
call_id: z
|
|
373
|
+
.string()
|
|
374
|
+
.optional()
|
|
375
|
+
.describe("Call ID (for typing in a call's chat)"),
|
|
376
|
+
conversation_id: z
|
|
377
|
+
.string()
|
|
378
|
+
.optional()
|
|
379
|
+
.describe("Conversation ID (for typing in a DM)"),
|
|
380
|
+
}, async ({ call_id, conversation_id }) => {
|
|
381
|
+
if (call_id) {
|
|
382
|
+
await chamadePost(`/api/call/${call_id}/typing`, {});
|
|
383
|
+
}
|
|
384
|
+
else if (conversation_id) {
|
|
385
|
+
await chamadePost("/api/typing", { conversation_id });
|
|
386
|
+
}
|
|
387
|
+
else {
|
|
388
|
+
throw new Error("Either call_id or conversation_id is required");
|
|
389
|
+
}
|
|
390
|
+
return {
|
|
391
|
+
content: [{ type: "text", text: "Typing indicator sent." }],
|
|
392
|
+
};
|
|
393
|
+
});
|
|
394
|
+
// -- Tool: leave ------------------------------------------------------------
|
|
395
|
+
server.tool("chamade_leave", "Leave the meeting and hang up. Only use when explicitly asked to leave or when the meeting is over.", {
|
|
396
|
+
call_id: z.string().describe("Call ID from chamade_join"),
|
|
397
|
+
}, async ({ call_id }) => {
|
|
398
|
+
calls.delete(call_id);
|
|
399
|
+
// Channel mode: stop watching
|
|
400
|
+
if (CHANNEL_MODE) {
|
|
401
|
+
channelUnwatchCall(call_id);
|
|
402
|
+
}
|
|
403
|
+
await chamadeDelete(`/api/call/${call_id}`);
|
|
404
|
+
return {
|
|
405
|
+
content: [{ type: "text", text: `Left call ${call_id}.` }],
|
|
406
|
+
};
|
|
407
|
+
});
|
|
408
|
+
// -- Tool: list_calls -------------------------------------------------------
|
|
409
|
+
server.tool("chamade_list_calls", "List all active calls.", {}, async () => {
|
|
410
|
+
const data = (await chamadeGet("/api/calls"));
|
|
411
|
+
if (data.calls.length === 0) {
|
|
412
|
+
return {
|
|
413
|
+
content: [{ type: "text", text: "No active calls." }],
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
const lines = data.calls.map((c) => {
|
|
417
|
+
const direction = c.direction === "inbound" ? " [inbound]" : "";
|
|
418
|
+
const caller = c.caller ? ` from ${c.caller}` : "";
|
|
419
|
+
const url = c.meeting_url ? ` — ${c.meeting_url}` : "";
|
|
420
|
+
return `- ${c.id} (${c.platform}) — ${c.state}${direction}${caller}${url}`;
|
|
421
|
+
});
|
|
422
|
+
return {
|
|
423
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
424
|
+
};
|
|
425
|
+
});
|
|
426
|
+
// -- Tool: account ----------------------------------------------------------
|
|
427
|
+
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 () => {
|
|
428
|
+
const data = (await chamadeGet("/api/account"));
|
|
429
|
+
const lines = [`Plan: ${data.plan}`];
|
|
430
|
+
// Credit or voice quota
|
|
431
|
+
const credit = data.credit;
|
|
432
|
+
const voice = data.voice_quota;
|
|
433
|
+
if (credit) {
|
|
434
|
+
lines.push(`Credit: ${credit.remaining_eur.toFixed(2)} EUR remaining (${credit.used_eur.toFixed(2)} / ${credit.total_eur.toFixed(2)} EUR used)`);
|
|
435
|
+
}
|
|
436
|
+
else if (voice) {
|
|
437
|
+
lines.push(`Voice quota: ${voice.used_minutes.toFixed(1)} / ${voice.limit_minutes} min used`);
|
|
438
|
+
}
|
|
439
|
+
const concurrent = data.concurrent_calls;
|
|
440
|
+
if (concurrent) {
|
|
441
|
+
const limitStr = concurrent.limit === 0 ? "unlimited" : String(concurrent.limit);
|
|
442
|
+
lines.push(`Active calls: ${concurrent.active} / ${limitStr}`);
|
|
443
|
+
}
|
|
444
|
+
// Platform statuses
|
|
445
|
+
const platforms = data.platforms;
|
|
446
|
+
if (platforms) {
|
|
447
|
+
lines.push("", "Platforms:");
|
|
448
|
+
for (const [name, status] of Object.entries(platforms)) {
|
|
449
|
+
const icon = status.startsWith("ok") ? "+" : status.startsWith("error") ? "!" : "-";
|
|
450
|
+
lines.push(` ${icon} ${name}: ${status}`);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return {
|
|
454
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
455
|
+
};
|
|
456
|
+
});
|
|
457
|
+
// -- Tool: inbox ------------------------------------------------------------
|
|
458
|
+
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.", {
|
|
459
|
+
conversation_id: z
|
|
460
|
+
.string()
|
|
461
|
+
.optional()
|
|
462
|
+
.describe("Optional conversation ID to get details and messages"),
|
|
463
|
+
limit: z
|
|
464
|
+
.number()
|
|
465
|
+
.default(20)
|
|
466
|
+
.describe("Max messages to return (when viewing a conversation)"),
|
|
467
|
+
}, async ({ conversation_id, limit }) => {
|
|
468
|
+
if (conversation_id) {
|
|
469
|
+
const data = (await chamadeGet(`/api/inbox/${conversation_id}?limit=${limit}`));
|
|
470
|
+
const conv = data.conversation;
|
|
471
|
+
const messages = data.messages;
|
|
472
|
+
const capabilities = conv.capabilities ?? [];
|
|
473
|
+
const lines = [
|
|
474
|
+
`Conversation: ${conv.id}`,
|
|
475
|
+
`Platform: ${conv.platform}`,
|
|
476
|
+
`Contact: ${conv.remote_name || conv.remote_id}`,
|
|
477
|
+
`Type: ${conv.channel_type}`,
|
|
478
|
+
`Capabilities: ${capabilities.join(", ") || "text"}`,
|
|
479
|
+
`Messages: ${conv.message_count}`,
|
|
480
|
+
conv.call_id ? `Active call: ${conv.call_id}` : "",
|
|
481
|
+
"",
|
|
482
|
+
];
|
|
483
|
+
if (messages.length > 0) {
|
|
484
|
+
lines.push("Messages:");
|
|
485
|
+
for (const m of messages) {
|
|
486
|
+
const dir = m.direction === "inbound" ? "←" : "→";
|
|
487
|
+
const name = m.sender_name || (m.direction === "inbound" ? "them" : "you");
|
|
488
|
+
lines.push(` ${dir} [${name}] ${m.content}`);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
else {
|
|
492
|
+
lines.push("(no messages yet)");
|
|
493
|
+
}
|
|
494
|
+
return {
|
|
495
|
+
content: [{ type: "text", text: lines.filter(Boolean).join("\n") }],
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
// List conversations
|
|
499
|
+
const data = (await chamadeGet("/api/inbox"));
|
|
500
|
+
if (data.conversations.length === 0) {
|
|
501
|
+
return {
|
|
502
|
+
content: [{ type: "text", text: "No active conversations." }],
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
const lines = data.conversations.map((c) => {
|
|
506
|
+
const capabilities = c.capabilities ?? [];
|
|
507
|
+
const cap = capabilities.length > 0 ? ` [${capabilities.join(",")}]` : "";
|
|
508
|
+
const callInfo = c.call_id ? ` (voice: ${c.call_id})` : "";
|
|
509
|
+
return `- ${c.id} (${c.platform}) — ${c.remote_name || c.remote_id} — ${c.message_count} msgs${cap}${callInfo}`;
|
|
510
|
+
});
|
|
511
|
+
return {
|
|
512
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
513
|
+
};
|
|
514
|
+
});
|
|
515
|
+
// -- Tool: send -------------------------------------------------------------
|
|
516
|
+
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.).", {
|
|
517
|
+
conversation_id: z.string().describe("Conversation ID from chamade_inbox"),
|
|
518
|
+
text: z.string().describe("Message text to send"),
|
|
519
|
+
}, async ({ conversation_id, text }) => {
|
|
520
|
+
channelStopTyping(conversation_id);
|
|
521
|
+
const data = (await chamadePost("/api/send", {
|
|
522
|
+
conversation_id,
|
|
523
|
+
text,
|
|
524
|
+
}));
|
|
525
|
+
return {
|
|
526
|
+
content: [
|
|
527
|
+
{
|
|
528
|
+
type: "text",
|
|
529
|
+
text: `Message sent (${data.delivery || "async"}): "${text}"`,
|
|
530
|
+
},
|
|
531
|
+
],
|
|
532
|
+
};
|
|
533
|
+
});
|
|
534
|
+
// -- Resource: transcript ---------------------------------------------------
|
|
535
|
+
const transcriptTemplate = new ResourceTemplate("chamade://calls/{call_id}/transcript", {
|
|
536
|
+
list: async () => {
|
|
537
|
+
return {
|
|
538
|
+
resources: Array.from(calls.entries()).map(([id, state]) => ({
|
|
539
|
+
uri: `chamade://calls/${id}/transcript`,
|
|
540
|
+
name: `Transcript — ${state.platform} call ${id.slice(0, 8)}`,
|
|
541
|
+
mimeType: "text/plain",
|
|
542
|
+
})),
|
|
543
|
+
};
|
|
544
|
+
},
|
|
545
|
+
});
|
|
546
|
+
server.resource("call-transcript", transcriptTemplate, {
|
|
547
|
+
description: "Live transcript of an active call. Subscribe for real-time updates. Each read returns only new lines since the last read.",
|
|
548
|
+
mimeType: "text/plain",
|
|
549
|
+
}, async (uri, params) => {
|
|
550
|
+
const callId = params.call_id;
|
|
551
|
+
const state = calls.get(callId);
|
|
552
|
+
const since = state?.lastTranscriptLength ?? 0;
|
|
553
|
+
try {
|
|
554
|
+
const data = (await chamadeGet(`/api/call/${callId}?since=${since}`));
|
|
555
|
+
const transcriptLength = data.transcript_length ?? 0;
|
|
556
|
+
const lines = data.transcript ?? [];
|
|
557
|
+
if (state) {
|
|
558
|
+
state.lastTranscriptLength = transcriptLength;
|
|
559
|
+
}
|
|
560
|
+
return {
|
|
561
|
+
contents: [
|
|
562
|
+
{
|
|
563
|
+
uri: uri.href,
|
|
564
|
+
mimeType: "text/plain",
|
|
565
|
+
text: lines.length
|
|
566
|
+
? lines.join("\n")
|
|
567
|
+
: "(no new transcript since last read)",
|
|
568
|
+
},
|
|
569
|
+
],
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
catch {
|
|
573
|
+
return {
|
|
574
|
+
contents: [
|
|
575
|
+
{
|
|
576
|
+
uri: uri.href,
|
|
577
|
+
mimeType: "text/plain",
|
|
578
|
+
text: `No active call with ID: ${callId}`,
|
|
579
|
+
},
|
|
580
|
+
],
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
// ---------------------------------------------------------------------------
|
|
585
|
+
// Channel mode — push events via notifications/claude/channel
|
|
586
|
+
// ---------------------------------------------------------------------------
|
|
587
|
+
const _callWatchers = new Map();
|
|
588
|
+
const _closedWatchers = new Set();
|
|
589
|
+
const _typingIntervals = new Map();
|
|
590
|
+
/**
|
|
591
|
+
* Start repeating typing indicator for a conversation.
|
|
592
|
+
* Sends immediately then every 2s. Stops after 30s max.
|
|
593
|
+
*/
|
|
594
|
+
function channelStartTyping(conversationId) {
|
|
595
|
+
channelStopTyping(conversationId);
|
|
596
|
+
const send = () => chamadePost("/api/typing", { conversation_id: conversationId }).catch(() => { });
|
|
597
|
+
send();
|
|
598
|
+
const interval = setInterval(send, 2000);
|
|
599
|
+
_typingIntervals.set(conversationId, interval);
|
|
600
|
+
// Safety: stop after 30s even if chamade_send is never called
|
|
601
|
+
setTimeout(() => channelStopTyping(conversationId), 30_000);
|
|
602
|
+
}
|
|
603
|
+
function channelStopTyping(conversationId) {
|
|
604
|
+
const interval = _typingIntervals.get(conversationId);
|
|
605
|
+
if (interval) {
|
|
606
|
+
clearInterval(interval);
|
|
607
|
+
_typingIntervals.delete(conversationId);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Push a channel notification to Claude Code.
|
|
612
|
+
* Only active in channel mode — noop otherwise.
|
|
613
|
+
*/
|
|
614
|
+
async function channelPush(content, meta = {}) {
|
|
615
|
+
if (!CHANNEL_MODE)
|
|
616
|
+
return;
|
|
617
|
+
try {
|
|
618
|
+
await server.server.notification({
|
|
619
|
+
method: "notifications/claude/channel",
|
|
620
|
+
params: {
|
|
621
|
+
content,
|
|
622
|
+
meta: { source: "chamade", ...meta },
|
|
623
|
+
},
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
catch (e) {
|
|
627
|
+
console.error("[chamade-channel] Push failed:", e);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Watch a call's transcript via WebSocket push.
|
|
632
|
+
* Opens a WS to /api/call/{id}/stream and pushes each event.
|
|
633
|
+
*/
|
|
634
|
+
function channelWatchCall(callId) {
|
|
635
|
+
if (_callWatchers.has(callId) || _closedWatchers.has(callId))
|
|
636
|
+
return;
|
|
637
|
+
let retryDelay = 1000;
|
|
638
|
+
let retryCount = 0;
|
|
639
|
+
const MAX_RETRIES = 5;
|
|
640
|
+
function connect() {
|
|
641
|
+
if (_closedWatchers.has(callId))
|
|
642
|
+
return;
|
|
643
|
+
const url = `${CHAMADE_WS_URL}/api/call/${callId}/stream?api_key=${API_KEY}`;
|
|
644
|
+
const ws = new WebSocket(url);
|
|
645
|
+
ws.on("open", () => {
|
|
646
|
+
retryDelay = 1000;
|
|
647
|
+
_callWatchers.set(callId, ws);
|
|
648
|
+
console.error(`[chamade-channel] Watching call ${callId}`);
|
|
649
|
+
});
|
|
650
|
+
ws.on("message", async (raw) => {
|
|
651
|
+
try {
|
|
652
|
+
const data = JSON.parse(raw.toString());
|
|
653
|
+
switch (data.type) {
|
|
654
|
+
case "transcript":
|
|
655
|
+
retryCount = 0; // Real data — reset retry counter
|
|
656
|
+
if (data.is_final !== false) {
|
|
657
|
+
await channelPush(`[${data.speaker || "?"}] ${data.text || ""}`, { type: "transcript", call_id: callId });
|
|
658
|
+
}
|
|
659
|
+
break;
|
|
660
|
+
case "chat":
|
|
661
|
+
case "chat_received":
|
|
662
|
+
await channelPush(`[chat:${data.sender || "?"}] ${data.text || ""}`, { type: "chat", call_id: callId });
|
|
663
|
+
break;
|
|
664
|
+
case "state":
|
|
665
|
+
await channelPush(`Call state: ${data.state}`, { type: "state", call_id: callId });
|
|
666
|
+
if (data.state === "ended" || data.state === "error") {
|
|
667
|
+
channelUnwatchCall(callId);
|
|
668
|
+
}
|
|
669
|
+
break;
|
|
670
|
+
case "error":
|
|
671
|
+
await channelPush(`Call error: ${data.message || data.code || "unknown"}`, { type: "error", call_id: callId });
|
|
672
|
+
// Stop watching on any terminal error
|
|
673
|
+
channelUnwatchCall(callId);
|
|
674
|
+
if (data.code === "bridge_disconnected") {
|
|
675
|
+
await channelPush(`Bridge connection lost for call ${callId}. Use chamade_join with the same meeting_url to reconnect.`, { type: "disconnected", call_id: callId });
|
|
676
|
+
}
|
|
677
|
+
break;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
catch {
|
|
681
|
+
// Ignore parse errors
|
|
682
|
+
}
|
|
683
|
+
});
|
|
684
|
+
ws.on("close", () => {
|
|
685
|
+
_callWatchers.delete(callId);
|
|
686
|
+
retryCount++;
|
|
687
|
+
if (!_closedWatchers.has(callId) && retryCount <= MAX_RETRIES) {
|
|
688
|
+
setTimeout(connect, retryDelay);
|
|
689
|
+
retryDelay = Math.min(retryDelay * 2, 30_000);
|
|
690
|
+
}
|
|
691
|
+
else if (!_closedWatchers.has(callId)) {
|
|
692
|
+
console.error(`[chamade-channel] Call ${callId}: max retries (${MAX_RETRIES}) reached, giving up`);
|
|
693
|
+
channelUnwatchCall(callId);
|
|
694
|
+
}
|
|
695
|
+
});
|
|
696
|
+
ws.on("error", () => {
|
|
697
|
+
// Close handler will fire next and handle reconnection
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
connect();
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* Stop watching a call (on leave or call end).
|
|
704
|
+
*/
|
|
705
|
+
function channelUnwatchCall(callId) {
|
|
706
|
+
_closedWatchers.add(callId);
|
|
707
|
+
const ws = _callWatchers.get(callId);
|
|
708
|
+
if (ws) {
|
|
709
|
+
try {
|
|
710
|
+
ws.close();
|
|
711
|
+
}
|
|
712
|
+
catch { /* ignore */ }
|
|
713
|
+
_callWatchers.delete(callId);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* Watch inbox for DMs, incoming calls, and conversation events.
|
|
718
|
+
* Reconnects automatically on disconnect.
|
|
719
|
+
*/
|
|
720
|
+
function channelWatchInbox() {
|
|
721
|
+
let retryDelay = 1000;
|
|
722
|
+
function connect() {
|
|
723
|
+
const url = `${CHAMADE_WS_URL}/api/inbox/stream?api_key=${API_KEY}`;
|
|
724
|
+
const ws = new WebSocket(url);
|
|
725
|
+
ws.on("open", () => {
|
|
726
|
+
retryDelay = 1000;
|
|
727
|
+
console.error("[chamade-channel] Inbox WS connected");
|
|
728
|
+
});
|
|
729
|
+
ws.on("message", async (raw) => {
|
|
730
|
+
try {
|
|
731
|
+
const data = JSON.parse(raw.toString());
|
|
732
|
+
switch (data.type) {
|
|
733
|
+
case "message":
|
|
734
|
+
// Auto-send repeating typing indicator until chamade_send replies
|
|
735
|
+
if (data.conversation_id) {
|
|
736
|
+
channelStartTyping(data.conversation_id);
|
|
737
|
+
}
|
|
738
|
+
await channelPush(`New message from ${data.sender_name || "unknown"} on ${data.platform}: "${data.text}"`, {
|
|
739
|
+
type: "message",
|
|
740
|
+
conversation_id: data.conversation_id || "",
|
|
741
|
+
platform: data.platform || "",
|
|
742
|
+
});
|
|
743
|
+
break;
|
|
744
|
+
case "incoming_call":
|
|
745
|
+
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 || "" });
|
|
746
|
+
// Auto-watch if already active (auto_answer)
|
|
747
|
+
if (data.state === "active" && data.call_id) {
|
|
748
|
+
channelWatchCall(data.call_id);
|
|
749
|
+
}
|
|
750
|
+
break;
|
|
751
|
+
case "voice_started":
|
|
752
|
+
await channelPush(`Voice started. Call ID: ${data.call_id}. Platform: ${data.platform}.`, {
|
|
753
|
+
type: "voice_started",
|
|
754
|
+
call_id: data.call_id || "",
|
|
755
|
+
conversation_id: data.conversation_id || "",
|
|
756
|
+
});
|
|
757
|
+
if (data.call_id) {
|
|
758
|
+
channelWatchCall(data.call_id);
|
|
759
|
+
}
|
|
760
|
+
break;
|
|
761
|
+
case "conversation_started":
|
|
762
|
+
await channelPush(`New conversation from ${data.remote_name || "unknown"} on ${data.platform}.`, {
|
|
763
|
+
type: "conversation",
|
|
764
|
+
conversation_id: data.conversation_id || "",
|
|
765
|
+
platform: data.platform || "",
|
|
766
|
+
});
|
|
767
|
+
break;
|
|
768
|
+
case "conversation_ended":
|
|
769
|
+
await channelPush(`Conversation ended.`, { type: "conversation_ended", conversation_id: data.conversation_id || "" });
|
|
770
|
+
break;
|
|
771
|
+
case "call_disconnected":
|
|
772
|
+
await channelPush(`Call disconnected: ${data.message || "connection lost"}. Call ID: ${data.call_id}.`, { type: "call_disconnected", call_id: data.call_id || "" });
|
|
773
|
+
break;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
catch {
|
|
777
|
+
// Ignore parse errors
|
|
778
|
+
}
|
|
779
|
+
});
|
|
780
|
+
ws.on("close", () => {
|
|
781
|
+
setTimeout(connect, retryDelay);
|
|
782
|
+
retryDelay = Math.min(retryDelay * 2, 30_000);
|
|
783
|
+
});
|
|
784
|
+
ws.on("error", () => {
|
|
785
|
+
// Close handler will fire next and handle reconnection
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
connect();
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* Sync active calls — watch any calls that existed before channel started.
|
|
792
|
+
*/
|
|
793
|
+
async function channelSyncActiveCalls() {
|
|
794
|
+
try {
|
|
795
|
+
const data = (await chamadeGet("/api/calls"));
|
|
796
|
+
for (const call of data.calls) {
|
|
797
|
+
if (call.state !== "ended" &&
|
|
798
|
+
call.state !== "error" &&
|
|
799
|
+
!_callWatchers.has(call.id) &&
|
|
800
|
+
!_closedWatchers.has(call.id)) {
|
|
801
|
+
channelWatchCall(call.id);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
catch {
|
|
806
|
+
// Ignore — will retry on next interval
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
// ---------------------------------------------------------------------------
|
|
810
|
+
// Start
|
|
811
|
+
// ---------------------------------------------------------------------------
|
|
812
|
+
async function main() {
|
|
813
|
+
const transport = new StdioServerTransport();
|
|
814
|
+
await server.connect(transport);
|
|
815
|
+
if (CHANNEL_MODE) {
|
|
816
|
+
console.error("[chamade-channel] Channel mode active — push events enabled");
|
|
817
|
+
channelWatchInbox();
|
|
818
|
+
await channelSyncActiveCalls();
|
|
819
|
+
setInterval(channelSyncActiveCalls, 15_000);
|
|
820
|
+
}
|
|
821
|
+
console.error("[chamade-mcp] Server running on stdio");
|
|
822
|
+
}
|
|
823
|
+
main().catch((err) => {
|
|
824
|
+
console.error("[chamade-mcp] Fatal:", err);
|
|
825
|
+
process.exit(1);
|
|
826
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@chamade/mcp-server",
|
|
3
|
+
"version": "1.1.0",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"chamade-mcp": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "dist/index.js",
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc",
|
|
12
|
+
"start": "node dist/index.js",
|
|
13
|
+
"dev": "tsx src/index.ts",
|
|
14
|
+
"test": "tsx test/run.ts"
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "https://codeberg.org/skilpa/chamade.git",
|
|
19
|
+
"directory": "chamade-mcp"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://chamade.io",
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://codeberg.org/skilpa/chamade/issues"
|
|
24
|
+
},
|
|
25
|
+
"author": "Nafis <contact@nafis.io>",
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@modelcontextprotocol/sdk": "^1.25.0",
|
|
28
|
+
"ws": "^8.18.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/node": "^22.0.0",
|
|
32
|
+
"@types/ws": "^8.5.0",
|
|
33
|
+
"tsx": "^4.0.0",
|
|
34
|
+
"typescript": "^5.7.0"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"dist"
|
|
38
|
+
],
|
|
39
|
+
"keywords": [
|
|
40
|
+
"mcp",
|
|
41
|
+
"chamade",
|
|
42
|
+
"voice",
|
|
43
|
+
"ai-agent",
|
|
44
|
+
"meeting",
|
|
45
|
+
"discord",
|
|
46
|
+
"teams",
|
|
47
|
+
"meet",
|
|
48
|
+
"telegram",
|
|
49
|
+
"sip",
|
|
50
|
+
"zoom",
|
|
51
|
+
"whatsapp"
|
|
52
|
+
],
|
|
53
|
+
"license": "MIT"
|
|
54
|
+
}
|