Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ A template for building [Deep Agents](https://github.com/langchain-ai/deepagents
- Orchestrator with analyst and publisher subagents
- Skills: `client-intake`, `bmi-report`, `email-formatter`
- MCP auth modes: SSO pass-through, OAuth, and DCR
- MCP Apps host APIs (`resources/read` + app `tools/call`) for interactive `ui://` UIs in Template UI

**Infrastructure:**
- Aegra dev server with Redis-backed SSE streaming
Expand Down Expand Up @@ -85,6 +86,8 @@ curl -N -X POST "http://localhost:5002/threads/THREAD_ID/runs/stream" \
| `/mcp/{name}/connect` | POST | Start OAuth/DCR flow for an MCP server |
| `/mcp/oauth/callback` | GET | OAuth redirect handler |
| `/mcp/{name}/status` | GET | MCP connection status for current user |
| `/mcp/{name}/resources/read` | POST | MCP Apps: read a `ui://` resource |
| `/mcp/{name}/tools/call` | POST | MCP Apps: app-initiated tool call |

Use [template-ui](https://github.com/redhat-data-and-ai/template-ui) for a full chat experience against this API.

Expand Down Expand Up @@ -156,6 +159,23 @@ MCP servers are defined in [`config/agent/mcp.json`](./config/agent/mcp.json) an

Set `"auth": false` for public/local MCP servers with no Authorization header.

### MCP Apps (interactive UI)

The agent advertises the MCP Apps UI extension on `initialize` and exposes host proxy routes used by Template UI:

| Endpoint | Method | Description |
|---|---|---|
| `/mcp/{name}/resources/read` | POST | Read a `ui://` Apps resource (HTML) |
| `/mcp/{name}/tools/call` | POST | App-initiated tool call (`visibility` must include `app`) |

Add any SEP-1865-compliant App server to `mcp.json` the same way as a normal MCP — no agent code changes.

Compliance smoke tests:

```bash
.venv/bin/python -m pytest tests/unit/aegra/test_mcp_apps_smoke.py -q
```

### MCP URL by run mode

| Mode | `url` in `mcp.json` |
Expand Down
6 changes: 6 additions & 0 deletions config/agent/runtime/ui.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ security:
connect_src: ["'self'"]
font_src: ["'self'", "data:"]
object_src: ["'none'"]
frame_src: ["'self'"] # MCP Apps sandbox proxy iframe
frame_ancestors: ["'none'"]
cross_origin_embedder_policy: false # Must be false — breaks SSE streaming

Expand All @@ -55,6 +56,11 @@ otel:
enabled: true # Master switch — set true to activate OTEL tracing
service_name: "template-ui"

# --- Features ---
features:
mcp_apps:
enabled: true # Serve /sandbox_proxy.html for MCP Apps host rendering

# --- Announcement Banner ---
announcement:
enabled: true
Expand Down
58 changes: 49 additions & 9 deletions deep_agent/aegra/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,21 @@
import httpx
from langchain_mcp_adapters.client import MultiServerMCPClient

from deep_agent.aegra.mcp_apps import (
McpAppCallToolResultInterceptor,
ensure_mcp_apps_capability_advertised,
prepare_tools_for_model,
)
from deep_agent.src.agent.config import agent_config
from deep_agent.src.error_handling import CircuitBreaker, create_circuit_breaker
from deep_agent.src.settings import settings
from deep_agent.utils.pylogger import get_python_logger

logger = get_python_logger(log_level=settings.PYTHON_LOG_LEVEL)

# Advertise MCP Apps UI capability for langchain-mcp-adapters sessions.
ensure_mcp_apps_capability_advertised()

_SSO_TOKEN_URL: str = ""

_mcp_breaker: CircuitBreaker | None = None
Expand Down Expand Up @@ -406,22 +414,31 @@ async def _connect_single_server(
*,
required: bool = False,
server_key: str | None = None,
mcp_server: str | None = None,
) -> list[Any]:
"""Connect to one MCP server and return its tools.
"""Connect to one MCP server and return its model-visible tools.

Failures are logged and return empty list for fault isolation.
Updates the module-level circuit breaker on success/failure.

Tools are annotated with ``mcp_server`` (mcp.json key) and app-only
tools (``visibility: ["app"]``) are filtered out before return so the
LLM never sees them. No shared Apps registry is written.

Args:
name: Human-readable server identifier used in log messages.
name: Human-readable server identifier used in log messages /
MultiServerMCPClient connection key (may be ``tool_prefix``).
config: MCP client connection config (url, transport, headers, etc.).
server_cfg: Raw MCP server definition from ``mcp.json`` (auth, ssl_verify).
timeout: Seconds before the connection attempt is cancelled.
required: If True the server is explicitly enabled in config,
so connection failures are logged at error level.
server_key: Optional key override for auth token lookup.
server_key: Optional mcp.json key for auth token lookup and Apps
metadata. Defaults to ``mcp_server`` or ``name`` when omitted.
mcp_server: Alias for ``server_key`` (v2 Apps naming). Ignored when
``server_key`` is provided.
"""
auth_key = server_key or name
auth_key = server_key or mcp_server or name
breaker = _get_mcp_breaker()
if breaker.is_open:
logger.warning(f"[{name}] circuit breaker open — skipping connection")
Expand All @@ -437,13 +454,25 @@ async def _connect_single_server(
_TokenInjectorInterceptor(
name, server_cfg, server_key=auth_key
),
# Capture raw MCP CallToolResult before LC conversion so
# UI-bound tools embed spec-faithful mcp_app.result.
McpAppCallToolResultInterceptor(),
],
tool_name_prefix=bool(server_cfg.get("tool_prefix", "")),
)
tools: list[Any] = await client.get_tools()
logger.info(f"[{name}] loaded {len(tools)} tool(s)")
model_tools = prepare_tools_for_model(tools, auth_key)
skipped = len(tools) - len(model_tools)
if skipped:
logger.info(
"[%s] hid %d app-only tool(s) from the model (%d model-visible)",
name,
skipped,
len(model_tools),
)
logger.info(f"[{name}] loaded {len(model_tools)} tool(s)")
breaker.record_success()
return tools
return model_tools
except TimeoutError:
if attempt < max_attempts:
logger.warning(
Expand All @@ -458,15 +487,21 @@ async def _connect_single_server(
"[%s] MCP OAuth required — returning auth placeholder tool",
name,
)
return [_create_auth_placeholder_tool(auth_key, server_cfg)]
return prepare_tools_for_model(
[_create_auth_placeholder_tool(auth_key, server_cfg)],
auth_key,
)
elif _is_auth_error(exc):
auth_mode = server_cfg.get("auth_mode", "sso")
if auth_mode in ("oauth", "dcr"):
logger.info(
"[%s] MCP tool discovery auth failed — returning auth placeholder tool",
name,
)
return [_create_auth_placeholder_tool(auth_key, server_cfg)]
return prepare_tools_for_model(
[_create_auth_placeholder_tool(auth_key, server_cfg)],
auth_key,
)
else:
logger.warning(
f"[{name}] MCP auth failed — {type(exc).__name__}: {exc}"
Expand Down Expand Up @@ -639,7 +674,12 @@ async def get_mcp_tools(
mcp_prefix_name,
auth_mode,
)
placeholder_tools.append([_create_auth_placeholder_tool(name, entry)])
placeholder_tools.append(
prepare_tools_for_model(
[_create_auth_placeholder_tool(name, entry)],
name,
)
)
continue
connect_jobs.append(
_connect_single_server(
Expand Down
Loading
Loading