Skip to content
Merged
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
163 changes: 161 additions & 2 deletions docs/MCP_SERVER.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ Use these fields when creating personal tasks:
- `p_success`: probability from `0` to `1`; the MCP layer computes expected value as `value * p_success`.
- `cash_cost`: dollars required to execute; defaults to `0`.
- `executor_type`: `Self` for normal user work, even if AI assists; `AI Agent` only for autonomous assistant work.
- `depends_on`: task IDs that must be `VERIFIED` before this task appears in queues.
- `blockerTaskRefs`: task IDs or exact task keys that must be `VERIFIED` before this task appears in queues. `depends_on` remains a legacy alias.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- `available_at`: earliest ISO time the task should appear in active queues.
- `due_at`: due date or expiry date.
- `deadline_policy`: `NONE`, `SOFT`, `EXPIRES`, or `REQUIRED`.
Expand All @@ -74,7 +74,7 @@ Deadline policy rules:
- `EXPIRES`: opportunity disappears after `due_at`, such as a grant or application.
- `REQUIRED`: must-do obligation, such as taxes, legal filings, medicine refills, or safety/health maintenance.

Do not use difficulty or urgency words as substitutes for estimates. If something is mandatory, encode the avoided downside in `value`, put the real due date in `due_at`, and use `deadline_policy: "REQUIRED"`. If something unlocks other work, use `depends_on`.
Do not use difficulty or urgency words as substitutes for estimates. If something is mandatory, encode the avoided downside in `value`, put the real due date in `due_at`, and use `deadline_policy: "REQUIRED"`. If something unlocks other work, add its task ID or exact task key to each dependent task's `blockerTaskRefs`.

Recommended OAuth scope for a personal life-planning AI:

Expand All @@ -99,6 +99,165 @@ Do not request `tasks:admin` for personal planning. Public manual search and pub

Task-listing tools take `visibility: "all" | "public" | "private"` — signed-in callers default to `all` (public plus their own private work). On the tools that previously exposed `scope`/`taskScope`, `"accessible"` survives as a deprecated alias for `"all"`; `listTasks` never had the alias and takes only `visibility`.

## Task References, Pagination, And Bundles

Call `listTasks` or `searchTasks` with `paginated: true`.

Example `listTasks` request:

```json
{
"parentTaskId": "<task whose children you are enumerating>",
"visibility": "all",
"limit": 50,
"paginated": true
}
```

`searchTasks` uses the same pagination fields plus its required `query`.

Paginated response from either tool:

```json
{
"tasks": [],
"nextCursor": "<opaque cursor or null>"
}
```

When `nextCursor` is not `null`, repeat the same tool call with that exact value as `cursor`. Keep `paginated: true`, the query, and every filter unchanged. Continue until `nextCursor` is `null` before saying that a task list or search is complete. Paginated inventory uses immutable task-ID order so priority or relevance changes between calls cannot move tasks across the cursor; use queue-ranking tools when order matters. A cursor belongs only to the call that produced it. Calls with neither `paginated: true` nor `cursor` retain the legacy one-page array response for existing clients.

Pagination is intentionally bounded. If a call returns `RESULT_WINDOW_EXCEEDED`, narrow its query-level filters instead of assuming the returned window is complete.

A persisted task reference is one of these:

- The task ID returned by a create, apply, search, or list operation.
- The exact stable `taskKey`.

Titles are display text, not references. Do not search for a task merely to recover the ID of something the preceding write already returned.

`createTask` returns the persisted identifier as both `id` and `taskId`, and repeats it in `writeReceipt.taskId`. Save it or the stable task key immediately.

For `createTask`, use `parentTaskId` or `parentTaskKey` for the parent. Use `blockerTaskRefs` and `blockedTaskRefs` for graph edges; each reference may be a persisted ID or exact task key. The legacy `depends_on`, `blockerTaskIds`, and `blockedTaskIds` fields remain accepted, but new agents should use the `*Refs` names because they describe the actual contract.

For one shared/public multi-task proposal, use `proposeTaskBundle` and give every candidate:

- A short, unique `ref` for links within that request.
- A stable `taskKey` for idempotency and later calls.

Use a candidate's `ref` in another candidate's `parentTaskRef`, `blockerRefs`, or `dependencies[].taskRef`. An `id` candidate field remains a legacy alias for `ref`. Candidate refs, legacy ids, and task keys must not collide.

