diff --git a/docs/mcp-instance-runtime.md b/docs/mcp-instance-runtime.md index e86e81d8..bdb4e0b9 100644 --- a/docs/mcp-instance-runtime.md +++ b/docs/mcp-instance-runtime.md @@ -107,7 +107,7 @@ That response means the instance runtime path is working and correctly refusing ## MCP Tool Groups -The server currently has 136 tool registrations across `workers/mcp/src`. +The server currently has 137 tool registrations across `workers/mcp/src`. Some are capability-gated and appear only for users with matching agent surfaces, so `tools/list` on your own connection is the authoritative surface — 19 of those registrations are gated. The `/health` marker reports the same total from @@ -179,7 +179,7 @@ Status: active Verified live: -- MCP exposed its tool set (26 at the time of this 2026-06-11 record; 136 tool registrations in source today — see MCP Tool Groups above). +- MCP exposed its tool set (26 at the time of this 2026-06-11 record; 137 tool registrations in source today — see MCP Tool Groups above). - Required instance tools were present. - `subscribe_agent` returned the existing active instance. - `add_instance_knowledge` saved a document to that private instance. diff --git a/platform-docs/mcp.md b/platform-docs/mcp.md index 0efd1e79..2db76d2a 100644 --- a/platform-docs/mcp.md +++ b/platform-docs/mcp.md @@ -107,7 +107,7 @@ Confirm before destructive actions. ## What `initialize` Answers -- `serverInfo.version`: `0.1.6` +- `serverInfo.version`: `0.1.7` That is the same value the published MCP-registry manifest (`server.json`) carries, and both are read from one constant — `MCP_SERVER_VERSION` in `workers/mcp/src/server-version.ts` — @@ -196,7 +196,7 @@ The two published hints are **derived, not hand-maintained per tool**. `workers/mcp/src/tool-metadata.ts` classifies every tool `read` / `write` / `runtime` / `destructive` in one table, and `annotationsFor()` maps that classification onto the two hints. The classification is then derived **back out of the handlers** by `index.test.ts`, -which drives all 136 tools under two different scope sets and reads the required scope out +which drives all 137 tools under two different scope sets and reads the required scope out of each refusal — so a tool announced read-only that enforces a write gate fails the build rather than reaching a host. `conformance.test.ts` asserts the same thing against a real `tools/list` response. @@ -318,7 +318,7 @@ More recipes, with real argument names, are in ## Tool Surface -The server registers **136 tools**. 117 are always present. The remaining 19 are gated to +The server registers **137 tools**. 118 are always present. The remaining 19 are gated to the console surfaces of the connected user's own subscribed agents, so the surface is per-connection: diff --git a/server.json b/server.json index a515c05b..8ed07e48 100644 --- a/server.json +++ b/server.json @@ -3,7 +3,7 @@ "name": "io.github.ProAgentStore/platform", "title": "ProAgentStore", "description": "MCP-first control plane for ProAgentStore agents and private instances.", - "version": "0.1.6", + "version": "0.1.7", "repository": { "url": "https://github.com/ProAgentStore/platform", "source": "github" diff --git a/store/.well-known/mcp-server.json b/store/.well-known/mcp-server.json index a515c05b..8ed07e48 100644 --- a/store/.well-known/mcp-server.json +++ b/store/.well-known/mcp-server.json @@ -3,7 +3,7 @@ "name": "io.github.ProAgentStore/platform", "title": "ProAgentStore", "description": "MCP-first control plane for ProAgentStore agents and private instances.", - "version": "0.1.6", + "version": "0.1.7", "repository": { "url": "https://github.com/ProAgentStore/platform", "source": "github" diff --git a/store/about/index.html b/store/about/index.html index feee5a75..1c9eb34c 100644 --- a/store/about/index.html +++ b/store/about/index.html @@ -176,7 +176,7 @@

Data isolation

Developer tools

CLInpm i -g @proagentstore/cli then pags up: one runner serves ALL your agents over a WebSocket relay — no tunnel binary, no config. Plus pags login, pags init, pags check, pags publish.

-

MCP Server — manage agents from Claude Code, Cursor, or VS Code. 136 tools across creator operations, public trials, private instance runtime, coding, and error observability (list_errors).

+

MCP Server — manage agents from Claude Code, Cursor, or VS Code. 137 tools across creator operations, public trials, private instance runtime, coding, and error observability (list_errors).

Skills — install the same ProAgentStore MCP workflow in Codex and Claude Code from the skills catalog.

Webhook ingestion — Zapier, Make, n8n can POST documents directly into an instance's knowledge base.

