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

MCP `steer`: say a word to one running crew run by its id, as the run page does #7165

Merged opened this

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

Merged

scout/forge:scout/6242-mcp-steer-say-a-word-to-one-running-crew → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/6242-mcp-steer-say-a-word-to-one-running-crew was merged into main as 17ed266.

Review

Sign in to review this change.
5 files changed +145 −0
docs/MCP.md +1
@@ -153,6 +153,7 @@ for a tunnel and a forge that sleeps.
153153 | `snapshot_now` | no arguments: take a snapshot now, as the Backups page's button does; a forge admin with an admin-scope token; answers `{ name, repos, apps, bytes, says }` | yes |
154154 | `spawn` | `{ name, apps?, crew?, dryRun? }`: a child forge, planned first (`dryRun` is true unless `false`); an admin's, and an `ro` token may plan but not make | yes |
155155 | `ssh_keys` | `{ action?, title?, key?, id? }`: list your own SSH keys (default), add one (`key` is an authorized_keys line, `title` optional) or remove one by `id`; the API's rules and errors, add and remove need an `rw` token | yes |
156 | `steer` | `{ repo, run, text }`: say a word to one running crew run by its id, as the run page's steer box and `POST …/runs/:id/steer` do; a writer's (never an agent), the run an agent's, `text` not empty; answers `{ says, outcome }` (`sent` or `commented`) | yes |
156157 | `stop` | stop one run by its id from `now` | yes |
157158 | `subscription_add` | `{ name, token, apiBase? }` (forge admin): put a Claude account in the crew's pool, as the Subscriptions page does, with its refusals; answers `{ id, name, says }`; the token is never read back | yes |
158159 | `subscription_budget` | `{ id, usd }`: set an account's whole-life budget in dollars (up to the `subscription.budget_max_usd` limit), or clear it with `usd: null`, as the Subscriptions page does; a locked budget can only be lowered; a forge admin with an admin token | yes |
src/http/mcp/tools/index.ts +1
@@ -127,6 +127,7 @@ export { tool as snapshot_now } from "./snapshot_now.ts";
127127 export { tool as spawn } from "./spawn.ts";
128128 export { tool as spend } from "./spend.ts";
129129 export { tool as ssh_keys } from "./ssh_keys.ts";
130 export { tool as steer } from "./steer.ts";
130131 export { tool as stop } from "./stop.ts";
131132 export { tool as subscription_add } from "./subscription_add.ts";
132133 export { tool as subscription_budget } from "./subscription_budget.ts";
src/http/mcp/tools/steer.ts added +33
@@ -0,0 +1,33 @@
1 import { steerRepoRun } from "../../../actions/runs.ts";
2 import { good, bad, type Tool, str, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
3
4 export const tool: Tool = {
5 name: "steer",
6 doc: "`{ repo, run, text }`: say a word to one running crew run by its id, as the run page's steer box and `POST …/runs/:id/steer` do; a writer's (never an agent), the run an agent's, `text` not empty; answers `{ says, outcome }` (`sent` or `commented`)",
7 description: "Say a word to one crew run by its id.",
8 inputSchema: {
9 type: "object",
10 properties: { repo: REPO_ARG, run: { type: "string" }, text: { type: "string" } },
11 required: ["repo", "run", "text"],
12 },
13 writes: true,
14 run(ctx, user, args) {
15 const repo = repoArg(ctx, user, args);
16 if (isAnswer(repo)) return repo;
17 const steered = steerRepoRun(ctx.app, user, repo, str(args["run"]), str(args["text"]));
18 if (!steered.ok)
19 return bad(
20 steered.error,
21 steered.error === "empty" || steered.error === "not_agent"
22 ? "say something, to an agent's run"
23 : `refused: ${steered.error}`,
24 );
25 return good({
26 says:
27 steered.value === "sent"
28 ? "The agent hears it at its next step."
29 : "No session was listening; it went on the conversation for the next run.",
30 outcome: steered.value,
31 });
32 },
33 };
test/mcp-steer.test.ts added +109
@@ -0,0 +1,109 @@
1 /**
2 * The `steer` tool is the API's steer over MCP: the same action, so the same
3 * gate. A writer's rw token steers an agent run and the words are recorded; an
4 * ro token, a reader, an empty text and a non-agent run are each refused.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { newApp } from "../src/actions/apps.ts";
12 import { openIssue } from "../src/actions/issues.ts";
13 import { getRepo } from "../src/core/repos.ts";
14 import { listComments } from "../src/core/issues.ts";
15 import { createRun, type Run } from "../src/core/runs.ts";
16 import { createUser, getUserByName } from "../src/core/users.ts";
17 import { serve, type Servers } from "../src/http/server.ts";
18 import { seedAdmin } from "./helpers/admin.ts";
19 import { promote } from "./helpers/members.ts";
20 import { tokenFor } from "./helpers/tokens.ts";
21
22 describe("MCP steer", () => {
23 let dataDir: string;
24 let app: App;
25 let servers: Servers;
26 let aliceRw: string;
27 let aliceRo: string;
28 let bobRw: string;
29 let agentRun: Run;
30 let deployRun: Run;
31 let issueId: string;
32 let id = 1;
33
34 const tool = async (token: string, args: unknown) => {
35 const res = await fetch(`${servers.url}/mcp`, {
36 method: "POST",
37 headers: {
38 "content-type": "application/json",
39 accept: "application/json, text/event-stream",
40 authorization: `Bearer ${token}`,
41 },
42 body: JSON.stringify({
43 jsonrpc: "2.0",
44 id: id++,
45 method: "tools/call",
46 params: { name: "steer", arguments: args },
47 }),
48 });
49 const body: any = await res.json();
50 const text: string = body.result.content[0].text;
51 return body.result.isError === true ? { error: text } : { value: JSON.parse(text) };
52 };
53
54 beforeAll(async () => {
55 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-steer-"));
56 app = createApp({ dataDir, port: 0 });
57 servers = await serve(app);
58 await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-good-passphrase" });
59 const b = await createUser(app.db, {
60 origin: "seed",
61 trusted: true,
62 username: "bob",
63 email: "[email protected]",
64 password: "a-good-passphrase",
65 });
66 if (!b.ok) throw new Error(b.error);
67 promote(app.db, b.value);
68 const alice = getUserByName(app.db, "alice")!;
69 const made = await newApp(app, alice, { name: "hello", visibility: "public" } as never);
70 if (!made.ok) throw new Error(String(made.error));
71 const repo = getRepo(app.db, "alice", "hello")!;
72 const opened = openIssue(app, alice, repo, { title: "Paint the fence" });
73 if (!opened.ok) throw new Error(String(opened.error));
74 issueId = opened.value.id;
75 agentRun = createRun(app.db, { repoId: repo.id, kind: "agent", issueId });
76 deployRun = createRun(app.db, { repoId: repo.id, kind: "deploy" as never });
77 aliceRw = tokenFor(app, "alice", "rw");
78 aliceRo = tokenFor(app, "alice", "ro");
79 bobRw = tokenFor(app, "bob", "rw");
80 }, 30_000);
81
82 afterAll(async () => {
83 servers.stop();
84 app.close();
85 await rm(dataDir, { recursive: true, force: true });
86 });
87
88 const said = () => listComments(app.db, issueId).map((c) => c.body);
89
90 test("a writer steers an agent run and the words are recorded", async () => {
91 const r = await tool(aliceRw, { repo: "alice/hello", run: agentRun.id, text: "paint it blue" });
92 expect(r.value.outcome).toBe("commented");
93 expect(typeof r.value.says).toBe("string");
94 expect(said()).toContain("paint it blue");
95 });
96
97 test("an ro token, a reader, an empty text and a non-agent run are refused", async () => {
98 const ro = await tool(aliceRo, { repo: "alice/hello", run: agentRun.id, text: "no" });
99 expect(ro.error).toBeDefined();
100 const reader = await tool(bobRw, { repo: "alice/hello", run: agentRun.id, text: "reader words" });
101 expect(reader.error).toContain("forbidden");
102 const empty = await tool(aliceRw, { repo: "alice/hello", run: agentRun.id, text: " " });
103 expect(empty.error).toContain("empty");
104 const other = await tool(aliceRw, { repo: "alice/hello", run: deployRun.id, text: "hi" });
105 expect(other.error).toContain("not_agent");
106 expect(said()).not.toContain("no");

Nit: said() is an array, so this only fails if a comment's whole body is exactly no. That still proves the ro refusal left nothing behind. A more distinctive word, like ro words, would make the intent clearer.

107 expect(said()).not.toContain("reader words");
108 });
109 });
test/mcp.test.ts +1
@@ -310,6 +310,7 @@ describe("the forge over MCP", () => {
310310 "quotas",
311311 "signup",
312312 "ssh_keys",
313 "steer",
313314 "email_verify",
314315 "parent_unlink",
315316 "vision",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs