zeph-mcp 0.22.4

MCP client with multi-server lifecycle and Qdrant tool registry for Zeph
Documentation

zeph-mcp

Crates.io docs.rs License: MIT OR Apache-2.0 MSRV

MCP client with multi-server lifecycle and Qdrant tool registry for Zeph.

Overview

Implements the Model Context Protocol client for Zeph, managing connections to multiple MCP servers, discovering their tools at startup, and routing tool calls through a unified executor. Built on rmcp 3.1.

Key Modules

  • client — low-level MCP transport and session handling; ToolListChangedHandler receives tools/list_changed notifications, applies sanitize_tools() (rate-limited to once per 5 s per server, capped at 100 tools), and forwards the sanitized list to McpManager via a refresh channel
  • managerMcpManager, McpTransport, ServerEntry for multi-server lifecycle; command allowlist validation (npx, uvx, node, python3, docker, mcpls, etc.), env var blocklist (LD_PRELOAD, DYLD_*, NODE_OPTIONS, etc.), and path separator rejection. Split into connect, call, ingest, retry, server, and builder submodules
  • sanitizesanitize_tools() applied to all tool definitions at registration time and again on every tools/list_changed refresh; strips the 27 shared zeph_common::patterns::RAW_INJECTION_PATTERNS, Unicode Cf-category characters, and caps descriptions at mcp.max_description_bytes (default 2048); fields triggering a pattern are replaced with "[sanitized]" — tool registration is never blocked
  • executorMcpToolExecutor bridging MCP tools into the ToolExecutor trait; propagates caller_id from sub-agent dispatches to the audit log and (when configured) validated images into ToolOutput.media
  • registryMcpToolRegistry for tool lookup and optional Qdrant-backed search
  • semantic_indexSemanticToolIndex for embedding-ranked tool discovery
  • pruningPruningCache, the per-message tool-set cache
  • oauth — OAuth 2.1 callback listener used by McpTransport::OAuth connections; binds the callback port before the browser flow starts, then awaits the ?code=…&state=… redirect
  • elicitationelicitation/create handling with a phishing-prevention header
  • roots — the roots/list handler
  • attestation / trust_scoreexpected_tools attestation and persistent per-server trust scoring
  • toolMcpTool wrapper with schema and metadata
  • prompt — MCP prompt template support
  • errorMcpError error types with typed McpErrorCode for retry classification (Transient, RateLimited, InvalidInput, AuthFailure, ServerError, NotFound, PolicyBlocked)

Startup auto-retry

When an MCP server fails to connect at startup, McpManager retries with exponential backoff: jitter(min(startup_retry_backoff_ms * 2^(attempt - 1), 8000 ms)). Jitter is full-jitter, AWS-style, over [nominal * 3/4, nominal], so concurrent servers do not reconnect in lockstep.

Field Type Default Description
max_connect_attempts u8 3 Connect attempts per server at startup. Must be in 1..=10; out-of-range values are rejected at parse time
startup_retry_backoff_ms u64 1000 Base delay before the first retry; doubles per attempt, capped at 8 s
[mcp]
max_connect_attempts     = 5
startup_retry_backoff_ms = 1000

[!NOTE] Both settings are global — there is no per-server override. Dynamic add_server calls retain single-attempt behaviour regardless of max_connect_attempts.

HTTP 4xx authentication errors (401, 403) are mapped to McpError::HttpAuth and are not retried — a permanent auth failure will not exhaust the retry budget.

[!TIP] Increase max_connect_attempts for servers that have slow cold-start times (e.g. Docker-based servers that pull images on first run).

MCP Roots protocol

The MCP client implements the roots/list handler, exposing configured project roots to MCP servers. Roots are declared per server via roots on a [[mcp.servers]] entry and passed to that server's connection at initialization time. Servers that support roots/list can use this information to scope their file system access to the declared directories.

[[mcp.servers]]
id      = "filesystem"
command = "npx"
args    = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]

[[mcp.servers.roots]]
uri  = "file:///workspace/myproject"
name = "project"

Semantic tool discovery

SemanticToolIndex indexes all registered MCP tool definitions as embedding vectors in Qdrant (or the SQLite vector backend). On each LLM turn, only the top-K most relevant tools — ranked by cosine similarity to the current query — are included in the tools array sent to the model. This keeps the tools payload small for models with narrow context windows and reduces prompt injection surface area.

Field Type Default Description
strategy "none" | "embedding" | "llm" "none" Discovery strategy. none passes every tool through
top_k usize 10 Top-scoring tools included per turn (embedding strategy)
min_similarity f32 0.2 Minimum cosine similarity for inclusion (embedding strategy)
embedding_provider provider name "" Name from [[llm.providers]]; empty = the agent's default embedding provider
always_include Vec<String> [] Tool names included regardless of score
min_tools_to_filter usize 10 Skip discovery entirely below this tool count
strict bool false Treat an embedding failure as a hard error instead of falling back to all tools
[mcp.tool_discovery]
strategy            = "embedding"
top_k               = 20
min_similarity      = 0.35
embedding_provider  = "fast"
min_tools_to_filter = 10

