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:
Ragnor Comerford 2026-06-17 16:04:05 +02:00
parent bcd0d9c867
commit c8e91c11f0
No known key found for this signature in database
14 changed files with 396 additions and 107 deletions

View file

@ -10,6 +10,10 @@ pub struct QueryDecl {
pub name: String,
pub description: 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 match_clause: Vec<Clause>,
pub return_clause: Vec<Projection>,
@ -18,11 +22,23 @@ pub struct QueryDecl {
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)]
pub struct Param {
pub name: String,
pub type_name: String,
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)]

View file

@ -50,6 +50,8 @@ fn parse_query_decl(pair: pest::iterators::Pair<Rule>) -> Result<QueryDecl> {
let mut description = None;
let mut instruction = None;
let mut mcp = McpQueryMeta::default();
let mut mcp_seen = false;
let mut params = Vec::new();
let mut match_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 => {
let (annotation_name, value) = parse_query_annotation(item)?;
match annotation_name {
"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 => {
Rule::query_annotation => match parse_query_annotation(item)? {
ParsedAnnotation::Description(value) => {
if description.replace(value).is_some() {
return Err(NanoError::Parse(format!(
"unsupported query annotation: @{}",
other
"query `{}` cannot include duplicate @description annotations",
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 => {
let body = item
.into_inner()
@ -157,6 +160,7 @@ fn parse_query_decl(pair: pest::iterators::Pair<Rule>) -> Result<QueryDecl> {
name,
description,
instruction,
mcp,
params,
match_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
.into_inner()
.next()
.ok_or_else(|| NanoError::Parse("query annotation cannot be empty".to_string()))?;
match inner.as_rule() {
Rule::description_annotation => {
let value = inner
.into_inner()
.next()
.ok_or_else(|| {
NanoError::Parse("@description requires a string literal".to_string())
})
.map(|value| parse_string_lit(value.as_str()))??;
Ok(("description", value))
}
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))
}
Rule::description_annotation => Ok(ParsedAnnotation::Description(annotation_string(
inner,
"@description",
)?)),
Rule::instruction_annotation => Ok(ParsedAnnotation::Instruction(annotation_string(
inner,
"@instruction",
)?)),
Rule::mcp_annotation => Ok(ParsedAnnotation::Mcp(parse_mcp_annotation(inner)?)),
other => Err(NanoError::Parse(format!(
"unexpected query annotation rule: {:?}",
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> {
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 type_ref = inner.next().unwrap();
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,
type_name: base,
nullable,
description,
})
}

View file

@ -62,6 +62,64 @@ return { $p.name }
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]
fn test_parse_no_params() {
let input = r#"

View file

@ -12,9 +12,14 @@ query_decl = {
~ query_body
~ "}"
}
query_annotation = { description_annotation | instruction_annotation }
query_annotation = { description_annotation | instruction_annotation | mcp_annotation }
description_annotation = { "@description" ~ "(" ~ 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 }
mutation_body = { mutation_stmt+ }
@ -33,7 +38,10 @@ mutation_assignment = { ident ~ ":" ~ match_value ~ ","? }
mutation_predicate = { ident ~ comp_op ~ match_value }
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) ~ "?"? }
list_type = { "[" ~ base_type ~ "]" }