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; in snippet mode each hit with text carries citation, the marker to cite it by. |
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. Given a codebase_id, it searches that workspace alone. |
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. |
document_fields | The document profiles of a workspace's document catalog: their fields, the values each takes, and their selection policies. |
document_versions | Find documents by key, a file's path or field filters, or list one document's versions, with which is selected now and, with explain, why. |
document_read | The exact text of one document version, selected or not, with each paragraph pinned for citation. At most 50,000 bytes a call. |
document_compare | The changes between two versions of one document, computed deterministically, with changed numbers and negations flagged. |
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. The four document_* tools appear only while documents are on for the tenant; see Document Versions. See the tenant guide for the scope each tool needs.
Routing Enrichment
MCP tools can be used as routing enrichment providers. In a routing.toml that sets enabled = true, a tool-enrich rule that names the provider searches the codebase when a message arrives, and an enrichment-prepend rule injects what it found before the LLM responds; see Routing. The provider:
# 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.
Citations from tool results
An answer may cite only what the turn was given. A citation pin the model writes without a tool result behind it is dropped as invented. A service marked as an evidence source may supply pins: the ids its tool results list, in their structured result, become citable for that turn:
[mcp.services.ugent-context]
evidence_source = trueThe context engine's document_read and document_compare return paragraphs pinned to an exact document version, as [[cite:doc:...]], and each workspace_search hit carries the marker to cite it by as citation, [[cite:code:...]]. Without this flag, an answer citing them keeps no citation; what the turn was given before it started, by retrieval or by memory, stays citable. Mark only a context engine you trust, since anything the service lists becomes citable. It is off by default.
- The pins come from the structured result, never from the text of a content block, and only in a known namespace (
code:,doc:). - Only this workspace's hits. A
code:pin names a path, which a reader opens in this workspace. So a search the agent ran in another indexed workspace (acodebase_idother than this one's) supplies nocode:pins: the same path here is a different file. Adoc:pin names its version and stands wherever it came from. - A path with a space is cited too. In a citation id the engine writes whitespace, control characters, the byte-order mark and
%itself percent-encoded (Foo%20Bar/x.md:1-2); readers decode them, and the agent copies the marker as given. - Every citation is kept, in order. A turn lists the citations it keeps in the order its answer first cites them; the web workspace shows the first 64.
- Each turn has its own. A result arriving after its turn ended validates nothing in the next one. A turn keeps at most 4,096 pins.
- Delegated work hands its pins back. A task's answer keeps the pins its own tool results named, and the turn that reads that answer may cite them too. A task of another user hands none.
See Document Versions for how a cited version looks to the reader.
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
Three settings bound an MCP service's calls:
[mcp.services.ugent-context]
tool_timeout_secs = 300 # one tool call, for every tool of this service
[mcp.services.ugent-context.transport.options]
connect_timeout_secs = 30 # opening a connection (default 30)
request_timeout_secs = 300 # one HTTP request, a tool call's included (default 60)| Key | Default | Bounds |
|---|---|---|
connect_timeout_secs | 30 | Opening a TCP connection. 0 means no deadline. |
request_timeout_secs | 60 | One HTTP request: a POST or DELETE as a whole, and the server-to-client stream only until its response begins. 0 means no deadline. |
tool_timeout_secs | unset | One tool call, for every tool the service exposes, on any transport. |
The two transport options apply to http services; a unix_socket transport reads neither. The long-lived stream on which the server sends to the client is never cut by request_timeout_secs, so it needs no setting of its own.
A tool call over HTTP is a POST, so request_timeout_secs bounds it as well as the tool timeout: a call that may run longer than 60 seconds needs both raised. Setting request_timeout_secs = 0 lifts the bound from initialize and from tool calls too, so a server that accepts a connection and then stalls holds them indefinitely.
Without tool_timeout_secs, an MCP tool call may run for 120 seconds. Tools whose names mark them as slow get longer: 900 seconds when the name contains deep, research, codebase, graph_search, graph_neighbors or web_search, and 300 for image, video and data-visualization analysis. A tool_timeout_secs replaces these for every tool of the service, graph_neighbors included, so set it no lower than the slowest call you expect.
A stream that is cut reports response body failed: followed by its cause. HTTP errors name the service's URL without its query or user information, either of which can carry a credential.
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://10.0.0.42: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.