feat(web): WebMCP write/mutating dispatch + bootstrap (TASK-1893) (#766)

Wire every mutating catalog action in the browser WebMCP dispatcher to a
real api client method, completing the full-surface scope (DR-5). Each new
dispatch entry forces the route wsSlug and rejects an agent-supplied
workspace arg (DR-4); no write action silently no-ops.

Actions wired:
- pad_item: create, update, delete, restore, move, link, unlink, star,
  unstar, comment, bulk-update, import (export/deps/list-comments/starred
  reads also added)
- pad_collection: create, update, delete
- pad_role: create, update, delete
- pad_playbook: run (side-effect-free)
- pad_library: activate (resolve-by-title, mirrors the CLI)
- pad_meta: bootstrap (scope addition — GET /agent/bootstrap)

Thin client.ts methods added: playbooks.run, agentBootstrap,
library.activateByTitle.

Consent (DR-2): mutating tools mix reads+writes so descriptors emit no
readOnlyHint — the browser fires per-invocation consent before execute
runs. This dispatcher is the post-consent execution layer, not the gate.

pad_project next/standup/changelog left as honest-error stubs (need new
backend endpoints, tracked in TASK-1894).

Refs TASK-1893 / PLAN-1888

Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
This commit is contained in:
xarmian
2026-06-24 23:43:37 -04:00
committed by GitHub
parent 2b11383e13
commit 2ee7dd3fac
4 changed files with 1099 additions and 108 deletions
+60
View File
@@ -497,6 +497,24 @@ export const api = {
/** Full playbook item by ref / slug / invocation_slug. */
get: (ws: string, ref: string) =>
request<Item>(`/workspaces/${ws}/playbooks/${ref}`),
/**
* Bind args to a playbook's declared spec and return the body +
* bound args + any unsatisfied required args. Side-effect-free
* server-side (the server only parses; the agent executes) —
* mirrors the CLI's `pad playbook run` and the MCP
* `pad_playbook.action=run`. Pass either a pre-parsed `args` map
* or raw CLI tokens (`raw_args`); the server merges them.
*/
run: (
ws: string,
ref: string,
body?: { args?: Record<string, unknown>; raw_args?: string[] }
) =>
request<unknown>(`/workspaces/${ws}/playbooks/${ref}/run`, {
method: 'POST',
body: JSON.stringify(body ?? {})
}),
},
// ── Workspaces ────────────────────────────────────────────────────────────
@@ -1126,6 +1144,20 @@ export const api = {
request<DashboardResponse>(`/workspaces/${ws}/dashboard`)
},
// ── Agent bootstrap ───────────────────────────────────────────────────────
/**
* One-round-trip agent context for a workspace — user + collections +
* always-on conventions + roles + playbook metadata + dashboard +
* `needs_onboarding`. Mirrors the CLI `pad bootstrap`, the MCP
* `pad_meta.action=bootstrap`, and the `pad://workspace/{ws}/bootstrap`
* resource. Read-only. Typed loosely (`unknown`) because the
* AgentBootstrap shape lives in the Go server package and is not
* mirrored as a TS interface.
*/
agentBootstrap: (ws: string) =>
request<unknown>(`/workspaces/${ws}/agent/bootstrap`),
// ── Workspace Graph (PLAN-1730 / TASK-1732) ───────────────────────────────
graph: {
@@ -1283,6 +1315,34 @@ export const api = {
fields: JSON.stringify(fields)
})
});
},
/**
* Activate a library convention or playbook by its exact title.
* Resolves the title against the global library client-side
* (conventions first, then playbooks — the same precedence the CLI
* `pad library activate` and the MCP `pad_library.action=activate`
* use) and creates the matching workspace item. There is no
* server-side activate-by-title endpoint; the resolution lives in
* the client, mirroring cmd/pad/main.go::libraryActivateCmd.
*
* Throws when no entry matches the title.
*/
activateByTitle: async (ws: string, title: string): Promise<Item> => {
const conv = await api.library.get();
for (const cat of conv.categories ?? []) {
const match = (cat.conventions ?? []).find((c) => c.title === title);
if (match) return api.library.activate(ws, match);
}
const plib = await api.library.getPlaybooks();
for (const cat of plib.categories ?? []) {
const match = (cat.playbooks ?? []).find((p) => p.title === title);
if (match) return api.library.activatePlaybook(ws, match);
}
throw new PadApiError({
code: 'not_found',
message: `no library convention or playbook titled ${JSON.stringify(title)}`
});
}
},
+4
View File
@@ -384,6 +384,7 @@ export interface Collection {
export interface CollectionCreate {
name: string;
slug?: string;
prefix?: string;
icon?: string;
description?: string;
schema?: string;
@@ -397,6 +398,7 @@ export interface FieldMigration {
export interface CollectionUpdate {
name?: string;
prefix?: string;
icon?: string;
description?: string;
schema?: string;
@@ -629,6 +631,8 @@ export interface ItemCreate {
tags?: string;
pinned?: boolean;
parent_id?: string;
assigned_user_id?: string;
agent_role_id?: string;
created_by?: string;
source?: string;
}
+566 -61
View File
@@ -9,39 +9,99 @@ function mockApi() {
search: vi.fn(async () => ({ results: [] })),
items: {
list: vi.fn(async () => []),
get: vi.fn(async () => ({ ref: 'TASK-1' })),
get: vi.fn(async () => ({ id: 'uuid-target', ref: 'TASK-1' })),
backlinks: vi.fn(async () => []),
create: vi.fn(async () => ({ ref: 'TASK-9' })),
update: vi.fn(async () => ({ ref: 'TASK-1' })),
delete: vi.fn(async () => undefined),
restore: vi.fn(async () => ({ ref: 'TASK-1' })),
move: vi.fn(async () => ({ ref: 'TASK-1' })),
star: vi.fn(async () => undefined),
unstar: vi.fn(async () => undefined),
starred: vi.fn(async () => []),
bulk: vi.fn(async () => ({ op: 'move', updated: [], failed: [] })),
},
links: {
list: vi.fn(async () => [
{ id: 'link-1', link_type: 'blocks', target_id: 'uuid-target' },
]),
create: vi.fn(async () => ({ id: 'link-2' })),
delete: vi.fn(async () => undefined),
},
comments: {
list: vi.fn(async () => []),
create: vi.fn(async () => ({ id: 'c-1' })),
},
dashboard: { get: vi.fn(async () => ({ ok: true })) },
collections: { list: vi.fn(async () => []) },
agentRoles: { list: vi.fn(async () => []) },
playbooks: { list: vi.fn(async () => []), get: vi.fn(async () => ({})) },
library: { get: vi.fn(async () => ({})) },
collections: {
list: vi.fn(async () => []),
create: vi.fn(async () => ({ slug: 'risks' })),
update: vi.fn(async () => ({ slug: 'risks' })),
delete: vi.fn(async () => undefined),
},
agentRoles: {
list: vi.fn(async () => []),
get: vi.fn(async () => ({ id: 'role-uuid', slug: 'reviewer' })),
create: vi.fn(async () => ({ slug: 'reviewer' })),
update: vi.fn(async () => ({ slug: 'reviewer' })),
delete: vi.fn(async () => undefined),
},
members: {
list: vi.fn(async () => ({
members: [
{ user_id: 'user-uuid', user_name: 'Dave', user_email: 'dave@example.com' },
],
invitations: [],
})),
},
playbooks: {
list: vi.fn(async () => []),
get: vi.fn(async () => ({})),
run: vi.fn(async () => ({ body: 'do the thing' })),
},
library: {
get: vi.fn(async () => ({})),
activateByTitle: vi.fn(async () => ({ ref: 'CONVE-3' })),
},
workspaces: { list: vi.fn(async () => []) },
exportItemArtifact: vi.fn(async () => ({ filename: 'x.pad.md', text: 'ARTIFACT' })),
importArtifact: vi.fn(async () => ({ ref: 'PLAYB-7', slug: 'ship', warnings: [] })),
agentBootstrap: vi.fn(async () => ({ needs_onboarding: false })),
};
}
type Api = typeof ApiClient;
// All catalog read actions are read_only=true; writes false. The real
// lookup comes from the served read set; here we hardcode the relevant ones.
// Mirror the served read set: known reads → true, known writes false.
// (The dispatcher no longer branches on this — the route table is the source
// of truth — but register.ts still supplies it, so we keep it realistic.)
const READ = new Set([
'pad_search:query',
'pad_item:list',
'pad_item:get',
'pad_item:deps',
'pad_item:list-comments',
'pad_item:starred',
'pad_item:backlinks',
'pad_item:export',
'pad_project:dashboard',
'pad_collection:list',
'pad_role:list',
'pad_playbook:list',
'pad_playbook:get',
'pad_playbook:run',
'pad_library:list',
'pad_library:get',
'pad_workspace:list',
'pad_meta:bootstrap',
]);
const isReadOnly = (tool: string, action: string): boolean | undefined => {
// Mirror the served read set: known reads → true, known writes → false.
if (READ.has(`${tool}:${action}`)) return true;
if (['create', 'update', 'delete', 'import'].includes(action)) return false;
if (
['create', 'update', 'delete', 'import', 'comment', 'link', 'unlink', 'move',
'restore', 'star', 'unstar', 'bulk-update', 'activate'].includes(action)
)
return false;
return undefined;
};
@@ -51,89 +111,537 @@ function parse(result: { content: { text: string }[]; isError?: boolean }) {
return { isError: result.isError === true, text: result.content[0]?.text ?? '' };
}
function run(api: ReturnType<typeof mockApi>, tool: string, args: Record<string, unknown>) {
return dispatch(api as unknown as Api, WS, isReadOnly, tool, args);
}
// ── Reads (regression — unchanged from 3a) ───────────────────────────────────
describe('dispatch — wsSlug injection (DR-4)', () => {
it('passes the route wsSlug to a read handler, never an arg', async () => {
const api = mockApi();
await dispatch(api as unknown as Api, WS, isReadOnly, 'pad_item', {
action: 'list',
status: 'open',
});
await run(api, 'pad_item', { action: 'list', status: 'open' });
expect(api.items.list).toHaveBeenCalledWith(WS, expect.objectContaining({ status: 'open' }));
});
it('injects wsSlug into search filters', async () => {
const api = mockApi();
await dispatch(api as unknown as Api, WS, isReadOnly, 'pad_search', {
action: 'query',
query: 'hello',
});
await run(api, 'pad_search', { action: 'query', query: 'hello' });
expect(api.search).toHaveBeenCalledWith('hello', expect.objectContaining({ workspace: WS }));
});
it('forwards ref to items.get', async () => {
const api = mockApi();
await dispatch(api as unknown as Api, WS, isReadOnly, 'pad_item', {
action: 'get',
ref: 'TASK-5',
});
await run(api, 'pad_item', { action: 'get', ref: 'TASK-5' });
expect(api.items.get).toHaveBeenCalledWith(WS, 'TASK-5');
});
});
// ── Supplied-workspace rejection applies to writes too (DR-4) ────────────────
describe('dispatch — supplied-workspace rejection (DR-4)', () => {
it('rejects an agent-supplied workspace arg outright', async () => {
it('rejects an agent-supplied workspace arg outright on a read', async () => {
const api = mockApi();
const result = await dispatch(api as unknown as Api, WS, isReadOnly, 'pad_item', {
action: 'list',
workspace: 'other-workspace',
});
const result = await run(api, 'pad_item', { action: 'list', workspace: 'other-workspace' });
const { isError, text } = parse(result);
expect(isError).toBe(true);
expect(text).toMatch(/workspace/i);
// And it never reached the handler.
expect(api.items.list).not.toHaveBeenCalled();
});
it('rejects an agent-supplied workspace arg on a WRITE (create), no client call', async () => {
const api = mockApi();
const result = await run(api, 'pad_item', {
action: 'create',
collection: 'tasks',
title: 'X',
workspace: 'other-workspace',
});
expect(parse(result).isError).toBe(true);
expect(api.items.create).not.toHaveBeenCalled();
});
it('rejects an agent-supplied workspace on delete even when it equals the route slug', async () => {
const api = mockApi();
const result = await run(api, 'pad_item', { action: 'delete', ref: 'TASK-1', workspace: WS });
expect(parse(result).isError).toBe(true);
expect(api.items.delete).not.toHaveBeenCalled();
});
it('ignores an empty-string workspace arg (treated as not supplied)', async () => {
const api = mockApi();
const result = await dispatch(api as unknown as Api, WS, isReadOnly, 'pad_item', {
action: 'list',
workspace: '',
});
const result = await run(api, 'pad_item', { action: 'list', workspace: '' });
expect(parse(result).isError).toBe(false);
expect(api.items.list).toHaveBeenCalledWith(WS, expect.anything());
});
});
it('rejects even when the supplied workspace equals the route slug', async () => {
// ── pad_item writes ─────────────────────────────────────────────────────────
describe('dispatch — pad_item writes', () => {
it('create rolls flat fields into the fields JSON + injects ws + source', async () => {
const api = mockApi();
const result = await dispatch(api as unknown as Api, WS, isReadOnly, 'pad_item', {
action: 'list',
workspace: WS,
await run(api, 'pad_item', {
action: 'create',
collection: 'tasks',
title: 'Fix bug',
status: 'open',
priority: 'high',
parent: 'PLAN-3',
content: 'body',
tags: ['v1', 'frontend'],
});
expect(api.items.create).toHaveBeenCalledTimes(1);
const [ws, coll, data] = api.items.create.mock.calls[0] as unknown as [string, string, any];
expect(ws).toBe(WS);
expect(coll).toBe('tasks');
expect(data.content).toBe('body');
expect(data.source).toBe('web');
expect(JSON.parse(data.fields)).toEqual({ status: 'open', priority: 'high', parent: 'PLAN-3' });
expect(JSON.parse(data.tags)).toEqual(['v1', 'frontend']);
});
it('create resolves role slug → agent_role_id and assign → assigned_user_id', async () => {
const api = mockApi();
await run(api, 'pad_item', {
action: 'create',
collection: 'tasks',
title: 'X',
role: 'reviewer',
assign: 'dave@example.com',
});
expect(api.agentRoles.get).toHaveBeenCalledWith(WS, 'reviewer');
expect(api.members.list).toHaveBeenCalledWith(WS);
const data = (api.items.create.mock.calls[0] as unknown as [string, string, any])[2];
expect(data.agent_role_id).toBe('role-uuid');
expect(data.assigned_user_id).toBe('user-uuid');
});
it('update errors (no item write) when an assignee can not be resolved', async () => {
const api = mockApi();
api.members.list.mockResolvedValueOnce({ members: [], invitations: [] });
const result = await run(api, 'pad_item', {
action: 'update',
ref: 'TASK-1',
assign: 'ghost@example.com',
});
expect(parse(result).isError).toBe(true);
expect(api.items.list).not.toHaveBeenCalled();
expect(api.items.update).not.toHaveBeenCalled();
});
});
describe('dispatch — write actions (TASK-3b)', () => {
it('returns a precise not-wired error for a write action, no silent no-op', async () => {
it('create errors without collection/title and never calls the client', async () => {
const api = mockApi();
const result = await dispatch(api as unknown as Api, WS, isReadOnly, 'pad_item', {
action: 'delete',
const result = await run(api, 'pad_item', { action: 'create', title: 'no collection' });
expect(parse(result).isError).toBe(true);
expect(api.items.create).not.toHaveBeenCalled();
});
it('update omits fields JSON when only title changes (no field blow-away)', async () => {
const api = mockApi();
await run(api, 'pad_item', { action: 'update', ref: 'TASK-1', title: 'Renamed' });
const [ws, ref, data] = api.items.update.mock.calls[0] as unknown as [string, string, any];
expect(ws).toBe(WS);
expect(ref).toBe('TASK-1');
expect(data.title).toBe('Renamed');
expect(data.fields).toBeUndefined();
});
it('update forwards status into fields + the audit comment + force', async () => {
const api = mockApi();
await run(api, 'pad_item', {
action: 'update',
ref: 'TASK-1',
status: 'done',
comment: 'shipped',
force: true,
});
const { isError, text } = parse(result);
expect(isError).toBe(true);
expect(text).toMatch(/TASK-3b/);
const data = (api.items.update.mock.calls[0] as unknown as [string, string, any])[2];
expect(JSON.parse(data.fields)).toEqual({ status: 'done' });
expect(data.comment).toBe('shipped');
expect(data.force).toBe(true);
});
it('create maps `field` key=value entries into the fields JSON', async () => {
const api = mockApi();
await run(api, 'pad_item', {
action: 'create',
collection: 'tasks',
title: 'X',
field: ['due_date=2026-06-01', 'effort=l'],
});
const data = (api.items.create.mock.calls[0] as unknown as [string, string, any])[2];
expect(JSON.parse(data.fields)).toEqual({ due_date: '2026-06-01', effort: 'l' });
});
it('create errors (no client call) on a malformed `field` entry — no silent drop', async () => {
const api = mockApi();
const result = await run(api, 'pad_item', {
action: 'create',
collection: 'tasks',
title: 'X',
field: ['not-a-pair'],
});
expect(parse(result).isError).toBe(true);
expect(parse(result).text).toMatch(/field/i);
expect(api.items.create).not.toHaveBeenCalled();
});
it('create errors on a non-string `field` entry — no silent drop', async () => {
const api = mockApi();
const result = await run(api, 'pad_item', {
action: 'create',
collection: 'tasks',
title: 'X',
field: [{ key: 'status', value: 'done' }],
});
expect(parse(result).isError).toBe(true);
expect(api.items.create).not.toHaveBeenCalled();
});
it('delete maps to items.delete with the route ws', async () => {
const api = mockApi();
await run(api, 'pad_item', { action: 'delete', ref: 'TASK-1' });
expect(api.items.delete).toHaveBeenCalledWith(WS, 'TASK-1');
});
it('move passes target_collection + force', async () => {
const api = mockApi();
await run(api, 'pad_item', {
action: 'move',
ref: 'TASK-1',
target_collection: 'ideas',
force: true,
});
expect(api.items.move).toHaveBeenCalledWith(WS, 'TASK-1', 'ideas', undefined, { force: true });
});
it('move passes `field` overrides through to field_overrides', async () => {
const api = mockApi();
await run(api, 'pad_item', {
action: 'move',
ref: 'TASK-1',
target_collection: 'ideas',
field: ['category=infra'],
});
expect(api.items.move).toHaveBeenCalledWith(
WS,
'TASK-1',
'ideas',
{ category: 'infra' },
expect.anything(),
);
});
it('comment maps message → body and reply_to → parent_id', async () => {
const api = mockApi();
await run(api, 'pad_item', {
action: 'comment',
ref: 'TASK-1',
message: 'looks good',
reply_to: 'c-0',
});
expect(api.comments.create).toHaveBeenCalledWith(
WS,
'TASK-1',
expect.objectContaining({ body: 'looks good', parent_id: 'c-0' }),
);
});
it('star/unstar map to the right client methods', async () => {
const api = mockApi();
await run(api, 'pad_item', { action: 'star', ref: 'TASK-1' });
await run(api, 'pad_item', { action: 'unstar', ref: 'TASK-1' });
expect(api.items.star).toHaveBeenCalledWith(WS, 'TASK-1');
expect(api.items.unstar).toHaveBeenCalledWith(WS, 'TASK-1');
});
it('bulk-update (status) maps to the move verb with refs as ids', async () => {
const api = mockApi();
await run(api, 'pad_item', { action: 'bulk-update', refs: ['TASK-1', 'TASK-2'], status: 'done' });
expect(api.items.bulk).toHaveBeenCalledWith(
WS,
expect.objectContaining({ op: 'move', ids: ['TASK-1', 'TASK-2'], status: 'done' }),
);
});
it('bulk-update (priority) maps to the set-priority verb', async () => {
const api = mockApi();
await run(api, 'pad_item', { action: 'bulk-update', refs: ['TASK-1'], priority: 'high' });
expect(api.items.bulk).toHaveBeenCalledWith(
WS,
expect.objectContaining({ op: 'set-priority', ids: ['TASK-1'], priority: 'high' }),
);
});
it('bulk-update errors (no partial write) on a non-string ref element', async () => {
const api = mockApi();
const result = await run(api, 'pad_item', {
action: 'bulk-update',
refs: ['TASK-1', 123],
status: 'done',
});
expect(parse(result).isError).toBe(true);
expect(api.items.bulk).not.toHaveBeenCalled();
});
it('bulk-update errors (no client call) when refs missing', async () => {
const api = mockApi();
const result = await run(api, 'pad_item', { action: 'bulk-update', status: 'done' });
expect(parse(result).isError).toBe(true);
expect(api.items.bulk).not.toHaveBeenCalled();
});
});
describe('dispatch — read action result envelope', () => {
it('wraps the client result as JSON text content', async () => {
// ── pad_item link / unlink ───────────────────────────────────────────────────
describe('dispatch — pad_item link/unlink', () => {
it('link resolves the target ref to an id and stores the canonical type', async () => {
const api = mockApi();
const result = await dispatch(api as unknown as Api, WS, isReadOnly, 'pad_project', {
action: 'dashboard',
await run(api, 'pad_item', {
action: 'link',
ref: 'TASK-1',
target: 'TASK-2',
link_type: 'blocks',
});
expect(api.items.get).toHaveBeenCalledWith(WS, 'TASK-2');
expect(api.links.create).toHaveBeenCalledWith(WS, 'TASK-1', {
target_id: 'uuid-target',
link_type: 'blocks',
});
});
it('blocked-by inverts source/target before creating a blocks edge', async () => {
const api = mockApi();
await run(api, 'pad_item', {
action: 'link',
ref: 'TASK-1',
target: 'TASK-2',
link_type: 'blocked-by',
});
// source becomes the target (TASK-2), target id resolved from ref (TASK-1).
expect(api.items.get).toHaveBeenCalledWith(WS, 'TASK-1');
expect(api.links.create).toHaveBeenCalledWith(WS, 'TASK-2', {
target_id: 'uuid-target',
link_type: 'blocks',
});
});
it('split-from stores as split_from', async () => {
const api = mockApi();
await run(api, 'pad_item', {
action: 'link',
ref: 'TASK-1',
target: 'PLAN-2',
link_type: 'split-from',
});
expect(api.links.create).toHaveBeenCalledWith(WS, 'TASK-1', {
target_id: 'uuid-target',
link_type: 'split_from',
});
});
it('link errors on an unknown link_type, no client call', async () => {
const api = mockApi();
const result = await run(api, 'pad_item', {
action: 'link',
ref: 'TASK-1',
target: 'TASK-2',
link_type: 'nonsense',
});
expect(parse(result).isError).toBe(true);
expect(api.links.create).not.toHaveBeenCalled();
});
it('unlink finds the matching edge and deletes by id', async () => {
const api = mockApi();
await run(api, 'pad_item', {
action: 'unlink',
ref: 'TASK-1',
target: 'TASK-2',
link_type: 'blocks',
});
expect(api.links.list).toHaveBeenCalledWith(WS, 'TASK-1');
expect(api.links.delete).toHaveBeenCalledWith(WS, 'link-1');
});
});
// ── pad_item export / import (artifact passthrough) ──────────────────────────
describe('dispatch — pad_item export/import', () => {
it('export returns the artifact text', async () => {
const api = mockApi();
const result = await run(api, 'pad_item', { action: 'export', ref: 'PLAYB-1' });
expect(api.exportItemArtifact).toHaveBeenCalledWith(WS, 'PLAYB-1');
expect(parse(result).isError).toBe(false);
});
it('import passes the full artifact text through to importArtifact', async () => {
const api = mockApi();
const ARTIFACT = '---\ncollection: playbooks\n---\n# Ship\nbody';
await run(api, 'pad_item', { action: 'import', artifact: ARTIFACT });
expect(api.importArtifact).toHaveBeenCalledWith(WS, ARTIFACT);
});
it('import errors (no client call) when artifact is missing', async () => {
const api = mockApi();
const result = await run(api, 'pad_item', { action: 'import' });
expect(parse(result).isError).toBe(true);
expect(api.importArtifact).not.toHaveBeenCalled();
});
});
// ── pad_collection / pad_role writes ─────────────────────────────────────────
describe('dispatch — collection + role writes', () => {
it('collection create maps name + slug + schema', async () => {
const api = mockApi();
await run(api, 'pad_collection', {
action: 'create',
name: 'Risks',
slug: 'risks',
schema: '{}',
});
expect(api.collections.create).toHaveBeenCalledWith(
WS,
expect.objectContaining({ name: 'Risks', slug: 'risks', schema: '{}' }),
);
});
it('collection create rolls layout/default_view/board_group_by into settings + prefix', async () => {
const api = mockApi();
await run(api, 'pad_collection', {
action: 'create',
name: 'Risks',
prefix: 'RISK',
layout: 'balanced',
default_view: 'board',
board_group_by: 'status',
});
const data = (api.collections.create.mock.calls[0] as unknown as [string, any])[1];
expect(data.prefix).toBe('RISK');
expect(JSON.parse(data.settings)).toEqual({
layout: 'balanced',
default_view: 'board',
board_group_by: 'status',
});
});
it('collection create accepts an object-shaped schema', async () => {
const api = mockApi();
await run(api, 'pad_collection', {
action: 'create',
name: 'Risks',
schema: { fields: [{ key: 'status', type: 'select' }] },
});
const data = (api.collections.create.mock.calls[0] as unknown as [string, any])[1];
expect(JSON.parse(data.schema)).toEqual({ fields: [{ key: 'status', type: 'select' }] });
});
it('collection create errors on the fields DSL (no browser parser), no client call', async () => {
const api = mockApi();
const result = await run(api, 'pad_collection', {
action: 'create',
name: 'Risks',
fields: 'status:select:open,done',
});
expect(parse(result).isError).toBe(true);
expect(parse(result).text).toMatch(/fields/i);
expect(api.collections.create).not.toHaveBeenCalled();
});
it('collection update targets the slug + carries prefix/sort_order', async () => {
const api = mockApi();
await run(api, 'pad_collection', {
action: 'update',
slug: 'risks',
name: 'Risk Register',
prefix: 'RISK',
sort_order: 3,
});
expect(api.collections.update).toHaveBeenCalledWith(
WS,
'risks',
expect.objectContaining({ name: 'Risk Register', prefix: 'RISK', sort_order: 3 }),
);
});
it('collection delete targets the slug', async () => {
const api = mockApi();
await run(api, 'pad_collection', { action: 'delete', slug: 'risks' });
expect(api.collections.delete).toHaveBeenCalledWith(WS, 'risks');
});
it('role create maps name + description', async () => {
const api = mockApi();
await run(api, 'pad_role', { action: 'create', name: 'Reviewer', description: 'Reviews PRs' });
expect(api.agentRoles.create).toHaveBeenCalledWith(
WS,
expect.objectContaining({ name: 'Reviewer', description: 'Reviews PRs' }),
);
});
it('role update targets the slug and maps new_slug → body.slug + sort_order', async () => {
const api = mockApi();
await run(api, 'pad_role', {
action: 'update',
slug: 'reviewer',
new_slug: 'pr-reviewer',
icon: '👀',
sort_order: 2,
});
expect(api.agentRoles.update).toHaveBeenCalledWith(
WS,
'reviewer',
expect.objectContaining({ slug: 'pr-reviewer', icon: '👀', sort_order: 2 }),
);
});
});
// ── pad_playbook.run + pad_library.activate + pad_meta.bootstrap ─────────────
describe('dispatch — playbook/library/meta writes', () => {
it('playbook run forwards ref + args + raw_args', async () => {
const api = mockApi();
await run(api, 'pad_playbook', {
action: 'run',
ref: 'ship',
args: { stop_after_each: true },
raw_args: ['PLAN-1'],
});
expect(api.playbooks.run).toHaveBeenCalledWith(
WS,
'ship',
expect.objectContaining({ args: { stop_after_each: true }, raw_args: ['PLAN-1'] }),
);
});
it('library activate resolves by title via the route ws', async () => {
const api = mockApi();
await run(api, 'pad_library', { action: 'activate', title: 'Ship tasks' });
expect(api.library.activateByTitle).toHaveBeenCalledWith(WS, 'Ship tasks');
});
it('library activate errors (no client call) without a title', async () => {
const api = mockApi();
const result = await run(api, 'pad_library', { action: 'activate' });
expect(parse(result).isError).toBe(true);
expect(api.library.activateByTitle).not.toHaveBeenCalled();
});
it('meta bootstrap maps to agentBootstrap with the route ws', async () => {
const api = mockApi();
const result = await run(api, 'pad_meta', { action: 'bootstrap' });
expect(api.agentBootstrap).toHaveBeenCalledWith(WS);
expect(parse(result).isError).toBe(false);
});
});
// ── Envelope + error behaviour ───────────────────────────────────────────────
describe('dispatch — result envelope + errors', () => {
it('wraps a read result as JSON text content', async () => {
const api = mockApi();
const result = await run(api, 'pad_project', { action: 'dashboard' });
const { isError, text } = parse(result);
expect(isError).toBe(false);
expect(JSON.parse(text)).toEqual({ ok: true });
@@ -141,33 +649,30 @@ describe('dispatch — read action result envelope', () => {
it('errors clearly when action is missing', async () => {
const api = mockApi();
const result = await dispatch(api as unknown as Api, WS, isReadOnly, 'pad_item', {});
const result = await run(api, 'pad_item', {});
expect(parse(result).isError).toBe(true);
expect(parse(result).text).toMatch(/action/);
});
it('errors for a read action with no browser mapping (e.g. pad_project.standup)', async () => {
it('errors for a catalog action with no browser mapping (pad_project.standup)', async () => {
const api = mockApi();
// standup is read_only in the catalog but has no browser handler.
const isReadStandup = (t: string, a: string) =>
a === 'standup' ? true : isReadOnly(t, a);
const result = await dispatch(api as unknown as Api, WS, isReadStandup, 'pad_project', {
action: 'standup',
});
const result = await run(api, 'pad_project', { action: 'standup' });
const { isError, text } = parse(result);
expect(isError).toBe(true);
expect(text).toMatch(/not available/i);
});
it('surfaces a thrown client error as an error result', async () => {
it('surfaces a thrown client error as an error result (server ACL → tool error)', async () => {
const api = mockApi();
api.dashboard.get.mockRejectedValueOnce(new Error('boom'));
const result = await dispatch(api as unknown as Api, WS, isReadOnly, 'pad_project', {
action: 'dashboard',
api.items.create.mockRejectedValueOnce(new Error('forbidden'));
const result = await run(api, 'pad_item', {
action: 'create',
collection: 'tasks',
title: 'X',
});
const { isError, text } = parse(result);
expect(isError).toBe(true);
expect(text).toMatch(/boom/);
expect(text).toMatch(/forbidden/);
});
it('errors when there is no active workspace', async () => {
+469 -47
View File
@@ -1,25 +1,37 @@
// dispatch.ts — the `(toolName, action, args) → client.ts` router for the
// WebMCP surface (PLAN-1888 / TASK-1892, piece 4). Pure/injectable: takes the
// `api` client + the route `wsSlug` as arguments so it's unit-testable with a
// mocked client and no browser.
// WebMCP surface (PLAN-1888 / TASK-1892 read dispatch, TASK-1893 write
// dispatch). Pure/injectable: takes the `api` client + the route `wsSlug` as
// arguments so it's unit-testable with a mocked client and no browser.
//
// Two correctness constraints, both unit-tested:
// Correctness constraints, all unit-tested:
// - DR-4: the workspace is FROZEN to the route `wsSlug`. An agent-supplied
// `workspace` arg is REJECTED (not silently overridden) so a stray /
// malicious workspace can't slip through. Every read handler receives the
// route wsSlug, never an arg-derived one.
// - This task wires READ actions only. WRITE actions are recognized and
// return a clear "not yet wired (TASK-3b)" error — never a silent no-op.
// malicious workspace can't slip through. Every handler receives the route
// wsSlug, never an arg-derived one.
// - No silent no-ops: every mutating catalog action either dispatches to a
// real client.ts method or returns a precise, actionable error. A write
// action with no browser mapping returns an honest "not available" error.
//
// Consent (DR-2): consent is NOT enforced here. The browser host fires
// per-invocation consent BEFORE `execute` runs, gated by the descriptor's
// `readOnlyHint`. Mutating tools (pad_item, pad_collection, pad_role,
// pad_library) carry NO readOnlyHint (they mix reads + writes — descriptors.ts
// `isAllReadOnly`), so the host prompts on every call. By the time a write
// handler below runs, the user has already consented. This dispatcher is the
// post-consent execution layer, not the gate. (A real Chrome-149 consent check
// can't run in CI — see the PR body's manual-verification note.)
import type { api as ApiClient } from '$lib/api/client';
import type { ItemCreate, ItemUpdate } from '$lib/types';
type Api = typeof ApiClient;
/** Result envelope the WebMCP host expects from `execute`. */
export type DispatchResult = ModelContextToolResult;
/** A read-action handler: pure call into the api client. */
type ReadHandler = (
/** A handler: pure call into the api client. Reads and writes share the shape;
* the route wsSlug is injected, the agent's `workspace` arg never reaches it. */
type Handler = (
api: Api,
wsSlug: string,
args: Record<string, unknown>,
@@ -42,31 +54,290 @@ function num(args: Record<string, unknown>, key: string): number | undefined {
return undefined;
}
function bool(args: Record<string, unknown>, key: string): boolean | undefined {
const v = args[key];
if (typeof v === 'boolean') return v;
if (v === 'true') return true;
if (v === 'false') return false;
return undefined;
}
function requireRef(args: Record<string, unknown>): string {
const ref = str(args, 'ref') ?? str(args, 'slug');
if (!ref) throw new Error("missing required arg 'ref'");
return ref;
}
function requireArg(args: Record<string, unknown>, key: string): string {
const v = str(args, key);
if (!v) throw new Error(`missing required arg '${key}'`);
return v;
}
/**
* Coerce an array<string> param (the MCP transport may also deliver a single
* string). Returns undefined when absent/empty. Throws on a non-string element
* rather than filtering it out, so a malformed array (e.g. `refs: ["TASK-1", 123]`,
* `tags: [123]`) surfaces a precise tool error instead of a partial / empty
* write — no silent param drop.
*/
function strArray(args: Record<string, unknown>, key: string): string[] | undefined {
const v = args[key];
if (v === undefined || v === null) return undefined;
if (typeof v === 'string') return v.length > 0 ? [v] : undefined;
if (!Array.isArray(v)) {
throw new Error(`'${key}' must be an array of strings`);
}
const out: string[] = [];
for (const e of v) {
if (typeof e !== 'string') {
throw new Error(`'${key}' must contain only strings (got ${JSON.stringify(e)})`);
}
if (e.length > 0) out.push(e);
}
return out.length > 0 ? out : undefined;
}
/**
* Parse the catalog's `field` param (array of "key=value" strings for
* schema-declared custom fields) into a key→value object. Throws on a
* malformed entry (missing `=` or empty key) rather than silently dropping it,
* so an agent that mis-shapes a field setter gets a precise tool error instead
* of a write that quietly omits the field.
*/
function parseFieldKVP(args: Record<string, unknown>): Record<string, string> {
const raw = args.field;
if (raw === undefined || raw === null) return {};
// Accept the canonical array<string> shape, or a single string (lenient,
// matching strArray elsewhere). Anything else is malformed — throw rather
// than coerce/drop, so a mis-shaped `field` never silently no-ops.
const entries: unknown[] = Array.isArray(raw) ? raw : [raw];
const out: Record<string, string> = {};
for (const entry of entries) {
if (typeof entry !== 'string') {
throw new Error(
`malformed 'field' entry ${JSON.stringify(entry)} — expected a "key=value" string`,
);
}
if (entry.length === 0) continue;
const idx = entry.indexOf('=');
if (idx <= 0) {
throw new Error(
`malformed 'field' entry ${JSON.stringify(entry)} — expected "key=value"`,
);
}
out[entry.slice(0, idx)] = entry.slice(idx + 1);
}
return out;
}
/**
* Roll the catalog's flat item params (status / priority / category / parent
* plus arbitrary `field` key=value entries) into the `fields` JSON string the
* create/update endpoints persist — the browser-side mirror of the CLI's
* field-building in cmd/pad/main.go::item create. The server resolves a
* `parent` ref inside the fields JSON itself (handlers_items.go ~:580), so no
* client-side parent lookup is needed.
*
* Returns the JSON string, or undefined when no flat fields were supplied (so
* an update carrying only e.g. `title` doesn't blow away the item's fields).
*/
function buildFieldsJSON(args: Record<string, unknown>): string | undefined {
const fields: Record<string, unknown> = {};
for (const key of ['status', 'priority', 'category', 'parent']) {
const v = str(args, key);
if (v !== undefined) fields[key] = v;
}
Object.assign(fields, parseFieldKVP(args));
if (Object.keys(fields).length === 0) return undefined;
return JSON.stringify(fields);
}
/** Tags: the catalog passes a JSON array of strings; ItemCreate/ItemUpdate
* store the canonical JSON-encoded string form. */
function buildTagsJSON(args: Record<string, unknown>): string | undefined {
const tags = strArray(args, 'tags');
if (!tags) return undefined;
return JSON.stringify(tags);
}
/**
* Resolve the catalog's `role` (slug) and `assign` (user name/email) item
* params to the `agent_role_id` / `assigned_user_id` the create/update
* endpoints persist — the browser mirror of the CLI's slug→ID / name→userID
* resolution (cmd/pad/main.go::item create). Throws (surfaced as a tool error,
* never a silent drop) when the role/user can't be resolved.
*/
async function resolveAssignment(
api: Api,
ws: string,
args: Record<string, unknown>,
): Promise<{ agent_role_id?: string; assigned_user_id?: string }> {
const out: { agent_role_id?: string; assigned_user_id?: string } = {};
const role = str(args, 'role');
if (role !== undefined) {
const resolved = await api.agentRoles.get(ws, role);
if (!resolved?.id) throw new Error(`role ${JSON.stringify(role)} not found`);
out.agent_role_id = resolved.id;
}
const assign = str(args, 'assign');
if (assign !== undefined) {
const { members } = await api.members.list(ws);
const match = members.find(
(m) =>
m.user_name?.toLowerCase() === assign.toLowerCase() ||
m.user_email?.toLowerCase() === assign.toLowerCase(),
);
if (!match) throw new Error(`user ${JSON.stringify(assign)} not found in workspace members`);
out.assigned_user_id = match.user_id;
}
return out;
}
/**
* Resolve the collection `schema` param to the JSON string the create/update
* endpoints persist. The catalog's `schema` is a structured CollectionSchema
* object, but the MCP transport may deliver it either as a JSON-encoded string
* or as a nested object — accept both. The `fields` compact DSL has no
* browser-side parser (it lives only in the Go CLI), so rather than silently
* dropping it we throw a precise error pointing the caller at `schema`.
*/
function collectionSchema(args: Record<string, unknown>): string | undefined {
if (str(args, 'fields') !== undefined) {
throw new Error(
"the 'fields' DSL is not available in the browser WebMCP surface — " +
"pass 'schema' (structured CollectionSchema JSON) instead",
);
}
const schema = args.schema;
if (schema === undefined || schema === null || schema === '') return undefined;
if (typeof schema === 'string') return schema;
if (typeof schema === 'object') return JSON.stringify(schema);
throw new Error("'schema' must be a CollectionSchema object or JSON string");
}
/**
* Roll the collection settings params (layout / default_view / board_group_by)
* into the JSON `settings` string the endpoint persists — the browser mirror
* of the CLI's CollectionSettings build. Returns undefined when none were
* supplied so an update doesn't clobber existing settings.
*/
function collectionSettings(args: Record<string, unknown>): string | undefined {
const settings: Record<string, unknown> = {};
const layout = str(args, 'layout');
if (layout !== undefined) settings.layout = layout;
const defaultView = str(args, 'default_view');
if (defaultView !== undefined) settings.default_view = defaultView;
const boardGroupBy = str(args, 'board_group_by');
if (boardGroupBy !== undefined) settings.board_group_by = boardGroupBy;
if (Object.keys(settings).length === 0) return undefined;
return JSON.stringify(settings);
}
function ok(result: unknown): DispatchResult {
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
return { content: [{ type: 'text', text: JSON.stringify(result ?? null) }] };
}
function errResult(message: string): DispatchResult {
return { content: [{ type: 'text', text: message }], isError: true };
}
// ── read dispatch table ──────────────────────────────────────────────────
// ── link/unlink routing ──────────────────────────────────────────────────
//
// Keyed by `${toolName}:${action}`. Only READ actions appear here (this
// task). Each handler receives the ROUTE wsSlug — never an arg-supplied one.
// Project next/standup/changelog and pad_meta/bootstrap have no clean REST
// read endpoint exposed to the browser today, so they're intentionally
// absent and fall through to the "not available in browser" branch rather
// than being faked.
// Mirrors internal/mcp/catalog_item.go::itemLinkRoutes. The catalog's uniform
// (ref, target, link_type) shape maps onto the web `links` API, which is a
// lower-level graph surface: POST /items/{source}/links takes a TARGET UUID +
// a canonical graph type, and DELETE /links/{id} removes by edge id.
//
// Two translations vs. the catalog:
// - link_type → canonical graph type. The catalog's `blocked-by` is the
// inverse of `blocks` (no separate graph type): source/target swap and the
// stored type becomes `blocks`. `split-from` stores as `split_from`.
// - ref/target → ids. The path source resolves a ref/slug server-side, but
// the target must be a UUID, so we resolve the target ref via items.get.
// For inverted types the roles swap before resolution.
const READ_HANDLERS: Record<string, ReadHandler> = {
// pad_search
interface LinkRoute {
/** Canonical graph link_type stored server-side. */
graphType: string;
/** When true, swap (ref, target) so the stored edge points the right way. */
inverted: boolean;
}
const LINK_ROUTES: Record<string, LinkRoute> = {
blocks: { graphType: 'blocks', inverted: false },
// "ref blocked-by target" == "target blocks ref": swap, store as blocks.
'blocked-by': { graphType: 'blocks', inverted: true },
supersedes: { graphType: 'supersedes', inverted: false },
implements: { graphType: 'implements', inverted: false },
'split-from': { graphType: 'split_from', inverted: false },
};
function resolveLinkRoute(args: Record<string, unknown>): {
sourceRef: string;
targetRef: string;
route: LinkRoute;
} {
const ref = requireRef(args);
const target = requireArg(args, 'target');
const linkType = str(args, 'link_type');
const route = linkType ? LINK_ROUTES[linkType] : undefined;
if (!route) {
throw new Error(
`link_type is required (one of: ${Object.keys(LINK_ROUTES).sort().join(', ')})`,
);
}
const [sourceRef, targetRef] = route.inverted ? [target, ref] : [ref, target];
return { sourceRef, targetRef, route };
}
async function dispatchItemLink(
api: Api,
ws: string,
args: Record<string, unknown>,
): Promise<unknown> {
const { sourceRef, targetRef, route } = resolveLinkRoute(args);
// The target must be a UUID for the web links API; resolve the ref.
const targetItem = await api.items.get(ws, targetRef);
return api.links.create(ws, sourceRef, {
target_id: targetItem.id,
link_type: route.graphType,
});
}
async function dispatchItemUnlink(
api: Api,
ws: string,
args: Record<string, unknown>,
): Promise<unknown> {
const { sourceRef, targetRef, route } = resolveLinkRoute(args);
const targetItem = await api.items.get(ws, targetRef);
// The web links API deletes by edge id, so find the matching edge from the
// source item's link list.
const links = await api.links.list(ws, sourceRef);
const match = links.find(
(l) => l.link_type === route.graphType && l.target_id === targetItem.id,
);
if (!match) {
throw new Error(`no ${route.graphType} link to ${targetRef} to remove`);
}
return api.links.delete(ws, match.id);
}
// ── dispatch table ─────────────────────────────────────────────────────────
//
// Keyed by `${toolName}:${action}`. Holds BOTH reads and writes — the read/
// write split is enforced by the served `read_only` flag + the per-invocation
// consent gate (DR-2), not by which table an action lives in. Each handler
// receives the ROUTE wsSlug — never an arg-supplied one (DR-4).
//
// pad_project next/standup/changelog and any other catalog read with no
// browser REST mapping are intentionally absent and fall through to the "not
// available in browser" branch rather than being faked (TASK-1894 wires
// next/standup/changelog once their backend endpoints exist).
const HANDLERS: Record<string, Handler> = {
// ── pad_search ──
'pad_search:query': (api, ws, args) =>
api.search(str(args, 'query') ?? str(args, 'q') ?? '', {
workspace: ws,
@@ -76,7 +347,7 @@ const READ_HANDLERS: Record<string, ReadHandler> = {
limit: num(args, 'limit'),
}),
// pad_item reads
// ── pad_item reads ──
'pad_item:list': (api, ws, args) =>
api.items.list(ws, {
collection: str(args, 'collection'),
@@ -87,32 +358,189 @@ const READ_HANDLERS: Record<string, ReadHandler> = {
limit: num(args, 'limit'),
}),
'pad_item:get': (api, ws, args) => api.items.get(ws, requireRef(args)),
'pad_item:deps': (api, ws, args) => api.links.list(ws, requireRef(args)),
'pad_item:list-comments': (api, ws, args) =>
api.comments.list(ws, requireRef(args)),
'pad_item:starred': (api, ws, args) =>
api.items.starred(ws, {
include_terminal: bool(args, 'all') === true ? true : undefined,
}),
'pad_item:backlinks': (api, ws, args) =>
api.items.backlinks(ws, requireRef(args), {
limit: num(args, 'limit'),
offset: num(args, 'offset'),
}),
// export is read-only (side-effect-free): returns the artifact text.
'pad_item:export': (api, ws, args) =>
api.exportItemArtifact(ws, requireRef(args)),
// pad_project reads — only dashboard has a browser REST endpoint today.
// ── pad_item writes ──
'pad_item:create': async (api, ws, args) => {
const collection = requireArg(args, 'collection');
const title = requireArg(args, 'title');
const data: ItemCreate = { title, source: 'web' };
const content = str(args, 'content');
if (content !== undefined) data.content = content;
const fields = buildFieldsJSON(args);
if (fields !== undefined) data.fields = fields;
const tags = buildTagsJSON(args);
if (tags !== undefined) data.tags = tags;
// role / assign resolve to ids; throws (not a silent drop) on miss.
const { agent_role_id, assigned_user_id } = await resolveAssignment(api, ws, args);
if (agent_role_id !== undefined) data.agent_role_id = agent_role_id;
if (assigned_user_id !== undefined) data.assigned_user_id = assigned_user_id;
return api.items.create(ws, collection, data);
},
'pad_item:update': async (api, ws, args) => {
const ref = requireRef(args);
const data: ItemUpdate = { source: 'web' };
const title = str(args, 'title');
if (title !== undefined) data.title = title;
const content = str(args, 'content');
if (content !== undefined) data.content = content;
const fields = buildFieldsJSON(args);
if (fields !== undefined) data.fields = fields;
const tags = buildTagsJSON(args);
if (tags !== undefined) data.tags = tags;
const comment = str(args, 'comment');
if (comment !== undefined) data.comment = comment;
if (bool(args, 'force') === true) data.force = true;
const { agent_role_id, assigned_user_id } = await resolveAssignment(api, ws, args);
if (agent_role_id !== undefined) data.agent_role_id = agent_role_id;
if (assigned_user_id !== undefined) data.assigned_user_id = assigned_user_id;
return api.items.update(ws, ref, data);
},
'pad_item:delete': (api, ws, args) => api.items.delete(ws, requireRef(args)),
'pad_item:restore': (api, ws, args) => api.items.restore(ws, requireRef(args)),
'pad_item:move': (api, ws, args) => {
// The catalog's `field` param maps to the move endpoint's
// field_overrides (mirrors the CLI's `pad item move --field`).
const overrides = parseFieldKVP(args);
return api.items.move(
ws,
requireRef(args),
requireArg(args, 'target_collection'),
Object.keys(overrides).length > 0 ? overrides : undefined,
{ force: bool(args, 'force') === true ? true : undefined },
);
},
'pad_item:link': (api, ws, args) => dispatchItemLink(api, ws, args),
'pad_item:unlink': (api, ws, args) => dispatchItemUnlink(api, ws, args),
'pad_item:star': (api, ws, args) => api.items.star(ws, requireRef(args)),
'pad_item:unstar': (api, ws, args) => api.items.unstar(ws, requireRef(args)),
'pad_item:comment': (api, ws, args) =>
api.comments.create(ws, requireRef(args), {
body: requireArg(args, 'message'),
parent_id: str(args, 'reply_to'),
source: 'web',
}),
'pad_item:bulk-update': (api, ws, args) => {
const ids = strArray(args, 'refs');
if (!ids || ids.length === 0) {
throw new Error('refs is required (array of item references)');
}
const status = str(args, 'status');
const priority = str(args, 'priority');
if (status === undefined && priority === undefined) {
throw new Error('bulk-update requires at least one of status / priority');
}
// The web bulk endpoint applies ONE verb per call (handlers_items_bulk.go):
// status rides the `move` verb, priority the `set-priority` verb. Mirror
// that one-verb-per-request shape (the MCP/CLI bulk-update sets both in a
// single command, but the web endpoint is split — surface the limit
// honestly rather than silently dropping one).
if (status !== undefined && priority !== undefined) {
throw new Error(
'bulk-update accepts status OR priority per call in the browser, not both',
);
}
const force = bool(args, 'force') === true ? true : undefined;
if (status !== undefined) {
return api.items.bulk(ws, { op: 'move', ids, status, force });
}
return api.items.bulk(ws, { op: 'set-priority', ids, priority: priority!, force });
},
// import ingests a portable artifact (YAML frontmatter + body). The
// catalog passes the full artifact text in `artifact` (NOT `content`).
'pad_item:import': (api, ws, args) =>
api.importArtifact(ws, requireArg(args, 'artifact')),
// ── pad_project reads — only dashboard has a browser REST endpoint today.
'pad_project:dashboard': (api, ws) => api.dashboard.get(ws),
// pad_collection reads
// ── pad_collection ──
'pad_collection:list': (api, ws) => api.collections.list(ws),
'pad_collection:create': (api, ws, args) =>
api.collections.create(ws, {
name: requireArg(args, 'name'),
slug: str(args, 'slug'),
prefix: str(args, 'prefix'),
icon: str(args, 'icon'),
description: str(args, 'description'),
schema: collectionSchema(args),
settings: collectionSettings(args),
}),
'pad_collection:update': (api, ws, args) =>
api.collections.update(ws, requireArg(args, 'slug'), {
name: str(args, 'name'),
prefix: str(args, 'prefix'),
icon: str(args, 'icon'),
description: str(args, 'description'),
schema: collectionSchema(args),
settings: collectionSettings(args),
sort_order: num(args, 'sort_order'),
}),
'pad_collection:delete': (api, ws, args) =>
api.collections.delete(ws, requireArg(args, 'slug')),
// pad_role reads
// ── pad_role ──
'pad_role:list': (api, ws) => api.agentRoles.list(ws),
'pad_role:create': (api, ws, args) =>
api.agentRoles.create(ws, {
name: requireArg(args, 'name'),
slug: str(args, 'slug'),
description: str(args, 'description'),
icon: str(args, 'icon'),
tools: str(args, 'tools'),
}),
'pad_role:update': (api, ws, args) =>
api.agentRoles.update(ws, requireArg(args, 'slug'), {
name: str(args, 'name'),
// catalog `new_slug` is the rename target; the web update body's
// `slug` field IS the new slug (the URL path carries the current one).
slug: str(args, 'new_slug'),
description: str(args, 'description'),
icon: str(args, 'icon'),
tools: str(args, 'tools'),
sort_order: num(args, 'sort_order'),
}),
'pad_role:delete': (api, ws, args) =>
api.agentRoles.delete(ws, requireArg(args, 'slug')),
// pad_playbook reads
// ── pad_playbook ──
'pad_playbook:list': (api, ws) => api.playbooks.list(ws),
'pad_playbook:get': (api, ws, args) =>
api.playbooks.get(ws, requireRef(args)),
'pad_playbook:get': (api, ws, args) => api.playbooks.get(ws, requireRef(args)),
// run is side-effect-free server-side (parses + binds, the agent executes),
// classified read_only in the catalog.
'pad_playbook:run': (api, ws, args) =>
api.playbooks.run(ws, requireRef(args), {
args: (args.args as Record<string, unknown> | undefined) ?? undefined,
raw_args: strArray(args, 'raw_args'),
}),
// pad_library reads
// ── pad_library ──
'pad_library:list': (api) => api.library.get(),
'pad_library:get': (api) => api.library.get(),
'pad_library:activate': (api, ws, args) =>
api.library.activateByTitle(ws, requireArg(args, 'title')),
// pad_workspace reads
// ── pad_workspace reads ──
'pad_workspace:list': (api) => api.workspaces.list(),
// ── pad_meta ──
// bootstrap → GET /workspaces/{ws}/agent/bootstrap (scope addition,
// TASK-1893 comment). Read-only one-shot workspace context.
'pad_meta:bootstrap': (api, ws) => api.agentBootstrap(ws),
};
// ── dispatcher ─────────────────────────────────────────────────────────────
@@ -123,7 +551,10 @@ const READ_HANDLERS: Record<string, ReadHandler> = {
* @param api the singleton api client (injected for testability)
* @param wsSlug the ROUTE workspace slug — the only workspace authority
* @param isReadOnlyAction `(toolName, action) → boolean` from the descriptor's
* action map (the Go read set, served over the wire)
* action map (the Go read set, served over the wire).
* Retained for parity / future per-read-write
* branching; the route table itself is the source of
* truth for what's wired.
* @param toolName e.g. "pad_item"
* @param args the raw args object from the agent (includes `action`)
*
@@ -133,11 +564,13 @@ const READ_HANDLERS: Record<string, ReadHandler> = {
export async function dispatch(
api: Api,
wsSlug: string,
isReadOnlyAction: (toolName: string, action: string) => boolean | undefined,
_isReadOnlyAction: (toolName: string, action: string) => boolean | undefined,
toolName: string,
args: Record<string, unknown>,
): Promise<DispatchResult> {
// DR-4: the workspace is route-bound. Reject any attempt to set it.
// DR-4: the workspace is route-bound. Reject any attempt to set it — even
// when it equals the route slug, so the contract is unambiguous and a
// future route mismatch can't slip through.
if ('workspace' in args && args.workspace !== undefined && args.workspace !== '') {
return errResult(
"the 'workspace' argument is not accepted — WebMCP tools always " +
@@ -154,23 +587,12 @@ export async function dispatch(
return errResult(`missing required arg 'action' for ${toolName}`);
}
const readOnly = isReadOnlyAction(toolName, action);
// Write actions: recognized but not yet wired (this task is read-only).
// Never a silent no-op — return a precise, actionable error.
if (readOnly === false) {
return errResult(
`${toolName}.${action} is a write action — not yet wired in the ` +
'browser WebMCP surface (follows in TASK-3b)',
);
}
const key = `${toolName}:${action}`;
const handler = READ_HANDLERS[key];
const handler = HANDLERS[key];
if (!handler) {
// A read action the catalog exposes but with no browser REST mapping
// (e.g. pad_project.standup, pad_meta.bootstrap). Honest error, not a
// fake result.
// A catalog action with no browser mapping (e.g. pad_project.standup,
// pending TASK-1894). Honest error, never a fake result or silent
// no-op.
return errResult(
`${toolName}.${action} is not available in the browser WebMCP surface`,
);