Skip to content

feat: enable built-in MCP server — Phase-1 MCP-first reference#30

Draft
ashpreetbedi wants to merge 1 commit into
chore/update-2026-07-07from
feat/mcp-server-2026-07-07
Draft

feat: enable built-in MCP server — Phase-1 MCP-first reference#30
ashpreetbedi wants to merge 1 commit into
chore/update-2026-07-07from
feat/mcp-server-2026-07-07

Conversation

@ashpreetbedi

Copy link
Copy Markdown
Contributor

Phase-1 reference implementation for the Apps → MCP-first direction (see agentos-family/docs/proposals/apps-mcp-first-spec.md). Stacked on chore/update-2026-07-07 — the built-in MCP server needs agno ≥ 2.7.0a5 ([mcp] extra + the 421 host-guard fix); do not retarget to main (still 2.5.14).

What changed

  • app/main.pyenable_mcp_server=True on the AgentOS(...) ctor. Nothing else: auth, scheduler, lifespan unchanged. Dash now serves the built-in 8-tool streamable-HTTP MCP server at /mcp, so any MCP client (Claude Code, Cursor, claude.ai connectors, other Apps) can run_team("dash", ...) against the warehouse.
  • pyproject.tomlagno[os]agno[os,mcp] at the same pin (2.7.0a6); dropped the standalone mcp dep (the extra supplies fastmcp==3.4.3mcp==1.27.0 transitively). requirements.txt regenerated via ./scripts/generate_requirements.sh.
  • scripts/mcp_check.sh — ported from agentos-railway, retargeted to run_team("dash", ...) with a tool-free default question; expects "MCP OK — 8 tools".
  • README.md — MCP section: endpoint, uvx agno connect / claude mcp add, chat-app connector setup, auth model, smoke check.

Identity spike — does the built-in 8-tool server honor per-request identity/role scoping?

Verified against the installed agno 2.7.0a6 source (agno/os/mcp.py, agno/os/app.py, agno/os/middleware/jwt.py).

Per-request identity: YES, natively.

  1. One auth layer covers /mcp. The parent app's single AuthMiddleware (app.py::_add_auth_middleware) runs before Starlette dispatches to the MCP mount — JWTs, service-account PATs, internal service token, and security key are all verified there, and the identity lands on request.state.user_id (+ scopes, admin_scope, authorization_enabled). No auth code lives on the mounted sub-app.
  2. The verified subject overrides caller-supplied user_id. Every run tool calls _resolve_user_id() (agno/os/mcp.py:158), which reads the in-flight request via fastmcp.server.dependencies.get_http_request() and replaces any client-passed user_id with request.state.user_id. MCP callers cannot spoof attribution in prd.
  3. Per-tool scope enforcement, reusing REST's mechanism verbatim. _require_tool_scopes() (mcp.py:230) maps each tool call onto its equivalent REST route (run_teamPOST /teams/{id}/runs, etc.) and runs check_route_scopes with the caller's token scopes — so a PAT scoped to a subset of resources gets "insufficient permissions" per call, identically to REST. get_agentos_config additionally filters the visible agent/team/workflow roster by per-resource scopes (filter_resources_by_access).
  4. User isolation hooks exist but are opt-in. With AuthorizationConfig(user_isolation=True), _scoped_read_user_id() pins non-admin get_sessions/get_session_runs to the caller's own id, and continue_run/cancel_run verify run ownership. Dash (like coda/scout) does not set user_isolation, so a valid org token can read other users' sessions over MCP — the same exposure the REST API already has (no new privilege; tracked as a follow-up in the standardization proposal).

Role scoping of the tool list: NO — static, not per-request. The tool surface is fixed at server build time (build_mcp_server): MCPServerConfig.include_tags/exclude_tags/enable_builtin_tools scope tools at registration, for all callers alike. tools/list returns the same set to every authenticated caller; role differences surface as call-time authorization errors, not different tool lists. There is no per-request dynamic tool-filtering hook.

