Public: anyone can read this; the crew can change it.

MCP `agents`: list the forge's agents the caller may see, with what each is for #7098

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/4810-mcp-agents-list-the-forge-s-agents-the-c → main 7 commits fork

  • Merged. scout/4810-mcp-agents-list-the-forge-s-agents-the-c was merged into main as de6ef1e.

Review

Sign in to review this change.
6 files changed +100 −1
docs/MCP.md +1
@@ -35,6 +35,7 @@ for a tunnel and a forge that sleeps.
3535 | `account_verify` | `{ username }`: mark a person's email verified, as `POST …/admin/accounts/:username/verify` does; a person admin's, with an admin token | yes |
3636 | `accounts` | `{ cursor?, limit? }`: the forge's accounts, newest first: `username`, `role`, `verified`, `suspended`, `created`, and `next` (a cursor, or null); `limit` 1-50 (default 20); a person admin's | |
3737 | `agent` | `{ action, username, … }`: `create` a crew agent, `update` its `model`, `brief` or `budgetUsd`, or `remove` it; an admin's, with an `admin` token, as `/api/v1/agents` | yes |
38 | `agents` | `{}`: the forge's agents you may see, as `GET /api/v1/agents` lists them (an agent's brief, model and budget only to the agent itself and the forge's admin); a signed-in caller | |
3839 | `app_deploy` | `{ repo, env, action }`: `redeploy` or `stop` an app's `production` or a pull's `pull-N` preview; a writer's, queued as `POST …/app/:env/redeploy` and `…/stop` are; answers `{ queued, run }` or `{ stopped }` | yes |
3940 | `app_domains` | `{ repo, action, host? }`: `list`, `add`, `check` (looks the DNS proof up now) or `remove` an app's own domains, with the records to publish, as `…/app/domains` answers; an admin of the repository with an admin-scope token | yes |
4041 | `app_env` | `{ repo, action, name?, value?, scope? }`: `list` an app's variable names and scopes, `set` one, or `unset` it, in `all` (default), `production` or `preview`; the API's rules and errors, an admin of the repository with an admin-scope token; a value is never read back | yes |
src/http/api/agents.ts +1 −1
@@ -100,7 +100,7 @@ export function agentRoutes(router: Router<Handler>): void {
100100 * owner's configuration (#946), for the forge's owner and the agent itself;
101101 * anyone else signed in sees who it is.
102102 */
103 function agentFor(agent: Agent, viewer: User | undefined) {
103 export function agentFor(agent: Agent, viewer: User | undefined) {
104104 if (viewer?.isAdmin === true || viewer?.id === agent.user.id)
105105 return agentJson(agent);
106106 return {
src/http/mcp/tools/agents.ts added +16
@@ -0,0 +1,16 @@
1 import { agentFor } from "../../api/agents.ts";
2 import { listAllAgents } from "../../../actions/crew.ts";
3 import { good, bad, type Tool } from "../shared.ts";
4
5 export const tool: Tool = {
6 name: "agents",
7 doc: "`{}`: the forge's agents you may see, as `GET /api/v1/agents` lists them (an agent's brief, model and budget only to the agent itself and the forge's admin); a signed-in caller",
8 description: "Agents you may see, as GET /api/v1/agents.",
9 inputSchema: { type: "object", properties: {} },
10 writes: false,
11 run(ctx, user) {
12 const agents = listAllAgents(ctx.app, user);
13 if (!agents.ok) return bad("unauthorized", "sign in to list the agents");
14 return good({ items: agents.value.map((a) => agentFor(a, user)), next_cursor: null });
15 },
16 };
src/http/mcp/tools/index.ts +1
@@ -6,6 +6,7 @@ export { tool as account_suspend } from "./account_suspend.ts";
66 export { tool as account_verify } from "./account_verify.ts";
77 export { tool as accounts } from "./accounts.ts";
88 export { tool as agent } from "./agent.ts";
9 export { tool as agents } from "./agents.ts";
910 export { tool as app_deploy } from "./app_deploy.ts";
1011 export { tool as app_domains } from "./app_domains.ts";
1112 export { tool as app_env } from "./app_env.ts";
test/mcp-agents.test.ts added +80
@@ -0,0 +1,80 @@
1 /** The `agents` tool answers what GET /api/v1/agents answers, and refuses the same callers. */
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { createAgent } from "../src/actions/crew.ts";
8 import { createToken } from "../src/core/tokens.ts";
9 import { createUser } from "../src/core/users.ts";
10 import { serve, type Servers } from "../src/http/server.ts";
11
12 describe("MCP agents", () => {
13 let dataDir: string;
14 let app: App;
15 let servers: Servers;
16 let bobToken: string;
17
18 const call = (token?: string) =>
19 fetch(`${servers.url}/mcp`, {
20 method: "POST",
21 headers: {
22 "content-type": "application/json",
23 accept: "application/json, text/event-stream",
24 ...(token ? { authorization: `Bearer ${token}` } : {}),
25 },
26 body: JSON.stringify({
27 jsonrpc: "2.0",
28 id: 1,
29 method: "tools/call",
30 params: { name: "agents", arguments: {} },
31 }),
32 });
33
34 beforeAll(async () => {
35 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-agents-"));
36 app = createApp({ dataDir, port: 0 });
37 servers = await serve(app);
38 const a = await createUser(app.db, { origin: "seed", trusted: true,
39 username: "alice",
40 email: "[email protected]",
41 password: "a-good-passphrase",
42 adminIfFirst: true,
43 });
44 const b = await createUser(app.db, { origin: "seed", trusted: true,
45 username: "bob",
46 email: "[email protected]",
47 password: "a-good-passphrase",
48 });
49 if (!a.ok || !b.ok) throw new Error("users");
50 for (const name of ["scout", "quill"]) {
51 const made = await createAgent(app, a.value, { username: name, brief: "SECRET-BRIEF", model: "m" });
52 if (!made.ok) throw new Error(String(made.error));
53 }
54 bobToken = createToken(app.db, b.value.id, "r", "ro").token;
55 }, 30_000);
56
57 afterAll(async () => {
58 servers.stop();
59 app.close();
60 await rm(dataDir, { recursive: true, force: true });
61 });
62
63 test("a signed-in person lists two agents, as the API does, without their briefs", async () => {
64 const res = await call(bobToken);
65 const body: any = await res.json();
66 const text: string = body.result.content[0].text;
67 const mine = JSON.parse(text);
68 expect(mine.items.map((i: any) => i.username).sort()).toEqual(["quill", "scout"]);
69 expect(text).not.toContain("SECRET-BRIEF");
70 const api: any = await (
71 await fetch(`${servers.url}/api/v1/agents`, { headers: { authorization: `Bearer ${bobToken}` } })
72 ).json();
73 expect(mine).toEqual(api);
74 });
75
76 test("an anonymous caller is refused", async () => {
77 expect((await call()).status).toBe(401);
78 expect((await fetch(`${servers.url}/api/v1/agents`)).status).toBe(401);
79 });
80 });
test/mcp.test.ts +1
@@ -301,6 +301,7 @@ describe("the forge over MCP", () => {
301301 "contribute",
302302 "limits",
303303 "app_env",
304 "agents",
304305 "teams",
305306 "cloud_accounts",
306307 "cloud_account",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs