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

@ -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]
async fn per_query_mode_does_not_expose_meta_tools() {
// 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 {
name: "graph_query".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");
assert!(

View file

@ -145,14 +145,24 @@ pub fn graph_path(root: &Path) -> PathBuf {
}
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(
specs
.iter()
.map(|(name, source, expose)| RegistrySpec {
name: name.to_string(),
source: source.to_string(),
expose: *expose,
tool_name: None,
.map(|(name, source, expose)| {
let source = if *expose {
source.to_string()
} else {
match source.find('{') {
Some(i) => format!("{}@mcp(expose: false) {}", &source[..i], &source[i..]),
None => source.to_string(),
}
};
RegistrySpec { name: name.to_string(), source }
})
.collect(),
)