letagents 0.6.0 â 0.8.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 +47 -12
- package/dist/mcp/local-state.js +138 -0
- package/dist/mcp/repo-visibility.js +204 -0
- package/dist/mcp/room-id.js +12 -0
- package/dist/mcp/server.js +769 -133
- package/dist/mcp/sse-client.js +46 -16
- package/package.json +8 -3
package/dist/mcp/server.js
CHANGED
|
@@ -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,282 @@ 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
|
-
|
|
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 toPublicRoomState(state) {
|
|
119
|
+
if (!state) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
room_id: state.room_id,
|
|
124
|
+
code: state.code ?? null,
|
|
125
|
+
joined_via: state.joined_via,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function toPublicStoredRoomSession(session) {
|
|
129
|
+
if (!session) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
room_id: session.room_id,
|
|
134
|
+
code: session.code ?? null,
|
|
135
|
+
joined_via: session.joined_via,
|
|
136
|
+
joined_at: session.joined_at,
|
|
137
|
+
last_seen_at: session.last_seen_at,
|
|
138
|
+
last_message_id: session.last_message_id ?? null,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function toPublicRoomResponse(response, fallbackRoomId) {
|
|
142
|
+
const { id: _legacyId, project_id: _legacyProjectId, ...rest } = response;
|
|
143
|
+
return {
|
|
144
|
+
...rest,
|
|
145
|
+
room_id: typeof rest.room_id === "string" ? rest.room_id : fallbackRoomId,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
function rememberRoom(state, lastMessageId) {
|
|
149
|
+
currentRoom = state;
|
|
150
|
+
saveRoomSession({
|
|
151
|
+
room_id: state.room_id,
|
|
152
|
+
project_id: state.project_id ?? null,
|
|
153
|
+
code: state.code ?? null,
|
|
154
|
+
joined_via: state.joined_via,
|
|
155
|
+
last_message_id: lastMessageId,
|
|
156
|
+
});
|
|
157
|
+
sseClient.unsubscribeAll();
|
|
158
|
+
sseClient.subscribe({
|
|
159
|
+
roomId: state.room_id,
|
|
160
|
+
projectId: state.project_id ?? null,
|
|
161
|
+
}, (_message) => {
|
|
162
|
+
touchRoomSession(state.room_id);
|
|
163
|
+
server.server.sendResourceListChanged();
|
|
164
|
+
});
|
|
165
|
+
return state;
|
|
166
|
+
}
|
|
167
|
+
function touchCurrentRoom(lastMessageId) {
|
|
168
|
+
if (!currentRoom) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
touchRoomSession(currentRoom.room_id, lastMessageId);
|
|
172
|
+
}
|
|
173
|
+
function getTargetRoomId(roomId) {
|
|
174
|
+
return roomId || currentRoom?.room_id || null;
|
|
175
|
+
}
|
|
176
|
+
function getFallbackProjectId() {
|
|
177
|
+
return currentRoom?.project_id ?? null;
|
|
178
|
+
}
|
|
179
|
+
function getLastMessageId(payload) {
|
|
180
|
+
if (!payload || typeof payload !== "object") {
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
const messages = payload.messages;
|
|
184
|
+
const lastMessage = messages?.at(-1);
|
|
185
|
+
return typeof lastMessage?.id === "string" ? lastMessage.id : undefined;
|
|
186
|
+
}
|
|
187
|
+
async function roomScopedApiCall(input) {
|
|
188
|
+
if (input.room_id) {
|
|
189
|
+
try {
|
|
190
|
+
const result = await apiCall(input.room_path(input.room_id), input.options);
|
|
191
|
+
touchRoomSession(input.room_id, getLastMessageId(result));
|
|
192
|
+
return result;
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
if (!input.project_id || !isMissingRouteError(error)) {
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (!input.project_id) {
|
|
201
|
+
throw new Error("No room is available for this request.");
|
|
202
|
+
}
|
|
203
|
+
const result = await apiCall(input.project_path(input.project_id), input.options);
|
|
204
|
+
if (input.room_id) {
|
|
205
|
+
touchRoomSession(input.room_id, getLastMessageId(result));
|
|
206
|
+
}
|
|
207
|
+
return result;
|
|
208
|
+
}
|
|
209
|
+
async function joinRoomIdentifier(identifier, joinedVia) {
|
|
210
|
+
const roomId = joinedVia === "join_code" ? normalizeInviteCode(identifier) : identifier.trim();
|
|
211
|
+
try {
|
|
212
|
+
const response = await apiCall(`/rooms/${encodeRoomIdPath(roomId)}/join`, { method: "POST" });
|
|
213
|
+
const joinedRoomId = typeof response.room_id === "string"
|
|
214
|
+
? response.room_id
|
|
215
|
+
: roomId;
|
|
216
|
+
const room = rememberRoom(toRoomState({
|
|
217
|
+
room_id: joinedRoomId,
|
|
218
|
+
project_id: typeof response.project_id === "string" ? response.project_id : null,
|
|
219
|
+
code: typeof response.code === "string"
|
|
220
|
+
? response.code
|
|
221
|
+
: looksLikeInviteCode(joinedRoomId)
|
|
222
|
+
? joinedRoomId
|
|
223
|
+
: null,
|
|
224
|
+
joined_via: joinedVia,
|
|
225
|
+
}));
|
|
226
|
+
return {
|
|
227
|
+
room,
|
|
228
|
+
response: { ...response, room_id: joinedRoomId },
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
if (!isMissingRouteError(error)) {
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (joinedVia === "join_code") {
|
|
237
|
+
const project = await apiCall(`/projects/join/${encodeURIComponent(roomId)}`);
|
|
238
|
+
const legacyRoomId = typeof project.code === "string"
|
|
239
|
+
? project.code
|
|
240
|
+
: roomId;
|
|
241
|
+
const room = rememberRoom(toRoomState({
|
|
242
|
+
room_id: legacyRoomId,
|
|
243
|
+
project_id: typeof project.id === "string" ? project.id : null,
|
|
244
|
+
code: typeof project.code === "string" ? project.code : legacyRoomId,
|
|
245
|
+
joined_via: joinedVia,
|
|
246
|
+
}));
|
|
247
|
+
return {
|
|
248
|
+
room,
|
|
249
|
+
response: {
|
|
250
|
+
...project,
|
|
251
|
+
room_id: legacyRoomId,
|
|
252
|
+
project_id: typeof project.id === "string" ? project.id : null,
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
const project = await apiCall(`/projects/room/${encodeURIComponent(roomId)}`, { method: "POST" });
|
|
257
|
+
const legacyRoomId = typeof project.name === "string" && project.name.trim()
|
|
258
|
+
? project.name
|
|
259
|
+
: typeof project.code === "string" && project.code.trim()
|
|
260
|
+
? project.code
|
|
261
|
+
: roomId;
|
|
262
|
+
const room = rememberRoom(toRoomState({
|
|
263
|
+
room_id: legacyRoomId,
|
|
264
|
+
project_id: typeof project.id === "string" ? project.id : null,
|
|
265
|
+
code: typeof project.code === "string"
|
|
266
|
+
? project.code
|
|
267
|
+
: looksLikeInviteCode(legacyRoomId)
|
|
268
|
+
? legacyRoomId
|
|
269
|
+
: null,
|
|
270
|
+
joined_via: joinedVia,
|
|
271
|
+
}));
|
|
272
|
+
return {
|
|
273
|
+
room,
|
|
274
|
+
response: {
|
|
275
|
+
...project,
|
|
276
|
+
room_id: legacyRoomId,
|
|
277
|
+
project_id: typeof project.id === "string" ? project.id : null,
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
async function createInviteRoom() {
|
|
282
|
+
const project = await apiCall("/projects", { method: "POST" });
|
|
283
|
+
const roomId = typeof project.code === "string"
|
|
284
|
+
? project.code
|
|
285
|
+
: typeof project.id === "string"
|
|
286
|
+
? project.id
|
|
287
|
+
: "unknown-room";
|
|
288
|
+
const room = rememberRoom(toRoomState({
|
|
289
|
+
room_id: roomId,
|
|
290
|
+
project_id: typeof project.id === "string" ? project.id : null,
|
|
291
|
+
code: typeof project.code === "string" ? project.code : roomId,
|
|
292
|
+
joined_via: "join_code",
|
|
293
|
+
}));
|
|
294
|
+
await autoRegisterAgentIdentity();
|
|
295
|
+
return {
|
|
296
|
+
room,
|
|
297
|
+
response: toPublicRoomResponse(project, roomId),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
async function joinInviteCode(code) {
|
|
301
|
+
const joined = await joinRoomIdentifier(code, "join_code");
|
|
302
|
+
await autoRegisterAgentIdentity();
|
|
303
|
+
return {
|
|
304
|
+
...toPublicRoomResponse(joined.response, joined.room.room_id),
|
|
305
|
+
joined_via: "join_code",
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
async function joinNamedRoom(name) {
|
|
309
|
+
const joined = await joinRoomIdentifier(name, "join_room");
|
|
310
|
+
await autoRegisterAgentIdentity();
|
|
311
|
+
return {
|
|
312
|
+
...toPublicRoomResponse(joined.response, joined.room.room_id),
|
|
313
|
+
joined_via: "join_room",
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
async function autoRegisterAgentIdentity() {
|
|
317
|
+
if (!AGENT_NAME || !currentRoom) {
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
await apiCall("/agents", {
|
|
322
|
+
method: "POST",
|
|
323
|
+
body: JSON.stringify({
|
|
324
|
+
name: AGENT_NAME,
|
|
325
|
+
display_name: AGENT_DISPLAY_NAME || AGENT_NAME,
|
|
326
|
+
owner_label: AGENT_OWNER_LABEL || undefined,
|
|
327
|
+
}),
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
catch (error) {
|
|
331
|
+
console.error("Agent identity registration failed:", error);
|
|
63
332
|
}
|
|
64
|
-
return res.json();
|
|
65
333
|
}
|
|
66
334
|
// ---------------------------------------------------------------------------
|
|
67
335
|
// MCP Server
|
|
@@ -70,14 +338,22 @@ const server = new McpServer({
|
|
|
70
338
|
name: "letagents",
|
|
71
339
|
version: "0.2.0",
|
|
72
340
|
});
|
|
73
|
-
const sseClient = new SseClient(API_URL);
|
|
341
|
+
const sseClient = new SseClient(API_URL, () => getLetagentsToken());
|
|
74
342
|
// ---------------------------------------------------------------------------
|
|
75
343
|
// MCP Resources
|
|
76
344
|
// ---------------------------------------------------------------------------
|
|
77
|
-
server.resource("
|
|
345
|
+
server.resource("room_messages", new ResourceTemplate("letagents://rooms/{room_id}/messages", {
|
|
78
346
|
list: undefined,
|
|
79
|
-
}), async (uri, {
|
|
80
|
-
const
|
|
347
|
+
}), async (uri, { room_id }) => {
|
|
348
|
+
const normalizedRoomId = String(room_id);
|
|
349
|
+
const storedSession = getStoredRoomSession(normalizedRoomId) ??
|
|
350
|
+
(currentRoom?.room_id === normalizedRoomId ? getStoredCurrentRoom() : null);
|
|
351
|
+
const result = await roomScopedApiCall({
|
|
352
|
+
room_id: normalizedRoomId,
|
|
353
|
+
project_id: storedSession?.project_id ?? null,
|
|
354
|
+
room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/messages`,
|
|
355
|
+
project_path: (projectId) => `/projects/${encodeURIComponent(projectId)}/messages`,
|
|
356
|
+
});
|
|
81
357
|
return {
|
|
82
358
|
contents: [
|
|
83
359
|
{
|
|
@@ -88,43 +364,52 @@ server.resource("project_messages", new ResourceTemplate("letagents://projects/{
|
|
|
88
364
|
],
|
|
89
365
|
};
|
|
90
366
|
});
|
|
367
|
+
// -- create_room ------------------------------------------------------------
|
|
368
|
+
server.tool("create_room", "Create a new invite room on Let Agents Chat. Returns the room ID and join code.", {}, async () => {
|
|
369
|
+
const created = await createInviteRoom();
|
|
370
|
+
return {
|
|
371
|
+
content: [
|
|
372
|
+
{
|
|
373
|
+
type: "text",
|
|
374
|
+
text: JSON.stringify(created.response, null, 2),
|
|
375
|
+
},
|
|
376
|
+
],
|
|
377
|
+
};
|
|
378
|
+
});
|
|
91
379
|
// -- create_project ---------------------------------------------------------
|
|
92
|
-
server.tool("create_project", "
|
|
93
|
-
const
|
|
94
|
-
// Auto-subscribe to SSE for this project
|
|
95
|
-
sseClient.subscribe(project.id, (_message) => {
|
|
96
|
-
server.server.sendResourceListChanged();
|
|
97
|
-
});
|
|
380
|
+
server.tool("create_project", "Legacy alias for create_room. Creates a new invite room and returns its join code.", {}, async () => {
|
|
381
|
+
const created = await createInviteRoom();
|
|
98
382
|
return {
|
|
99
383
|
content: [
|
|
100
384
|
{
|
|
101
385
|
type: "text",
|
|
102
|
-
text: JSON.stringify(
|
|
386
|
+
text: JSON.stringify(created.response, null, 2),
|
|
103
387
|
},
|
|
104
388
|
],
|
|
105
389
|
};
|
|
106
390
|
});
|
|
107
|
-
// --
|
|
108
|
-
server.tool("
|
|
109
|
-
code: z.string().describe("The
|
|
391
|
+
// -- join_code --------------------------------------------------------------
|
|
392
|
+
server.tool("join_code", "Join an existing room using an invite code.", {
|
|
393
|
+
code: z.string().describe("The invite code shared for the room (e.g. 'ABCX-7291')"),
|
|
110
394
|
}, async ({ code }) => {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
395
|
+
return {
|
|
396
|
+
content: [
|
|
397
|
+
{
|
|
398
|
+
type: "text",
|
|
399
|
+
text: JSON.stringify(await joinInviteCode(code), null, 2),
|
|
400
|
+
},
|
|
401
|
+
],
|
|
118
402
|
};
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
403
|
+
});
|
|
404
|
+
// -- join_project -----------------------------------------------------------
|
|
405
|
+
server.tool("join_project", "Legacy alias for join_code. Join an existing room using an invite code.", {
|
|
406
|
+
code: z.string().describe("The invite code shared for the room (e.g. 'ABCX-7291')"),
|
|
407
|
+
}, async ({ code }) => {
|
|
123
408
|
return {
|
|
124
409
|
content: [
|
|
125
410
|
{
|
|
126
411
|
type: "text",
|
|
127
|
-
text: JSON.stringify(
|
|
412
|
+
text: JSON.stringify(await joinInviteCode(code), null, 2),
|
|
128
413
|
},
|
|
129
414
|
],
|
|
130
415
|
};
|
|
@@ -133,23 +418,11 @@ server.tool("join_project", "Join an existing Let Agents Chat project using a jo
|
|
|
133
418
|
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
419
|
name: z.string().describe("The room name to join (e.g. 'github.com/owner/repo')"),
|
|
135
420
|
}, 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
|
-
});
|
|
148
421
|
return {
|
|
149
422
|
content: [
|
|
150
423
|
{
|
|
151
424
|
type: "text",
|
|
152
|
-
text: JSON.stringify(
|
|
425
|
+
text: JSON.stringify(await joinNamedRoom(name), null, 2),
|
|
153
426
|
},
|
|
154
427
|
],
|
|
155
428
|
};
|
|
@@ -170,7 +443,17 @@ server.tool("get_current_room", "Get information about the currently joined room
|
|
|
170
443
|
content: [
|
|
171
444
|
{
|
|
172
445
|
type: "text",
|
|
173
|
-
text: JSON.stringify({
|
|
446
|
+
text: JSON.stringify({
|
|
447
|
+
connected: true,
|
|
448
|
+
...toPublicRoomState(currentRoom),
|
|
449
|
+
auth: getStoredAuth()
|
|
450
|
+
? {
|
|
451
|
+
source: process.env.LETAGENTS_TOKEN ? "env" : "local_state",
|
|
452
|
+
expires_at: getStoredAuth()?.expires_at ?? null,
|
|
453
|
+
account: getStoredAuth()?.account ?? null,
|
|
454
|
+
}
|
|
455
|
+
: null,
|
|
456
|
+
}, null, 2),
|
|
174
457
|
},
|
|
175
458
|
],
|
|
176
459
|
};
|
|
@@ -209,11 +492,11 @@ server.tool("check_repo", "Inspect the current repository context for Let Agents
|
|
|
209
492
|
config_file: configPath ?? null,
|
|
210
493
|
config_contents: configContents,
|
|
211
494
|
derived_room_from_git: derivedRoom ?? null,
|
|
212
|
-
current_room: currentRoom
|
|
495
|
+
current_room: toPublicRoomState(currentRoom),
|
|
213
496
|
join_hint: !currentRoom
|
|
214
497
|
? repoRoot
|
|
215
|
-
? "Run initialize_repo to set up .letagents.json, or join_room/
|
|
216
|
-
: "Not inside a git repo. Use
|
|
498
|
+
? "Run initialize_repo to set up .letagents.json, or use join_room/join_code to connect."
|
|
499
|
+
: "Not inside a git repo. Use create_room, join_code, or join_room to connect manually."
|
|
217
500
|
: null,
|
|
218
501
|
}, null, 2),
|
|
219
502
|
},
|
|
@@ -227,21 +510,22 @@ server.tool("post_status", "Broadcast a lightweight status update to the current
|
|
|
227
510
|
"Status updates are distinct from chat messages and can be filtered separately.", {
|
|
228
511
|
sender: z.string().describe("Name of the agent posting the status (e.g. 'codex-agent')"),
|
|
229
512
|
status: z.string().describe("Short status description (e.g. 'reviewing PR #2', 'idle', 'thinking...')"),
|
|
230
|
-
|
|
513
|
+
room_id: z
|
|
231
514
|
.string()
|
|
232
515
|
.optional()
|
|
233
|
-
.describe("
|
|
234
|
-
}, async ({ sender, status,
|
|
235
|
-
const
|
|
236
|
-
|
|
516
|
+
.describe("Canonical room ID. Defaults to the current room."),
|
|
517
|
+
}, async ({ sender, status, room_id }) => {
|
|
518
|
+
const targetRoomId = getTargetRoomId(room_id);
|
|
519
|
+
const targetProjectId = getFallbackProjectId();
|
|
520
|
+
if (!targetRoomId && !targetProjectId) {
|
|
237
521
|
return {
|
|
238
522
|
content: [
|
|
239
523
|
{
|
|
240
524
|
type: "text",
|
|
241
525
|
text: JSON.stringify({
|
|
242
526
|
success: false,
|
|
243
|
-
error: "No
|
|
244
|
-
hint: "Join a room first
|
|
527
|
+
error: "No room_id provided and not currently in a room.",
|
|
528
|
+
hint: "Join or create a room first, or pass room_id explicitly.",
|
|
245
529
|
}, null, 2),
|
|
246
530
|
},
|
|
247
531
|
],
|
|
@@ -250,10 +534,17 @@ server.tool("post_status", "Broadcast a lightweight status update to the current
|
|
|
250
534
|
// Status messages use a reserved prefix so the UI (and agents) can distinguish
|
|
251
535
|
// them from normal chat messages without changing the data model.
|
|
252
536
|
const statusText = `[status] ${status}`;
|
|
253
|
-
const message = await
|
|
254
|
-
|
|
255
|
-
|
|
537
|
+
const message = await roomScopedApiCall({
|
|
538
|
+
room_id: targetRoomId,
|
|
539
|
+
project_id: targetProjectId,
|
|
540
|
+
room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/messages`,
|
|
541
|
+
project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/messages`,
|
|
542
|
+
options: {
|
|
543
|
+
method: "POST",
|
|
544
|
+
body: JSON.stringify({ sender, text: statusText }),
|
|
545
|
+
},
|
|
256
546
|
});
|
|
547
|
+
touchCurrentRoom(typeof message.id === "string" ? message.id : undefined);
|
|
257
548
|
return {
|
|
258
549
|
content: [
|
|
259
550
|
{
|
|
@@ -262,8 +553,8 @@ server.tool("post_status", "Broadcast a lightweight status update to the current
|
|
|
262
553
|
success: true,
|
|
263
554
|
status_posted: status,
|
|
264
555
|
sender,
|
|
265
|
-
message_id: message.id,
|
|
266
|
-
timestamp: message.timestamp,
|
|
556
|
+
message_id: typeof message.id === "string" ? message.id : null,
|
|
557
|
+
timestamp: typeof message.timestamp === "string" ? message.timestamp : null,
|
|
267
558
|
}, null, 2),
|
|
268
559
|
},
|
|
269
560
|
],
|
|
@@ -274,7 +565,7 @@ const TASK_STATUSES = [
|
|
|
274
565
|
"proposed", "accepted", "assigned", "in_progress",
|
|
275
566
|
"blocked", "in_review", "merged", "done", "cancelled",
|
|
276
567
|
];
|
|
277
|
-
server.tool("add_task", "Add a new task to the
|
|
568
|
+
server.tool("add_task", "Add a new task to the room board. Tasks normally start as 'proposed' and must be " +
|
|
278
569
|
"accepted before an agent can claim them, but tasks created by trusted agents already " +
|
|
279
570
|
"active in the room may be auto-accepted. Use this when a human or agent identifies " +
|
|
280
571
|
"work that needs to be done.", {
|
|
@@ -282,31 +573,39 @@ server.tool("add_task", "Add a new task to the project board. Tasks normally sta
|
|
|
282
573
|
description: z.string().optional().describe("Longer description of what needs to be done"),
|
|
283
574
|
created_by: z.string().describe("Name of the agent or human creating the task"),
|
|
284
575
|
source_message_id: z.string().optional().describe("Optional message ID where task was agreed, e.g. 'msg_42'"),
|
|
285
|
-
|
|
286
|
-
}, async ({ title, description, created_by, source_message_id,
|
|
287
|
-
const
|
|
288
|
-
|
|
576
|
+
room_id: z.string().optional().describe("Canonical room ID. Defaults to current room."),
|
|
577
|
+
}, async ({ title, description, created_by, source_message_id, room_id }) => {
|
|
578
|
+
const targetRoomId = getTargetRoomId(room_id);
|
|
579
|
+
const targetProjectId = getFallbackProjectId();
|
|
580
|
+
if (!targetRoomId && !targetProjectId) {
|
|
289
581
|
return {
|
|
290
582
|
content: [{ type: "text", text: JSON.stringify({ success: false, error: "Not in a room. Join one first." }) }],
|
|
291
583
|
};
|
|
292
584
|
}
|
|
293
|
-
const task = await
|
|
294
|
-
|
|
295
|
-
|
|
585
|
+
const task = await roomScopedApiCall({
|
|
586
|
+
room_id: targetRoomId,
|
|
587
|
+
project_id: targetProjectId,
|
|
588
|
+
room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/tasks`,
|
|
589
|
+
project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/tasks`,
|
|
590
|
+
options: {
|
|
591
|
+
method: "POST",
|
|
592
|
+
body: JSON.stringify({ title, description, created_by, source_message_id }),
|
|
593
|
+
},
|
|
296
594
|
});
|
|
297
595
|
return {
|
|
298
596
|
content: [{ type: "text", text: JSON.stringify({ success: true, task }, null, 2) }],
|
|
299
597
|
};
|
|
300
598
|
});
|
|
301
|
-
server.tool("get_board", "Get the current task board for the
|
|
599
|
+
server.tool("get_board", "Get the current task board for the room. By default shows only open tasks " +
|
|
302
600
|
"(not done/cancelled). Agents should check this on startup and when idle to " +
|
|
303
601
|
"see if there is unassigned work to claim.", {
|
|
304
602
|
status: z.enum(TASK_STATUSES).optional().describe("Filter by specific status"),
|
|
305
603
|
open_only: z.boolean().optional().describe("If true (default), only show tasks not done/cancelled"),
|
|
306
|
-
|
|
307
|
-
}, async ({ status, open_only,
|
|
308
|
-
const
|
|
309
|
-
|
|
604
|
+
room_id: z.string().optional().describe("Canonical room ID. Defaults to current room."),
|
|
605
|
+
}, async ({ status, open_only, room_id }) => {
|
|
606
|
+
const targetRoomId = getTargetRoomId(room_id);
|
|
607
|
+
const targetProjectId = getFallbackProjectId();
|
|
608
|
+
if (!targetRoomId && !targetProjectId) {
|
|
310
609
|
return {
|
|
311
610
|
content: [{ type: "text", text: JSON.stringify({ success: false, error: "Not in a room. Join one first." }) }],
|
|
312
611
|
};
|
|
@@ -317,7 +616,12 @@ server.tool("get_board", "Get the current task board for the project. By default
|
|
|
317
616
|
if (open_only !== false)
|
|
318
617
|
params.set("open", "true");
|
|
319
618
|
const qs = params.toString();
|
|
320
|
-
const result = await
|
|
619
|
+
const result = await roomScopedApiCall({
|
|
620
|
+
room_id: targetRoomId,
|
|
621
|
+
project_id: targetProjectId,
|
|
622
|
+
room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/tasks${qs ? `?${qs}` : ""}`,
|
|
623
|
+
project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/tasks${qs ? `?${qs}` : ""}`,
|
|
624
|
+
});
|
|
321
625
|
return {
|
|
322
626
|
content: [{ type: "text", text: JSON.stringify({ success: true, ...result }, null, 2) }],
|
|
323
627
|
};
|
|
@@ -327,18 +631,25 @@ server.tool("claim_task", "Claim an accepted task. The task must be in 'accepted
|
|
|
327
631
|
"Do NOT claim proposed tasks â they need to be accepted first.", {
|
|
328
632
|
task_id: z.string().describe("The task ID to claim, e.g. 'task_1'"),
|
|
329
633
|
assignee: z.string().describe("Your agent name, e.g. 'antigravity'"),
|
|
330
|
-
|
|
331
|
-
}, async ({ task_id, assignee,
|
|
332
|
-
const
|
|
333
|
-
|
|
634
|
+
room_id: z.string().optional().describe("Canonical room ID. Defaults to current room."),
|
|
635
|
+
}, async ({ task_id, assignee, room_id }) => {
|
|
636
|
+
const targetRoomId = getTargetRoomId(room_id);
|
|
637
|
+
const targetProjectId = getFallbackProjectId();
|
|
638
|
+
if (!targetRoomId && !targetProjectId) {
|
|
334
639
|
return {
|
|
335
640
|
content: [{ type: "text", text: JSON.stringify({ success: false, error: "Not in a room." }) }],
|
|
336
641
|
};
|
|
337
642
|
}
|
|
338
643
|
try {
|
|
339
|
-
const updated = await
|
|
340
|
-
|
|
341
|
-
|
|
644
|
+
const updated = await roomScopedApiCall({
|
|
645
|
+
room_id: targetRoomId,
|
|
646
|
+
project_id: targetProjectId,
|
|
647
|
+
room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/tasks/${encodeURIComponent(task_id)}`,
|
|
648
|
+
project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/tasks/${encodeURIComponent(task_id)}`,
|
|
649
|
+
options: {
|
|
650
|
+
method: "PATCH",
|
|
651
|
+
body: JSON.stringify({ status: "assigned", assignee }),
|
|
652
|
+
},
|
|
342
653
|
});
|
|
343
654
|
return {
|
|
344
655
|
content: [{ type: "text", text: JSON.stringify({ success: true, task: updated }, null, 2) }],
|
|
@@ -357,18 +668,25 @@ server.tool("update_task", "Update a task's status or assignee. Status transitio
|
|
|
357
668
|
status: z.enum(TASK_STATUSES).optional().describe("New status for the task"),
|
|
358
669
|
assignee: z.string().optional().describe("New assignee for the task"),
|
|
359
670
|
pr_url: z.string().optional().describe("PR URL to link to the task"),
|
|
360
|
-
|
|
361
|
-
}, async ({ task_id, status, assignee, pr_url,
|
|
362
|
-
const
|
|
363
|
-
|
|
671
|
+
room_id: z.string().optional().describe("Canonical room ID. Defaults to current room."),
|
|
672
|
+
}, async ({ task_id, status, assignee, pr_url, room_id }) => {
|
|
673
|
+
const targetRoomId = getTargetRoomId(room_id);
|
|
674
|
+
const targetProjectId = getFallbackProjectId();
|
|
675
|
+
if (!targetRoomId && !targetProjectId) {
|
|
364
676
|
return {
|
|
365
677
|
content: [{ type: "text", text: JSON.stringify({ success: false, error: "Not in a room." }) }],
|
|
366
678
|
};
|
|
367
679
|
}
|
|
368
680
|
try {
|
|
369
|
-
const updated = await
|
|
370
|
-
|
|
371
|
-
|
|
681
|
+
const updated = await roomScopedApiCall({
|
|
682
|
+
room_id: targetRoomId,
|
|
683
|
+
project_id: targetProjectId,
|
|
684
|
+
room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/tasks/${encodeURIComponent(task_id)}`,
|
|
685
|
+
project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/tasks/${encodeURIComponent(task_id)}`,
|
|
686
|
+
options: {
|
|
687
|
+
method: "PATCH",
|
|
688
|
+
body: JSON.stringify({ status, assignee, pr_url }),
|
|
689
|
+
},
|
|
372
690
|
});
|
|
373
691
|
return {
|
|
374
692
|
content: [{ type: "text", text: JSON.stringify({ success: true, task: updated }, null, 2) }],
|
|
@@ -385,18 +703,25 @@ server.tool("complete_task", "Submit a task for review. Moves the task to 'in_re
|
|
|
385
703
|
"the work is merged before it can be marked done.", {
|
|
386
704
|
task_id: z.string().describe("The task ID to submit for review"),
|
|
387
705
|
pr_url: z.string().optional().describe("GitHub PR URL for the work"),
|
|
388
|
-
|
|
389
|
-
}, async ({ task_id, pr_url,
|
|
390
|
-
const
|
|
391
|
-
|
|
706
|
+
room_id: z.string().optional().describe("Canonical room ID. Defaults to current room."),
|
|
707
|
+
}, async ({ task_id, pr_url, room_id }) => {
|
|
708
|
+
const targetRoomId = getTargetRoomId(room_id);
|
|
709
|
+
const targetProjectId = getFallbackProjectId();
|
|
710
|
+
if (!targetRoomId && !targetProjectId) {
|
|
392
711
|
return {
|
|
393
712
|
content: [{ type: "text", text: JSON.stringify({ success: false, error: "Not in a room." }) }],
|
|
394
713
|
};
|
|
395
714
|
}
|
|
396
715
|
try {
|
|
397
|
-
const updated = await
|
|
398
|
-
|
|
399
|
-
|
|
716
|
+
const updated = await roomScopedApiCall({
|
|
717
|
+
room_id: targetRoomId,
|
|
718
|
+
project_id: targetProjectId,
|
|
719
|
+
room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/tasks/${encodeURIComponent(task_id)}`,
|
|
720
|
+
project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/tasks/${encodeURIComponent(task_id)}`,
|
|
721
|
+
options: {
|
|
722
|
+
method: "PATCH",
|
|
723
|
+
body: JSON.stringify({ status: "in_review", pr_url }),
|
|
724
|
+
},
|
|
400
725
|
});
|
|
401
726
|
return {
|
|
402
727
|
content: [{ type: "text", text: JSON.stringify({ success: true, task: updated }, null, 2) }],
|
|
@@ -516,16 +841,7 @@ server.tool("initialize_repo", "Initialize the current repo for Let Agents Chat
|
|
|
516
841
|
}
|
|
517
842
|
// Auto-join the room after creating config
|
|
518
843
|
try {
|
|
519
|
-
const
|
|
520
|
-
currentRoom = {
|
|
521
|
-
room: roomName,
|
|
522
|
-
project_id: project.id,
|
|
523
|
-
code: project.code,
|
|
524
|
-
joined_via: "config",
|
|
525
|
-
};
|
|
526
|
-
sseClient.subscribe(project.id, (_message) => {
|
|
527
|
-
server.server.sendResourceListChanged();
|
|
528
|
-
});
|
|
844
|
+
const joined = await joinRoomIdentifier(roomName, "config");
|
|
529
845
|
return {
|
|
530
846
|
content: [
|
|
531
847
|
{
|
|
@@ -533,9 +849,8 @@ server.tool("initialize_repo", "Initialize the current repo for Let Agents Chat
|
|
|
533
849
|
text: JSON.stringify({
|
|
534
850
|
success: true,
|
|
535
851
|
created: configPath,
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
code: project.code,
|
|
852
|
+
room_id: joined.room.room_id,
|
|
853
|
+
code: joined.room.code ?? null,
|
|
539
854
|
joined: true,
|
|
540
855
|
hint: "Consider adding .letagents.json to git so other contributors auto-join the same room.",
|
|
541
856
|
}, null, 2),
|
|
@@ -552,7 +867,7 @@ server.tool("initialize_repo", "Initialize the current repo for Let Agents Chat
|
|
|
552
867
|
text: JSON.stringify({
|
|
553
868
|
success: true,
|
|
554
869
|
created: configPath,
|
|
555
|
-
|
|
870
|
+
room_id: roomName,
|
|
556
871
|
joined: false,
|
|
557
872
|
error: `Config created but auto-join failed: ${err instanceof Error ? err.message : err}`,
|
|
558
873
|
hint: "The .letagents.json was created. Use join_room to manually connect.",
|
|
@@ -563,15 +878,27 @@ server.tool("initialize_repo", "Initialize the current repo for Let Agents Chat
|
|
|
563
878
|
}
|
|
564
879
|
});
|
|
565
880
|
// -- send_message -----------------------------------------------------------
|
|
566
|
-
server.tool("send_message", "Send a message to a Let Agents Chat
|
|
567
|
-
|
|
881
|
+
server.tool("send_message", "Send a message to a Let Agents Chat room.", {
|
|
882
|
+
room_id: z.string().optional().describe("Canonical room ID. Defaults to the current room."),
|
|
568
883
|
sender: z.string().describe("Name identifying the sending agent (e.g. 'antigravity-agent')"),
|
|
569
884
|
text: z.string().describe("The message text to send"),
|
|
570
|
-
}, async ({
|
|
571
|
-
const
|
|
572
|
-
|
|
573
|
-
|
|
885
|
+
}, async ({ room_id, sender, text }) => {
|
|
886
|
+
const targetRoomId = getTargetRoomId(room_id);
|
|
887
|
+
const targetProjectId = getFallbackProjectId();
|
|
888
|
+
if (!targetRoomId && !targetProjectId) {
|
|
889
|
+
throw new Error("No room is currently selected. Join a room first or pass room_id.");
|
|
890
|
+
}
|
|
891
|
+
const message = await roomScopedApiCall({
|
|
892
|
+
room_id: targetRoomId,
|
|
893
|
+
project_id: targetProjectId,
|
|
894
|
+
room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/messages`,
|
|
895
|
+
project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/messages`,
|
|
896
|
+
options: {
|
|
897
|
+
method: "POST",
|
|
898
|
+
body: JSON.stringify({ sender, text }),
|
|
899
|
+
},
|
|
574
900
|
});
|
|
901
|
+
touchCurrentRoom(typeof message.id === "string" ? message.id : undefined);
|
|
575
902
|
return {
|
|
576
903
|
content: [
|
|
577
904
|
{
|
|
@@ -582,10 +909,17 @@ server.tool("send_message", "Send a message to a Let Agents Chat project.", {
|
|
|
582
909
|
};
|
|
583
910
|
});
|
|
584
911
|
// -- read_messages ----------------------------------------------------------
|
|
585
|
-
server.tool("read_messages", "Read all messages from a Let Agents Chat
|
|
586
|
-
|
|
587
|
-
}, async ({
|
|
588
|
-
const
|
|
912
|
+
server.tool("read_messages", "Read all messages from a Let Agents Chat room.", {
|
|
913
|
+
room_id: z.string().optional().describe("Canonical room ID. Defaults to the current room."),
|
|
914
|
+
}, async ({ room_id }) => {
|
|
915
|
+
const targetRoomId = getTargetRoomId(room_id);
|
|
916
|
+
const targetProjectId = getFallbackProjectId();
|
|
917
|
+
const result = await roomScopedApiCall({
|
|
918
|
+
room_id: targetRoomId,
|
|
919
|
+
project_id: targetProjectId,
|
|
920
|
+
room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/messages`,
|
|
921
|
+
project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/messages`,
|
|
922
|
+
});
|
|
589
923
|
return {
|
|
590
924
|
content: [
|
|
591
925
|
{
|
|
@@ -598,8 +932,8 @@ server.tool("read_messages", "Read all messages from a Let Agents Chat project."
|
|
|
598
932
|
// -- wait_for_messages ------------------------------------------------------
|
|
599
933
|
const MAX_POLL_TIMEOUT_MS = 180000; // 3 minutes
|
|
600
934
|
const DEFAULT_POLL_TIMEOUT_MS = 30000; // 30 seconds
|
|
601
|
-
server.tool("wait_for_messages", "Wait for new messages in a Let Agents Chat
|
|
602
|
-
|
|
935
|
+
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.", {
|
|
936
|
+
room_id: z.string().optional().describe("Canonical room ID. Defaults to the current room."),
|
|
603
937
|
after_message_id: z
|
|
604
938
|
.string()
|
|
605
939
|
.optional()
|
|
@@ -608,7 +942,9 @@ server.tool("wait_for_messages", "Wait for new messages in a Let Agents Chat pro
|
|
|
608
942
|
.number()
|
|
609
943
|
.optional()
|
|
610
944
|
.describe("Maximum wait time in milliseconds. If set to 0, the default timeout will be used."),
|
|
611
|
-
}, async ({
|
|
945
|
+
}, async ({ room_id, after_message_id, timeout }) => {
|
|
946
|
+
const targetRoomId = getTargetRoomId(room_id);
|
|
947
|
+
const targetProjectId = getFallbackProjectId();
|
|
612
948
|
const serverTimeout = Math.min(Math.max(timeout || DEFAULT_POLL_TIMEOUT_MS, 1000), MAX_POLL_TIMEOUT_MS);
|
|
613
949
|
const clientTimeout = serverTimeout + 5000; // 5s buffer over server timeout
|
|
614
950
|
const params = new URLSearchParams();
|
|
@@ -616,7 +952,304 @@ server.tool("wait_for_messages", "Wait for new messages in a Let Agents Chat pro
|
|
|
616
952
|
params.set("after", after_message_id);
|
|
617
953
|
params.set("timeout", String(serverTimeout));
|
|
618
954
|
const queryString = params.toString();
|
|
619
|
-
const result = await
|
|
955
|
+
const result = await roomScopedApiCall({
|
|
956
|
+
room_id: targetRoomId,
|
|
957
|
+
project_id: targetProjectId,
|
|
958
|
+
room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/messages/poll?${queryString}`,
|
|
959
|
+
project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/messages/poll?${queryString}`,
|
|
960
|
+
options: { signal: AbortSignal.timeout(clientTimeout) },
|
|
961
|
+
});
|
|
962
|
+
if (targetRoomId) {
|
|
963
|
+
touchRoomSession(targetRoomId, getLastMessageId(result));
|
|
964
|
+
}
|
|
965
|
+
return {
|
|
966
|
+
content: [
|
|
967
|
+
{
|
|
968
|
+
type: "text",
|
|
969
|
+
text: JSON.stringify(result, null, 2),
|
|
970
|
+
},
|
|
971
|
+
],
|
|
972
|
+
};
|
|
973
|
+
});
|
|
974
|
+
// -- onboarding -------------------------------------------------------------
|
|
975
|
+
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.", {
|
|
976
|
+
cwd: z
|
|
977
|
+
.string()
|
|
978
|
+
.optional()
|
|
979
|
+
.describe("Working directory to inspect for repo context. Defaults to the current process directory."),
|
|
980
|
+
}, async ({ cwd }) => {
|
|
981
|
+
const workingDir = cwd || process.cwd();
|
|
982
|
+
const repoRoot = resolveGitRoot(workingDir);
|
|
983
|
+
const configRoom = getRoomFromConfig(workingDir);
|
|
984
|
+
const gitRoom = repoRoot ? getGitRemoteIdentity(repoRoot) : null;
|
|
985
|
+
const storedAuth = getStoredAuth();
|
|
986
|
+
const pendingAuth = getPendingDeviceAuth();
|
|
987
|
+
const savedCurrentRoom = getStoredCurrentRoom();
|
|
988
|
+
const detectedRoom = configRoom || gitRoom;
|
|
989
|
+
let nextStep = "join_room";
|
|
990
|
+
if (!storedAuth && pendingAuth) {
|
|
991
|
+
nextStep = "poll_device_auth";
|
|
992
|
+
}
|
|
993
|
+
else if (savedCurrentRoom && !currentRoom) {
|
|
994
|
+
nextStep = "resume_room_session";
|
|
995
|
+
}
|
|
996
|
+
return {
|
|
997
|
+
content: [
|
|
998
|
+
{
|
|
999
|
+
type: "text",
|
|
1000
|
+
text: JSON.stringify({
|
|
1001
|
+
api_url: API_URL,
|
|
1002
|
+
local_state_path: getLocalStatePath(),
|
|
1003
|
+
authenticated: Boolean(process.env.LETAGENTS_TOKEN || storedAuth),
|
|
1004
|
+
auth_source: process.env.LETAGENTS_TOKEN
|
|
1005
|
+
? "env"
|
|
1006
|
+
: storedAuth
|
|
1007
|
+
? "local_state"
|
|
1008
|
+
: "none",
|
|
1009
|
+
account: storedAuth?.account ?? null,
|
|
1010
|
+
token_expires_at: storedAuth?.expires_at ?? null,
|
|
1011
|
+
pending_device_auth: pendingAuth,
|
|
1012
|
+
current_room: toPublicRoomState(currentRoom),
|
|
1013
|
+
saved_current_room: toPublicStoredRoomSession(savedCurrentRoom),
|
|
1014
|
+
detected_room_from_context: detectedRoom,
|
|
1015
|
+
repo_root: repoRoot,
|
|
1016
|
+
next_step: nextStep,
|
|
1017
|
+
}, null, 2),
|
|
1018
|
+
},
|
|
1019
|
+
],
|
|
1020
|
+
};
|
|
1021
|
+
});
|
|
1022
|
+
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.", {
|
|
1023
|
+
room_id: z
|
|
1024
|
+
.string()
|
|
1025
|
+
.optional()
|
|
1026
|
+
.describe("Optional room to associate with this auth request for later auto-join."),
|
|
1027
|
+
force: z
|
|
1028
|
+
.boolean()
|
|
1029
|
+
.optional()
|
|
1030
|
+
.describe("If true, replaces any existing pending device auth request."),
|
|
1031
|
+
}, async ({ room_id, force }) => {
|
|
1032
|
+
const existing = getPendingDeviceAuth();
|
|
1033
|
+
if (existing && !force) {
|
|
1034
|
+
return {
|
|
1035
|
+
content: [
|
|
1036
|
+
{
|
|
1037
|
+
type: "text",
|
|
1038
|
+
text: JSON.stringify({
|
|
1039
|
+
success: true,
|
|
1040
|
+
reused_existing_request: true,
|
|
1041
|
+
...existing,
|
|
1042
|
+
}, null, 2),
|
|
1043
|
+
},
|
|
1044
|
+
],
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
const response = await apiCall("/auth/device/start", {
|
|
1048
|
+
method: "POST",
|
|
1049
|
+
});
|
|
1050
|
+
const pendingAuth = setPendingDeviceAuth({
|
|
1051
|
+
request_id: response.request_id,
|
|
1052
|
+
user_code: response.user_code,
|
|
1053
|
+
verification_uri: response.verification_uri,
|
|
1054
|
+
interval_seconds: response.interval,
|
|
1055
|
+
expires_at: new Date(Date.now() + response.expires_in * 1000).toISOString(),
|
|
1056
|
+
started_at: new Date().toISOString(),
|
|
1057
|
+
suggested_room_id: room_id ?? currentRoom?.room_id ?? getRoomFromConfig() ?? undefined,
|
|
1058
|
+
});
|
|
1059
|
+
return {
|
|
1060
|
+
content: [
|
|
1061
|
+
{
|
|
1062
|
+
type: "text",
|
|
1063
|
+
text: JSON.stringify({
|
|
1064
|
+
success: true,
|
|
1065
|
+
...pendingAuth,
|
|
1066
|
+
}, null, 2),
|
|
1067
|
+
},
|
|
1068
|
+
],
|
|
1069
|
+
};
|
|
1070
|
+
});
|
|
1071
|
+
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.", {
|
|
1072
|
+
request_id: z
|
|
1073
|
+
.string()
|
|
1074
|
+
.optional()
|
|
1075
|
+
.describe("The device auth request to poll. Defaults to the locally saved pending request."),
|
|
1076
|
+
room_id: z
|
|
1077
|
+
.string()
|
|
1078
|
+
.optional()
|
|
1079
|
+
.describe("Optional room to auto-join after authorization succeeds."),
|
|
1080
|
+
auto_join: z
|
|
1081
|
+
.boolean()
|
|
1082
|
+
.optional()
|
|
1083
|
+
.describe("If true, tries to join the room immediately after the auth succeeds."),
|
|
1084
|
+
}, async ({ request_id, room_id, auto_join }) => {
|
|
1085
|
+
const pendingAuth = request_id
|
|
1086
|
+
? getPendingDeviceAuth()?.request_id === request_id
|
|
1087
|
+
? getPendingDeviceAuth()
|
|
1088
|
+
: null
|
|
1089
|
+
: getPendingDeviceAuth();
|
|
1090
|
+
if (!pendingAuth && !request_id) {
|
|
1091
|
+
return {
|
|
1092
|
+
content: [
|
|
1093
|
+
{
|
|
1094
|
+
type: "text",
|
|
1095
|
+
text: JSON.stringify({ success: false, error: "No pending device auth request found." }, null, 2),
|
|
1096
|
+
},
|
|
1097
|
+
],
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
const requestId = request_id || pendingAuth?.request_id;
|
|
1101
|
+
if (!requestId) {
|
|
1102
|
+
return {
|
|
1103
|
+
content: [
|
|
1104
|
+
{
|
|
1105
|
+
type: "text",
|
|
1106
|
+
text: JSON.stringify({ success: false, error: "A request_id is required when nothing is saved locally." }, null, 2),
|
|
1107
|
+
},
|
|
1108
|
+
],
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
const result = await apiCall(`/auth/device/poll/${encodeURIComponent(requestId)}`);
|
|
1112
|
+
if (result.status === "pending" || result.status === "slow_down") {
|
|
1113
|
+
if (pendingAuth) {
|
|
1114
|
+
setPendingDeviceAuth({
|
|
1115
|
+
...pendingAuth,
|
|
1116
|
+
interval_seconds: result.interval ?? pendingAuth.interval_seconds,
|
|
1117
|
+
expires_at: result.expires_in !== undefined
|
|
1118
|
+
? new Date(Date.now() + result.expires_in * 1000).toISOString()
|
|
1119
|
+
: pendingAuth.expires_at,
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
return {
|
|
1123
|
+
content: [
|
|
1124
|
+
{
|
|
1125
|
+
type: "text",
|
|
1126
|
+
text: JSON.stringify({ success: true, ...result }, null, 2),
|
|
1127
|
+
},
|
|
1128
|
+
],
|
|
1129
|
+
};
|
|
1130
|
+
}
|
|
1131
|
+
if (result.status === "denied" || result.status === "expired") {
|
|
1132
|
+
clearPendingDeviceAuth();
|
|
1133
|
+
clearStoredAuth();
|
|
1134
|
+
return {
|
|
1135
|
+
content: [
|
|
1136
|
+
{
|
|
1137
|
+
type: "text",
|
|
1138
|
+
text: JSON.stringify({ success: false, ...result }, null, 2),
|
|
1139
|
+
},
|
|
1140
|
+
],
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
if (!result.letagents_token) {
|
|
1144
|
+
throw new Error("Device auth completed without a LetAgents token.");
|
|
1145
|
+
}
|
|
1146
|
+
clearPendingDeviceAuth();
|
|
1147
|
+
const storedAuth = setStoredAuth({
|
|
1148
|
+
token: result.letagents_token,
|
|
1149
|
+
expires_at: result.expires_at,
|
|
1150
|
+
account: result.account,
|
|
1151
|
+
stored_at: new Date().toISOString(),
|
|
1152
|
+
source: "device_flow",
|
|
1153
|
+
});
|
|
1154
|
+
let joinedRoom = null;
|
|
1155
|
+
const roomToJoin = room_id ||
|
|
1156
|
+
pendingAuth?.suggested_room_id ||
|
|
1157
|
+
currentRoom?.room_id ||
|
|
1158
|
+
getRoomFromConfig() ||
|
|
1159
|
+
undefined;
|
|
1160
|
+
if (auto_join && roomToJoin) {
|
|
1161
|
+
const joinedVia = looksLikeInviteCode(roomToJoin) ? "join_code" : "join_room";
|
|
1162
|
+
const joined = await joinRoomIdentifier(roomToJoin, joinedVia);
|
|
1163
|
+
joinedRoom = joined.room;
|
|
1164
|
+
await autoRegisterAgentIdentity();
|
|
1165
|
+
}
|
|
1166
|
+
return {
|
|
1167
|
+
content: [
|
|
1168
|
+
{
|
|
1169
|
+
type: "text",
|
|
1170
|
+
text: JSON.stringify({
|
|
1171
|
+
success: true,
|
|
1172
|
+
status: "authorized",
|
|
1173
|
+
account: storedAuth.account ?? null,
|
|
1174
|
+
expires_at: storedAuth.expires_at ?? null,
|
|
1175
|
+
auto_joined_room: joinedRoom,
|
|
1176
|
+
}, null, 2),
|
|
1177
|
+
},
|
|
1178
|
+
],
|
|
1179
|
+
};
|
|
1180
|
+
});
|
|
1181
|
+
server.tool("clear_saved_auth", "Clear any locally saved LetAgents auth token and pending device auth request.", {}, async () => {
|
|
1182
|
+
clearPendingDeviceAuth();
|
|
1183
|
+
clearStoredAuth();
|
|
1184
|
+
return {
|
|
1185
|
+
content: [
|
|
1186
|
+
{
|
|
1187
|
+
type: "text",
|
|
1188
|
+
text: JSON.stringify({
|
|
1189
|
+
success: true,
|
|
1190
|
+
env_token_still_present: Boolean(process.env.LETAGENTS_TOKEN),
|
|
1191
|
+
}, null, 2),
|
|
1192
|
+
},
|
|
1193
|
+
],
|
|
1194
|
+
};
|
|
1195
|
+
});
|
|
1196
|
+
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.", {
|
|
1197
|
+
room_id: z
|
|
1198
|
+
.string()
|
|
1199
|
+
.optional()
|
|
1200
|
+
.describe("Optional saved room ID to resume. Defaults to the last current room."),
|
|
1201
|
+
}, async ({ room_id }) => {
|
|
1202
|
+
const savedRoom = (room_id ? getStoredRoomSession(room_id) : null) ??
|
|
1203
|
+
getStoredCurrentRoom();
|
|
1204
|
+
if (!savedRoom) {
|
|
1205
|
+
return {
|
|
1206
|
+
content: [
|
|
1207
|
+
{
|
|
1208
|
+
type: "text",
|
|
1209
|
+
text: JSON.stringify({ success: false, error: "No saved room session found." }, null, 2),
|
|
1210
|
+
},
|
|
1211
|
+
],
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
const joined = await joinRoomIdentifier(savedRoom.room_id, savedRoom.joined_via);
|
|
1215
|
+
await autoRegisterAgentIdentity();
|
|
1216
|
+
return {
|
|
1217
|
+
content: [
|
|
1218
|
+
{
|
|
1219
|
+
type: "text",
|
|
1220
|
+
text: JSON.stringify({
|
|
1221
|
+
success: true,
|
|
1222
|
+
rejoined_from_local_state: true,
|
|
1223
|
+
server_session_resumed: false,
|
|
1224
|
+
last_message_id_before_restart: savedRoom.last_message_id ?? null,
|
|
1225
|
+
room: joined.room,
|
|
1226
|
+
}, null, 2),
|
|
1227
|
+
},
|
|
1228
|
+
],
|
|
1229
|
+
};
|
|
1230
|
+
});
|
|
1231
|
+
// -- check_repo_visibility --------------------------------------------------
|
|
1232
|
+
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.", {
|
|
1233
|
+
cwd: z
|
|
1234
|
+
.string()
|
|
1235
|
+
.optional()
|
|
1236
|
+
.describe("Working directory to detect git remote from. Defaults to the MCP server's working directory."),
|
|
1237
|
+
}, async ({ cwd }) => {
|
|
1238
|
+
const { autoDetectRepo } = await import("./repo-visibility.js");
|
|
1239
|
+
const result = await autoDetectRepo(cwd);
|
|
1240
|
+
if (!result) {
|
|
1241
|
+
return {
|
|
1242
|
+
content: [
|
|
1243
|
+
{
|
|
1244
|
+
type: "text",
|
|
1245
|
+
text: JSON.stringify({
|
|
1246
|
+
error: "Not in a git repository or no remote configured",
|
|
1247
|
+
suggestion: "Use create_room to create an invite room instead",
|
|
1248
|
+
}, null, 2),
|
|
1249
|
+
},
|
|
1250
|
+
],
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
620
1253
|
return {
|
|
621
1254
|
content: [
|
|
622
1255
|
{
|
|
@@ -638,23 +1271,26 @@ async function main() {
|
|
|
638
1271
|
// 1. Try .letagents.json config
|
|
639
1272
|
const configRoom = getRoomFromConfig();
|
|
640
1273
|
if (configRoom) {
|
|
641
|
-
|
|
642
|
-
currentRoom = { room: configRoom, project_id: project.id, code: project.code, joined_via: "config" };
|
|
643
|
-
sseClient.subscribe(project.id, (_message) => { server.server.sendResourceListChanged(); });
|
|
1274
|
+
await joinRoomIdentifier(configRoom, "config");
|
|
644
1275
|
console.error(`đ Auto-joined room '${configRoom}' (from .letagents.json)`);
|
|
645
1276
|
return;
|
|
646
1277
|
}
|
|
647
1278
|
// 2. Try git remote URL
|
|
648
1279
|
const gitRoom = getGitRemoteIdentity();
|
|
649
1280
|
if (gitRoom) {
|
|
650
|
-
|
|
651
|
-
currentRoom = { room: gitRoom, project_id: project.id, code: project.code, joined_via: "git-remote" };
|
|
652
|
-
sseClient.subscribe(project.id, (_message) => { server.server.sendResourceListChanged(); });
|
|
1281
|
+
await joinRoomIdentifier(gitRoom, "git-remote");
|
|
653
1282
|
console.error(`đ Auto-joined room '${gitRoom}' (inferred from git remote â consider adding a .letagents.json)`);
|
|
654
1283
|
return;
|
|
655
1284
|
}
|
|
656
|
-
// 3.
|
|
657
|
-
|
|
1285
|
+
// 3. Fall back to the most recent saved room session
|
|
1286
|
+
const savedCurrentRoom = getStoredCurrentRoom();
|
|
1287
|
+
if (savedCurrentRoom) {
|
|
1288
|
+
await joinRoomIdentifier(savedCurrentRoom.room_id, savedCurrentRoom.joined_via);
|
|
1289
|
+
console.error(`đ Rejoined saved room '${savedCurrentRoom.room_id}' (from local state)`);
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
// 4. No context found
|
|
1293
|
+
console.error("âšī¸ No .letagents.json, git remote, or saved room found â use create_room, join_code, or join_room to connect.");
|
|
658
1294
|
}
|
|
659
1295
|
catch (err) {
|
|
660
1296
|
// Auto-join failure should never block the MCP server
|