MCP Servers
UGENT connects to Model Context Protocol (MCP) servers to extend the agent with external tools, search engines, code analyzers, and more.
Enabling MCP
[mcp]
auto_connect = trueWhen auto_connect is enabled, UGENT connects to all configured MCP servers on startup and discovers their tools with tools/list.
Transport Types
HTTP (Streamable HTTP)
[mcp.services.exa]
enabled = true
max_inflight_calls = 1
[mcp.services.exa.transport]
type = "http"
url = "https://mcp.exa.ai/mcp?exaApiKey=YOUR_KEY"
[mcp.services.exa.transport.options]
allow_stateless = true
connect_timeout_secs = 30
request_timeout_secs = 60
reinit_on_expired_session = truestdio (Local Process)
[mcp.services.filesystem]
enabled = true
max_inflight_calls = 1
[mcp.services.filesystem.transport]
type = "stdio"
command = "npx"
args = ["-y", "@modelcontextprotocol/server-filesystem", "/srv/data"]Unix Socket (Unix only)
[mcp.services.daemon-mcp]
enabled = true
[mcp.services.daemon-mcp.transport]
type = "unix_socket"
socket_path = "/tmp/mcp.sock"
uri = "http://localhost/mcp"The context engine
The context engine serves MCP itself, over HTTP. It provides semantic code search, knowledge graph traversal, database questions, and durable memory.
There is no separate bridge process to install or keep running: point the client at the engine's endpoint and authenticate with a tenant API key.
[mcp.services.ugent-context]
enabled = true
max_inflight_calls = 4
token_ref = "@context_engine_token"
[mcp.services.ugent-context.transport]
type = "http"
url = "https://<your-data-plane>/rpc"POST /mcp is served as an alias for /rpc and behaves identically, so a client whose documentation assumes /mcp works unchanged. /rpc is canonical.
Adding it from the command line
ugent mcp add ugent-context --url https://<your-data-plane>/rpc \
--token-ref @context_engine_token--token-ref stores a vault handle resolved at connect time, so the key never lands in ugent.toml. Pass a literal instead and the command warns, naming the file it is about to be written into.
ugent mcp list # what is configured, and where each points
ugent mcp test ugent-context # connect and print the tools it advertises
ugent mcp remove ugent-contextugent mcp add handles HTTP services; stdio servers carry no credential and are configured with /mcp add in the REPL. Writing the config re-serializes the whole file, so comments in a hand-maintained ugent.toml are lost — the command says so on each write.
Other clients
The engine is a plain Streamable HTTP MCP server, so the stock mcp add command of each client works unmodified.
Claude Code:
claude mcp add --transport http ugent-context https://<your-data-plane>/rpc \
--header "Authorization: Bearer ugctx_..."Verify with claude mcp list, which reports the server as ✔ Connected.
Codex:
export UGENT_CONTEXT_KEY='ugctx_...'
codex mcp add ugent-context --url https://<your-data-plane>/rpc \
--bearer-token-env-var UGENT_CONTEXT_KEYCodex takes the token from an environment variable rather than a literal, so it never reaches ~/.codex/config.toml. It is read when Codex runs, not when the server was added, so put the export in a shell profile or the next terminal has no credential. Verify with codex mcp list.
If Codex refuses to start after adding a URL
Older builds wrote a bare url key that the loader then rejected with url is not supported for stdio, which breaks every codex command until the [mcp_servers.*] block is deleted by hand (openai/codex#32300). Fixed by 0.153.4. On an older build, either upgrade or enable the rmcp client with codex mcp add --enable rmcp_client ....
For a tenant deployment, the console's Connect MCP page mints the key and renders each of these with the key already in it. See the tenant guide.
Renaming a service is not free
Tool names are built as mcp_{service}_{tool}, so changing a service's name changes every tool it exposes. Anything that referred to the old names — [tokens.compaction] unprojected_tool_names, routing tool_name entries, firewall pins — silently stops matching. Rename deliberately, or leave an existing service under the name it already has.
Available Tools
| Tool | Description |
|---|---|
workspace_search | Hybrid code and document search with snippet, structure, and synthesis modes. Returns ranked snippets with line numbers. |
workspace_overview | Discover all indexed codebases and view graph statistics (nodes, edges, breakdown by kind). With codebase_id plus aspects, also returns an architecture report for one workspace: languages, packages, boundaries, layers, hotspots, clusters, and opt-in cycles. Replaces the former graph_stats and graph_list_codebases. |
graph_search | Search the knowledge graph for code entities by name. Returns node IDs for chaining into graph_neighbors. |
graph_neighbors | Explore callers, callees, imports, containers, and references around any node. |
database_query | Ask data questions in plain English. Generates safe, read-only SQL grounded on auto-profiled table statistics. |
memory_recall | Retrieve durable facts and preferences with hybrid lexical plus vector recall. |
memory_store | Create or update a self-contained fact, preference, or event summary. |
memory_forget | Delete memory records by id or filter. Filters combine with AND; an empty selector is rejected. |
memory_ingest | Queue conversation transcripts for async memory creation with optional LLM fact extraction. |
memory_ingest_status | Poll an ingest job for its status and the records it created. |
The five memory_* tools appear in tools/list only when the engine has [memory].mcp_enabled = true; a key holding the memory:* scopes can call them regardless. See the tenant guide for the scope each tool needs.
Routing Enrichment
MCP tools can be used as routing enrichment providers. When a message arrives, the routing framework automatically searches the codebase and injects relevant context before the LLM responds:
# routing.toml
[[providers]]
name = "deep-context"
type = "tool"
[[providers.tools]]
tool_name = "mcp_ugent-context_workspace_search"
input_mode = "query_passthrough"
summary_format = "search"
default_params = { max_results = 12, snippet_lines = 20 }Custom Headers
Some MCP servers require authentication headers:
[mcp.services.my-service.headers]
Authorization = "Bearer your-token"
X-Custom-Header = "value"Keep the token out of the file
token_ref names a vault handle instead of writing the secret into ugent.toml:
[mcp.services.ugent-context]
token_ref = "@context_engine_token"It is resolved at connect time into Authorization: Bearer <token>. An Authorization you write in headers wins, so an existing config keeps working unchanged. A handle that fails to resolve is logged and dropped rather than sent as its own literal — forwarding @context_engine_token as a bearer token would authenticate nothing and put the handle on the wire.
Serving several people through one service
A service shared by more than one end user should identify them, or the server cannot filter per person:
[mcp.services.ugent-context]
per_actor = true
actor_header = "x-ugent-actor" # the defaultEach distinct actor gets its own session with that header injected at connect, so the server sees who is asking. HTTP transports only; a turn that carries no actor uses the base session. On a context engine that requires an actor, this is what stops every call being refused — see the tenant guide for the key scopes involved.
Connection Resilience
Retrying a tool call
Retries apply to tool calls the server asks you to retry, not to connection failures. A backpressure-aware server (the context engine is one) signals transient overload with -32005 (tool timed out) or -32006 (at capacity), or with error.data.retryable. Search and graph calls are read-only, so UGENT retries those with exponential backoff and full jitter before surfacing the error to the model. A server's retry_after_ms hint is honoured as a floor.
Permanent errors — unknown method, bad arguments, anything authentication — are never retried, because retrying cannot change the answer.
[mcp.services.ugent-context]
tool_call_max_attempts = 3 # including the first; 1 disables retries
tool_call_retry_base_ms = 250
tool_call_retry_max_ms = 4000Connecting
reinit_on_expired_session(default:true) — re-initializes automatically when the server returns 404 for an existing session IDinitialized_ack_mode(default:"lenient") — accepts a vendor-compatible 200 for the initialized notification. Set"strict"to require the 202/204 that streamable HTTP specifiesallow_stateless(default:true) — accepts a server that returns no MCP session ID header
On connect, UGENT offers protocol versions newest-first and keeps the first one the server accepts, ending with a request that names no version at all — so a server speaking an older revision still connects rather than failing the handshake.
Tool Name Convention
MCP tools are registered with the prefix mcp_ or mcp__ followed by the service name and tool name. For example, the Exa web search tool appears as mcp_exa_web_search_exa.
Disable Flags
Skip all MCP servers for a session:
ugent --disable-mcpThe MCP manager is not created — MCP status hooks and auto-connect are fully skipped.
Timeout Configuration
Per-service timeout overrides:
[mcp.services.context-mcp]
tool_timeout_secs = 30For high-depth graph_neighbors calls, consider setting a higher timeout to avoid default expiration.
macOS: Local Network permission
On macOS 15 (Sequoia) and later, an app must be granted Local Network access before it can reach LAN addresses. This bites any MCP client that talks to a context engine on another machine on your network — ugent, Codex, Claude Code, Claude Desktop, or anything else — and it is easy to misread as a broken server, a bad token, or a firewall.
Symptom
The MCP client fails at connect time while the same endpoint answers curl from the same machine, at the same moment:
MCP startup failed: handshaking with MCP server failed:
error sending request for url (http://192.168.2.13:9002/rpc)Underneath it is EHOSTUNREACH / "No route to host" — a routing verdict, not a refusal. The server logs show no request arriving at all, because nothing leaves the machine.
Confirming it
Run these from the same shell. Apple's own binaries under /usr/bin are exempt from the gate, so a split between them and anything else is the signature:
# Apple-signed: expected to work
/usr/bin/curl -s -m 5 -o /dev/null -w '%{http_code}\n' http://ENGINE_HOST:9002/health
# third-party (Homebrew, npm, your own build): fails when the gate applies
node -e "require('net').connect(9002,'ENGINE_HOST',()=>console.log('OK')).on('error',e=>console.log(e.code))"OK means the launching app already has the permission. EHOSTUNREACH means it does not.
Two checks worth doing before blaming the gate:
- Loopback is never affected. An engine on
127.0.0.1will always work. If a client used to work against a local engine and broke when the engine moved to another host, this is why. - A hostname is not a get-out. Split-horizon DNS that resolves a public name to a private address is still a LAN address, and is still gated.
Fix: grant the permission to the launching app
System Settings → Privacy & Security → Local Network, then enable the application you start the MCP client from — your terminal (Ghostty, iTerm, Terminal, Warp), or the desktop app if it launches the client for you. Quit and relaunch it; the permission is read at launch.
The grant follows the responsible app, not the binary. A terminal that has the permission generally passes it to the commands it runs. A desktop app that spawns a CLI may not, so granting the desktop app is not always sufficient — test with the one-liner above from the same context.
Because the entry is tied to a binary's identity, a client installed at a versioned path (for example a Homebrew Cask, or node_modules) needs re-approving whenever an upgrade changes that path.
Fix: an Apple-signed stdio bridge
If the client is launched by something that will not pass the permission on, put an exempt binary in the middle. /usr/bin/python3 ships with macOS and is not gated, and the exemption applies even when it is spawned by a process that is — so a stdio MCP server running under it reaches the LAN regardless of the launcher.
Save this as ~/.config/ugent/mcp-bridge.py (any path will do; the examples below use an absolute one because neither client expands ~ in args):
#!/usr/bin/env python3
"""Forward stdio MCP traffic to an HTTP MCP endpoint.
Run with /usr/bin/python3 specifically. A Homebrew python is subject to the
same Local Network gate and defeats the point.
"""
import json, os, sys, urllib.error, urllib.request
URL = os.environ.get("MCP_URL", "")
TOKEN = os.environ.get("MCP_TOKEN", "")
ACTOR = os.environ.get("MCP_ACTOR", "")
def headers():
h = {"Content-Type": "application/json",
"Accept": "application/json, text/event-stream"}
if TOKEN:
h["Authorization"] = f"Bearer {TOKEN}"
if ACTOR:
h["x-ugent-actor"] = ACTOR
return h
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
msg_id = json.loads(line).get("id")
except json.JSONDecodeError as exc:
print(f"bridge: unparseable message: {exc}", file=sys.stderr, flush=True)
continue
try:
req = urllib.request.Request(
URL, data=line.encode(), headers=headers(), method="POST")
with urllib.request.urlopen(req, timeout=120) as resp:
body = resp.read().decode("utf-8", "replace").strip()
except Exception as exc:
if msg_id is not None:
sys.stdout.write(json.dumps({
"jsonrpc": "2.0", "id": msg_id,
"error": {"code": -32000, "message": f"transport error: {exc}"},
}) + "\n")
sys.stdout.flush()
continue
# A notification carries no id and gets no response object.
if msg_id is not None and body:
sys.stdout.write(body + "\n")
sys.stdout.flush()Point the client at it as a stdio server. In ugent.toml:
[mcp.services.context-mcp]
enabled = true
[mcp.services.context-mcp.transport]
type = "stdio"
command = "/usr/bin/python3"
args = ["/Users/you/.config/ugent/mcp-bridge.py"]
[mcp.services.context-mcp.transport.env]
MCP_URL = "http://ENGINE_HOST:9002/rpc"
MCP_TOKEN = "ugctx_..."
MCP_ACTOR = "owner"env belongs to the transport table, and args is not tilde-expanded — use an absolute path.
The equivalent for Codex in ~/.codex/config.toml:
[mcp_servers.context-mcp]
command = "/usr/bin/python3"
args = ["/Users/you/.config/ugent/mcp-bridge.py"]
[mcp_servers.context-mcp.env]
MCP_URL = "http://ENGINE_HOST:9002/rpc"
MCP_TOKEN = "ugctx_..."
MCP_ACTOR = "owner"The bridge is plain stdio, which the MCP specification keeps as one of its two official transports alongside Streamable HTTP, so this is not a temporary workaround riding a deprecated path.
What this is not
Worth ruling out explicitly, because both cost time to chase:
- Not an outbound firewall. LuLu and similar tools allow Apple binaries and loopback by default, which mimics this exact pattern. Check the rule for your binary before assuming — an
allowrule for all endpoints, plus no entry in the tool's own log at the moment of failure, clears it. - Not a
GETversusPOSTmismatch. A Streamable HTTP MCP endpoint is POST-only by design, and returning405 Method Not AllowedforGETis explicitly permitted by the specification. Those log lines are normal.