From ff34d5b6eb3674d51e00a107872882dec088d4b5 Mon Sep 17 00:00:00 2001 From: Ragnor Comerford Date: Tue, 13 Oct 2026 21:43:55 -0700 Subject: [PATCH] feat(server): schema and ad-hoc read tools on /mcp, metadata at the advertised path --- .../mcp-schema-read-discovery.added.md | 15 + crates/omnigraph-server/src/handlers.rs | 1 + crates/omnigraph-server/src/http_contract.rs | 4 +- crates/omnigraph-server/src/lib.rs | 38 +- crates/omnigraph-server/src/main.rs | 15 +- crates/omnigraph-server/src/mcp.rs | 439 ++++++++++++++++-- crates/omnigraph-server/src/settings.rs | 1 + .../omnigraph-server/tests/integration/mcp.rs | 282 ++++++++++- .../tests/integration/openapi.rs | 12 +- ...identity-credentials-and-applied-policy.md | 6 +- ...026-09-26-self-contained-server-testing.md | 4 +- docs/user/index.md | 1 + docs/user/operations/mcp.md | 110 +++++ docs/user/operations/server.md | 32 +- openapi.json | 33 ++ 15 files changed, 913 insertions(+), 80 deletions(-) create mode 100644 changelog.d/mcp-schema-read-discovery.added.md create mode 100644 docs/user/operations/mcp.md diff --git a/changelog.d/mcp-schema-read-discovery.added.md b/changelog.d/mcp-schema-read-discovery.added.md new file mode 100644 index 000000000..73f8012b2 --- /dev/null +++ b/changelog.d/mcp-schema-read-discovery.added.md @@ -0,0 +1,15 @@ +- The server's `/mcp` endpoint gains a `schema` tool, which returns a graph's + `.pg` schema under the `read` action, and an opt-in `read` tool, which runs an + ad-hoc GQ read through `POST /query`, refuses mutations, and is listed only + when the server starts with `--mcp-allow-adhoc` (or + `OMNIGRAPH_MCP_ALLOW_ADHOC=1`). Tools now carry titles, fuller descriptions + and, where the result shape is fixed, an output schema, and `initialize` + returns instructions that tell a model to read the schema first. See + [MCP][mcp-schema-read-guide]. +- Protected-resource metadata is also served at the path the `/mcp` challenge + advertises (`/.well-known/oauth-protected-resource/`), so an + OAuth client such as Claude connects to a resource identifier with a path + without a proxy rewrite, and a refused bearer token now gets + `error="invalid_token"` in that challenge. + +[mcp-schema-read-guide]: ../docs/user/operations/mcp.md diff --git a/crates/omnigraph-server/src/handlers.rs b/crates/omnigraph-server/src/handlers.rs index d76be84f3..2c46243a6 100644 --- a/crates/omnigraph-server/src/handlers.rs +++ b/crates/omnigraph-server/src/handlers.rs @@ -312,6 +312,7 @@ const ALWAYS_FLAT_PATHS: &[&str] = &[ "/cluster/deployments", "/cluster/deployments/{id}", "/.well-known/oauth-protected-resource", + "/.well-known/oauth-protected-resource/{resource_path}", ]; /// In multi-mode `server_openapi`, every protected path-item is diff --git a/crates/omnigraph-server/src/http_contract.rs b/crates/omnigraph-server/src/http_contract.rs index d36019563..fa486f833 100644 --- a/crates/omnigraph-server/src/http_contract.rs +++ b/crates/omnigraph-server/src/http_contract.rs @@ -37,7 +37,7 @@ pub(crate) async fn require_contract(request: Request, next: Next) -> Result Response { let path = request.uri().path(); - if matches!(path, "/mcp" | "/.well-known/oauth-protected-resource") { + if path == "/mcp" || crate::mcp::is_resource_metadata_path(path) { return next.run(request).await; } let discovery = path == "/healthz"; @@ -60,7 +60,7 @@ pub(crate) fn describe_contract(doc: &mut utoipa::openapi::OpenApi) { schema.enum_values = Some(vec![HTTP_API_CONTRACT.into()]); for (path, item) in &mut doc.paths.paths { // OAuth resource metadata follows its standard protocol, as does MCP. - if path == "/.well-known/oauth-protected-resource" { + if crate::mcp::is_resource_metadata_path(path) { continue; } let protected = diff --git a/crates/omnigraph-server/src/lib.rs b/crates/omnigraph-server/src/lib.rs index 406b22cc6..dfc583823 100644 --- a/crates/omnigraph-server/src/lib.rs +++ b/crates/omnigraph-server/src/lib.rs @@ -31,6 +31,7 @@ pub mod workload; pub use graph_id::GraphId; pub use identity::{AuthSource, AuthenticatedActor, GraphKey, ResolvedActor, Scope, TenantId}; +pub use mcp::McpSettings; pub use registry::{ BlockedGraph, GraphEntry, GraphHandle, GraphRegistry, InsertError, LoadingGraph, RegistryLookup, RegistrySnapshot, StartupFailure, @@ -112,6 +113,7 @@ fn hash_bearer_token(token: &str) -> BearerTokenHash { ), paths( mcp::resource_metadata, + mcp::resource_metadata_at_resource_path, deployment::status, deployment::lookup, deployment::apply, @@ -237,6 +239,7 @@ pub struct ManagedServerConfig { canonical_root: String, trust: Option, oidc_trust: Option>, + mcp: McpSettings, } impl ManagedServerConfig { @@ -253,6 +256,12 @@ impl ManagedServerConfig { self.config.shutdown_grace = grace; self } + + /// Set the MCP boot settings, preserving the validated root binding. + pub fn with_mcp_settings(mut self, mcp: McpSettings) -> Self { + self.mcp = mcp; + self + } } /// The default bound on graceful shutdown. @@ -398,6 +407,8 @@ pub struct AppState { bearer_tokens: Arc<[(BearerTokenHash, Arc)]>, data_token_trust: Option>, oidc_identity_trust: Option>, + /// MCP boot settings: which opt-in tools `/mcp` lists and admits. + mcp: Arc, /// Bounded process-wide ownership for queued served-export bytes. The /// response body and detached producer jointly retain each reservation. export_transport: export_transport::ExportTransport, @@ -767,6 +778,7 @@ impl AppState { bearer_tokens, data_token_trust: None, oidc_identity_trust: None, + mcp: Arc::new(McpSettings::default()), operations: operations::OperationRuntime::new(), export_transport: export_transport::ExportTransport::with_defaults(), witness: Arc::new(BootWitness::default()), @@ -820,6 +832,7 @@ impl AppState { bearer_tokens, data_token_trust: None, oidc_identity_trust: None, + mcp: Arc::new(McpSettings::default()), operations: operations::OperationRuntime::new(), export_transport: export_transport::ExportTransport::with_defaults(), witness: Arc::new(BootWitness::default()), @@ -911,6 +924,17 @@ impl AppState { self } + /// Attach the MCP boot settings `/mcp` reads for every request. + #[must_use] + pub fn with_mcp_settings(mut self, mcp: McpSettings) -> Self { + self.mcp = Arc::new(mcp); + self + } + + pub(crate) fn mcp_settings(&self) -> &McpSettings { + &self.mcp + } + pub(crate) fn oidc_resource_metadata(&self) -> Option { self.oidc_identity_trust .as_ref() @@ -2736,19 +2760,26 @@ pub fn build_app(state: AppState) -> Router { } pub async fn serve(config: ServerConfig) -> Result<()> { - serve_config(config, None, None).await + serve_config(config, None, None, McpSettings::default()).await +} + +/// Serve with explicit MCP boot settings (`--mcp-*` flags and their +/// environment variables, resolved by [`McpSettings::resolve`]). +pub async fn serve_with_mcp_settings(config: ServerConfig, mcp: McpSettings) -> Result<()> { + serve_config(config, None, None, mcp).await } /// Serve settings whose offline data-token trust was validated against their /// applied snapshot's canonical root before any graph engine open. pub async fn serve_with_data_token_trust(config: ManagedServerConfig) -> Result<()> { - serve_config(config.config, config.trust, config.oidc_trust).await + serve_config(config.config, config.trust, config.oidc_trust, config.mcp).await } async fn serve_config( config: ServerConfig, data_token_trust: Option, oidc_identity_trust: Option>, + mcp: McpSettings, ) -> Result<()> { // RFC 0049: the signal listener is installed before anything else, so // the shutdown bound covers startup. On the signal it sets `draining`, @@ -2866,7 +2897,8 @@ async fn serve_config( Arc::clone(&draining), shutdown_grace, ) - .with_process_defaults(process_defaults); + .with_process_defaults(process_defaults) + .with_mcp_settings(mcp); deployment::initialize_boot_activation(&state); let retained_admission = state.cluster_admission.clone(); let startup_owner = operations diff --git a/crates/omnigraph-server/src/main.rs b/crates/omnigraph-server/src/main.rs index e328963f4..0ef31cfac 100644 --- a/crates/omnigraph-server/src/main.rs +++ b/crates/omnigraph-server/src/main.rs @@ -3,8 +3,8 @@ use std::path::PathBuf; use clap::Parser; use color_eyre::eyre::Result; use omnigraph_server::{ - init_tracing, load_server_settings, load_server_settings_with_identity_trust, - resolve_shutdown_grace, serve, serve_with_data_token_trust, + McpSettings, init_tracing, load_server_settings, load_server_settings_with_identity_trust, + resolve_shutdown_grace, serve_with_data_token_trust, serve_with_mcp_settings, }; #[derive(Debug, Parser)] @@ -50,6 +50,11 @@ struct Cli { /// termination grace must be longer. #[arg(long)] shutdown_grace_seconds: Option, + /// List and admit the MCP `read` tool, which runs ad-hoc GQ reads under the + /// caller's applied `read` policy. Off by default. Equivalent to setting + /// `OMNIGRAPH_MCP_ALLOW_ADHOC=1`. + #[arg(long)] + mcp_allow_adhoc: bool, } #[tokio::main] @@ -58,6 +63,7 @@ async fn main() -> Result<()> { init_tracing(); let cli = Cli::parse(); + let mcp = McpSettings::resolve(cli.mcp_allow_adhoc); match (cli.data_token_trust, cli.oidc_identity_trust) { (data, oidc) if data.is_some() || oidc.is_some() => { let settings = load_server_settings_with_identity_trust( @@ -69,7 +75,8 @@ async fn main() -> Result<()> { oidc.as_deref(), ) .await? - .with_shutdown_grace(resolve_shutdown_grace(cli.shutdown_grace_seconds)?); + .with_shutdown_grace(resolve_shutdown_grace(cli.shutdown_grace_seconds)?) + .with_mcp_settings(mcp); serve_with_data_token_trust(settings).await } _ => { @@ -81,7 +88,7 @@ async fn main() -> Result<()> { ) .await?; settings.shutdown_grace = resolve_shutdown_grace(cli.shutdown_grace_seconds)?; - serve(settings).await + serve_with_mcp_settings(settings, mcp).await } } } diff --git a/crates/omnigraph-server/src/mcp.rs b/crates/omnigraph-server/src/mcp.rs index b6d303d86..11a6e871f 100644 --- a/crates/omnigraph-server/src/mcp.rs +++ b/crates/omnigraph-server/src/mcp.rs @@ -1,4 +1,5 @@ -//! Read-only MCP transport over the existing graph discovery and stored-query handlers. +//! Read-only MCP transport over the existing graph discovery, schema, stored-query +//! and inline-query handlers. //! The official SDK owns protocol framing, negotiation and cancellation. use std::future::Future; use std::sync::Arc; @@ -6,7 +7,7 @@ use std::time::Duration; use axum::body::{Bytes, to_bytes}; use axum::extract::{Path, Request, State}; -use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; +use axum::http::{HeaderMap, HeaderValue, StatusCode, Uri, header}; use axum::middleware::{self, Next}; use axum::response::{IntoResponse, Response}; use axum::routing::get; @@ -33,6 +34,61 @@ use crate::{ApiError, AppState, AuthenticatedActor, GraphId, GraphKey}; pub(crate) const REQUEST_BYTES: usize = 64 * 1024; const RESULT_BYTES: usize = 1024 * 1024; const READ_DEADLINE: Duration = Duration::from_secs(30); +const RESOURCE_METADATA_PATH: &str = "/.well-known/oauth-protected-resource"; + +/// Environment equivalent of `--mcp-allow-adhoc`. +pub const MCP_ALLOW_ADHOC_ENV: &str = "OMNIGRAPH_MCP_ALLOW_ADHOC"; + +/// Process boot settings for `/mcp`. They select which opt-in tools are listed +/// and admitted; they never grant anything, since every tool still runs only +/// what the applied policy allows its caller. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[non_exhaustive] +pub struct McpSettings { + /// Lists and admits the ad-hoc `read` tool. + pub allow_adhoc: bool, +} + +impl McpSettings { + /// The boot flags, each enabled also by its environment variable. + pub fn resolve(allow_adhoc: bool) -> Self { + Self { + allow_adhoc: allow_adhoc || env_flag(MCP_ALLOW_ADHOC_ENV), + } + } +} + +fn env_flag(name: &str) -> bool { + std::env::var(name).is_ok_and(|value| { + let value = value.trim(); + !value.is_empty() && value != "0" && !value.eq_ignore_ascii_case("false") + }) +} + +/// Whether `path` is protected-resource metadata: the root document or the +/// path-suffixed location the challenge advertises. +pub(crate) fn is_resource_metadata_path(path: &str) -> bool { + path.strip_prefix(RESOURCE_METADATA_PATH) + .is_some_and(|rest| rest.is_empty() || rest.starts_with('/')) +} + +/// The metadata URL a client derives from the resource identifier (RFC 9728 +/// §3.1): the well-known prefix inserted before the resource's path. +fn advertised_metadata_url(resource: &str) -> Option { + let mut url = url::Url::parse(resource).ok()?; + let path = format!( + "{RESOURCE_METADATA_PATH}{}", + url.path().trim_end_matches('/') + ); + url.set_path(&path); + url.set_query(None); + url.set_fragment(None); + Some(url) +} + +/// The metadata path this server advertises, fixed by its boot binding. +#[derive(Clone)] +pub(crate) struct AdvertisedMetadataPath(Arc); /// Mount only when an explicitly configured OIDC resource enables this surface. /// Every HTTP request authenticates again; no MCP session retains an actor. @@ -75,13 +131,24 @@ pub(crate) fn router(state: AppState) -> Router { state.clone(), handlers::require_bearer_auth, )) - .route_layer(middleware::from_fn_with_state(state, resource_challenge)); - Router::new() - .route( - "/.well-known/oauth-protected-resource", - get(resource_metadata), - ) - .merge(protected) + .route_layer(middleware::from_fn_with_state( + state.clone(), + resource_challenge, + )); + // Computed once from the fixed binding. A wildcard route compared with the + // raw request path keeps the configured path out of the router's syntax. + let advertised = state + .oidc_resource_url() + .and_then(|resource| advertised_metadata_url(&resource)) + .map(|url| AdvertisedMetadataPath(Arc::from(url.path()))); + let mut discovery = Router::new().route(RESOURCE_METADATA_PATH, get(resource_metadata)); + if let Some(advertised) = advertised { + discovery = discovery.route( + &format!("{RESOURCE_METADATA_PATH}/{{*resource_path}}"), + get(resource_metadata_at_resource_path).layer(Extension(advertised)), + ); + } + discovery.merge(protected) } #[utoipa::path(get, path = "/.well-known/oauth-protected-resource", tag = "management", @@ -95,25 +162,57 @@ pub(crate) async fn resource_metadata(State(state): State) -> Response } } +/// The same metadata at the location the `/mcp` challenge advertises for a +/// resource identifier with a path: the well-known prefix followed by that +/// path (`/.well-known/oauth-protected-resource/mcp` for `https://host/mcp`). +/// Every other suffix is 404. +#[utoipa::path(get, path = "/.well-known/oauth-protected-resource/{resource_path}", tag = "management", + operation_id = "oauthProtectedResourceMetadataAtResourcePath", + params(("resource_path" = String, Path, description = "The configured resource identifier's path, without its leading `/`")), + responses((status = 200, description = "Public OAuth protected resource metadata", body = Value), + (status = 404, description = "Not the configured resource's path, or OAuth resource identity is not configured")))] +pub(crate) async fn resource_metadata_at_resource_path( + State(state): State, + Extension(AdvertisedMetadataPath(advertised)): Extension, + uri: Uri, +) -> Response { + if uri.path() == &*advertised { + resource_metadata(State(state)).await + } else { + StatusCode::NOT_FOUND.into_response() + } +} + async fn resource_challenge( State(state): State, request: Request, next: Next, ) -> Response { + // RFC 6750 §3.1: `invalid_token` answers a presented bearer credential + // that was refused; a request without one gets no error code. + let presented = request + .headers() + .get_all(header::AUTHORIZATION) + .iter() + .any(|value| { + value + .as_bytes() + .get(..7) + .is_some_and(|scheme| scheme.eq_ignore_ascii_case(b"bearer ")) + }); let mut response = next.run(request).await; if response.status() == StatusCode::UNAUTHORIZED { - if let Some(mut url) = state + if let Some(url) = state .oidc_resource_url() - .and_then(|url| url::Url::parse(&url).ok()) + .and_then(|resource| advertised_metadata_url(&resource)) { - let path = format!( - "/.well-known/oauth-protected-resource{}", - url.path().trim_end_matches('/') - ); - url.set_path(&path); - url.set_query(None); - url.set_fragment(None); - if let Ok(value) = HeaderValue::from_str(&format!("Bearer resource_metadata=\"{url}\"")) + let error = if presented { + "error=\"invalid_token\", " + } else { + "" + }; + if let Ok(value) = + HeaderValue::from_str(&format!("Bearer {error}resource_metadata=\"{url}\"")) { response .headers_mut() @@ -151,12 +250,29 @@ struct QueryArguments { #[serde(default)] branch: Option, } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ReadArguments { + graph: String, + query: String, + #[serde(default)] + name: Option, + #[serde(default)] + params: Option>, + #[serde(default)] + branch: Option, + #[serde(default)] + snapshot: Option, +} impl ServerHandler for GraphTools { fn get_info(&self) -> ServerInfo { ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) - .with_server_info(Implementation::new("omnigraph-server", env!("CARGO_PKG_VERSION"))) - .with_instructions("Discover graph names, list readable stored queries, then invoke a named read. Applied graph policy governs every read. Mutation tools are unavailable.") + .with_server_info(Implementation::new( + "omnigraph-server", + env!("CARGO_PKG_VERSION"), + )) + .with_instructions(instructions(self.state.mcp_settings())) } async fn list_tools( @@ -164,11 +280,15 @@ impl ServerHandler for GraphTools { _: Option, _: RequestContext, ) -> Result { - Ok(ListToolsResult::with_all_items(tools())) + Ok(ListToolsResult::with_all_items(tools( + self.state.mcp_settings(), + ))) } fn get_tool(&self, name: &str) -> Option { - tools().into_iter().find(|tool| tool.name == name) + tools(self.state.mcp_settings()) + .into_iter() + .find(|tool| tool.name == name) } async fn call_tool( @@ -312,6 +432,35 @@ impl GraphTools { .0; bounded_result(&output) } + "schema" => { + let args: GraphArguments = arguments_as(arguments)?; + let (handle, actor) = self.graph(&args.graph, actor, &graph)?; + let output = handlers::server_schema_get(Extension(handle), Some(Extension(actor))) + .await? + .0; + bounded_result(&output) + } + "read" if self.state.mcp_settings().allow_adhoc => { + let args: ReadArguments = arguments_as(arguments)?; + let (handle, actor) = self.graph(&args.graph, actor, &graph)?; + let request: crate::api::QueryRequest = serde_json::from_value(json!({ + "query": args.query, + "name": args.name, + "params": args.params, + "branch": args.branch, + "snapshot": args.snapshot, + })) + .map_err(|_| ApiError::bad_request("invalid MCP tool arguments"))?; + let output = handlers::server_query( + State(self.state.clone()), + Extension(handle), + Some(Extension(actor)), + Ok(Json(request)), + ) + .await? + .0; + bounded_result(&output) + } _ => Err(ApiError::bad_request("unknown MCP tool")), } } @@ -339,14 +488,242 @@ fn arguments_as(value: Value) -> Result Vec { - let text = json!({"type":"string","minLength":1}); - [ - ("graphs", "List effective graph identifiers and display names.", json!({"type":"object","properties":{},"additionalProperties":false})), - ("queries", "List stored reads visible under the graph's applied read policy. Invocation is separately authorized.", json!({"type":"object","properties":{"graph":text},"required":["graph"],"additionalProperties":false})), - ("query", "Invoke a named stored read under the graph's applied policy. Defaults to the main branch; mutations are refused.", json!({"type":"object","properties":{"graph":text,"name":text,"params":{"type":"object"},"branch":text},"required":["graph","name"],"additionalProperties":false})), - ].into_iter().map(|(name, description, schema)| Tool::new(name, description, schema.as_object().expect("literal object").clone()) - .with_annotations(ToolAnnotations::new().read_only(true))).collect() +/// Which boot setting, if any, lists and admits a tool. +#[derive(Clone, Copy)] +enum Gate { + Always, + Adhoc, +} + +impl Gate { + fn open(self, settings: &McpSettings) -> bool { + match self { + Gate::Always => true, + Gate::Adhoc => settings.allow_adhoc, + } + } +} + +struct ToolSpec { + name: &'static str, + title: &'static str, + description: &'static str, + input: fn() -> Value, + /// Success shape, for tools whose result is fixed. The declared output + /// schema also admits the error envelope: clients validate any structured + /// content against it, including an `isError` result's. + output: Option Value>, + gate: Gate, +} + +const TOOLS: &[ToolSpec] = &[ + ToolSpec { + name: "graphs", + title: "List graphs", + description: "List the graphs this server serves: each graph's `graph_id` and display name. Every other tool takes one of these ids as `graph`. Listing a graph does not mean your credential may read it.", + input: no_arguments_schema, + output: Some(graphs_output_schema), + gate: Gate::Always, + }, + ToolSpec { + name: "schema", + title: "Read a graph's schema", + description: "Return a graph's schema as `.pg` source (`schema_source`): its node and edge types, properties with their types, keys, edge endpoints and declared embeddings. Call this before writing any query: GQ names types, properties and edges exactly as declared here, and edges are matched in lowerCamelCase.", + input: graph_schema, + output: Some(schema_output_schema), + gate: Gate::Always, + }, + ToolSpec { + name: "queries", + title: "List stored reads", + description: "List a graph's stored reads: each `name`, its description and instruction, and typed `params` (`name`, `kind`, `nullable`; `item_kind` for lists, `vector_dim` for vectors). A non-nullable parameter is required. Run one with `query`. Invocation is authorized separately, so a listed read can still be refused.", + input: graph_schema, + output: Some(queries_output_schema), + gate: Gate::Always, + }, + ToolSpec { + name: "query", + title: "Run a stored read", + description: "Run a stored read by `name` with `params` keyed by its declared parameter names (see `queries`). Reads `branch`, `main` when omitted. Returns `rows`, `row_count` and the read's `graph_commit_id`. Stored mutations are refused.", + input: query_schema, + output: None, + gate: Gate::Always, + }, + ToolSpec { + name: "read", + title: "Run a GQ read", + description: "Run a GQ read query you write. Call `schema` first. Declare every value as a typed parameter (`query people($min: I32) { match { $p: Person $p.age >= $min } return { $p.name } }`) and pass the values in `params`; pick one declared query with `name` when the source declares several. Reads `branch` (default `main`) or a `snapshot`, never both. Search operators (`nearest`, `bm25`, `rrf`) need a trailing `limit`. Also answers `branch list`, `branch diff [into ]`, `branch merge-base …`, `branch merge … dry run` and `explain query …`, sent without `branch`, `snapshot`, `name` or `params`. Mutations and branch writes are refused. Returns `rows`, `row_count` and `graph_commit_id`.", + input: read_schema, + output: None, + gate: Gate::Adhoc, + }, +]; + +fn instructions(settings: &McpSettings) -> String { + let mut text = String::from( + "OmniGraph serves typed property graphs. Every call runs under the applied policy for your credential; a listed tool or graph can still be refused.\n\ + 1. Call `graphs` for graph ids.\n\ + 2. Call `schema` for the graph before writing a query: it declares the types, properties, keys and edges a query may name.\n\ + 3. Prefer a stored read: `queries` lists them with typed parameters, and `query` runs one.\n", + ); + if settings.allow_adhoc { + text.push_str( + "4. Otherwise write a GQ read and run it with `read`, with every value passed as a declared, typed parameter. `read` refuses mutations.\n", + ); + } + text.push_str( + "Results carry the read's `graph_commit_id`. A result is limited to 1 MiB and a read to 30 seconds; narrow the query with filters or `limit` when a result is too large. A refused call returns `isError` with the HTTP `status` and the server's `error` body, or an MCP `code` and `message`.", + ); + text +} + +fn tools(settings: &McpSettings) -> Vec { + TOOLS + .iter() + .filter(|spec| spec.gate.open(settings)) + .map(|spec| { + let mut tool = Tool::new(spec.name, spec.description, object((spec.input)())) + .with_title(spec.title) + .with_annotations(ToolAnnotations::new().read_only(true).open_world(false)); + if let Some(output) = spec.output { + tool = tool.with_raw_output_schema(Arc::new(object(json!({ + "type": "object", + "anyOf": [output(), error_schema()], + })))); + } + tool + }) + .collect() +} + +fn object(value: Value) -> serde_json::Map { + match value { + Value::Object(map) => map, + _ => unreachable!("tool schemas are JSON object literals"), + } +} + +fn text_schema(description: &str) -> Value { + json!({"type": "string", "minLength": 1, "description": description}) +} + +fn graph_argument() -> Value { + text_schema("Graph id, from `graphs`.") +} + +fn no_arguments_schema() -> Value { + json!({"type": "object", "properties": {}, "additionalProperties": false}) +} + +fn graph_schema() -> Value { + json!({ + "type": "object", + "properties": {"graph": graph_argument()}, + "required": ["graph"], + "additionalProperties": false, + }) +} + +fn query_schema() -> Value { + json!({ + "type": "object", + "properties": { + "graph": graph_argument(), + "name": text_schema("Stored read name, from `queries`."), + "params": {"type": "object", "description": "Values keyed by the read's declared parameter names."}, + "branch": text_schema("Branch to read. Defaults to `main`."), + }, + "required": ["graph", "name"], + "additionalProperties": false, + }) +} + +fn read_schema() -> Value { + json!({ + "type": "object", + "properties": { + "graph": graph_argument(), + "query": text_schema("GQ source declaring one or more read queries, or one branch read or `explain` statement."), + "name": text_schema("Which declared query to run when `query` declares several."), + "params": {"type": "object", "description": "Values keyed by the query's declared parameter names, without `$`."}, + "branch": text_schema("Branch to read. Defaults to `main`. Not with `snapshot`."), + "snapshot": text_schema("Graph commit id to read instead of a branch head."), + }, + "required": ["graph", "query"], + "additionalProperties": false, + }) +} + +/// The `isError` envelope: an HTTP refusal (`status` and the route's `error` +/// body) or an MCP-side refusal (`code` and `message`). +fn error_schema() -> Value { + json!({ + "type": "object", + "properties": { + "status": {"type": "integer"}, + "error": {"type": "object"}, + "code": {"type": "string"}, + "message": {"type": "string"}, + }, + "additionalProperties": false, + }) +} + +fn graphs_output_schema() -> Value { + json!({ + "type": "object", + "properties": {"graphs": {"type": "array", "items": { + "type": "object", + "properties": {"graph_id": {"type": "string"}, "display_name": {"type": "string"}}, + "required": ["graph_id", "display_name"], + }}}, + "required": ["graphs"], + }) +} + +fn schema_output_schema() -> Value { + json!({ + "type": "object", + "properties": { + "schema_source": {"type": "string"}, + "system_columns": { + "type": "object", + "properties": {"id": {"type": "string"}, "src": {"type": "string"}, "dst": {"type": "string"}}, + "required": ["id", "src", "dst"], + }, + }, + "required": ["schema_source"], + }) +} + +fn queries_output_schema() -> Value { + let kind = json!({"type": "string", "enum": ["string", "bool", "int", "bigint", "float", "date", "datetime", "blob", "vector", "list"]}); + json!({ + "type": "object", + "properties": {"queries": {"type": "array", "items": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "tool_name": {"type": "string"}, + "description": {"type": "string"}, + "instruction": {"type": "string"}, + "mutation": {"type": "boolean"}, + "params": {"type": "array", "items": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "kind": kind, + "item_kind": kind, + "vector_dim": {"type": "integer"}, + "nullable": {"type": "boolean"}, + }, + "required": ["name", "kind", "nullable"], + }}, + }, + "required": ["name", "tool_name", "mutation", "params"], + }}}, + "required": ["queries"], + }) } struct LimitedBytes(Vec); diff --git a/crates/omnigraph-server/src/settings.rs b/crates/omnigraph-server/src/settings.rs index 0a688a1aa..5d20f975c 100644 --- a/crates/omnigraph-server/src/settings.rs +++ b/crates/omnigraph-server/src/settings.rs @@ -392,6 +392,7 @@ pub async fn load_server_settings_with_identity_trust( canonical_root, trust, oidc_trust, + mcp: McpSettings::default(), }) } diff --git a/crates/omnigraph-server/tests/integration/mcp.rs b/crates/omnigraph-server/tests/integration/mcp.rs index 111f7d8b4..d3db398f1 100644 --- a/crates/omnigraph-server/tests/integration/mcp.rs +++ b/crates/omnigraph-server/tests/integration/mcp.rs @@ -60,6 +60,18 @@ fn trust(root: &std::path::Path) -> Arc { } async fn fixture_app() -> (tempfile::TempDir, Router, AppState) { + fixture_app_with(omnigraph_server::McpSettings::default()).await +} + +fn adhoc() -> omnigraph_server::McpSettings { + let mut settings = omnigraph_server::McpSettings::default(); + settings.allow_adhoc = true; + settings +} + +async fn fixture_app_with( + settings: omnigraph_server::McpSettings, +) -> (tempfile::TempDir, Router, AppState) { let temp = init_loaded_graph().await; let graph = graph_path(temp.path()); let policy = temp.path().join("policy.yaml"); @@ -139,7 +151,8 @@ async fn fixture_app() -> (tempfile::TempDir, Router, AppState) { None, ) .unwrap() - .with_oidc_identity_trust(trust(temp.path())); + .with_oidc_identity_trust(trust(temp.path())) + .with_mcp_settings(settings); (temp, omnigraph_server::build_app(state.clone()), state) } @@ -195,7 +208,7 @@ async fn oidc_mcp_reuses_discovery_cedar_and_stored_read_handlers_without_mutati .iter() .map(|t| t["name"].as_str().unwrap()) .collect(); - assert_eq!(names, vec!["graphs", "queries", "query"]); + assert_eq!(names, vec!["graphs", "schema", "queries", "query"]); for tool in list["result"]["tools"].as_array().unwrap() { assert_eq!(tool["annotations"]["readOnlyHint"], true); } @@ -356,6 +369,35 @@ async fn mcp_authenticates_every_request_and_enforces_resource_and_http_bounds() serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()).unwrap(); assert_eq!(metadata["resource"], RESOURCE); assert!(!metadata.to_string().contains("stable_alice")); + // The challenge's URL itself answers the same document, so a client + // probing the path-suffixed location first needs no proxy rewrite. + let advertised = "/.well-known/oauth-protected-resource/clusters/A/incarnations/one"; + let response = app + .clone() + .oneshot(Request::get(advertised).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()["cache-control"], "no-store"); + assert!(!response.headers().contains_key(HTTP_API_CONTRACT_HEADER)); + let at_path: Value = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()).unwrap(); + assert_eq!(at_path, metadata); + for other in [ + "/.well-known/oauth-protected-resource/clusters/A", + "/.well-known/oauth-protected-resource/clusters/A/incarnations/one/", + "/.well-known/oauth-protected-resource/clusters/A/incarnations/two", + "/.well-known/oauth-protected-resource/clusters/A/incarnations/one/extra", + "/.well-known/oauth-protected-resource/mcp", + "/.well-known/oauth-protected-resource/clusters/%41/incarnations/one", + ] { + let response = app + .clone() + .oneshot(Request::get(other).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND, "{other}"); + } let response = app .clone() .oneshot(Request::get("/no-such-route").body(Body::empty()).unwrap()) @@ -373,15 +415,29 @@ async fn mcp_authenticates_every_request_and_enforces_resource_and_http_bounds() token("unknown", RESOURCE), "ogc_retired".into(), ] { + let response = app + .clone() + .oneshot(rpc(Some(&wrong), "tools/list", json!({}))) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + // RFC 6750: a presented, refused bearer is `invalid_token`. assert_eq!( - app.clone() - .oneshot(rpc(Some(&wrong), "tools/list", json!({}))) - .await - .unwrap() - .status(), - StatusCode::UNAUTHORIZED + response.headers()["www-authenticate"], + "Bearer error=\"invalid_token\", resource_metadata=\"https://data.example/.well-known/oauth-protected-resource/clusters/A/incarnations/one\"" ); } + // Another scheme is not a presented bearer credential. + let mut basic = rpc(None, "tools/list", json!({})); + basic + .headers_mut() + .insert("authorization", "Basic YWxpY2U6eA==".parse().unwrap()); + let response = app.clone().oneshot(basic).await.unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!( + response.headers()["www-authenticate"], + "Bearer resource_metadata=\"https://data.example/.well-known/oauth-protected-resource/clusters/A/incarnations/one\"" + ); let mut forged = rpc(None, "tools/list", json!({})); forged .extensions_mut() @@ -555,3 +611,213 @@ async fn mcp_requires_explicit_resource_identity_configuration() { StatusCode::NOT_FOUND ); } + +async fn tool_list(app: &Router, token: &str) -> Vec { + let (status, list) = json_response(app, rpc(Some(token), "tools/list", json!({}))).await; + assert_eq!(status, StatusCode::OK, "{list}"); + list["result"]["tools"].as_array().unwrap().clone() +} + +async fn instructions(app: &Router, token: &str) -> String { + let (status, body) = json_response( + app, + rpc( + Some(token), + "initialize", + json!({"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"fixture","version":"1"}}), + ), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + body["result"]["instructions"].as_str().unwrap().to_owned() +} + +/// One line per tool: name, required arguments, read-only hint, whether an +/// output schema is declared. The opt-in `read` tool appears only when the +/// server enables it. +#[tokio::test] +async fn mcp_tool_list_follows_the_adhoc_setting_and_describes_every_tool() { + let alice = token("alice", RESOURCE); + for (settings, expected) in [ + ( + omnigraph_server::McpSettings::default(), + vec![ + "graphs [] read_only output", + "schema [graph] read_only output", + "queries [graph] read_only output", + "query [graph,name] read_only", + ], + ), + ( + adhoc(), + vec![ + "graphs [] read_only output", + "schema [graph] read_only output", + "queries [graph] read_only output", + "query [graph,name] read_only", + "read [graph,query] read_only", + ], + ), + ] { + let enabled = settings.allow_adhoc; + let (_temp, app, _state) = fixture_app_with(settings).await; + let tools = tool_list(&app, &alice).await; + let lines: Vec = tools + .iter() + .map(|tool| { + let required: Vec<&str> = tool["inputSchema"]["required"] + .as_array() + .map(|names| names.iter().map(|n| n.as_str().unwrap()).collect()) + .unwrap_or_default(); + assert_eq!(tool["inputSchema"]["additionalProperties"], false); + assert!(tool["title"].as_str().is_some_and(|t| !t.is_empty())); + assert!(tool["description"].as_str().unwrap().len() > 60); + assert_eq!(tool["annotations"]["openWorldHint"], false); + let mut line = format!( + "{} [{}]{}", + tool["name"].as_str().unwrap(), + required.join(","), + if tool["annotations"]["readOnlyHint"] == true { + " read_only" + } else { + "" + } + ); + if let Some(output) = tool.get("outputSchema") { + assert_eq!(output["type"], "object"); + assert_eq!(output["anyOf"].as_array().unwrap().len(), 2); + line.push_str(" output"); + } + line + }) + .collect(); + assert_eq!(lines, expected); + let text = instructions(&app, &alice).await; + assert!(text.contains("Call `schema`"), "{text}"); + assert_eq!(text.contains("`read`"), enabled, "{text}"); + if !enabled { + let refused = call(&app, &alice, "read", json!({"graph":"default","query":"query q() { match { $p: Person } return { $p.name } }"})).await; + assert!(refused.get("error").is_some(), "{refused}"); + } + } +} + +#[tokio::test] +async fn mcp_schema_and_adhoc_read_reuse_their_routes_and_policy() { + let (temp, app, _state) = fixture_app_with(adhoc()).await; + let alice = token("alice", RESOURCE); + let bob = token("bob", RESOURCE); + + // `schema` answers exactly the route's body. + let schema = call(&app, &alice, "schema", json!({"graph":"default"})).await; + assert_eq!(schema["result"]["isError"], false, "{schema}"); + let (status, native) = json_response(&app, get_request("/graphs/default/schema", &alice)).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(schema["result"]["structuredContent"], native); + assert!( + native["schema_source"] + .as_str() + .unwrap() + .contains("node Person") + ); + // `queries` carries each stored read's typed parameters. + let catalog = call(&app, &alice, "queries", json!({"graph":"default"})).await; + let person = catalog["result"]["structuredContent"]["queries"] + .as_array() + .unwrap() + .iter() + .find(|query| query["name"] == "person") + .unwrap() + .clone(); + assert_eq!( + person["params"], + json!([{"name":"name","kind":"string","nullable":false}]) + ); + + // `read` answers exactly `POST /query`'s body, including branch reads. + let source = "query adults($min: I32) { match { $p: Person $p.age >= $min } return { $p.name } order { $p.name asc } }"; + for arguments in [ + json!({"graph":"default","query":source,"params":{"min":30}}), + json!({"graph":"default","query":source,"params":{"min":30},"branch":"main"}), + json!({"graph":"default","query":"branch list"}), + ] { + let read = call(&app, &alice, "read", arguments.clone()).await; + assert_eq!(read["result"]["isError"], false, "{read}"); + let mut body = arguments.clone(); + body.as_object_mut().unwrap().remove("graph"); + let request = Request::post("/graphs/default/query") + .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) + .header("content-type", "application/json") + .header("authorization", format!("Bearer {alice}")) + .body(Body::from(body.to_string())) + .unwrap(); + let (status, native) = json_response(&app, request).await; + assert_eq!(status, StatusCode::OK, "{native}"); + assert_eq!(read["result"]["structuredContent"], native); + } + + // A denied actor gets the route's refusal in the shared envelope. + for (tool, arguments) in [ + ("schema", json!({"graph":"default"})), + ( + "read", + json!({"graph":"default","query":source,"params":{"min":1}}), + ), + ] { + let denied = call(&app, &bob, tool, arguments).await; + assert_eq!(denied["result"]["isError"], true, "{denied}"); + assert_eq!( + denied["result"]["structuredContent"]["status"], 403, + "{denied}" + ); + assert_eq!( + denied["result"]["structuredContent"]["error"]["code"], + "forbidden" + ); + } + + // Writes never pass through `read`, and leave the graph unchanged. + let before = manifest_dataset_version(&graph_path(temp.path())).await; + for query in [ + "query add() { insert Person { name: \"Mallory\", age: 1 } }", + "query bump() { update Person set { age: 99 } where name = \"Alice\" }", + "query drop() { delete Person where name = \"Alice\" }", + "branch create feature from main", + ] { + let refused = call( + &app, + &alice, + "read", + json!({"graph":"default","query":query}), + ) + .await; + assert_eq!(refused["result"]["isError"], true, "{query}: {refused}"); + assert_eq!( + refused["result"]["structuredContent"]["status"], 400, + "{query}: {refused}" + ); + } + let unknown = call( + &app, + &alice, + "read", + json!({"graph":"default","query":"branch list","settings":{}}), + ) + .await; + assert_eq!(unknown["result"]["isError"], true, "{unknown}"); + assert_eq!( + manifest_dataset_version(&graph_path(temp.path())).await, + before + ); + + // The request bound applies to the inline source as to any MCP request. + let oversized = rpc( + Some(&alice), + "tools/call", + json!({"name":"read","arguments":{"graph":"default","query":format!("// {}\nbranch list", "x".repeat(64 * 1024))}}), + ); + assert_eq!( + app.clone().oneshot(oversized).await.unwrap().status(), + StatusCode::PAYLOAD_TOO_LARGE + ); +} diff --git a/crates/omnigraph-server/tests/integration/openapi.rs b/crates/omnigraph-server/tests/integration/openapi.rs index d8cbcbd7f..7daf73c1a 100644 --- a/crates/omnigraph-server/tests/integration/openapi.rs +++ b/crates/omnigraph-server/tests/integration/openapi.rs @@ -192,6 +192,7 @@ fn openapi_info_contains_version() { // protected route nests under `/graphs/{graph_id}/…`. const EXPECTED_PATHS: &[&str] = &[ "/.well-known/oauth-protected-resource", + "/.well-known/oauth-protected-resource/{resource_path}", "/healthz", "/readyz", "/graphs", @@ -2191,7 +2192,11 @@ async fn auth_mode_healthz_still_has_no_security() { .body(Body::empty()) .unwrap(); let (_, json) = json_response(&app, request).await; - for path in ["/healthz", "/.well-known/oauth-protected-resource"] { + for path in [ + "/healthz", + "/.well-known/oauth-protected-resource", + "/.well-known/oauth-protected-resource/{resource_path}", + ] { let operation = &json["paths"][path]["get"]; assert!( operation.get("security").is_none() || operation["security"].is_null(), @@ -2369,6 +2374,7 @@ async fn multi_mode_openapi_keeps_management_paths_flat() { "/cluster/deployments", "/cluster/deployments/{id}", "/.well-known/oauth-protected-resource", + "/.well-known/oauth-protected-resource/{resource_path}", ] { assert!( paths.contains_key(flat), @@ -2408,6 +2414,7 @@ async fn multi_mode_openapi_prefixes_operation_ids_with_cluster() { | "/cluster/deployments" | "/cluster/deployments/{id}" | "/.well-known/oauth-protected-resource" + | "/.well-known/oauth-protected-resource/{resource_path}" ) { continue; } @@ -2480,6 +2487,7 @@ async fn multi_mode_openapi_declares_graph_id_path_parameter() { "/cluster/deployments", "/cluster/deployments/{id}", "/.well-known/oauth-protected-resource", + "/.well-known/oauth-protected-resource/{resource_path}", ] { let item = paths.get(flat).unwrap(); for method in ["get", "head", "post", "put", "delete", "patch"] { @@ -2611,7 +2619,7 @@ fn openapi_describes_api_contract_admission_and_response_identity() { .iter() .filter(|parameter| parameter["name"] == HTTP_API_CONTRACT_HEADER) .collect(); - let oauth = path == "/.well-known/oauth-protected-resource"; + let oauth = path.starts_with("/.well-known/oauth-protected-resource"); let protected = path == "/graphs" || path.starts_with("/graphs/") || path.starts_with("/cluster/"); if protected { diff --git a/docs/rfcs/2026-09-09-identity-credentials-and-applied-policy.md b/docs/rfcs/2026-09-09-identity-credentials-and-applied-policy.md index e48c09ae3..d61398322 100644 --- a/docs/rfcs/2026-09-09-identity-credentials-and-applied-policy.md +++ b/docs/rfcs/2026-09-09-identity-credentials-and-applied-policy.md @@ -309,9 +309,11 @@ per-cluster native signing key. When OIDC trust is configured, the server exposes protected-resource metadata and a stateless `/mcp` transport built on the official Rust MCP SDK. Its -initial tools provide graph existence, stored-read catalog and stored-read -invocation through the same HTTP handler functions and Cedar gates. The +initial tools provided graph existence, stored-read catalog and stored-read +invocation through the same HTTP handler functions and Cedar gates; the read-kind assertion prevents invoking a mutation through the read tool. +[RFC 0003](0003-mcp-server-surface.md) owns the tool catalog, discovery paths +and mounting from then on. Protocol framing, negotiation and cancellation use the SDK. Requests are bounded to 64 KiB, returned tool data to 1 MiB, read attempts to 30 seconds and concurrent tools to 16. Tool visibility is not permission to invoke. diff --git a/docs/rfcs/2026-09-26-self-contained-server-testing.md b/docs/rfcs/2026-09-26-self-contained-server-testing.md index 87828f9c9..c70002d10 100644 --- a/docs/rfcs/2026-09-26-self-contained-server-testing.md +++ b/docs/rfcs/2026-09-26-self-contained-server-testing.md @@ -175,7 +175,7 @@ separately. |---|---| | `omnigraph-gqt` runner (`src/lib.rs`) | Case parsing, server epoch identities, comparisons, capability admission and terminal reporting | | `omnigraph-gqt` supervisor (`src/dst_runner.rs`) | Immutable input, worker/server/helper lifetime, real deadlines, replay and containment | -| `omnigraph-gqt` client adapters, one per API (`engine`, `http`, `cli`; `mcp` once an MCP tool executes an authored GQ read) | Dispatch of each step by its header's API against the one owned server; the physical read after containment | +| `omnigraph-gqt` client adapters, one per API (`engine`, `http`, `cli`, `mcp`) | Dispatch of each step by its header's API against the one owned server; the physical read after containment | | CLI/server process support (`crates/omnigraph-cli/tests/support/mod.rs`: `converged_loaded_cluster`, `spawn_server_with_cluster`, `TestServer`) | Local cluster provisioning, production connection adapter, readiness and lifecycle controls | | `omnigraph-dst` `run_universe` | One controlled scheduling, time, entropy and storage environment; its server consumer is owned by the Deterministic server execution proposal | | Engine test support (`crates/omnigraph/tests/helpers/`) | Real engine calls, storage controls and physical-state oracles | @@ -209,7 +209,7 @@ that path. | Step header | Where the step goes under the `omnigraph-server` route | |---|---| | `via http` | The runner's real HTTP client, kernel TCP on loopback, the real server process and its listener, `axum::serve`, router, authorization, handler, the embedded engine, and the response back over the same socket. | -| `via mcp` | The server's MCP surface at `/mcp` (`crates/omnigraph-server/src/mcp.rs`, streamable HTTP, mounted only when an OIDC resource is configured). Its shipped tools invoke stored reads only, so `via mcp` is refused before fixture effects as `unsupported_capability: mcp` until a tool that executes an authored GQ read exists ([RFC 0003](0003-mcp-server-surface.md) or its amendment); `via mcp` is admitted on `--- query` only. | +| `via mcp` | The server's MCP surface at `/mcp` (`crates/omnigraph-server/src/mcp.rs`, streamable HTTP, mounted only when an OIDC resource is configured). A `--- query` step runs its authored GQ read through the `read` tool ([RFC 0003](0003-mcp-server-surface.md)), so the owned server starts with `--mcp-allow-adhoc`; `via mcp` is admitted on `--- query` only. | | `--- cli` | The `omnigraph` binary spawned by the runner in served mode against the same listener, so the CLI's own HTTP client crosses the same socket path; its evidence is exit code, stdout and stderr. | | `via engine` | On a `--- query`, a read-only `Omnigraph` open of the owned root, admitted only after a lifecycle step has contained the serving process: the physical read. `--- mutate via engine` in such a file is refused as `invalid_case`. | diff --git a/docs/user/index.md b/docs/user/index.md index 9277096c8..65619cc62 100644 --- a/docs/user/index.md +++ b/docs/user/index.md @@ -34,6 +34,7 @@ deployment. - [Managed data credentials and queries](cli/managed-data.md) - [Managed creation, configuration and deletion](cli/managed-lifecycle.md) - [HTTP server](operations/server.md) +- [MCP](operations/mcp.md) - [Troubleshooting](operations/troubleshooting.md) ## Operate a deployment diff --git a/docs/user/operations/mcp.md b/docs/user/operations/mcp.md new file mode 100644 index 000000000..cf1671064 --- /dev/null +++ b/docs/user/operations/mcp.md @@ -0,0 +1,110 @@ +# MCP + +`omnigraph-server` serves the Model Context Protocol at `/mcp`, so Claude, +ChatGPT, Cursor and other MCP clients can read a graph with a URL and the +caller's own credential. It is the only OmniGraph MCP implementation. + +Every tool calls the same handler as its HTTP route, under the same actor and +the same [policy](policy.md) action. A tool appearing in the list is not +permission to use it: a call the policy refuses returns the route's refusal. + +## Enable it + +`/mcp` is mounted when the server is started with an +[OIDC resource identity](server.md#oidc-resource-identities-and-mcp) +(`--oidc-identity-trust`). The resource identifier in that file is the URL users +enter in their MCP client, for example `https://graphs.example.com/mcp`. + +The endpoint is stateless Streamable HTTP built on the official Rust MCP SDK: +each request is a `POST` that carries its own bearer credential and gets a JSON +response. There is no MCP session, and a successful `initialize` never admits a +later unauthenticated request. + +### Sign-in and discovery + +A request without a valid credential is refused with 401 before its body is +read. The response's challenge names the protected-resource metadata: + +```text +WWW-Authenticate: Bearer resource_metadata="https://graphs.example.com/.well-known/oauth-protected-resource/mcp" +``` + +When a bearer credential was presented and refused (expired, wrong audience, +unknown subject), the challenge also carries `error="invalid_token"`, which tells +a client to refresh or sign in again. + +The server answers the metadata both at `/.well-known/oauth-protected-resource` +and at the advertised path: the well-known prefix followed by the resource +identifier's path (`/mcp` above). Any other path below the prefix is 404. No +proxy rewrite is needed. + +### Host and Origin + +The SDK refuses a request whose `Host` is not allowed with 403. Allowed hosts +are `localhost`, `127.0.0.1`, `::1` and the resource identifier's authority. +A browser `Origin` must be the resource identifier's origin; native clients send +no `Origin`. + +## Settings + +| Flag | Environment | Default | Effect | +|---|---|---|---| +| `--mcp-allow-adhoc` | `OMNIGRAPH_MCP_ALLOW_ADHOC=1` | off | Lists and admits the `read` tool | + +The setting is read once at boot. It enables a tool; it grants nothing. When it +is off, `read` is absent from `tools/list` and a call to it is refused as an +unknown tool. + +## Tools + +| Tool | Arguments | Runs | Policy action | +|---|---|---|---| +| `graphs` | none | `GET /graphs/discovery` | any authenticated identity | +| `schema` | `graph` | `GET /graphs/{graph}/schema` | `read` | +| `queries` | `graph` | `GET /graphs/{graph}/queries`, stored mutations removed | `read` | +| `query` | `graph`, `name`, optional `params`, `branch` | `POST /graphs/{graph}/queries/{name}` as a read | `invoke_query`, then `read` | +| `read` (opt-in) | `graph`, `query`, optional `name`, `params`, `branch`, `snapshot` | `POST /graphs/{graph}/query` | `read` | + +All tools are read-only. The server's `initialize` instructions tell a model to +call `graphs`, then `schema`, then a stored read with `query` or, when enabled, +its own GQ read with `read`. + +- `queries` lists each stored read with its typed parameters (`name`, `kind`, + `nullable`, plus `item_kind` for lists and `vector_dim` for vectors). +- `query` refuses stored mutations. +- `read` takes GQ source with typed parameter declarations, the same body as + `POST /query`. It also answers `branch list`, `branch diff`, + `branch merge-base`, `branch merge … dry run` and `explain query …`. It refuses + `insert`, `update`, `delete` and branch writes with the route's 400. + +## Results and errors + +A successful call returns the route's JSON response body as structured content, +plus a text copy for older clients. `graphs`, `schema` and `queries` declare an +output schema. + +A refused call returns `isError: true` with one of two shapes: + +- `{"status": , "error": }` when the route + refused it, for example 403 for a policy denial, 404 for an unknown or hidden + graph, 503 for a graph that is not available; +- `{"code": "...", "message": "..."}` when the MCP layer refused it: + `capacity_exceeded`, `cancelled`, `deadline_exceeded` or `result_too_large`. + +## Limits + +| Limit | Value | +|---|---| +| Request body | 64 KiB | +| Complete tool result, including the text copy | 1 MiB | +| Wait for one read | 30 seconds | +| Concurrent tool executions | 16 | + +A cancelled or expired call stops the caller's wait. The execution keeps its +input reservation and slot until it finishes, and there is no later result +lookup. See [admission and shutdown](../deployment.md). + +## Logs + +Verbose MCP SDK logs contain query arguments and results, so the server keeps +them out of its log; see [Logs](server.md#logs). diff --git a/docs/user/operations/server.md b/docs/user/operations/server.md index 2ffed9fa7..209f9a549 100644 --- a/docs/user/operations/server.md +++ b/docs/user/operations/server.md @@ -186,30 +186,10 @@ never fetch keys or call a control service, so publisher outages eventually prevent new OIDC access; accepted operations can finish. Admission refresh does not restart writers. Graph configuration retains its normal activation. -With this profile configured, the server additionally exposes: - -- `GET /.well-known/oauth-protected-resource`: public resource identifier, - authorization server and supported bearer delivery, without identity lists. -- `/mcp`: Streamable HTTP MCP using the maintained Rust SDK. Authentication - failures advertise protected-resource metadata for standard OAuth clients. - -The initial MCP tools are `graphs` (IDs and names), `queries` (permitted stored -read names for one graph), and `query` (a named stored read with parameters and -an optional branch). Mutation definitions are excluded and cannot be invoked -through a read tool. These tools use the same actor and Cedar checks as HTTP -graph requests. Limits are 30 seconds to wait, 16 concurrent executions, -64 KiB requests and 1 MiB results. Cancelled or expired callers stop waiting; -execution retains its input and slot until it finishes, with no later result -lookup. See [admission and shutdown](../deployment.md). - -Verbose MCP SDK logs contain query arguments and results, so the server keeps -them out of its log; see [Logs](#logs). - -Requests require the resource authority or a loopback host; browsers must use -the resource origin, while native clients can omit `Origin`. The deployment -must separately supply a reachable server URL and public metadata at the -advertised resource location. Direct/static deployments without this option -keep their existing routes and do not expose MCP. +With this profile configured, the server also serves public +protected-resource metadata and an OAuth challenge on `/mcp`, so standard OAuth +clients such as Claude can sign in. The [MCP guide](mcp.md) describes the +endpoint, its tools, settings and limits. ## Route families @@ -222,8 +202,8 @@ keep their existing routes and do not expose MCP. | `GET /graphs/discovery` | Graph IDs and display names only; requires an identity credential | | `POST /cluster/deployments` | Submit an exact-ID deployment: graph creation/deletion or schema, query, policy, provider and Blob-binding changes | | `GET /cluster/deployments`, `GET /cluster/deployments/{id}` | Authorized deployment status and current-process activation observation | -| `GET /.well-known/oauth-protected-resource` | Public OIDC resource metadata; only when OIDC trust is configured | -| `/mcp` | Stored reads and discovery over MCP; only when OIDC trust is configured | +| `GET /.well-known/oauth-protected-resource`, also at the resource's path suffix | Public OIDC resource metadata; only when OIDC trust is configured | +| `/mcp` | The [MCP endpoint](mcp.md); only when OIDC trust is configured | | `/graphs/{id}/query`, `/mutate` | Run inline GQ source | | `/graphs/{id}/mutate/if-graph-commit` | Run an inline conditional mutation | | `/graphs/{id}/queries` | List and invoke stored queries, including conditional mutations | diff --git a/openapi.json b/openapi.json index 27928ac54..95a735bc6 100644 --- a/openapi.json +++ b/openapi.json @@ -31,6 +31,39 @@ } } }, + "/.well-known/oauth-protected-resource/{resource_path}": { + "get": { + "tags": [ + "management" + ], + "summary": "The same metadata at the location the `/mcp` challenge advertises for a\nresource identifier with a path: the well-known prefix followed by that\npath (`/.well-known/oauth-protected-resource/mcp` for `https://host/mcp`).\nEvery other suffix is 404.", + "operationId": "oauthProtectedResourceMetadataAtResourcePath", + "parameters": [ + { + "name": "resource_path", + "in": "path", + "description": "The configured resource identifier's path, without its leading `/`", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Public OAuth protected resource metadata", + "content": { + "application/json": { + "schema": {} + } + } + }, + "404": { + "description": "Not the configured resource's path, or OAuth resource identity is not configured" + } + } + } + }, "/cluster/deployments": { "get": { "tags": [