Add GET /queries stored-query catalog endpoint

List a graph's mcp.expose stored queries as a typed tool catalog so a client
(the MCP server) can register them as tools without fetching .gq source.
Each entry carries name, MCP tool_name, description/instruction, a
read/mutate flag, and decomposed typed params (kind enum: string|bool|int|
bigint|float|date|datetime|blob|vector|list, plus item_kind for lists and
vector_dim) — so the consumer builds an input schema with a closed match and
never re-parses omnigraph type spelling. I64/U64 are bigint (string on the
wire): a JSON number loses precision past 2^53 and the engine already accepts
decimal strings.

Read-gated (works in default-deny; the catalog is graph-wide, authorized
against main). NOT Cedar-filtered per query yet — a reader can list a query
whose invoke_query they lack (documented gap until per-query authz lands);
invocation stays invoke_query-gated + deny==404.

- api: QueriesCatalogOutput / QueryCatalogEntry / ParamDescriptor / ParamKind
  + query_catalog_entry (reuses PropType::from_param_type_name; scalar_kind is
  exhaustive, so a new ScalarType is a compile error here until catalogued).
- GET /queries route in per_graph_protected (→ /graphs/{id}/queries in multi
  mode); OpenAPI regenerated; path allowlists updated.
- Tests: projection unit (every kind, list, vector, nullable, mutation,
  empty) + handler (exposed-only filter, read-gate probe-oracle, empty
  registry).
This commit is contained in:
Ragnor Comerford 2026-05-31 13:09:03 +02:00
parent 6cad21cb6a
commit a087ac4476
No known key found for this signature in database
6 changed files with 481 additions and 1 deletions

View file

@ -1,8 +1,11 @@
use omnigraph::db::{GraphCommit, MergeOutcome, ReadTarget, SchemaApplyResult, Snapshot};
use omnigraph::error::{MergeConflict, MergeConflictKind};
use omnigraph::loader::{IngestResult, LoadMode};
use crate::queries::StoredQuery;
use omnigraph_compiler::SchemaMigrationStep;
use omnigraph_compiler::query::ast::Param;
use omnigraph_compiler::result::QueryResult;
use omnigraph_compiler::types::{PropType, ScalarType};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use utoipa::{IntoParams, ToSchema};
@ -330,6 +333,132 @@ pub enum InvokeStoredQueryResponse {
Change(ChangeOutput),
}
/// The kind of a stored-query parameter, decomposed so a client (e.g. an
/// MCP server) can build a typed input schema with a closed `match` and
/// never re-parse omnigraph's type spelling. `bigint`/`date`/`datetime`/
/// `blob` are carried as JSON strings on the wire: a 64-bit integer past
/// 2^53 loses precision as a JSON number, and Date/DateTime are ISO
/// strings, Blob a blob-URI string.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum ParamKind {
String,
Bool,
Int,
#[serde(rename = "bigint")]
BigInt,
Float,
Date,
#[serde(rename = "datetime")]
DateTime,
Blob,
Vector,
List,
}
/// One declared parameter of a stored query, projected for the catalog.
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ParamDescriptor {
pub name: String,
pub kind: ParamKind,
/// Element kind when `kind == list` (always a scalar — the grammar
/// forbids lists of vectors or nested lists).
#[serde(skip_serializing_if = "Option::is_none")]
pub item_kind: Option<ParamKind>,
/// Dimension when `kind == vector`.
#[serde(skip_serializing_if = "Option::is_none")]
pub vector_dim: Option<u32>,
/// `false` → the caller must supply it; `true` → optional.
pub nullable: bool,
}
/// One entry in the stored-query catalog (`GET /queries`).
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct QueryCatalogEntry {
/// Registry key / invoke path segment (`POST /queries/{name}`).
pub name: String,
/// MCP tool id (the `tool_name` override, else `name`).
pub tool_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub instruction: Option<String>,
/// `true` for a stored mutation → an MCP read-only hint of `false`.
pub mutation: bool,
pub params: Vec<ParamDescriptor>,
}
/// Response for `GET /queries`: the `mcp.expose` subset of a graph's
/// stored-query registry, each with typed parameters.
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct QueriesCatalogOutput {
pub queries: Vec<QueryCatalogEntry>,
}
/// Total map from a resolved scalar to its catalog kind. Exhaustive on
/// purpose: a new `ScalarType` is a compile error here until catalogued.
fn scalar_kind(scalar: ScalarType) -> ParamKind {
match scalar {
ScalarType::String => ParamKind::String,
ScalarType::Bool => ParamKind::Bool,
ScalarType::I32 | ScalarType::U32 => ParamKind::Int,
ScalarType::I64 | ScalarType::U64 => ParamKind::BigInt,
ScalarType::F32 | ScalarType::F64 => ParamKind::Float,
ScalarType::Date => ParamKind::Date,
ScalarType::DateTime => ParamKind::DateTime,
ScalarType::Blob => ParamKind::Blob,
ScalarType::Vector(_) => ParamKind::Vector,
}
}
fn param_descriptor(param: &Param) -> ParamDescriptor {
match PropType::from_param_type_name(&param.type_name, param.nullable) {
Some(pt) if pt.list => ParamDescriptor {
name: param.name.clone(),
kind: ParamKind::List,
item_kind: Some(scalar_kind(pt.scalar)),
vector_dim: None,
nullable: param.nullable,
},
Some(pt) => {
let (kind, vector_dim) = match pt.scalar {
ScalarType::Vector(dim) => (ParamKind::Vector, Some(dim)),
other => (scalar_kind(other), None),
};
ParamDescriptor {
name: param.name.clone(),
kind,
item_kind: None,
vector_dim,
nullable: param.nullable,
}
}
// Unreachable for a parsed query (every declared param type is
// grammatical); fall back to an opaque string so the field is still
// usable rather than dropped.
None => ParamDescriptor {
name: param.name.clone(),
kind: ParamKind::String,
item_kind: None,
vector_dim: None,
nullable: param.nullable,
},
}
}
/// Project a loaded stored query into its catalog entry (typed params,
/// MCP tool name, read/mutate flag, description/instruction).
pub fn query_catalog_entry(query: &StoredQuery) -> QueryCatalogEntry {
QueryCatalogEntry {
name: query.name.clone(),
tool_name: query.effective_tool_name().to_string(),
description: query.decl.description.clone(),
instruction: query.decl.instruction.clone(),
mutation: query.is_mutation(),
params: query.decl.params.iter().map(param_descriptor).collect(),
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)]
pub struct SchemaApplyRequest {
/// Project schema in `.pg` source form. The diff against the current