mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-06-24 02:38:06 +02:00
feat(mcp): per-query @mcp(...) annotation + per-param @description + @instruction folding
Wire the `.gq` authoring surface that controls how a stored query is projected
as an MCP tool. All of it rides in the query source (content-addressed,
re-parsed at boot), so there is no cluster.yaml / catalog / serving-snapshot
plumbing — and it is orthogonal to Cedar `invoke_query` (presentation, not
authorization).
- Per-parameter `@description("…")` (leading the variable) → carried on
`Param.description`, mapped through `param_descriptor`, and emitted on the
outer JSON-Schema property by `param_json_schema`, so it shows up in both the
MCP tool input schema and the `GET /queries` catalog.
- Query `@mcp(expose: <bool>, tool_name: "<name>")` → parsed into
`QueryDecl.mcp`; `StoredQuery::is_exposed()` / `effective_tool_name()` resolve
from it. `expose: false` hides a query from the agent surface (`tools/list`,
`stored_query_list`, run-by-name) while keeping it HTTP/service-callable.
- `@instruction` is folded into the MCP tool description (after `@description`),
so the agent-facing how/when-to-use guidance reaches `tools/list`.
- Removes the now-dead `RegistrySpec.{expose, tool_name}` fields (server + CLI);
`settings.rs` no longer hardcodes `expose: true`. Test helpers express
exposure by injecting `@mcp(expose: false)` into the source (the real path).
openapi.json regenerated: `ParamDescriptor` gains an optional `description`.
Tests: compiler parser (param @description, @mcp parse + duplicate rejection),
api-types schema_equivalence (description on the outer property), server mcp
(folded description + param docs + @mcp tool rename, list==call). Full
workspace gate green.
This commit is contained in:
parent
bcd0d9c867
commit
c8e91c11f0
14 changed files with 396 additions and 107 deletions
|
|
@ -383,6 +383,10 @@ pub struct ParamDescriptor {
|
||||||
pub vector_dim: Option<u32>,
|
pub vector_dim: Option<u32>,
|
||||||
/// `false` → the caller must supply it; `true` → optional.
|
/// `false` → the caller must supply it; `true` → optional.
|
||||||
pub nullable: bool,
|
pub nullable: bool,
|
||||||
|
/// Per-parameter documentation from a leading `@description("…")`, surfaced
|
||||||
|
/// into the JSON-Schema property `description` (MCP tool input + catalog).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One entry in the stored-query catalog (`GET /queries`).
|
/// One entry in the stored-query catalog (`GET /queries`).
|
||||||
|
|
@ -432,6 +436,7 @@ pub fn param_descriptor(param: &Param) -> ParamDescriptor {
|
||||||
item_kind: Some(scalar_kind(pt.scalar)),
|
item_kind: Some(scalar_kind(pt.scalar)),
|
||||||
vector_dim: None,
|
vector_dim: None,
|
||||||
nullable: param.nullable,
|
nullable: param.nullable,
|
||||||
|
description: param.description.clone(),
|
||||||
},
|
},
|
||||||
Some(pt) => {
|
Some(pt) => {
|
||||||
let (kind, vector_dim) = match pt.scalar {
|
let (kind, vector_dim) = match pt.scalar {
|
||||||
|
|
@ -444,6 +449,7 @@ pub fn param_descriptor(param: &Param) -> ParamDescriptor {
|
||||||
item_kind: None,
|
item_kind: None,
|
||||||
vector_dim,
|
vector_dim,
|
||||||
nullable: param.nullable,
|
nullable: param.nullable,
|
||||||
|
description: param.description.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Unreachable for a parsed query (every declared param type is
|
// Unreachable for a parsed query (every declared param type is
|
||||||
|
|
@ -455,6 +461,7 @@ pub fn param_descriptor(param: &Param) -> ParamDescriptor {
|
||||||
item_kind: None,
|
item_kind: None,
|
||||||
vector_dim: None,
|
vector_dim: None,
|
||||||
nullable: param.nullable,
|
nullable: param.nullable,
|
||||||
|
description: param.description.clone(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -514,11 +521,17 @@ pub fn param_json_schema(p: &ParamDescriptor) -> Value {
|
||||||
// The coercer accepts explicit `null` for a nullable param (and its
|
// The coercer accepts explicit `null` for a nullable param (and its
|
||||||
// omission); a strict client would reject `null` against the bare scalar.
|
// omission); a strict client would reject `null` against the bare scalar.
|
||||||
// Allow null at the schema level for nullable params.
|
// Allow null at the schema level for nullable params.
|
||||||
if p.nullable {
|
let mut schema = if p.nullable {
|
||||||
json!({ "anyOf": [ base, { "type": "null" } ] })
|
json!({ "anyOf": [ base, { "type": "null" } ] })
|
||||||
} else {
|
} else {
|
||||||
base
|
base
|
||||||
|
};
|
||||||
|
// Put the description on the OUTER property object (a sibling of `anyOf`
|
||||||
|
// for nullable params, never nested inside it), so clients read it directly.
|
||||||
|
if let Some(description) = &p.description {
|
||||||
|
schema["description"] = json!(description);
|
||||||
}
|
}
|
||||||
|
schema
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ fn descriptor(type_name: &str, nullable: bool) -> omnigraph_api_types::ParamDesc
|
||||||
name: "p".to_string(),
|
name: "p".to_string(),
|
||||||
type_name: type_name.to_string(),
|
type_name: type_name.to_string(),
|
||||||
nullable,
|
nullable,
|
||||||
|
description: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -138,6 +139,41 @@ fn nullable_rule_matches_the_parent_coercer() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn param_description_lands_on_the_outer_property() {
|
||||||
|
let doc = "the user's slug";
|
||||||
|
let with_doc = |type_name: &str, nullable: bool| {
|
||||||
|
param_json_schema(¶m_descriptor(&Param {
|
||||||
|
name: "p".to_string(),
|
||||||
|
type_name: type_name.to_string(),
|
||||||
|
nullable,
|
||||||
|
description: Some(doc.to_string()),
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
// Non-nullable: description is a sibling of the type.
|
||||||
|
let scalar = with_doc("String", false);
|
||||||
|
assert_eq!(scalar["description"], json!(doc));
|
||||||
|
assert_eq!(scalar["type"], json!("string"));
|
||||||
|
|
||||||
|
// Nullable: description sits on the OUTER object next to `anyOf`, never
|
||||||
|
// inside it (a consumer reading `anyOf[i].description` must not find it).
|
||||||
|
let nullable = with_doc("I32", true);
|
||||||
|
assert_eq!(nullable["description"], json!(doc));
|
||||||
|
assert!(nullable.get("anyOf").is_some(), "nullable schema keeps anyOf: {nullable}");
|
||||||
|
for branch in nullable["anyOf"].as_array().unwrap() {
|
||||||
|
assert!(branch.get("description").is_none(), "description leaked into anyOf branch: {branch}");
|
||||||
|
}
|
||||||
|
// Carries on a composite (list) too, and the value still validates.
|
||||||
|
let list = with_doc("[String]", false);
|
||||||
|
assert_eq!(list["description"], json!(doc));
|
||||||
|
assert!(schema_accepts(&list, &json!(["a", "b"])));
|
||||||
|
|
||||||
|
// Absent description → no `description` key (wire shape unchanged).
|
||||||
|
assert!(descriptor("String", false).description.is_none());
|
||||||
|
assert!(param_json_schema(&descriptor("String", false)).get("description").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn vector_dim_bounds_are_present_or_omitted() {
|
fn vector_dim_bounds_are_present_or_omitted() {
|
||||||
let with_dim = param_json_schema(&descriptor("Vector(4)", false));
|
let with_dim = param_json_schema(&descriptor("Vector(4)", false));
|
||||||
|
|
|
||||||
|
|
@ -780,8 +780,6 @@ fn registry_from_serving_queries(
|
||||||
.map(|q| omnigraph_server::queries::RegistrySpec {
|
.map(|q| omnigraph_server::queries::RegistrySpec {
|
||||||
name: q.name.clone(),
|
name: q.name.clone(),
|
||||||
source: q.source.clone(),
|
source: q.source.clone(),
|
||||||
expose: false,
|
|
||||||
tool_name: None,
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
QueryRegistry::from_specs(specs).map_err(|errors| {
|
QueryRegistry::from_specs(specs).map_err(|errors| {
|
||||||
|
|
@ -890,8 +888,8 @@ pub(crate) async fn execute_queries_list(
|
||||||
.iter()
|
.iter()
|
||||||
.map(|q| QueriesListItem {
|
.map(|q| QueriesListItem {
|
||||||
name: q.name.clone(),
|
name: q.name.clone(),
|
||||||
mcp_expose: q.expose,
|
mcp_expose: q.is_exposed(),
|
||||||
tool_name: q.tool_name.clone(),
|
tool_name: q.decl.mcp.tool_name.clone(),
|
||||||
mutation: q.is_mutation(),
|
mutation: q.is_mutation(),
|
||||||
params: q
|
params: q
|
||||||
.decl
|
.decl
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,10 @@ pub struct QueryDecl {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub instruction: Option<String>,
|
pub instruction: Option<String>,
|
||||||
|
/// MCP-presentation controls from the `@mcp(...)` annotation (tool name +
|
||||||
|
/// visibility on the agent tool surface). Distinct from `description` /
|
||||||
|
/// `instruction`, which are general docs consumed by both REST and MCP.
|
||||||
|
pub mcp: McpQueryMeta,
|
||||||
pub params: Vec<Param>,
|
pub params: Vec<Param>,
|
||||||
pub match_clause: Vec<Clause>,
|
pub match_clause: Vec<Clause>,
|
||||||
pub return_clause: Vec<Projection>,
|
pub return_clause: Vec<Projection>,
|
||||||
|
|
@ -18,11 +22,23 @@ pub struct QueryDecl {
|
||||||
pub mutations: Vec<Mutation>,
|
pub mutations: Vec<Mutation>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parsed `@mcp(...)` annotation. Both fields default to `None`: `expose`
|
||||||
|
/// absent ⇒ exposed (the historical default); `tool_name` absent ⇒ the query
|
||||||
|
/// name. Presentation only — never an authorization control.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct McpQueryMeta {
|
||||||
|
pub expose: Option<bool>,
|
||||||
|
pub tool_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Param {
|
pub struct Param {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub type_name: String,
|
pub type_name: String,
|
||||||
pub nullable: bool,
|
pub nullable: bool,
|
||||||
|
/// Optional per-parameter documentation from a leading `@description("…")`,
|
||||||
|
/// surfaced into tool input-schema property descriptions.
|
||||||
|
pub description: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,8 @@ fn parse_query_decl(pair: pest::iterators::Pair<Rule>) -> Result<QueryDecl> {
|
||||||
|
|
||||||
let mut description = None;
|
let mut description = None;
|
||||||
let mut instruction = None;
|
let mut instruction = None;
|
||||||
|
let mut mcp = McpQueryMeta::default();
|
||||||
|
let mut mcp_seen = false;
|
||||||
let mut params = Vec::new();
|
let mut params = Vec::new();
|
||||||
let mut match_clause = Vec::new();
|
let mut match_clause = Vec::new();
|
||||||
let mut return_clause = Vec::new();
|
let mut return_clause = Vec::new();
|
||||||
|
|
@ -66,33 +68,34 @@ fn parse_query_decl(pair: pest::iterators::Pair<Rule>) -> Result<QueryDecl> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Rule::query_annotation => {
|
Rule::query_annotation => match parse_query_annotation(item)? {
|
||||||
let (annotation_name, value) = parse_query_annotation(item)?;
|
ParsedAnnotation::Description(value) => {
|
||||||
match annotation_name {
|
if description.replace(value).is_some() {
|
||||||
"description" => {
|
|
||||||
if description.replace(value).is_some() {
|
|
||||||
return Err(NanoError::Parse(format!(
|
|
||||||
"query `{}` cannot include duplicate @description annotations",
|
|
||||||
name
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"instruction" => {
|
|
||||||
if instruction.replace(value).is_some() {
|
|
||||||
return Err(NanoError::Parse(format!(
|
|
||||||
"query `{}` cannot include duplicate @instruction annotations",
|
|
||||||
name
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
other => {
|
|
||||||
return Err(NanoError::Parse(format!(
|
return Err(NanoError::Parse(format!(
|
||||||
"unsupported query annotation: @{}",
|
"query `{}` cannot include duplicate @description annotations",
|
||||||
other
|
name
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
ParsedAnnotation::Instruction(value) => {
|
||||||
|
if instruction.replace(value).is_some() {
|
||||||
|
return Err(NanoError::Parse(format!(
|
||||||
|
"query `{}` cannot include duplicate @instruction annotations",
|
||||||
|
name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ParsedAnnotation::Mcp(value) => {
|
||||||
|
if mcp_seen {
|
||||||
|
return Err(NanoError::Parse(format!(
|
||||||
|
"query `{}` cannot include duplicate @mcp annotations",
|
||||||
|
name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
mcp_seen = true;
|
||||||
|
mcp = value;
|
||||||
|
}
|
||||||
|
},
|
||||||
Rule::query_body => {
|
Rule::query_body => {
|
||||||
let body = item
|
let body = item
|
||||||
.into_inner()
|
.into_inner()
|
||||||
|
|
@ -157,6 +160,7 @@ fn parse_query_decl(pair: pest::iterators::Pair<Rule>) -> Result<QueryDecl> {
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
instruction,
|
instruction,
|
||||||
|
mcp,
|
||||||
params,
|
params,
|
||||||
match_clause,
|
match_clause,
|
||||||
return_clause,
|
return_clause,
|
||||||
|
|
@ -166,32 +170,36 @@ fn parse_query_decl(pair: pest::iterators::Pair<Rule>) -> Result<QueryDecl> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_query_annotation(pair: pest::iterators::Pair<Rule>) -> Result<(&'static str, String)> {
|
enum ParsedAnnotation {
|
||||||
|
Description(String),
|
||||||
|
Instruction(String),
|
||||||
|
Mcp(McpQueryMeta),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the single string-literal argument from an `@name("…")`-shaped
|
||||||
|
/// annotation pair (`description_annotation` / `instruction_annotation`).
|
||||||
|
fn annotation_string(pair: pest::iterators::Pair<Rule>, what: &str) -> Result<String> {
|
||||||
|
pair.into_inner()
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| NanoError::Parse(format!("{what} requires a string literal")))
|
||||||
|
.map(|value| parse_string_lit(value.as_str()))?
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_query_annotation(pair: pest::iterators::Pair<Rule>) -> Result<ParsedAnnotation> {
|
||||||
let inner = pair
|
let inner = pair
|
||||||
.into_inner()
|
.into_inner()
|
||||||
.next()
|
.next()
|
||||||
.ok_or_else(|| NanoError::Parse("query annotation cannot be empty".to_string()))?;
|
.ok_or_else(|| NanoError::Parse("query annotation cannot be empty".to_string()))?;
|
||||||
match inner.as_rule() {
|
match inner.as_rule() {
|
||||||
Rule::description_annotation => {
|
Rule::description_annotation => Ok(ParsedAnnotation::Description(annotation_string(
|
||||||
let value = inner
|
inner,
|
||||||
.into_inner()
|
"@description",
|
||||||
.next()
|
)?)),
|
||||||
.ok_or_else(|| {
|
Rule::instruction_annotation => Ok(ParsedAnnotation::Instruction(annotation_string(
|
||||||
NanoError::Parse("@description requires a string literal".to_string())
|
inner,
|
||||||
})
|
"@instruction",
|
||||||
.map(|value| parse_string_lit(value.as_str()))??;
|
)?)),
|
||||||
Ok(("description", value))
|
Rule::mcp_annotation => Ok(ParsedAnnotation::Mcp(parse_mcp_annotation(inner)?)),
|
||||||
}
|
|
||||||
Rule::instruction_annotation => {
|
|
||||||
let value = inner
|
|
||||||
.into_inner()
|
|
||||||
.next()
|
|
||||||
.ok_or_else(|| {
|
|
||||||
NanoError::Parse("@instruction requires a string literal".to_string())
|
|
||||||
})
|
|
||||||
.map(|value| parse_string_lit(value.as_str()))??;
|
|
||||||
Ok(("instruction", value))
|
|
||||||
}
|
|
||||||
other => Err(NanoError::Parse(format!(
|
other => Err(NanoError::Parse(format!(
|
||||||
"unexpected query annotation rule: {:?}",
|
"unexpected query annotation rule: {:?}",
|
||||||
other
|
other
|
||||||
|
|
@ -199,9 +207,70 @@ fn parse_query_annotation(pair: pest::iterators::Pair<Rule>) -> Result<(&'static
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse `@mcp(expose: <bool>, tool_name: "<name>")` into [`McpQueryMeta`].
|
||||||
|
/// Both keys are optional; a repeated key is a loud error.
|
||||||
|
fn parse_mcp_annotation(pair: pest::iterators::Pair<Rule>) -> Result<McpQueryMeta> {
|
||||||
|
let mut meta = McpQueryMeta::default();
|
||||||
|
for arg in pair.into_inner() {
|
||||||
|
let kv = arg
|
||||||
|
.into_inner()
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| NanoError::Parse("@mcp argument cannot be empty".to_string()))?;
|
||||||
|
match kv.as_rule() {
|
||||||
|
Rule::mcp_expose_arg => {
|
||||||
|
let value = kv
|
||||||
|
.into_inner()
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
NanoError::Parse("@mcp expose requires a boolean".to_string())
|
||||||
|
})?
|
||||||
|
.as_str()
|
||||||
|
== "true";
|
||||||
|
if meta.expose.replace(value).is_some() {
|
||||||
|
return Err(NanoError::Parse(
|
||||||
|
"@mcp cannot include duplicate `expose` arguments".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Rule::mcp_tool_name_arg => {
|
||||||
|
let value = kv
|
||||||
|
.into_inner()
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
NanoError::Parse("@mcp tool_name requires a string literal".to_string())
|
||||||
|
})
|
||||||
|
.map(|value| parse_string_lit(value.as_str()))??;
|
||||||
|
if meta.tool_name.replace(value).is_some() {
|
||||||
|
return Err(NanoError::Parse(
|
||||||
|
"@mcp cannot include duplicate `tool_name` arguments".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
return Err(NanoError::Parse(format!(
|
||||||
|
"unexpected @mcp argument rule: {:?}",
|
||||||
|
other
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(meta)
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_param(pair: pest::iterators::Pair<Rule>) -> Result<Param> {
|
fn parse_param(pair: pest::iterators::Pair<Rule>) -> Result<Param> {
|
||||||
let mut inner = pair.into_inner();
|
let mut inner = pair.into_inner();
|
||||||
let var = inner.next().unwrap().as_str();
|
let mut next = inner
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| NanoError::Parse("parameter is missing a variable".to_string()))?;
|
||||||
|
// Optional leading `@description("…")` documents the parameter.
|
||||||
|
let mut description = None;
|
||||||
|
if next.as_rule() == Rule::description_annotation {
|
||||||
|
description = Some(annotation_string(next, "@description")?);
|
||||||
|
next = inner
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| NanoError::Parse("parameter is missing a variable".to_string()))?;
|
||||||
|
}
|
||||||
|
let var = next.as_str();
|
||||||
let name = var.strip_prefix('$').unwrap_or(var).to_string();
|
let name = var.strip_prefix('$').unwrap_or(var).to_string();
|
||||||
let type_ref = inner.next().unwrap();
|
let type_ref = inner.next().unwrap();
|
||||||
let nullable = type_ref.as_str().trim_end().ends_with('?');
|
let nullable = type_ref.as_str().trim_end().ends_with('?');
|
||||||
|
|
@ -237,6 +306,7 @@ fn parse_param(pair: pest::iterators::Pair<Rule>) -> Result<Param> {
|
||||||
name,
|
name,
|
||||||
type_name: base,
|
type_name: base,
|
||||||
nullable,
|
nullable,
|
||||||
|
description,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,64 @@ return { $p.name }
|
||||||
assert!(err.to_string().contains("duplicate @description"));
|
assert!(err.to_string().contains("duplicate @description"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_param_description() {
|
||||||
|
let input = r#"
|
||||||
|
query find(@description("the user's slug") $slug: String, $limit: I32?) {
|
||||||
|
match {
|
||||||
|
$u: User { slug: $slug }
|
||||||
|
}
|
||||||
|
return { $u.name }
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
let qf = parse_query(input).unwrap();
|
||||||
|
let q = &qf.queries[0];
|
||||||
|
assert_eq!(q.params.len(), 2);
|
||||||
|
// Annotated param keeps its name/type and gains the doc.
|
||||||
|
assert_eq!(q.params[0].name, "slug");
|
||||||
|
assert_eq!(q.params[0].type_name, "String");
|
||||||
|
assert!(!q.params[0].nullable);
|
||||||
|
assert_eq!(q.params[0].description.as_deref(), Some("the user's slug"));
|
||||||
|
// Un-annotated param: no description, nullable preserved (annotation slot
|
||||||
|
// sits before the variable, so it composes with the trailing `?`).
|
||||||
|
assert_eq!(q.params[1].name, "limit");
|
||||||
|
assert!(q.params[1].nullable);
|
||||||
|
assert_eq!(q.params[1].description, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_mcp_annotation() {
|
||||||
|
// Either argument order parses; expose + tool_name both captured.
|
||||||
|
for input in [
|
||||||
|
r#"query q() @mcp(tool_name: "lookup", expose: false) { match { $p: Person } return { $p.name } }"#,
|
||||||
|
r#"query q() @mcp(expose: false, tool_name: "lookup") { match { $p: Person } return { $p.name } }"#,
|
||||||
|
] {
|
||||||
|
let qf = parse_query(input).unwrap();
|
||||||
|
let q = &qf.queries[0];
|
||||||
|
assert_eq!(q.mcp.tool_name.as_deref(), Some("lookup"), "input: {input}");
|
||||||
|
assert_eq!(q.mcp.expose, Some(false), "input: {input}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Absent @mcp ⇒ both None (exposed-by-default, name as tool name).
|
||||||
|
let bare = parse_query(r#"query q() { match { $p: Person } return { $p.name } }"#).unwrap();
|
||||||
|
assert_eq!(bare.queries[0].mcp.tool_name, None);
|
||||||
|
assert_eq!(bare.queries[0].mcp.expose, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_duplicate_mcp_annotation_is_rejected() {
|
||||||
|
let dup_block = r#"query q() @mcp(expose: true) @mcp(expose: false) { match { $p: Person } return { $p.name } }"#;
|
||||||
|
assert!(
|
||||||
|
parse_query(dup_block).unwrap_err().to_string().contains("duplicate @mcp"),
|
||||||
|
"two @mcp annotations must be rejected"
|
||||||
|
);
|
||||||
|
let dup_key = r#"query q() @mcp(expose: true, expose: false) { match { $p: Person } return { $p.name } }"#;
|
||||||
|
assert!(
|
||||||
|
parse_query(dup_key).unwrap_err().to_string().contains("duplicate `expose`"),
|
||||||
|
"a repeated @mcp key must be rejected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parse_no_params() {
|
fn test_parse_no_params() {
|
||||||
let input = r#"
|
let input = r#"
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,14 @@ query_decl = {
|
||||||
~ query_body
|
~ query_body
|
||||||
~ "}"
|
~ "}"
|
||||||
}
|
}
|
||||||
query_annotation = { description_annotation | instruction_annotation }
|
query_annotation = { description_annotation | instruction_annotation | mcp_annotation }
|
||||||
description_annotation = { "@description" ~ "(" ~ string_lit ~ ")" }
|
description_annotation = { "@description" ~ "(" ~ string_lit ~ ")" }
|
||||||
instruction_annotation = { "@instruction" ~ "(" ~ string_lit ~ ")" }
|
instruction_annotation = { "@instruction" ~ "(" ~ string_lit ~ ")" }
|
||||||
|
// MCP-presentation controls (the agent tool surface only): tool name + visibility.
|
||||||
|
mcp_annotation = { "@mcp" ~ "(" ~ mcp_arg ~ ("," ~ mcp_arg)* ~ ","? ~ ")" }
|
||||||
|
mcp_arg = { mcp_expose_arg | mcp_tool_name_arg }
|
||||||
|
mcp_expose_arg = { "expose" ~ ":" ~ bool_lit }
|
||||||
|
mcp_tool_name_arg = { "tool_name" ~ ":" ~ string_lit }
|
||||||
|
|
||||||
query_body = { read_query_body | mutation_body }
|
query_body = { read_query_body | mutation_body }
|
||||||
mutation_body = { mutation_stmt+ }
|
mutation_body = { mutation_stmt+ }
|
||||||
|
|
@ -33,7 +38,10 @@ mutation_assignment = { ident ~ ":" ~ match_value ~ ","? }
|
||||||
mutation_predicate = { ident ~ comp_op ~ match_value }
|
mutation_predicate = { ident ~ comp_op ~ match_value }
|
||||||
|
|
||||||
param_list = { param ~ ("," ~ param)* }
|
param_list = { param ~ ("," ~ param)* }
|
||||||
param = { variable ~ ":" ~ type_ref }
|
// A leading `@description("…")` documents the parameter (surfaced in tool input
|
||||||
|
// schemas). Leading position avoids PEG ambiguity with `type_ref`'s trailing `?`
|
||||||
|
// and the `,` separator.
|
||||||
|
param = { description_annotation? ~ variable ~ ":" ~ type_ref }
|
||||||
|
|
||||||
type_ref = { (list_type | base_type | vector_type) ~ "?"? }
|
type_ref = { (list_type | base_type | vector_type) ~ "?"? }
|
||||||
list_type = { "[" ~ base_type ~ "]" }
|
list_type = { "[" ~ base_type ~ "]" }
|
||||||
|
|
|
||||||
|
|
@ -1087,8 +1087,7 @@ pub(crate) async fn server_list_queries(
|
||||||
)?;
|
)?;
|
||||||
let queries = match handle.queries.as_ref() {
|
let queries = match handle.queries.as_ref() {
|
||||||
Some(registry) => registry
|
Some(registry) => registry
|
||||||
.iter()
|
.exposed()
|
||||||
.filter(|q| q.expose)
|
|
||||||
.map(api::query_catalog_entry)
|
.map(api::query_catalog_entry)
|
||||||
.collect(),
|
.collect(),
|
||||||
None => Vec::new(),
|
None => Vec::new(),
|
||||||
|
|
|
||||||
|
|
@ -535,11 +535,19 @@ fn stored_query_input_schema(stored: &StoredQuery) -> Value {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stored_query_tool(stored: &StoredQuery) -> Tool {
|
fn stored_query_tool(stored: &StoredQuery) -> Tool {
|
||||||
let description = stored
|
// The MCP tool description folds `@description` and `@instruction` (the
|
||||||
|
// agent-facing "how to use" guidance) into the one description slot MCP
|
||||||
|
// tools have. Instruction-only queries still surface their instruction
|
||||||
|
// (appended to the fallback base).
|
||||||
|
let mut description = stored
|
||||||
.decl
|
.decl
|
||||||
.description
|
.description
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| format!("Stored query '{}'.", stored.name));
|
.unwrap_or_else(|| format!("Stored query '{}'.", stored.name));
|
||||||
|
if let Some(instruction) = &stored.decl.instruction {
|
||||||
|
description.push_str("\n\n");
|
||||||
|
description.push_str(instruction);
|
||||||
|
}
|
||||||
let annotations = if stored.is_mutation() {
|
let annotations = if stored.is_mutation() {
|
||||||
write_annotations(true)
|
write_annotations(true)
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -31,15 +31,8 @@ pub struct StoredQuery {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
/// Full `.gq` source text the query was selected from.
|
/// Full `.gq` source text the query was selected from.
|
||||||
pub source: Arc<str>,
|
pub source: Arc<str>,
|
||||||
/// Parsed declaration (params, mutations, description, …).
|
/// Parsed declaration (params, mutations, description, `@mcp(...)`, …).
|
||||||
pub decl: QueryDecl,
|
pub decl: QueryDecl,
|
||||||
/// Whether this query is listed in the MCP tool catalog (`GET /queries`).
|
|
||||||
/// Default `true` (the manifest entry is the opt-in); `expose: false`
|
|
||||||
/// keeps it HTTP/service-callable but hidden from the agent tool list.
|
|
||||||
/// Catalog membership only — not an authorization gate.
|
|
||||||
pub expose: bool,
|
|
||||||
/// Optional MCP tool-name override; defaults to `name`.
|
|
||||||
pub tool_name: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StoredQuery {
|
impl StoredQuery {
|
||||||
|
|
@ -49,13 +42,22 @@ impl StoredQuery {
|
||||||
!self.decl.mutations.is_empty()
|
!self.decl.mutations.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The MCP tool name this query is catalogued under: the explicit
|
/// Whether this query is listed on the MCP tool surface (`GET /queries` and
|
||||||
/// `tool_name` override, else the query `name`. The catalog key —
|
/// the `/mcp` tool catalog). From the source `@mcp(expose: …)` annotation;
|
||||||
/// enforced unique across exposed queries at load. Server-side
|
/// absent ⇒ `true`. An unexposed query stays HTTP/service-callable by name —
|
||||||
/// consumers (the uniqueness check, the future catalog projection) read
|
/// this is **presentation only, not an authorization gate** (Cedar
|
||||||
/// this; the CLI `queries list` resolves the same rule on its own DTO.
|
/// `invoke_query` is the authority for who may call it).
|
||||||
|
pub fn is_exposed(&self) -> bool {
|
||||||
|
self.decl.mcp.expose.unwrap_or(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The MCP tool name this query is catalogued under: the source
|
||||||
|
/// `@mcp(tool_name: …)` override, else the query `name`. The catalog key —
|
||||||
|
/// enforced unique across exposed queries at load. Server-side consumers
|
||||||
|
/// (the uniqueness check, the catalog/MCP projection) read this; the CLI
|
||||||
|
/// `queries list` resolves the same rule on its own DTO.
|
||||||
pub fn effective_tool_name(&self) -> &str {
|
pub fn effective_tool_name(&self) -> &str {
|
||||||
self.tool_name.as_deref().unwrap_or(&self.name)
|
self.decl.mcp.tool_name.as_deref().unwrap_or(&self.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -67,13 +69,13 @@ pub struct QueryRegistry {
|
||||||
|
|
||||||
/// In-memory registry spec: a query's name + already-read `.gq` source. The
|
/// In-memory registry spec: a query's name + already-read `.gq` source. The
|
||||||
/// input to [`QueryRegistry::from_specs`] — built by the server's cluster boot
|
/// input to [`QueryRegistry::from_specs`] — built by the server's cluster boot
|
||||||
/// and by the CLI's `queries` tooling from a cluster serving snapshot.
|
/// and by the CLI's `queries` tooling from a cluster serving snapshot. MCP
|
||||||
|
/// presentation (`expose` / `tool_name`) is carried in the source `@mcp(...)`
|
||||||
|
/// annotation, not here — see [`StoredQuery::is_exposed`] / `effective_tool_name`.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct RegistrySpec {
|
pub struct RegistrySpec {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub source: String,
|
pub source: String,
|
||||||
pub expose: bool,
|
|
||||||
pub tool_name: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A single registry load failure. Collected (not fail-fast) so a bad
|
/// A single registry load failure. Collected (not fail-fast) so a bad
|
||||||
|
|
@ -120,8 +122,6 @@ impl QueryRegistry {
|
||||||
name: spec.name,
|
name: spec.name,
|
||||||
source: Arc::from(spec.source),
|
source: Arc::from(spec.source),
|
||||||
decl,
|
decl,
|
||||||
expose: spec.expose,
|
|
||||||
tool_name: spec.tool_name,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -159,7 +159,7 @@ impl QueryRegistry {
|
||||||
for builtin in crate::mcp::BUILTIN_TOOL_NAMES {
|
for builtin in crate::mcp::BUILTIN_TOOL_NAMES {
|
||||||
claimed.insert(builtin, BUILTIN_OWNER);
|
claimed.insert(builtin, BUILTIN_OWNER);
|
||||||
}
|
}
|
||||||
for query in by_name.values().filter(|q| q.expose) {
|
for query in by_name.values().filter(|q| q.is_exposed()) {
|
||||||
let tool = query.effective_tool_name();
|
let tool = query.effective_tool_name();
|
||||||
if let Some(winner) = claimed.insert(tool, &query.name) {
|
if let Some(winner) = claimed.insert(tool, &query.name) {
|
||||||
let message = if winner == BUILTIN_OWNER {
|
let message = if winner == BUILTIN_OWNER {
|
||||||
|
|
@ -182,11 +182,11 @@ impl QueryRegistry {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve by symbol name, **ignoring `expose`**. The raw catalog accessor
|
/// Resolve by symbol name, **ignoring exposure**. The raw catalog accessor
|
||||||
/// for HTTP/service callers (`expose:false` queries are deliberately
|
/// for HTTP/service callers (`@mcp(expose: false)` queries are deliberately
|
||||||
/// HTTP-callable; see [`StoredQuery::expose`]). The MCP backend must NOT use
|
/// HTTP-callable; see [`StoredQuery::is_exposed`]). The MCP backend must NOT
|
||||||
/// this — it resolves through [`Self::exposed_by_name`] so the agent surface
|
/// use this — it resolves through [`Self::exposed_by_name`] so the agent
|
||||||
/// can never reach a query hidden from the tool list.
|
/// surface can never reach a query hidden from the tool list.
|
||||||
pub fn lookup(&self, name: &str) -> Option<&StoredQuery> {
|
pub fn lookup(&self, name: &str) -> Option<&StoredQuery> {
|
||||||
self.by_name.get(name)
|
self.by_name.get(name)
|
||||||
}
|
}
|
||||||
|
|
@ -201,14 +201,14 @@ impl QueryRegistry {
|
||||||
/// `stored_query_list` tool, and per-query tool dispatch all funnel through
|
/// `stored_query_list` tool, and per-query tool dispatch all funnel through
|
||||||
/// it, so they cannot drift on which queries an agent may see or run.
|
/// it, so they cannot drift on which queries an agent may see or run.
|
||||||
pub fn exposed(&self) -> impl Iterator<Item = &StoredQuery> {
|
pub fn exposed(&self) -> impl Iterator<Item = &StoredQuery> {
|
||||||
self.by_name.values().filter(|q| q.expose)
|
self.by_name.values().filter(|q| q.is_exposed())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve by symbol name, **exposed-only** — the MCP `stored_query_run`
|
/// Resolve by symbol name, **exposed-only** — the MCP `stored_query_run`
|
||||||
/// resolver. An unexposed query is unreachable by name through this path
|
/// resolver. An unexposed query is unreachable by name through this path
|
||||||
/// even to a caller that knows the name (the agent surface honors `expose`).
|
/// even to a caller that knows the name (the agent surface honors `expose`).
|
||||||
pub fn exposed_by_name(&self, name: &str) -> Option<&StoredQuery> {
|
pub fn exposed_by_name(&self, name: &str) -> Option<&StoredQuery> {
|
||||||
self.by_name.get(name).filter(|q| q.expose)
|
self.by_name.get(name).filter(|q| q.is_exposed())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_empty(&self) -> bool {
|
pub fn is_empty(&self) -> bool {
|
||||||
|
|
@ -284,7 +284,7 @@ pub fn check(registry: &QueryRegistry, catalog: &Catalog) -> CheckReport {
|
||||||
message: err.to_string(),
|
message: err.to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if query.expose {
|
if query.is_exposed() {
|
||||||
for param in &query.decl.params {
|
for param in &query.decl.params {
|
||||||
// Resolve to the structured type via the compiler's own
|
// Resolve to the structured type via the compiler's own
|
||||||
// resolver rather than string-matching `Vector(` — one
|
// resolver rather than string-matching `Vector(` — one
|
||||||
|
|
@ -332,21 +332,34 @@ pub fn format_check_breakages(label: &str, report: &CheckReport) -> String {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn spec(name: &str, source: &str, expose: bool) -> RegistrySpec {
|
/// Inject an `@mcp(<args>)` annotation between the param-list `)` and the
|
||||||
RegistrySpec {
|
/// body `{` (the first `{` in a query source is always the body open).
|
||||||
name: name.to_string(),
|
/// MCP presentation now lives in the source, so the test helpers express
|
||||||
source: source.to_string(),
|
/// expose/tool_name by rewriting the `.gq` rather than via dead spec fields.
|
||||||
expose,
|
fn inject_mcp(source: &str, args: &str) -> String {
|
||||||
tool_name: None,
|
match source.find('{') {
|
||||||
|
Some(i) => format!("{}@mcp({}) {}", &source[..i], args, &source[i..]),
|
||||||
|
None => source.to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn spec(name: &str, source: &str, expose: bool) -> RegistrySpec {
|
||||||
|
let source = if expose {
|
||||||
|
source.to_string()
|
||||||
|
} else {
|
||||||
|
inject_mcp(source, "expose: false")
|
||||||
|
};
|
||||||
|
RegistrySpec { name: name.to_string(), source }
|
||||||
|
}
|
||||||
|
|
||||||
fn spec_tool(name: &str, source: &str, expose: bool, tool_name: &str) -> RegistrySpec {
|
fn spec_tool(name: &str, source: &str, expose: bool, tool_name: &str) -> RegistrySpec {
|
||||||
|
let mut args = format!("tool_name: {tool_name:?}");
|
||||||
|
if !expose {
|
||||||
|
args.push_str(", expose: false");
|
||||||
|
}
|
||||||
RegistrySpec {
|
RegistrySpec {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
source: source.to_string(),
|
source: inject_mcp(source, &args),
|
||||||
expose,
|
|
||||||
tool_name: Some(tool_name.to_string()),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -360,7 +373,7 @@ mod tests {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let q = reg.lookup("find_user").unwrap();
|
let q = reg.lookup("find_user").unwrap();
|
||||||
assert_eq!(q.name, "find_user");
|
assert_eq!(q.name, "find_user");
|
||||||
assert!(q.expose);
|
assert!(q.is_exposed());
|
||||||
assert_eq!(q.decl.params.len(), 1);
|
assert_eq!(q.decl.params.len(), 1);
|
||||||
assert!(!q.is_mutation());
|
assert!(!q.is_mutation());
|
||||||
// No override → the effective tool name is the query name.
|
// No override → the effective tool name is the query name.
|
||||||
|
|
|
||||||
|
|
@ -77,11 +77,9 @@ pub(crate) async fn load_cluster_settings(
|
||||||
.map(|query| queries::RegistrySpec {
|
.map(|query| queries::RegistrySpec {
|
||||||
name: query.name.clone(),
|
name: query.name.clone(),
|
||||||
source: query.source.clone(),
|
source: query.source.clone(),
|
||||||
// The §D5 bridge: the cluster registry has no expose flag
|
// MCP presentation (expose / tool_name) rides in the `.gq`
|
||||||
// (exposure becomes a policy decision in Phase 6) — cluster
|
// source `@mcp(...)` annotation, re-parsed here; the registry
|
||||||
// mode lists every stored query.
|
// spec carries only identity + source.
|
||||||
expose: true,
|
|
||||||
tool_name: None,
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let registry = QueryRegistry::from_specs(specs).map_err(|errors| {
|
let registry = QueryRegistry::from_specs(specs).map_err(|errors| {
|
||||||
|
|
@ -392,8 +390,6 @@ mod tests {
|
||||||
let spec = |name: &str, source: &str| RegistrySpec {
|
let spec = |name: &str, source: &str| RegistrySpec {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
source: source.to_string(),
|
source: source.to_string(),
|
||||||
expose: false,
|
|
||||||
tool_name: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Empty registry → nothing attached, no error.
|
// Empty registry → nothing attached, no error.
|
||||||
|
|
|
||||||
|
|
@ -518,6 +518,65 @@ async fn write_tool_listed_when_only_unprotected_writes_allowed() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stored_query_tool_folds_docs_and_honors_mcp_annotation() {
|
||||||
|
// A query carrying @description + @instruction + a per-param @description +
|
||||||
|
// @mcp(tool_name: …) projects as ONE tool whose name is the override, whose
|
||||||
|
// description folds the instruction in, and whose input schema documents the
|
||||||
|
// param — and it is callable under the override name (list == call).
|
||||||
|
const SRC: &str = r#"query find_person(@description("the person's exact name") $name: String)
|
||||||
|
@description("Find a person by name.")
|
||||||
|
@instruction("Use only for an exact name; for fuzzy matches use search.")
|
||||||
|
@mcp(tool_name: "lookup_person")
|
||||||
|
{ match { $p: Person { name: $name } } return { $p.age } }"#;
|
||||||
|
|
||||||
|
let (_t, app) = app_with_stored_queries(
|
||||||
|
&[("find_person", SRC, true)],
|
||||||
|
&[("act-invoke", "tok")],
|
||||||
|
INVOKE_POLICY_YAML,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let (_s, list) =
|
||||||
|
json_response(&app, mcp_request(Some("tok"), rpc(1, "tools/list", json!({})))).await;
|
||||||
|
let tools = list["result"]["tools"].as_array().unwrap();
|
||||||
|
let tool = tools
|
||||||
|
.iter()
|
||||||
|
.find(|t| t["name"] == json!("lookup_person"))
|
||||||
|
.unwrap_or_else(|| panic!("lookup_person not listed: {:?}", tool_names(&list)));
|
||||||
|
// The @mcp tool name replaces the query name on the surface.
|
||||||
|
assert!(
|
||||||
|
!tool_names(&list).contains(&"find_person".to_string()),
|
||||||
|
"query name must not double as a tool: {:?}",
|
||||||
|
tool_names(&list)
|
||||||
|
);
|
||||||
|
// Description folds @description then @instruction.
|
||||||
|
let desc = tool["description"].as_str().unwrap();
|
||||||
|
assert!(desc.contains("Find a person by name."), "description: {desc}");
|
||||||
|
assert!(desc.contains("Use only for an exact name"), "instruction folded in: {desc}");
|
||||||
|
// The parameter carries its @description in the tool input schema.
|
||||||
|
let param_desc =
|
||||||
|
tool["inputSchema"]["properties"]["params"]["properties"]["name"]["description"].as_str();
|
||||||
|
assert_eq!(
|
||||||
|
param_desc,
|
||||||
|
Some("the person's exact name"),
|
||||||
|
"param doc in input schema: {}",
|
||||||
|
tool["inputSchema"]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Callable under the override name (list and call agree).
|
||||||
|
let (status, v) = json_response(
|
||||||
|
&app,
|
||||||
|
mcp_request(
|
||||||
|
Some("tok"),
|
||||||
|
rpc(2, "tools/call", json!({ "name": "lookup_person", "arguments": { "params": { "name": "Nobody" } } })),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert_ne!(v["result"]["isError"], json!(true), "renamed tool not callable: {v}");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn per_query_mode_does_not_expose_meta_tools() {
|
async fn per_query_mode_does_not_expose_meta_tools() {
|
||||||
// Below the auto threshold the projection is per-query, so the discovery +
|
// Below the auto threshold the projection is per-query, so the discovery +
|
||||||
|
|
@ -607,8 +666,6 @@ fn stored_query_shadowing_a_builtin_is_a_load_error() {
|
||||||
let result = QueryRegistry::from_specs(vec![RegistrySpec {
|
let result = QueryRegistry::from_specs(vec![RegistrySpec {
|
||||||
name: "graph_query".to_string(),
|
name: "graph_query".to_string(),
|
||||||
source: "query graph_query() { match { $p: Person } return { $p.name } }".to_string(),
|
source: "query graph_query() { match { $p: Person } return { $p.name } }".to_string(),
|
||||||
expose: true,
|
|
||||||
tool_name: None,
|
|
||||||
}]);
|
}]);
|
||||||
let errors = result.expect_err("expected a collision error");
|
let errors = result.expect_err("expected a collision error");
|
||||||
assert!(
|
assert!(
|
||||||
|
|
|
||||||
|
|
@ -145,14 +145,24 @@ pub fn graph_path(root: &Path) -> PathBuf {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn stored_query_registry(specs: &[(&str, &str, bool)]) -> QueryRegistry {
|
pub fn stored_query_registry(specs: &[(&str, &str, bool)]) -> QueryRegistry {
|
||||||
|
// MCP `expose` now lives in the `.gq` source `@mcp(...)` annotation. The
|
||||||
|
// `(name, source, expose)` tuple stays for ergonomics: when `expose` is
|
||||||
|
// false, inject `@mcp(expose: false)` between the param-list `)` and the
|
||||||
|
// body `{` (the first `{` in a query source is always the body open), so
|
||||||
|
// the real parse path is exercised.
|
||||||
QueryRegistry::from_specs(
|
QueryRegistry::from_specs(
|
||||||
specs
|
specs
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(name, source, expose)| RegistrySpec {
|
.map(|(name, source, expose)| {
|
||||||
name: name.to_string(),
|
let source = if *expose {
|
||||||
source: source.to_string(),
|
source.to_string()
|
||||||
expose: *expose,
|
} else {
|
||||||
tool_name: None,
|
match source.find('{') {
|
||||||
|
Some(i) => format!("{}@mcp(expose: false) {}", &source[..i], &source[i..]),
|
||||||
|
None => source.to_string(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
RegistrySpec { name: name.to_string(), source }
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -2198,6 +2198,13 @@
|
||||||
"nullable"
|
"nullable"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"description": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"description": "Per-parameter documentation from a leading `@description(\"…\")`, surfaced\ninto the JSON-Schema property `description` (MCP tool input + catalog)."
|
||||||
|
},
|
||||||
"item_kind": {
|
"item_kind": {
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue