Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions changelog.d/mcp-schema-read-discovery.added.md
Original file line number Diff line number Diff line change
@@ -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/<resource path>`), 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking. The relative link ../docs/user/operations/mcp.md here — confirm this resolves correctly when the changelog is rendered. If the rendering context is the repository root, the ../ prefix would step out of the repo. Consider an absolute path from the repo root like docs/user/operations/mcp.md or a full URL. (This may already be the correct convention for your changelog rendering; just flagging it.)

1 change: 1 addition & 0 deletions crates/omnigraph-server/src/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions crates/omnigraph-server/src/http_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ pub(crate) async fn require_contract(request: Request, next: Next) -> Result<Res

pub(crate) async fn identify_response(request: Request, next: Next) -> 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";
Expand All @@ -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 =
Expand Down
38 changes: 35 additions & 3 deletions crates/omnigraph-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -237,6 +239,7 @@ pub struct ManagedServerConfig {
canonical_root: String,
trust: Option<data_tokens::DataTokenTrust>,
oidc_trust: Option<Arc<oidc_identity::OidcIdentityTrust>>,
mcp: McpSettings,
}

impl ManagedServerConfig {
Expand All @@ -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.
Expand Down Expand Up @@ -398,6 +407,8 @@ pub struct AppState {
bearer_tokens: Arc<[(BearerTokenHash, Arc<str>)]>,
data_token_trust: Option<Arc<data_tokens::DataTokenTrust>>,
oidc_identity_trust: Option<Arc<oidc_identity::OidcIdentityTrust>>,
/// MCP boot settings: which opt-in tools `/mcp` lists and admits.
mcp: Arc<McpSettings>,
/// Bounded process-wide ownership for queued served-export bytes. The
/// response body and detached producer jointly retain each reservation.
export_transport: export_transport::ExportTransport,
Expand Down Expand Up @@ -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()),
Expand Down Expand Up @@ -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()),
Expand Down Expand Up @@ -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<serde_json::Value> {
self.oidc_identity_trust
.as_ref()
Expand Down Expand Up @@ -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<data_tokens::DataTokenTrust>,
oidc_identity_trust: Option<Arc<oidc_identity::OidcIdentityTrust>>,
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`,
Expand Down Expand Up @@ -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
Expand Down
15 changes: 11 additions & 4 deletions crates/omnigraph-server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -50,6 +50,11 @@ struct Cli {
/// termination grace must be longer.
#[arg(long)]
shutdown_grace_seconds: Option<u64>,
/// 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]
Expand All @@ -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(
Expand All @@ -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
}
_ => {
Expand All @@ -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
}
}
}
Loading
Loading