outputSchema forwarding

When mcp.forward_output_schema = true, Zeph appends a bounded "Expected output schema" hint derived from the MCP tool's outputSchema to the tool description sent to the LLM. This enables more accurate tool-result parsing and typed tool chaining. Schema content is sanitized through the injection pipeline; the hint is capped at mcp.output_schema_hint_bytes (default: 1024 bytes). The tool cache key covers both description and output_schema to prevent stale hits on server reconnects.

[mcp]
forward_output_schema    = true
output_schema_hint_bytes = 1024

[!NOTE] forward_output_schema is supported by Claude and OpenAI backends. Compatible, Gemini, and Ollama providers emit a WARN log when the setting is enabled, since those backends do not support structured output schemas.

Note:

The embedding strategy requires an embedding model — set embedding_provider to a name from [[llm.providers]], or leave it empty to use the agent's default embedding provider. With strict = false (the default) an embedding failure falls back to passing all tools through rather than failing the turn.

Per-message pruning cache

PruningCache tracks which tool set was sent in the previous LLM request. If the ranked tool list for the current turn is identical, the cache returns the pre-serialized JSON blob directly, skipping re-serialization and re-ranking.

Cache invalidation triggers on: new tool registered, tool removed, tools/list_changed notification, or config reload. No manual configuration is required; the cache is always active when [mcp.tool_discovery] enabled = true.

Tool attestation

expected_tools in a server config entry declares the tool names that server is authorised to expose. A tool appearing in tools/list that is not in expected_tools is logged as a security warning; for untrusted and sandboxed servers it is also filtered out of the registry. For trusted servers the warning is logged but the tool is kept.

[[mcp.servers]]
id             = "filesystem"
command        = "npx"
args           = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
expected_tools = ["read_file", "write_file", "list_directory"]

[!IMPORTANT] An empty (or omitted) expected_tools means attestation is skipped, not that all tools are blocked — every tool the server advertises is accepted. To restrict which tools a server may expose, use tool_allowlist together with a non-trusted trust_level.

McpManager also caches each server's tool fingerprints (Blake3 of name + description + input_schema) across reconnects. On the next connect or tools/list_changed refresh, a tool whose description or schema silently changed since the previous session logs a schema-drift ("rug-pull") warning — detection only, no automatic blocking.

Elicitation

MCP servers can request structured user input via the elicitation/create method. When enabled, Zeph presents a phishing-prevention header before displaying the server's form and routes the response back over a bounded channel.

Config field Type Default Description
elicitation_enabled bool false Enable elicitation globally (opt-in)
elicitation_timeout u64 (secs) 120 Seconds to wait for user input before timing out
elicitation_queue_capacity usize 16 Bounded channel capacity for pending elicitation requests
elicitation_warn_sensitive_fields bool true Warn when field names suggest sensitive input (password, token, key, etc.)

A per-server elicitation_enabled override takes precedence over the global setting. Sandboxed servers (trust level Sandboxed) can never use elicitation regardless of config.

[mcp]
elicitation_enabled = true
elicitation_timeout = 120

Security hardening

  • Tool collision detection — when two servers expose tools with the same sanitized_id, a warning is emitted at registration time. The first-registered tool wins.
  • Tool-list snapshot locking — set lock_tool_list = true under [mcp] to reject any tools/list_changed refresh after the initial snapshot. Prevents malicious servers from injecting new tools mid-session. This is a global switch, not a per-server field.
  • Per-server stdio env isolationenv_isolation = true (or default_env_isolation = true globally) strips the inherited process environment before spawning stdio MCP servers, preventing accidental secret leakage via PATH, HOME, and similar variables. Explicitly declared env keys are still passed through.
  • Intent-anchor nonce boundaries — tool output from MCP servers is wrapped with per-call nonce delimiters before entering the LLM context, reducing prompt injection surface.
  • Schema depth-cap dropping — both input_schema and output_schema are dropped to an empty object when a tool definition nests past MAX_SCHEMA_DEPTH (10 levels), closing an injection vector where a malicious server buries a payload too deep for pattern matching to reach. Each drop counts as an injection for trust-score purposes; the input_schemas_dropped/output_schemas_dropped counters are surfaced through ServerConnectOutcome/McpServerStatus into the TUI.
  • Bounded cross-reference regex cachename_referenced_in's per-tool-name regex caches are capped at 256 entries via lru::LruCache, so a server that rotates its advertised tool names cannot grow memory unbounded over the lifetime of a long-running daemon/gateway process.
[mcp]
default_env_isolation = true   # strip env for all stdio servers by default
lock_tool_list        = true   # reject tool list changes after startup (global)

[[mcp.servers]]
id            = "untrusted"
command       = "npx"
args          = ["-y", "some-mcp-server"]
trust_level   = "sandboxed"    # strict: only allowlisted tools are exposed
env_isolation = true           # explicit per-server override

Trust calibration

