letagents 0.5.0 → 0.7.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.
@@ -8,11 +8,16 @@ import { execSync } from "child_process";
8
8
  import { SseClient } from "./sse-client.js";
9
9
  import { getRoomFromConfig } from "./config-reader.js";
10
10
  import { getGitRemoteIdentity } from "./git-remote.js";
11
+ import { clearPendingDeviceAuth, clearStoredAuth, getLocalStatePath, getPendingDeviceAuth, getStoredAuth, getStoredCurrentRoom, getStoredRoomSession, saveRoomSession, setPendingDeviceAuth, setStoredAuth, touchRoomSession, } from "./local-state.js";
12
+ import { encodeRoomIdPath, looksLikeInviteCode, normalizeInviteCode, } from "./room-id.js";
11
13
  let currentRoom = null;
12
14
  // ---------------------------------------------------------------------------
13
15
  // Config
14
16
  // ---------------------------------------------------------------------------
15
- const API_URL = process.env.LETAGENTS_API_URL || "http://localhost:3001";
17
+ const API_URL = (process.env.LETAGENTS_API_URL || "http://localhost:3001").replace(/\/+$/, "");
18
+ const AGENT_NAME = (process.env.LETAGENTS_AGENT_NAME || process.env.AGENT_NAME || "").trim();
19
+ const AGENT_DISPLAY_NAME = (process.env.LETAGENTS_AGENT_DISPLAY_NAME || "").trim();
20
+ const AGENT_OWNER_LABEL = (process.env.LETAGENTS_AGENT_OWNER_LABEL || "").trim();
16
21
  // ---------------------------------------------------------------------------
17
22
  // Helpers
18
23
  // ---------------------------------------------------------------------------
@@ -49,19 +54,217 @@ function findExistingConfig(startDir) {
49
54
  }
50
55
  return null;
51
56
  }
57
+ class ApiError extends Error {
58
+ status;
59
+ body;
60
+ constructor(status, body) {
61
+ super(`API error ${status}: ${body}`);
62
+ this.name = "ApiError";
63
+ this.status = status;
64
+ this.body = body;
65
+ }
66
+ }
67
+ function getLetagentsToken() {
68
+ return process.env.LETAGENTS_TOKEN || getStoredAuth()?.token || "";
69
+ }
70
+ function getAuthorizationHeader() {
71
+ const letagentsToken = getLetagentsToken();
72
+ if (letagentsToken) {
73
+ return `Bearer ${letagentsToken}`;
74
+ }
75
+ return null;
76
+ }
77
+ function isMissingRouteError(error) {
78
+ return (error instanceof ApiError &&
79
+ (error.status === 404 || error.status === 405) &&
80
+ /Cannot (GET|POST|PATCH)|Not Found|Cannot GET \/rooms|Cannot POST \/rooms/i.test(error.body));
81
+ }
52
82
  async function apiCall(path, options) {
83
+ const headers = {
84
+ "Content-Type": "application/json",
85
+ ...options?.headers,
86
+ };
87
+ const authorizationHeader = getAuthorizationHeader();
88
+ if (authorizationHeader && !headers.Authorization) {
89
+ headers.Authorization = authorizationHeader;
90
+ }
53
91
  const res = await fetch(`${API_URL}${path}`, {
54
92
  ...options,
55
- headers: {
56
- "Content-Type": "application/json",
57
- ...options?.headers,
58
- },
93
+ headers,
59
94
  });
60
95
  if (!res.ok) {
61
96
  const body = await res.text();
62
- throw new Error(`API error ${res.status}: ${body}`);
97
+ if (res.status === 401) {
98
+ // Only clear on 401 (invalid/expired credential), NOT on 403
99
+ // (valid credential but insufficient permissions, e.g., private repo access)
100
+ clearStoredAuth();
101
+ }
102
+ throw new ApiError(res.status, body);
103
+ }
104
+ const body = await res.text();
105
+ if (!body) {
106
+ return null;
107
+ }
108
+ return JSON.parse(body);
109
+ }
110
+ function toRoomState(input) {
111
+ return {
112
+ room_id: input.room_id,
113
+ project_id: input.project_id ?? null,
114
+ code: input.code ?? null,
115
+ joined_via: input.joined_via,
116
+ };
117
+ }
118
+ function rememberRoom(state, lastMessageId) {
119
+ currentRoom = state;
120
+ saveRoomSession({
121
+ room_id: state.room_id,
122
+ project_id: state.project_id ?? null,
123
+ code: state.code ?? null,
124
+ joined_via: state.joined_via,
125
+ last_message_id: lastMessageId,
126
+ });
127
+ sseClient.unsubscribeAll();
128
+ sseClient.subscribe({
129
+ roomId: state.room_id,
130
+ projectId: state.project_id ?? null,
131
+ }, (_message) => {
132
+ touchRoomSession(state.room_id);
133
+ server.server.sendResourceListChanged();
134
+ });
135
+ return state;
136
+ }
137
+ function touchCurrentRoom(lastMessageId) {
138
+ if (!currentRoom) {
139
+ return;
140
+ }
141
+ touchRoomSession(currentRoom.room_id, lastMessageId);
142
+ }
143
+ function getTargetRoomId(roomId) {
144
+ return roomId || currentRoom?.room_id || null;
145
+ }
146
+ function getTargetProjectId(projectId) {
147
+ return projectId || currentRoom?.project_id || null;
148
+ }
149
+ function getLastMessageId(payload) {
150
+ if (!payload || typeof payload !== "object") {
151
+ return undefined;
152
+ }
153
+ const messages = payload.messages;
154
+ const lastMessage = messages?.at(-1);
155
+ return typeof lastMessage?.id === "string" ? lastMessage.id : undefined;
156
+ }
157
+ async function roomScopedApiCall(input) {
158
+ if (input.room_id) {
159
+ try {
160
+ const result = await apiCall(input.room_path(input.room_id), input.options);
161
+ touchRoomSession(input.room_id, getLastMessageId(result));
162
+ return result;
163
+ }
164
+ catch (error) {
165
+ if (!input.project_id || !isMissingRouteError(error)) {
166
+ throw error;
167
+ }
168
+ }
169
+ }
170
+ if (!input.project_id) {
171
+ throw new Error("No room_id or project_id is available for this request.");
172
+ }
173
+ const result = await apiCall(input.project_path(input.project_id), input.options);
174
+ if (input.room_id) {
175
+ touchRoomSession(input.room_id, getLastMessageId(result));
176
+ }
177
+ return result;
178
+ }
179
+ async function joinRoomIdentifier(identifier, joinedVia) {
180
+ const roomId = joinedVia === "join_code" ? normalizeInviteCode(identifier) : identifier.trim();
181
+ try {
182
+ const response = await apiCall(`/rooms/${encodeRoomIdPath(roomId)}/join`, { method: "POST" });
183
+ const joinedRoomId = typeof response.room_id === "string"
184
+ ? response.room_id
185
+ : roomId;
186
+ const room = rememberRoom(toRoomState({
187
+ room_id: joinedRoomId,
188
+ project_id: typeof response.project_id === "string" ? response.project_id : null,
189
+ code: typeof response.code === "string"
190
+ ? response.code
191
+ : looksLikeInviteCode(joinedRoomId)
192
+ ? joinedRoomId
193
+ : null,
194
+ joined_via: joinedVia,
195
+ }));
196
+ return {
197
+ room,
198
+ response: { ...response, room_id: joinedRoomId },
199
+ };
200
+ }
201
+ catch (error) {
202
+ if (!isMissingRouteError(error)) {
203
+ throw error;
204
+ }
205
+ }
206
+ if (joinedVia === "join_code") {
207
+ const project = await apiCall(`/projects/join/${encodeURIComponent(roomId)}`);
208
+ const legacyRoomId = typeof project.code === "string"
209
+ ? project.code
210
+ : roomId;
211
+ const room = rememberRoom(toRoomState({
212
+ room_id: legacyRoomId,
213
+ project_id: typeof project.id === "string" ? project.id : null,
214
+ code: typeof project.code === "string" ? project.code : legacyRoomId,
215
+ joined_via: joinedVia,
216
+ }));
217
+ return {
218
+ room,
219
+ response: {
220
+ ...project,
221
+ room_id: legacyRoomId,
222
+ project_id: typeof project.id === "string" ? project.id : null,
223
+ },
224
+ };
225
+ }
226
+ const project = await apiCall(`/projects/room/${encodeURIComponent(roomId)}`, { method: "POST" });
227
+ const legacyRoomId = typeof project.name === "string" && project.name.trim()
228
+ ? project.name
229
+ : typeof project.code === "string" && project.code.trim()
230
+ ? project.code
231
+ : roomId;
232
+ const room = rememberRoom(toRoomState({
233
+ room_id: legacyRoomId,
234
+ project_id: typeof project.id === "string" ? project.id : null,
235
+ code: typeof project.code === "string"
236
+ ? project.code
237
+ : looksLikeInviteCode(legacyRoomId)
238
+ ? legacyRoomId
239
+ : null,
240
+ joined_via: joinedVia,
241
+ }));
242
+ return {
243
+ room,
244
+ response: {
245
+ ...project,
246
+ room_id: legacyRoomId,
247
+ project_id: typeof project.id === "string" ? project.id : null,
248
+ },
249
+ };
250
+ }
251
+ async function autoRegisterAgentIdentity() {
252
+ if (!AGENT_NAME || !currentRoom) {
253
+ return;
254
+ }
255
+ try {
256
+ await apiCall("/agents", {
257
+ method: "POST",
258
+ body: JSON.stringify({
259
+ name: AGENT_NAME,
260
+ display_name: AGENT_DISPLAY_NAME || AGENT_NAME,
261
+ owner_label: AGENT_OWNER_LABEL || undefined,
262
+ }),
263
+ });
264
+ }
265
+ catch (error) {
266
+ console.error("Agent identity registration failed:", error);
63
267
  }
64
- return res.json();
65
268
  }
