MCP agent protocol
The MCP (Model Context Protocol) provider enables external agents to participate in NSED deliberation with full tool access — reading past proposals, searching history, updating scratchpad — before submitting their result. Unlike the exec provider (one-shot stdin/stdout), MCP agents get a bidirectional tool-calling channel.
Hybrid Protocol
The MCP provider uses a hybrid stdin-push + MCP approach:
- Context push: The
AgentContextJSON envelope is written to stdin as a single line (same format as exec provider) - MCP session: The same stdin/stdout pipes then carry MCP JSON-RPC messages for tool calls and submission
This ensures context is immediately available without requiring a tool call, while MCP provides the tool-calling channel.
sequenceDiagram
participant O as Orchestrator
participant N as NATS
participant W as NatsNsedWorker
participant M as McpAgent
participant P as External Process (MCP Client)
O->>N: publish task
N->>W: deliver task
W->>M: propose(ctx) / evaluate(ctx)
M->>P: spawn process
M->>P: write AgentContext JSON line to stdin
M->>P: start MCP server on same stdin/stdout
P->>P: read initial context from stdin
P->>M: MCP initialize
M->>P: server info + tool list
P->>M: nsed_read_proposal (optional research)
M->>P: proposal content
P->>M: nsed_search (optional research)
M->>P: search results
P->>M: nsed_propose / nsed_evaluate (terminal)
M->>P: success confirmation
M->>W: Proposal / Vec<Evaluation>
W->>N: publish result
N->>O: deliver resultStdin Envelope
The first line written to the subprocess stdin is a JSON object identical to the exec provider envelope:
{
"phase": "propose",
"context": {
"task_description": "Design an authentication system",
"round_number": 1,
"total_rounds": 3,
"phase": "Proposing",
"candidates": [],
"previous_own_proposal": null,
"previous_critiques": [],
"user_injections": [],
"phase_budget_remaining_secs": 120.0,
"session_id": "abc123"
}
}
After this line, the same stdin/stdout carry MCP JSON-RPC messages. The subprocess should:
- Read and parse the first line as the context envelope
- Begin the MCP handshake on the same pipes
MCP Tools
The NSED MCP server exposes the following tools:
Terminal Tools (exactly one must be called)
| Tool | Phase | Description |
|---|---|---|
nsed_propose |
propose | Submit a proposal. Ends the phase. |
nsed_evaluate |
evaluate | Submit evaluations. Ends the phase. |
Research Tools (optional, call as needed)
| Tool | Description |
|---|---|
nsed_get_context |
Refresh the deliberation context (also pushed via stdin) |
nsed_read_proposal |
Read a proposal from a previous round by agent ID |
nsed_read_critiques |
Read evaluation feedback from evaluators |
nsed_search |
Full-text search across deliberation history |
nsed_update_scratchpad |
Write to persistent cross-round memory |
Tool Schemas
nsed_propose
Default schema:
{
"thought_process": "string — your reasoning and analysis",
"content": "string — the actual proposal"
}
The advertised input_schema is overridden per-instance when a before_prompt
middleware declares a proposal_schema (see
middleware-declared schema):
NsedMcpServer::list_tools replaces nsed_propose's schema with the declared one
during the propose phase, so the agent is told the exact required shape (e.g.
{rationale, ops}). The handler accepts either shape — the default
{thought_process, content}, a structured content, or the whole declared
envelope (extra fields are forwarded verbatim as the proposal content). An empty
submission (no content and no envelope fields) is rejected so the agent retries.
nsed_evaluate
{
"evaluations": [
{
"target_id": "string — candidate ID being evaluated",
"score": 0.85,
"justification": "string — brief reasoning for the score",
"stance": "strong_agree | agree | neutral | disagree | strong_disagree (optional)",
"is_final_solution": false,
"claim_assessments": [
{
"claim_id": "string — 6-char hex ID for cross-round tracking (optional)",
"cite": "string — the assessed claim, quoted VERBATIM from the proposal (an exact substring). Aliases: claim, quote, text. Common wrappers (\"…\", > …, `…`, Label: \"…\") are stripped; the quote is then resolved and replaced with the exact proposal span so the client can locate it. A quote matching NO span is REJECTED and nsed_evaluate must be re-submitted with a corrected quote.",
"verdict": "verified | contested | unverified | wrong",
"reason": "string — reasoning for the verdict (optional)"
}
],
"disagreements": [
{
"claim_id": "string — references a claim_id above (optional)",
"proposal_claims": "string — what the proposal claims",
"evaluator_position": "string — the evaluator's counter-position",
"confidence": "high | medium | low"
}
],
"category_scores": {
"correctness": 85.0,
"completeness": 70.0,
"novelty": 60.0,
"feasibility": 90.0,
"evidence_quality": 75.0,
"conciseness": 40.0
}
}
]
}
Score range: 0.0 (worst) to 1.0 (best). All fields beyond target_id, score, and justification are optional — agents can submit minimal evaluations or the full structured analysis.
The structured evaluation fields align with the NSED Vector Alignment protocol used by native LLM agents:
| Field | Description |
|---|---|
stance |
Overall evaluator position toward the proposal |
is_final_solution |
Whether this proposal is viable as a final answer |
claim_assessments |
Assessment of key claims with verdicts (verified/contested/unverified/wrong). Each cite must be an exact verbatim quote from the proposal — matched against the final solution and the shown thought-process window — or the evaluation is rejected for re-quoting. |
disagreements |
Specific disagreement points with counter-positions |
category_scores |
Per-category signed quality scores, −100 to +100. Negative undermines the proposal, positive supports it |
nsed_read_proposal
{
"agent_id": "string — author's agent ID, OR the anonymized Candidate_X label (required)",
"round": 1,
"offset": 0,
"limit": 5000
}
During evaluation, proposals are presented anonymized — an evaluator sees
Candidate_A, Candidate_B, … rather than real author IDs. Pass that same
Candidate_X label as agent_id; the tool resolves it against the current
round's candidate set. (Real author IDs still work for prior, non-anonymized
rounds.)
nsed_read_critiques
{
"round": 1,
"agent_id": "string — filter by evaluator (optional)"
}
nsed_search
{
"query": "string — free-text search",
"round": 1,
"agent_id": "string — filter by agent (optional)"
}
nsed_update_scratchpad
{
"content": "string — replaces current scratchpad content"
}
Phase-Aware Tool Filtering
During the propose phase, only nsed_propose is available as a terminal tool. During the evaluate phase, only nsed_evaluate is available. Calling the wrong terminal tool returns an error message (not a protocol error).
YAML Configuration
providers:
mcp_local:
type: mcp
agents:
- name: PYTHON_MCP_AGENT
provider_id: mcp_local
model_name: custom
mcp:
command: ["python3", "agents/mcp_agent.py"]
# working_dir: "/opt/agents"
# timeout_secs: 60
# env:
# OPENAI_API_KEY: "sk-..."
McpProviderConfig Fields
| Field | Type | Default | Description |
|---|---|---|---|
command |
Vec<String> |
required | Command and arguments. First element is the binary. |
working_dir |
Option<String> |
cwd | Working directory for the subprocess. |
env |
Map<String, String> |
{} |
Extra environment variables (additive). |
timeout_secs |
Option<u64> |
budget/300s | Hard timeout. Falls back to phase budget, then 300s. |
Environment Variables
In addition to the env map from config, the MCP provider injects these environment variables into the subprocess:
| Variable | Description | Example |
|---|---|---|
NSED_SESSION_ID |
Deliberation session ID (for stateful agents) | abc123 |
NSED_AGENT_NAME |
This agent's name | RESEARCH_AGENT |
NSED_ROUND |
Current round number | 2 |
NSED_PHASE |
Current phase (propose or evaluate) |
propose |
These enable stateful agents like Claude CLI to maintain session continuity:
mcp:
command: ["claude", "--session-id", "${NSED_SESSION_ID}", "--mcp-config", "tools.json"]
Timeout Behavior
The effective timeout follows this priority:
timeout_secsfrom config (if set)phase_budget_remaining_secsfrom the agent context (rounded up, minimum 1s)- 300 seconds (default fallback)
If the subprocess doesn't call a terminal tool within the timeout, the process is killed and the phase fails.
Claude CLI Provider
The claude provider is a specialized wrapper around the MCP protocol that automatically constructs Claude CLI flags from AgentConfig fields. It uses the same hybrid stdin+MCP protocol under the hood.
YAML Configuration
providers:
claude_cli:
type: claude
agents:
- name: CLAUDE_REVIEWER
provider_id: claude_cli
model_name: sonnet
persona: "You are a security-focused code reviewer"
system_prompt_override: "Review all proposals for security vulnerabilities"
claude:
permission_mode: bypassPermissions
max_budget_usd: 0.50
mcp_config: ["./nsed-tools.json"]
allowed_tools: ["Read", "Grep", "Bash(git:*)"]
context_files: ["docs/architecture.md", "specs/api-contract.json"]
extra_args: ["--verbose"]
AgentConfig → Claude CLI Flag Mapping
| AgentConfig Field | Claude CLI Flag | Notes |
|---|---|---|
model_name |
--model |
Skipped if "custom" |
system_prompt_override |
--system-prompt |
Full system prompt replacement |
persona |
--append-system-prompt |
Appended to default system prompt |
session_id (from context) |
--session-id (round 1) / --resume (round 2+) |
Conversation persistence |
| — | --print |
Always set (non-interactive mode) |
| — | --output-format json |
Always set (structured output) |
ClaudeProviderConfig Fields
| Field | Type | Default | Description |
|---|---|---|---|
model |
Option<String> |
AgentConfig.model_name |
Model override (e.g. "opus") |
working_dir |
Option<String> |
cwd | Working directory |
env |
Map<String, String> |
{} |
Extra env vars |
timeout_secs |
Option<u64> |
budget/600s | Hard timeout |
permission_mode |
String |
"bypassPermissions" |
--permission-mode value |
max_budget_usd |
Option<f64> |
— | --max-budget-usd per phase |
mcp_config |
Vec<String> |
[] |
Paths to MCP config JSON files |
allowed_tools |
Vec<String> |
[] |
--allowed-tools filter |
context_files |
Vec<String> |
[] |
Files injected into system prompt (see below) |
add_dirs |
Vec<String> |
[] |
Directories for Claude tool access (--add-dir) |
disallowed_tools |
Vec<String> |
[] |
Additional --disallowed-tools entries |
writable |
bool |
false |
Allow Write/Edit/NotebookEdit tools. Read-only by default. |
agents |
Map<String, ClaudeSubAgentDef> |
{} |
Sub-agent definitions (--agents). See below. |
extra_args |
Vec<String> |
[] |
Additional CLI flags |
Context Files
The context_files field allows injecting file contents into Claude's system prompt per agent. Each file is read by NSED at invocation time and inlined as a <context_file> block via --append-system-prompt. No directory access is granted — use add_dirs to explicitly allow Claude tool access to directories.
claude:
context_files:
- docs/architecture.md # relative to working_dir
- /abs/path/to/spec.json # absolute paths also work
This is useful for giving each agent domain-specific knowledge:
agents:
- name: SECURITY_REVIEWER
provider_id: claude_cli
model_name: opus
claude:
context_files: ["docs/security-policy.md", "specs/auth-flow.md"]
- name: PERFORMANCE_REVIEWER
provider_id: claude_cli
model_name: sonnet
claude:
context_files: ["docs/perf-baselines.md", "benchmarks/latest.json"]
Missing files are silently skipped with a warning log. Relative paths resolve from working_dir (if set), otherwise from the current directory.
Directory Access (add_dirs)
Grant Claude Read tool access to specific directories:
claude:
add_dirs:
- ./src # relative to working_dir
- /data/shared # absolute path
Security defaults:
- Read-only by default:
writable: falseauto-injects--disallowed-tools Write,Edit,NotebookEdit. Setwritable: trueto allow file modifications. - Sandbox isolation: When no
add_dirsare configured, NSED injects--add-dir /tmp/nsed_claude_sandbox(an empty directory) to prevent Claude from accessing the working directory. Without this, Claude'sbypassPermissionsmode grants full CWD access. disallowed_tools: Merges with the read-only defaults. Specify additional tools to block (e.g.,["Bash"]).
Sub-Agents (agents)
Define Claude sub-agents that the primary agent can delegate to. Each sub-agent runs in its own context window with a custom system prompt, specific tool access, and independent permissions. See Claude Code sub-agents docs.
claude:
agents:
researcher:
description: "Searches technical documentation"
prompt: "You research topics thoroughly and return structured findings"
tools: ["Read", "Grep", "Glob", "Bash"]
model: haiku
maxTurns: 10
effort: medium
fact_checker:
description: "Verifies claims against source material"
prompt: "You verify factual claims and flag inaccuracies"
tools: ["Read", "Grep"]
disallowedTools: ["Write", "Edit"]
permissionMode: dontAsk
db_analyst:
description: "Executes read-only database queries"
prompt: "You are a data analyst. Execute SELECT queries only."
tools: ["Bash"]
background: true
isolation: worktree
memory: project
skills: ["sql-patterns"]
mcpServers:
- github
- playwright:
type: stdio
command: npx
args: ["-y", "@playwright/mcp@latest"]
ClaudeSubAgentDef fields
| Field | Type | Required | Description |
|---|---|---|---|
description |
String |
Yes | When Claude should delegate to this sub-agent |
prompt |
String |
Yes | System prompt (the sub-agent's instructions) |
tools |
Vec<String> |
No | Tool allowlist. Inherits all if omitted |
disallowedTools |
Vec<String> |
No | Tool denylist, removed from inherited/allowed |
model |
String |
No | "sonnet", "opus", "haiku", "inherit", or full model ID |
permissionMode |
String |
No | "default", "acceptEdits", "dontAsk", "bypassPermissions", "plan" |
maxTurns |
u32 |
No | Maximum agentic turns before the sub-agent stops |
mcpServers |
Vec<Value> |
No | MCP servers: string references or inline { "name": { config } } |
effort |
String |
No | "low", "medium", "high", "max" (Opus only) |
background |
bool |
No | Run concurrent with main conversation |
isolation |
String |
No | "worktree" for isolated git worktree |
memory |
String |
No | Persistent memory: "user", "project", or "local" |
skills |
Vec<String> |
No | Skills to preload into context |
initialPrompt |
String |
No | Auto-submitted first turn when running as main agent |
This maps to --agents '<JSON>', enabling Claude to spawn specialized sub-agents during deliberation.
Comparison: Exec vs MCP vs Claude
| Feature | Exec | MCP | Claude |
|---|---|---|---|
| Context delivery | stdin JSON (one-shot) | stdin JSON push + nsed_get_context |
stdin push + nsed_get_context |
| Response delivery | stdout JSON | nsed_propose / nsed_evaluate |
nsed_propose / nsed_evaluate |
| Tool access | None | Full (read proposals, search, scratchpad) | Full + Claude's built-in tools |
| Session persistence | None | Via env vars | --session-id / --resume |
| Configuration | Manual command | Manual command | Auto-built from AgentConfig |
| Complexity | Simple | Requires MCP client library | Zero-code (YAML only) |
| Best for | Simple scripts, CLI tools | Custom LLM agents | Claude as a deliberation agent |
Example: Python MCP Client
See examples/mcp_agent.py for a complete reference implementation using the Python mcp package.
Minimal structure:
#!/usr/bin/env python3
import asyncio, json, sys
import anyio
from mcp.client.session import ClientSession
from mcp.shared.message import SessionMessage
from mcp.types import JSONRPCMessage
async def main():
loop = asyncio.get_event_loop()
# Step 1: Read initial context from stdin
first_line = await loop.run_in_executor(None, sys.stdin.readline)
envelope = json.loads(first_line)
context = envelope["context"]
phase = envelope["phase"]
# Step 2: Set up MCP client on same stdin/stdout
# (see full example for stream bridging code)
# Step 3: Use MCP tools
async with ClientSession(...) as session:
await session.initialize()
if phase == "propose":
# Optional: research
# await session.call_tool("nsed_search", {"query": "..."})
await session.call_tool("nsed_propose", {
"thought_process": "My reasoning...",
"content": "My proposal...",
})
if __name__ == "__main__":
asyncio.run(main())
In-Process HTTP MCP Server
The claude provider runs NsedMcpServer as an in-process HTTP server on localhost. Claude CLI connects to it via "type": "http" in --mcp-config, giving it access to all 7 deliberation tools. Results flow back through an in-process channel — no temp files, no subprocess.
sequenceDiagram
participant CA as ClaudeAgent
participant H as HTTP MCP Server<br/>(in-process)
participant CL as Claude CLI
CA->>H: start_http_mcp_server(ctx, phase)
Note over H: Binds 127.0.0.1:0 (OS picks port)
CA->>CL: Spawn with --mcp-config {"type":"http","url":"..."}
CL->>H: MCP initialize (HTTP)
H->>CL: ServerInfo (all 7 tools, phase-filtered)
CL->>H: nsed_get_context
H->>CL: AgentContext JSON
CL->>H: nsed_propose / nsed_evaluate (terminal)
H-->>CA: McpResult via oneshot channel
Note over CA: cancel token shuts down HTTP serverHow It Works
ClaudeAgent::start_http_mcp_server()binds a TCP listener on127.0.0.1:0(OS-assigned port)NsedMcpServeris served via rmcp'sStreamableHttpServicewithLocalSessionManager(stateful mode)- A
SharedMcpState(behindArc) shares theAgentContextand aoneshot::Sender<McpResult>across server instances write_mcp_config_http(port)generates a temp config file for Claude CLI- When the agent calls a terminal tool (
nsed_propose/nsed_evaluate), the result is sent through the oneshot channel ClaudeAgentcancels theCancellationTokento shut down the HTTP server after receiving the result
MCP Config Format
The generated --mcp-config uses HTTP transport:
{
"mcpServers": {
"nsed": {
"type": "http",
"url": "http://127.0.0.1:{port}/mcp"
}
}
}
No command, args, or env fields — Claude CLI connects to an already-running server.
Error Handling
| Scenario | Behavior |
|---|---|
| Subprocess exits without calling terminal tool | Error: "terminal tool channel closed without result" |
| Subprocess times out | Process killed, error: "timed out after Ns" |
| Wrong terminal tool for phase | Error message returned via MCP (not protocol error) |
| Subprocess fails to start | Error: "failed to spawn" |
| Empty command | Error: "command is empty" |