mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-10-04 02:52:55 +08:00
new plan
This commit is contained in:
@@ -0,0 +1,758 @@
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap, HashSet},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
use prost::Message;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
cursor::{
|
||||
context_sync::RequestContextSynchronizer, proto::agent::v1 as pb, tools::runtime::McpRoute,
|
||||
},
|
||||
model::ToolDefinition,
|
||||
store::BlobId,
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
pub async fn hydrate(
|
||||
request: &pb::AgentRunRequest,
|
||||
context_sync: &RequestContextSynchronizer,
|
||||
) -> Result<pb::RequestContext> {
|
||||
let mut context = request_context(request).cloned().unwrap_or_default();
|
||||
let Some(parts) = request
|
||||
.action
|
||||
.as_ref()
|
||||
.and_then(|action| action.request_context_parts.as_ref())
|
||||
else {
|
||||
if is_background_completion(request) {
|
||||
return context_sync
|
||||
.load(request.conversation_id.as_deref().unwrap_or_default())
|
||||
.await;
|
||||
}
|
||||
return Ok(context);
|
||||
};
|
||||
|
||||
if let Some(current) = context_sync
|
||||
.refresh_if_missing(
|
||||
parts,
|
||||
request.conversation_id.as_deref().unwrap_or_default(),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
context.rules = current.rules;
|
||||
context.non_file_rules = current.non_file_rules;
|
||||
context.cloud_rule = current.cloud_rule;
|
||||
context.agent_skills = current.agent_skills;
|
||||
context.skill_options = current.skill_options;
|
||||
context.custom_subagents = current.custom_subagents;
|
||||
context.tools = current.tools;
|
||||
context.mcp_instructions = current.mcp_instructions;
|
||||
context.mcp_file_system_options = current.mcp_file_system_options;
|
||||
context.mcp_meta_tool_options = current.mcp_meta_tool_options;
|
||||
return Ok(context);
|
||||
}
|
||||
|
||||
if let Some(part) = decode_part::<pb::RequestContextRulesPart>(
|
||||
"rules",
|
||||
&parts.rules_blob_id,
|
||||
parts.rules_byte_length,
|
||||
context_sync,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
context.rules = part.rules;
|
||||
context.non_file_rules = part.non_file_rules;
|
||||
context.cloud_rule = part.cloud_rule;
|
||||
}
|
||||
if let Some(part) = decode_part::<pb::RequestContextSkillsPart>(
|
||||
"skills",
|
||||
&parts.skills_blob_id,
|
||||
parts.skills_byte_length,
|
||||
context_sync,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
context.agent_skills = part.agent_skills;
|
||||
context.skill_options = part.skill_options;
|
||||
}
|
||||
if let Some(part) = decode_part::<pb::RequestContextSubagentsPart>(
|
||||
"subagents",
|
||||
&parts.subagents_blob_id,
|
||||
parts.subagents_byte_length,
|
||||
context_sync,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
context.custom_subagents = part.custom_subagents;
|
||||
}
|
||||
if let Some(part) = decode_part::<pb::RequestContextMcpsPart>(
|
||||
"MCP",
|
||||
&parts.mcps_blob_id,
|
||||
parts.mcps_byte_length,
|
||||
context_sync,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
context.tools = part.tools;
|
||||
context.mcp_instructions = part.mcp_instructions;
|
||||
context.mcp_file_system_options = part.mcp_file_system_options;
|
||||
context.mcp_meta_tool_options = part.mcp_meta_tool_options;
|
||||
}
|
||||
Ok(context)
|
||||
}
|
||||
|
||||
fn is_background_completion(request: &pb::AgentRunRequest) -> bool {
|
||||
matches!(
|
||||
request
|
||||
.action
|
||||
.as_ref()
|
||||
.and_then(|action| action.action.as_ref()),
|
||||
Some(pb::conversation_action::Action::BackgroundTaskCompletionAction(_))
|
||||
)
|
||||
}
|
||||
|
||||
async fn decode_part<T: Message + Default>(
|
||||
name: &str,
|
||||
raw_id: &[u8],
|
||||
expected_length: u32,
|
||||
context_sync: &RequestContextSynchronizer,
|
||||
) -> Result<Option<T>> {
|
||||
if raw_id.is_empty() {
|
||||
if expected_length != 0 {
|
||||
return Err(Error::Protocol(format!(
|
||||
"{name} context has a byte length but no BlobID"
|
||||
)));
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
let id = BlobId::from_bytes(raw_id)?;
|
||||
let data = context_sync.get(&id).await?.ok_or_else(|| {
|
||||
Error::Protocol(format!(
|
||||
"{name} context Blob is missing: {}",
|
||||
id.to_base64()
|
||||
))
|
||||
})?;
|
||||
if data.len() != expected_length as usize {
|
||||
return Err(Error::Protocol(format!(
|
||||
"{name} context Blob length mismatch: expected {expected_length}, got {}",
|
||||
data.len()
|
||||
)));
|
||||
}
|
||||
T::decode(data.as_slice())
|
||||
.map(Some)
|
||||
.map_err(|error| Error::Protocol(format!("invalid {name} context Blob: {error}")))
|
||||
}
|
||||
|
||||
pub fn request_context(request: &pb::AgentRunRequest) -> Option<&pb::RequestContext> {
|
||||
let action = request.action.as_ref()?;
|
||||
action
|
||||
.request_context_parts
|
||||
.as_ref()
|
||||
.and_then(|parts| parts.dynamic_context.as_ref())
|
||||
.or_else(|| match action.action.as_ref()? {
|
||||
pb::conversation_action::Action::UserMessageAction(action) => {
|
||||
action.request_context.as_ref()
|
||||
}
|
||||
pb::conversation_action::Action::ExecutePlanAction(action) => {
|
||||
action.request_context.as_ref()
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn compile_context(context: &pb::RequestContext, today: &str) -> String {
|
||||
let mut sections = Vec::new();
|
||||
let mut transcripts = None;
|
||||
if let Some(env) = &context.env {
|
||||
let workspace = env
|
||||
.workspace_paths
|
||||
.first()
|
||||
.map(String::as_str)
|
||||
.unwrap_or("");
|
||||
let repo = context.git_repos.iter().find(|repo| repo.path == workspace);
|
||||
sections.push(format!(
|
||||
"<user_info>\nOS Version: {}\n\nShell: {}\n\nWorkspace Path: {}\n\nIs directory a git repo: {}\n\nTerminals folder: {}\n\nToday's date: {}\n\nNote: Prefer using absolute paths over relative paths as tool call args when possible.\n</user_info>",
|
||||
env.os_version,
|
||||
env.shell,
|
||||
workspace,
|
||||
repo.map(|repo| format!("Yes, at {}", repo.path)).unwrap_or_else(|| "No".into()),
|
||||
env.terminals_folder,
|
||||
today,
|
||||
));
|
||||
if !env.agent_transcripts_folder.is_empty() {
|
||||
transcripts = Some(format!(
|
||||
"<agent_transcripts>\nAgent transcripts (past chats) live in {}. They have names like <uuid>.jsonl, cite parent chat transcripts to the user as [<title for chat <=6 words>\n](<uuid excluding .jsonl>). Don't discuss the folder structure.\n</agent_transcripts>",
|
||||
env.agent_transcripts_folder
|
||||
));
|
||||
}
|
||||
}
|
||||
sections.extend(context.git_repos.iter().map(|repo| {
|
||||
format!(
|
||||
"<git_status>\nThis is the git status at the start of the conversation. Note that this status is a snapshot in time, and will not update during the conversation.\n\n\nGit repo: {}\n\n```\n{}\n```\n</git_status>",
|
||||
repo.path, repo.status
|
||||
)
|
||||
}));
|
||||
sections.extend(transcripts);
|
||||
let skill_contents = context
|
||||
.agent_skills
|
||||
.iter()
|
||||
.map(|skill| skill.content.as_str())
|
||||
.filter(|content| !content.is_empty())
|
||||
.collect::<HashSet<_>>();
|
||||
let mut rules = context
|
||||
.rules
|
||||
.iter()
|
||||
.chain(context.non_file_rules.iter())
|
||||
.filter(|rule| {
|
||||
!rule.content.trim().is_empty()
|
||||
&& !is_skill_rule(rule)
|
||||
&& !skill_contents.contains(rule.content.as_str())
|
||||
})
|
||||
.map(|rule| format!("<user_rule>\n{}\n</user_rule>", rule.content))
|
||||
.collect::<Vec<_>>();
|
||||
rules.extend(
|
||||
context
|
||||
.cloud_rule
|
||||
.iter()
|
||||
.map(|rule| format!("<user_rule>\n{rule}\n</user_rule>")),
|
||||
);
|
||||
if !rules.is_empty() {
|
||||
sections.push(format!("<rules>\n{}\n</rules>", rules.join("\n")));
|
||||
}
|
||||
let skills = context
|
||||
.agent_skills
|
||||
.iter()
|
||||
.filter(|skill| !skill.disable_model_invocation)
|
||||
.map(|skill| {
|
||||
format!(
|
||||
"<agent_skill fullPath=\"{}\">{}</agent_skill>",
|
||||
xml(&skill.full_path),
|
||||
xml(&skill.description),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if !skills.is_empty() {
|
||||
sections.push(format!(
|
||||
"<agent_skills>\n<available_skills>\n{}\n</available_skills>\n</agent_skills>",
|
||||
skills.join("\n")
|
||||
));
|
||||
}
|
||||
let subagents = context
|
||||
.custom_subagents
|
||||
.iter()
|
||||
.map(|agent| {
|
||||
format!(
|
||||
"<subagent name=\"{}\">{}</subagent>",
|
||||
xml(&agent.name),
|
||||
agent.description
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if !subagents.is_empty() {
|
||||
sections.push(format!(
|
||||
"<subagents>\n{}\n</subagents>",
|
||||
subagents.join("\n")
|
||||
));
|
||||
}
|
||||
{
|
||||
let servers = context
|
||||
.mcp_meta_tool_options
|
||||
.as_ref()
|
||||
.into_iter()
|
||||
.flat_map(|options| &options.mcp_descriptors)
|
||||
.filter_map(compile_mcp_descriptor)
|
||||
.collect::<Vec<_>>();
|
||||
if !servers.is_empty() {
|
||||
sections.push(format!(
|
||||
"<mcp_meta_tools>\nThe following MCP tools are available. Call a listed tool directly with CallMcpTool without calling GetMcpTools first. If a call returns an error, use it to correct the arguments or authentication and retry when appropriate.\n<mcp_meta_tool_servers>\n{}\n</mcp_meta_tool_servers>\n</mcp_meta_tools>",
|
||||
servers.join("\n")
|
||||
));
|
||||
}
|
||||
}
|
||||
sections.join("\n\n")
|
||||
}
|
||||
|
||||
fn compile_mcp_descriptor(server: &pb::McpDescriptor) -> Option<String> {
|
||||
if server.server_identifier.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let tools = server
|
||||
.tools
|
||||
.iter()
|
||||
.filter(|tool| !tool.tool_name.trim().is_empty())
|
||||
.map(|tool| {
|
||||
let mut lines = vec![format!("<mcp_tool name=\"{}\">", xml(&tool.tool_name))];
|
||||
if let Some(path) = tool
|
||||
.definition_path
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
lines.push(format!("<definition_path>{}</definition_path>", xml(path)));
|
||||
}
|
||||
if let Some(description) = tool
|
||||
.description
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
lines.push(format!("<description>{}</description>", xml(description)));
|
||||
}
|
||||
if let Some(schema) = mcp_input_schema(tool) {
|
||||
lines.push(format!("<input_schema>{}</input_schema>", xml(&schema)));
|
||||
}
|
||||
lines.push("</mcp_tool>".into());
|
||||
lines.join("\n")
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if tools.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(format!(
|
||||
"<mcp_meta_tool_server name=\"{}\" identifier=\"{}\">\n<tools>\n{}\n</tools>\n</mcp_meta_tool_server>",
|
||||
xml(if server.server_name.trim().is_empty() {
|
||||
&server.server_identifier
|
||||
} else {
|
||||
&server.server_name
|
||||
}),
|
||||
xml(&server.server_identifier),
|
||||
tools.join("\n"),
|
||||
))
|
||||
}
|
||||
|
||||
fn mcp_input_schema(tool: &pb::McpToolDescriptor) -> Option<String> {
|
||||
tool.input_schema_json
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(|value| {
|
||||
serde_json::from_str::<Value>(value)
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_else(|_| value.to_string())
|
||||
})
|
||||
.or_else(|| {
|
||||
tool.input_schema
|
||||
.as_ref()
|
||||
.map(prost_value)
|
||||
.map(|value| value.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn meta_mcp_routes(context: &pb::RequestContext) -> HashMap<(String, String), McpRoute> {
|
||||
context
|
||||
.mcp_meta_tool_options
|
||||
.as_ref()
|
||||
.into_iter()
|
||||
.flat_map(|options| &options.mcp_descriptors)
|
||||
.filter(|server| !server.server_identifier.trim().is_empty())
|
||||
.flat_map(|server| {
|
||||
server.tools.iter().filter_map(move |tool| {
|
||||
if tool.tool_name.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let provider_identifier = if server.server_name.trim().is_empty() {
|
||||
server.server_identifier.clone()
|
||||
} else {
|
||||
server.server_name.clone()
|
||||
};
|
||||
Some((
|
||||
(server.server_identifier.clone(), tool.tool_name.clone()),
|
||||
McpRoute {
|
||||
name: format!("{}-{}", server.server_identifier, tool.tool_name),
|
||||
provider_identifier,
|
||||
tool_name: tool.tool_name.clone(),
|
||||
description: tool.description.clone().unwrap_or_default(),
|
||||
},
|
||||
))
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_skill_rule(rule: &pb::CursorRule) -> bool {
|
||||
Path::new(&rule.full_path)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.eq_ignore_ascii_case("SKILL.md"))
|
||||
}
|
||||
|
||||
pub fn selected_context(user: &pb::UserMessage) -> Option<String> {
|
||||
let selected = user.selected_context.as_ref()?;
|
||||
let mut sections = selected.extra_context.clone();
|
||||
sections.extend(
|
||||
selected
|
||||
.files
|
||||
.iter()
|
||||
.map(|file| format!("<file path=\"{}\">\n{}\n</file>", file.path, file.content)),
|
||||
);
|
||||
sections.extend(
|
||||
selected
|
||||
.code_selections
|
||||
.iter()
|
||||
.map(|value| format!("<code path=\"{}\">\n{}\n</code>", value.path, value.content)),
|
||||
);
|
||||
sections.extend(selected.terminals.iter().map(|value| {
|
||||
format!(
|
||||
"<terminal title=\"{}\">\n{}\n</terminal>",
|
||||
value.title.as_deref().unwrap_or_default(),
|
||||
value.content
|
||||
)
|
||||
}));
|
||||
sections.extend(selected.terminal_selections.iter().map(|value| {
|
||||
format!(
|
||||
"<terminal_selection title=\"{}\">\n{}\n</terminal_selection>",
|
||||
value.title.as_deref().unwrap_or_default(),
|
||||
value.content
|
||||
)
|
||||
}));
|
||||
sections.extend(selected.cursor_rules.iter().filter_map(|value| {
|
||||
value.rule.as_ref().map(|rule| {
|
||||
format!(
|
||||
"<rule path=\"{}\">\n{}\n</rule>",
|
||||
rule.full_path, rule.content
|
||||
)
|
||||
})
|
||||
}));
|
||||
sections.extend(selected.cursor_commands.iter().map(|value| {
|
||||
format!(
|
||||
"<command name=\"{}\">\n{}\n</command>",
|
||||
value.name, value.content
|
||||
)
|
||||
}));
|
||||
sections.extend(selected.selected_skills.iter().map(|value| {
|
||||
format!(
|
||||
"<skill path=\"{}\">\n{}\n{}\n</skill>",
|
||||
value.full_path, value.description, value.content
|
||||
)
|
||||
}));
|
||||
sections.extend(selected.external_links.iter().map(|value| {
|
||||
format!(
|
||||
"External link: {}{}",
|
||||
value.url,
|
||||
value
|
||||
.pdf_content
|
||||
.as_deref()
|
||||
.map(|content| format!("\n{content}"))
|
||||
.unwrap_or_default()
|
||||
)
|
||||
}));
|
||||
Some(sections.join("\n\n"))
|
||||
}
|
||||
|
||||
pub fn dynamic_mcp(
|
||||
request: &pb::AgentRunRequest,
|
||||
context: &pb::RequestContext,
|
||||
) -> Result<BTreeMap<String, (pb::McpToolDefinition, ToolDefinition)>> {
|
||||
let direct = request
|
||||
.mcp_tools
|
||||
.iter()
|
||||
.flat_map(|tools| tools.mcp_tools.iter());
|
||||
let contextual = context.tools.iter();
|
||||
let mut output = BTreeMap::new();
|
||||
for wire in direct.chain(contextual) {
|
||||
if wire.name.is_empty() {
|
||||
return Err(Error::Protocol(
|
||||
"MCP tool definition is missing name".into(),
|
||||
));
|
||||
}
|
||||
let parameters = match wire.input_schema_json.as_deref() {
|
||||
Some(json) if !json.trim().is_empty() => serde_json::from_str(json)?,
|
||||
_ => prost_value(wire.input_schema.as_ref().ok_or_else(|| {
|
||||
Error::Protocol(format!("MCP tool {} is missing input schema", wire.name))
|
||||
})?),
|
||||
};
|
||||
let parameters = normalize_mcp_parameters(&wire.name, parameters)?;
|
||||
let name = model_tool_name(&wire.name);
|
||||
let definition = ToolDefinition {
|
||||
name: name.clone(),
|
||||
description: wire.description.clone(),
|
||||
parameters,
|
||||
};
|
||||
if output
|
||||
.insert(name.clone(), (wire.clone(), definition))
|
||||
.is_some()
|
||||
{
|
||||
return Err(Error::Protocol(format!(
|
||||
"duplicate MCP tool name after normalization: {name}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn normalize_mcp_parameters(tool_name: &str, mut parameters: Value) -> Result<Value> {
|
||||
let schema = parameters
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| invalid_mcp_parameters(tool_name))?;
|
||||
match schema.get("type") {
|
||||
Some(Value::String(schema_type)) if schema_type == "object" => return Ok(parameters),
|
||||
Some(_) => return Err(invalid_mcp_parameters(tool_name)),
|
||||
None => {}
|
||||
}
|
||||
let object_only_union = ["anyOf", "oneOf"].into_iter().any(|keyword| {
|
||||
schema
|
||||
.get(keyword)
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|branches| {
|
||||
!branches.is_empty()
|
||||
&& branches.iter().all(|branch| {
|
||||
branch
|
||||
.as_object()
|
||||
.and_then(|branch| branch.get("type"))
|
||||
.and_then(Value::as_str)
|
||||
== Some("object")
|
||||
})
|
||||
})
|
||||
});
|
||||
if !object_only_union {
|
||||
return Err(invalid_mcp_parameters(tool_name));
|
||||
}
|
||||
// OpenAI-compatible function schemas (and the corresponding schema
|
||||
// validators used by other providers) require the root schema to declare
|
||||
// an object type. Cursor's app-control MCP sometimes sends an object-only
|
||||
// `anyOf`/`oneOf` schema without that root annotation. Preserve the union
|
||||
// while adding the annotation to the model-facing copy.
|
||||
schema.insert("type".into(), Value::String("object".into()));
|
||||
Ok(parameters)
|
||||
}
|
||||
|
||||
fn invalid_mcp_parameters(tool_name: &str) -> Error {
|
||||
Error::Protocol(format!(
|
||||
"MCP tool {tool_name} input schema must describe an object"
|
||||
))
|
||||
}
|
||||
|
||||
fn model_tool_name(name: &str) -> String {
|
||||
name.chars()
|
||||
.map(|character| {
|
||||
if character.is_ascii_alphanumeric() || matches!(character, '_' | '-') {
|
||||
character
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn prost_value(value: &prost_types::Value) -> Value {
|
||||
use prost_types::value::Kind;
|
||||
match value.kind.as_ref() {
|
||||
None | Some(Kind::NullValue(_)) => Value::Null,
|
||||
Some(Kind::NumberValue(value)) => serde_json::Number::from_f64(*value)
|
||||
.map(Value::Number)
|
||||
.unwrap_or(Value::Null),
|
||||
Some(Kind::StringValue(value)) => Value::String(value.clone()),
|
||||
Some(Kind::BoolValue(value)) => Value::Bool(*value),
|
||||
Some(Kind::StructValue(value)) => Value::Object(
|
||||
value
|
||||
.fields
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), prost_value(value)))
|
||||
.collect(),
|
||||
),
|
||||
Some(Kind::ListValue(value)) => {
|
||||
Value::Array(value.values.iter().map(prost_value).collect())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn xml(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('"', """)
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn direct_mcp_tool(name: &str) -> pb::McpToolDefinition {
|
||||
pb::McpToolDefinition {
|
||||
name: name.into(),
|
||||
provider_identifier: "extension-GitKraken".into(),
|
||||
tool_name: "git_status".into(),
|
||||
description: "Get repository status".into(),
|
||||
input_schema_json: Some(r#"{"type":"object"}"#.into()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_mcp_normalizes_extension_identifier_for_model_tool_names() {
|
||||
let original = "user-eamodio.gitlens-extension-GitKraken-git_status";
|
||||
let request = pb::AgentRunRequest {
|
||||
mcp_tools: Some(pb::McpTools {
|
||||
mcp_tools: vec![direct_mcp_tool(original)],
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let tools = dynamic_mcp(&request, &pb::RequestContext::default()).unwrap();
|
||||
let normalized = "user-eamodio_gitlens-extension-GitKraken-git_status";
|
||||
let (wire, definition) = tools.get(normalized).unwrap();
|
||||
|
||||
assert_eq!(definition.name, normalized);
|
||||
assert_eq!(wire.name, original);
|
||||
assert_eq!(wire.provider_identifier, "extension-GitKraken");
|
||||
assert_eq!(wire.tool_name, "git_status");
|
||||
assert!(normalized
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-')));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_mcp_rejects_names_that_collide_after_normalization() {
|
||||
let request = pb::AgentRunRequest {
|
||||
mcp_tools: Some(pb::McpTools {
|
||||
mcp_tools: vec![
|
||||
direct_mcp_tool("server.name-tool"),
|
||||
direct_mcp_tool("server_name-tool"),
|
||||
],
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let error = dynamic_mcp(&request, &pb::RequestContext::default()).unwrap_err();
|
||||
assert!(error
|
||||
.to_string()
|
||||
.contains("duplicate MCP tool name after normalization: server_name-tool"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_mcp_normalizes_cursor_object_union_without_mutating_wire_schema() {
|
||||
let original_schema = serde_json::json!({
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"rootPath": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"required": ["rootPath"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"rootPaths": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 },
|
||||
"minItems": 1
|
||||
}
|
||||
},
|
||||
"required": ["rootPaths"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
});
|
||||
let original_json = original_schema.to_string();
|
||||
let mut tool = direct_mcp_tool("cursor-app-control-move_agent_to_cloned_root");
|
||||
tool.input_schema_json = Some(original_json.clone());
|
||||
let request = pb::AgentRunRequest {
|
||||
mcp_tools: Some(pb::McpTools {
|
||||
mcp_tools: vec![tool],
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let tools = dynamic_mcp(&request, &pb::RequestContext::default()).unwrap();
|
||||
let (wire, definition) = tools
|
||||
.get("cursor-app-control-move_agent_to_cloned_root")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(definition.parameters["type"], "object");
|
||||
assert_eq!(definition.parameters["anyOf"], original_schema["anyOf"]);
|
||||
assert_eq!(
|
||||
wire.input_schema_json.as_deref(),
|
||||
Some(original_json.as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_mcp_preserves_valid_object_schema() {
|
||||
let original_schema = serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": { "type": "string" }
|
||||
},
|
||||
"required": ["query"],
|
||||
"additionalProperties": false
|
||||
});
|
||||
let mut tool = direct_mcp_tool("search");
|
||||
tool.input_schema_json = Some(original_schema.to_string());
|
||||
let request = pb::AgentRunRequest {
|
||||
mcp_tools: Some(pb::McpTools {
|
||||
mcp_tools: vec![tool],
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let tools = dynamic_mcp(&request, &pb::RequestContext::default()).unwrap();
|
||||
let (_, definition) = tools.get("search").unwrap();
|
||||
|
||||
assert_eq!(definition.parameters, original_schema);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_mcp_rejects_schemas_that_are_not_provably_objects() {
|
||||
let invalid_schemas = [
|
||||
serde_json::Value::Null,
|
||||
serde_json::json!({ "type": "string" }),
|
||||
serde_json::json!({ "properties": { "query": { "type": "string" } } }),
|
||||
serde_json::json!({
|
||||
"anyOf": [
|
||||
{ "type": "object" },
|
||||
{ "type": "string" }
|
||||
]
|
||||
}),
|
||||
];
|
||||
|
||||
for schema in invalid_schemas {
|
||||
let mut tool = direct_mcp_tool("unsafe_schema");
|
||||
tool.input_schema_json = Some(schema.to_string());
|
||||
let request = pb::AgentRunRequest {
|
||||
mcp_tools: Some(pb::McpTools {
|
||||
mcp_tools: vec![tool],
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let error = dynamic_mcp(&request, &pb::RequestContext::default()).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("MCP tool unsafe_schema input schema must describe an object"),
|
||||
"unexpected error for {schema}: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meta_mcp_routes_projects_descriptor_routing_without_runtime_discovery() {
|
||||
let context = pb::RequestContext {
|
||||
mcp_meta_tool_options: Some(pb::McpMetaToolOptions {
|
||||
enabled: true,
|
||||
mcp_descriptors: vec![pb::McpDescriptor {
|
||||
server_name: "fast-context".into(),
|
||||
server_identifier: "fast-context".into(),
|
||||
tools: vec![pb::McpToolDescriptor {
|
||||
tool_name: "fast_context_search".into(),
|
||||
description: Some("search code".into()),
|
||||
input_schema_json: Some(r#"{"type":"object"}"#.into()),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
}],
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let routes = meta_mcp_routes(&context);
|
||||
let route = routes
|
||||
.get(&("fast-context".into(), "fast_context_search".into()))
|
||||
.unwrap();
|
||||
assert_eq!(route.name, "fast-context-fast_context_search");
|
||||
assert_eq!(route.provider_identifier, "fast-context");
|
||||
assert_eq!(route.tool_name, "fast_context_search");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user