```json
{
"candidates": [
{
"ref": "implement-contract",
"taskKey": "optimitron:dev:mcp-task-reference-contract",
"title": "Make MCP task references consistent",
"description": "Accept exact task IDs or task keys and return an explicit reference map.",
"parentTaskRef": "optimitron:dev",
"estimatedEffortHours": 4,
"executorType": "AI Agent",
"acceptanceCriteria": [
"Standalone and bundle dependency calls resolve exact task keys",
"Every created draft is mapped to its persisted task ID"
]
},
{
"ref": "document-contract",
"taskKey": "optimitron:dev:mcp-task-reference-docs",
"title": "Document the MCP task reference contract",
"description": "Give agents one copyable task-authoring workflow.",
"parentTaskRef": "implement-contract",
"blockerRefs": ["implement-contract"],
"dependencies": [
{
"taskRef": "implement-contract",
"assumptions": ["Documentation must describe the shipped contract"]
}
],
"estimatedEffortHours": 1,
"executorType": "AI Agent",
"acceptanceCriteria": [
"The MCP instructions and guide contain the same reference rules"
]
}
]
}
```

The response includes review decisions plus a `referenceMap` object whose keys are the accepted bundle refs, legacy ids, task keys, and persisted IDs and whose values are persisted task IDs. Save that map. `createdDrafts`, `existingDrafts`, and `changedDrafts` report the write outcome. Bundle refs end with that request; later calls use the mapped task ID or task key.

```json
{
"createdDrafts": [
{
"proposalRef": "implement-contract",
"taskId": "<persisted implementation task ID>",
"title": "Make MCP task references consistent"
},
{
"proposalRef": "document-contract",
"taskId": "<persisted documentation task ID>",
"title": "Document the MCP task reference contract"
}
],
"existingDrafts": [],
"changedDrafts": [],
"referenceMap": {
"implement-contract": "<persisted implementation task ID>",
"optimitron:dev:mcp-task-reference-contract": "<persisted implementation task ID>",
"document-contract": "<persisted documentation task ID>",
"optimitron:dev:mcp-task-reference-docs": "<persisted documentation task ID>"
},
"review": {
"decisions": [
{
"proposalRef": "implement-contract",
"title": "Make MCP task references consistent",
"promotable": true,
"evaluation": {
"qualityScore": 1,
"rationale": []
},
"issues": []
},
{
"proposalRef": "document-contract",
"title": "Document the MCP task reference contract",
"promotable": true,
"evaluation": {
"qualityScore": 1,
"rationale": []
},
"issues": []
}
],
"promotableCount": 2,
"summary": "<review summary>"
},
"message": "<write summary>"
}
```

`proposeTaskBundle` creates reviewed `DRAFT` tasks. Inspect every decision, then call `promoteTask` with the accepted tasks' returned IDs or exact task keys. Being the task owner does not skip this promotion boundary.

The caller's OAuth grant must include the selected personal or organization target. Accepted task rows, parents, blocker edges, communication endpoints, impact estimates, and source provenance commit in one transaction; a failed attachment does not leave a partial bundle.

Private source-derived work uses a different, atomic protocol: call `reviewPrivateTaskBundle`, inspect its normalized actions and errors, then pass the unchanged bundle and returned `reviewHash` to `applyPrivateTaskBundle`. Its candidate `ref`, `parentRef`, and `dependencyRefs` are local to that reviewed bundle. Successful apply returns persisted IDs and creates private `ACTIVE` tasks; do not send private work through the public draft/promotion path.

### Finishing A Task

There is no generic `COMPLETED` task status. Pick the operation from the work's actual ownership and review boundary:

| Work | Operation | Result |
| --------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| Private, uncompensated `Self` task created by and owned by the caller | `completeTask` with factual evidence | Records the evidence and moves the task to `VERIFIED`. |
| One contributor finishing a claim on open work | `completeTaskClaim` | Marks that claim `COMPLETED` for review; it does not by itself verify the task. |
| Delegated, shared, paid, public, organization, or agent work | `startTaskExecution`, `submitTaskArtifact`, then `submitTaskForVerification` | Preserves the formal artifact and verification trail; an authorized human accepts or rejects it. |

Do not call `updateTask(status="VERIFIED")`; the server rejects that shortcut. If `completeTask` says the task is ineligible, read the returned reason instead of submitting an unrelated claim. Correct a genuinely misclassified private one-person task to `OPEN_SINGLE` only when its real workflow permits that change.

Example private task (use the `personalRoot.id` returned by `getMe`):

