omnigraph/crates/omnigraph-mcp/src/service.rs
Ragnor Comerford bcd0d9c867
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.
2026-06-17 14:00:52 +02:00

80 lines
2.8 KiB
Rust

//! `McpService<B>` — the rmcp `ServerHandler` adapter. Pulls the request's
//! `http::request::Parts` out of the context once and delegates each method to
//! the [`McpBackend`]. Maps the backend's non-paginated `Vec<T>` returns to
//! rmcp's `List*Result` with `next_cursor: None`.
use rmcp::ServerHandler;
use rmcp::ErrorData as McpError;
use rmcp::model::{
CallToolRequestParams, CallToolResult, ListResourcesResult, ListToolsResult,
PaginatedRequestParams, ReadResourceRequestParams, ReadResourceResult, ServerInfo,
};
use rmcp::service::{RequestContext, RoleServer};
use crate::McpBackend;
#[derive(Clone)]
pub(crate) struct McpService<B: McpBackend> {
backend: B,
}
impl<B: McpBackend> McpService<B> {
pub(crate) fn new(backend: B) -> Self {
Self { backend }
}
/// The HTTP `Parts` injected by `StreamableHttpService` into the request
/// context extensions (`tower.rs` does `request.into_parts()` then
/// `req.request.extensions_mut().insert(part)`). Absent only on an internal
/// wiring error, not a client-reachable path.
fn parts(ctx: &RequestContext<RoleServer>) -> Result<&http::request::Parts, McpError> {
ctx.extensions
.get::<http::request::Parts>()
.ok_or_else(|| McpError::internal_error("request parts missing from MCP context", None))
}
}
impl<B: McpBackend> ServerHandler for McpService<B> {
fn get_info(&self) -> ServerInfo {
self.backend.server_info()
}
async fn list_tools(
&self,
_request: Option<PaginatedRequestParams>,
context: RequestContext<RoleServer>,
) -> Result<ListToolsResult, McpError> {
let parts = Self::parts(&context)?;
let tools = self.backend.list_tools(parts).await?;
Ok(ListToolsResult::with_all_items(tools))
}
async fn call_tool(
&self,
request: CallToolRequestParams,
context: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
let parts = Self::parts(&context)?;
let args = request.arguments.unwrap_or_default();
self.backend.call_tool(parts, &request.name, args).await
}
async fn list_resources(
&self,
_request: Option<PaginatedRequestParams>,
context: RequestContext<RoleServer>,
) -> Result<ListResourcesResult, McpError> {
let parts = Self::parts(&context)?;
let resources = self.backend.list_resources(parts).await?;
Ok(ListResourcesResult::with_all_items(resources))
}
async fn read_resource(
&self,
request: ReadResourceRequestParams,
context: RequestContext<RoleServer>,
) -> Result<ReadResourceResult, McpError> {
let parts = Self::parts(&context)?;
self.backend.read_resource(parts, &request.uri).await
}
}