MCP — Model Context Protocol¶
Orqest is both an MCP client (consume external tools from MCP servers) and an MCP server (expose Orqest's autonomy primitives — create_agent, run_agent, solve_goal — to Claude Desktop, Cursor, or any other MCP client). It also ships auto-discovery: when a tool is missing at runtime, Orqest can search MCP for a server advertising the capability, gate the request through a PermissionGate, and register the discovered tools transparently.
What problem does this solve?¶
MCP is the de-facto protocol for tool interoperability. An agent that integrates only with hand-coded tools is sealed off from the broader ecosystem; an agent that integrates with MCP can use any MCP-compliant capability without re-coding. Orqest wraps the MCP wire format in pydantic-ai-shaped Tool instances, so MCP capabilities slot into your existing agent runs with zero translation. Auto-discovery closes the last mile: when an LLM hallucinates a tool name, instead of failing, the framework finds and registers a real one — gated by an explicit security boundary.
The five primitives¶
| Primitive | Role |
|---|---|
MCPServerManager |
Client — manage connections to multiple MCP servers |
MCPToolAdapter |
Bridge — adapt MCP tool definitions into pydantic-ai Tool instances |
MCPDiscovery |
Search — find MCP servers that advertise a capability |
DiscoveryHook |
Opportunistic — auto-discover on "tool not found" errors |
PermissionGate |
Security boundary — gates which tool names may be auto-registered |
Plus create_orqest_server for exposing Orqest itself as an MCP server.
MCPServerManager — client¶
The lifecycle owner. Connects to multiple MCP servers, exposes their tools as a flat list of pydantic-ai Tool instances.
import asyncio
from orqest.mcp import MCPConfig, MCPServerConfig, MCPServerManager
async def main():
config = MCPConfig(
servers=[
MCPServerConfig(
name="filesystem",
command="npx",
args=["-y", "@modelcontextprotocol/server-filesystem", "/tmp/sandbox"],
),
MCPServerConfig(
name="github",
command="npx",
args=["-y", "@modelcontextprotocol/server-github"],
env={"GITHUB_TOKEN": "ghp_..."},
),
],
)
async with MCPServerManager(config) as manager:
tools = manager.get_all_tools() # list[pydantic_ai.Tool]
# Pass to your agent
agent = MyAgent(..., tools=tools)
await agent.run(state)
asyncio.run(main())
MCPServerManager is an async context manager. On __aenter__ it connect_all()s each server; on __aexit__ it disconnects cleanly.
For single-server access without the multi-server manager:
from orqest.mcp import MCPConnection, MCPServerConfig
async with MCPConnection(MCPServerConfig(name="fs", command="...", args=[...])) as conn:
for tool in conn.tools:
...
MCPToolAdapter — bridging¶
Converts MCP tool definitions to pydantic-ai Tool instances. The adapter wraps the tool callable in graceful error-string return semantics — when the underlying MCP server raises, the agent sees a string error rather than a crash.
from orqest.mcp import MCPToolAdapter
adapter = MCPToolAdapter(connection)
tool = adapter.adapt(mcp_tool_def) # single tool
tools = adapter.adapt_many(connection.tools) # batch
In practice, MCPServerManager.get_all_tools() already does the adaptation — you rarely call the adapter directly.
MCPDiscovery — search¶
Finds MCP servers that advertise a given capability. search() composes two sources:
- Well-known manifests — base URLs passed as
well_known_urlsare probed for/.well-known/mcp.json(explicitly configured, so tried first) - Online registry — the configured registry endpoints are queried by capability
Preview — live registries
The well-known probe and registry query are wired. What remains preview: the registry response-shape parsing is untested against live registries, and there is no search-engine fallback.
from orqest.mcp import MCPDiscovery
discovery = MCPDiscovery()
results = await discovery.search("compute_p95_latency", max_results=5)
for server in results:
print(f"{server.name}: {server.command} {server.args}")
Results are DiscoveredServer records — enough metadata to instantiate an MCPServerConfig and connect.
PermissionGate — the security boundary¶
A remote MCP server is a code-execution surface. Auto-registering a tool from an arbitrary server is a security risk. The gate is the explicit "yes" boundary, default-deny.
from orqest.mcp import AllowAll, AllowList, DenyAll
# Deny every discovery (default)
gate = DenyAll()
# Permit any tool name — DEVELOPMENT ONLY
gate = AllowAll()
# Production: regex allowlist (re.search semantics; anchor with ^…$ for full match)
gate = AllowList([
r"^web\.", # web.search, web.fetch
r"^fs\.", # fs.read, fs.write
r"^compute_p\d+_latency",
])
PermissionGate is a Protocol — you can plug in your own gate (per-user, per-tenant, time-bounded, audit-logged) by implementing async def allow(self, tool_name: str) -> bool.
Auto-discovery — two integration paths¶
Deliberate: ToolRegistry.get_or_discover¶
Called by code that knows it needs a tool right now. Common pattern: tool resolution at agent-spawn time.
from orqest.autonomy import ToolRegistry
from orqest.mcp import MCPDiscovery, MCPServerManager, AllowList
registry = ToolRegistry()
tool = await registry.get_or_discover(
"compute_p95_latency",
discovery=MCPDiscovery(),
manager=MCPServerManager(MCPConfig(servers=[])),
permission=AllowList([r"^compute_"]),
audit_bus=workbench.event_bus,
max_servers=3,
)
Returns the registered Tool on hit, None on miss. The gate is consulted before any network call.
Opportunistic: DiscoveryHook¶
Catches runtime "tool not found" errors raised by hallucinating LLMs and recovers via discovery. Wire on a HookRunner.
from orqest.mcp import DiscoveryHook, AllowList
hook = DiscoveryHook(
registry=registry,
discovery=MCPDiscovery(),
manager=manager,
permission=AllowList([r"^web\.", r"^fs\."]),
audit_bus=workbench.event_bus,
)
runner = HookRunner(hooks=[hook])
# Pass runner to your CompoundTool / SubAgentTool / agent
When an LLM fabricates a tool call to web.scrape_table (which doesn't exist locally), DiscoveryHook.on_error fires:
- Detects "tool not found" pattern in the error message
- Asks
MCPDiscoveryto search forweb.scrape_table - Gate consulted —
AllowListmatches^web\., permits MCPServerManagerconnects,MCPToolAdapteradapts,ToolRegistryregisters- Returns
Redirect(new_tool="web.scrape_table")— the caller retries with the now-registered tool
If the gate denies, discovery fails, or the error wasn't a tool-not-found, returns Continue() (no recovery).
Audit events¶
All discovery flows emit typed events on the EventBus when an audit_bus is provided:
| Event | When |
|---|---|
discovery.requested |
A missing tool triggered discovery |
discovery.connected |
A discovered tool was registered (one per tool) |
discovery.denied |
PermissionGate rejected the request |
discovery.failed |
Discovery succeeded conceptually but registration failed |
These flow through the same bus the rest of the cognitive substrate uses — subscribe handlers that forward to your existing observability layer (structlog / OpenTelemetry / etc.).
Orqest as an MCP server¶
create_orqest_server exposes Orqest itself as a FastMCP server. The exposed tools:
create_agent(spec_json)— accept anAgentSpecand instantiate aDynamicAgentrun_agent(name, input)— invoke a previously-created agentsolve_goal(goal)— run theMetaOrchestratorlist_agents()— enumerate the registered roster
from orqest.autonomy import AgentFactory, MetaOrchestrator, ToolRegistry
from orqest.mcp import create_orqest_server
registry = ToolRegistry()
factory = AgentFactory(registry=registry, default_model="openai:gpt-4.1", api_key="sk-...")
meta = MetaOrchestrator(planner=..., factory=factory, registry=registry)
server = create_orqest_server(
factory=factory,
registry=registry,
meta=meta,
default_model="openai:gpt-4.1",
api_key="sk-...",
)
# Run as a FastMCP server (stdio transport for Claude Desktop / Cursor)
# python -m orqest.mcp.server
Mount in Claude Desktop / Cursor by adding to the MCP config:
Auto-discovery scans the standard config locations: ~/.claude.json, ~/.claude/claude.json, ~/.config/Claude/claude_desktop_config.json.
Best practices¶
- Default
PermissionGate.DenyAll. Auto-discovery is off by default; consumers opt in withAllowList(...)orAllowAll()for dev. Don't shipAllowAllin production. - Anchor regex patterns.
re.searchsemantics meansr"web"matchesweb.fetchANDcobwebs.compute. Anchor with^...$(or at least^...). - Audit everything. Pass
audit_bus=workbench.event_busso denied/failed discoveries surface in your logs. Silent denials are the worst kind of bug. - Use the deliberate path when you can.
ToolRegistry.get_or_discoveris the predictable path;DiscoveryHookis the safety net for hallucinations. Don't lean on the safety net as a primary discovery strategy. MCPServerManageris a context manager. Useasync with; don't callconnect_all/disconnectmanually unless you have a reason.
Pitfalls¶
- Don't expose Orqest as an MCP server with
AllowAllon the consumer side. That's a "remote code execution as a service" configuration. Paircreate_orqest_serverwith proper auth on the transport. - Don't share
MCPServerManageracross processes. Each connection holds OS-level handles; cross-process sharing breaks cleanup semantics. - Don't trust the tool description from a discovered MCP server. The
descriptionis what the LLM reads to decide whether to call the tool — a malicious server can advertise misleading descriptions. CombinePermissionGatewith name-based allowlisting; consider description-validation hooks for high-trust deployments. - Don't auto-register dynamic-discovery into the spawned-agent roster. A
DynamicAgentspawned by the orchestrator should reference tools from the static registry the consumer wired; allowing everyDynamicAgentto discover at will defeats the audit trail. Discovery is a registry-level decision, not a per-agent one.
What's happening under the hood¶
MCPServerManager.connect_all()spawns each server's transport (stdio subprocess or HTTP), establishes the MCP handshake, and inventories toolsMCPToolAdapter.adapt[_many]wraps each MCP tool'scallin a coroutine that catchesMCPErrorand returns a string-shaped error result (preserves agent flow)ToolRegistry.get_or_discoveron miss: emitdiscovery.requested, search viaMCPDiscovery, gate viaPermissionGate.allow, connect viaMCPServerManager, adapt + register, emitdiscovery.connectedper toolDiscoveryHook.on_errorshort-circuits toContinueunless the error matches a "tool not found" string fragment; otherwise routes to the same registry path as the deliberate flowcreate_orqest_serverbuilds a FastMCP server whose tools are bound to the suppliedfactory/registry/meta; FastMCP handles the JSON-RPC wire format
Related Concepts¶
- Autonomy —
ToolRegistryis the substrate MCP integrates with - Hooks & Lifecycle —
DiscoveryHookreturnsHookDecisions throughHookRunner - Self-Healing —
DiscoveryHookrecovers from "tool not found" errors via the discovery flow - Observability —
discovery.*audit events flow on theEventBus