mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-06-18 02:24:27 +02:00
feat(mcp): MCP server surface — Streamable-HTTP transport + tool/resource projection (RFC-003)
Add the `omnigraph-mcp` crate (stateless Streamable-HTTP transport, `McpBackend`
seam, fail-closed Host/Origin policy) and the server backend projecting built-in
operations and the per-graph stored-query registry as MCP tools + resources over
`POST /graphs/{id}/mcp`. Every tool delegates to the same engine/handler
functions the REST routes use and is gated by the same Cedar `authorize` path;
reads/writes carry structured output.
Includes three correctness fixes from review + live testing:
- tools/list is a faithful relaxation of the per-call gate: a built-in whose
authorization depends on a caller-chosen branch is shown iff the actor could
invoke it on some branch, via PolicyEngine::permits_on_any_branch (capability
probe through the same Cedar authorizer). A fabricated-`main` probe wrongly
hid graph_mutate under the canonical "protect main, write unprotected" policy.
- The stored-query surface honors mode + `expose` on call as well as on list:
resolve_stored_tool is the single membership test, so the meta pair
(stored_query_list/stored_query_run) is callable only in `meta` mode and
stored_query_run resolves exposed-only. An `expose:false` query is unreachable
by name on the agent surface (it stays HTTP/service-callable).
- The loopback Host allow-list is the full set [127.0.0.1, ::1, localhost]
(matches rmcp's default), so an IPv6 loopback `Host: [::1]` is accepted
regardless of which stack the server bound.
The protocol-version contract is documented (initialize negotiates the version
in its body, so the MCP-Protocol-Version header is validated on non-init
requests only) and pinned by a test.
Tests: omnigraph-mcp/tests/standalone.rs, omnigraph-server/tests/mcp.rs,
omnigraph-policy permits_on_any_branch unit test, omnigraph-api-types schema
projection. Full workspace gate green.
This commit is contained in:
parent
c43b81d318
commit
bcd0d9c867
20 changed files with 2968 additions and 43 deletions
|
|
@ -234,7 +234,12 @@ impl McpHostPolicy {
|
|||
pub fn from_bind(bind: &SocketAddr, public_hosts: &[String], browser_origins: &[String]) -> Self {
|
||||
let loopback = bind.ip().is_loopback();
|
||||
Self {
|
||||
allowed_hosts: if loopback { Some(vec!["127.0.0.1".into(), "localhost".into()]) }
|
||||
// Loopback bind ⇒ the full loopback Host set (both stacks + the
|
||||
// hostname alias), matching rmcp's default `["localhost","127.0.0.1","::1"]`.
|
||||
// The Host header is independent of the bound socket (in-process,
|
||||
// proxies, dual-stack localhost), so a 127-bound server must still
|
||||
// accept a `[::1]` Host — deriving the list from `bind.ip()` alone 403'd it.
|
||||
allowed_hosts: if loopback { Some(vec!["127.0.0.1".into(), "::1".into(), "localhost".into()]) }
|
||||
else if public_hosts.is_empty() { None } else { Some(public_hosts.to_vec()) },
|
||||
origin: if !browser_origins.is_empty() { OriginPolicy::Allow(browser_origins.to_vec()) }
|
||||
else if loopback { OriginPolicy::Unchecked } // local dev convenience only
|
||||
|
|
@ -597,11 +602,22 @@ Represent built-ins as a `Builtin` enum (one variant per tool; `descriptor` / `g
|
|||
`call` as match arms) — lower liability than ~13 unit structs + `dyn`. Stored-query
|
||||
tools are a sibling populator over `handle.queries`.
|
||||
|
||||
**`list_tools` / `list_resources` are Cedar-filtered** by running the *same*
|
||||
authorization the call path runs, with **default args (branch `main`)** — not a
|
||||
`branch: None` probe (which matches no `branch_scope` rule and would hide tools the
|
||||
actor can call on a scoped branch). Over-showing a branch-scoped grant is the safe
|
||||
direction; `call_tool` is the authoritative gate.
|
||||
**`list_tools` / `list_resources` are Cedar-filtered as a *relaxation* of the
|
||||
call-path gate** — listing never hides a tool the caller could invoke on some
|
||||
branch (over-showing is the safe direction; `call_tool` is authoritative). A
|
||||
built-in whose authorization depends on a caller-chosen branch (`graph_mutate`,
|
||||
`graph_load`, `branch_*`) is shown iff `authorize_any_branch` →
|
||||
`PolicyEngine::permits_on_any_branch(actor, action)` is true: that probes the
|
||||
branch-shape space (omitted / protected / unprotected) through the same Cedar
|
||||
authorizer and returns true if *any* shape is allowed. A fixed-branch probe is
|
||||
wrong here — both a fabricated `main` (denied under "protect `main`, write
|
||||
unprotected branches", the canonical workflow) and a `branch: None` probe
|
||||
(matches no `branch_scope` rule) under-show `graph_mutate` to an actor who can
|
||||
write feature branches. The stored-query surface gets the same list/call
|
||||
agreement structurally: `resolve_stored_tool` is the single membership test, so
|
||||
the meta pair is callable only in `meta` mode and `stored_query_run` resolves
|
||||
**exposed-only** (an `expose:false` query is unreachable by name on the agent
|
||||
surface, though it stays HTTP/service-callable).
|
||||
|
||||
## 11. Dispatch reuse + error classification
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue