Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
af6595f
Refuse ledger transfer/approval methods in canister_update_call
claude Aug 26, 2026
6ddd4a1
Remove financial framing; state the server is not for financial opera…
claude Aug 26, 2026
d9a0a33
Block cycles-ledger creation spends; scope the no-financial claim (re…
claude Aug 26, 2026
46cc3d6
Use opencloud.org example; scope financial claims to enforcement (rev…
claude Aug 26, 2026
f1a52ca
State the no-financial rule as policy, not as a capability claim (rev…
claude Aug 26, 2026
33a33c3
Present the posture as policy plus standardized-method guardrail (rev…
claude Aug 26, 2026
4897e25
Merge remote-tracking branch 'origin/main' into claude/icp-marketplac…
claude Aug 26, 2026
0f31725
Merge branch 'claude/icp-marketplace-compliance-vpawel-2-ledger-guard…
claude Aug 26, 2026
ef65fef
Stack on the ledger-guard branch; mark the top-up milestone merged
claude Aug 26, 2026
cdb31ab
Frame the financial restriction as intent plus reasonable measures
claude Aug 26, 2026
01de76c
Align the listing draft and reviewer test with the intent framing
claude Aug 26, 2026
6bb73a0
Explain why real funds live on the standardized ledger surface (review)
claude Aug 27, 2026
40ff3a5
Merge remote-tracking branch 'origin/main' into claude/icp-marketplac…
claude Aug 27, 2026
52a7b98
Merge branch 'claude/icp-marketplace-compliance-vpawel-2-ledger-guard…
claude Aug 27, 2026
d28a5ed
Split the guard: literal ICRC matching plus ledger-scoped abstract names
claude Aug 27, 2026
20ea3e3
Merge branch 'claude/icp-marketplace-compliance-vpawel-2-ledger-guard…
claude Aug 27, 2026
17d33bc
Describe the per-ledger scoping of the guard in the OpenAI doc
claude Aug 27, 2026
c068c23
Point the creation refusal at the user-run icp CLI, not a connector tool
claude Aug 27, 2026
0979f0b
Merge branch 'claude/icp-marketplace-compliance-vpawel-2-ledger-guard…
claude Aug 27, 2026
fe696eb
Make the financial policy a server instruction in plain protective terms
claude Aug 27, 2026
59002f3
Merge branch 'claude/icp-marketplace-compliance-vpawel-2-ledger-guard…
claude Aug 27, 2026
05b3bda
Align the directory-submission docs with the server-instruction policy
claude Aug 27, 2026
dddc4a4
Make icp_create_canister instructions-only: never execute funding
claude Aug 27, 2026
a06d0ed
Merge branch 'claude/icp-marketplace-compliance-vpawel-2-ledger-guard…
claude Aug 27, 2026
15eb6b3
Merge the instructions-only creation change; sweep NNS and de-risk th…
claude Aug 27, 2026
1538d39
Keep the instructions-only tool descriptions functional, not disclaiming
claude Aug 27, 2026
776af88
Merge branch 'claude/icp-marketplace-compliance-vpawel-2-ledger-guard…
claude Aug 27, 2026
c46add8
Fix the read-only tool count and pin the top-up example prompts
claude Aug 27, 2026
82c7335
Merge remote-tracking branch 'origin/main' into claude/icp-marketplac…
claude Aug 27, 2026
184d70c
Split IcTools into IcCanisterTools and IcProtocolTools
claude Aug 27, 2026
1ef2037
Neutralize the remaining financial data examples; fix a heading and a…
claude Aug 27, 2026
e5f4af5
Merge branch 'claude/icp-marketplace-compliance-vpawel-3-definanciali…
claude Aug 27, 2026
92402c3
Merge remote-tracking branch 'origin/main' into claude/icp-marketplac…
claude Aug 27, 2026
f80c516
Trigger the org-required External PR Ruleset workflow
claude Aug 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions crates/imcp2-core/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
Expand Down
191 changes: 156 additions & 35 deletions crates/imcp2-core/src/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down Expand Up @@ -49,16 +49,46 @@ 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
/// binary which already-validated session this call acts as.
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
Expand All @@ -71,7 +101,6 @@ pub struct IcTools {
pub type SessionResolver =
Arc<dyn Fn(&RequestContext<RoleServer>) -> Option<String> + Send + Sync>;

#[tool_router]
impl IcTools {
pub fn new(
agent: Agent,
Expand All @@ -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<Tool> {
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<RoleServer>) -> Option<String> {
Expand Down Expand Up @@ -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::<calls::OqlGuideOutput>(),
)]
async fn icp_oql_guide(
&self,
Parameters(_args): Parameters<management::NoArgs>,
) -> Result<CallToolResult, McpError> {
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),
Expand Down Expand Up @@ -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<RoleServer>) -> Option<String> {
(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::<calls::OqlGuideOutput>(),
)]
async fn icp_oql_guide(
&self,
Parameters(_args): Parameters<management::NoArgs>,
) -> Result<CallToolResult, McpError> {
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),
Expand Down Expand Up @@ -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<PaginatedRequestParams>,
_ctx: RequestContext<RoleServer>,
) -> Result<ListToolsResult, McpError> {
Ok(ListToolsResult {
tools: Self::all_tools(),
..Default::default()
})
}

async fn call_tool(
&self,
request: CallToolRequestParams,
context: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
// 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
Comment thread
aterga marked this conversation as resolved.
Resolved
}
}

fn get_tool(&self, name: &str) -> Option<Tool> {
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(),
Expand All @@ -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://<name>`
// 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)
Expand All @@ -2104,7 +2194,7 @@ impl ServerHandler for IcTools {
) -> Result<ReadResourceResult, McpError> {
// 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,
Expand Down Expand Up @@ -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()),
Expand Down Expand Up @@ -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")
Expand All @@ -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),
Expand All @@ -2473,14 +2563,45 @@ 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
// language lives in the server instructions, not per-tool descriptions).
// 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")
Expand All @@ -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")
Expand All @@ -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
Expand All @@ -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")
Expand Down
Loading