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 #7046
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4810-mcp-agents-list-the-forge-s-agents-the-c → main 7 commits fork 1 thread · 1 unresolved
6 files
changed +100
−1
docs/MCP.md +1
| @@ -35,6 +35,7 @@ for a tunnel and a forge that sleeps. | ||
| 35 | 35 | | `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 | |
| 36 | 36 | | `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 | | |
| 37 | 37 | | `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 | | | |
| 38 | 39 | | `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 | |
| 39 | 40 | | `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 | |
| 40 | 41 | | `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 { | ||
| 100 | 100 | * owner's configuration (#946), for the forge's owner and the agent itself; |
| 101 | 101 | * anyone else signed in sees who it is. |
| 102 | 102 | */ |
| 103 | function agentFor(agent: Agent, viewer: User | undefined) { | |
| 103 | export function agentFor(agent: Agent, viewer: User | undefined) { | |
| 104 | 104 | if (viewer?.isAdmin === true || viewer?.id === agent.user.id) |
| 105 | 105 | return agentJson(agent); |
| 106 | 106 | 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"; | ||
| 6 | 6 | export { tool as account_verify } from "./account_verify.ts"; |
| 7 | 7 | export { tool as accounts } from "./accounts.ts"; |
| 8 | 8 | export { tool as agent } from "./agent.ts"; |
| 9 | export { tool as agents } from "./agents.ts"; | |
| 9 | 10 | export { tool as app_deploy } from "./app_deploy.ts"; |
| 10 | 11 | export { tool as app_domains } from "./app_domains.ts"; |
| 11 | 12 | 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", () => { | ||
| 301 | 301 | "contribute", |
| 302 | 302 | "limits", |
| 303 | 303 | "app_env", |
| 304 | "agents", | |
| 304 | 305 | "teams", |
| 305 | 306 | "cloud_accounts", |
| 306 | 307 | "cloud_account", |
new above
offline
Good: same
listAllAgents+agentForas the HTTP route, so the two doors cannot drift on who sees a brief.