Merge branch 'main' into ragnorc/omnigraph-mcp-crate

Bring the MCP feature branch up to date with main (14 commits). One
conflict — compiler/parser.rs: main's `NanoError` → `CompilerError` rename
vs this branch's `@mcp` / per-param `@description` parser additions; resolved
by keeping the new parsing under the renamed error type. The CLI `queries list`
change (#280, surfacing `@description`/`@instruction`) auto-merged with this
branch's `mcp_expose`/`tool_name` columns.
This commit is contained in:
Ragnor Comerford 2026-06-19 21:59:14 +02:00
commit fbf455a250
No known key found for this signature in database
110 changed files with 6396 additions and 2511 deletions

View file

@ -13,7 +13,6 @@ use serde_json::Value;
use serial_test::serial;
use tower::ServiceExt;
mod support;
use support::*;
@ -414,7 +413,7 @@ async fn cluster_boot_serves_applied_state() {
assert!(server_policy.is_none());
let state =
omnigraph_server::open_multi_graph_state(graphs, Vec::new(), None, config_path)
omnigraph_server::open_multi_graph_state(graphs, Vec::new(), None, config_path, false)
.await
.unwrap();
let app = build_app(state);
@ -424,7 +423,10 @@ async fn cluster_boot_serves_applied_state() {
// GET /graphs refuses even in cluster mode.
let (status, body) = json_response(
&app,
Request::builder().uri("/graphs").body(Body::empty()).unwrap(),
Request::builder()
.uri("/graphs")
.body(Body::empty())
.unwrap(),
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN, "{body}");
@ -460,6 +462,115 @@ async fn cluster_boot_serves_applied_state() {
assert_eq!(status, StatusCode::OK, "{body}");
}
#[tokio::test]
async fn cluster_boot_quarantines_graph_open_failures() {
let temp = tempfile::tempdir().unwrap();
let schema = "\nnode Person {\n name: String @key\n}\n";
let good_uri = temp.path().join("good.omni");
Omnigraph::init(good_uri.to_string_lossy().as_ref(), schema)
.await
.unwrap();
let bad_uri = temp.path().join("missing.omni");
let server_policy = omnigraph_server::PolicySource::Inline(
r#"
version: 1
kind: server
groups:
admins: [act-admin]
rules:
- id: admins-list-graphs
allow:
actors: { group: admins }
actions: [graph_list]
"#
.to_string(),
);
let graphs = vec![
omnigraph_server::GraphStartupConfig {
graph_id: "broken".to_string(),
uri: bad_uri.to_string_lossy().to_string(),
policy: None,
embedding: None,
queries: stored_query_registry(&[]),
},
omnigraph_server::GraphStartupConfig {
graph_id: "good".to_string(),
uri: good_uri.to_string_lossy().to_string(),
policy: None,
embedding: None,
queries: stored_query_registry(&[]),
},
];
let strict_err = match omnigraph_server::open_multi_graph_state(
graphs.clone(),
vec![("act-admin".to_string(), "admin-token".to_string())],
Some(&server_policy),
temp.path().join("cluster.yaml"),
true,
)
.await
{
Ok(_) => panic!("strict startup should reject a failed graph open"),
Err(err) => err,
};
assert!(
strict_err
.to_string()
.contains("strict multi-graph startup requires every graph to open"),
"{strict_err}"
);
let state = omnigraph_server::open_multi_graph_state(
graphs,
vec![("act-admin".to_string(), "admin-token".to_string())],
Some(&server_policy),
temp.path().join("cluster.yaml"),
false,
)
.await
.unwrap();
let mut ready: Vec<_> = state
.routing()
.registry
.list()
.iter()
.map(|handle| handle.key.graph_id.as_str().to_string())
.collect();
ready.sort();
assert_eq!(ready, vec!["good"]);
let app = build_app(state);
let (status, body) = json_response(
&app,
Request::builder()
.uri("/graphs")
.header("authorization", "Bearer admin-token")
.body(Body::empty())
.unwrap(),
)
.await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(
body["graphs"]
.as_array()
.unwrap()
.iter()
.map(|graph| graph["graph_id"].as_str().unwrap())
.collect::<Vec<_>>(),
vec!["good"]
);
let (status, body) = json_response(
&app,
Request::builder()
.uri("/graphs/broken/queries")
.header("authorization", "Bearer admin-token")
.body(Body::empty())
.unwrap(),
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND, "{body}");
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn cluster_boot_injects_embedding_provider_config() {
@ -555,6 +666,7 @@ graphs:
Vec::new(),
server_policy.as_ref(),
config_path,
false,
)
.await
.unwrap();
@ -665,7 +777,10 @@ async fn cluster_boot_wires_policy_bindings_into_cedar_slots() {
.unwrap();
fs::write(
temp.path().join("cluster.policy.yaml"),
permit_all_policy_yaml(&["default"]).replace("protected_branches: [main]\n", "protected_branches: [main]\nkind: server\n"),
permit_all_policy_yaml(&["default"]).replace(
"protected_branches: [main]\n",
"protected_branches: [main]\nkind: server\n",
),
)
.unwrap();
fs::write(
@ -719,7 +834,7 @@ graphs:
async fn cluster_boot_refusals() {
// RFC-011 cluster-only: with no --cluster, boot refuses with the
// cluster-required remedy.
let err = omnigraph_server::load_server_settings(None, None, true)
let err = omnigraph_server::load_server_settings(None, None, true, false)
.await
.unwrap_err();
assert!(err.to_string().contains("boots from a cluster"), "{err}");
@ -729,7 +844,12 @@ async fn cluster_boot_refusals() {
// Tampered catalog blob refuses boot with the remedy.
let blob_dir = dir.join("__cluster/resources/query/knowledge/find_person");
let blob = fs::read_dir(&blob_dir).unwrap().next().unwrap().unwrap().path();
let blob = fs::read_dir(&blob_dir)
.unwrap()
.next()
.unwrap()
.unwrap()
.path();
fs::write(&blob, "tampered").unwrap();
let err = cluster_settings(&dir).await.unwrap_err();
assert!(

View file

@ -11,7 +11,6 @@ use omnigraph_server::api::ReadRequest;
use omnigraph_server::{AppState, build_app};
use serde_json::json;
mod support;
use support::*;
@ -137,6 +136,7 @@ async fn server_boots_cluster_from_bare_storage_uri_and_serves_query() {
Some(&std::path::PathBuf::from(&root)),
None,
true,
false,
)
.await
.unwrap();
@ -153,6 +153,7 @@ async fn server_boots_cluster_from_bare_storage_uri_and_serves_query() {
Vec::new(),
server_policy.as_ref(),
config_path,
false,
)
.await
.unwrap();
@ -170,7 +171,9 @@ async fn server_boots_cluster_from_bare_storage_uri_and_serves_query() {
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(value["rows"][0]["p.name"], "Ada", "{value}");
}

View file

@ -370,6 +370,47 @@ async fn list_queries_requires_invoke_query() {
assert!(names.contains(&"find_person"), "invoker sees the exposed query: {names:?}");
}
#[tokio::test(flavor = "multi_thread")]
async fn list_queries_surfaces_query_description_and_instruction() {
// E2e for the query-level `.gq` surface: `@description`/`@instruction` on
// a stored query declaration are carried through to clients via the typed
// `QueryCatalogEntry` fields over `GET /queries`. A query without them
// omits both fields (serde `skip_serializing_if = "Option::is_none"`).
let described = "query described($name: String) \
@description(\"Find a person by exact name.\") \
@instruction(\"Use for exact lookups; prefer search for fuzzy matches.\") \
{ match { $p: Person { name: $name } } return { $p.age } }";
let (_temp, app) = app_with_stored_queries(
&[
("described", described, true),
("bare", "query bare() { match { $p: Person } return { $p.name } }", true),
],
&[("act-invoke", "t-invoke")],
INVOKE_POLICY_YAML,
)
.await;
let (status, body) = json_response(&app, get_request(&g("/queries"), "t-invoke")).await;
assert_eq!(status, StatusCode::OK, "body: {body}");
let entries = body["queries"].as_array().unwrap();
let described = entries.iter().find(|q| q["name"] == "described").unwrap();
assert_eq!(
described["description"], "Find a person by exact name.",
"query @description surfaces over GET /queries: {described}"
);
assert_eq!(
described["instruction"],
"Use for exact lookups; prefer search for fuzzy matches.",
"query @instruction surfaces over GET /queries: {described}"
);
let bare = entries.iter().find(|q| q["name"] == "bare").unwrap();
assert!(
bare.get("description").is_none() && bare.get("instruction").is_none(),
"a query without the annotations omits both fields: {bare}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn list_queries_is_empty_when_no_registry() {
let (_temp, app) = app_for_loaded_graph_with_auth("demo-token").await;

View file

@ -15,15 +15,12 @@ use omnigraph::db::{Omnigraph, ReadTarget};
use omnigraph::error::OmniError;
use omnigraph::loader::{LoadMode, load_jsonl};
use omnigraph_policy::{PolicyChecker, PolicyEngine};
use omnigraph_server::api::{
BranchCreateRequest, BranchMergeRequest, ChangeRequest, ReadRequest,
};
use omnigraph_server::api::{BranchCreateRequest, BranchMergeRequest, ChangeRequest, ReadRequest};
use omnigraph_server::queries::{QueryRegistry, RegistrySpec};
use omnigraph_server::{AppState, build_app};
use serde_json::{Value, json};
use tower::ServiceExt;
pub const MUTATION_QUERIES: &str = r#"
query insert_person($name: String, $age: I32) {
insert Person { name: $name, age: $age }
@ -1212,6 +1209,8 @@ graphs:
temp
}
pub async fn cluster_settings(dir: &Path) -> color_eyre::eyre::Result<omnigraph_server::ServerConfig> {
omnigraph_server::load_server_settings(Some(&dir.to_path_buf()), None, true).await
pub async fn cluster_settings(
dir: &Path,
) -> color_eyre::eyre::Result<omnigraph_server::ServerConfig> {
omnigraph_server::load_server_settings(Some(&dir.to_path_buf()), None, true, false).await
}