letagents 0.5.0 â 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -3
- 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 +714 -124
- 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
|
],
|
|
@@ -274,24 +509,33 @@ const TASK_STATUSES = [
|
|
|
274
509
|
"proposed", "accepted", "assigned", "in_progress",
|
|
275
510
|
"blocked", "in_review", "merged", "done", "cancelled",
|
|
276
511
|
];
|
|
277
|
-
server.tool("add_task", "Add a new task to the project board. Tasks start as 'proposed' and must be " +
|
|
278
|
-
"accepted before an agent can claim them
|
|
279
|
-
"
|
|
512
|
+
server.tool("add_task", "Add a new task to the project board. Tasks normally start as 'proposed' and must be " +
|
|
513
|
+
"accepted before an agent can claim them, but tasks created by trusted agents already " +
|
|
514
|
+
"active in the room may be auto-accepted. Use this when a human or agent identifies " +
|
|
515
|
+
"work that needs to be done.", {
|
|
280
516
|
title: z.string().describe("Short task title, e.g. 'Wire up Jest test runner'"),
|
|
281
517
|
description: z.string().optional().describe("Longer description of what needs to be done"),
|
|
282
518
|
created_by: z.string().describe("Name of the agent or human creating the task"),
|
|
283
519
|
source_message_id: z.string().optional().describe("Optional message ID where task was agreed, e.g. 'msg_42'"),
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
520
|
+
room_id: z.string().optional().describe("Canonical room ID. Defaults to current room."),
|
|
521
|
+
project_id: z.string().optional().describe("Legacy project ID. Defaults to current room."),
|
|
522
|
+
}, async ({ title, description, created_by, source_message_id, room_id, project_id }) => {
|
|
523
|
+
const targetRoomId = getTargetRoomId(room_id);
|
|
524
|
+
const targetProjectId = getTargetProjectId(project_id);
|
|
525
|
+
if (!targetRoomId && !targetProjectId) {
|
|
288
526
|
return {
|
|
289
527
|
content: [{ type: "text", text: JSON.stringify({ success: false, error: "Not in a room. Join one first." }) }],
|
|
290
528
|
};
|
|
291
529
|
}
|
|
292
|
-
const task = await
|
|
293
|
-
|
|
294
|
-
|
|
530
|
+
const task = await roomScopedApiCall({
|
|
531
|
+
room_id: targetRoomId,
|
|
532
|
+
project_id: targetProjectId,
|
|
533
|
+
room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/tasks`,
|
|
534
|
+
project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/tasks`,
|
|
535
|
+
options: {
|
|
536
|
+
method: "POST",
|
|
537
|
+
body: JSON.stringify({ title, description, created_by, source_message_id }),
|
|
538
|
+
},
|
|
295
539
|
});
|
|
296
540
|
return {
|
|
297
541
|
content: [{ type: "text", text: JSON.stringify({ success: true, task }, null, 2) }],
|
|
@@ -302,10 +546,12 @@ server.tool("get_board", "Get the current task board for the project. By default
|
|
|
302
546
|
"see if there is unassigned work to claim.", {
|
|
303
547
|
status: z.enum(TASK_STATUSES).optional().describe("Filter by specific status"),
|
|
304
548
|
open_only: z.boolean().optional().describe("If true (default), only show tasks not done/cancelled"),
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
549
|
+
room_id: z.string().optional().describe("Canonical room ID. Defaults to current room."),
|
|
550
|
+
project_id: z.string().optional().describe("Legacy project ID. Defaults to current room."),
|
|
551
|
+
}, async ({ status, open_only, room_id, project_id }) => {
|
|
552
|
+
const targetRoomId = getTargetRoomId(room_id);
|
|
553
|
+
const targetProjectId = getTargetProjectId(project_id);
|
|
554
|
+
if (!targetRoomId && !targetProjectId) {
|
|
309
555
|
return {
|
|
310
556
|
content: [{ type: "text", text: JSON.stringify({ success: false, error: "Not in a room. Join one first." }) }],
|
|
311
557
|
};
|
|
@@ -316,7 +562,12 @@ server.tool("get_board", "Get the current task board for the project. By default
|
|
|
316
562
|
if (open_only !== false)
|
|
317
563
|
params.set("open", "true");
|
|
318
564
|
const qs = params.toString();
|
|
319
|
-
const result = await
|
|
565
|
+
const result = await roomScopedApiCall({
|
|
566
|
+
room_id: targetRoomId,
|
|
567
|
+
project_id: targetProjectId,
|
|
568
|
+
room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/tasks${qs ? `?${qs}` : ""}`,
|
|
569
|
+
project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/tasks${qs ? `?${qs}` : ""}`,
|
|
570
|
+
});
|
|
320
571
|
return {
|
|
321
572
|
content: [{ type: "text", text: JSON.stringify({ success: true, ...result }, null, 2) }],
|
|
322
573
|
};
|
|
@@ -326,18 +577,26 @@ server.tool("claim_task", "Claim an accepted task. The task must be in 'accepted
|
|
|
326
577
|
"Do NOT claim proposed tasks â they need to be accepted first.", {
|
|
327
578
|
task_id: z.string().describe("The task ID to claim, e.g. 'task_1'"),
|
|
328
579
|
assignee: z.string().describe("Your agent name, e.g. 'antigravity'"),
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
580
|
+
room_id: z.string().optional().describe("Canonical room ID. Defaults to current room."),
|
|
581
|
+
project_id: z.string().optional().describe("Legacy project ID. Defaults to current room."),
|
|
582
|
+
}, async ({ task_id, assignee, room_id, project_id }) => {
|
|
583
|
+
const targetRoomId = getTargetRoomId(room_id);
|
|
584
|
+
const targetProjectId = getTargetProjectId(project_id);
|
|
585
|
+
if (!targetRoomId && !targetProjectId) {
|
|
333
586
|
return {
|
|
334
587
|
content: [{ type: "text", text: JSON.stringify({ success: false, error: "Not in a room." }) }],
|
|
335
588
|
};
|
|
336
589
|
}
|
|
337
590
|
try {
|
|
338
|
-
const updated = await
|
|
339
|
-
|
|
340
|
-
|
|
591
|
+
const updated = await roomScopedApiCall({
|
|
592
|
+
room_id: targetRoomId,
|
|
593
|
+
project_id: targetProjectId,
|
|
594
|
+
room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/tasks/${encodeURIComponent(task_id)}`,
|
|
595
|
+
project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/tasks/${encodeURIComponent(task_id)}`,
|
|
596
|
+
options: {
|
|
597
|
+
method: "PATCH",
|
|
598
|
+
body: JSON.stringify({ status: "assigned", assignee }),
|
|
599
|
+
},
|
|
341
600
|
});
|
|
342
601
|
return {
|
|
343
602
|
content: [{ type: "text", text: JSON.stringify({ success: true, task: updated }, null, 2) }],
|
|
@@ -356,18 +615,26 @@ server.tool("update_task", "Update a task's status or assignee. Status transitio
|
|
|
356
615
|
status: z.enum(TASK_STATUSES).optional().describe("New status for the task"),
|
|
357
616
|
assignee: z.string().optional().describe("New assignee for the task"),
|
|
358
617
|
pr_url: z.string().optional().describe("PR URL to link to the task"),
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
618
|
+
room_id: z.string().optional().describe("Canonical room ID. Defaults to current room."),
|
|
619
|
+
project_id: z.string().optional().describe("Legacy project ID. Defaults to current room."),
|
|
620
|
+
}, async ({ task_id, status, assignee, pr_url, room_id, project_id }) => {
|
|
621
|
+
const targetRoomId = getTargetRoomId(room_id);
|
|
622
|
+
const targetProjectId = getTargetProjectId(project_id);
|
|
623
|
+
if (!targetRoomId && !targetProjectId) {
|
|
363
624
|
return {
|
|
364
625
|
content: [{ type: "text", text: JSON.stringify({ success: false, error: "Not in a room." }) }],
|
|
365
626
|
};
|
|
366
627
|
}
|
|
367
628
|
try {
|
|
368
|
-
const updated = await
|
|
369
|
-
|
|
370
|
-
|
|
629
|
+
const updated = await roomScopedApiCall({
|
|
630
|
+
room_id: targetRoomId,
|
|
631
|
+
project_id: targetProjectId,
|
|
632
|
+
room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/tasks/${encodeURIComponent(task_id)}`,
|
|
633
|
+
project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/tasks/${encodeURIComponent(task_id)}`,
|
|
634
|
+
options: {
|
|
635
|
+
method: "PATCH",
|
|
636
|
+
body: JSON.stringify({ status, assignee, pr_url }),
|
|
637
|
+
},
|
|
371
638
|
});
|
|
372
639
|
return {
|
|
373
640
|
content: [{ type: "text", text: JSON.stringify({ success: true, task: updated }, null, 2) }],
|
|
@@ -384,18 +651,26 @@ server.tool("complete_task", "Submit a task for review. Moves the task to 'in_re
|
|
|
384
651
|
"the work is merged before it can be marked done.", {
|
|
385
652
|
task_id: z.string().describe("The task ID to submit for review"),
|
|
386
653
|
pr_url: z.string().optional().describe("GitHub PR URL for the work"),
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
654
|
+
room_id: z.string().optional().describe("Canonical room ID. Defaults to current room."),
|
|
655
|
+
project_id: z.string().optional().describe("Legacy project ID. Defaults to current room."),
|
|
656
|
+
}, async ({ task_id, pr_url, room_id, project_id }) => {
|
|
657
|
+
const targetRoomId = getTargetRoomId(room_id);
|
|
658
|
+
const targetProjectId = getTargetProjectId(project_id);
|
|
659
|
+
if (!targetRoomId && !targetProjectId) {
|
|
391
660
|
return {
|
|
392
661
|
content: [{ type: "text", text: JSON.stringify({ success: false, error: "Not in a room." }) }],
|
|
393
662
|
};
|
|
394
663
|
}
|
|
395
664
|
try {
|
|
396
|
-
const updated = await
|
|
397
|
-
|
|
398
|
-
|
|
665
|
+
const updated = await roomScopedApiCall({
|
|
666
|
+
room_id: targetRoomId,
|
|
667
|
+
project_id: targetProjectId,
|
|
668
|
+
room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/tasks/${encodeURIComponent(task_id)}`,
|
|
669
|
+
project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/tasks/${encodeURIComponent(task_id)}`,
|
|
670
|
+
options: {
|
|
671
|
+
method: "PATCH",
|
|
672
|
+
body: JSON.stringify({ status: "in_review", pr_url }),
|
|
673
|
+
},
|
|
399
674
|
});
|
|
400
675
|
return {
|
|
401
676
|
content: [{ type: "text", text: JSON.stringify({ success: true, task: updated }, null, 2) }],
|
|
@@ -515,16 +790,7 @@ server.tool("initialize_repo", "Initialize the current repo for Let Agents Chat
|
|
|
515
790
|
}
|
|
516
791
|
// Auto-join the room after creating config
|
|
517
792
|
try {
|
|
518
|
-
const
|
|
519
|
-
currentRoom = {
|
|
520
|
-
room: roomName,
|
|
521
|
-
project_id: project.id,
|
|
522
|
-
code: project.code,
|
|
523
|
-
joined_via: "config",
|
|
524
|
-
};
|
|
525
|
-
sseClient.subscribe(project.id, (_message) => {
|
|
526
|
-
server.server.sendResourceListChanged();
|
|
527
|
-
});
|
|
793
|
+
const joined = await joinRoomIdentifier(roomName, "config");
|
|
528
794
|
return {
|
|
529
795
|
content: [
|
|
530
796
|
{
|
|
@@ -532,9 +798,9 @@ server.tool("initialize_repo", "Initialize the current repo for Let Agents Chat
|
|
|
532
798
|
text: JSON.stringify({
|
|
533
799
|
success: true,
|
|
534
800
|
created: configPath,
|
|
535
|
-
|
|
536
|
-
project_id:
|
|
537
|
-
code:
|
|
801
|
+
room_id: joined.room.room_id,
|
|
802
|
+
project_id: joined.room.project_id ?? null,
|
|
803
|
+
code: joined.room.code ?? null,
|
|
538
804
|
joined: true,
|
|
539
805
|
hint: "Consider adding .letagents.json to git so other contributors auto-join the same room.",
|
|
540
806
|
}, null, 2),
|
|
@@ -551,7 +817,7 @@ server.tool("initialize_repo", "Initialize the current repo for Let Agents Chat
|
|
|
551
817
|
text: JSON.stringify({
|
|
552
818
|
success: true,
|
|
553
819
|
created: configPath,
|
|
554
|
-
|
|
820
|
+
room_id: roomName,
|
|
555
821
|
joined: false,
|
|
556
822
|
error: `Config created but auto-join failed: ${err instanceof Error ? err.message : err}`,
|
|
557
823
|
hint: "The .letagents.json was created. Use join_room to manually connect.",
|
|
@@ -562,15 +828,28 @@ server.tool("initialize_repo", "Initialize the current repo for Let Agents Chat
|
|
|
562
828
|
}
|
|
563
829
|
});
|
|
564
830
|
// -- send_message -----------------------------------------------------------
|
|
565
|
-
server.tool("send_message", "Send a message to a Let Agents Chat
|
|
566
|
-
|
|
831
|
+
server.tool("send_message", "Send a message to a Let Agents Chat room.", {
|
|
832
|
+
room_id: z.string().optional().describe("Canonical room ID. Defaults to the current room."),
|
|
833
|
+
project_id: z.string().optional().describe("Legacy project ID. Defaults to the current room."),
|
|
567
834
|
sender: z.string().describe("Name identifying the sending agent (e.g. 'antigravity-agent')"),
|
|
568
835
|
text: z.string().describe("The message text to send"),
|
|
569
|
-
}, async ({ project_id, sender, text }) => {
|
|
570
|
-
const
|
|
571
|
-
|
|
572
|
-
|
|
836
|
+
}, async ({ room_id, project_id, sender, text }) => {
|
|
837
|
+
const targetRoomId = getTargetRoomId(room_id);
|
|
838
|
+
const targetProjectId = getTargetProjectId(project_id);
|
|
839
|
+
if (!targetRoomId && !targetProjectId) {
|
|
840
|
+
throw new Error("No room is currently selected. Join a room first or pass room_id.");
|
|
841
|
+
}
|
|
842
|
+
const message = await roomScopedApiCall({
|
|
843
|
+
room_id: targetRoomId,
|
|
844
|
+
project_id: targetProjectId,
|
|
845
|
+
room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/messages`,
|
|
846
|
+
project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/messages`,
|
|
847
|
+
options: {
|
|
848
|
+
method: "POST",
|
|
849
|
+
body: JSON.stringify({ sender, text }),
|
|
850
|
+
},
|
|
573
851
|
});
|
|
852
|
+
touchCurrentRoom(typeof message.id === "string" ? message.id : undefined);
|
|
574
853
|
return {
|
|
575
854
|
content: [
|
|
576
855
|
{
|
|
@@ -581,10 +860,18 @@ server.tool("send_message", "Send a message to a Let Agents Chat project.", {
|
|
|
581
860
|
};
|
|
582
861
|
});
|
|
583
862
|
// -- read_messages ----------------------------------------------------------
|
|
584
|
-
server.tool("read_messages", "Read all messages from a Let Agents Chat
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
863
|
+
server.tool("read_messages", "Read all messages from a Let Agents Chat room.", {
|
|
864
|
+
room_id: z.string().optional().describe("Canonical room ID. Defaults to the current room."),
|
|
865
|
+
project_id: z.string().optional().describe("Legacy project ID. Defaults to the current room."),
|
|
866
|
+
}, async ({ room_id, project_id }) => {
|
|
867
|
+
const targetRoomId = getTargetRoomId(room_id);
|
|
868
|
+
const targetProjectId = getTargetProjectId(project_id);
|
|
869
|
+
const result = await roomScopedApiCall({
|
|
870
|
+
room_id: targetRoomId,
|
|
871
|
+
project_id: targetProjectId,
|
|
872
|
+
room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/messages`,
|
|
873
|
+
project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/messages`,
|
|
874
|
+
});
|
|
588
875
|
return {
|
|
589
876
|
content: [
|
|
590
877
|
{
|
|
@@ -597,8 +884,9 @@ server.tool("read_messages", "Read all messages from a Let Agents Chat project."
|
|
|
597
884
|
// -- wait_for_messages ------------------------------------------------------
|
|
598
885
|
const MAX_POLL_TIMEOUT_MS = 180000; // 3 minutes
|
|
599
886
|
const DEFAULT_POLL_TIMEOUT_MS = 30000; // 30 seconds
|
|
600
|
-
server.tool("wait_for_messages", "Wait for new messages in a Let Agents Chat
|
|
601
|
-
|
|
887
|
+
server.tool("wait_for_messages", "Wait for new messages in a Let Agents Chat room. Blocks until new messages arrive or 30 seconds elapse. Use the after_message_id parameter to only receive messages newer than a specific message.", {
|
|
888
|
+
room_id: z.string().optional().describe("Canonical room ID. Defaults to the current room."),
|
|
889
|
+
project_id: z.string().optional().describe("Legacy project ID. Defaults to the current room."),
|
|
602
890
|
after_message_id: z
|
|
603
891
|
.string()
|
|
604
892
|
.optional()
|
|
@@ -607,7 +895,9 @@ server.tool("wait_for_messages", "Wait for new messages in a Let Agents Chat pro
|
|
|
607
895
|
.number()
|
|
608
896
|
.optional()
|
|
609
897
|
.describe("Maximum wait time in milliseconds. If set to 0, the default timeout will be used."),
|
|
610
|
-
}, async ({ project_id, after_message_id, timeout }) => {
|
|
898
|
+
}, async ({ room_id, project_id, after_message_id, timeout }) => {
|
|
899
|
+
const targetRoomId = getTargetRoomId(room_id);
|
|
900
|
+
const targetProjectId = getTargetProjectId(project_id);
|
|
611
901
|
const serverTimeout = Math.min(Math.max(timeout || DEFAULT_POLL_TIMEOUT_MS, 1000), MAX_POLL_TIMEOUT_MS);
|
|
612
902
|
const clientTimeout = serverTimeout + 5000; // 5s buffer over server timeout
|
|
613
903
|
const params = new URLSearchParams();
|
|
@@ -615,7 +905,304 @@ server.tool("wait_for_messages", "Wait for new messages in a Let Agents Chat pro
|
|
|
615
905
|
params.set("after", after_message_id);
|
|
616
906
|
params.set("timeout", String(serverTimeout));
|
|
617
907
|
const queryString = params.toString();
|
|
618
|
-
const result = await
|
|
908
|
+
const result = await roomScopedApiCall({
|
|
909
|
+
room_id: targetRoomId,
|
|
910
|
+
project_id: targetProjectId,
|
|
911
|
+
room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/messages/poll?${queryString}`,
|
|
912
|
+
project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/messages/poll?${queryString}`,
|
|
913
|
+
options: { signal: AbortSignal.timeout(clientTimeout) },
|
|
914
|
+
});
|
|
915
|
+
if (targetRoomId) {
|
|
916
|
+
touchRoomSession(targetRoomId, getLastMessageId(result));
|
|
917
|
+
}
|
|
918
|
+
return {
|
|
919
|
+
content: [
|
|
920
|
+
{
|
|
921
|
+
type: "text",
|
|
922
|
+
text: JSON.stringify(result, null, 2),
|
|
923
|
+
},
|
|
924
|
+
],
|
|
925
|
+
};
|
|
926
|
+
});
|
|
927
|
+
// -- onboarding -------------------------------------------------------------
|
|
928
|
+
server.tool("get_onboarding_status", "Inspect local Let Agents MCP auth and room-session state so a user can finish onboarding without guessing what is missing.", {
|
|
929
|
+
cwd: z
|
|
930
|
+
.string()
|
|
931
|
+
.optional()
|
|
932
|
+
.describe("Working directory to inspect for repo context. Defaults to the current process directory."),
|
|
933
|
+
}, async ({ cwd }) => {
|
|
934
|
+
const workingDir = cwd || process.cwd();
|
|
935
|
+
const repoRoot = resolveGitRoot(workingDir);
|
|
936
|
+
const configRoom = getRoomFromConfig(workingDir);
|
|
937
|
+
const gitRoom = repoRoot ? getGitRemoteIdentity(repoRoot) : null;
|
|
938
|
+
const storedAuth = getStoredAuth();
|
|
939
|
+
const pendingAuth = getPendingDeviceAuth();
|
|
940
|
+
const savedCurrentRoom = getStoredCurrentRoom();
|
|
941
|
+
const detectedRoom = configRoom || gitRoom;
|
|
942
|
+
let nextStep = "join_room";
|
|
943
|
+
if (!storedAuth && pendingAuth) {
|
|
944
|
+
nextStep = "poll_device_auth";
|
|
945
|
+
}
|
|
946
|
+
else if (savedCurrentRoom && !currentRoom) {
|
|
947
|
+
nextStep = "resume_room_session";
|
|
948
|
+
}
|
|
949
|
+
return {
|
|
950
|
+
content: [
|
|
951
|
+
{
|
|
952
|
+
type: "text",
|
|
953
|
+
text: JSON.stringify({
|
|
954
|
+
api_url: API_URL,
|
|
955
|
+
local_state_path: getLocalStatePath(),
|
|
956
|
+
authenticated: Boolean(process.env.LETAGENTS_TOKEN || storedAuth),
|
|
957
|
+
auth_source: process.env.LETAGENTS_TOKEN
|
|
958
|
+
? "env"
|
|
959
|
+
: storedAuth
|
|
960
|
+
? "local_state"
|
|
961
|
+
: "none",
|
|
962
|
+
account: storedAuth?.account ?? null,
|
|
963
|
+
token_expires_at: storedAuth?.expires_at ?? null,
|
|
964
|
+
pending_device_auth: pendingAuth,
|
|
965
|
+
current_room: currentRoom,
|
|
966
|
+
saved_current_room: savedCurrentRoom,
|
|
967
|
+
detected_room_from_context: detectedRoom,
|
|
968
|
+
repo_root: repoRoot,
|
|
969
|
+
next_step: nextStep,
|
|
970
|
+
}, null, 2),
|
|
971
|
+
},
|
|
972
|
+
],
|
|
973
|
+
};
|
|
974
|
+
});
|
|
975
|
+
server.tool("start_device_auth", "Start GitHub Device Flow for Let Agents Chat and persist the pending request locally. Use this when private repo access or explicit LetAgents auth is needed.", {
|
|
976
|
+
room_id: z
|
|
977
|
+
.string()
|
|
978
|
+
.optional()
|
|
979
|
+
.describe("Optional room to associate with this auth request for later auto-join."),
|
|
980
|
+
force: z
|
|
981
|
+
.boolean()
|
|
982
|
+
.optional()
|
|
983
|
+
.describe("If true, replaces any existing pending device auth request."),
|
|
984
|
+
}, async ({ room_id, force }) => {
|
|
985
|
+
const existing = getPendingDeviceAuth();
|
|
986
|
+
if (existing && !force) {
|
|
987
|
+
return {
|
|
988
|
+
content: [
|
|
989
|
+
{
|
|
990
|
+
type: "text",
|
|
991
|
+
text: JSON.stringify({
|
|
992
|
+
success: true,
|
|
993
|
+
reused_existing_request: true,
|
|
994
|
+
...existing,
|
|
995
|
+
}, null, 2),
|
|
996
|
+
},
|
|
997
|
+
],
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
const response = await apiCall("/auth/device/start", {
|
|
1001
|
+
method: "POST",
|
|
1002
|
+
});
|
|
1003
|
+
const pendingAuth = setPendingDeviceAuth({
|
|
1004
|
+
request_id: response.request_id,
|
|
1005
|
+
user_code: response.user_code,
|
|
1006
|
+
verification_uri: response.verification_uri,
|
|
1007
|
+
interval_seconds: response.interval,
|
|
1008
|
+
expires_at: new Date(Date.now() + response.expires_in * 1000).toISOString(),
|
|
1009
|
+
started_at: new Date().toISOString(),
|
|
1010
|
+
suggested_room_id: room_id ?? currentRoom?.room_id ?? getRoomFromConfig() ?? undefined,
|
|
1011
|
+
});
|
|
1012
|
+
return {
|
|
1013
|
+
content: [
|
|
1014
|
+
{
|
|
1015
|
+
type: "text",
|
|
1016
|
+
text: JSON.stringify({
|
|
1017
|
+
success: true,
|
|
1018
|
+
...pendingAuth,
|
|
1019
|
+
}, null, 2),
|
|
1020
|
+
},
|
|
1021
|
+
],
|
|
1022
|
+
};
|
|
1023
|
+
});
|
|
1024
|
+
server.tool("poll_device_auth", "Poll a pending GitHub Device Flow request. On success this stores the LetAgents token locally and can optionally join a room immediately.", {
|
|
1025
|
+
request_id: z
|
|
1026
|
+
.string()
|
|
1027
|
+
.optional()
|
|
1028
|
+
.describe("The device auth request to poll. Defaults to the locally saved pending request."),
|
|
1029
|
+
room_id: z
|
|
1030
|
+
.string()
|
|
1031
|
+
.optional()
|
|
1032
|
+
.describe("Optional room to auto-join after authorization succeeds."),
|
|
1033
|
+
auto_join: z
|
|
1034
|
+
.boolean()
|
|
1035
|
+
.optional()
|
|
1036
|
+
.describe("If true, tries to join the room immediately after the auth succeeds."),
|
|
1037
|
+
}, async ({ request_id, room_id, auto_join }) => {
|
|
1038
|
+
const pendingAuth = request_id
|
|
1039
|
+
? getPendingDeviceAuth()?.request_id === request_id
|
|
1040
|
+
? getPendingDeviceAuth()
|
|
1041
|
+
: null
|
|
1042
|
+
: getPendingDeviceAuth();
|
|
1043
|
+
if (!pendingAuth && !request_id) {
|
|
1044
|
+
return {
|
|
1045
|
+
content: [
|
|
1046
|
+
{
|
|
1047
|
+
type: "text",
|
|
1048
|
+
text: JSON.stringify({ success: false, error: "No pending device auth request found." }, null, 2),
|
|
1049
|
+
},
|
|
1050
|
+
],
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
const requestId = request_id || pendingAuth?.request_id;
|
|
1054
|
+
if (!requestId) {
|
|
1055
|
+
return {
|
|
1056
|
+
content: [
|
|
1057
|
+
{
|
|
1058
|
+
type: "text",
|
|
1059
|
+
text: JSON.stringify({ success: false, error: "A request_id is required when nothing is saved locally." }, null, 2),
|
|
1060
|
+
},
|
|
1061
|
+
],
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
const result = await apiCall(`/auth/device/poll/${encodeURIComponent(requestId)}`);
|
|
1065
|
+
if (result.status === "pending" || result.status === "slow_down") {
|
|
1066
|
+
if (pendingAuth) {
|
|
1067
|
+
setPendingDeviceAuth({
|
|
1068
|
+
...pendingAuth,
|
|
1069
|
+
interval_seconds: result.interval ?? pendingAuth.interval_seconds,
|
|
1070
|
+
expires_at: result.expires_in !== undefined
|
|
1071
|
+
? new Date(Date.now() + result.expires_in * 1000).toISOString()
|
|
1072
|
+
: pendingAuth.expires_at,
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
return {
|
|
1076
|
+
content: [
|
|
1077
|
+
{
|
|
1078
|
+
type: "text",
|
|
1079
|
+
text: JSON.stringify({ success: true, ...result }, null, 2),
|
|
1080
|
+
},
|
|
1081
|
+
],
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
if (result.status === "denied" || result.status === "expired") {
|
|
1085
|
+
clearPendingDeviceAuth();
|
|
1086
|
+
clearStoredAuth();
|
|
1087
|
+
return {
|
|
1088
|
+
content: [
|
|
1089
|
+
{
|
|
1090
|
+
type: "text",
|
|
1091
|
+
text: JSON.stringify({ success: false, ...result }, null, 2),
|
|
1092
|
+
},
|
|
1093
|
+
],
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
if (!result.letagents_token) {
|
|
1097
|
+
throw new Error("Device auth completed without a LetAgents token.");
|
|
1098
|
+
}
|
|
1099
|
+
clearPendingDeviceAuth();
|
|
1100
|
+
const storedAuth = setStoredAuth({
|
|
1101
|
+
token: result.letagents_token,
|
|
1102
|
+
expires_at: result.expires_at,
|
|
1103
|
+
account: result.account,
|
|
1104
|
+
stored_at: new Date().toISOString(),
|
|
1105
|
+
source: "device_flow",
|
|
1106
|
+
});
|
|
1107
|
+
let joinedRoom = null;
|
|
1108
|
+
const roomToJoin = room_id ||
|
|
1109
|
+
pendingAuth?.suggested_room_id ||
|
|
1110
|
+
currentRoom?.room_id ||
|
|
1111
|
+
getRoomFromConfig() ||
|
|
1112
|
+
undefined;
|
|
1113
|
+
if (auto_join && roomToJoin) {
|
|
1114
|
+
const joinedVia = looksLikeInviteCode(roomToJoin) ? "join_code" : "join_room";
|
|
1115
|
+
const joined = await joinRoomIdentifier(roomToJoin, joinedVia);
|
|
1116
|
+
joinedRoom = joined.room;
|
|
1117
|
+
await autoRegisterAgentIdentity();
|
|
1118
|
+
}
|
|
1119
|
+
return {
|
|
1120
|
+
content: [
|
|
1121
|
+
{
|
|
1122
|
+
type: "text",
|
|
1123
|
+
text: JSON.stringify({
|
|
1124
|
+
success: true,
|
|
1125
|
+
status: "authorized",
|
|
1126
|
+
account: storedAuth.account ?? null,
|
|
1127
|
+
expires_at: storedAuth.expires_at ?? null,
|
|
1128
|
+
auto_joined_room: joinedRoom,
|
|
1129
|
+
}, null, 2),
|
|
1130
|
+
},
|
|
1131
|
+
],
|
|
1132
|
+
};
|
|
1133
|
+
});
|
|
1134
|
+
server.tool("clear_saved_auth", "Clear any locally saved LetAgents auth token and pending device auth request.", {}, async () => {
|
|
1135
|
+
clearPendingDeviceAuth();
|
|
1136
|
+
clearStoredAuth();
|
|
1137
|
+
return {
|
|
1138
|
+
content: [
|
|
1139
|
+
{
|
|
1140
|
+
type: "text",
|
|
1141
|
+
text: JSON.stringify({
|
|
1142
|
+
success: true,
|
|
1143
|
+
env_token_still_present: Boolean(process.env.LETAGENTS_TOKEN),
|
|
1144
|
+
}, null, 2),
|
|
1145
|
+
},
|
|
1146
|
+
],
|
|
1147
|
+
};
|
|
1148
|
+
});
|
|
1149
|
+
server.tool("resume_room_session", "Rejoin the last locally saved room context, or a specific saved room, after a restart. This recreates participation in the room; it does not preserve a prior server-side session ID.", {
|
|
1150
|
+
room_id: z
|
|
1151
|
+
.string()
|
|
1152
|
+
.optional()
|
|
1153
|
+
.describe("Optional saved room ID to resume. Defaults to the last current room."),
|
|
1154
|
+
}, async ({ room_id }) => {
|
|
1155
|
+
const savedRoom = (room_id ? getStoredRoomSession(room_id) : null) ??
|
|
1156
|
+
getStoredCurrentRoom();
|
|
1157
|
+
if (!savedRoom) {
|
|
1158
|
+
return {
|
|
1159
|
+
content: [
|
|
1160
|
+
{
|
|
1161
|
+
type: "text",
|
|
1162
|
+
text: JSON.stringify({ success: false, error: "No saved room session found." }, null, 2),
|
|
1163
|
+
},
|
|
1164
|
+
],
|
|
1165
|
+
};
|
|
1166
|
+
}
|
|
1167
|
+
const joined = await joinRoomIdentifier(savedRoom.room_id, savedRoom.joined_via);
|
|
1168
|
+
await autoRegisterAgentIdentity();
|
|
1169
|
+
return {
|
|
1170
|
+
content: [
|
|
1171
|
+
{
|
|
1172
|
+
type: "text",
|
|
1173
|
+
text: JSON.stringify({
|
|
1174
|
+
success: true,
|
|
1175
|
+
rejoined_from_local_state: true,
|
|
1176
|
+
server_session_resumed: false,
|
|
1177
|
+
last_message_id_before_restart: savedRoom.last_message_id ?? null,
|
|
1178
|
+
room: joined.room,
|
|
1179
|
+
}, null, 2),
|
|
1180
|
+
},
|
|
1181
|
+
],
|
|
1182
|
+
};
|
|
1183
|
+
});
|
|
1184
|
+
// -- check_repo_visibility --------------------------------------------------
|
|
1185
|
+
server.tool("check_repo_visibility", "Auto-detect the current repo's git remote and check if it's public or private. Returns the canonical key, provider, visibility, and suggested room type (discoverable for public, invite for private/unknown). Useful for deciding whether to auto-join a discoverable room or create an invite room.", {
|
|
1186
|
+
cwd: z
|
|
1187
|
+
.string()
|
|
1188
|
+
.optional()
|
|
1189
|
+
.describe("Working directory to detect git remote from. Defaults to the MCP server's working directory."),
|
|
1190
|
+
}, async ({ cwd }) => {
|
|
1191
|
+
const { autoDetectRepo } = await import("./repo-visibility.js");
|
|
1192
|
+
const result = await autoDetectRepo(cwd);
|
|
1193
|
+
if (!result) {
|
|
1194
|
+
return {
|
|
1195
|
+
content: [
|
|
1196
|
+
{
|
|
1197
|
+
type: "text",
|
|
1198
|
+
text: JSON.stringify({
|
|
1199
|
+
error: "Not in a git repository or no remote configured",
|
|
1200
|
+
suggestion: "Use create_project to create an invite room instead",
|
|
1201
|
+
}, null, 2),
|
|
1202
|
+
},
|
|
1203
|
+
],
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
619
1206
|
return {
|
|
620
1207
|
content: [
|
|
621
1208
|
{
|
|
@@ -631,29 +1218,32 @@ server.tool("wait_for_messages", "Wait for new messages in a Let Agents Chat pro
|
|
|
631
1218
|
async function main() {
|
|
632
1219
|
const transport = new StdioServerTransport();
|
|
633
1220
|
await server.connect(transport);
|
|
634
|
-
console.error("đ Let Agents Chat MCP server running on stdio (v0.
|
|
1221
|
+
console.error("đ Let Agents Chat MCP server running on stdio (v0.6.0)");
|
|
635
1222
|
// --- Auto-join from repo context ---
|
|
636
1223
|
try {
|
|
637
1224
|
// 1. Try .letagents.json config
|
|
638
1225
|
const configRoom = getRoomFromConfig();
|
|
639
1226
|
if (configRoom) {
|
|
640
|
-
|
|
641
|
-
currentRoom = { room: configRoom, project_id: project.id, code: project.code, joined_via: "config" };
|
|
642
|
-
sseClient.subscribe(project.id, (_message) => { server.server.sendResourceListChanged(); });
|
|
1227
|
+
await joinRoomIdentifier(configRoom, "config");
|
|
643
1228
|
console.error(`đ Auto-joined room '${configRoom}' (from .letagents.json)`);
|
|
644
1229
|
return;
|
|
645
1230
|
}
|
|
646
1231
|
// 2. Try git remote URL
|
|
647
1232
|
const gitRoom = getGitRemoteIdentity();
|
|
648
1233
|
if (gitRoom) {
|
|
649
|
-
|
|
650
|
-
currentRoom = { room: gitRoom, project_id: project.id, code: project.code, joined_via: "git-remote" };
|
|
651
|
-
sseClient.subscribe(project.id, (_message) => { server.server.sendResourceListChanged(); });
|
|
1234
|
+
await joinRoomIdentifier(gitRoom, "git-remote");
|
|
652
1235
|
console.error(`đ Auto-joined room '${gitRoom}' (inferred from git remote â consider adding a .letagents.json)`);
|
|
653
1236
|
return;
|
|
654
1237
|
}
|
|
655
|
-
// 3.
|
|
656
|
-
|
|
1238
|
+
// 3. Fall back to the most recent saved room session
|
|
1239
|
+
const savedCurrentRoom = getStoredCurrentRoom();
|
|
1240
|
+
if (savedCurrentRoom) {
|
|
1241
|
+
await joinRoomIdentifier(savedCurrentRoom.room_id, savedCurrentRoom.joined_via);
|
|
1242
|
+
console.error(`đ Rejoined saved room '${savedCurrentRoom.room_id}' (from local state)`);
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
// 4. No context found
|
|
1246
|
+
console.error("âšī¸ No .letagents.json, git remote, or saved room found â use join_project or join_room to connect.");
|
|
657
1247
|
}
|
|
658
1248
|
catch (err) {
|
|
659
1249
|
// Auto-join failure should never block the MCP server
|