```json
Expand Down
64 changes: 47 additions & 17 deletions packages/web/scripts/mcp-personal-task-engine-smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,12 @@ type QueueRow = {

type CreatedTask = {
id: string;
taskKey: string;
title: string;
};

const createdTasks: CreatedTask[] = [];
let personalRootId = "";

function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
Expand Down Expand Up @@ -99,18 +101,27 @@ async function createTask(input: {
p_success: number;
executor_type: "Self" | "AI Agent";
}) {
const task = await callTool<CreatedTask>("createTask", {
title: `${RUN_ID}: ${input.title}`,
assert(personalRootId, "getMe returned no personal planning root");
const title = `${RUN_ID}: ${input.title}`;
const taskKey = `${RUN_ID}:${input.key}`;
const response = await callTool<{ id?: string; taskId?: string; title?: string }>("createTask", {
title,
description: `Temporary personal task engine smoke test row ${input.key}.`,
parentTaskId: personalRootId,
taskKey,
hours: input.hours,
value: input.value,
p_success: input.p_success,
cash_cost: 0,
executor_type: input.executor_type,
ev_math: "Smoke-test deterministic EV inputs.",
category: "OTHER",
acceptanceCriteria: ["The smoke test completes and cleans up this task"],
impactStatement: "Verifies the live personal task queue and dependency contract.",
});
assert(task.id, `createTask returned no id for ${input.title}`);
const taskId = response.taskId ?? response.id;
assert(taskId, `createTask returned no task ID for ${input.title}`);
const task = { id: taskId, taskKey, title: response.title ?? title };
createdTasks.push(task);
return task;
}
Expand All @@ -126,13 +137,29 @@ async function getAIQueue() {
}

async function markDone(task: CreatedTask) {
await callTool("updateTask", {
await callTool("completeTask", {
taskId: task.id,
status: "VERIFIED",
completionEvidence: "Completed by MCP personal task engine smoke test.",
});
}

async function searchPublicSmokeTasks() {
const tasks: unknown[] = [];
let cursor: string | null = null;
do {
const page = await callTool<{ nextCursor: string | null; tasks: unknown[] }>("searchTasks", {
...(cursor ? { cursor } : {}),
limit: 5,
paginated: true,
query: RUN_ID,
visibility: "public",
});
tasks.push(...page.tasks);
cursor = page.nextCursor;
} while (cursor);
return tasks;
}

async function cleanup() {
for (const task of [...createdTasks].reverse()) {
try {
Expand All @@ -146,7 +173,9 @@ async function cleanup() {
async function main() {
console.log(`MCP personal task engine smoke test against ${BASE}`);

const publicBefore = await callTool<unknown[]>("listTasks", { limit: 500 });
const me = await callTool<{ personalRoot: { id: string } | null }>("getMe");
personalRootId = me.personalRoot?.id ?? "";
assert(personalRootId, "getMe returned no personalRoot; grant tasks:personal");

try {
const A = await createTask({ key: "A", title: "Build product demo", hours: 6, value: 50000, p_success: 0.9, executor_type: "Self" });
Expand All @@ -158,13 +187,18 @@ async function main() {
const G = await createTask({ key: "G", title: "Write Grant Application Beta", hours: 6, value: 50000, p_success: 0.2, executor_type: "Self" });
const H = await createTask({ key: "H", title: "Set up donation page", hours: 2, value: 10000, p_success: 0.8, executor_type: "Self" });
const I = await createTask({ key: "I", title: "Organize files and folders", hours: 1, value: 100, p_success: 1, executor_type: "Self" });
const J = await createTask({ key: "J", title: "Research potential funders list", hours: 3, value: 20000, p_success: 0.7, executor_type: "AI Agent" });

await callTool("updateTask", { taskId: C.id, depends_on: [A.id] });
await callTool("updateTask", { taskId: D.id, depends_on: [A.id] });
await callTool("updateTask", { taskId: E.id, depends_on: [A.id] });
await callTool("updateTask", { taskId: F.id, depends_on: [A.id, B.id, C.id] });
await callTool("updateTask", { taskId: G.id, depends_on: [A.id, B.id, D.id] });
await createTask({ key: "J", title: "Research potential funders list", hours: 3, value: 20000, p_success: 0.7, executor_type: "AI Agent" });
const publicSmokeTasks = await searchPublicSmokeTasks();
assert(
publicSmokeTasks.length === 0,
`Personal smoke tasks leaked into public search: ${JSON.stringify(publicSmokeTasks)}`,
);

await callTool("updateTask", { taskId: C.id, blockerTaskRefs: [A.taskKey] });
await callTool("updateTask", { taskId: D.id, blockerTaskRefs: [A.taskKey] });
await callTool("updateTask", { taskId: E.id, blockerTaskRefs: [A.taskKey] });
await callTool("updateTask", { taskId: F.id, blockerTaskRefs: [A.taskKey, B.taskKey, C.taskKey] });
await callTool("updateTask", { taskId: G.id, blockerTaskRefs: [A.taskKey, B.taskKey, D.taskKey] });

assertQueue(await getMyQueue(), [
"Create credibility page on website",
Expand Down Expand Up @@ -231,14 +265,10 @@ async function main() {
await markDone(task);
}
assertQueue(await getMyQueue(), [], {});
await markDone(J);
} finally {
await cleanup();
}

const publicAfter = await callTool<unknown[]>("listTasks", { limit: 500 });
assert(publicAfter.length === publicBefore.length, "Public task count changed after personal smoke test cleanup");

console.log("MCP personal task engine smoke test passed.");
}

Expand Down
Loading
Loading