66
269
  // ---------------------------------------------------------------------------
67
270
  // MCP Server
@@ -70,10 +273,32 @@ const server = new McpServer({
70
273
  name: "letagents",
71
274
  version: "0.2.0",
72
275
  });
73
- const sseClient = new SseClient(API_URL);
276
+ const sseClient = new SseClient(API_URL, () => getLetagentsToken());
74
277
  // ---------------------------------------------------------------------------
75
278
  // MCP Resources
76
279
  // ---------------------------------------------------------------------------
280
+ server.resource("room_messages", new ResourceTemplate("letagents://rooms/{room_id}/messages", {
281
+ list: undefined,
282
+ }), async (uri, { room_id }) => {
283
+ const normalizedRoomId = String(room_id);
284
+ const storedSession = getStoredRoomSession(normalizedRoomId) ??
285
+ (currentRoom?.room_id === normalizedRoomId ? getStoredCurrentRoom() : null);
286
+ const result = await roomScopedApiCall({
287
+ room_id: normalizedRoomId,
288
+ project_id: storedSession?.project_id ?? null,
289
+ room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/messages`,
290
+ project_path: (projectId) => `/projects/${encodeURIComponent(projectId)}/messages`,
291
+ });
292
+ return {
293
+ contents: [
294
+ {
295
+ uri: uri.href,
296
+ mimeType: "application/json",
297
+ text: JSON.stringify(result, null, 2),
298
+ },
299
+ ],
300
+ };
301
+ });
77
302
  server.resource("project_messages", new ResourceTemplate("letagents://projects/{project_id}/messages", {
78
303
  list: undefined,
79
304
  }), async (uri, { project_id }) => {
@@ -91,15 +316,23 @@ server.resource("project_messages", new ResourceTemplate("letagents://projects/{
91
316
  // -- create_project ---------------------------------------------------------
92
317
  server.tool("create_project", "Create a new project on Let Agents Chat. Returns a project ID and a join code that other agents can use to join.", {}, async () => {
93
318
  const project = await apiCall("/projects", { method: "POST" });
94
- // Auto-subscribe to SSE for this project
95
- sseClient.subscribe(project.id, (_message) => {
96
- server.server.sendResourceListChanged();
97
- });
319
+ const roomId = typeof project.code === "string"
320
+ ? project.code
321
+ : typeof project.id === "string"
322
+ ? project.id
323
+ : "unknown-room";
324
+ rememberRoom(toRoomState({
325
+ room_id: roomId,
326
+ project_id: typeof project.id === "string" ? project.id : null,
327
+ code: typeof project.code === "string" ? project.code : roomId,
328
+ joined_via: "join_code",
329
+ }));
330
+ await autoRegisterAgentIdentity();
98
331
  return {
99
332
  content: [
100
333
  {
101
334
  type: "text",
102
- text: JSON.stringify(project, null, 2),
335
+ text: JSON.stringify({ ...project, room_id: roomId }, null, 2),
103
336
  },
104
337
  ],
105
338
  };
@@ -108,23 +341,13 @@ server.tool("create_project", "Create a new project on Let Agents Chat. Returns
108
341
  server.tool("join_project", "Join an existing Let Agents Chat project using a join code.", {
109
342
  code: z.string().describe("The join code shared by the project creator (e.g. 'ABCX-7291')"),
110
343
  }, async ({ code }) => {
111
- const project = await apiCall(`/projects/join/${encodeURIComponent(code)}`);
112
- // Track room state
113
- currentRoom = {
114
- room: project.name || code,
115
- project_id: project.id,
116
- code: project.code || code,
117
- joined_via: "join_code",
118
- };
119
- // Auto-subscribe to SSE for this project
120
- sseClient.subscribe(project.id, (_message) => {
121
- server.server.sendResourceListChanged();
122
- });
344
+ const joined = await joinRoomIdentifier(code, "join_code");
345
+ await autoRegisterAgentIdentity();
123
346
  return {
124
347
  content: [
125
348
  {
126
349
  type: "text",
127
- text: JSON.stringify({ ...project, joined_via: "join_code" }, null, 2),
350
+ text: JSON.stringify({ ...joined.response, joined_via: "join_code" }, null, 2),
128
351
  },
129
352
  ],
130
353
  };
@@ -133,23 +356,13 @@ server.tool("join_project", "Join an existing Let Agents Chat project using a jo
133
356
  server.tool("join_room", "Join a named room on Let Agents Chat. Creates the room if it doesn't exist. Use this for repo-based room joining.", {
134
357
  name: z.string().describe("The room name to join (e.g. 'github.com/owner/repo')"),
135
358
  }, async ({ name }) => {
136
- const project = await apiCall(`/projects/room/${encodeURIComponent(name)}`, { method: "POST" });
137
- // Track room state
138
- currentRoom = {
139
- room: name,
140
- project_id: project.id,
141
- code: project.code,
142
- joined_via: "join_room",
143
- };
144
- // Auto-subscribe to SSE
145
- sseClient.subscribe(project.id, (_message) => {
146
- server.server.sendResourceListChanged();
147
- });
359
+ const joined = await joinRoomIdentifier(name, "join_room");
360
+ await autoRegisterAgentIdentity();
148
361
  return {
149
362
  content: [
150
363
  {
151
364
  type: "text",
152
- text: JSON.stringify({ ...project, joined_via: "join_room" }, null, 2),
365
+ text: JSON.stringify({ ...joined.response, joined_via: "join_room" }, null, 2),
153
366
  },
154
367
  ],
155
368
  };
@@ -170,7 +383,17 @@ server.tool("get_current_room", "Get information about the currently joined room
170
383
  content: [
171
384
  {
172
385
  type: "text",
173
- text: JSON.stringify({ connected: true, ...currentRoom }, null, 2),
386
+ text: JSON.stringify({
387
+ connected: true,
388
+ ...currentRoom,
389
+ auth: getStoredAuth()
390
+ ? {
391
+ source: process.env.LETAGENTS_TOKEN ? "env" : "local_state",
392
+ expires_at: getStoredAuth()?.expires_at ?? null,
393
+ account: getStoredAuth()?.account ?? null,
394
+ }
395
+ : null,
396
+ }, null, 2),
174
397
  },
175
398
  ],
176
399
  };
@@ -227,21 +450,26 @@ server.tool("post_status", "Broadcast a lightweight status update to the current
227
450
  "Status updates are distinct from chat messages and can be filtered separately.", {
228
451
  sender: z.string().describe("Name of the agent posting the status (e.g. 'codex-agent')"),
229
452
  status: z.string().describe("Short status description (e.g. 'reviewing PR #2', 'idle', 'thinking...')"),
453
+ room_id: z
454
+ .string()
455
+ .optional()
456
+ .describe("Canonical room ID. Defaults to the current room."),
230
457
  project_id: z
231
458
  .string()
232
459
  .optional()
233
- .describe("Project ID to post status to. Defaults to the current room if joined."),
234
- }, async ({ sender, status, project_id }) => {
235
- const targetProjectId = project_id || currentRoom?.project_id;
236
- if (!targetProjectId) {
460
+ .describe("Legacy project ID. Defaults to the current room if joined."),
461
+ }, async ({ sender, status, room_id, project_id }) => {
462
+ const targetRoomId = getTargetRoomId(room_id);
463
+ const targetProjectId = getTargetProjectId(project_id);
464
+ if (!targetRoomId && !targetProjectId) {
237
465
  return {
238
466
  content: [
239
467
  {
240
468
  type: "text",
241
469
  text: JSON.stringify({
242
470
  success: false,
243
- error: "No project_id provided and not currently in a room.",
244
- hint: "Join a room first with join_project or join_room, or pass project_id explicitly.",
471
+ error: "No room_id or project_id provided and not currently in a room.",
472
+ hint: "Join a room first with join_project or join_room, or pass room_id explicitly.",
245
473
  }, null, 2),
246
474
  },
247
475
  ],
@@ -250,10 +478,17 @@ server.tool("post_status", "Broadcast a lightweight status update to the current
250
478
  // Status messages use a reserved prefix so the UI (and agents) can distinguish
251
479
  // them from normal chat messages without changing the data model.
252
480
  const statusText = `[status] ${status}`;
253
- const message = await apiCall(`/projects/${encodeURIComponent(targetProjectId)}/messages`, {
254
- method: "POST",
255
- body: JSON.stringify({ sender, text: statusText }),
481
+ const message = await roomScopedApiCall({
482
+ room_id: targetRoomId,
483
+ project_id: targetProjectId,
484
+ room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/messages`,
485
+ project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/messages`,
486
+ options: {
487
+ method: "POST",
488
+ body: JSON.stringify({ sender, text: statusText }),
489
+ },
256
490
  });
491
+ touchCurrentRoom(typeof message.id === "string" ? message.id : undefined);
257
492
  return {
258
493
  content: [
259
494
  {
@@ -262,8 +497,8 @@ server.tool("post_status", "Broadcast a lightweight status update to the current
262
497
  success: true,
263
498
  status_posted: status,
264
499
  sender,
265
- message_id: message.id,
266
- timestamp: message.timestamp,
500
+ message_id: typeof message.id === "string" ? message.id : null,
501
+ timestamp: typeof message.timestamp === "string" ? message.timestamp : null,
267
502
  }, null, 2),
268
503
  },
269
504
  ],
@@ -274,24 +509,33 @@ const TASK_STATUSES = [
274
509
  "proposed", "accepted", "assigned", "in_progress",
275
510
  "blocked", "in_review", "merged", "done", "cancelled",
276
511
  ];
277
- server.tool("add_task", "Add a new task to the project board. Tasks start as 'proposed' and must be " +
278
- "accepted before an agent can claim them. Use this when a human or agent " +
279
- "identifies work that needs to be done.", {
512
+ server.tool("add_task", "Add a new task to the project board. Tasks normally start as 'proposed' and must be " +
513
+ "accepted before an agent can claim them, but tasks created by trusted agents already " +
514
+ "active in the room may be auto-accepted. Use this when a human or agent identifies " +
515
+ "work that needs to be done.", {
280
516
  title: z.string().describe("Short task title, e.g. 'Wire up Jest test runner'"),
281
517
  description: z.string().optional().describe("Longer description of what needs to be done"),
282
518
  created_by: z.string().describe("Name of the agent or human creating the task"),
283
519
  source_message_id: z.string().optional().describe("Optional message ID where task was agreed, e.g. 'msg_42'"),
284
- project_id: z.string().optional().describe("Project ID. Defaults to current room."),
285
- }, async ({ title, description, created_by, source_message_id, project_id }) => {
286
- const targetProjectId = project_id || currentRoom?.project_id;
287
- if (!targetProjectId) {
520
+ room_id: z.string().optional().describe("Canonical room ID. Defaults to current room."),
521
+ project_id: z.string().optional().describe("Legacy project ID. Defaults to current room."),
522
+ }, async ({ title, description, created_by, source_message_id, room_id, project_id }) => {
523
+ const targetRoomId = getTargetRoomId(room_id);
524
+ const targetProjectId = getTargetProjectId(project_id);
525
+ if (!targetRoomId && !targetProjectId) {
288
526
  return {
289
527
  content: [{ type: "text", text: JSON.stringify({ success: false, error: "Not in a room. Join one first." }) }],
290
528
  };
291
529
  }
292
- const task = await apiCall(`/projects/${encodeURIComponent(targetProjectId)}/tasks`, {
293
- method: "POST",
294
- body: JSON.stringify({ title, description, created_by, source_message_id }),
530
+ const task = await roomScopedApiCall({
531
+ room_id: targetRoomId,
532
+ project_id: targetProjectId,
533
+ room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/tasks`,
534
+ project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/tasks`,
535
+ options: {
536
+ method: "POST",
537
+ body: JSON.stringify({ title, description, created_by, source_message_id }),
538
+ },
295
539
  });
296
540
  return {
297
541
  content: [{ type: "text", text: JSON.stringify({ success: true, task }, null, 2) }],
@@ -302,10 +546,12 @@ server.tool("get_board", "Get the current task board for the project. By default
302
546
  "see if there is unassigned work to claim.", {
303
547
  status: z.enum(TASK_STATUSES).optional().describe("Filter by specific status"),
304
548
  open_only: z.boolean().optional().describe("If true (default), only show tasks not done/cancelled"),
305
- project_id: z.string().optional().describe("Project ID. Defaults to current room."),
306
- }, async ({ status, open_only, project_id }) => {
307
- const targetProjectId = project_id || currentRoom?.project_id;
308
- if (!targetProjectId) {
549
+ room_id: z.string().optional().describe("Canonical room ID. Defaults to current room."),
550
+ project_id: z.string().optional().describe("Legacy project ID. Defaults to current room."),
551
+ }, async ({ status, open_only, room_id, project_id }) => {
552
+ const targetRoomId = getTargetRoomId(room_id);
553
+ const targetProjectId = getTargetProjectId(project_id);
554
+ if (!targetRoomId && !targetProjectId) {
309
555
  return {
310
556
  content: [{ type: "text", text: JSON.stringify({ success: false, error: "Not in a room. Join one first." }) }],
311
557
  };
@@ -316,7 +562,12 @@ server.tool("get_board", "Get the current task board for the project. By default
316
562
  if (open_only !== false)
317
563
  params.set("open", "true");
318
564
  const qs = params.toString();
319
- const result = await apiCall(`/projects/${encodeURIComponent(targetProjectId)}/tasks${qs ? `?${qs}` : ""}`);
565
+ const result = await roomScopedApiCall({
566
+ room_id: targetRoomId,
567
+ project_id: targetProjectId,
568
+ room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/tasks${qs ? `?${qs}` : ""}`,
569
+ project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/tasks${qs ? `?${qs}` : ""}`,
570
+ });
320
571
  return {
321
572
  content: [{ type: "text", text: JSON.stringify({ success: true, ...result }, null, 2) }],
322
573
  };
@@ -326,18 +577,26 @@ server.tool("claim_task", "Claim an accepted task. The task must be in 'accepted
326
577
  "Do NOT claim proposed tasks — they need to be accepted first.", {
327
578
  task_id: z.string().describe("The task ID to claim, e.g. 'task_1'"),
328
579
  assignee: z.string().describe("Your agent name, e.g. 'antigravity'"),
329
- project_id: z.string().optional().describe("Project ID. Defaults to current room."),
330
- }, async ({ task_id, assignee, project_id }) => {
331
- const targetProjectId = project_id || currentRoom?.project_id;
332
- if (!targetProjectId) {
580
+ room_id: z.string().optional().describe("Canonical room ID. Defaults to current room."),
581
+ project_id: z.string().optional().describe("Legacy project ID. Defaults to current room."),
582
+ }, async ({ task_id, assignee, room_id, project_id }) => {
583
+ const targetRoomId = getTargetRoomId(room_id);
584
+ const targetProjectId = getTargetProjectId(project_id);
585
+ if (!targetRoomId && !targetProjectId) {
333
586
  return {
334
587
  content: [{ type: "text", text: JSON.stringify({ success: false, error: "Not in a room." }) }],
335
588
  };
336
589
  }
337
590
  try {
338
- const updated = await apiCall(`/projects/${encodeURIComponent(targetProjectId)}/tasks/${encodeURIComponent(task_id)}`, {
339
- method: "PATCH",
340
- body: JSON.stringify({ status: "assigned", assignee }),
591
+ const updated = await roomScopedApiCall({
592
+ room_id: targetRoomId,
593
+ project_id: targetProjectId,
594
+ room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/tasks/${encodeURIComponent(task_id)}`,
595
+ project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/tasks/${encodeURIComponent(task_id)}`,
596
+ options: {
597
+ method: "PATCH",
598
+ body: JSON.stringify({ status: "assigned", assignee }),
599
+ },
341
600
  });
342
601
  return {
343
602
  content: [{ type: "text", text: JSON.stringify({ success: true, task: updated }, null, 2) }],
@@ -356,18 +615,26 @@ server.tool("update_task", "Update a task's status or assignee. Status transitio
356
615
  status: z.enum(TASK_STATUSES).optional().describe("New status for the task"),
357
616
  assignee: z.string().optional().describe("New assignee for the task"),
358
617
  pr_url: z.string().optional().describe("PR URL to link to the task"),
359
- project_id: z.string().optional().describe("Project ID. Defaults to current room."),
360
- }, async ({ task_id, status, assignee, pr_url, project_id }) => {
361
- const targetProjectId = project_id || currentRoom?.project_id;
362
- if (!targetProjectId) {
618
+ room_id: z.string().optional().describe("Canonical room ID. Defaults to current room."),
619
+ project_id: z.string().optional().describe("Legacy project ID. Defaults to current room."),
620
+ }, async ({ task_id, status, assignee, pr_url, room_id, project_id }) => {
621
+ const targetRoomId = getTargetRoomId(room_id);
622
+ const targetProjectId = getTargetProjectId(project_id);
623
+ if (!targetRoomId && !targetProjectId) {
363
624
  return {
364
625
  content: [{ type: "text", text: JSON.stringify({ success: false, error: "Not in a room." }) }],
365
626
  };
366
627
  }
367
628
  try {
368
- const updated = await apiCall(`/projects/${encodeURIComponent(targetProjectId)}/tasks/${encodeURIComponent(task_id)}`, {
369
- method: "PATCH",
370
- body: JSON.stringify({ status, assignee, pr_url }),
629
+ const updated = await roomScopedApiCall({
630
+ room_id: targetRoomId,
631
+ project_id: targetProjectId,
632
+ room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/tasks/${encodeURIComponent(task_id)}`,
633
+ project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/tasks/${encodeURIComponent(task_id)}`,
634
+ options: {
635
+ method: "PATCH",
636
+ body: JSON.stringify({ status, assignee, pr_url }),
637
+ },
371
638
  });
372
639
  return {
373
640
  content: [{ type: "text", text: JSON.stringify({ success: true, task: updated }, null, 2) }],
@@ -384,18 +651,26 @@ server.tool("complete_task", "Submit a task for review. Moves the task to 'in_re
384
651
  "the work is merged before it can be marked done.", {
385
652
  task_id: z.string().describe("The task ID to submit for review"),
386
653
  pr_url: z.string().optional().describe("GitHub PR URL for the work"),
387
- project_id: z.string().optional().describe("Project ID. Defaults to current room."),
388
- }, async ({ task_id, pr_url, project_id }) => {
389
- const targetProjectId = project_id || currentRoom?.project_id;
390
- if (!targetProjectId) {
654
+ room_id: z.string().optional().describe("Canonical room ID. Defaults to current room."),
655
+ project_id: z.string().optional().describe("Legacy project ID. Defaults to current room."),
656
+ }, async ({ task_id, pr_url, room_id, project_id }) => {
657
+ const targetRoomId = getTargetRoomId(room_id);
658
+ const targetProjectId = getTargetProjectId(project_id);
659
+ if (!targetRoomId && !targetProjectId) {
391
660
  return {
392
661
  content: [{ type: "text", text: JSON.stringify({ success: false, error: "Not in a room." }) }],
393
662
  };
394
663
  }
395
664
  try {
396
- const updated = await apiCall(`/projects/${encodeURIComponent(targetProjectId)}/tasks/${encodeURIComponent(task_id)}`, {
397
- method: "PATCH",
398
- body: JSON.stringify({ status: "in_review", pr_url }),
665
+ const updated = await roomScopedApiCall({
666
+ room_id: targetRoomId,
667
+ project_id: targetProjectId,
668
+ room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/tasks/${encodeURIComponent(task_id)}`,
669
+ project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/tasks/${encodeURIComponent(task_id)}`,
670
+ options: {
671
+ method: "PATCH",
672
+ body: JSON.stringify({ status: "in_review", pr_url }),
673
+ },
399
674
  });
400
675
  return {
401
676
  content: [{ type: "text", text: JSON.stringify({ success: true, task: updated }, null, 2) }],
@@ -515,16 +790,7 @@ server.tool("initialize_repo", "Initialize the current repo for Let Agents Chat
515
790
  }
516
791
  // Auto-join the room after creating config
517
792
  try {
518
- const project = await apiCall(`/projects/room/${encodeURIComponent(roomName)}`, { method: "POST" });
519
- currentRoom = {
520
- room: roomName,
521
- project_id: project.id,
522
- code: project.code,
523
- joined_via: "config",
524
- };
525
- sseClient.subscribe(project.id, (_message) => {
526
- server.server.sendResourceListChanged();
527
- });
793
+ const joined = await joinRoomIdentifier(roomName, "config");
528
794
  return {
529
795
  content: [
530
796
  {
@@ -532,9 +798,9 @@ server.tool("initialize_repo", "Initialize the current repo for Let Agents Chat
532
798
  text: JSON.stringify({
533
799
  success: true,
534
800
  created: configPath,
535
- room: roomName,
536
- project_id: project.id,
537
- code: project.code,
801
+ room_id: joined.room.room_id,
802
+ project_id: joined.room.project_id ?? null,
803
+ code: joined.room.code ?? null,
538
804
  joined: true,
539
805
  hint: "Consider adding .letagents.json to git so other contributors auto-join the same room.",
540
806
  }, null, 2),
@@ -551,7 +817,7 @@ server.tool("initialize_repo", "Initialize the current repo for Let Agents Chat
551
817
  text: JSON.stringify({
552
818
  success: true,
553
819
  created: configPath,
554
- room: roomName,
820
+ room_id: roomName,
555
821
  joined: false,
556
822
  error: `Config created but auto-join failed: ${err instanceof Error ? err.message : err}`,
557
823
  hint: "The .letagents.json was created. Use join_room to manually connect.",
@@ -562,15 +828,28 @@ server.tool("initialize_repo", "Initialize the current repo for Let Agents Chat
562
828
  }
563
829
  });
564
830
  // -- send_message -----------------------------------------------------------
565
- server.tool("send_message", "Send a message to a Let Agents Chat project.", {
566
- project_id: z.string().describe("The project ID to send the message to"),
831
+ server.tool("send_message", "Send a message to a Let Agents Chat room.", {
832
+ room_id: z.string().optional().describe("Canonical room ID. Defaults to the current room."),
833
+ project_id: z.string().optional().describe("Legacy project ID. Defaults to the current room."),
567
834
  sender: z.string().describe("Name identifying the sending agent (e.g. 'antigravity-agent')"),
568
835
  text: z.string().describe("The message text to send"),
569
- }, async ({ project_id, sender, text }) => {
570
- const message = await apiCall(`/projects/${encodeURIComponent(project_id)}/messages`, {
571
- method: "POST",
572
- body: JSON.stringify({ sender, text }),
836
+ }, async ({ room_id, project_id, sender, text }) => {
837
+ const targetRoomId = getTargetRoomId(room_id);
838
+ const targetProjectId = getTargetProjectId(project_id);
839
+ if (!targetRoomId && !targetProjectId) {
840
+ throw new Error("No room is currently selected. Join a room first or pass room_id.");
841
+ }
842
+ const message = await roomScopedApiCall({
843
+ room_id: targetRoomId,
844
+ project_id: targetProjectId,
845
+ room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/messages`,
846
+ project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/messages`,
847
+ options: {
848
+ method: "POST",
849
+ body: JSON.stringify({ sender, text }),
850
+ },
573
851
  });
852
+ touchCurrentRoom(typeof message.id === "string" ? message.id : undefined);
574
853
  return {
575
854
  content: [
576
855
  {
@@ -581,10 +860,18 @@ server.tool("send_message", "Send a message to a Let Agents Chat project.", {
581
860
  };
582
861
  });
583
862
  // -- read_messages ----------------------------------------------------------
584
- server.tool("read_messages", "Read all messages from a Let Agents Chat project.", {
585
- project_id: z.string().describe("The project ID to read messages from"),
586
- }, async ({ project_id }) => {
587
- const result = await apiCall(`/projects/${encodeURIComponent(project_id)}/messages`);
863
+ server.tool("read_messages", "Read all messages from a Let Agents Chat room.", {
864
+ room_id: z.string().optional().describe("Canonical room ID. Defaults to the current room."),
865
+ project_id: z.string().optional().describe("Legacy project ID. Defaults to the current room."),
866
+ }, async ({ room_id, project_id }) => {
867
+ const targetRoomId = getTargetRoomId(room_id);
868
+ const targetProjectId = getTargetProjectId(project_id);
869
+ const result = await roomScopedApiCall({
870
+ room_id: targetRoomId,
871
+ project_id: targetProjectId,
872
+ room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/messages`,
873
+ project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/messages`,
874
+ });
588
875
  return {
589
876
  content: [
590
877
  {
@@ -597,8 +884,9 @@ server.tool("read_messages", "Read all messages from a Let Agents Chat project."
597
884
  // -- wait_for_messages ------------------------------------------------------
598
885
  const MAX_POLL_TIMEOUT_MS = 180000; // 3 minutes
599
886
  const DEFAULT_POLL_TIMEOUT_MS = 30000; // 30 seconds
600
- server.tool("wait_for_messages", "Wait for new messages in a Let Agents Chat project. Blocks until new messages arrive or 30 seconds elapse. Use the after_message_id parameter to only receive messages newer than a specific message.", {
601
- project_id: z.string().describe("The project ID to wait for messages in"),
887
+ server.tool("wait_for_messages", "Wait for new messages in a Let Agents Chat room. Blocks until new messages arrive or 30 seconds elapse. Use the after_message_id parameter to only receive messages newer than a specific message.", {
888
+ room_id: z.string().optional().describe("Canonical room ID. Defaults to the current room."),
889
+ project_id: z.string().optional().describe("Legacy project ID. Defaults to the current room."),
602
890
  after_message_id: z
603
891
  .string()
604
892
  .optional()
@@ -607,7 +895,9 @@ server.tool("wait_for_messages", "Wait for new messages in a Let Agents Chat pro
607
895
  .number()
608
896
  .optional()
609
897
  .describe("Maximum wait time in milliseconds. If set to 0, the default timeout will be used."),
610
- }, async ({ project_id, after_message_id, timeout }) => {
898
+ }, async ({ room_id, project_id, after_message_id, timeout }) => {
899
+ const targetRoomId = getTargetRoomId(room_id);
900
+ const targetProjectId = getTargetProjectId(project_id);
611
901
  const serverTimeout = Math.min(Math.max(timeout || DEFAULT_POLL_TIMEOUT_MS, 1000), MAX_POLL_TIMEOUT_MS);
612
902
  const clientTimeout = serverTimeout + 5000; // 5s buffer over server timeout
613
903
  const params = new URLSearchParams();
@@ -615,7 +905,304 @@ server.tool("wait_for_messages", "Wait for new messages in a Let Agents Chat pro
615
905
  params.set("after", after_message_id);
616
906
  params.set("timeout", String(serverTimeout));
617
907
  const queryString = params.toString();
618
- const result = await apiCall(`/projects/${encodeURIComponent(project_id)}/messages/poll?${queryString}`, { signal: AbortSignal.timeout(clientTimeout) });
908
+ const result = await roomScopedApiCall({
909
+ room_id: targetRoomId,
910
+ project_id: targetProjectId,
911
+ room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/messages/poll?${queryString}`,
912
+ project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/messages/poll?${queryString}`,
913
+ options: { signal: AbortSignal.timeout(clientTimeout) },
914
+ });
915
+ if (targetRoomId) {
916
+ touchRoomSession(targetRoomId, getLastMessageId(result));
917
+ }
918
+ return {
919
+ content: [
920
+ {
921
+ type: "text",
922
+ text: JSON.stringify(result, null, 2),
923
+ },
924
+ ],
925
+ };
926
+ });
927
+ // -- onboarding -------------------------------------------------------------
928
+ server.tool("get_onboarding_status", "Inspect local Let Agents MCP auth and room-session state so a user can finish onboarding without guessing what is missing.", {
929
+ cwd: z
930
+ .string()
931
+ .optional()
932
+ .describe("Working directory to inspect for repo context. Defaults to the current process directory."),
933
+ }, async ({ cwd }) => {
934
+ const workingDir = cwd || process.cwd();
935
+ const repoRoot = resolveGitRoot(workingDir);
936
+ const configRoom = getRoomFromConfig(workingDir);
937
+ const gitRoom = repoRoot ? getGitRemoteIdentity(repoRoot) : null;
938
+ const storedAuth = getStoredAuth();
939
+ const pendingAuth = getPendingDeviceAuth();
940
+ const savedCurrentRoom = getStoredCurrentRoom();
941
+ const detectedRoom = configRoom || gitRoom;
942
+ let nextStep = "join_room";
943
+ if (!storedAuth && pendingAuth) {
944
+ nextStep = "poll_device_auth";
945
+ }
946
+ else if (savedCurrentRoom && !currentRoom) {
947
+ nextStep = "resume_room_session";
948
+ }
949
+ return {
950
+ content: [
951
+ {
952
+ type: "text",
953
+ text: JSON.stringify({
954
+ api_url: API_URL,
955
+ local_state_path: getLocalStatePath(),
956
+ authenticated: Boolean(process.env.LETAGENTS_TOKEN || storedAuth),
957
+ auth_source: process.env.LETAGENTS_TOKEN
958
+ ? "env"
959
+ : storedAuth
960
+ ? "local_state"
961
+ : "none",
962
+ account: storedAuth?.account ?? null,
963
+ token_expires_at: storedAuth?.expires_at ?? null,
964
+ pending_device_auth: pendingAuth,
965
+ current_room: currentRoom,
966
+ saved_current_room: savedCurrentRoom,
967
+ detected_room_from_context: detectedRoom,
968
+ repo_root: repoRoot,
969
+ next_step: nextStep,
970
+ }, null, 2),
971
+ },
972
+ ],
973
+ };
974
+ });
975
+ server.tool("start_device_auth", "Start GitHub Device Flow for Let Agents Chat and persist the pending request locally. Use this when private repo access or explicit LetAgents auth is needed.", {
976
+ room_id: z
977
+ .string()
978
+ .optional()
979
+ .describe("Optional room to associate with this auth request for later auto-join."),
980
+ force: z
981
+ .boolean()
982
+ .optional()
983
+ .describe("If true, replaces any existing pending device auth request."),
984
+ }, async ({ room_id, force }) => {
985
+ const existing = getPendingDeviceAuth();
986
+ if (existing && !force) {
987
+ return {
988
+ content: [
989
+ {
990
+ type: "text",
991
+ text: JSON.stringify({
992
+ success: true,
993
+ reused_existing_request: true,
994
+ ...existing,
995
+ }, null, 2),
996
+ },
997
+ ],
998
+ };
999
+ }
1000
+ const response = await apiCall("/auth/device/start", {
1001
+ method: "POST",
1002
+ });
1003
+ const pendingAuth = setPendingDeviceAuth({
1004
+ request_id: response.request_id,
1005
+ user_code: response.user_code,
1006
+ verification_uri: response.verification_uri,
1007
+ interval_seconds: response.interval,
1008
+ expires_at: new Date(Date.now() + response.expires_in * 1000).toISOString(),
1009
+ started_at: new Date().toISOString(),
1010
+ suggested_room_id: room_id ?? currentRoom?.room_id ?? getRoomFromConfig() ?? undefined,
1011
+ });
1012
+ return {
1013
+ content: [
1014
+ {
1015
+ type: "text",
1016
+ text: JSON.stringify({
1017
+ success: true,
1018
+ ...pendingAuth,
1019
+ }, null, 2),
1020
+ },
1021
+ ],
1022
+ };
1023
+ });
1024
+ server.tool("poll_device_auth", "Poll a pending GitHub Device Flow request. On success this stores the LetAgents token locally and can optionally join a room immediately.", {
1025
+ request_id: z
1026
+ .string()
1027
+ .optional()
1028
+ .describe("The device auth request to poll. Defaults to the locally saved pending request."),
1029
+ room_id: z
1030
+ .string()
1031
+ .optional()
1032
+ .describe("Optional room to auto-join after authorization succeeds."),
1033
+ auto_join: z
1034
+ .boolean()
1035
+ .optional()
1036
+ .describe("If true, tries to join the room immediately after the auth succeeds."),
1037
+ }, async ({ request_id, room_id, auto_join }) => {
1038
+ const pendingAuth = request_id
1039
+ ? getPendingDeviceAuth()?.request_id === request_id
1040
+ ? getPendingDeviceAuth()
1041
+ : null
1042
+ : getPendingDeviceAuth();
1043
+ if (!pendingAuth && !request_id) {
1044
+ return {
1045
+ content: [
1046
+ {
1047
+ type: "text",
1048
+ text: JSON.stringify({ success: false, error: "No pending device auth request found." }, null, 2),
1049
+ },
1050
+ ],
1051
+ };
1052
+ }
1053
+ const requestId = request_id || pendingAuth?.request_id;
1054
+ if (!requestId) {
1055
+ return {
1056
+ content: [
1057
+ {
1058
+ type: "text",
1059
+ text: JSON.stringify({ success: false, error: "A request_id is required when nothing is saved locally." }, null, 2),
1060
+ },
1061
+ ],
1062
+ };
1063
+ }
1064
+ const result = await apiCall(`/auth/device/poll/${encodeURIComponent(requestId)}`);
1065
+ if (result.status === "pending" || result.status === "slow_down") {
1066
+ if (pendingAuth) {
1067
+ setPendingDeviceAuth({
1068
+ ...pendingAuth,
1069
+ interval_seconds: result.interval ?? pendingAuth.interval_seconds,
1070
+ expires_at: result.expires_in !== undefined
1071
+ ? new Date(Date.now() + result.expires_in * 1000).toISOString()
1072
+ : pendingAuth.expires_at,
1073
+ });
1074
+ }
1075
+ return {
1076
+ content: [
1077
+ {
1078
+ type: "text",
1079
+ text: JSON.stringify({ success: true, ...result }, null, 2),
1080
+ },
1081
+ ],
1082
+ };
1083
+ }
1084
+ if (result.status === "denied" || result.status === "expired") {
1085
+ clearPendingDeviceAuth();
1086
+ clearStoredAuth();
1087
+ return {
1088
+ content: [
1089
+ {
1090
+ type: "text",
1091
+ text: JSON.stringify({ success: false, ...result }, null, 2),
1092
+ },
1093
+ ],
1094
+ };
1095
+ }
1096
+ if (!result.letagents_token) {
1097
+ throw new Error("Device auth completed without a LetAgents token.");
1098
+ }
1099
+ clearPendingDeviceAuth();
1100
+ const storedAuth = setStoredAuth({
1101
+ token: result.letagents_token,
1102
+ expires_at: result.expires_at,
1103
+ account: result.account,
1104
+ stored_at: new Date().toISOString(),
1105
+ source: "device_flow",
1106
+ });
1107
+ let joinedRoom = null;
1108
+ const roomToJoin = room_id ||
1109
+ pendingAuth?.suggested_room_id ||
1110
+ currentRoom?.room_id ||
1111
+ getRoomFromConfig() ||
1112
+ undefined;
1113
+ if (auto_join && roomToJoin) {
1114
+ const joinedVia = looksLikeInviteCode(roomToJoin) ? "join_code" : "join_room";
1115
+ const joined = await joinRoomIdentifier(roomToJoin, joinedVia);
1116
+ joinedRoom = joined.room;
1117
+ await autoRegisterAgentIdentity();
1118
+ }
1119
+ return {
1120
+ content: [
1121
+ {
1122
+ type: "text",
1123
+ text: JSON.stringify({
1124
+ success: true,
1125
+ status: "authorized",
1126
+ account: storedAuth.account ?? null,
1127
+ expires_at: storedAuth.expires_at ?? null,
1128
+ auto_joined_room: joinedRoom,
1129
+ }, null, 2),
1130
+ },
1131
+ ],
1132
+ };
1133
+ });
1134
+ server.tool("clear_saved_auth", "Clear any locally saved LetAgents auth token and pending device auth request.", {}, async () => {
1135
+ clearPendingDeviceAuth();
1136
+ clearStoredAuth();
1137
+ return {
1138
+ content: [
1139
+ {
1140
+ type: "text",
1141
+ text: JSON.stringify({
1142
+ success: true,
1143
+ env_token_still_present: Boolean(process.env.LETAGENTS_TOKEN),
1144
+ }, null, 2),
1145
+ },
1146
+ ],
1147
+ };
1148
+ });
1149
+ server.tool("resume_room_session", "Rejoin the last locally saved room context, or a specific saved room, after a restart. This recreates participation in the room; it does not preserve a prior server-side session ID.", {
1150
+ room_id: z
1151
+ .string()
1152
+ .optional()
1153
+ .describe("Optional saved room ID to resume. Defaults to the last current room."),
1154
+ }, async ({ room_id }) => {
1155
+ const savedRoom = (room_id ? getStoredRoomSession(room_id) : null) ??
1156
+ getStoredCurrentRoom();
1157
+ if (!savedRoom) {
1158
+ return {
1159
+ content: [
1160
+ {
1161
+ type: "text",
1162
+ text: JSON.stringify({ success: false, error: "No saved room session found." }, null, 2),
1163
+ },
1164
+ ],
1165
+ };
1166
+ }
1167
+ const joined = await joinRoomIdentifier(savedRoom.room_id, savedRoom.joined_via);
1168
+ await autoRegisterAgentIdentity();
1169
+ return {
1170
+ content: [
1171
+ {
1172
+ type: "text",
1173
+ text: JSON.stringify({
1174
+ success: true,
1175
+ rejoined_from_local_state: true,
1176
+ server_session_resumed: false,
1177
+ last_message_id_before_restart: savedRoom.last_message_id ?? null,
1178
+ room: joined.room,
1179
+ }, null, 2),
1180
+ },
1181
+ ],
1182
+ };
1183
+ });
1184
+ // -- check_repo_visibility --------------------------------------------------
1185
+ server.tool("check_repo_visibility", "Auto-detect the current repo's git remote and check if it's public or private. Returns the canonical key, provider, visibility, and suggested room type (discoverable for public, invite for private/unknown). Useful for deciding whether to auto-join a discoverable room or create an invite room.", {
1186
+ cwd: z
1187
+ .string()
1188
+ .optional()
1189
+ .describe("Working directory to detect git remote from. Defaults to the MCP server's working directory."),
1190
+ }, async ({ cwd }) => {
1191
+ const { autoDetectRepo } = await import("./repo-visibility.js");
1192
+ const result = await autoDetectRepo(cwd);
1193
+ if (!result) {
1194
+ return {
1195
+ content: [
1196
+ {
1197
+ type: "text",
1198
+ text: JSON.stringify({
1199
+ error: "Not in a git repository or no remote configured",
1200
+ suggestion: "Use create_project to create an invite room instead",
1201
+ }, null, 2),
1202
+ },
1203
+ ],
1204
+ };
1205
+ }
619
1206
  return {
620
1207
  content: [
621
1208
  {
@@ -631,29 +1218,32 @@ server.tool("wait_for_messages", "Wait for new messages in a Let Agents Chat pro
631
1218
  async function main() {
632
1219
  const transport = new StdioServerTransport();
633
1220
  await server.connect(transport);
634
- console.error("🔌 Let Agents Chat MCP server running on stdio (v0.3.1)");
1221
+ console.error("🔌 Let Agents Chat MCP server running on stdio (v0.6.0)");
635
1222
  // --- Auto-join from repo context ---
636
1223
  try {
637
1224
  // 1. Try .letagents.json config
638
1225
  const configRoom = getRoomFromConfig();
639
1226
  if (configRoom) {
640
- const project = await apiCall(`/projects/room/${encodeURIComponent(configRoom)}`, { method: "POST" });
641
- currentRoom = { room: configRoom, project_id: project.id, code: project.code, joined_via: "config" };
642
- sseClient.subscribe(project.id, (_message) => { server.server.sendResourceListChanged(); });
1227
+ await joinRoomIdentifier(configRoom, "config");
643
1228
  console.error(`🏠 Auto-joined room '${configRoom}' (from .letagents.json)`);
644
1229
  return;
645
1230
  }
646
1231
  // 2. Try git remote URL
647
1232
  const gitRoom = getGitRemoteIdentity();
648
1233
  if (gitRoom) {
649
- const project = await apiCall(`/projects/room/${encodeURIComponent(gitRoom)}`, { method: "POST" });
650
- currentRoom = { room: gitRoom, project_id: project.id, code: project.code, joined_via: "git-remote" };
651
- sseClient.subscribe(project.id, (_message) => { server.server.sendResourceListChanged(); });
1234
+ await joinRoomIdentifier(gitRoom, "git-remote");
652
1235
  console.error(`🏠 Auto-joined room '${gitRoom}' (inferred from git remote — consider adding a .letagents.json)`);
653
1236
  return;
654
1237
  }
655
- // 3. No context found
656
- console.error("â„šī¸ No .letagents.json or git remote found — use join_project or join_room to connect.");
1238
+ // 3. Fall back to the most recent saved room session
1239
+ const savedCurrentRoom = getStoredCurrentRoom();
1240
+ if (savedCurrentRoom) {
1241
+ await joinRoomIdentifier(savedCurrentRoom.room_id, savedCurrentRoom.joined_via);
1242
+ console.error(`🏠 Rejoined saved room '${savedCurrentRoom.room_id}' (from local state)`);
1243
+ return;
1244
+ }
1245
+ // 4. No context found
1246
+ console.error("â„šī¸ No .letagents.json, git remote, or saved room found — use join_project or join_room to connect.");
657
1247
  }
658
1248
  catch (err) {
659
1249
  // Auto-join failure should never block the MCP server