ServerTrustScore tracks a persistent per-server score in [0.0, 1.0], starting at 0.5 (neutral). Successful tool calls raise it; failures and injection detections lower it. recommended_trust_level() maps the current score onto an McpTrustLevel for runtime gating, and scores are persisted through TrustScoreStore so they survive agent restarts.

Decay is asymmetric: only scores above the 0.5 neutral point decay over time. A low-scoring server must earn trust back through successful calls — it cannot recover by waiting.

Field Type Default Description
enabled bool false Enable trust calibration (opt-in)
probe_on_connect bool true Run the pre-invocation probe on connect
monitor_invocations bool true Update trust scores from invocation outcomes
persist_scores bool true Persist scores to SQLite
decay_rate_per_day f64 Per-day decay applied to scores above 0.5
injection_penalty f64 Score penalty applied when injection is detected
verifier_provider provider name "" Optional LLM provider for trust verification; empty = disabled
[mcp.trust_calibration]
enabled             = true
probe_on_connect    = true
monitor_invocations = true
persist_scores      = true

Structured error codes

Every McpError::ToolCall carries a typed McpErrorCode that the agent uses to decide whether to retry:

Code Retryable When
Transient Yes Temporary failure; connection drops, timeouts
RateLimited Yes Server asked to back off
ServerError Yes Internal server error
InvalidInput No Bad parameters — retrying unchanged will fail again
AuthFailure No Token invalid or expired
NotFound No Tool or resource does not exist
PolicyBlocked No Blocked by policy rule or OAP authorization

Errors that do not carry an explicit code (timeouts, connection failures, SSRF blocks) are mapped automatically. McpErrorCode::is_retryable() is the authoritative retry gate used by the agent loop.

OAP authorization

Tool calls can be authorized declaratively via [tools.authorization] in config. Rules are appended after [tools.policy] rules using first-match-wins semantics. OAP is disabled by default.

[tools.authorization]
enabled = true

[[tools.authorization.rules]]
effect = "allow"
tool   = "read_file"

[[tools.authorization.rules]]
effect = "deny"
tool   = "shell"

Each rule carries a single tool glob and an effect of "allow" or "deny". Denied calls return McpErrorCode::PolicyBlocked and are not retried.

Tool call quota

Limit the total number of tool calls per agent session:

[tools]
max_tool_calls_per_session = 100   # None = unlimited (default)

Only the first attempt counts against the quota — retries of a failed call are free.

Configuration

[[mcp.servers]]
id = "filesystem"
command = "npx"
args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
env = {}

[[mcp.servers]]
id = "fetch"
command = "uvx"
args = ["mcp-server-fetch"]

Per-server trust_level governs SSRF validation and tool exposure:

trust_level SSRF Tool exposure
"trusted" Skipped — localhost and private IPs reachable All tools exposed
"untrusted" (default) Enforced Fails closed (zero tools) with no tool_allowlist, unless allow_untrusted_without_allowlist = true
"sandboxed" Enforced Only allowlisted tools; empty allowlist = no tools

[!CAUTION] trust_level = "trusted" is the only thing that bypasses SSRF validation — being declared statically in [[mcp.servers]] does not. Reserve it for operator-controlled servers you need to reach over localhost or a private IP.

MCP image passthrough

Servers with media_passthrough = true may return ContentBlock::Image blocks that are validated by zeph_sanitizer::MediaSanitizer and attached to ToolOutput.media as native image parts. Validation covers magic-byte vs declared-MIME agreement, a format allowlist, an encoded byte cap, and decoded dimension/pixel caps (decompression-bomb defense).

Global caps live under [mcp.media] and apply to every passthrough-enabled server:

Field Type Default Description
max_image_bytes usize 5242880 (5 MiB) Encoded size cap, checked before any decode
max_dimension_px u32 8192 Maximum width or height of the decoded image
max_pixels u64 64000000 Maximum total pixel count (width × height)
max_images_per_result usize 4 Images validated/attached per single tool result
max_images_per_turn usize 8 Images attached per turn across all tool calls in the batch
allowed_formats Vec<String> ["jpeg", "png", "gif", "webp"] Permitted image formats
[mcp.media]
max_image_bytes       = 5242880
max_images_per_result = 4
allowed_formats       = ["png", "jpeg"]

[[mcp.servers]]
id                = "screenshot"
command           = "uvx"
args              = ["mcp-server-screenshot"]
media_passthrough = true

[!IMPORTANT] Passthrough is off unless the server sets media_passthrough = true and a MediaSanitizer is attached. Servers at trust_level = "sandboxed" never pass images through, regardless of the flag. A rejected image is not fatal — the rendered text placeholder always remains as the fallback.

Features

Feature Default Description
sqlite yes SQLite backend (via zeph-db, zeph-memory, zeph-tools)
postgres no PostgreSQL backend
mock no Exposes MockMcpCaller for downstream tests
test-utils no Test utilities and testcontainers for PostgreSQL integration tests (implies postgres)
profiling no Extra tracing spans for latency profiling

Installation

cargo add zeph-mcp

Documentation

Full documentation: https://bug-ops.github.io/zeph/

License

Licensed under either of MIT or Apache License, Version 2.0 at your option.