The path for role-scoped surfaces (context is the template): context achieves owner/guest scoping not by filtering the built-in list per caller but by (a) MCPServerConfig.authorize=_caller_is_owner — a per-call predicate middleware that 401s non-owners after JWT, before any tool runs (mcp.py:1034), and (b) a custom tool whose user_id parameter agno fills with the verified JWT subject and hides from the client-facing schema (_inject_user_id, mcp.py:121) — spoof-proof, with an in-tool role check as defense-in-depth. Apps that want per-role tool surfaces should follow that pattern (custom tools + authorize gate), not the generic 8. For dash's v1, the default config is correct: JWT gates /mcp in prd, the verified subject owns attribution, and scopes gate each call.

One more spike finding worth recording: agno 2.7 disables fastmcp ≥3.4.3's built-in Host/Origin guard (http_app(host_origin_protection=False), mcp.py:1140) and makes DNS-rebinding protection opt-in via MCPServerConfig.allowed_hosts. Default config (dash) therefore has no host guard → no 421 risk behind Railway's proxy, but also no rebinding protection — same posture as the agentos-railway starter. If the public-domain probe below 4xxs anyway, copy context's AGENTOS_URL-derived allowed_hosts pattern.

Validation done (no secrets, no compose up)

  • ./scripts/validate.sh — ruff + mypy clean (36 files).
  • docker compose config — parses.
  • RUNTIME_ENV=dev python -c "import app.main" against a throwaway pgvector — imports clean, enable_mcp_server=True, MCP app mounted (inner route /mcp), and build_mcp_server(agent_os) registers exactly the 8 expected tools: cancel_run, continue_run, get_agentos_config, get_session_runs, get_sessions, run_agent, run_team, run_workflow.
  • NOT done: docker compose up (no secrets in the fresh clone; shared ports), evals, deploy.

Pre-existing note (bump branch, not this PR): import logs Registry: multiple distinct databases share id 'dash-db'get_postgres_db() constructs several instances with one id.

Reviewer checklist — live verification

  • Local gate: docker compose up -d --build, then ./scripts/mcp_check.sh → "MCP OK — 8 tools" + a real team answer.
  • Deploy to Railway (single replica — dash already is), then probe /mcp over the PUBLIC domain, not localhost — the fastmcp 421 host-guard episode made localhost-green/prod-broken the signature failure mode. Handshake + list_tools must return the 8 tools via https://<railway-domain>/mcp.
  • Auth: with RUNTIME_ENV=prd, an unauthenticated /mcp call is rejected; an authenticated call (PAT / JWT from os.agno.com) handshakes and run_team("dash", ...) succeeds, with the run attributed to the token subject (check the session's user_id).
  • Spoof check: pass an explicit user_id in a run_agent/run_team call with a JWT — the run must be attributed to the JWT subject, not the passed id.
  • Confirm no 4xx from host/origin checking behind Railway's proxy (if it appears, wire context's allowed_hosts pattern).
  • Standard serial gate for the branch (/docs 200, validate, smoke evals) before merging into the bump PR.

🤖 Generated with Claude Code

Dash now serves agno's built-in 8-tool MCP server at /mcp
(enable_mcp_server=True), making the warehouse callable by any MCP
client via run_team("dash", ...). Stacked on the agno 2.7.0a6 bump
branch (the [mcp] extra and the 421 host-guard fix require >=2.7.0a5).

- pyproject: agno[os] -> agno[os,mcp]; drop the standalone mcp dep
  (fastmcp 3.4.3 supplies mcp==1.27.0 transitively); regenerated
  requirements.txt
- app/main.py: enable_mcp_server=True on the AgentOS ctor — auth,
  scheduler, lifespan unchanged
- scripts/mcp_check.sh: ported from agentos-railway, retargeted to
  run_team("dash", ...) with a tool-free default question
- README: MCP section (endpoint, uvx agno connect, connector setup,
  auth model, smoke check)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ashpreetbedi

Copy link
Copy Markdown
Contributor Author

🤖 Automated re-review

No blocking issues found.

Re-reviewed the current diff for correctness bugs and reuse/simplification/efficiency issues; nothing blocking surfaced.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant