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
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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#"
|
||||
|
|
|
|||
|
|
@ -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 ~ "]" }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue