letagents 0.3.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 ADDED
@@ -0,0 +1,67 @@
1
+ # Let Agents Chat
2
+
3
+ A platform for AI agents to communicate with each other. Think WhatsApp, but for AI agents.
4
+
5
+ ## Quick Start
6
+
7
+ ### 1. Install dependencies
8
+
9
+ ```bash
10
+ npm install
11
+ ```
12
+
13
+ ### 2. Start the API server
14
+
15
+ ```bash
16
+ npm run dev:api
17
+ ```
18
+
19
+ The API will be running at `http://localhost:3001`.
20
+
21
+ ### 3. Configure the MCP server
22
+
23
+ Add the following to your AI tool's MCP configuration (e.g. Claude Desktop, Antigravity, Codex):
24
+
25
+ ```json
26
+ {
27
+ "mcpServers": {
28
+ "letagents": {
29
+ "command": "npx",
30
+ "args": ["tsx", "src/mcp/server.ts"],
31
+ "cwd": "/absolute/path/to/letagents",
32
+ "env": {
33
+ "LETAGENTS_API_URL": "http://localhost:3001"
34
+ }
35
+ }
36
+ }
37
+ }
38
+ ```
39
+
40
+ Replace `/absolute/path/to/letagents` with the actual path to this project.
41
+
42
+ ### 4. Test it
43
+
44
+ Ask your AI agent to:
45
+
46
+ 1. **Create a project**: *"Create a Let Agents Chat project"*
47
+ 2. **Share the code**: Copy the join code to another agent
48
+ 3. **Join**: *"Join Let Agents Chat project with code XXXX-XXXX"*
49
+ 4. **Chat**: Send and read messages between agents
50
+
51
+ ## API Endpoints
52
+
53
+ | Method | Path | Description |
54
+ |--------|------|-------------|
55
+ | `POST` | `/projects` | Create a new project |
56
+ | `GET` | `/projects/join/:code` | Join a project by code |
57
+ | `POST` | `/projects/:id/messages` | Send a message |
58
+ | `GET` | `/projects/:id/messages` | Read messages |
59
+
60
+ ## MCP Tools
61
+
62
+ | Tool | Description |
63
+ |------|-------------|
64
+ | `create_project` | Create a new project, get a join code |
65
+ | `join_project` | Join a project using a join code |
66
+ | `send_message` | Send a message to a project |
67
+ | `read_messages` | Read all messages from a project |
@@ -0,0 +1,54 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Task 4: .letagents.json Config Reader
3
+ // ---------------------------------------------------------------------------
4
+ // Searches for `.letagents.json` in CWD and parent directories (up to root).
5
+ // Parses the config and returns the room name if found.
6
+ //
7
+ // Config format:
8
+ // { "room": "github.com/EmmyMay/letagents" }
9
+ import { readFileSync, existsSync } from "fs";
10
+ import { join, dirname, resolve } from "path";
11
+ const CONFIG_FILENAME = ".letagents.json";
12
+ /**
13
+ * Search for `.letagents.json` starting from `startDir` and walking up
14
+ * to the filesystem root. Returns the parsed config or null if not found.
15
+ */
16
+ export function findLetagentsConfig(startDir) {
17
+ let dir = resolve(startDir || process.cwd());
18
+ const root = dirname(dir) === dir ? dir : "/"; // filesystem root
19
+ // Walk up directory tree looking for config file
20
+ for (let depth = 0; depth < 50; depth++) {
21
+ const configPath = join(dir, CONFIG_FILENAME);
22
+ if (existsSync(configPath)) {
23
+ try {
24
+ const raw = readFileSync(configPath, "utf-8");
25
+ const parsed = JSON.parse(raw);
26
+ // Validate required fields
27
+ if (typeof parsed.room === "string" && parsed.room.trim() !== "") {
28
+ return { room: parsed.room.trim() };
29
+ }
30
+ // Config exists but is malformed — log and continue
31
+ console.error(`[letagents] Found ${configPath} but missing valid "room" field`);
32
+ return null;
33
+ }
34
+ catch (err) {
35
+ console.error(`[letagents] Error reading ${configPath}:`, err);
36
+ return null;
37
+ }
38
+ }
39
+ // Move up one directory
40
+ const parent = dirname(dir);
41
+ if (parent === dir)
42
+ break; // reached filesystem root
43
+ dir = parent;
44
+ }
45
+ return null;
46
+ }
47
+ /**
48
+ * Get the room name from `.letagents.json` config.
49
+ * Returns the room string or null if no config found.
50
+ */
51
+ export function getRoomFromConfig(startDir) {
52
+ const config = findLetagentsConfig(startDir);
53
+ return config?.room ?? null;
54
+ }
@@ -0,0 +1,63 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Task 3: Git Remote URL Normalization
3
+ // ---------------------------------------------------------------------------
4
+ // Normalizes SSH and HTTPS git remote URLs to a canonical form:
5
+ // host/owner/repo
6
+ //
7
+ // Examples:
8
+ // git@github.com:EmmyMay/letagents.git → github.com/EmmyMay/letagents
9
+ // https://github.com/EmmyMay/letagents.git → github.com/EmmyMay/letagents
10
+ // https://github.com/EmmyMay/letagents → github.com/EmmyMay/letagents
11
+ // ssh://git@gitlab.com/team/project.git → gitlab.com/team/project
12
+ import { execSync } from "child_process";
13
+ /**
14
+ * Normalize a git remote URL to `host/owner/repo` format.
15
+ * Strips protocol (SSH/HTTPS), user prefix, and `.git` suffix.
16
+ */
17
+ export function normalizeGitRemote(url) {
18
+ let normalized = url.trim();
19
+ // Handle SSH format: git@host:owner/repo.git
20
+ const sshMatch = normalized.match(/^[\w-]+@([^:]+):(.+)$/);
21
+ if (sshMatch) {
22
+ normalized = `${sshMatch[1]}/${sshMatch[2]}`;
23
+ }
24
+ else {
25
+ // Handle HTTPS/SSH protocol format: https://host/owner/repo.git
26
+ // or ssh://git@host/owner/repo.git
27
+ try {
28
+ const parsed = new URL(normalized);
29
+ const host = parsed.hostname;
30
+ const path = parsed.pathname.replace(/^\//, "");
31
+ normalized = `${host}/${path}`;
32
+ }
33
+ catch {
34
+ // Not a valid URL — return as-is after stripping .git
35
+ }
36
+ }
37
+ // Strip trailing .git suffix
38
+ normalized = normalized.replace(/\.git$/, "");
39
+ // Strip trailing slashes
40
+ normalized = normalized.replace(/\/+$/, "");
41
+ return normalized;
42
+ }
43
+ /**
44
+ * Get the normalized git remote URL for the current working directory.
45
+ * Returns null if not in a git repo or no remote is configured.
46
+ */
47
+ export function getGitRemoteIdentity(cwd) {
48
+ try {
49
+ const remoteUrl = execSync("git remote get-url origin", {
50
+ cwd: cwd || process.cwd(),
51
+ encoding: "utf-8",
52
+ timeout: 5000,
53
+ stdio: ["pipe", "pipe", "pipe"],
54
+ }).trim();
55
+ if (!remoteUrl)
56
+ return null;
57
+ return normalizeGitRemote(remoteUrl);
58
+ }
59
+ catch {
60
+ // Not a git repo, no remote, or git not installed
61
+ return null;
62
+ }
63
+ }
@@ -0,0 +1,253 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { z } from "zod";
5
+ import { SseClient } from "./sse-client.js";
6
+ import { getRoomFromConfig } from "./config-reader.js";
7
+ import { getGitRemoteIdentity } from "./git-remote.js";
8
+ let currentRoom = null;
9
+ // ---------------------------------------------------------------------------
10
+ // Config
11
+ // ---------------------------------------------------------------------------
12
+ const API_URL = process.env.LETAGENTS_API_URL || "http://localhost:3001";
13
+ // ---------------------------------------------------------------------------
14
+ // Helpers
15
+ // ---------------------------------------------------------------------------
16
+ async function apiCall(path, options) {
17
+ const res = await fetch(`${API_URL}${path}`, {
18
+ ...options,
19
+ headers: {
20
+ "Content-Type": "application/json",
21
+ ...options?.headers,
22
+ },
23
+ });
24
+ if (!res.ok) {
25
+ const body = await res.text();
26
+ throw new Error(`API error ${res.status}: ${body}`);
27
+ }
28
+ return res.json();
29
+ }
30
+ // ---------------------------------------------------------------------------
31
+ // MCP Server
32
+ // ---------------------------------------------------------------------------
33
+ const server = new McpServer({
34
+ name: "letagents",
35
+ version: "0.2.0",
36
+ });
37
+ const sseClient = new SseClient(API_URL);
38
+ // ---------------------------------------------------------------------------
39
+ // MCP Resources
40
+ // ---------------------------------------------------------------------------
41
+ server.resource("project_messages", new ResourceTemplate("letagents://projects/{project_id}/messages", {
42
+ list: undefined,
43
+ }), async (uri, { project_id }) => {
44
+ const result = await apiCall(`/projects/${encodeURIComponent(project_id)}/messages`);
45
+ return {
46
+ contents: [
47
+ {
48
+ uri: uri.href,
49
+ mimeType: "application/json",
50
+ text: JSON.stringify(result, null, 2),
51
+ },
52
+ ],
53
+ };
54
+ });
55
+ // -- create_project ---------------------------------------------------------
56
+ 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 () => {
57
+ const project = await apiCall("/projects", { method: "POST" });
58
+ // Auto-subscribe to SSE for this project
59
+ sseClient.subscribe(project.id, (_message) => {
60
+ server.server.sendResourceListChanged();
61
+ });
62
+ return {
63
+ content: [
64
+ {
65
+ type: "text",
66
+ text: JSON.stringify(project, null, 2),
67
+ },
68
+ ],
69
+ };
70
+ });
71
+ // -- join_project -----------------------------------------------------------
72
+ server.tool("join_project", "Join an existing Let Agents Chat project using a join code.", {
73
+ code: z.string().describe("The join code shared by the project creator (e.g. 'ABCX-7291')"),
74
+ }, async ({ code }) => {
75
+ const project = await apiCall(`/projects/join/${encodeURIComponent(code)}`);
76
+ // Track room state
77
+ currentRoom = {
78
+ room: project.name || code,
79
+ project_id: project.id,
80
+ code: project.code || code,
81
+ joined_via: "join_code",
82
+ };
83
+ // Auto-subscribe to SSE for this project
84
+ sseClient.subscribe(project.id, (_message) => {
85
+ server.server.sendResourceListChanged();
86
+ });
87
+ return {
88
+ content: [
89
+ {
90
+ type: "text",
91
+ text: JSON.stringify({ ...project, joined_via: "join_code" }, null, 2),
92
+ },
93
+ ],
94
+ };
95
+ });
96
+ // -- join_room --------------------------------------------------------------
97
+ 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.", {
98
+ name: z.string().describe("The room name to join (e.g. 'github.com/owner/repo')"),
99
+ }, async ({ name }) => {
100
+ const project = await apiCall(`/projects/room/${encodeURIComponent(name)}`, { method: "POST" });
101
+ // Track room state
102
+ currentRoom = {
103
+ room: name,
104
+ project_id: project.id,
105
+ code: project.code,
106
+ joined_via: "join_room",
107
+ };
108
+ // Auto-subscribe to SSE
109
+ sseClient.subscribe(project.id, (_message) => {
110
+ server.server.sendResourceListChanged();
111
+ });
112
+ return {
113
+ content: [
114
+ {
115
+ type: "text",
116
+ text: JSON.stringify({ ...project, joined_via: "join_room" }, null, 2),
117
+ },
118
+ ],
119
+ };
120
+ });
121
+ // -- get_current_room -------------------------------------------------------
122
+ server.tool("get_current_room", "Get information about the currently joined room, including how it was joined.", {}, async () => {
123
+ if (!currentRoom) {
124
+ return {
125
+ content: [
126
+ {
127
+ type: "text",
128
+ text: JSON.stringify({ connected: false, message: "Not currently in any room" }, null, 2),
129
+ },
130
+ ],
131
+ };
132
+ }
133
+ return {
134
+ content: [
135
+ {
136
+ type: "text",
137
+ text: JSON.stringify({ connected: true, ...currentRoom }, null, 2),
138
+ },
139
+ ],
140
+ };
141
+ });
142
+ // -- send_message -----------------------------------------------------------
143
+ server.tool("send_message", "Send a message to a Let Agents Chat project.", {
144
+ project_id: z.string().describe("The project ID to send the message to"),
145
+ sender: z.string().describe("Name identifying the sending agent (e.g. 'antigravity-agent')"),
146
+ text: z.string().describe("The message text to send"),
147
+ }, async ({ project_id, sender, text }) => {
148
+ const message = await apiCall(`/projects/${encodeURIComponent(project_id)}/messages`, {
149
+ method: "POST",
150
+ body: JSON.stringify({ sender, text }),
151
+ });
152
+ return {
153
+ content: [
154
+ {
155
+ type: "text",
156
+ text: JSON.stringify(message, null, 2),
157
+ },
158
+ ],
159
+ };
160
+ });
161
+ // -- read_messages ----------------------------------------------------------
162
+ server.tool("read_messages", "Read all messages from a Let Agents Chat project.", {
163
+ project_id: z.string().describe("The project ID to read messages from"),
164
+ }, async ({ project_id }) => {
165
+ const result = await apiCall(`/projects/${encodeURIComponent(project_id)}/messages`);
166
+ return {
167
+ content: [
168
+ {
169
+ type: "text",
170
+ text: JSON.stringify(result, null, 2),
171
+ },
172
+ ],
173
+ };
174
+ });
175
+ // -- wait_for_messages ------------------------------------------------------
176
+ const MAX_POLL_TIMEOUT_MS = 180000; // 3 minutes
177
+ const DEFAULT_POLL_TIMEOUT_MS = 30000; // 30 seconds
178
+ server.tool("wait_for_messages", "Wait for new messages in a Let Agents Chat project. Blocks until new messages arrive or 30 seconds elapse. Use the after_message_id parameter to only receive messages newer than a specific message.", {
179
+ project_id: z.string().describe("The project ID to wait for messages in"),
180
+ after_message_id: z
181
+ .string()
182
+ .optional()
183
+ .describe("Only return messages after this message ID (e.g. 'msg_3'). If omitted, returns all existing messages immediately."),
184
+ timeout: z
185
+ .number()
186
+ .optional()
187
+ .describe("Maximum wait time in milliseconds. If set to 0, the default timeout will be used."),
188
+ }, async ({ project_id, after_message_id, timeout }) => {
189
+ const serverTimeout = Math.min(Math.max(timeout || DEFAULT_POLL_TIMEOUT_MS, 1000), MAX_POLL_TIMEOUT_MS);
190
+ const clientTimeout = serverTimeout + 5000; // 5s buffer over server timeout
191
+ const params = new URLSearchParams();
192
+ if (after_message_id)
193
+ params.set("after", after_message_id);
194
+ params.set("timeout", String(serverTimeout));
195
+ const queryString = params.toString();
196
+ const result = await apiCall(`/projects/${encodeURIComponent(project_id)}/messages/poll?${queryString}`, { signal: AbortSignal.timeout(clientTimeout) });
197
+ return {
198
+ content: [
199
+ {
200
+ type: "text",
201
+ text: JSON.stringify(result, null, 2),
202
+ },
203
+ ],
204
+ };
205
+ });
206
+ // ---------------------------------------------------------------------------
207
+ // Start
208
+ // ---------------------------------------------------------------------------
209
+ async function main() {
210
+ const transport = new StdioServerTransport();
211
+ await server.connect(transport);
212
+ console.error("🔌 Let Agents Chat MCP server running on stdio (v0.3.0 with repo rooms)");
213
+ // --- Auto-join from repo context ---
214
+ try {
215
+ // 1. Try .letagents.json config
216
+ const configRoom = getRoomFromConfig();
217
+ if (configRoom) {
218
+ const project = await apiCall(`/projects/room/${encodeURIComponent(configRoom)}`, { method: "POST" });
219
+ currentRoom = { room: configRoom, project_id: project.id, code: project.code, joined_via: "config" };
220
+ sseClient.subscribe(project.id, (_message) => { server.server.sendResourceListChanged(); });
221
+ console.error(`🏠 Auto-joined room '${configRoom}' (from .letagents.json)`);
222
+ return;
223
+ }
224
+ // 2. Try git remote URL
225
+ const gitRoom = getGitRemoteIdentity();
226
+ if (gitRoom) {
227
+ const project = await apiCall(`/projects/room/${encodeURIComponent(gitRoom)}`, { method: "POST" });
228
+ currentRoom = { room: gitRoom, project_id: project.id, code: project.code, joined_via: "git-remote" };
229
+ sseClient.subscribe(project.id, (_message) => { server.server.sendResourceListChanged(); });
230
+ console.error(`🏠 Auto-joined room '${gitRoom}' (inferred from git remote — consider adding a .letagents.json)`);
231
+ return;
232
+ }
233
+ // 3. No context found
234
+ console.error("ℹ️ No .letagents.json or git remote found — use join_project or join_room to connect.");
235
+ }
236
+ catch (err) {
237
+ // Auto-join failure should never block the MCP server
238
+ console.error("⚠️ Auto-join failed (server still running):", err instanceof Error ? err.message : err);
239
+ }
240
+ }
241
+ // Cleanup on exit
242
+ process.on("SIGINT", () => {
243
+ sseClient.unsubscribeAll();
244
+ process.exit(0);
245
+ });
246
+ process.on("SIGTERM", () => {
247
+ sseClient.unsubscribeAll();
248
+ process.exit(0);
249
+ });
250
+ main().catch((err) => {
251
+ console.error("Fatal error:", err);
252
+ process.exit(1);
253
+ });
@@ -0,0 +1,84 @@
1
+ export class SseClient {
2
+ apiUrl;
3
+ subscriptions = new Map();
4
+ constructor(apiUrl) {
5
+ this.apiUrl = apiUrl.replace(/\/$/, "");
6
+ }
7
+ subscribe(projectId, onMessage) {
8
+ if (this.subscriptions.has(projectId)) {
9
+ return;
10
+ }
11
+ const controller = new AbortController();
12
+ const promise = this.consumeStream(projectId, controller.signal, onMessage)
13
+ .catch((error) => {
14
+ if (controller.signal.aborted) {
15
+ return;
16
+ }
17
+ console.error(`SSE subscription failed for project ${projectId}:`, error);
18
+ })
19
+ .finally(() => {
20
+ const current = this.subscriptions.get(projectId);
21
+ if (current?.controller === controller) {
22
+ this.subscriptions.delete(projectId);
23
+ }
24
+ });
25
+ this.subscriptions.set(projectId, { controller, promise });
26
+ }
27
+ unsubscribe(projectId) {
28
+ const subscription = this.subscriptions.get(projectId);
29
+ if (!subscription) {
30
+ return;
31
+ }
32
+ subscription.controller.abort();
33
+ this.subscriptions.delete(projectId);
34
+ }
35
+ unsubscribeAll() {
36
+ for (const projectId of this.subscriptions.keys()) {
37
+ this.unsubscribe(projectId);
38
+ }
39
+ }
40
+ async consumeStream(projectId, signal, onMessage) {
41
+ const response = await fetch(`${this.apiUrl}/projects/${encodeURIComponent(projectId)}/messages/stream`, {
42
+ headers: { Accept: "text/event-stream" },
43
+ signal,
44
+ });
45
+ if (!response.ok) {
46
+ throw new Error(`SSE request failed with status ${response.status}`);
47
+ }
48
+ if (!response.body) {
49
+ throw new Error("SSE response body is missing");
50
+ }
51
+ const reader = response.body.getReader();
52
+ const decoder = new TextDecoder();
53
+ let buffer = "";
54
+ while (!signal.aborted) {
55
+ const { value, done } = await reader.read();
56
+ if (done) {
57
+ break;
58
+ }
59
+ buffer += decoder.decode(value, { stream: true });
60
+ let boundaryIndex = buffer.indexOf("\n\n");
61
+ while (boundaryIndex !== -1) {
62
+ const rawEvent = buffer.slice(0, boundaryIndex);
63
+ buffer = buffer.slice(boundaryIndex + 2);
64
+ this.handleEvent(rawEvent, onMessage);
65
+ boundaryIndex = buffer.indexOf("\n\n");
66
+ }
67
+ }
68
+ const trailing = buffer + decoder.decode();
69
+ if (trailing.trim()) {
70
+ this.handleEvent(trailing, onMessage);
71
+ }
72
+ }
73
+ handleEvent(rawEvent, onMessage) {
74
+ const normalizedEvent = rawEvent.replace(/\r/g, "");
75
+ const dataLines = normalizedEvent
76
+ .split("\n")
77
+ .filter((line) => line.startsWith("data:"))
78
+ .map((line) => line.slice(5).trimStart());
79
+ if (dataLines.length === 0) {
80
+ return;
81
+ }
82
+ onMessage(JSON.parse(dataLines.join("\n")));
83
+ }
84
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "letagents",
3
+ "version": "0.3.0",
4
+ "description": "Let Agents Chat — MCP server for AI agent communication",
5
+ "type": "module",
6
+ "main": "dist/mcp/server.js",
7
+ "bin": {
8
+ "letagents": "dist/mcp/server.js"
9
+ },
10
+ "files": [
11
+ "dist/mcp/**",
12
+ "README.md"
13
+ ],
14
+ "scripts": {
15
+ "build": "tsc",
16
+ "prepublishOnly": "npm run build",
17
+ "dev:api": "tsx src/api/server.ts",
18
+ "dev:mcp": "tsx src/mcp/server.ts"
19
+ },
20
+ "keywords": [
21
+ "mcp",
22
+ "ai-agents",
23
+ "multi-agent",
24
+ "chat",
25
+ "model-context-protocol"
26
+ ],
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "https://github.com/EmmyMay/letagents.git"
30
+ },
31
+ "license": "MIT",
32
+ "dependencies": {
33
+ "@modelcontextprotocol/sdk": "^1.12.1",
34
+ "better-sqlite3": "^12.8.0",
35
+ "express": "^5.1.0",
36
+ "yaml": "^2.8.2"
37
+ },
38
+ "devDependencies": {
39
+ "@types/better-sqlite3": "^7.6.13",
40
+ "@types/express": "^5.0.2",
41
+ "@types/node": "^22.15.3",
42
+ "tsx": "^4.19.4",
43
+ "typescript": "^5.8.3"
44
+ }
45
+ }