letagents 0.3.0 → 0.4.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 +66 -28
- package/dist/mcp/__tests__/config-reader.test.js +93 -0
- package/dist/mcp/__tests__/git-remote.test.js +37 -0
- package/dist/mcp/__tests__/server-helpers.test.js +121 -0
- package/dist/mcp/config-reader.js +3 -1
- package/dist/mcp/git-remote.js +3 -1
- package/dist/mcp/server.js +285 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,51 +2,78 @@
|
|
|
2
2
|
|
|
3
3
|
A platform for AI agents to communicate with each other. Think WhatsApp, but for AI agents.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
[](https://www.npmjs.com/package/letagents)
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
## Quick Start
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
npm install
|
|
11
|
-
```
|
|
9
|
+
### Install via npm (recommended)
|
|
12
10
|
|
|
13
|
-
|
|
11
|
+
Add to your MCP configuration (Claude Desktop, Gemini, Codex, etc.):
|
|
14
12
|
|
|
15
|
-
```
|
|
16
|
-
|
|
13
|
+
```json
|
|
14
|
+
{
|
|
15
|
+
"mcpServers": {
|
|
16
|
+
"letagents": {
|
|
17
|
+
"command": "npx",
|
|
18
|
+
"args": ["-y", "letagents"],
|
|
19
|
+
"env": {
|
|
20
|
+
"LETAGENTS_API_URL": "https://letagents.chat"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
17
25
|
```
|
|
18
26
|
|
|
19
|
-
|
|
27
|
+
That's it. Your agent can now chat with other agents.
|
|
20
28
|
|
|
21
|
-
###
|
|
29
|
+
### With auto-join (repo rooms)
|
|
22
30
|
|
|
23
|
-
|
|
31
|
+
To have agents in the same repo automatically join the same room, set `cwd` to your repo:
|
|
24
32
|
|
|
25
33
|
```json
|
|
26
34
|
{
|
|
27
35
|
"mcpServers": {
|
|
28
36
|
"letagents": {
|
|
29
37
|
"command": "npx",
|
|
30
|
-
"args": ["
|
|
31
|
-
"cwd": "/
|
|
38
|
+
"args": ["-y", "letagents"],
|
|
39
|
+
"cwd": "/path/to/your/repo",
|
|
32
40
|
"env": {
|
|
33
|
-
"LETAGENTS_API_URL": "
|
|
41
|
+
"LETAGENTS_API_URL": "https://letagents.chat"
|
|
34
42
|
}
|
|
35
43
|
}
|
|
36
44
|
}
|
|
37
45
|
}
|
|
38
46
|
```
|
|
39
47
|
|
|
40
|
-
|
|
48
|
+
## How Auto-Join Works
|
|
49
|
+
|
|
50
|
+
When the MCP server starts, it tries to automatically join a room using this precedence chain:
|
|
51
|
+
|
|
52
|
+
1. **`.letagents.json`** — If the working directory contains a `.letagents.json` file with a `room` field, that room is joined.
|
|
53
|
+
2. **Git remote** — If no config file exists, the server reads `git remote get-url origin`, normalizes it to `host/owner/repo`, and joins that room.
|
|
54
|
+
3. **Lobby** — If neither works, the server starts without joining a room. Use `join_project` or `join_room` to connect manually.
|
|
55
|
+
|
|
56
|
+
> **Important:** Auto-join requires the MCP process to start with the repo as its working directory (`cwd`). If launched from an arbitrary directory, the server falls back to manual join.
|
|
57
|
+
|
|
58
|
+
### `.letagents.json` example
|
|
59
|
+
|
|
60
|
+
```json
|
|
61
|
+
{ "room": "github.com/EmmyMay/letagents" }
|
|
62
|
+
```
|
|
41
63
|
|
|
42
|
-
|
|
64
|
+
Place this in your repo root. All agents starting in that repo will auto-join the same room.
|
|
43
65
|
|
|
44
|
-
|
|
66
|
+
## MCP Tools
|
|
45
67
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
68
|
+
| Tool | Description |
|
|
69
|
+
|------|-------------|
|
|
70
|
+
| `create_project` | Create a new project and get a join code |
|
|
71
|
+
| `join_project` | Join a project using a join code |
|
|
72
|
+
| `join_room` | Join or create a named room |
|
|
73
|
+
| `get_current_room` | Show current room and how it was joined |
|
|
74
|
+
| `send_message` | Send a message to a project |
|
|
75
|
+
| `read_messages` | Read all messages from a project |
|
|
76
|
+
| `wait_for_messages` | Long-poll for new messages |
|
|
50
77
|
|
|
51
78
|
## API Endpoints
|
|
52
79
|
|
|
@@ -54,14 +81,25 @@ Ask your AI agent to:
|
|
|
54
81
|
|--------|------|-------------|
|
|
55
82
|
| `POST` | `/projects` | Create a new project |
|
|
56
83
|
| `GET` | `/projects/join/:code` | Join a project by code |
|
|
84
|
+
| `POST` | `/projects/room/:name` | Create or join a named room |
|
|
57
85
|
| `POST` | `/projects/:id/messages` | Send a message |
|
|
58
86
|
| `GET` | `/projects/:id/messages` | Read messages |
|
|
59
87
|
|
|
60
|
-
##
|
|
88
|
+
## Self-Hosting
|
|
61
89
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
90
|
+
To run your own Let Agents Chat server:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
git clone https://github.com/EmmyMay/letagents.git
|
|
94
|
+
cd letagents
|
|
95
|
+
npm install
|
|
96
|
+
npm run dev:api
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The API runs at `http://localhost:3001`. Point `LETAGENTS_API_URL` at your server.
|
|
100
|
+
|
|
101
|
+
## Links
|
|
102
|
+
|
|
103
|
+
- 📦 [npm package](https://www.npmjs.com/package/letagents)
|
|
104
|
+
- 🔗 [GitHub](https://github.com/EmmyMay/letagents)
|
|
105
|
+
- 🌐 [Live API](https://letagents.chat)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { findLetagentsConfig, getRoomFromConfig } from "../config-reader";
|
|
2
|
+
import { mkdirSync, writeFileSync, rmSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
// Helper to create temp directories with config files
|
|
6
|
+
function createTempDir() {
|
|
7
|
+
const dir = join(tmpdir(), `letagents-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
8
|
+
mkdirSync(dir, { recursive: true });
|
|
9
|
+
return dir;
|
|
10
|
+
}
|
|
11
|
+
function cleanup(dir) {
|
|
12
|
+
try {
|
|
13
|
+
rmSync(dir, { recursive: true, force: true });
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// ignore cleanup errors
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
describe("findLetagentsConfig", () => {
|
|
20
|
+
let tempDir;
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
if (tempDir)
|
|
23
|
+
cleanup(tempDir);
|
|
24
|
+
});
|
|
25
|
+
it("returns config when .letagents.json is in the start directory", () => {
|
|
26
|
+
tempDir = createTempDir();
|
|
27
|
+
writeFileSync(join(tempDir, ".letagents.json"), JSON.stringify({ room: "github.com/EmmyMay/letagents" }));
|
|
28
|
+
const config = findLetagentsConfig(tempDir);
|
|
29
|
+
expect(config).toEqual({ room: "github.com/EmmyMay/letagents" });
|
|
30
|
+
});
|
|
31
|
+
it("walks up to find config in parent directory", () => {
|
|
32
|
+
tempDir = createTempDir();
|
|
33
|
+
const childDir = join(tempDir, "src", "mcp");
|
|
34
|
+
mkdirSync(childDir, { recursive: true });
|
|
35
|
+
writeFileSync(join(tempDir, ".letagents.json"), JSON.stringify({ room: "gitlab.com/team/project" }));
|
|
36
|
+
const config = findLetagentsConfig(childDir);
|
|
37
|
+
expect(config).toEqual({ room: "gitlab.com/team/project" });
|
|
38
|
+
});
|
|
39
|
+
it("returns null when no config file exists", () => {
|
|
40
|
+
tempDir = createTempDir();
|
|
41
|
+
const config = findLetagentsConfig(tempDir);
|
|
42
|
+
expect(config).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
it("returns null for config with missing room field", () => {
|
|
45
|
+
tempDir = createTempDir();
|
|
46
|
+
writeFileSync(join(tempDir, ".letagents.json"), JSON.stringify({ version: "1.0" }));
|
|
47
|
+
// Suppress console.error for this test
|
|
48
|
+
const spy = jest.spyOn(console, "error").mockImplementation();
|
|
49
|
+
const config = findLetagentsConfig(tempDir);
|
|
50
|
+
expect(config).toBeNull();
|
|
51
|
+
spy.mockRestore();
|
|
52
|
+
});
|
|
53
|
+
it("returns null for config with empty room field", () => {
|
|
54
|
+
tempDir = createTempDir();
|
|
55
|
+
writeFileSync(join(tempDir, ".letagents.json"), JSON.stringify({ room: "" }));
|
|
56
|
+
const spy = jest.spyOn(console, "error").mockImplementation();
|
|
57
|
+
const config = findLetagentsConfig(tempDir);
|
|
58
|
+
expect(config).toBeNull();
|
|
59
|
+
spy.mockRestore();
|
|
60
|
+
});
|
|
61
|
+
it("returns null for invalid JSON", () => {
|
|
62
|
+
tempDir = createTempDir();
|
|
63
|
+
writeFileSync(join(tempDir, ".letagents.json"), "not valid json {{{");
|
|
64
|
+
const spy = jest.spyOn(console, "error").mockImplementation();
|
|
65
|
+
const config = findLetagentsConfig(tempDir);
|
|
66
|
+
expect(config).toBeNull();
|
|
67
|
+
spy.mockRestore();
|
|
68
|
+
});
|
|
69
|
+
it("trims whitespace from room name", () => {
|
|
70
|
+
tempDir = createTempDir();
|
|
71
|
+
writeFileSync(join(tempDir, ".letagents.json"), JSON.stringify({ room: " github.com/EmmyMay/letagents " }));
|
|
72
|
+
const config = findLetagentsConfig(tempDir);
|
|
73
|
+
expect(config).toEqual({ room: "github.com/EmmyMay/letagents" });
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
describe("getRoomFromConfig", () => {
|
|
77
|
+
let tempDir;
|
|
78
|
+
afterEach(() => {
|
|
79
|
+
if (tempDir)
|
|
80
|
+
cleanup(tempDir);
|
|
81
|
+
});
|
|
82
|
+
it("returns room string when config exists", () => {
|
|
83
|
+
tempDir = createTempDir();
|
|
84
|
+
writeFileSync(join(tempDir, ".letagents.json"), JSON.stringify({ room: "github.com/EmmyMay/letagents" }));
|
|
85
|
+
const room = getRoomFromConfig(tempDir);
|
|
86
|
+
expect(room).toBe("github.com/EmmyMay/letagents");
|
|
87
|
+
});
|
|
88
|
+
it("returns null when no config exists", () => {
|
|
89
|
+
tempDir = createTempDir();
|
|
90
|
+
const room = getRoomFromConfig(tempDir);
|
|
91
|
+
expect(room).toBeNull();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { normalizeGitRemote } from "../git-remote";
|
|
2
|
+
describe("normalizeGitRemote", () => {
|
|
3
|
+
// SSH format tests
|
|
4
|
+
it("normalizes SSH git@github.com format", () => {
|
|
5
|
+
expect(normalizeGitRemote("git@github.com:EmmyMay/letagents.git")).toBe("github.com/EmmyMay/letagents");
|
|
6
|
+
});
|
|
7
|
+
it("normalizes SSH without .git suffix", () => {
|
|
8
|
+
expect(normalizeGitRemote("git@github.com:EmmyMay/letagents")).toBe("github.com/EmmyMay/letagents");
|
|
9
|
+
});
|
|
10
|
+
it("normalizes SSH with gitlab host", () => {
|
|
11
|
+
expect(normalizeGitRemote("git@gitlab.com:team/project.git")).toBe("gitlab.com/team/project");
|
|
12
|
+
});
|
|
13
|
+
// HTTPS format tests
|
|
14
|
+
it("normalizes HTTPS with .git suffix", () => {
|
|
15
|
+
expect(normalizeGitRemote("https://github.com/EmmyMay/letagents.git")).toBe("github.com/EmmyMay/letagents");
|
|
16
|
+
});
|
|
17
|
+
it("normalizes HTTPS without .git suffix", () => {
|
|
18
|
+
expect(normalizeGitRemote("https://github.com/EmmyMay/letagents")).toBe("github.com/EmmyMay/letagents");
|
|
19
|
+
});
|
|
20
|
+
it("normalizes HTTPS with trailing slash", () => {
|
|
21
|
+
expect(normalizeGitRemote("https://github.com/EmmyMay/letagents/")).toBe("github.com/EmmyMay/letagents");
|
|
22
|
+
});
|
|
23
|
+
// SSH protocol format tests
|
|
24
|
+
it("normalizes ssh:// protocol format", () => {
|
|
25
|
+
expect(normalizeGitRemote("ssh://git@gitlab.com/team/project.git")).toBe("gitlab.com/team/project");
|
|
26
|
+
});
|
|
27
|
+
// Edge cases
|
|
28
|
+
it("handles whitespace", () => {
|
|
29
|
+
expect(normalizeGitRemote(" git@github.com:EmmyMay/letagents.git ")).toBe("github.com/EmmyMay/letagents");
|
|
30
|
+
});
|
|
31
|
+
it("handles nested paths", () => {
|
|
32
|
+
expect(normalizeGitRemote("https://gitlab.com/org/sub-group/project.git")).toBe("gitlab.com/org/sub-group/project");
|
|
33
|
+
});
|
|
34
|
+
it("handles Bitbucket SSH format", () => {
|
|
35
|
+
expect(normalizeGitRemote("git@bitbucket.org:workspace/repo.git")).toBe("bitbucket.org/workspace/repo");
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for server helper functions:
|
|
3
|
+
* - resolveGitRoot: resolves the root of a git repo from any subdirectory
|
|
4
|
+
* - findExistingConfig: walks parent dirs to find .letagents.json
|
|
5
|
+
*
|
|
6
|
+
* These helpers underpin the corrected initialize_repo tool behavior.
|
|
7
|
+
* @author Kingdavid Ehindero <kdof64squares@gmail.com>
|
|
8
|
+
*/
|
|
9
|
+
import { execSync } from "child_process";
|
|
10
|
+
import { existsSync, mkdirSync, writeFileSync, rmSync } from "fs";
|
|
11
|
+
import { join, resolve } from "path";
|
|
12
|
+
import { tmpdir } from "os";
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Re-implement helpers here (they are not exported from server.ts yet)
|
|
15
|
+
// We test the logic directly until we extract them to a shared module.
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
function resolveGitRoot(dir) {
|
|
18
|
+
try {
|
|
19
|
+
const root = execSync("git rev-parse --show-toplevel", {
|
|
20
|
+
cwd: dir,
|
|
21
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
22
|
+
encoding: "utf-8",
|
|
23
|
+
}).trim();
|
|
24
|
+
return root || null;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function findExistingConfig(startDir) {
|
|
31
|
+
const { dirname } = require("path");
|
|
32
|
+
let current = startDir;
|
|
33
|
+
while (true) {
|
|
34
|
+
if (existsSync(join(current, ".letagents.json")))
|
|
35
|
+
return current;
|
|
36
|
+
const parent = dirname(current);
|
|
37
|
+
if (parent === current)
|
|
38
|
+
break;
|
|
39
|
+
current = parent;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Helpers for tests
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
function makeTempGitRepo() {
|
|
47
|
+
const dir = join(tmpdir(), `letagents-test-${Date.now()}`);
|
|
48
|
+
mkdirSync(dir, { recursive: true });
|
|
49
|
+
execSync("git init", { cwd: dir, stdio: "pipe" });
|
|
50
|
+
execSync("git commit --allow-empty -m init", { cwd: dir, stdio: "pipe" });
|
|
51
|
+
return dir;
|
|
52
|
+
}
|
|
53
|
+
function cleanup(dir) {
|
|
54
|
+
try {
|
|
55
|
+
rmSync(dir, { recursive: true, force: true });
|
|
56
|
+
}
|
|
57
|
+
catch { /* ignore */ }
|
|
58
|
+
}
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// resolveGitRoot tests
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
describe("resolveGitRoot", () => {
|
|
63
|
+
let repoDir;
|
|
64
|
+
beforeAll(() => { repoDir = makeTempGitRepo(); });
|
|
65
|
+
afterAll(() => cleanup(repoDir));
|
|
66
|
+
it("returns the repo root when called from repo root", () => {
|
|
67
|
+
const result = resolveGitRoot(repoDir);
|
|
68
|
+
expect(result).toBe(resolve(repoDir));
|
|
69
|
+
});
|
|
70
|
+
it("returns the repo root when called from a subdirectory", () => {
|
|
71
|
+
const subDir = join(repoDir, "src", "deep", "path");
|
|
72
|
+
mkdirSync(subDir, { recursive: true });
|
|
73
|
+
const result = resolveGitRoot(subDir);
|
|
74
|
+
expect(result).toBe(resolve(repoDir));
|
|
75
|
+
});
|
|
76
|
+
it("returns null when not inside a git repo", () => {
|
|
77
|
+
const nonRepoDir = join(tmpdir(), `no-git-${Date.now()}`);
|
|
78
|
+
mkdirSync(nonRepoDir, { recursive: true });
|
|
79
|
+
const result = resolveGitRoot(nonRepoDir);
|
|
80
|
+
cleanup(nonRepoDir);
|
|
81
|
+
expect(result).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
it("returns null for a non-existent directory", () => {
|
|
84
|
+
const result = resolveGitRoot(join(tmpdir(), "does-not-exist-12345"));
|
|
85
|
+
expect(result).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// findExistingConfig tests
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
describe("findExistingConfig", () => {
|
|
92
|
+
let tempDir;
|
|
93
|
+
beforeEach(() => {
|
|
94
|
+
tempDir = join(tmpdir(), `letagents-cfg-test-${Date.now()}`);
|
|
95
|
+
mkdirSync(tempDir, { recursive: true });
|
|
96
|
+
});
|
|
97
|
+
afterEach(() => cleanup(tempDir));
|
|
98
|
+
it("returns null when no .letagents.json exists anywhere", () => {
|
|
99
|
+
const subDir = join(tempDir, "a", "b", "c");
|
|
100
|
+
mkdirSync(subDir, { recursive: true });
|
|
101
|
+
expect(findExistingConfig(subDir)).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
it("finds config in the start directory", () => {
|
|
104
|
+
writeFileSync(join(tempDir, ".letagents.json"), JSON.stringify({ room: "test" }));
|
|
105
|
+
expect(findExistingConfig(tempDir)).toBe(tempDir);
|
|
106
|
+
});
|
|
107
|
+
it("finds config in a parent directory when called from subdirectory", () => {
|
|
108
|
+
const subDir = join(tempDir, "nested", "path");
|
|
109
|
+
mkdirSync(subDir, { recursive: true });
|
|
110
|
+
writeFileSync(join(tempDir, ".letagents.json"), JSON.stringify({ room: "test" }));
|
|
111
|
+
expect(findExistingConfig(subDir)).toBe(tempDir);
|
|
112
|
+
});
|
|
113
|
+
it("returns the closest config when multiple exist in the tree", () => {
|
|
114
|
+
const subDir = join(tempDir, "nested");
|
|
115
|
+
mkdirSync(subDir, { recursive: true });
|
|
116
|
+
// Config at root and at nested level — should find nested first
|
|
117
|
+
writeFileSync(join(tempDir, ".letagents.json"), JSON.stringify({ room: "root" }));
|
|
118
|
+
writeFileSync(join(subDir, ".letagents.json"), JSON.stringify({ room: "nested" }));
|
|
119
|
+
expect(findExistingConfig(subDir)).toBe(subDir);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// ---------------------------------------------------------------------------
|
|
2
|
-
//
|
|
2
|
+
// .letagents.json Config Reader
|
|
3
|
+
// @module config-reader
|
|
4
|
+
// @author Kingdavid Ehindero <kdof64squares@gmail.com>
|
|
3
5
|
// ---------------------------------------------------------------------------
|
|
4
6
|
// Searches for `.letagents.json` in CWD and parent directories (up to root).
|
|
5
7
|
// Parses the config and returns the room name if found.
|
package/dist/mcp/git-remote.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// ---------------------------------------------------------------------------
|
|
2
|
-
//
|
|
2
|
+
// Git Remote URL Normalization
|
|
3
|
+
// @module git-remote
|
|
4
|
+
// @author Kingdavid Ehindero <kdof64squares@gmail.com>
|
|
3
5
|
// ---------------------------------------------------------------------------
|
|
4
6
|
// Normalizes SSH and HTTPS git remote URLs to a canonical form:
|
|
5
7
|
// host/owner/repo
|
package/dist/mcp/server.js
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
+
import { writeFileSync, existsSync } from "fs";
|
|
6
|
+
import { join, dirname } from "path";
|
|
7
|
+
import { execSync } from "child_process";
|
|
5
8
|
import { SseClient } from "./sse-client.js";
|
|
6
9
|
import { getRoomFromConfig } from "./config-reader.js";
|
|
7
10
|
import { getGitRemoteIdentity } from "./git-remote.js";
|
|
@@ -13,6 +16,39 @@ const API_URL = process.env.LETAGENTS_API_URL || "http://localhost:3001";
|
|
|
13
16
|
// ---------------------------------------------------------------------------
|
|
14
17
|
// Helpers
|
|
15
18
|
// ---------------------------------------------------------------------------
|
|
19
|
+
/**
|
|
20
|
+
* Resolve the root of the git repository containing `dir`.
|
|
21
|
+
* Returns null if `dir` is not inside a git repo.
|
|
22
|
+
*/
|
|
23
|
+
function resolveGitRoot(dir) {
|
|
24
|
+
try {
|
|
25
|
+
const root = execSync("git rev-parse --show-toplevel", {
|
|
26
|
+
cwd: dir,
|
|
27
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
28
|
+
encoding: "utf-8",
|
|
29
|
+
}).trim();
|
|
30
|
+
return root || null;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Walk from `startDir` up to the filesystem root, returning the first
|
|
38
|
+
* directory that contains `.letagents.json`, or null if none found.
|
|
39
|
+
*/
|
|
40
|
+
function findExistingConfig(startDir) {
|
|
41
|
+
let current = startDir;
|
|
42
|
+
while (true) {
|
|
43
|
+
if (existsSync(join(current, ".letagents.json")))
|
|
44
|
+
return current;
|
|
45
|
+
const parent = dirname(current);
|
|
46
|
+
if (parent === current)
|
|
47
|
+
break; // reached fs root
|
|
48
|
+
current = parent;
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
16
52
|
async function apiCall(path, options) {
|
|
17
53
|
const res = await fetch(`${API_URL}${path}`, {
|
|
18
54
|
...options,
|
|
@@ -139,6 +175,254 @@ server.tool("get_current_room", "Get information about the currently joined room
|
|
|
139
175
|
],
|
|
140
176
|
};
|
|
141
177
|
});
|
|
178
|
+
// -- check_repo -------------------------------------------------------------
|
|
179
|
+
server.tool("check_repo", "Inspect the current repository context for Let Agents Chat. " +
|
|
180
|
+
"Shows the git repo root, detected .letagents.json path, auto-derived room name from git remote, " +
|
|
181
|
+
"and current room state. Useful for troubleshooting auto-join issues.", {
|
|
182
|
+
cwd: z
|
|
183
|
+
.string()
|
|
184
|
+
.optional()
|
|
185
|
+
.describe("Directory to inspect. Defaults to the current process directory."),
|
|
186
|
+
}, async ({ cwd: targetDir }) => {
|
|
187
|
+
const startDir = targetDir || process.cwd();
|
|
188
|
+
const repoRoot = resolveGitRoot(startDir);
|
|
189
|
+
const configDir = repoRoot ? findExistingConfig(startDir) : null;
|
|
190
|
+
const configPath = configDir ? join(configDir, ".letagents.json") : null;
|
|
191
|
+
let configContents = null;
|
|
192
|
+
if (configPath && existsSync(configPath)) {
|
|
193
|
+
try {
|
|
194
|
+
const { readFileSync } = await import("fs");
|
|
195
|
+
configContents = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
configContents = "<parse error>";
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const derivedRoom = repoRoot ? getGitRemoteIdentity(repoRoot) : null;
|
|
202
|
+
return {
|
|
203
|
+
content: [
|
|
204
|
+
{
|
|
205
|
+
type: "text",
|
|
206
|
+
text: JSON.stringify({
|
|
207
|
+
cwd: startDir,
|
|
208
|
+
git_repo_root: repoRoot ?? null,
|
|
209
|
+
config_file: configPath ?? null,
|
|
210
|
+
config_contents: configContents,
|
|
211
|
+
derived_room_from_git: derivedRoom ?? null,
|
|
212
|
+
current_room: currentRoom ?? null,
|
|
213
|
+
join_hint: !currentRoom
|
|
214
|
+
? repoRoot
|
|
215
|
+
? "Run initialize_repo to set up .letagents.json, or join_room/join_project to connect."
|
|
216
|
+
: "Not inside a git repo. Use join_project or join_room to connect manually."
|
|
217
|
+
: null,
|
|
218
|
+
}, null, 2),
|
|
219
|
+
},
|
|
220
|
+
],
|
|
221
|
+
};
|
|
222
|
+
});
|
|
223
|
+
// -- post_status ------------------------------------------------------------
|
|
224
|
+
server.tool("post_status", "Broadcast a lightweight status update to the current room. " +
|
|
225
|
+
"Use this to let other agents and humans know what you are currently doing, " +
|
|
226
|
+
"e.g. 'reviewing PR #2', 'waiting for tests', 'writing WISHLIST.md'. " +
|
|
227
|
+
"Status updates are distinct from chat messages and can be filtered separately.", {
|
|
228
|
+
sender: z.string().describe("Name of the agent posting the status (e.g. 'codex-agent')"),
|
|
229
|
+
status: z.string().describe("Short status description (e.g. 'reviewing PR #2', 'idle', 'thinking...')"),
|
|
230
|
+
project_id: z
|
|
231
|
+
.string()
|
|
232
|
+
.optional()
|
|
233
|
+
.describe("Project ID to post status to. Defaults to the current room if joined."),
|
|
234
|
+
}, async ({ sender, status, project_id }) => {
|
|
235
|
+
const targetProjectId = project_id || currentRoom?.project_id;
|
|
236
|
+
if (!targetProjectId) {
|
|
237
|
+
return {
|
|
238
|
+
content: [
|
|
239
|
+
{
|
|
240
|
+
type: "text",
|
|
241
|
+
text: JSON.stringify({
|
|
242
|
+
success: false,
|
|
243
|
+
error: "No project_id provided and not currently in a room.",
|
|
244
|
+
hint: "Join a room first with join_project or join_room, or pass project_id explicitly.",
|
|
245
|
+
}, null, 2),
|
|
246
|
+
},
|
|
247
|
+
],
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
// Status messages use a reserved prefix so the UI (and agents) can distinguish
|
|
251
|
+
// them from normal chat messages without changing the data model.
|
|
252
|
+
const statusText = `[status] ${status}`;
|
|
253
|
+
const message = await apiCall(`/projects/${encodeURIComponent(targetProjectId)}/messages`, {
|
|
254
|
+
method: "POST",
|
|
255
|
+
body: JSON.stringify({ sender, text: statusText }),
|
|
256
|
+
});
|
|
257
|
+
return {
|
|
258
|
+
content: [
|
|
259
|
+
{
|
|
260
|
+
type: "text",
|
|
261
|
+
text: JSON.stringify({
|
|
262
|
+
success: true,
|
|
263
|
+
status_posted: status,
|
|
264
|
+
sender,
|
|
265
|
+
message_id: message.id,
|
|
266
|
+
timestamp: message.timestamp,
|
|
267
|
+
}, null, 2),
|
|
268
|
+
},
|
|
269
|
+
],
|
|
270
|
+
};
|
|
271
|
+
});
|
|
272
|
+
server.tool("initialize_repo", "Initialize the current repo for Let Agents Chat by creating a .letagents.json config file. " +
|
|
273
|
+
"This explicitly sets up repo-based room auto-join. Reads git remote to derive the room name, " +
|
|
274
|
+
"or accepts a custom room name. Will NOT overwrite an existing .letagents.json. " +
|
|
275
|
+
"Always writes to the repo root, not the current working directory.", {
|
|
276
|
+
room: z
|
|
277
|
+
.string()
|
|
278
|
+
.optional()
|
|
279
|
+
.describe("Custom room name. If omitted, auto-derived from git remote (e.g. 'github.com/owner/repo')"),
|
|
280
|
+
cwd: z
|
|
281
|
+
.string()
|
|
282
|
+
.optional()
|
|
283
|
+
.describe("Working directory hint for repo detection. Defaults to the current process directory."),
|
|
284
|
+
}, async ({ room, cwd: targetDir }) => {
|
|
285
|
+
const startDir = targetDir || process.cwd();
|
|
286
|
+
// Resolve true git repo root — never write to a subdirectory
|
|
287
|
+
const repoRoot = resolveGitRoot(startDir);
|
|
288
|
+
if (!repoRoot) {
|
|
289
|
+
return {
|
|
290
|
+
content: [
|
|
291
|
+
{
|
|
292
|
+
type: "text",
|
|
293
|
+
text: JSON.stringify({
|
|
294
|
+
success: false,
|
|
295
|
+
error: "Not inside a git repository",
|
|
296
|
+
hint: "Run this tool from inside a git repo, or pass a 'cwd' pointing to one.",
|
|
297
|
+
}, null, 2),
|
|
298
|
+
},
|
|
299
|
+
],
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
// Walk parent dirs from startDir (not repoRoot) to catch configs in subtrees below caller
|
|
303
|
+
const existingConfigDir = findExistingConfig(startDir);
|
|
304
|
+
if (existingConfigDir) {
|
|
305
|
+
const existingPath = join(existingConfigDir, ".letagents.json");
|
|
306
|
+
return {
|
|
307
|
+
content: [
|
|
308
|
+
{
|
|
309
|
+
type: "text",
|
|
310
|
+
text: JSON.stringify({
|
|
311
|
+
success: false,
|
|
312
|
+
error: ".letagents.json already exists",
|
|
313
|
+
path: existingPath,
|
|
314
|
+
hint: existingConfigDir === repoRoot
|
|
315
|
+
? "Delete the existing file first if you want to reinitialize."
|
|
316
|
+
: `Found a config in a parent directory (${existingConfigDir}). Delete it or move it to ${repoRoot} to reinitialize.`,
|
|
317
|
+
}, null, 2),
|
|
318
|
+
},
|
|
319
|
+
],
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
const configPath = join(repoRoot, ".letagents.json");
|
|
323
|
+
// Safety check (shouldn't be needed after findExistingConfig, but defensive)
|
|
324
|
+
if (existsSync(configPath)) {
|
|
325
|
+
return {
|
|
326
|
+
content: [
|
|
327
|
+
{
|
|
328
|
+
type: "text",
|
|
329
|
+
text: JSON.stringify({
|
|
330
|
+
success: false,
|
|
331
|
+
error: ".letagents.json already exists",
|
|
332
|
+
path: configPath,
|
|
333
|
+
hint: "Delete the existing file first if you want to reinitialize.",
|
|
334
|
+
}, null, 2),
|
|
335
|
+
},
|
|
336
|
+
],
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
// Determine room name
|
|
340
|
+
let roomName = room;
|
|
341
|
+
if (!roomName) {
|
|
342
|
+
const gitRoom = getGitRemoteIdentity(repoRoot);
|
|
343
|
+
if (!gitRoom) {
|
|
344
|
+
return {
|
|
345
|
+
content: [
|
|
346
|
+
{
|
|
347
|
+
type: "text",
|
|
348
|
+
text: JSON.stringify({
|
|
349
|
+
success: false,
|
|
350
|
+
error: "Cannot derive room name: no git remote found and no custom room name provided",
|
|
351
|
+
hint: "Pass a 'room' parameter or run from inside a git repo with a remote configured.",
|
|
352
|
+
}, null, 2),
|
|
353
|
+
},
|
|
354
|
+
],
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
roomName = gitRoom;
|
|
358
|
+
}
|
|
359
|
+
// Write the config file
|
|
360
|
+
const config = { room: roomName };
|
|
361
|
+
try {
|
|
362
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
363
|
+
}
|
|
364
|
+
catch (err) {
|
|
365
|
+
return {
|
|
366
|
+
content: [
|
|
367
|
+
{
|
|
368
|
+
type: "text",
|
|
369
|
+
text: JSON.stringify({
|
|
370
|
+
success: false,
|
|
371
|
+
error: `Failed to write config: ${err instanceof Error ? err.message : err}`,
|
|
372
|
+
path: configPath,
|
|
373
|
+
}, null, 2),
|
|
374
|
+
},
|
|
375
|
+
],
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
// Auto-join the room after creating config
|
|
379
|
+
try {
|
|
380
|
+
const project = await apiCall(`/projects/room/${encodeURIComponent(roomName)}`, { method: "POST" });
|
|
381
|
+
currentRoom = {
|
|
382
|
+
room: roomName,
|
|
383
|
+
project_id: project.id,
|
|
384
|
+
code: project.code,
|
|
385
|
+
joined_via: "config",
|
|
386
|
+
};
|
|
387
|
+
sseClient.subscribe(project.id, (_message) => {
|
|
388
|
+
server.server.sendResourceListChanged();
|
|
389
|
+
});
|
|
390
|
+
return {
|
|
391
|
+
content: [
|
|
392
|
+
{
|
|
393
|
+
type: "text",
|
|
394
|
+
text: JSON.stringify({
|
|
395
|
+
success: true,
|
|
396
|
+
created: configPath,
|
|
397
|
+
room: roomName,
|
|
398
|
+
project_id: project.id,
|
|
399
|
+
code: project.code,
|
|
400
|
+
joined: true,
|
|
401
|
+
hint: "Consider adding .letagents.json to git so other contributors auto-join the same room.",
|
|
402
|
+
}, null, 2),
|
|
403
|
+
},
|
|
404
|
+
],
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
catch (err) {
|
|
408
|
+
// Config was created but auto-join failed
|
|
409
|
+
return {
|
|
410
|
+
content: [
|
|
411
|
+
{
|
|
412
|
+
type: "text",
|
|
413
|
+
text: JSON.stringify({
|
|
414
|
+
success: true,
|
|
415
|
+
created: configPath,
|
|
416
|
+
room: roomName,
|
|
417
|
+
joined: false,
|
|
418
|
+
error: `Config created but auto-join failed: ${err instanceof Error ? err.message : err}`,
|
|
419
|
+
hint: "The .letagents.json was created. Use join_room to manually connect.",
|
|
420
|
+
}, null, 2),
|
|
421
|
+
},
|
|
422
|
+
],
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
});
|
|
142
426
|
// -- send_message -----------------------------------------------------------
|
|
143
427
|
server.tool("send_message", "Send a message to a Let Agents Chat project.", {
|
|
144
428
|
project_id: z.string().describe("The project ID to send the message to"),
|
|
@@ -209,7 +493,7 @@ server.tool("wait_for_messages", "Wait for new messages in a Let Agents Chat pro
|
|
|
209
493
|
async function main() {
|
|
210
494
|
const transport = new StdioServerTransport();
|
|
211
495
|
await server.connect(transport);
|
|
212
|
-
console.error("🔌 Let Agents Chat MCP server running on stdio (v0.3.
|
|
496
|
+
console.error("🔌 Let Agents Chat MCP server running on stdio (v0.3.1)");
|
|
213
497
|
// --- Auto-join from repo context ---
|
|
214
498
|
try {
|
|
215
499
|
// 1. Try .letagents.json config
|