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
CLI — npm 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",