Skip to content

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

toml
[mcp]
auto_connect = true

When 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)

toml
[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 = true

stdio (Local Process)

toml
[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)

toml
[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.

toml
[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

bash
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.

bash
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-context

ugent 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:

bash
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:

bash
export UGENT_CONTEXT_KEY='ugctx_...'
codex mcp add ugent-context --url https://<your-data-plane>/rpc \
  --bearer-token-env-var UGENT_CONTEXT_KEY

Codex 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

ToolDescription
workspace_searchHybrid code and document search with snippet, structure, and synthesis modes. Returns ranked snippets with line numbers.
workspace_overviewDiscover 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_searchSearch the knowledge graph for code entities by name. Returns node IDs for chaining into graph_neighbors.
graph_neighborsExplore callers, callees, imports, containers, and references around any node.
database_queryAsk data questions in plain English. Generates safe, read-only SQL grounded on auto-profiled table statistics.
memory_recallRetrieve durable facts and preferences with hybrid lexical plus vector recall.
memory_storeCreate or update a self-contained fact, preference, or event summary.
memory_forgetDelete memory records by id or filter. Filters combine with AND; an empty selector is rejected.
memory_ingestQueue conversation transcripts for async memory creation with optional LLM fact extraction.
memory_ingest_statusPoll 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:

toml
# 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:

toml
[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:

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:

toml
[mcp.services.ugent-context]
per_actor = true
actor_header = "x-ugent-actor"   # the default

Each 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.

toml
[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 = 4000

Connecting

  • reinit_on_expired_session (default: true) — re-initializes automatically when the server returns 404 for an existing session ID
  • initialized_ack_mode (default: "lenient") — accepts a vendor-compatible 200 for the initialized notification. Set "strict" to require the 202/204 that streamable HTTP specifies
  • allow_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:

bash
ugent --disable-mcp

The MCP manager is not created — MCP status hooks and auto-connect are fully skipped.

Timeout Configuration

Per-service timeout overrides:

toml
[mcp.services.context-mcp]
tool_timeout_secs = 30

For 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:

bash
# 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.1 will 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):

python
#!/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:

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:

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 allow rule for all endpoints, plus no entry in the tool's own log at the moment of failure, clears it.
  • Not a GET versus POST mismatch. A Streamable HTTP MCP endpoint is POST-only by design, and returning 405 Method Not Allowed for GET is explicitly permitted by the specification. Those log lines are normal.

Released under the Private Beta License.