Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/mcp-instance-runtime.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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.
Expand Down
6 changes: 3 additions & 3 deletions platform-docs/mcp.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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` —
Expand DownExpand Up@@ -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.
Expand DownExpand Up@@ -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:

Expand Down
2 changes: 1 addition & 1 deletion server.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion store/.well-known/mcp-server.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion store/about/index.html
Original file line numberDiff line numberDiff line change
Expand Up@@ -176,7 +176,7 @@ <h2>Data isolation</h2>

<h2>Developer tools</h2>
<p><strong>CLI</strong> — <code style="background:var(--panel);padding:0.1rem 0.4rem;border-radius:4px;font-size:0.85rem">npm i -g @proagentstore/cli</code> then <code style="background:var(--panel);padding:0.1rem 0.4rem;border-radius:4px;font-size:0.85rem">pags up</code>: one runner serves ALL your agents over a WebSocket relay — no tunnel binary, no config. Plus <code style="background:var(--panel);padding:0.1rem 0.4rem;border-radius:4px;font-size:0.85rem">pags login</code>, <code style="background:var(--panel);padding:0.1rem 0.4rem;border-radius:4px;font-size:0.85rem">pags init</code>, <code style="background:var(--panel);padding:0.1rem 0.4rem;border-radius:4px;font-size:0.85rem">pags check</code>, <code style="background:var(--panel);padding:0.1rem 0.4rem;border-radius:4px;font-size:0.85rem">pags publish</code>.</p>
<p><strong>MCP Server</strong> — manage agents from Claude Code, Cursor, or VS Code. 136 tools across creator operations, public trials, private instance runtime, coding, and error observability (<code style="background:var(--panel);padding:0.1rem 0.4rem;border-radius:4px;font-size:0.85rem">list_errors</code>).</p>
<p><strong>MCP Server</strong> — manage agents from Claude Code, Cursor, or VS Code. 137 tools across creator operations, public trials, private instance runtime, coding, and error observability (<code style="background:var(--panel);padding:0.1rem 0.4rem;border-radius:4px;font-size:0.85rem">list_errors</code>).</p>
<p><strong>Skills</strong> — install the same ProAgentStore MCP workflow in Codex and Claude Code from <a href="/skills/">the skills catalog</a>.</p>
<p><strong>Webhook ingestion</strong> — Zapier, Make, n8n can POST documents directly into an instance's knowledge base.</p>
<p><strong>API key vault</strong> — store your OpenAI, Anthropic, Google AI keys once. Encrypted AES-256-GCM. Agents access via proxy.</p>
Expand Down
2 changes: 1 addition & 1 deletion store/llms-full.txt
Original file line numberDiff line numberDiff line change
Expand Up@@ -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:

Expand Down
44 changes: 44 additions & 0 deletions store/openapi.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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:
Expand Down
162 changes: 162 additions & 0 deletions workers/api/src/routes/instances-tasks-patch.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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);
});
});
70 changes: 70 additions & 0 deletions workers/api/src/routes/instances-tasks.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, never>);

// Same caps as `/tasks/direct`, so a field cannot be grown past its create limit by
// editing it afterwards.
const patch: Record<string, unknown> = {};
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
Expand Down
6 changes: 6 additions & 0 deletions workers/api/src/routes/instances.contract.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand DownExpand Up@@ -389,6 +390,7 @@ const OWNERSHIP: Record<string, string[]> = {
"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",
Expand DownExpand Up@@ -560,6 +562,10 @@ const GATES: Record<string, [number, number]> = {
"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],
Expand Down
Loading
Loading