letagents 0.6.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.
- 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 +709 -120
- 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,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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
112
|
-
|
|
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({ ...
|
|
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
|
|
137
|
-
|
|
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({ ...
|
|
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({
|
|
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("
|
|
234
|
-
}, async ({ sender, status, project_id }) => {
|
|
235
|
-
const
|
|
236
|
-
|
|
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
|
|
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
|
|
254
|
-
|
|
255
|
-
|
|
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
|
],
|
|
@@ -282,17 +517,25 @@ server.tool("add_task", "Add a new task to the project board. Tasks normally sta
|
|
|
282
517
|
description: z.string().optional().describe("Longer description of what needs to be done"),
|
|
283
518
|
created_by: z.string().describe("Name of the agent or human creating the task"),
|
|
284
519
|
source_message_id: z.string().optional().describe("Optional message ID where task was agreed, e.g. 'msg_42'"),
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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) {
|
|
289
526
|
return {
|
|
290
527
|
content: [{ type: "text", text: JSON.stringify({ success: false, error: "Not in a room. Join one first." }) }],
|
|
291
528
|
};
|
|
292
529
|
}
|
|
293
|
-
const task = await
|
|
294
|
-
|
|
295
|
-
|
|
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
|
+
},
|
|
296
539
|
});
|
|
297
540
|
return {
|
|
298
541
|
content: [{ type: "text", text: JSON.stringify({ success: true, task }, null, 2) }],
|
|
@@ -303,10 +546,12 @@ server.tool("get_board", "Get the current task board for the project. By default
|
|
|
303
546
|
"see if there is unassigned work to claim.", {
|
|
304
547
|
status: z.enum(TASK_STATUSES).optional().describe("Filter by specific status"),
|
|
305
548
|
open_only: z.boolean().optional().describe("If true (default), only show tasks not done/cancelled"),
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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) {
|
|
310
555
|
return {
|
|
311
556
|
content: [{ type: "text", text: JSON.stringify({ success: false, error: "Not in a room. Join one first." }) }],
|
|
312
557
|
};
|
|
@@ -317,7 +562,12 @@ server.tool("get_board", "Get the current task board for the project. By default
|
|
|
317
562
|
if (open_only !== false)
|
|
318
563
|
params.set("open", "true");
|
|
319
564
|
const qs = params.toString();
|
|
320
|
-
const result = await
|
|
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
|
+
});
|
|
321
571
|
return {
|
|
322
572
|
content: [{ type: "text", text: JSON.stringify({ success: true, ...result }, null, 2) }],
|
|
323
573
|
};
|
|
@@ -327,18 +577,26 @@ server.tool("claim_task", "Claim an accepted task. The task must be in 'accepted
|
|
|
327
577
|
"Do NOT claim proposed tasks â they need to be accepted first.", {
|
|
328
578
|
task_id: z.string().describe("The task ID to claim, e.g. 'task_1'"),
|
|
329
579
|
assignee: z.string().describe("Your agent name, e.g. 'antigravity'"),
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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) {
|
|
334
586
|
return {
|
|
335
587
|
content: [{ type: "text", text: JSON.stringify({ success: false, error: "Not in a room." }) }],
|
|
336
588
|
};
|
|
337
589
|
}
|
|
338
590
|
try {
|
|
339
|
-
const updated = await
|
|
340
|
-
|
|
341
|
-
|
|
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
|
+
},
|
|
342
600
|
});
|
|
343
601
|
return {
|
|
344
602
|
content: [{ type: "text", text: JSON.stringify({ success: true, task: updated }, null, 2) }],
|
|
@@ -357,18 +615,26 @@ server.tool("update_task", "Update a task's status or assignee. Status transitio
|
|
|
357
615
|
status: z.enum(TASK_STATUSES).optional().describe("New status for the task"),
|
|
358
616
|
assignee: z.string().optional().describe("New assignee for the task"),
|
|
359
617
|
pr_url: z.string().optional().describe("PR URL to link to the task"),
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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) {
|
|
364
624
|
return {
|
|
365
625
|
content: [{ type: "text", text: JSON.stringify({ success: false, error: "Not in a room." }) }],
|
|
366
626
|
};
|
|
367
627
|
}
|
|
368
628
|
try {
|
|
369
|
-
const updated = await
|
|
370
|
-
|
|
371
|
-
|
|
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
|
+
},
|
|
372
638
|
});
|
|
373
639
|
return {
|
|
374
640
|
content: [{ type: "text", text: JSON.stringify({ success: true, task: updated }, null, 2) }],
|
|
@@ -385,18 +651,26 @@ server.tool("complete_task", "Submit a task for review. Moves the task to 'in_re
|
|
|
385
651
|
"the work is merged before it can be marked done.", {
|
|
386
652
|
task_id: z.string().describe("The task ID to submit for review"),
|
|
387
653
|
pr_url: z.string().optional().describe("GitHub PR URL for the work"),
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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) {
|
|
392
660
|
return {
|
|
393
661
|
content: [{ type: "text", text: JSON.stringify({ success: false, error: "Not in a room." }) }],
|
|
394
662
|
};
|
|
395
663
|
}
|
|
396
664
|
try {
|
|
397
|
-
const updated = await
|
|
398
|
-
|
|
399
|
-
|
|
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
|
+
},
|
|
400
674
|
});
|
|
401
675
|
return {
|
|
402
676
|
content: [{ type: "text", text: JSON.stringify({ success: true, task: updated }, null, 2) }],
|
|
@@ -516,16 +790,7 @@ server.tool("initialize_repo", "Initialize the current repo for Let Agents Chat
|
|
|
516
790
|
}
|
|
517
791
|
// Auto-join the room after creating config
|
|
518
792
|
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
|
-
});
|
|
793
|
+
const joined = await joinRoomIdentifier(roomName, "config");
|
|
529
794
|
return {
|
|
530
795
|
content: [
|
|
531
796
|
{
|
|
@@ -533,9 +798,9 @@ server.tool("initialize_repo", "Initialize the current repo for Let Agents Chat
|
|
|
533
798
|
text: JSON.stringify({
|
|
534
799
|
success: true,
|
|
535
800
|
created: configPath,
|
|
536
|
-
|
|
537
|
-
project_id:
|
|
538
|
-
code:
|
|
801
|
+
room_id: joined.room.room_id,
|
|
802
|
+
project_id: joined.room.project_id ?? null,
|
|
803
|
+
code: joined.room.code ?? null,
|
|
539
804
|
joined: true,
|
|
540
805
|
hint: "Consider adding .letagents.json to git so other contributors auto-join the same room.",
|
|
541
806
|
}, null, 2),
|
|
@@ -552,7 +817,7 @@ server.tool("initialize_repo", "Initialize the current repo for Let Agents Chat
|
|
|
552
817
|
text: JSON.stringify({
|
|
553
818
|
success: true,
|
|
554
819
|
created: configPath,
|
|
555
|
-
|
|
820
|
+
room_id: roomName,
|
|
556
821
|
joined: false,
|
|
557
822
|
error: `Config created but auto-join failed: ${err instanceof Error ? err.message : err}`,
|
|
558
823
|
hint: "The .letagents.json was created. Use join_room to manually connect.",
|
|
@@ -563,15 +828,28 @@ server.tool("initialize_repo", "Initialize the current repo for Let Agents Chat
|
|
|
563
828
|
}
|
|
564
829
|
});
|
|
565
830
|
// -- send_message -----------------------------------------------------------
|
|
566
|
-
server.tool("send_message", "Send a message to a Let Agents Chat
|
|
567
|
-
|
|
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."),
|
|
568
834
|
sender: z.string().describe("Name identifying the sending agent (e.g. 'antigravity-agent')"),
|
|
569
835
|
text: z.string().describe("The message text to send"),
|
|
570
|
-
}, async ({ project_id, sender, text }) => {
|
|
571
|
-
const
|
|
572
|
-
|
|
573
|
-
|
|
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
|
+
},
|
|
574
851
|
});
|
|
852
|
+
touchCurrentRoom(typeof message.id === "string" ? message.id : undefined);
|
|
575
853
|
return {
|
|
576
854
|
content: [
|
|
577
855
|
{
|
|
@@ -582,10 +860,18 @@ server.tool("send_message", "Send a message to a Let Agents Chat project.", {
|
|
|
582
860
|
};
|
|
583
861
|
});
|
|
584
862
|
// -- read_messages ----------------------------------------------------------
|
|
585
|
-
server.tool("read_messages", "Read all messages from a Let Agents Chat
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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
|
+
});
|
|
589
875
|
return {
|
|
590
876
|
content: [
|
|
591
877
|
{
|
|
@@ -598,8 +884,9 @@ server.tool("read_messages", "Read all messages from a Let Agents Chat project."
|
|
|
598
884
|
// -- wait_for_messages ------------------------------------------------------
|
|
599
885
|
const MAX_POLL_TIMEOUT_MS = 180000; // 3 minutes
|
|
600
886
|
const DEFAULT_POLL_TIMEOUT_MS = 30000; // 30 seconds
|
|
601
|
-
server.tool("wait_for_messages", "Wait for new messages in a Let Agents Chat
|
|
602
|
-
|
|
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."),
|
|
603
890
|
after_message_id: z
|
|
604
891
|
.string()
|
|
605
892
|
.optional()
|
|
@@ -608,7 +895,9 @@ server.tool("wait_for_messages", "Wait for new messages in a Let Agents Chat pro
|
|
|
608
895
|
.number()
|
|
609
896
|
.optional()
|
|
610
897
|
.describe("Maximum wait time in milliseconds. If set to 0, the default timeout will be used."),
|
|
611
|
-
}, 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);
|
|
612
901
|
const serverTimeout = Math.min(Math.max(timeout || DEFAULT_POLL_TIMEOUT_MS, 1000), MAX_POLL_TIMEOUT_MS);
|
|
613
902
|
const clientTimeout = serverTimeout + 5000; // 5s buffer over server timeout
|
|
614
903
|
const params = new URLSearchParams();
|
|
@@ -616,7 +905,304 @@ server.tool("wait_for_messages", "Wait for new messages in a Let Agents Chat pro
|
|
|
616
905
|
params.set("after", after_message_id);
|
|
617
906
|
params.set("timeout", String(serverTimeout));
|
|
618
907
|
const queryString = params.toString();
|
|
619
|
-
const result = await
|
|
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
|
+
}
|
|
620
1206
|
return {
|
|
621
1207
|
content: [
|
|
622
1208
|
{
|
|
@@ -638,23 +1224,26 @@ async function main() {
|
|
|
638
1224
|
// 1. Try .letagents.json config
|
|
639
1225
|
const configRoom = getRoomFromConfig();
|
|
640
1226
|
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(); });
|
|
1227
|
+
await joinRoomIdentifier(configRoom, "config");
|
|
644
1228
|
console.error(`đ Auto-joined room '${configRoom}' (from .letagents.json)`);
|
|
645
1229
|
return;
|
|
646
1230
|
}
|
|
647
1231
|
// 2. Try git remote URL
|
|
648
1232
|
const gitRoom = getGitRemoteIdentity();
|
|
649
1233
|
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(); });
|
|
1234
|
+
await joinRoomIdentifier(gitRoom, "git-remote");
|
|
653
1235
|
console.error(`đ Auto-joined room '${gitRoom}' (inferred from git remote â consider adding a .letagents.json)`);
|
|
654
1236
|
return;
|
|
655
1237
|
}
|
|
656
|
-
// 3.
|
|
657
|
-
|
|
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.");
|
|
658
1247
|
}
|
|
659
1248
|
catch (err) {
|
|
660
1249
|
// Auto-join failure should never block the MCP server
|