Skip to content
Draft
Changes from all commits
Commits
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
168 changes: 156 additions & 12 deletions crates/mcp/src/surfpool/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,18 @@ fn compact_template_json(template: &surfpool_types::OverrideTemplate) -> serde_j
obj
}

fn index_template_json(template: &surfpool_types::OverrideTemplate) -> serde_json::Value {
serde_json::json!({
"id": template.id,
"name": template.name,
"description": template.description,
"protocol": template.protocol,
"accountType": template.account_type,
"tags": template.tags,
"hasLlmContext": template.llm_context.is_some(),
})
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct SearchConstantOptionsParams {
Expand All @@ -104,6 +116,15 @@ pub struct SearchConstantOptionsParams {
pub query: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct GetTemplateParams {
#[schemars(
description = "Template id from get_override_templates (e.g., \"pyth-price-feed-v2\")."
)]
pub template_id: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct StartSurfnetWithTokenAccountsParams {
#[schemars(
Expand Down Expand Up @@ -520,7 +541,7 @@ impl Surfpool {

IMPORTANT:
- If a user asks to create a scenario, DO NOT USE this method. Instead, use the dedicated `create_scenario` tool.
- There is NO RPC method called `surfnet_getOverrideTemplates`. Override templates are ONLY available via the MCP resource str:///override_templates (use read_resource, not this RPC tool).
- There is NO RPC method called `surfnet_getOverrideTemplates`. The override templates index is available via the MCP resource str:///override_templates (use read_resource, not this RPC tool); one template's full detail comes from the get_override_template tool.
"#)]
async fn call_surfnet_rpc(
&self,
Expand Down Expand Up @@ -605,7 +626,8 @@ impl Surfpool {
- The `tags` field MUST be a JSON array [], NOT a JSON string
- DO NOT stringify nested objects - pass them as native JSON

⚠️ CRITICAL: You MUST call `get_override_templates` FIRST to get valid template data.
⚠️ CRITICAL: You MUST call `get_override_templates` FIRST to pick a templateId, then
`get_override_template` on that id for its properties and account shape.
DO NOT invent templateId values, property names, or account addresses - they MUST come from the templates.

STRICT RULES - VIOLATIONS WILL CAUSE ERRORS:
Expand Down Expand Up @@ -895,7 +917,7 @@ impl Surfpool {
}

#[tool(
description = "Fetches ALL available override templates. MUST be called before create_scenario to get valid templateId values and property names. Constants are summarized as {label, description, optionsCount} - resolve an actual option value with search_constant_options."
description = "Lists all override templates as a light index: {id, name, description, protocol, accountType, tags, hasLlmContext}. Call this first to pick a templateId, then get_override_template for that one template's full detail (properties, address, llmContext). Constants are resolved with search_constant_options."
)]
async fn get_override_templates(&self) -> Result<CallToolResult, McpError> {
let registry = self.template_registry.read().map_err(|_| {
Expand All @@ -910,13 +932,42 @@ impl Surfpool {
let templates: Vec<serde_json::Value> = registry
.all()
.iter()
.map(|t| compact_template_json(t))
.map(|t| index_template_json(t))
.collect();

let json_str = serde_json::to_string(&templates).unwrap_or_default();
Ok(CallToolResult::success(vec![Content::text(json_str)]))
}

#[tool(
description = "Fetches one template's full detail (properties, address, constants summarized as {label, description, optionsCount}, and llmContext). Call after get_override_templates with the id you picked, before create_scenario. Resolve an actual constant option value with search_constant_options."
)]
async fn get_override_template(
&self,
Parameters(params): Parameters<GetTemplateParams>,
) -> Result<CallToolResult, McpError> {
let registry = self.template_registry.read().map_err(|_| {
use std::borrow::Cow;
McpError {
code: ErrorCode(-32603),
message: Cow::from("Failed to read template registry"),
data: None,
}
})?;

let all_templates = registry.all();
let Some(template) = all_templates.iter().find(|t| t.id == params.template_id) else {
let valid_ids: Vec<&String> = all_templates.iter().map(|t| &t.id).collect();
return Ok(CallToolResult::error(vec![Content::text(format!(
"Unknown templateId {:?}. Valid IDs are: {:?}",
params.template_id, valid_ids
))]));
};

let json_str = serde_json::to_string(&compact_template_json(template)).unwrap_or_default();
Ok(CallToolResult::success(vec![Content::text(json_str)]))
}

#[tool(
description = "Searches the options of a template's constants (price feeds, markets, token mints). Use after get_override_templates to resolve a constant_ref value: pass the templateId, optionally the constant name, and a query like \"SOL/USD\". Returns matching options whose `value` field is what create_scenario expects."
)]
Expand Down Expand Up @@ -1101,7 +1152,7 @@ impl ServerHandler for Surfpool {
let templates: Vec<serde_json::Value> = registry
.all()
.iter()
.map(|t| compact_template_json(t))
.map(|t| index_template_json(t))
.collect();

let templates_json = serde_json::to_string(&templates).map_err(|_| {
Expand Down Expand Up @@ -1181,8 +1232,14 @@ mod tests {
})
}

fn template_id(id: &str) -> Parameters<GetTemplateParams> {
Parameters(GetTemplateParams {
template_id: id.to_string(),
})
}

#[tokio::test]
async fn get_override_templates_summarizes_constants_instead_of_inlining_options() {
async fn the_template_index_is_light_and_omits_the_heavy_fields() {
let surfpool = Surfpool::new();
let result = surfpool.get_override_templates().await.unwrap();
assert_ne!(result.is_error, Some(true));
Expand All @@ -1195,6 +1252,38 @@ mod tests {
.find(|t| t["id"] == "pyth-price-feed-v2")
.expect("pyth template present");

for heavy in ["llmContext", "properties", "address", "constants", "idl"] {
assert!(
pyth.get(heavy).is_none(),
"the index must not carry {heavy}"
);
}
assert_eq!(
pyth["hasLlmContext"], true,
"the index flags which templates have context to fetch"
);
}

#[tokio::test]
async fn get_override_template_returns_full_detail_and_summarizes_constants() {
let surfpool = Surfpool::new();
let result = surfpool
.get_override_template(template_id("pyth-price-feed-v2"))
.await
.unwrap();
assert_ne!(result.is_error, Some(true));

let pyth = json_of(&result);
assert!(
pyth.get("properties").is_some(),
"detail carries properties"
);
assert!(pyth.get("address").is_some(), "detail carries the address");
assert!(
pyth.get("llmContext").is_some(),
"detail carries llmContext"
);

let price_feed = &pyth["constants"]["price_feed"];
assert!(
price_feed["optionsCount"].as_u64().unwrap() > 0,
Expand All @@ -1206,6 +1295,44 @@ mod tests {
);
}

#[tokio::test]
async fn get_override_template_rejects_an_unknown_id_and_names_valid_ones() {
let surfpool = Surfpool::new();
let result = surfpool
.get_override_template(template_id("no-such-template"))
.await
.unwrap();
assert_eq!(result.is_error, Some(true));
let text = &result.content[0].as_text().expect("text").text;
assert!(
text.contains("pyth-price-feed-v2"),
"the error must name valid ids, got: {text}"
);
}

#[test]
fn the_index_is_lighter_than_the_full_detail_of_every_template() {
let registry = TemplateRegistry::new();
let index: Vec<_> = registry
.all()
.iter()
.map(|t| index_template_json(t))
.collect();
let full: Vec<_> = registry
.all()
.iter()
.map(|t| compact_template_json(t))
.collect();

let index_len = serde_json::to_string(&index).unwrap().len();
let full_len = serde_json::to_string(&full).unwrap().len();
assert!(
index_len * 3 < full_len,
"the index ({index_len} bytes) should be far lighter than the old full payload \
({full_len} bytes)"
);
}

#[tokio::test]
async fn search_finds_a_feed_case_insensitively_and_returns_usable_values() {
let surfpool = Surfpool::new();
Expand Down Expand Up @@ -1268,6 +1395,22 @@ mod tests {
}
}

#[test]
fn index_template_json_omits_the_heavy_fields() {
let registry = TemplateRegistry::new();
for template in registry.all() {
let json = index_template_json(template);
for heavy in ["llmContext", "properties", "address", "constants", "idl"] {
assert!(
json.get(heavy).is_none(),
"index entry for {} leaks {heavy}",
template.id
);
}
assert_eq!(json["hasLlmContext"], template.llm_context.is_some());
}
}

#[tokio::test]
async fn search_rejects_unknown_template_and_unknown_constant() {
let surfpool = Surfpool::new();
Expand All @@ -1286,7 +1429,7 @@ mod tests {
}

#[tokio::test]
async fn get_override_templates_lists_the_pump_templates_compactly() {
async fn the_pump_templates_appear_in_the_index_and_detail_summarizes_the_catalog() {
let surfpool = Surfpool::new();
let result = surfpool.get_override_templates().await.unwrap();
assert_ne!(result.is_error, Some(true));
Expand All @@ -1303,17 +1446,18 @@ mod tests {
let template = templates
.iter()
.find(|t| t["id"] == id)
.unwrap_or_else(|| panic!("template {id} missing from the model's view"));
.unwrap_or_else(|| panic!("template {id} missing from the index"));
assert!(
template.get("idl").is_none(),
"{id} must not inline the ~160KB IDL into the LLM context"
"{id} must not inline the ~160KB IDL into the index"
);
}

let curve = templates
.iter()
.find(|t| t["id"] == "pump-bonding-curve-custom")
let detail = surfpool
.get_override_template(template_id("pump-bonding-curve-custom"))
.await
.unwrap();
let curve = json_of(&detail);
let token_mint = &curve["constants"]["token_mint"];
assert!(
token_mint["optionsCount"].as_u64().unwrap() > 0,
Expand Down