@kodama-run/sdk 0.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.
@@ -0,0 +1,677 @@
1
+ #!/usr/bin/env bun
2
+ // ---------------------------------------------------------------------------
3
+ // Kodama MCP Server — reliability overhaul
4
+ //
5
+ // Fixes: parameter hallucination, blocking listen, lost turn events,
6
+ // loop termination, room completion, double-slash URL bug.
7
+ //
8
+ // Architecture: 5s non-blocking listen, turn queue, structured action
9
+ // responses, server-level workflow instructions.
10
+ // ---------------------------------------------------------------------------
11
+
12
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
13
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
14
+ import { z } from "zod";
15
+ import { Kodama } from "../room.ts";
16
+ import type { RoomMessage } from "@kodama-run/shared";
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // State
20
+ // ---------------------------------------------------------------------------
21
+
22
+ let kodama: Kodama | null = null;
23
+ let savedRoomCode: string | null = null;
24
+ let savedInviteToken: string | null = null;
25
+ let turnHandlerInstalled = false;
26
+ let roomCompleted = false;
27
+ let agentLeftFlag = false;
28
+ let roomDeletedFlag = false;
29
+ let savedOwnerToken: string | null = null;
30
+
31
+ /** Queue of turns waiting to be consumed by kodama_listen. */
32
+ const turnQueue: Array<{
33
+ messages: RoomMessage[];
34
+ round: number;
35
+ turn: number;
36
+ }> = [];
37
+
38
+ /** Resolve function for the current kodama_listen call waiting for a turn. */
39
+ let listenResolve: ((entry: { messages: RoomMessage[]; round: number; turn: number }) => void) | null = null;
40
+
41
+ /** Resolve function for kodama_say — the turn handler blocks on this. */
42
+ let sayResolve: ((response: { content: string; done?: boolean }) => void) | null = null;
43
+
44
+ /** Whisper guidance from the user. */
45
+ const whispers: string[] = [];
46
+
47
+ const DEFAULT_RELAY = process.env.KODAMA_RELAY ?? "http://localhost:8000";
48
+ const WEB_URL = process.env.KODAMA_WEB ?? "http://localhost:3000";
49
+ const LISTEN_TIMEOUT_MS = 5_000;
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Helpers
53
+ // ---------------------------------------------------------------------------
54
+
55
+ function toWsUrl(httpUrl: string): string {
56
+ return httpUrl
57
+ .replace(/^http:\/\//, "ws://")
58
+ .replace(/^https:\/\//, "wss://")
59
+ .replace(/\/+$/, "") + "/ws";
60
+ }
61
+
62
+ function installTurnHandler(room: Kodama): void {
63
+ if (turnHandlerInstalled) return;
64
+ turnHandlerInstalled = true;
65
+
66
+ room.on("turn", async (messages, ctx) => {
67
+ const entry = { messages, round: ctx.round, turn: ctx.turn };
68
+
69
+ if (listenResolve) {
70
+ // kodama_listen is already waiting — deliver immediately
71
+ listenResolve(entry);
72
+ listenResolve = null;
73
+ } else {
74
+ // kodama_listen hasn't been called yet — queue it
75
+ turnQueue.push(entry);
76
+ }
77
+
78
+ // Block until kodama_say provides the response content
79
+ const response = await new Promise<{ content: string; done?: boolean }>((resolve) => {
80
+ sayResolve = resolve;
81
+ });
82
+ return response;
83
+ });
84
+
85
+ room.on("error", (err) => {
86
+ process.stderr.write(`[kodama] error: ${err.message}\n`);
87
+ });
88
+
89
+ room.on("completed", () => {
90
+ roomCompleted = true;
91
+ if (sayResolve) {
92
+ sayResolve({ content: "" });
93
+ sayResolve = null;
94
+ }
95
+ if (listenResolve) {
96
+ listenResolve({ messages: [], round: 0, turn: 0 });
97
+ listenResolve = null;
98
+ }
99
+ });
100
+
101
+ room.on("agent_left", (agentId) => {
102
+ if (agentId === "room_deleted") {
103
+ roomDeletedFlag = true;
104
+ } else {
105
+ agentLeftFlag = true;
106
+ }
107
+ if (sayResolve) {
108
+ sayResolve({ content: "" });
109
+ sayResolve = null;
110
+ }
111
+ if (listenResolve) {
112
+ listenResolve({ messages: [], round: 0, turn: 0 });
113
+ listenResolve = null;
114
+ }
115
+ });
116
+ }
117
+
118
+ /** Wait for a turn entry: drain queue first, then wait up to LISTEN_TIMEOUT_MS. */
119
+ function listenWithTimeout(): Promise<{ messages: RoomMessage[]; round: number; turn: number } | null> {
120
+ // Drain queue first
121
+ if (turnQueue.length > 0) {
122
+ return Promise.resolve(turnQueue.shift()!);
123
+ }
124
+
125
+ // Wait with timeout
126
+ return new Promise((resolve) => {
127
+ const timer = setTimeout(() => {
128
+ listenResolve = null;
129
+ resolve(null);
130
+ }, LISTEN_TIMEOUT_MS);
131
+
132
+ listenResolve = (entry) => {
133
+ clearTimeout(timer);
134
+ resolve(entry);
135
+ };
136
+ });
137
+ }
138
+
139
+ async function fetchRoomState(): Promise<{ room_state: Record<string, unknown>; maxRounds: number }> {
140
+ if (!savedRoomCode) return { room_state: {}, maxRounds: 15 };
141
+ try {
142
+ const res = await fetch(`${DEFAULT_RELAY}/api/rooms/${savedRoomCode}`);
143
+ if (res.ok) {
144
+ const data = (await res.json()) as any;
145
+ return {
146
+ room_state: {
147
+ agents_connected: data.agents?.length ?? 0,
148
+ current_round: data.currentRound ?? 0,
149
+ status: data.status ?? "unknown",
150
+ },
151
+ maxRounds: data.config?.maxRounds ?? 15,
152
+ };
153
+ }
154
+ } catch {}
155
+ return { room_state: {}, maxRounds: 15 };
156
+ }
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // Server with workflow instructions
160
+ // ---------------------------------------------------------------------------
161
+
162
+ const server = new McpServer(
163
+ { name: "kodama", version: "1.0.0" },
164
+ {
165
+ capabilities: { tools: {} },
166
+ instructions: [
167
+ "Kodama is a chatroom where AI agents have conversations on behalf of their owners.",
168
+ "",
169
+ "WORKFLOW:",
170
+ "1. kodama_create_room — creates a room AND auto-joins. Display the COMPLETE output to the user verbatim — ALL links (invite, owner view, spectate) and room code. Do NOT summarize or rephrase. Then IMMEDIATELY call kodama_listen to start waiting for the other agent.",
171
+ "2. kodama_listen — wait for messages. If status is 'waiting', call kodama_listen again. If status is 'your_turn', respond with kodama_say.",
172
+ "3. After kodama_say: immediately call kodama_listen again.",
173
+ "4. REPEAT steps 2-3. NEVER stop this loop unless the response says action=CONVERSATION_OVER. When you get CONVERSATION_OVER, call kodama_leave to disconnect, then summarize the outcome for the user.",
174
+ "5. Only ask the user for input when you genuinely need a decision or preference you don't know.",
175
+ "6. When the user gives you guidance mid-conversation, incorporate it in your next kodama_say.",
176
+ "7. Be efficient. Reach conclusions. Don't pad rounds or repeat points already made. When you believe the conversation has reached its natural conclusion (agreements made, plans confirmed), set done=true on your kodama_say. The conversation ends automatically when both agents signal done.",
177
+ ].join("\n"),
178
+ },
179
+ );
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // kodama_create_room
183
+ // ---------------------------------------------------------------------------
184
+
185
+ server.tool(
186
+ "kodama_create_room",
187
+ "Create a Kodama chatroom and automatically join it. IMPORTANT: Display the returned output VERBATIM to the user — do not rephrase or summarize. Then immediately call kodama_listen to start waiting for the other agent. Do NOT wait for the user to tell you to proceed.",
188
+ {
189
+ topic: z.string().optional().describe("What this conversation is about, e.g. 'Dinner planning with Brandon'"),
190
+ agent_name: z.string().optional().describe("Display name for your agent. Use the user's name + the AI model name, e.g. 'Yining's Claude', 'Brandon's Codex'. Infer from context."),
191
+ max_rounds: z.number().optional().describe("Turn cap (1-100, default 15). Each agent speaking once = 1 round."),
192
+ },
193
+ async ({ topic, agent_name, max_rounds }) => {
194
+ if (kodama) {
195
+ return {
196
+ content: [{ type: "text" as const, text: "Already in a room. Call kodama_leave first if you want to create a new one." }],
197
+ isError: true,
198
+ };
199
+ }
200
+
201
+ const maxRounds = max_rounds ? Math.min(Math.max(1, max_rounds), 100) : undefined;
202
+
203
+ const body: Record<string, unknown> = {};
204
+ if (topic) body.topic = topic;
205
+ if (maxRounds) body.maxRounds = maxRounds;
206
+
207
+ const res = await fetch(`${DEFAULT_RELAY}/api/rooms`, {
208
+ method: "POST",
209
+ headers: { "Content-Type": "application/json" },
210
+ body: JSON.stringify(body),
211
+ });
212
+
213
+ if (!res.ok) {
214
+ const text = await res.text();
215
+ return {
216
+ content: [{ type: "text" as const, text: `Error creating room: ${text}. Is the relay running on port 8000?` }],
217
+ isError: true,
218
+ };
219
+ }
220
+
221
+ const data = (await res.json()) as { code: string; topic?: string; inviteTokens: string[] };
222
+ savedRoomCode = data.code;
223
+
224
+ const myToken = data.inviteTokens[0] ?? null;
225
+ const friendToken = data.inviteTokens[1] ?? data.inviteTokens[0] ?? "";
226
+
227
+ const room = new Kodama(data.code, {
228
+ relayUrl: toWsUrl(DEFAULT_RELAY),
229
+ name: agent_name ?? "Agent",
230
+ inviteToken: myToken ?? undefined,
231
+ });
232
+
233
+ installTurnHandler(room);
234
+ roomCompleted = false;
235
+ roomDeletedFlag = false;
236
+
237
+ try {
238
+ await room.join();
239
+ } catch (err) {
240
+ turnHandlerInstalled = false;
241
+
242
+ let newToken: string | null = null;
243
+ try {
244
+ const inviteRes = await fetch(`${DEFAULT_RELAY}/api/rooms/${data.code}/invite`, {
245
+ method: "POST",
246
+ headers: { "Content-Type": "application/json" },
247
+ body: JSON.stringify({}),
248
+ });
249
+ if (inviteRes.ok) {
250
+ const inviteData = (await inviteRes.json()) as { inviteToken: string };
251
+ newToken = inviteData.inviteToken;
252
+ }
253
+ } catch {}
254
+
255
+ savedInviteToken = newToken;
256
+
257
+ const msg = (err as Error).message;
258
+ return {
259
+ content: [{
260
+ type: "text" as const,
261
+ text: [
262
+ `Room created but failed to auto-join: ${msg}`,
263
+ ``,
264
+ `Room Code: ${data.code}`,
265
+ `Topic: ${data.topic ?? "(none)"}`,
266
+ ``,
267
+ `Share with your friend:`,
268
+ `${WEB_URL}/room/${data.code}#invite=${friendToken}`,
269
+ ``,
270
+ newToken
271
+ ? `Your invite token has been saved. Call kodama_join to retry.`
272
+ : `Could not regenerate invite token. Create a new room instead.`,
273
+ ].join("\n"),
274
+ }],
275
+ isError: true,
276
+ };
277
+ }
278
+
279
+ kodama = room;
280
+ savedOwnerToken = room.getOwnerToken();
281
+ savedInviteToken = null;
282
+
283
+ return {
284
+ content: [{
285
+ type: "text" as const,
286
+ text: [
287
+ `=== Room Created ===`,
288
+ ``,
289
+ `Room Code: ${data.code}`,
290
+ `Topic: ${data.topic ?? "(none)"}`,
291
+ ``,
292
+ `--- Share with your friend ---`,
293
+ `Invite Link: ${WEB_URL}/room/${data.code}#invite=${friendToken}`,
294
+ ``,
295
+ `--- Your Links ---`,
296
+ `Owner View: ${WEB_URL}/room/${data.code}#owner=${savedOwnerToken}`,
297
+ `Spectate: ${WEB_URL}/room/${data.code}`,
298
+ ``,
299
+ `Connected. Now call kodama_listen immediately.`,
300
+ ].join("\n"),
301
+ }],
302
+ };
303
+ },
304
+ );
305
+
306
+ // ---------------------------------------------------------------------------
307
+ // kodama_join
308
+ // ---------------------------------------------------------------------------
309
+
310
+ server.tool(
311
+ "kodama_join",
312
+ "Join a Kodama chatroom. If you just created the room, no parameters needed — your token is saved. If joining someone else's room, provide room_code and invite_token. IMPORTANT: Display the returned output VERBATIM — do not rephrase or summarize.",
313
+ {
314
+ room_code: z.string().optional().describe("Room code like KDM-7X3K. Optional if you just created one."),
315
+ invite_token: z.string().optional().describe("Invite token. Optional if you just created the room."),
316
+ agent_name: z.string().optional().describe("Display name for your agent. Use the user's name + 's Agent', e.g. 'Brandon's Agent'. Infer from context."),
317
+ },
318
+ async ({ room_code, invite_token, agent_name }) => {
319
+ if (kodama) {
320
+ return {
321
+ content: [{ type: "text" as const, text: "Already in a room. The conversation is active." }],
322
+ isError: true,
323
+ };
324
+ }
325
+
326
+ const code = room_code ?? savedRoomCode;
327
+ const token = invite_token ?? savedInviteToken;
328
+
329
+ if (!code || !token) {
330
+ return {
331
+ content: [{ type: "text" as const, text: "Missing room_code or invite_token. Create a room first with kodama_create_room, or provide both parameters." }],
332
+ isError: true,
333
+ };
334
+ }
335
+
336
+ const room = new Kodama(code, {
337
+ relayUrl: toWsUrl(DEFAULT_RELAY),
338
+ name: agent_name ?? "Agent",
339
+ inviteToken: token,
340
+ });
341
+
342
+ installTurnHandler(room);
343
+ roomCompleted = false;
344
+ roomDeletedFlag = false;
345
+
346
+ try {
347
+ await room.join();
348
+ } catch (err) {
349
+ turnHandlerInstalled = false;
350
+ const msg = (err as Error).message;
351
+ return {
352
+ content: [{ type: "text" as const, text: `Failed to join room ${code}: ${msg}. Check that the room exists and the invite token is valid.` }],
353
+ isError: true,
354
+ };
355
+ }
356
+
357
+ kodama = room;
358
+ savedRoomCode = code;
359
+ savedOwnerToken = room.getOwnerToken();
360
+
361
+ let topic: string | undefined;
362
+ try {
363
+ const roomRes = await fetch(`${DEFAULT_RELAY}/api/rooms/${code}`);
364
+ if (roomRes.ok) {
365
+ const roomData = (await roomRes.json()) as any;
366
+ topic = roomData.topic;
367
+ }
368
+ } catch {}
369
+
370
+ return {
371
+ content: [{
372
+ type: "text" as const,
373
+ text: [
374
+ `=== Joined Room ===`,
375
+ ``,
376
+ `Room Code: ${code}`,
377
+ `Topic: ${topic ?? "(none)"}`,
378
+ ``,
379
+ `--- Your Links ---`,
380
+ `Owner View: ${WEB_URL}/room/${code}#owner=${savedOwnerToken}`,
381
+ `Spectate: ${WEB_URL}/room/${code}`,
382
+ ``,
383
+ `Connected. Call kodama_listen to wait for messages.`,
384
+ ].join("\n"),
385
+ }],
386
+ };
387
+ },
388
+ );
389
+
390
+ // ---------------------------------------------------------------------------
391
+ // kodama_listen
392
+ // ---------------------------------------------------------------------------
393
+
394
+ server.tool(
395
+ "kodama_listen",
396
+ "Wait for the other agent's message. Returns in ~5 seconds whether or not a message arrived. If a message arrives, respond autonomously with kodama_say — do NOT ask the user what to say unless you genuinely need their input. If no message yet, call kodama_listen again.",
397
+ {},
398
+ async () => {
399
+ if (!kodama) {
400
+ return {
401
+ content: [{ type: "text" as const, text: "Not in a room. Call kodama_join first." }],
402
+ isError: true,
403
+ };
404
+ }
405
+
406
+ if (roomCompleted) {
407
+ const { room_state } = await fetchRoomState();
408
+ return {
409
+ content: [{
410
+ type: "text" as const,
411
+ text: JSON.stringify({
412
+ status: "completed",
413
+ action: "CONVERSATION_OVER",
414
+ room_state,
415
+ instruction: "The conversation has ended (max rounds reached). Call kodama_leave to disconnect, then summarize the outcome for the user.",
416
+ }),
417
+ }],
418
+ };
419
+ }
420
+
421
+ if (roomDeletedFlag) {
422
+ roomDeletedFlag = false;
423
+ const { room_state } = await fetchRoomState();
424
+ return {
425
+ content: [{
426
+ type: "text" as const,
427
+ text: JSON.stringify({
428
+ status: "room_deleted",
429
+ action: "CONVERSATION_OVER",
430
+ room_state,
431
+ instruction: "The room has been deleted by an owner. Call kodama_leave to disconnect, then summarize what was discussed.",
432
+ }),
433
+ }],
434
+ };
435
+ }
436
+
437
+ if (agentLeftFlag) {
438
+ agentLeftFlag = false;
439
+ const { room_state } = await fetchRoomState();
440
+ return {
441
+ content: [{
442
+ type: "text" as const,
443
+ text: JSON.stringify({
444
+ status: "agent_left",
445
+ action: "CONVERSATION_OVER",
446
+ room_state,
447
+ instruction: "The other agent has left the room. Call kodama_leave to disconnect, then summarize what was discussed.",
448
+ }),
449
+ }],
450
+ };
451
+ }
452
+
453
+ const entry = await listenWithTimeout();
454
+
455
+ // Check if agent left during the wait
456
+ if (agentLeftFlag) {
457
+ agentLeftFlag = false;
458
+ const { room_state } = await fetchRoomState();
459
+ return {
460
+ content: [{
461
+ type: "text" as const,
462
+ text: JSON.stringify({
463
+ status: "agent_left",
464
+ action: "CONVERSATION_OVER",
465
+ room_state,
466
+ instruction: "The other agent has left the room. Call kodama_leave to disconnect, then summarize what was discussed.",
467
+ }),
468
+ }],
469
+ };
470
+ }
471
+
472
+ if (roomDeletedFlag) {
473
+ roomDeletedFlag = false;
474
+ const { room_state } = await fetchRoomState();
475
+ return {
476
+ content: [{
477
+ type: "text" as const,
478
+ text: JSON.stringify({
479
+ status: "room_deleted",
480
+ action: "CONVERSATION_OVER",
481
+ room_state,
482
+ instruction: "The room has been deleted by an owner. Call kodama_leave to disconnect, then summarize what was discussed.",
483
+ }),
484
+ }],
485
+ };
486
+ }
487
+
488
+ if (roomCompleted) {
489
+ const { room_state } = await fetchRoomState();
490
+ return {
491
+ content: [{
492
+ type: "text" as const,
493
+ text: JSON.stringify({
494
+ status: "completed",
495
+ action: "CONVERSATION_OVER",
496
+ room_state,
497
+ instruction: "The conversation has ended. Call kodama_leave to disconnect, then summarize the outcome for the user.",
498
+ }),
499
+ }],
500
+ };
501
+ }
502
+
503
+ if (!entry) {
504
+ const { room_state } = await fetchRoomState();
505
+ const agentsConnected = (room_state.agents_connected as number) ?? 0;
506
+
507
+ return {
508
+ content: [{
509
+ type: "text" as const,
510
+ text: JSON.stringify({
511
+ status: "waiting",
512
+ action: "LISTEN_AGAIN",
513
+ room_state,
514
+ instruction: agentsConnected >= 2
515
+ ? "Both agents are connected but it's not your turn yet. Call kodama_listen again."
516
+ : `Waiting for the other agent to join (${agentsConnected}/2 connected). Call kodama_listen again.`,
517
+ }),
518
+ }],
519
+ };
520
+ }
521
+
522
+ const pendingWhispers = whispers.splice(0);
523
+
524
+ // Fetch room state for context (includes topic and maxRounds for approaching-limit check)
525
+ const { room_state: roomState, maxRounds: roomMaxRounds } = await fetchRoomState();
526
+ let topic: string | undefined;
527
+ if (savedRoomCode) {
528
+ try {
529
+ const res = await fetch(`${DEFAULT_RELAY}/api/rooms/${savedRoomCode}`);
530
+ if (res.ok) {
531
+ const data = await res.json() as any;
532
+ topic = data.topic;
533
+ }
534
+ } catch {}
535
+ }
536
+
537
+ const isFirstTurn = entry.messages.length === 0;
538
+ const approachingLimit = entry.round >= roomMaxRounds - 2;
539
+
540
+ return {
541
+ content: [{
542
+ type: "text" as const,
543
+ text: JSON.stringify({
544
+ status: "your_turn",
545
+ action: "RESPOND_THEN_LISTEN",
546
+ round: entry.round,
547
+ room_topic: topic,
548
+ room_state: roomState,
549
+ ...(approachingLimit ? { approaching_limit: true } : {}),
550
+ messages: entry.messages.map((m) => ({
551
+ from: m.agent.name,
552
+ text: m.content,
553
+ })),
554
+ ...(pendingWhispers.length > 0 ? { owner_guidance: pendingWhispers } : {}),
555
+ instruction: isFirstTurn
556
+ ? `You go first. The room topic is: "${topic ?? "general chat"}". Introduce yourself and start the conversation based on what the user asked you to do. Use kodama_say to send your opening message.`
557
+ : approachingLimit
558
+ ? "The conversation is nearing its turn limit. Start wrapping up — summarize any agreements, confirm next steps, and reach a conclusion. Use kodama_say to send your wrap-up message, then call kodama_listen."
559
+ : "Read the messages. Respond using kodama_say based on the user's goals and the room topic. Then call kodama_listen again. Do NOT ask the user what to say unless you need a genuine decision.",
560
+ }),
561
+ }],
562
+ };
563
+ },
564
+ );
565
+
566
+ // ---------------------------------------------------------------------------
567
+ // kodama_say
568
+ // ---------------------------------------------------------------------------
569
+
570
+ server.tool(
571
+ "kodama_say",
572
+ "Send your message in the conversation. After sending, IMMEDIATELY call kodama_listen to hear the reply. Never break this loop.",
573
+ {
574
+ message_text: z.string().describe("The message to send in the conversation"),
575
+ done: z.boolean().optional().describe("Set to true when you believe the conversation has reached its natural conclusion (plans confirmed, agreements made). When both agents signal done, the conversation ends automatically."),
576
+ },
577
+ async ({ message_text, done }) => {
578
+ if (!kodama) {
579
+ return {
580
+ content: [{ type: "text" as const, text: "Not in a room. Call kodama_join first." }],
581
+ isError: true,
582
+ };
583
+ }
584
+
585
+ if (!sayResolve) {
586
+ return {
587
+ content: [{ type: "text" as const, text: "Not your turn yet. Call kodama_listen first to wait for the other agent's message, then call kodama_say." }],
588
+ isError: true,
589
+ };
590
+ }
591
+
592
+ sayResolve({ content: message_text, done });
593
+ sayResolve = null;
594
+
595
+ return {
596
+ content: [{
597
+ type: "text" as const,
598
+ text: JSON.stringify({
599
+ status: "sent",
600
+ action: "LISTEN_FOR_REPLY",
601
+ instruction: "Message sent. Now call kodama_listen to hear their reply.",
602
+ }),
603
+ }],
604
+ };
605
+ },
606
+ );
607
+
608
+ // ---------------------------------------------------------------------------
609
+ // kodama_whisper
610
+ // ---------------------------------------------------------------------------
611
+
612
+ server.tool(
613
+ "kodama_whisper",
614
+ "Store guidance from the user to incorporate in your next response. This is delivered with the next kodama_listen result.",
615
+ {
616
+ guidance: z.string().describe("The user's guidance, e.g. 'push for Italian food' or 'suggest Thursday instead'"),
617
+ },
618
+ async ({ guidance }) => {
619
+ whispers.push(guidance);
620
+ return {
621
+ content: [{ type: "text" as const, text: `Guidance noted: "${guidance}". Will be included in your next kodama_listen result.` }],
622
+ };
623
+ },
624
+ );
625
+
626
+ // ---------------------------------------------------------------------------
627
+ // kodama_leave
628
+ // ---------------------------------------------------------------------------
629
+
630
+ server.tool(
631
+ "kodama_leave",
632
+ "Leave the current room and disconnect. ONLY call this when the user explicitly asks to leave or exit. NEVER call this on your own — even if the conversation seems finished, stay connected.",
633
+ {},
634
+ async () => {
635
+ if (!kodama) {
636
+ return {
637
+ content: [{ type: "text" as const, text: "Not in a room." }],
638
+ };
639
+ }
640
+
641
+ kodama.leave();
642
+ kodama = null;
643
+ turnHandlerInstalled = false;
644
+ roomCompleted = false;
645
+ agentLeftFlag = false;
646
+ roomDeletedFlag = false;
647
+ savedOwnerToken = null;
648
+ savedRoomCode = null;
649
+ savedInviteToken = null;
650
+ listenResolve = null;
651
+ sayResolve = null;
652
+ turnQueue.length = 0;
653
+ whispers.length = 0;
654
+
655
+ return {
656
+ content: [{
657
+ type: "text" as const,
658
+ text: "Left the room. You can join another room or create a new one.",
659
+ }],
660
+ };
661
+ },
662
+ );
663
+
664
+ // ---------------------------------------------------------------------------
665
+ // Start
666
+ // ---------------------------------------------------------------------------
667
+
668
+ async function main() {
669
+ const transport = new StdioServerTransport();
670
+ await server.connect(transport);
671
+ process.stderr.write("[kodama] MCP server ready\n");
672
+ }
673
+
674
+ main().catch((err) => {
675
+ process.stderr.write(`[kodama] fatal: ${err}\n`);
676
+ process.exit(1);
677
+ });