MCP tool surface v2
Status: implemented and deployed (#313 to #317, switched in #317 on 2026-09-19; #310 done). It replaced the 27 tools of the old surface with eight, as one incompatible switch; the live list is 6,462 characters, and McpDiscoveryTests holds it to that budget. The open questions were decided on 2026-09-17 (#312); see Decisions below. Two of those decisions change the data model and run after the switch as #320 (no revisions) and #321 (visibility); v2 itself changed only the tool surface. The measurements of #311 passed both gates; see Measurements. What connected agents have to change is in the release note #325.
Continue here
State on 2026-09-19. v2 is live on conderis.com with system guide v32. Recall and lists name memories as #N or memory:<id>. The Conderis Batch v2 schema and example are published under /schemas and /examples, and a test sends the published example through item_store. The owner's global CLAUDE.md uses the v2 names. Session summary: #329.
Next, in this order:
- #321 Memory visibility
privateorshared(Decision 1). It is the smaller change and the one agents notice:item_storealready advertises onlyprivate, sharedbut the server still acceptspublic. Needs a migration (public to shared) and a move action for organization memories; nothing moves automatically. - #320 Drop revisions (Decision 6). The large one: first move memory proposals off draft revisions, then put content on the item and one full-state history for every kind. It ends with deleting
IItemHistory/RevisionItemHistory(src/Conderis/Services/Items/ItemHistory.cs), the internal mapping fromversionto revisions. The web history views must look as they do today. - #323 Review setting per agent key (Decision 3, low priority). Small, independent, can go in any time.
Each one gets its own migration, its own system guide version and a short addition to the release note #325 when agents see a difference.
Rules when changing the tool surface:
- A changed tool description must also change
tests/Conderis.Tests/ToolSurface/V2Prototype.cs;BuiltV2ToolsMatchTheMeasuredSchemascompares them. Then updateMaxToolListCharactersinMcpDiscoveryTeststo the measured size and add the step to the log comment there. Raising it is a decision; shorten other text first. - A changed system guide text (
src/Conderis/Services/SystemGuide.cs) bumpsSystemGuide.Version; recall announces it, so agents can tell their copy is stale. The server instructions live inProgram.cs. - Web prompts that name tools:
Components/Pages/Connect.razorandGuide.razor. - HTTP-level MCP tests:
HttpTests,McpDiscoveryTests,ProposalHttpTests,InstructionPushTests. Direct tool tests:ItemReadToolsTests,ItemStoreToolsTests,ItemActionToolsTests,ProjectToolsTests. In direct tests use a fixedIHttpContextAccessor(FixedContext), because the default accessor keeps its context in a staticAsyncLocal.
How to verify and ship: build, test and migrations are on #282 and #283; publishing and deploying on #282. Deploying is outward-facing: ask the owner first, check git status, and afterwards run a fresh headless session against production (claude -p with the conderis tools) that recalls, creates and completes a task and changes a memory.
Why
The advertised tool list costs every connected agent context in every session: 27 tools, about 19,000 characters, a few characters under the budget in McpDiscoveryTests. Most of it repeated the same ideas per kind: four tools wrote items (memory_store, page_store, task_create, task_patch) with 38 parameters between them, three read lists, three decided proposals. The same things were named and behaved differently from tool to tool:
- Addressing an item:
number,taskNumber,sourceNumber/targetNumber,idnext tonumber,proposalId,requestId,ref. - Concurrency:
versionfor tasks, proposals and page moves,baseRevisionIdfor memories and page text;page_storeanditem_deletetook both. - Changing: a JSON patch object for tasks, omitted-keeps plus flags such as
clearExpiryfor memories, andproject_updatereplaced all three values (fixed in f996a12: omitted values are now kept). - Decisions:
approve: boolwith or without a reason,restore: boolturning archive into its opposite. - Reasons:
explanationwhen storing,reasonwhen reviewing. - Filters and paging:
includeClosedintask_listagainstincludeArchivedinitem_search, which also meant closed tasks there;offset/limitin one tool,limitonly in two, nothing in the rest. kindnamed the relation type in the relation tools and the item kind everywhere else.
Growth planned in #302 (paging, partial reads) did not fit into the budget as it stood.
The kinds are not fixed. Memories, pages, tasks and proposals exist today; commits (#299) are next, and more will follow. Per-kind tools cost three to four tools per kind, so the list grows with the product. In v2 a new kind is one line in the descriptions of item_store, item_list and item_get, one row in item_action, and a server-side validator. The list stays the same size.
The inconsistencies alone could have been fixed on the 27 tools without consolidation; #311 measured that option as a control.
Client context
Most clients (Cursor, Windsurf, Codex, Claude Desktop, Gemini CLI, Copilot) load the whole tool list into every session, so the list size is the primary cost. Claude Code is the exception: it loads MCP tools on demand through a keyword search over names and descriptions. For it, two other things matter:
- Per-tool size. An agent that only creates a task loads
item_storewith the field lists of all four kinds. - Findability.
task_listcarried its keyword in the name;item_storeanditem_actiondo not. Every v2 description therefore names its kinds in the first sentence.
Both were recorded by #311 for information; neither decides.
Rules
- Read, write and irreversible stay separate tools. Clients grant permissions per tool name, and
readOnlyHint/destructiveHintapply to a whole tool. A user must be able to allow reading without allowing writing, and writing without allowing deletion. Inside the write tools the server keeps checking per action; a client allowlist cannot separate task work from moderation initem_action(#314). - Per-kind data travels as one JSON object. The schema describes the envelope; the server deserializes, validates against the kind and refuses unknown fields. The tool description lists the fields per kind in one line each. Validation moves from the schema to runtime, so every refusal names the allowed fields or values.
- One patch rule for every kind: an omitted field is kept,
nullclears it.clearExpiryand similar flags disappear. Sending a task's current assignee back keeps it, even when that agent's connection has ended. - One
versionfor every item; content changes and task and item actions check it; moves, relations and proposal actions do not. Until #320 the server maps the version onto the published revision internally; no revision id appears in any v2 request or response. - Lists return short entries (#303, #306) and page with one contract (#304). Single items read in parts (#305).
- No aliases for old names. They would fill the budget again. The switch happened with system guide version 31, the server instructions, the web setup prompts and documentation together.
- A new kind adds no tool. It adds its fields line in item_store, filters line in item_list, actions row in item_action, the server-side validator and tests. This is the property that keeps the list flat as the product grows.
The eight tools
| Tool | Kind | Replaces |
|---|---|---|
recall |
read | memory_recall; system_memory as guide: true |
project_get |
read | project_list (without projectId), task_assignee_list |
project_store |
write | project_create, project_update |
item_get |
read | item_get, memory_get, a single proposal, relation_list, item_history |
item_list |
read | task_list, item_search, the proposal queue |
item_store |
write | memory_store, page_store, task_create, task_patch |
item_action |
write | task_claim, task_complete, proposal_review, proposal_withdraw, item_archive, relation_create, relation_remove |
item_delete |
irreversible | item_delete (request only) |
privacy_deletion_queue and privacy_deletion_review left MCP. Deciding a permanent deletion is a person's job in the web app. Requesting one stays available to agents, because an agent that stored a secret by mistake must be able to lock it at once.
The tools live in src/Conderis/Services/Items (ProjectTools, ItemReadTools, ItemStoreTools, ItemActionTools, with ItemRef, ItemPage, TextPart and ItemHistory); the system guide is SystemGuide. The descriptions measured in #311 are in tests/Conderis.Tests/ToolSurface/V2Prototype.cs, and a test keeps the built tools equal to them.
recall
projectId, focus, maxChars, guide. Unchanged behaviour (#215); guide: true returns the system guide instead of the recall. Upstream knowledge of a fork is always included; includeUpstream is dropped. Memories appear as #N in the recalled project, memory:<id> without a project, and with the long project reference only for an upstream's memory.
project_get
Without projectId: the short list of accessible projects. With it:
- Project: name, description, remote and whether it is verified, public or private, upstream.
- Your permissions here: may write, may moderate. An agent knows before
item_storewhether a change publishes or becomes a proposal. - Assignees: people by display name and agents, with their immutable references, never email addresses.
- Counts: open tasks, memories, pages, pending proposals. Later the hygiene score (#292) and recall mode (#296).
item_get
projectId, number or ref, history, version, offset, take.
ref:instructions,memory:<id>for personal memories,proposal:<id>. A project item is read bynumber.- Returns the item, its direct relations as short entries and its
version. Deeper relations are followed with further calls;depthis dropped. history: truereturns the version history as metadata (version, author, time, changed fields);versionreads the item as it was at that version.offset/takeread long text in parts (#305).
item_list
kind, projectId, query, filter, sort, limit, cursor.
kind:task,memory,page,proposal; later every further kind.querysearches text, which replacesitem_search.- Tasks are the active ones (open, in progress, blocked) unless
includeClosed. There is nostatusfilter: in the #311 sessions agents asked for "open tasks" translated that intostatus: openevery time and lost blocked and in-progress work; a description that explained the default did not change that. filteris a JSON object per kind: taskpriority,assignee,parent,includeClosed; memorytype,tags,visibility,includeArchived,includeExpired; pageparent,includeArchived; proposalstate,author,targetKind. An unknown filter is refused with the allowed ones.- Response per #304: short entries,
total,hasMore,nextCursor. A task entry names itsparentandassigneethe wayitem_getdoes.
item_store
| Parameter | Meaning |
|---|---|
kind |
memory, page, task, instructions; later every further kind |
projectId |
Missing for personal memories |
number |
Change this item; without it, create |
version |
The version read with item_get; required when content changes, not for a move |
fields |
JSON object for the kind |
reason |
Shown to the moderator when the change becomes a proposal |
propose |
Ask for review instead of publishing |
batch |
Many items at once, instead of fields |
Fields per kind:
- memory:
title,summary,content,tags,type,visibility,expiresAt, laterparent(#295) andweight(#294) - page:
title,content,parent,position - task:
title,content,status,priority,assignee,parent - instructions:
content
The response names the outcome (published, proposed, denied with the missing capability), the number and the new version.
Batches
One JSON object, used for moving memories between agents and for imports from other tools (task boards, wikis). Published as Conderis Batch v2: schema /schemas/conderis-batch-v2.schema.json, example /examples/conderis-batch-v2.json.
{
"idempotencyKey": "import-2026-09-17",
"source": "trello",
"items": [
{ "key": "epic", "kind": "task", "fields": { "title": "Checkout" } },
{ "kind": "task", "fields": { "title": "Card form", "parent": "epic" } }
]
}
keynames an item inside the batch;parentmay point at the key of an earlier item of the same kind or at an existing number. The response maps keys to the assigned numbers. An item holds onlykey,kindandfields; anything else is refused.- Up to 100 items and 1 MB per batch; kinds may be mixed. Larger imports send several batches.
- Each kind is stored atomically on its own (#315): tasks, pages and memories are written by separate services, so one transaction across them is not possible. A failing kind is reported and leaves the other kinds stored. Every kind records its result under the idempotency key plus its kind, so sending the same batch again returns the kinds already stored and retries only the one that failed.
- Memories that need review become proposals; tasks are never reviewed; pages in a batch need moderation rights.
- The existing Conderis Memory Batch v1 file stays accepted unchanged as
batchwithkind: memory.
item_action
projectId, number or ref, action, version, reason, target, relation.
| Kind | Actions |
|---|---|
| task | claim, complete, block (reason required), unblock, relate, unrelate; later reject |
| proposal | approve, reject (both with a reason), withdraw |
| memory, page | archive, restore, relate, unrelate (with target and relation) |
An action that does not apply to the item's kind is refused, naming the actions that do. A task is closed with complete or by setting status canceled, never archived.
Version: task and item actions need the version from item_get; relate, unrelate and the proposal actions do not. Proposals are immutable, so their version only guarded against deciding twice, which the pending state already prevents. In the #311 sessions agents left out the version on approve in three of eight runs.
item_delete
Requests permanent deletion of an item or of one version and locks its content immediately. A person decides in the web app.
Decisions (2026-09-17, #312)
- Visibility of a memory:
privateorshared. Implemented after the switch as #321; until thenitem_storeaccepts today's values.private: only the author, nobody else in the project or anywhere. WithoutprojectIdthis is a personal memory across projects.shared: everyone with access to the project, directly or through its organization, and everyone when the project is public. There is no separatepublicper memory: hiding a memory inside a public project would only be obscurity, and internal details do not belong in a public project anyway. The web app states next tosharedthat it is visible to everyone in a public project.sharedrather thanpublic:publicwould contradict the project's own public/private badge in a private project.sharedrather thanproject:projectdoes not say who is included, and readers would ask whether members, the organization or the public are meant.sharedstates the rule: everyone who has access to the project, which in an open-source project is everyone in the world.sharedis already the term in the web app.- Organization memories are dropped. An organization is an access group that gives its members its projects. Organization-wide guidance stays in the organization's agent instructions.
- Migration:
publicproject memories becomeshared; organization memories are listed for their owner with a move action into a project or the organization's instructions, nothing moves automatically. Upstream recall of a fork takes thesharedmemories of a public upstream. - Visibility is fixed after creation; a change is refused.
- Blocking a task is an action with a required reason (
block,unblock), so the reason is in the history and other agents see what the task waits for. - Forcing a proposal works both ways: a setting on the agent key in the web app ("changes by this agent always need review", #323, open) and
propose: trueper call (implemented). includeUpstreamand relationdepthare dropped.- Batches: up to 100 items and 1 MB, mixed kinds, each kind atomic on its own with its own retry (changed on 2026-09-18 in #315 from "the whole batch atomic": there is no shared transaction across the services).
- One
versionper item, and no revisions. The tool contract (oneversion) is part of v2; the storage change is #320 after the switch.- Every item already has a version that grows with each change; before v2 tasks and page moves checked it, memories and page text checked a revision id instead. From now on every content change checks the version.
- Revisions existed because a proposal used to be an unpublished revision next to the published one, so content lived in revision rows and the item only pointed at one. Proposals have been a separate system since #226 for pages and instructions; memory creates still run on a draft revision (
DraftRevisionId), which #320 moves to proposals first. - The item carries its current content, as tasks already do.
- One version history for every kind, storing full states: each content change appends item, version, the complete content, author and time; entries are never changed. Tasks switch from their field-change log to full states as well; the list of changed fields is computed at write time.
- The history serves reading an earlier state (
item_getwithversion), comparing a proposal with itsbaseVersion(the field already exists on proposals), and deleting a single version for privacy. Full states make all three trivial; a change log would have to be replayed and cannot lose a middle entry. - Until #320 a version of a memory, page or the instructions is the ordinal of its published revision along the base chain, counting privacy-locked revisions, so deleting one version keeps the numbers of the others.
- Migration: memory and page content moves from the published revision onto the item; existing revisions become history entries numbered in order of creation; proposals derive
baseVersionfrombaseRevisionId; the task change log is converted by replaying it into full states.
- Moves are not version-checked. A change of only
parentand/orpositionneeds noversionand does not increase it: whoever moves an item wants it at the new place, whatever happened before, so the last move wins. The server still checks at execution time that the target exists and is active, that no cycle forms and that the caller may move. A text change is therefore never refused because someone moved the item meanwhile. Apositionrefers to the siblings the caller saw and may land one place off after a concurrent reorder, which is harmless. A store that changes content and structure together needsversion. - From #311 (2026-09-18): the task list has no
statusfilter, onlyincludeClosed; proposal actions need noversion. - No email addresses (2026-09-18): responses name people by a display name each person sets in the settings, prefilled with the part before the @.
Measurements (#311, 2026-09-18)
Method: the schemas are generated offline by the MCP SDK exactly as the server advertises them; the offline list of the old tools matched the live list to the character (19,014). Stubs and the measuring test live in tests/Conderis.Tests/ToolSurface, the session harness in tests/ToolSurfaceSession (da0842d, ae81f71). Tokens are counted with o200k as an approximation.
List size (final v2 descriptions after the session findings)
| Series | Tools | Characters | Tokens |
|---|---|---|---|
| Before v2 | 27 | 19,014 | 4,043 |
Consistency fixes only, descriptions cut to v2 length (plus task_block) |
28 | 12,614 | 2,872 |
| v2 (measured) | 8 | 6,386 | 1,431 |
| v2 (live after #317) | 8 | 6,462 | about 1,450 |
The live list is 76 characters above the measurement, because item_store and item_action descriptions were corrected during implementation (batch atomicity per kind, actions per kind). v2 is 66 % smaller than the old surface and half the size of the consistent 28 tools. Largest live v2 tools: item_store 1,362, item_list 983, item_action 977, item_get 909.
Growth by one kind (commits as the example): v2 +97 characters (+23 tokens), for the added lines in item_store, item_list and item_get. Consistent series +1,038 characters (+244 tokens) for three tools (commit_list, commit_link, commit_unlink).
Round trip for on-demand clients (create, list, complete a task; information only): before task_create + task_list + task_complete 505 tokens, v2 item_store + item_list + item_action 745 tokens. An agent that loads tools one by one pays about 240 tokens more per round trip in v2; clients that load the whole list save about 2,600 tokens per session.
Findability (information only): a keyword score over name, first sentence and the rest of the description found the intended v2 tool first for all six queries ("create task", "store memory", "read page", "approve proposal", "block task", "recall project"). Before, "read page" ranked item_get second and "block task" had no tool.
Retries in a real session. A stub server serves the measured list of one surface over stdio, answers from a small fixed project, checks calls the way the real server does and logs every call. Headless Claude Code sessions (claude -p, tools loaded on demand) did the same six things against each surface: create a subtask, change a memory's summary, block a task with a reason, list the open tasks, read a section of a page, approve a proposal.
| Surface | Runs | Calls per run | Refused calls | What was refused |
|---|---|---|---|---|
| Before v2 | 3 | 10 | 0, 1, 1 | memory_store with number but without projectId |
| v2, first design | 5 | 9–11 | 0, 0, 1, 0, 1 | approve without version |
| v2, first design | 5 | — | — | every run listed "open tasks" with status: open and missed the blocked task #14 |
| v2, final design | 3 | 8–9 | 0, 1, 0 | status filter, answered with the allowed filters and corrected in the next call |
Every refusal was corrected in the next call. The first design had a correctness problem that no refusal showed: status: open returned a plausible but incomplete list. Two design changes followed (Decision 8), and with them all runs listed the right tasks. The old surface recorded a block reason only by writing it into the task description; v2 recorded it as a reason in every run.
Gate: met. The list is 6,386 characters against 19,014, and the final v2 needed one refused call in three runs against two in three for the old surface.
Related: #302 (bounded responses), #219 and #304 (paging), #305 (partial reads), #291 (memory structure), #299 (commits on tasks), #319 (decisions of 2026-09-17), #320 (no revisions), #321 (visibility), #323 (review setting per key), #325 (release note), #329 (session summary).