MCP Protocol Architecture
Overview
Model Context Protocol (MCP) integration enables Claude Desktop to orchestrate nexus-agents. The system implements the MCP 2025-11-25 specification.
Key Capabilities
- Tool Definitions - Zod-validated tool schemas
- Structured Output - JSON and text content blocks
- Resource Management - Dynamic context exposure
- Error Handling - Tool errors vs protocol errors
MCP Server Architecture
┌─────────────────────────────────────────────────────────────┐
│ Claude Desktop │
│ │ │
│ MCP Protocol │
│ │ │
└─────────────────────────┼───────────────────────────────────┘
│
┌─────────────────────────▼───────────────────────────────────┐
│ MCP Server (nexus-agents) │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Tool Registry │ │
│ │ orchestrate │ delegate │ expert │ workflow │ ... │ │
│ └──────────────────────────┬─────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────▼─────────────────────────────┐ │
│ │ Validation Layer (Zod) │ │
│ └──────────────────────────┬─────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────▼─────────────────────────────┐ │
│ │ Agent Orchestration │ │
│ │ Orchestrator │ Expert Pool │ Consensus │ Workflows │ │
│ └────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Tool Design Pattern
server.tool(
'tool_name',
{
// Zod schema with descriptions (Claude uses these)
param: z.string().describe('What this parameter does'),
optional: z.number().optional().describe('Optional parameter'),
},
async (args) => {
// 1. Validate input (already done by SDK, but double-check business rules)
const validated = Schema.safeParse(args);
if (!validated.success) {
return {
isError: true,
content: [{ type: 'text', text: validated.error.message }],
};
}
// 2. Execute with proper error handling
try {
const result = await doWork(validated.data);
return {
content: [{ type: 'text', text: JSON.stringify(result) }],
};
} catch (error) {
return {
isError: true,
content: [{ type: 'text', text: error.message }],
};
}
}
);
Tool Design Rules
- Clear names - Verb-noun format:
create_expert,run_workflow - Detailed descriptions - Claude uses these to decide when to call
- Zod validation - All inputs validated at tool boundary
- Tool errors vs protocol errors - Use
isError: truefor tool failures - Structured output - Support both
structuredContentandTextContent
Tool Interface
interface ITool {
readonly name: string;
readonly description: string;
readonly inputSchema: z.ZodSchema;
execute(input: unknown): Promise<ToolResult>;
}
interface IToolRegistry {
register(tool: ITool): void;
get(name: string): ITool | undefined;
list(): ToolInfo[];
unregister(name: string): boolean;
}
interface ToolResult {
isError?: boolean;
content: ToolContentBlock[];
}
type ToolContentBlock =
| { type: 'text'; text: string }
| { type: 'image'; data: string; mimeType: string }
| { type: 'resource'; uri: string; mimeType?: string };
Registered Tools
| Tool | Description | Key Parameters |
|---|---|---|
orchestrate |
Task orchestration with expert coordination | task, context, maxIterations |
create_ |
Create a specialized expert agent | role, modelPreference |
execute_ |
Execute a task using a created expert | expertId, task, context |
run_ |
Execute a workflow template | template, inputs, dryRun |
delegate_ |
Route task to optimal model | task, preferredCapability |
consensus_ |
Multi-model consensus voting | proposal, strategy, quickMode |
list_ |
List available expert types | format |
list_ |
List available workflow templates | category, format |
research_ |
Query research registry | action, techniqueId, query |
research_ |
Add paper to registry by arXiv ID | arxivId, topic, priority |
research_ |
Discover papers/repos from external sources | topic, source, maxResults |
research_ |
Analyze registry for gaps, trends, coverage | focus, topic |
research_ |
Review auto-cataloged research references | action, identifier, topic |
research_ |
Synthesize registry into topic clusters | topicFilter |
memory_ |
Query across all memory backends | query, limit, source |
memory_ |
Memory system statistics dashboard | includeDecay, includePromotion |
memory_ |
Write entry to a specific memory backend | backend, key, value, metadata |
weather_ |
Multi-CLI performance weather report | cli, category, includeAdaptive |
issue_ |
Triage GitHub issues with trust classification | issueUrl, dryRun |
run_ |
Execute graph-based workflows with checkpointing | workflow, inputs |
execute_ |
Execute AI software factory spec pipeline | spec, dryRun |
registry_ |
Generate draft model registry entry | provider, modelId, dryRun |
repo_ |
Analyze a GitHub repository structure | repoUrl, includeSecurityGaps |
repo_ |
Generate security scanning pipeline for a repo | repoUrl, analysis |
query_ |
Query execution trace JSONL files from disk | runId, eventType, limit |
Tool Examples
orchestrate
// Input
{ task: "Review this code for security issues", context: { file: "auth.ts" } }
// Output
{ summary: "...", experts_consulted: ["security", "code"], recommendations: [...] }
workflow
// Input
{ template: "code-review", inputs: { url: "https://github.com/..." }, dryRun: false }
// Output
{ status: "completed", steps: [...], output: "..." }
Tool Annotation Taxonomy (MCP 2025-11-25)
Every MCP tool in the manifest declares all four annotation hints from the 2025-11-25 spec. Annotations live in a single source of truth — the TOOL_MANIFEST array in packages/nexus-agents/src/mcp/tools/tool-manifest.ts, one { name, annotations, sideEffects } entry per tool; packages/nexus-agents/src/mcp/tool-annotations.ts is an accessor that re-exports from it — and each manifest-driven server.registerTool() call reads its annotations via getToolAnnotations(name).
The checkToolAnnotations gate in scripts/inject-governance.ts (run by pnpm governance:check, CI-wired) checks manifest self-consistency: it parses the manifest’s tool names and confirms each pairs with an annotations: { block, and has a second branch for annotated names absent from the manifest that cannot fire, since both sets are parsed from the same file. It does not read server.registerTool() call sites, so a tool registered outside the manifest is invisible to it — upstream MCP proxy tools registered by initUpstreamServers (cli-server-tools.ts) carry no annotations at all — and it does not check that all four hints are present. Nothing does: ToolAnnotations (tool-manifest.ts) declares all four hints optional, so the fact that every manifest entry currently carries all four is convention, not an enforced invariant.
Per the MCP spec these are hints, not enforcement primitives — clients should never make safety decisions based on annotations received from untrusted servers. Inside nexus-agents the only reader of the hints is a build-time gate: checkToolPrerequisites in scripts/inject-governance.ts parses readOnlyHint from the manifest and requires every non-read-only tool to appear in TOOL_PREREQUISITES or NO_PREREQUISITE (#2652). The runtime prerequisite gates in mcp/middleware/tool-prerequisites.ts do not read annotations, and no pipeline runner consults idempotentHint for retries (PipelineRunner.retryFailed gates on a per-node isRetryable flag). Otherwise the hints pass through to MCP clients unchanged, for permission-prompt UX across harnesses.
| Tool | readOnly | destructive | idempotent | openWorld | Why |
|---|---|---|---|---|---|
orchestrate |
❌ | ❌ | ❌ | ✅ | Spawns workers that may write; calls CLIs |
create_ |
❌ | ❌ | ❌ | ❌ | Mutates in-memory expert registry |
execute_ |
❌ | ❌ | ❌ | ✅ | Invokes external CLIs; expert may write |
run_ |
❌ | ❌ | ❌ | ✅ | Steps may write to registry / FS |
run_ |
❌ | ❌ | ❌ | ✅ | Multi-step graph; checkpointed |
run_ |
❌ | ❌ | ❌ | ✅ | Plugin by name; may write |
run_ |
❌ | ❌ | ❌ | ✅ | Full research→plan→implement loop |
execute_ |
❌ | ❌ | ❌ | ✅ | AI software factory spec |
consensus_ |
❌ | ❌ | ❌ | ✅ | Records vote outcome to audit log; calls CLIs |
supply_ |
❌ | ❌ | ❌ | ✅ | Per-axis vote; records outcome |
pr_ |
❌ | ❌ | ❌ | ✅ | Writes review comments |
delegate_ |
✅ | ❌ | ✅ | ❌ | Returns routing recommendation only |
list_ |
✅ | ❌ | ✅ | ❌ | Local registry read |
list_ |
✅ | ❌ | ✅ | ❌ | Local registry read |
research_ |
✅ | ❌ | ✅ | ❌ | Reads registry |
research_ |
✅ | ❌ | ✅ | ❌ | Reads registry |
research_ |
✅ | ❌ | ✅ | ❌ | Reads registry |
research_ |
✅ | ❌ | ✅ | ✅ | Calls external APIs (arXiv, GitHub); no registry write |
research_ |
❌ | ❌ | ❌ | ✅ | Mutates registry; calls arXiv API |
research_ |
❌ | ❌ | ❌ | ✅ | Mutates registry |
research_ |
❌ | ❌ | ❌ | ❌ | May approve/dismiss catalog entries |
survey_ |
✅ | ❌ | ✅ | ✅ | Transient GitHub search; no persistence |
vendor_ |
✅ | ❌ | ✅ | ❌ | Static lookup against curated seed |
compare_ |
✅ | ❌ | ✅ | ❌ | Local file diff only |
memory_ |
✅ | ❌ | ✅ | ❌ | Reads memory backends |
memory_ |
✅ | ❌ | ✅ | ❌ | Reads memory stats |
memory_ |
❌ | ❌ | ❌ | ❌ | Writes to memory backend |
weather_ |
✅ | ❌ | ✅ | ❌ | Reads outcome store |
query_ |
✅ | ❌ | ✅ | ❌ | Reads trace JSONL files |
query_ |
✅ | ❌ | ✅ | ❌ | Reads task-state log |
verify_ |
✅ | ❌ | ✅ | ❌ | Verifies hash chain; no mutation |
improvement_ |
❌ | ❌ | ❌ | ✅ | May file GitHub issues when fileIssues=true |
repo_ |
✅ | ❌ | ✅ | ✅ | Reads repo metadata via GitHub API |
repo_ |
✅ | ❌ | ✅ | ✅ | Returns plan; doesn’t write it |
extract_ |
✅ | ❌ | ✅ | ❌ | Reads source files |
search_ |
✅ | ❌ | ✅ | ❌ | Reads source files |
issue_ |
❌ | ❌ | ❌ | ✅ | May write GitHub labels/comments when authorized |
registry_ |
✅ | ❌ | ✅ | ❌ | Derives a draft registry entry in memory; never persists |
Why no tool is destructiveHint: true
A destructiveHint: true tool can delete or overwrite data without the caller’s input. nexus-agents tools are universally additive (writes are creates, not deletes) or return-only — no tool’s body calls rm, DELETE FROM, or equivalent. If a future tool needs destructiveHint: true (e.g., a “drop registry entry” tool), it should also wire a prerequisite gate per #2652.
Adding a new tool
- Implement the tool in
packages/nexus-agents/src/mcp/tools/<tool>.ts. - Add a
{ name, annotations, sideEffects }entry toTOOL_MANIFESTinpackages/nexus-agents/src/mcp/tools/tool-manifest.ts(REGISTERED_TOOL_NAMESis derived from it). - Map the entry to its handler in
HANDLER_TABLEinpackages/nexus-agents/src/cli-server-tools.ts(assertHandlerManifestParityfails if the two disagree). - Pass
annotations: getToolAnnotations('your_tool_name')to theserver.registerTool()config object. - Run
pnpm governance:check—checkToolAnnotationsconfirms the manifest entry carries anannotationsblock.
Resource Exposure
Dynamic context exposure to Claude:
server.resource('project_context', 'text/markdown', async () => {
const content = await readFile('CLAUDE.md', 'utf-8');
return { content };
});
// Claude can read with:
// resource://nexus-agents/project_context
Resource Types
| Resource | MIME Type | Purpose |
|---|---|---|
project_ |
text/markdown | CLAUDE.md instructions |
architecture |
text/markdown | ARCHITECTURE.md summary |
config |
application/json | Current configuration |
routing_ |
application/json | Routing performance data |
Testing MCP Tools
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
describe('MCP Tools', () => {
let client: Client;
let server: Server;
beforeEach(async () => {
// Create linked transport pair
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
server = createServer();
client = new Client({ name: 'test-client' });
await server.connect(serverTransport);
await client.connect(clientTransport);
});
it('should orchestrate task', async () => {
const result = await client.callTool({
name: 'orchestrate',
arguments: { task: 'Test task' },
});
expect(result.isError).toBe(false);
expect(result.content).toHaveLength(1);
});
});
Error Handling
Tool Errors (Recoverable)
Use for expected failures:
return {
isError: true,
content: [
{
type: 'text',
text: 'Validation failed: task cannot be empty',
},
],
};
Protocol Errors (Unexpected)
Throw for unexpected failures:
throw new McpError(ErrorCode.InternalError, 'Database connection failed');
Error Categories
| Category | isError | Throw | Example |
|---|---|---|---|
| Validation | true | No | Invalid input format |
| Not Found | true | No | Expert type doesn’t exist |
| Rate Limited | true | No | Too many requests |
| Internal | No | Yes | Database failure |
| Protocol | No | Yes | MCP spec violation |
Extension: Adding a New Tool
1. Define Tool Interface
// src/mcp/tools/my-tool.ts
import { z } from 'zod';
import { ITool, ToolResult } from '../types.js';
const InputSchema = z.object({
param: z.string().describe('What this does'),
option: z.number().optional().describe('Optional setting'),
});
export const myTool: ITool = {
name: 'my_tool',
description: 'Does something useful',
inputSchema: InputSchema,
async execute(input: unknown): Promise<ToolResult> {
const args = InputSchema.parse(input);
// ... implementation
return {
content: [{ type: 'text', text: JSON.stringify(result) }],
};
},
};
2. Register with Server
// src/mcp/index.ts
import { myTool } from './tools/my-tool.js';
export function registerTools(server: McpServer, registry: IToolRegistry): void {
registry.register(myTool);
// ... other tools
}
3. Add Tests
// src/mcp/tools/my-tool.test.ts
describe('my_tool', () => {
it('should handle valid input', async () => {
const result = await myTool.execute({ param: 'test' });
expect(result.isError).toBeFalsy();
});
it('should reject invalid input', async () => {
await expect(myTool.execute({})).rejects.toThrow();
});
});
Configuration
mcp:
server:
name: nexus-agents
version: 2.2.0
capabilities:
tools: true
resources: true
prompts: true # 4 prompts registered
transport: stdio # stdio | tcp
# tcp:
# port: 3000
# host: localhost
tools:
orchestrate:
maxTurns: 10
timeout: 300000 # 5 minutes
workflow:
dryRunDefault: false
Client Request Timeouts — Long-Running Tools
The MCP TypeScript SDK ships with DEFAULT_REQUEST_TIMEOUT_MSEC = 60_000 (see @modelcontextprotocol/sdk → shared/protocol.ts). That is a client-side cap on how long the client will wait for a tool response. If a nexus-agents tool has a configured per-tool budget that exceeds this, the client will kill the request before the server-side deadline can fire.
Long-running tools (orchestrate, consensus_vote, run_workflow) have multi-minute budgets configured in MCP_TIMEOUTS.perTool (packages/nexus-agents/src/config/timeouts.ts). Callers MUST configure their client to:
-
Pass
options.onprogressin the SDKclient.request()call. The SDK only injects_meta.progressTokeninto the JSON-RPC payload when anonprogresshandler is supplied (shared/protocol.ts:643). WithoutprogressToken, the server’swithProgressHeartbeatemits notifications into a void. -
Set
options.resetTimeoutOnProgress: true. The SDK only resets the per-request timer when annotifications/progressarrives AND this flag is true (shared/protocol.ts:714). Default isfalse. -
OR raise
options.timeoutexplicitly to the tool’s worst-case budget (e.g.600_000forconsensus_vote,900_000fororchestrate). Sufficient if you don’t want streaming progress, but the request will block for that long.
// Example: invoking consensus_vote from a custom MCP client
const result = await client.request(
{ method: 'tools/call', params: { name: 'consensus_vote', arguments: { ... } } },
CallToolResultSchema,
{
timeout: 600_000,
resetTimeoutOnProgress: true,
onprogress: (notif) => console.log(`heartbeat #${notif.progress}`),
}
);
Async dispatch — dispatch: 'async' (#4968)
The alternative to a long blocking request is asynchronous dispatch. Every async-capable tool (orchestrate, consensus_vote, run_workflow, run_dev_pipeline, run_pipeline, run, pr_review, execute_spec, supply_chain_tradeoff_panel, run_graph_workflow) takes the same input, composed from packages/nexus-agents/src/mcp/tools/async-dispatch-input.ts:
| Input | Value | Effect |
|---|---|---|
dispatch (omitted) |
— | Run inline; the response is the result. |
dispatch: 'sync' |
— | Same as omitted. |
dispatch: 'async' |
— | Return { status: 'pending', jobId } immediately; poll get_. |
mode: 'sync' or mode: 'async' |
any async-capable tool | Rejected (-32602) with an error naming dispatch. The three deprecated aliases were removed in 9.0 (#6225). |
Liveness (#6162). The background body runs under the async-job-body runaway guard (3.6M ms by default, raisable to 7.2M — see CONFIGURATION.md). runAsJob hands the body a progress() heartbeat; each call stamps lastProgressAt on the pending record, which get_job_result and list_jobs return so a poller can tell slow from stuck (the field is absent when the body never sent one). Under a guard above the 3.6M MCP ceiling the heartbeat is required: a body silent for 3 intervals of guard / 8 is recorded failed with wedged (no progress for N ms) exactly that long after its last heartbeat (a lazy watchdog measured from the record) and its concurrency slot released before the guard fires. Every in-tree async body heartbeats — consensus_vote, pr_review and supply_chain_tradeoff_panel per settled seat; run_pipeline, run_dev_pipeline and run through the stage events their stages emit on the pipeline bus (attributed to the job by the async context they are emitted from); orchestrate per task-state stage transition and per Orchestrator-agent model call (withStep events on stepBus, same attribution); run_graph_workflow and execute_spec per graph event; run_workflow per settled phase. A custom body dispatched through runAsJob under such a guard must call progress() (or heartbeatJob(jobId)) or emit on the pipeline bus or stepBus; one that never does is wedged by definition.
Sending the removed alias alongside dispatch also fails validation, even when their values agree. run_dev_pipeline’s mode: 'autonomous' | 'harness' is an unrelated, real field and is unchanged.
Operator diagnostic
toSdkCallbackWithTimeoutCheck (packages/nexus-agents/src/mcp/middleware/tool-wrapper.ts) emits a WARN at invocation time when a tool’s configured budget exceeds the SDK default AND the request arrived without _meta.progressToken. The WARN names the tool, the configured budget, and the SDK default — operators can grep server logs to confirm whether a “tool timed out” failure is actually a client-config mismatch rather than a real CLI failure. (Source: audit on #2619 / #2631.)
[warn] MCP tool budget exceeds client default and no progressToken received — request likely to be killed by client before server-side deadline
tool=consensus_vote configuredTimeoutMs=600000 mcpSdkDefaultMs=60000
If you see this WARN on every long-running tool call, the client is misconfigured — fix the client, not the server timeout.
Correlation-keyed event log (#2703)
The WARN above is also recorded as one JSONL row per mismatch at $NEXUS_DATA_DIR/mcp-telemetry/timeout-mismatch-events.jsonl. Each row carries a correlation eventId that also appears in the WARN log entry’s context, so a mismatched call can be joined from server logs to the recorded outcome — not just counted in aggregate.
Schema (one JSON object per line):
| Field | Type | Notes |
|---|---|---|
eventId |
string (UUID) | Join key against the WARN log entry’s eventId field |
toolName |
string | E.g. consensus_, orchestrate |
configuredTimeoutMs |
number | Server-side budget that triggered the mismatch |
mcpSdkDefaultMs |
number | 60000 — the SDK client default the budget exceeds |
startedAt |
string (ISO 8601) | When the wrapper fired the WARN |
endedAt |
string (ISO 8601) | When the call returned or threw |
durationMs |
number | endedAt - startedAt |
outcome |
'success' | 'error' |
Whether the call’s eventual result was isError: true |
errorCategory |
string (optional) | From the post-#2649 envelope (_) |
errorMessage |
string (optional) | First text-content line or thrown error, truncated to 500c |
This is the data surface for Epic #2631’s “when this is worth doing” gate: “of mismatches, what fraction end in errorCategory: 'timeout'?” The aggregation belongs in improvement_review / a fitness report (out of scope for #2703).
Claude Desktop Integration
Setup
Add to .mcp.json in your project root (or use claude mcp add-json):
{
"mcpServers": {
"nexus-agents": {
"command": "nexus-agents",
"args": ["--mode=server"]
}
}
}
Verification
# Test MCP server starts correctly
nexus-agents --mode=server --verbose
# Check tool registration
# Look for: "Registered tool: orchestrate"
Source Files
| File | Purpose |
|---|---|
src/mcp/index.ts |
Server creation and setup |
src/mcp/tools/ |
Tool implementations |
src/mcp/resources/ |
Resource handlers |
src/mcp/types.ts |
Type definitions |
src/cli/cli-server.ts |
Server mode entry point |
Protocol Reference
- Specification: modelcontextprotocol.io
- SDK Version: @modelcontextprotocol/sdk@1.27.1
- Protocol Version: 2025-11-25
Related Documents
- Agent System: AGENT_SYSTEM.md
- Security: SECURITY.md
- Full Architecture: ARCHITECTURE.md
- API Reference: ENTRYPOINTS.md