API key vault — store your OpenAI, Anthropic, Google AI keys once. Encrypted AES-256-GCM. Agents access via proxy.

diff --git a/store/llms-full.txt b/store/llms-full.txt index 9c6e7f50..663087d9 100644 --- a/store/llms-full.txt +++ b/store/llms-full.txt @@ -156,7 +156,7 @@ That response means the private instance path is working and correctly refusing ## Main MCP Tool Groups -The server registers 136 tools; 117 are always present and 19 are gated to the `apply`, `repo`, and `coding` surfaces. The lists below are the commonly used subset, not the full surface — call `tools/list` for the authoritative set, and see the tool table in `workers/mcp/README.md` for scopes, `dry_run` support, and confirmation values. +The server registers 137 tools; 118 are always present and 19 are gated to the `apply`, `repo`, and `coding` surfaces. The lists below are the commonly used subset, not the full surface — call `tools/list` for the authoritative set, and see the tool table in `workers/mcp/README.md` for scopes, `dry_run` support, and confirmation values. Creator tools: diff --git a/store/openapi.yaml b/store/openapi.yaml index b4884870..962ed82b 100644 --- a/store/openapi.yaml +++ b/store/openapi.yaml @@ -3019,6 +3019,50 @@ paths: schema: { type: object, properties: { ok: { type: boolean } } } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } + patch: + summary: Amend a board ticket + description: | + Merge new wording into an existing ticket: `title`, `description`, `reasoning`. A field + that is absent is left alone; `""` clears it. Identity and history are not editable — + `id`, `type` and `createdAt` are pinned from the stored row. + + This exists because POSTing `/tasks/direct` with an existing `id` is a REPLACE, not an + edit: that route rebuilds the task from the request body, so amending one field blanks + `description` and `reasoning`, resets `status`, and drops an actionable ticket's `action`. + + Deliberately cannot set `status`. Moving a card between columns is + `POST /v1/instances/{instanceId}/board/status`, which validates the target against the + agent's configured columns; accepting one here would be a second, unvalidated way to + move a card. + operationId: updateInstanceBoardTicket + tags: [Board] + parameters: + - { $ref: "#/components/parameters/InstanceId" } + - { name: taskId, in: path, required: true, schema: { type: string } } + requestBody: + required: true + content: + application/json: + schema: + type: object + minProperties: 1 + properties: + title: { type: string, maxLength: 200 } + description: { type: string, maxLength: 2000 } + reasoning: { type: string, maxLength: 8000 } + responses: + "200": + description: The amended ticket + content: + application/json: + schema: { type: object } + "400": + description: No amendable field supplied, or a field of the wrong type + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/NotFound" } /v1/instances/{instanceId}/tasks/{taskId}/approve: post: diff --git a/workers/api/src/routes/instances-tasks-patch.test.ts b/workers/api/src/routes/instances-tasks-patch.test.ts new file mode 100644 index 00000000..badc9cc7 --- /dev/null +++ b/workers/api/src/routes/instances-tasks-patch.test.ts @@ -0,0 +1,162 @@ +/** + * `PATCH /v1/instances/:instanceId/tasks/:taskId` — the merge, driven against the real schema + * (PAS #137). + * + * `instances.contract.test.ts` already pins that this route EXISTS and that a stranger is + * refused. Neither of those says the merge is a merge. The bug this route exists to avoid is + * silent and specific: `POST /tasks/direct` with an existing id already overwrote the card, + * and looked like an edit right up until the fields nobody mentioned came back empty. So the + * assertions below are mostly about what did NOT change. + */ +import { Hono } from "hono"; +import { describe, expect, it } from "vitest"; +import { HttpError } from "../lib/auth.js"; +import { realSchemaD1, seedTenant, type RealSchemaD1 } from "../lib/d1-sqlite.js"; +import { signSession } from "../lib/session.js"; +import { mirrorRuntimeTask, mirroredRuntimeTask } from "./instances-runtime.js"; +import { registerTaskRoutes } from "./instances-tasks.js"; +import type { Env } from "../types.js"; + +const SECRET = "tasks-patch-secret"; +const USER = "owner-1"; +const INSTANCE = "inst-1"; +const TASK = "task-1"; + +function buildApp(d1: RealSchemaD1) { + const env = { SESSION_SIGNING_KEY: SECRET, DB: d1.DB } as unknown as Env; + const app = new Hono<{ Bindings: Env }>(); + const router = new Hono<{ Bindings: Env }>(); + registerTaskRoutes(router); + app.route("/v1/instances", router); + app.onError((err, c) => { + if (err instanceof HttpError) return c.json({ error: err.message }, err.status as 400); + return c.json({ error: (err as Error).message }, 500); + }); + return { app, env }; +} + +/** An actionable ticket, as `POST /tasks/direct` would have written it. */ +const TICKET = { + id: TASK, + type: "ticket", + status: "needs_approval", + title: "Aply to Acme", + description: "Senior engineer, remote", + reasoning: "Matches the saved search and the salary floor.", + action: { kind: "run_pipeline", config: { pipeline: "apply" } }, + createdAt: "2026-08-01T09:00:00.000Z", + updatedAt: "2026-08-01T09:00:00.000Z", +}; + +async function setup() { + const d1 = realSchemaD1(); + seedTenant(d1, { userId: USER, instanceIds: [INSTANCE] }); + const { app, env } = buildApp(d1); + await mirrorRuntimeTask(env, INSTANCE, USER, TICKET); + const token = await signSession(USER, SECRET, { roles: ["user"] }); + return { d1, app, env, token }; +} + +const patch = (app: Hono<{ Bindings: Env }>, env: Env, token: string, body: unknown, taskId = TASK) => + app.request( + `/v1/instances/${INSTANCE}/tasks/${taskId}`, + { + method: "PATCH", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + env, + ); + +describe("PATCH a board ticket (PAS #137)", () => { + it("amends only the field it was given, and leaves the rest of the ticket alone", async () => { + const { app, env, token } = await setup(); + + const res = await patch(app, env, token, { title: "Apply to Acme" }); + expect(res.status).toBe(200); + + // The regression that motivated the route: a one-word title fix must not blank the + // prose either side of it, demote the card out of its column, or drop the work the + // ticket stands for. + const stored = (await mirroredRuntimeTask(env, INSTANCE, USER, TASK)) as Record; + expect(stored.title).toBe("Apply to Acme"); + expect(stored.description).toBe(TICKET.description); + expect(stored.reasoning).toBe(TICKET.reasoning); + expect(stored.status).toBe("needs_approval"); + expect(stored.action).toEqual(TICKET.action); + expect(stored.type).toBe("ticket"); + }); + + it("pins identity and creation time, and moves updatedAt", async () => { + const { app, env, token } = await setup(); + + await patch(app, env, token, { reasoning: "Re-checked: salary floor confirmed." }); + + const stored = (await mirroredRuntimeTask(env, INSTANCE, USER, TASK)) as Record; + expect(stored.id).toBe(TASK); + expect(stored.createdAt).toBe(TICKET.createdAt); + expect(stored.updatedAt).not.toBe(TICKET.updatedAt); + }); + + it("treats an empty string as clear-this-field, not as absent", async () => { + const { app, env, token } = await setup(); + + await patch(app, env, token, { description: "" }); + + const stored = (await mirroredRuntimeTask(env, INSTANCE, USER, TASK)) as Record; + expect(stored.description).toBe(""); + // …and the distinction is only meaningful if an omitted field still survives. + expect(stored.reasoning).toBe(TICKET.reasoning); + }); + + it("amends several fields at once", async () => { + const { app, env, token } = await setup(); + + await patch(app, env, token, { title: "Apply to Acme Corp", description: "Staff engineer, remote" }); + + const stored = (await mirroredRuntimeTask(env, INSTANCE, USER, TASK)) as Record; + expect(stored.title).toBe("Apply to Acme Corp"); + expect(stored.description).toBe("Staff engineer, remote"); + expect(stored.reasoning).toBe(TICKET.reasoning); + }); + + it("refuses a patch with nothing amendable in it rather than answering ok", async () => { + const { app, env, token } = await setup(); + + // `status` is deliberately not amendable here — moving a card is POST /board/status, + // which validates the target against the agent's columns. Sending only that is an + // empty patch, and must not read as success. + const res = await patch(app, env, token, { status: "completed" }); + expect(res.status).toBe(400); + + const stored = (await mirroredRuntimeTask(env, INSTANCE, USER, TASK)) as Record; + expect(stored.status).toBe("needs_approval"); + }); + + it("refuses an empty title and a non-string field", async () => { + const { app, env, token } = await setup(); + + expect((await patch(app, env, token, { title: " " })).status).toBe(400); + expect((await patch(app, env, token, { title: 42 })).status).toBe(400); + expect((await patch(app, env, token, { description: null })).status).toBe(400); + + const stored = (await mirroredRuntimeTask(env, INSTANCE, USER, TASK)) as Record; + expect(stored.title).toBe(TICKET.title); + }); + + it("caps a field at its create-time limit, so editing cannot grow it", async () => { + const { app, env, token } = await setup(); + + await patch(app, env, token, { title: "x".repeat(500) }); + + const stored = (await mirroredRuntimeTask(env, INSTANCE, USER, TASK)) as Record; + expect((stored.title as string).length).toBe(200); + }); + + it("404s for a task id that is not on this instance", async () => { + const { app, env, token } = await setup(); + + const res = await patch(app, env, token, { title: "nope" }, "task-does-not-exist"); + expect(res.status).toBe(404); + }); +}); diff --git a/workers/api/src/routes/instances-tasks.ts b/workers/api/src/routes/instances-tasks.ts index 7f98045b..fc8098d5 100644 --- a/workers/api/src/routes/instances-tasks.ts +++ b/workers/api/src/routes/instances-tasks.ts @@ -337,6 +337,76 @@ export function registerTaskRoutes(router: Hono<{ Bindings: Env }>): void { return c.json(task, 201); }); + /** + * Amend a board ticket's WORDING — title, description, reasoning (PAS #137). + * + * A ticket filed with imprecise wording could previously only be replaced: file a + * corrected one and cancel the first, leaving a dead card behind for what should be a + * one-field fix. + * + * POSTing `/tasks/direct` with an existing `id` looks like it would already do this — + * `mirrorRuntimeTask` is an upsert (`ON CONFLICT(id) DO UPDATE`) — but it is a REPLACE, + * not an edit. That route rebuilds the task from the request body, so amending one field + * blanks `description` and `reasoning`, resets `status` to completed, drops an actionable + * ticket's `action`, and moves `createdAt`. This route MERGES: absent means leave alone. + * + * Identity and history are not editable. `id`, `type` and `createdAt` are pinned from the + * stored row. `status` is deliberately NOT patchable here — moving a card is + * `POST /board/status`, which validates the target against the agent's configured columns. + * Accepting a status here would be a second, unvalidated way to move a card, and the + * validation is the whole reason that route exists. + * + * Reads and writes the D1 mirror only, with no runtime call. A ticket is runner-less by + * construction (`/tasks/direct` never reaches a runtime), so there is no live runner + * holding a fresher copy to reconcile against — unlike `GET /tasks/:taskId` above. + */ + router.patch("/:instanceId/tasks/:taskId", async (c) => { + const session = await requireUser(c); + const instanceId = c.req.param("instanceId"); + await requireOwnedInstance(c.env, instanceId, session.uid); + const taskId = c.req.param("taskId"); + + const stored = await mirroredRuntimeTask(c.env, instanceId, session.uid, taskId); + if (!isRecord(stored)) return c.json({ error: "Task not found" }, 404); + + const body = await c.req + .json<{ title?: unknown; description?: unknown; reasoning?: unknown }>() + .catch(() => ({}) as Record); + + // Same caps as `/tasks/direct`, so a field cannot be grown past its create limit by + // editing it afterwards. + const patch: Record = {}; + if (body.title !== undefined) { + if (typeof body.title !== "string" || !body.title.trim()) { + return c.json({ error: "title must be a non-empty string" }, 400); + } + patch.title = body.title.slice(0, 200); + } + if (body.description !== undefined) { + if (typeof body.description !== "string") return c.json({ error: "description must be a string" }, 400); + patch.description = body.description.slice(0, 2000); + } + if (body.reasoning !== undefined) { + if (typeof body.reasoning !== "string") return c.json({ error: "reasoning must be a string" }, 400); + patch.reasoning = body.reasoning.slice(0, 8000); + } + // An empty patch is a 400, not a cheerful 200. A caller that sent only unrecognised + // keys has a bug, and answering "ok" would hide it behind an unchanged card. + if (Object.keys(patch).length === 0) { + return c.json({ error: "provide at least one of title, description, reasoning" }, 400); + } + + const updated = { + ...stored, + ...patch, + id: typeof stored.id === "string" && stored.id ? stored.id : taskId, + createdAt: stored.createdAt ?? stored.created_at ?? new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + await mirrorRuntimeTask(c.env, instanceId, session.uid, updated); + return c.json(updated); + }); + /** * Run an actionable ticket — the runner-less approval gate. Reads the ticket's declared * `action` (fixed when the agent created it; this route never accepts new work), executes it diff --git a/workers/api/src/routes/instances.contract.test.ts b/workers/api/src/routes/instances.contract.test.ts index be1e4af9..3f834fc9 100644 --- a/workers/api/src/routes/instances.contract.test.ts +++ b/workers/api/src/routes/instances.contract.test.ts @@ -267,6 +267,7 @@ const ROUTES = [ "PUT /:instanceId/board-config", "POST /:instanceId/tasks", "POST /:instanceId/tasks/direct", + "PATCH /:instanceId/tasks/:taskId", "POST /:instanceId/tasks/:taskId/run", "GET /:instanceId/tasks/:taskId/thread", "POST /:instanceId/tasks/:taskId/thread", @@ -389,6 +390,7 @@ const OWNERSHIP: Record = { "PUT /:instanceId/board-config", "POST /:instanceId/tasks", "POST /:instanceId/tasks/direct", + "PATCH /:instanceId/tasks/:taskId", "POST /:instanceId/tasks/:taskId/run", "GET /:instanceId/tasks/:taskId/thread", "POST /:instanceId/tasks/:taskId/thread", @@ -560,6 +562,10 @@ const GATES: Record = { "PUT /:instanceId/board-config": [401, 404], "POST /:instanceId/tasks": [401, 404], "POST /:instanceId/tasks/direct": [401, 404], + // Editing a ticket's wording (PAS #137). Gated before the body is looked at, so a + // stranger is refused whether or not the patch itself is well-formed — which is why + // this needs no BODIES entry. + "PATCH /:instanceId/tasks/:taskId": [401, 404], "POST /:instanceId/tasks/:taskId/run": [401, 404], "GET /:instanceId/tasks/:taskId/thread": [401, 404], "POST /:instanceId/tasks/:taskId/thread": [401, 404], diff --git a/workers/mcp/AGENTS.md b/workers/mcp/AGENTS.md index dbfc4664..81b2b13c 100644 --- a/workers/mcp/AGENTS.md +++ b/workers/mcp/AGENTS.md @@ -12,7 +12,7 @@ Connection methods and the full tool table: [`README.md`](./README.md). unless the user explicitly overrides this. 2. **Discover the surface before using it.** The tool list is versioned and - *per-connection*: of the 136 tool registrations, 19 are gated to the console surfaces + *per-connection*: of the 137 tool registrations, 19 are gated to the console surfaces of the agents the connected user actually subscribes to (`apply`, `repo`, `coding`). A tool you used last week may be absent today, and a tool present for one user is absent for another. Call `tools/list` first; never assume a name exists. @@ -267,7 +267,7 @@ work. | Verify retrieval | `vector_stats`, `search_instance_knowledge`, `list_instance_files` | | Shape its behaviour | `set_instance_instructions`, `set_instance_settings`, `write_instance_memory`, `set_instance_model`, `set_instance_tool` | | Structured data | `list_instance_collections`, `query_instance_records`, `insert_instance_record` | -| Work board | `instance_board`, `set_board_item_status`, `create_instance_ticket`, `run_instance_task`, `approve_instance_task` | +| Work board | `instance_board`, `set_board_item_status`, `update_board_ticket`, `create_instance_ticket`, `run_instance_task`, `approve_instance_task` | | Local runtime | `register_instance_runtime`, `instance_runtime_status`, coding session tools | | Automate | `create_instance_trigger`, `run_instance_trigger`, `create_connection`, `create_supervision` | | Observe | `agent_trace`, `list_errors`, `instance_activity`, `list_pipeline_runs`, `usage_summary`, `mcp_audit_log` | diff --git a/workers/mcp/CLAUDE.md b/workers/mcp/CLAUDE.md index 82e03c6d..42cda9c7 100644 --- a/workers/mcp/CLAUDE.md +++ b/workers/mcp/CLAUDE.md @@ -67,7 +67,7 @@ src/ ├── runtime.ts 7 tools — the `pags up` runtime + its task queue ├── knowledge.ts 10 tools — documents, files, vectors, memory ├── observability.ts 9 tools — messages, activity, errors, trace, pipeline runs, feedback - ├── board.ts 8 tools — the board, its columns, the per-ticket thread (#150) + ├── board.ts 9 tools — the board, its columns, the per-ticket thread (#150) ├── settings.ts 11 tools — settings, name, instructions, model, translation, state ├── triggers.ts 5 tools — webhook / cron / connector-sync triggers ├── composition.ts 8 tools — supervision (#183), connections (#182), loops @@ -80,9 +80,9 @@ src/ └── coding.ts system_status + coding_timeline (gated to surfaces:["coding"]) + 3 loop tools ``` -**136 tool registrations** (`.tool(` in the files above): 31 in `index.ts` — 10 of them -inside a `groups.has("coding")` block — 13 in `storage-tools.ts`, and 92 across -`instance-tools/`. 117 are always registered; 19 are surface-gated (apply=4, repo=3, coding=12). +**137 tool registrations** (`.tool(` in the files above): 31 in `index.ts` — 10 of them +inside a `groups.has("coding")` block — 13 in `storage-tools.ts`, and 93 across +`instance-tools/`. 118 are always registered; 19 are surface-gated (apply=4, repo=3, coding=12). Those three numbers ADD UP to the headline, and that is the point of stating them: 31 + 13 + 92 = 136. They said 88 until #602, which made the paragraph sum to 132 — a total the diff --git a/workers/mcp/README.md b/workers/mcp/README.md index c2428090..08333cd5 100644 --- a/workers/mcp/README.md +++ b/workers/mcp/README.md @@ -126,7 +126,7 @@ implementation. ## Tools -**136 tool registrations.** 117 are always registered; 19 are gated to the console +**137 tool registrations.** 118 are always registered; 19 are gated to the console surfaces of the connected user's subscribed agents (`apply`, `repo`, `coding`), so a Repo Chat user never sees `apply_to_job`. @@ -251,6 +251,7 @@ Agent-scoped (the creator's template), not instance-scoped. | `get_instance_board_config` | Read board columns + preferred view | — | | | | `set_instance_board_config` | Override columns / view per instance | write | yes | | | `set_board_item_status` | Move a card, or reset it to automation | write | yes | | +| `update_board_ticket` | Amend an existing ticket's title / description / reasoning (never its column) | write | yes | | | `run_instance_task` | Create a task on the registered runtime | runtime | yes | | | `approve_instance_task` | Approve a task waiting for human approval | runtime | yes | | | `cancel_instance_task` | Cancel a runtime task | destructive | yes | `cancel_instance_task` | diff --git a/workers/mcp/src/instance-tools/board.ts b/workers/mcp/src/instance-tools/board.ts index 8b22313f..956ab041 100644 --- a/workers/mcp/src/instance-tools/board.ts +++ b/workers/mcp/src/instance-tools/board.ts @@ -1,6 +1,6 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { authRequired, authedCall, jsonText, text } from "../http.js"; +import { authRequired, authedCall, jsonText, text, type McpEnv } from "../http.js"; import { audit, dryRun, requirePermission } from "../safety.js"; import { clearFinishedSentence } from "../state-vocabulary.js"; import { groupBoard, type InstanceToolsCtx, isRec } from "./shared.js"; @@ -15,6 +15,48 @@ import { groupBoard, type InstanceToolsCtx, isRec } from "./shared.js"; * is grounded in that one ticket's record and CANNOT act — is a property of the board's * approval gate, not of chat. */ +/** + * Resolve a board `jobKey` to the task id the ticket routes take (PAS #137). + * + * The two identifiers address different things and nothing maps between them outside the + * board itself. `set_board_item_status` never needs the mapping — `/board/status` writes a + * SEPARATE jobKey-keyed overlay table and never touches the task — and the grouping that + * turns tasks into cards only exists inside the API's `buildInstanceBoard`. So the board is + * asked, rather than the key being guessed at. + * + * (For a ticket the two happen to coincide: `jobKeyForTask` falls back to the task id for + * anything without a job URL. Relying on that would be a latent bug — it is a fallback in a + * function whose other branches key by URL, not a contract.) + * + * Returns the card's `latestTaskId` — the ticket the card opens. A card that exists only as + * a status overlay (moved by hand, its runs since cleared) carries an EMPTY `latestTaskId`; + * that is reported as its own failure rather than sent on as a request to patch task "". + */ +async function resolveJobKeyToTaskId( + instanceId: string, + jobKey: string, + sessionToken: string, + env: McpEnv, +): Promise<{ taskId: string } | { error: string }> { + let data: unknown; + try { + data = await authedCall(`/v1/instances/${instanceId}/board`, sessionToken, {}, env); + } catch (e) { + return { error: `board unavailable: ${e instanceof Error ? e.message : String(e)}` }; + } + if (isRec(data) && data.error) return { error: String(data.error) }; + const items = isRec(data) && Array.isArray(data.items) ? data.items : []; + const card = items.find((it) => isRec(it) && it.jobKey === jobKey); + if (!card || !isRec(card)) return { error: `no board card with jobKey "${jobKey}" — get it from instance_board` }; + const taskId = typeof card.latestTaskId === "string" ? card.latestTaskId : ""; + if (!taskId) { + return { + error: `board card "${jobKey}" has no ticket to edit: it exists only as a moved card, or its runs have been cleared`, + }; + } + return { taskId }; +} + export function registerBoardTools(server: McpServer, ctx: InstanceToolsCtx): void { const { env, tokenFor, safetyFor } = ctx; @@ -92,6 +134,67 @@ export function registerBoardTools(server: McpServer, ctx: InstanceToolsCtx): vo }, ); + server.tool( + "update_board_ticket", + "Amend an existing board ticket's WORDING — its title, description and/or reasoning (PAS #137). Address the card by `job_key` from instance_board, the same key set_board_item_status takes. Only the fields you pass change: omit one and it is left alone, pass \"\" to clear it. Everything else about the ticket — its column, its declared action, when it was created — is untouched. To MOVE a card between columns use set_board_item_status; this tool never changes status. Use it to correct a ticket filed with wrong or imprecise wording instead of filing a second, corrected one.", + { + token: z.string().optional().describe("PAGS session token. Omit when connected with browser sign-in."), + instance_id: z.string(), + job_key: z.string().describe("The card's jobKey from instance_board"), + title: z.string().optional().describe("Replacement title (max 200 chars). Omit to leave it alone."), + description: z.string().optional().describe("Replacement one-line detail under the title (max 2000 chars). Omit to leave it alone, \"\" to clear it."), + reasoning: z.string().optional().describe("Replacement 'Why:' block — the decision/audit shown on the card (max 8000 chars). Omit to leave it alone, \"\" to clear it."), + dry_run: z.boolean().optional(), + }, + async ({ token, instance_id, job_key, title, description, reasoning, dry_run }) => { + const sessionToken = tokenFor(token); + if (!sessionToken) return authRequired(); + + // Built from what was PASSED, not from what is non-empty: "" is a real instruction + // (clear this field) and must survive into the patch, while an omitted field must not + // appear at all — that difference is the whole merge contract. + const patch: Record = {}; + if (title !== undefined) patch.title = title; + if (description !== undefined) patch.description = description; + if (reasoning !== undefined) patch.reasoning = reasoning; + if (Object.keys(patch).length === 0) { + return jsonText({ error: "provide at least one of title, description, reasoning" }); + } + + // The audited input names WHICH fields were amended, not their contents: a ticket's + // prose can be long and is already stored on the card. + const input = { instance_id, job_key, fields: Object.keys(patch) }; + const denied = await requirePermission(safetyFor(token), "write", "update_board_ticket", input); + if (denied) return denied; + + // The dry run answers BEFORE the jobKey is resolved, and the endpoint it reports is + // therefore templated rather than concrete. Resolving first would read better — a + // preview could then reject an unknown jobKey — but resolution is a board fetch, and a + // declared dry run reaching the network is exactly what `contract.test.ts` forbids. + // A preview that quietly makes a request is not a preview. + if (dry_run) { + return dryRun(safetyFor(token), "update_board_ticket", "amend board ticket wording", input, { + endpoint: `/v1/instances/${instance_id}/tasks/`, + method: "PATCH", + fields: Object.keys(patch), + }); + } + + // jobKey addresses a CARD, the ticket routes address a TASK, and only the board maps + // between them — see resolveJobKeyToTaskId. + const resolved = await resolveJobKeyToTaskId(instance_id, job_key, sessionToken, env); + if ("error" in resolved) return jsonText(resolved); + const data = await authedCall( + `/v1/instances/${instance_id}/tasks/${resolved.taskId}`, + sessionToken, + { method: "PATCH", body: JSON.stringify(patch) }, + env, + ); + if (!(data as { error?: string }).error) await audit(safetyFor(token), { tool: "update_board_ticket", action: "completed", input, result: data }); + return jsonText(data); + }, + ); + server.tool( "get_instance_board_config", "Read a private instance's board configuration: its columns, the preferred view (kanban | list), whether the columns are a per-instance override or the agent's own, and the agent's default columns. Pair with set_instance_board_config to customize the board.", diff --git a/workers/mcp/src/instance-tools/contract.test.ts b/workers/mcp/src/instance-tools/contract.test.ts index 96626898..62a49633 100644 --- a/workers/mcp/src/instance-tools/contract.test.ts +++ b/workers/mcp/src/instance-tools/contract.test.ts @@ -296,6 +296,7 @@ const TABLE: Record = { set_agent_settings_schema: ["settings", "write", null, "envelope", "agent_id,dry_run,settings_schema,token"], set_agent_stats_schema: ["stats", "write", null, "envelope", "agent_id,cards,dry_run,token"], set_board_item_status: ["board", "write", null, "envelope", "dry_run,instance_id,job_key,status,token"], + update_board_ticket: ["board", "write", null, "envelope", "description,dry_run,instance_id,job_key,reasoning,title,token"], set_instance_board_config: ["board", "write", null, "envelope", "columns,dry_run,instance_id,reset,token,view"], set_instance_instructions: ["settings", "write", null, "envelope", "dry_run,instance_id,instructions,token"], set_instance_model: ["settings", "write", null, "envelope", "dry_run,instance_id,model,token"], diff --git a/workers/mcp/src/server-version.ts b/workers/mcp/src/server-version.ts index c05a419f..cf9958b6 100644 --- a/workers/mcp/src/server-version.ts +++ b/workers/mcp/src/server-version.ts @@ -88,4 +88,4 @@ */ /** Advertised in `serverInfo.version`, and restated in `server.json` and `platform-docs/mcp.md`. */ -export const MCP_SERVER_VERSION = "0.1.6"; +export const MCP_SERVER_VERSION = "0.1.7"; diff --git a/workers/mcp/src/surface-lock.ts b/workers/mcp/src/surface-lock.ts index 4f55bad2..b315a0d0 100644 --- a/workers/mcp/src/surface-lock.ts +++ b/workers/mcp/src/surface-lock.ts @@ -116,4 +116,14 @@ export const SURFACE_LOCK: Record = { // through when `my_instances` became `{"instances":[…]}` under a frozen version, so the bump // is justified twice over here as it was for 0.1.5. Appended, never edited: 0.1.5 is published. "0.1.6": "sha256:bdf6eb2efd98c4df362bbc6762537ca8b687dc9a03ce21a519326a73c21b9e98", + // 0.1.7 (PAS #137): a new tool NAME, `update_board_ticket` — the first bump here for an + // addition rather than a paging argument. A board ticket could be filed and moved but never + // edited, so correcting one word meant filing a replacement and cancelling the original, + // leaving a dead card behind. + // + // The set of registered tool names is the first bullet of server-version.ts's bump list, and + // it is the one a caching host is most exposed to: a host holding the 0.1.6 list does not know + // this tool exists, and unlike a widened `inputSchema` there is no degraded call it can still + // make. Appended, never edited: 0.1.6 is published. + "0.1.7": "sha256:e038b7156b6790dbc48ab020e5d53d918c1b69d265ab2aa5def73b6e657806b2", }; diff --git a/workers/mcp/src/tool-count.ts b/workers/mcp/src/tool-count.ts index fc7f255d..743bfc79 100644 --- a/workers/mcp/src/tool-count.ts +++ b/workers/mcp/src/tool-count.ts @@ -20,10 +20,10 @@ */ /** Every tool the server can register, with all surfaces gated on. */ -export const MCP_TOOL_COUNT = 136; +export const MCP_TOOL_COUNT = 137; /** Registered for every connection, whatever the user is subscribed to. */ -export const MCP_TOOL_ALWAYS_ON = 117; +export const MCP_TOOL_ALWAYS_ON = 118; /** Registered only when the user has an agent with the matching console surface * (`apply`, `repo`, `coding`) — so a Repo Chat user never sees `apply_to_job`. */ diff --git a/workers/mcp/src/tool-metadata.ts b/workers/mcp/src/tool-metadata.ts index d122ee9a..d6578ead 100644 --- a/workers/mcp/src/tool-metadata.ts +++ b/workers/mcp/src/tool-metadata.ts @@ -202,6 +202,7 @@ export const TOOL_RISK: Record = { set_instance_tool: "write", set_translation_config: "write", stop_instance_loop: "write", + update_board_ticket: "write", subscribe_agent: "write", update_agent: "write", update_agent_board_config: "write", @@ -271,7 +272,7 @@ export const TOOL_RISK: Record = { * annotation is as much a regression as silently gaining one. */ export const MCP_RISK_COUNTS: Record = { read: 67, - write: 40, + write: 41, runtime: 15, destructive: 14, }; diff --git a/workers/mcp/src/write-readback.test.ts b/workers/mcp/src/write-readback.test.ts index d80a0efa..fda4a55d 100644 --- a/workers/mcp/src/write-readback.test.ts +++ b/workers/mcp/src/write-readback.test.ts @@ -124,6 +124,12 @@ const READBACK: Record = { // #574: the fix. `instance_board(reasoning:true)` returns it; `board-reasoning.test.ts` // proves that by driving the tool, not by trusting this line. "create_instance_ticket.reasoning": "instance_board", + // PAS #137 — the same three fields, amended rather than filed. They read back through the + // same card, so if `instance_board` ever stops returning one of them BOTH the create and + // the edit path go unreadable together, which is the honest coupling. + "update_board_ticket.title": "instance_board", + "update_board_ticket.description": "instance_board", + "update_board_ticket.reasoning": "instance_board", // ── conversation ── "chat_with_agent.message": "instance_messages",