mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-06-18 02:24:27 +02:00
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.
80 lines
2.8 KiB
Rust
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
|
|
}
|
|
}
|