Skip to content
Merged
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
14 changes: 14 additions & 0 deletions changelog.d/mcp-every-credential-parity-writes.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
- `/mcp` is now mounted whenever the server has a bearer credential source
(static tokens, signed data tokens or OIDC), so a self-hosted server reaches
Claude Code, Cursor and other MCP clients with its existing token; OAuth
discovery stays OIDC-only, `--mcp-allowed-host` admits extra `Host` names, and
without OIDC any request carrying an `Origin` header is refused.
New read tools `health`, `snapshot`, `branches_list`, `commits_list`,
`commits_get`, `commits_changes` and `changes_poll`, and resources for the
graph list and each graph's schema and branches, call the same handlers and
policy actions as their HTTP routes. Write tools `mutate`, `load`,
`branches_create`, `branches_delete` and `branches_merge` are listed only with
`--mcp-allow-writes`; they never retry, honour `if_graph_commit`, and report
`outcome_unknown` instead of guessing. See [MCP][mcp-every-credential-guide].

[mcp-every-credential-guide]: ../docs/user/operations/mcp.md
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
5 changes: 3 additions & 2 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 Expand Up @@ -2788,7 +2789,7 @@ async fn branch_create_body(
/// when route nesting changes" class.
#[derive(Deserialize)]
pub(crate) struct BranchPath {
branch: String,
pub(crate) branch: String,
}

#[utoipa::path(
Expand Down Expand Up @@ -3164,7 +3165,7 @@ pub(crate) async fn server_commit_list(
/// for the design rationale — same pattern, different field name.
#[derive(Deserialize)]
pub(crate) struct CommitPath {
commit_id: String,
pub(crate) commit_id: String,
}

#[utoipa::path(
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
50 changes: 46 additions & 4 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 All @@ -924,6 +948,14 @@ impl AppState {
.map(str::to_owned)
}

/// Whether any bearer credential can authenticate a request: static
/// tokens, signed data tokens or OIDC. Fixed at construction.
pub(crate) fn has_credential_source(&self) -> bool {
!self.bearer_tokens.is_empty()
|| self.data_token_trust.is_some()
|| self.oidc_identity_trust.is_some()
}

fn requires_bearer_auth(&self) -> bool {
if !self.bearer_tokens.is_empty()
|| self.data_token_trust.is_some()
Expand Down Expand Up @@ -2726,7 +2758,9 @@ pub fn build_app(state: AppState) -> Router {
.route("/readyz", get(server_ready))
.route("/openapi.json", get(server_openapi))
.merge(protected);
if state.oidc_identity_trust.is_some() {
// MCP needs an authenticated caller; without a credential source there is
// no actor for its policy checks, so the endpoint does not exist.
if state.has_credential_source() {
app = app.merge(mcp::router(state.clone()));
}
app.layer(middleware::from_fn(http_contract::identify_response))
Expand All @@ -2736,19 +2770,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 +2907,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
29 changes: 25 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,21 @@ 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,
/// List and admit the MCP write tools (`mutate`, `load`, `branches_create`,
/// `branches_delete`, `branches_merge`), each under its route's policy
/// action. Off by default. Equivalent to setting `OMNIGRAPH_MCP_ALLOW_WRITES=1`.
#[arg(long)]
mcp_allow_writes: bool,
/// A `Host` value `/mcp` admits, as `host` or `host:port`, besides the
/// loopback names and the OIDC resource authority. Repeatable; also read
/// comma-separated from `OMNIGRAPH_MCP_ALLOWED_HOSTS`.
#[arg(long = "mcp-allowed-host", value_name = "HOST")]
mcp_allowed_hosts: Vec<String>,
}

#[tokio::main]
Expand All @@ -58,6 +73,11 @@ async fn main() -> Result<()> {
init_tracing();

let cli = Cli::parse();
let mcp = McpSettings::resolve(
cli.mcp_allow_adhoc,
cli.mcp_allow_writes,
cli.mcp_allowed_hosts,
)?;
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 +89,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 +102,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