diff --git a/crates/imcp2-core/src/lib.rs b/crates/imcp2-core/src/lib.rs index e50035a..870939d 100644 --- a/crates/imcp2-core/src/lib.rs +++ b/crates/imcp2-core/src/lib.rs @@ -1,7 +1,8 @@ //! imcp2-core — the transport/OAuth-agnostic components of the IMCP2 Internet //! Computer MCP server, from which a serving binary is composed: //! -//! * [`tools::IcTools`] — the complete MCP tool surface (canister reads and +//! * [`tools::IcTools`] — the complete MCP tool surface, composed from +//! [`tools::IcCanisterTools`] and [`tools::IcProtocolTools`] (canister reads and //! writes in textual Candid, app/canister discovery, OQL, canister //! management, IC skills), an [`rmcp`] `ServerHandler` ready to serve over //! whichever transport the binary picks (streamable-HTTP in the hosted @@ -38,7 +39,7 @@ mod discover; mod management; pub use identities::{IiInstance, SessionGauges}; -pub use tools::{IcTools, SessionResolver}; +pub use tools::{IcCanisterTools, IcProtocolTools, IcTools, SessionResolver}; /// The IC [`Agent`] type the components are built around, re-exported so /// callers construct the injected agent from the exact `ic-agent` version this /// crate links. diff --git a/crates/imcp2-core/src/tools.rs b/crates/imcp2-core/src/tools.rs index fbfcb76..0a0d250 100644 --- a/crates/imcp2-core/src/tools.rs +++ b/crates/imcp2-core/src/tools.rs @@ -10,10 +10,10 @@ use candid::Principal; use ic_agent::{Agent, Identity}; use rmcp::{ - handler::server::wrapper::Parameters, + handler::server::{tool::ToolCallContext, wrapper::Parameters}, model::*, service::RequestContext, - tool, tool_handler, tool_router, + tool, tool_router, schemars, ErrorData as McpError, RoleServer, ServerHandler, }; @@ -49,9 +49,26 @@ const OQL_PRIMER_MD: &str = include_str!("../static/oql-primer.md"); // `discover`, `identities`, `skills`, `management`. main.rs only wires the tools // together. +/// The app- and canister-scoped tools: everything that reads, discovers, or +/// calls ONE app or canister (the `…canister…` / `…_app…` names) — Candid and +/// OQL reads, update calls, app resolution/discovery, and the per-app +/// identity tools. Served together with [`IcProtocolTools`] behind +/// [`IcTools`]. #[derive(Clone)] -pub struct IcTools { +pub struct IcCanisterTools { agent: Agent, + identities: Identities, + /// The authentication seam (see [`SessionResolver`]): asks the embedding + /// binary which already-validated session this call acts as. + session: SessionResolver, +} + +/// The IC protocol / meta-level tools (the `icp_` prefix): dashboard name/id +/// lookups, the official IC skills, the OQL dialect guide, and canister +/// creation/management as the standing management principal. Served together +/// with [`IcCanisterTools`] behind [`IcTools`]. +#[derive(Clone)] +pub struct IcProtocolTools { identities: Identities, skills: skills::SkillsCatalog, /// The authentication seam (see [`SessionResolver`]): asks the embedding @@ -59,6 +76,19 @@ pub struct IcTools { session: SessionResolver, } +/// The complete MCP tool surface: [`IcCanisterTools`] and [`IcProtocolTools`] +/// composed behind one [`ServerHandler`]. The split follows the scope +/// taxonomy the server instructions teach — an `icp_` prefix marks IC +/// protocol / meta-level tools, `…_app…`/`…canister…` names act on one app or +/// canister — while the served surface (tool names, descriptions, +/// annotations, instructions, resources) is exactly what the single struct +/// used to serve. +#[derive(Clone)] +pub struct IcTools { + canister: IcCanisterTools, + protocol: IcProtocolTools, +} + /// The one seam between deployments: how a tool call finds the Internet /// Identity session it acts as. **Authentication itself lives in the embedding /// binary, not here** — the resolver only reports the outcome of a validation @@ -71,7 +101,6 @@ pub struct IcTools { pub type SessionResolver = Arc) -> Option + Send + Sync>; -#[tool_router] impl IcTools { pub fn new( agent: Agent, @@ -80,13 +109,30 @@ impl IcTools { session: SessionResolver, ) -> Self { Self { - agent, - identities, - skills, - session, + canister: IcCanisterTools { + agent, + identities: identities.clone(), + session: session.clone(), + }, + protocol: IcProtocolTools { + identities, + skills, + session, + }, } } + /// Every tool on the served surface — the app/canister tools first, then + /// the protocol/meta tools — exactly the list `tools/list` returns. + pub fn all_tools() -> Vec { + let mut tools = IcCanisterTools::tool_router().list_all(); + tools.extend(IcProtocolTools::tool_router().list_all()); + tools + } +} + +#[tool_router] +impl IcCanisterTools { /// The already-validated session id this tool call acts as, per the /// embedding binary's [`SessionResolver`]. fn current_session_id(&self, ctx: &RequestContext) -> Option { @@ -168,19 +214,6 @@ impl IcTools { } } - #[tool( - description = "Load the OQL query-surface guide: the JSON query dialect for canisters that expose OQL (get_canister_candid reports `oql: true`) — entities/fields/edges via `schema`, and the `execute` query object (filters, aggregation, ordering, edge traversal, paging). This is step ONE of the fixed sequence guide→schema→query: read this once, then `get_canister_oql_schema` for the exact entity/field names (they are the schema's own — often PLURAL and unlike the Candid types/methods, e.g. `bookings` not `Booking`/`getBookings`), then `canister_query` with the `oql` argument. Never guess bespoke per-question methods. The schema read and the query REQUIRE the app's `derivation_origin` (from open_app / resolve_app) — anonymous per-app reads are disabled for now and are rejected with guidance. Both wrap the `schema`/`execute` methods, so you write plain JSON — no Candid escaping.", - annotations(title = "Get the OQL query guide", read_only_hint = true, destructive_hint = false, open_world_hint = false), - output_schema = schema_for_output::(), - )] - async fn icp_oql_guide( - &self, - Parameters(_args): Parameters, - ) -> Result { - let output = calls::OqlGuideOutput { content: OQL_PRIMER_MD.to_string() }; - Ok(ok_structured(output.content.clone(), &output)) - } - #[tool( description = "Fetch the OQL schema catalogue of a canister that exposes the OQL surface (get_canister_candid reports `oql: true`): its entities, their primary keys, fields, and edges, as JSON. The MIDDLE step of guide→schema→query: read `icp_oql_guide` once, then call THIS before canister_query so you use the exact entity/field names instead of guessing. Entity names are the schema's own — often PLURAL and different from the Candid types/methods (e.g. `bookings`, not `Booking`/`getBookings`). Returns the schema plus a ready-to-run `canister_query` example per entity (each preserving this call's identity). AUTH: `derivation_origin` is REQUIRED — the schema itself is gated by the caller's principal, so a read with no origin is REJECTED (anonymous per-app reads are disabled for now) with guidance to pass it, rather than returning an empty entity list you'd misread as \"the app has no data model\". Pass the app's canonical `derivation_origin` (from open_app / resolve_app) to read the entities visible to the USER; the reply echoes `derived_for_origin` / `acted_as_principal`.", annotations(title = "Get the OQL schema", read_only_hint = true, destructive_hint = false, open_world_hint = true), @@ -1194,6 +1227,29 @@ impl IcTools { } } +} + +#[tool_router] +impl IcProtocolTools { + /// The already-validated session id this tool call acts as, per the + /// embedding binary's [`SessionResolver`]. + fn current_session_id(&self, ctx: &RequestContext) -> Option { + (self.session)(ctx) + } + + #[tool( + description = "Load the OQL query-surface guide: the JSON query dialect for canisters that expose OQL (get_canister_candid reports `oql: true`) — entities/fields/edges via `schema`, and the `execute` query object (filters, aggregation, ordering, edge traversal, paging). This is step ONE of the fixed sequence guide→schema→query: read this once, then `get_canister_oql_schema` for the exact entity/field names (they are the schema's own — often PLURAL and unlike the Candid types/methods, e.g. `bookings` not `Booking`/`getBookings`), then `canister_query` with the `oql` argument. Never guess bespoke per-question methods. The schema read and the query REQUIRE the app's `derivation_origin` (from open_app / resolve_app) — anonymous per-app reads are disabled for now and are rejected with guidance. Both wrap the `schema`/`execute` methods, so you write plain JSON — no Candid escaping.", + annotations(title = "Get the OQL query guide", read_only_hint = true, destructive_hint = false, open_world_hint = false), + output_schema = schema_for_output::(), + )] + async fn icp_oql_guide( + &self, + Parameters(_args): Parameters, + ) -> Result { + let output = calls::OqlGuideOutput { content: OQL_PRIMER_MD.to_string() }; + Ok(ok_structured(output.content.clone(), &output)) + } + #[tool( description = "Find Internet Computer canisters by NAME. Searches the IC dashboard's service registries — the ICRC token ledgers (e.g. ckBTC, ckETH, ckUSDC, SNS tokens) by symbol/name, and the SNS project catalog by name — and returns matching canister ids. Use this when the user names a token, project, or service (e.g. \"ckUSDC\") rather than a canister id; then confirm with get_canister_candid, read with canister_query, and write with canister_update_call. (No public name-search exists over arbitrary canisters; this covers the IC's labelled services.)", annotations(title = "Find canisters by name", read_only_hint = true, destructive_hint = false, open_world_hint = true), @@ -2052,8 +2108,42 @@ const SERVER_INSTRUCTIONS: &str = "Internet Computer tools. Every tool speaks TE locally, have the user create the canister with the icp CLI (`icp_create_canister` \ prints the exact steps), then `icp_install_code`."; -#[tool_handler] impl ServerHandler for IcTools { + async fn list_tools( + &self, + _request: Option, + _ctx: RequestContext, + ) -> Result { + Ok(ListToolsResult { + tools: Self::all_tools(), + ..Default::default() + }) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + context: RequestContext, + ) -> Result { + // Dispatch by router membership; an unknown name falls through to the + // protocol router, whose miss produces the standard method-not-found + // error (same shape the macro-generated handler returned). + if IcCanisterTools::tool_router().has_route(&request.name) { + let tcc = ToolCallContext::new(&self.canister, request, context); + IcCanisterTools::tool_router().call(tcc).await + } else { + let tcc = ToolCallContext::new(&self.protocol, request, context); + IcProtocolTools::tool_router().call(tcc).await + } + } + + fn get_tool(&self, name: &str) -> Option { + IcCanisterTools::tool_router() + .get(name) + .cloned() + .or_else(|| IcProtocolTools::tool_router().get(name).cloned()) + } + fn get_info(&self) -> ServerInfo { ServerInfo::new( ServerCapabilities::builder().enable_tools().enable_resources().build(), @@ -2078,7 +2168,7 @@ impl ServerHandler for IcTools { // Surface the IC skills as resources too (best-effort: if the registry is // unreachable, the candid resources above still list). Each `skill://` // is read on demand in read_resource. - if let Ok(skills) = self.skills.list().await { + if let Ok(skills) = self.protocol.skills.list().await { for s in skills { let title = if s.title.is_empty() { format!("IC skill: {}", s.name) @@ -2104,7 +2194,7 @@ impl ServerHandler for IcTools { ) -> Result { // Skills are fetched live by name; the candid references are static. if let Some(name) = request.uri.strip_prefix(SKILL_URI_PREFIX) { - return match self.skills.get(name).await { + return match self.protocol.skills.get(name).await { Ok(md) => Ok(ReadResourceResult::new(vec![ResourceContents::text( md, request.uri, @@ -2380,7 +2470,7 @@ mod tests { // get_canister_candid as destructive. Assert the read/write classification serializes. #[test] fn every_tool_has_correct_read_write_annotations() { - let tools = super::IcTools::tool_router().list_all(); + let tools = super::IcTools::all_tools(); assert_eq!(tools.len(), 26, "expected 26 tools, got {}", tools.len()); assert!( tools.iter().all(|t| t.annotations.is_some()), @@ -2442,7 +2532,7 @@ mod tests { // refused method families, and the user-controlled venue. #[test] fn financial_policy_is_a_server_instruction_not_a_description() { - let tools = super::IcTools::tool_router().list_all(); + let tools = super::IcTools::all_tools(); let tool = tools .iter() .find(|t| &*t.name == "canister_update_call") @@ -2458,13 +2548,13 @@ mod tests { } // The local binary's login tools (`authenticate`/`auth_status`) live on its - // OWN wrapper handler, never in this router: this router IS what the hosted - // server advertises on `tools/list`, so a login tool landing here would ship - // to every hosted client (which logs in via OAuth instead). Regression guard - // for that boundary. + // OWN wrapper handler, never on these routers: this surface IS what the + // hosted server advertises on `tools/list`, so a login tool landing here + // would ship to every hosted client (which logs in via OAuth instead). + // Regression guard for that boundary. #[test] fn the_core_router_carries_no_local_login_tools() { - let tools = super::IcTools::tool_router().list_all(); + let tools = super::IcTools::all_tools(); for name in ["authenticate", "auth_status"] { assert!( tools.iter().all(|t| &*t.name != name), @@ -2473,6 +2563,37 @@ mod tests { } } + // The tool surface is split by the scope taxonomy the server instructions + // teach: every `icp_`-prefixed (protocol / meta-level) tool lives on + // IcProtocolTools, everything app-/canister-scoped on IcCanisterTools — + // no overlap, nothing dropped, and the composed list is what tools/list + // serves. + #[test] + fn the_split_follows_the_icp_prefix_taxonomy() { + let canister = super::IcCanisterTools::tool_router().list_all(); + let protocol = super::IcProtocolTools::tool_router().list_all(); + for t in &canister { + assert!( + !t.name.starts_with("icp_"), + "{} is icp_-prefixed and belongs on IcProtocolTools", + t.name + ); + } + for t in &protocol { + assert!( + t.name.starts_with("icp_"), + "{} is not icp_-prefixed and belongs on IcCanisterTools", + t.name + ); + } + let all = super::IcTools::all_tools(); + assert_eq!(all.len(), canister.len() + protocol.len()); + let mut names: Vec<&str> = all.iter().map(|t| &*t.name).collect(); + names.sort_unstable(); + names.dedup(); + assert_eq!(names.len(), all.len(), "tool names must be unique across the split routers"); + } + // icp_top_up_canister and icp_create_canister are instructions-only, and // their descriptions must say so FUNCTIONALLY — the tool prints steps and // never executes — without compliance disclaimers (per review, policy @@ -2480,7 +2601,7 @@ mod tests { // Guards against a future edit quietly reverting either. #[test] fn top_up_tool_declares_itself_instructions_only() { - let tools = super::IcTools::tool_router().list_all(); + let tools = super::IcTools::all_tools(); let tool = tools .iter() .find(|t| &*t.name == "icp_top_up_canister") @@ -2493,7 +2614,7 @@ mod tests { #[test] fn create_tool_declares_itself_instructions_only() { - let tools = super::IcTools::tool_router().list_all(); + let tools = super::IcTools::all_tools(); let tool = tools .iter() .find(|t| &*t.name == "icp_create_canister") @@ -2510,7 +2631,7 @@ mod tests { // whole surface: a new tool added without an output schema fails here. #[test] fn every_tool_declares_an_object_output_schema() { - let tools = super::IcTools::tool_router().list_all(); + let tools = super::IcTools::all_tools(); for t in &tools { let schema = t .output_schema @@ -2528,7 +2649,7 @@ mod tests { // Spot-check icp_find_canister_by_name's schema lists the expected properties. #[test] fn find_canister_declares_output_schema() { - let tools = super::IcTools::tool_router().list_all(); + let tools = super::IcTools::all_tools(); let tool = tools .iter() .find(|t| &*t.name == "icp_find_canister_by_name")