use crate::config::{McpConfig, ToolPolicy};
use crate::types::McpTool;
use rustapi_openapi::{Components, OpenApiSpec, Operation, Parameter, RequestBody, SchemaRef};
use std::collections::BTreeMap;
pub fn extract_tools_from_spec(spec: &OpenApiSpec, config: &McpConfig) -> Vec<McpTool> {
if !config.tools_enabled {
return vec![];
}
let mut tools = Vec::new();
let components = spec.components.as_ref();
for (path, path_item) in &spec.paths {
if !path_matches_prefixes(path, &config.allowed_path_prefixes) {
continue;
}
if let Some(op) = &path_item.get {
if let Some(tool) = operation_to_tool("GET", path, op, components, config) {
tools.push(tool);
}
}
if let Some(op) = &path_item.post {
if let Some(tool) = operation_to_tool("POST", path, op, components, config) {
tools.push(tool);
}
}
if let Some(op) = &path_item.put {
if let Some(tool) = operation_to_tool("PUT", path, op, components, config) {
tools.push(tool);
}
}
if let Some(op) = &path_item.patch {
if let Some(tool) = operation_to_tool("PATCH", path, op, components, config) {
tools.push(tool);
}
}
if let Some(op) = &path_item.delete {
if let Some(tool) = operation_to_tool("DELETE", path, op, components, config) {
tools.push(tool);
}
}
if tools.len() >= config.max_tools {
break;
}
}
if tools.len() > config.max_tools {
tools.truncate(config.max_tools);
}
tools
}
fn path_matches_prefixes(path: &str, prefixes: &[String]) -> bool {
if prefixes.is_empty() {
return true;
}
prefixes.iter().any(|p| path.starts_with(p))
}
fn is_read_method(method: &str) -> bool {
matches!(method.to_uppercase().as_str(), "GET" | "HEAD" | "OPTIONS")
}
fn operation_allowed_by_policy(method: &str, _op: &Operation, policy: &ToolPolicy) -> bool {
match policy {
ToolPolicy::All => true,
ToolPolicy::ReadOnly => is_read_method(method),
}
}
fn is_skipped_by_tag(op: &Operation) -> bool {
op.tags.iter().any(|t| {
let t = t.to_lowercase();
t == "mcp-skip" || t.contains(":skip") || t == "mcp:skip"
})
}
fn operation_to_tool(
method: &str,
path: &str,
op: &Operation,
components: Option<&Components>,
config: &McpConfig,
) -> Option<McpTool> {
if let Some(mcp_meta) = &op.x_mcp {
if mcp_meta.skip == Some(true) {
return None;
}
}
if is_skipped_by_tag(op) {
return None;
}
if !operation_allowed_by_policy(method, op, &config.tool_policy) {
return None;
}
if !config.allowed_tags.is_empty() {
let has_match = op.tags.iter().any(|t| config.allowed_tags.contains(t));
if !has_match {
return None;
}
}
let name = generate_tool_name(method, path, op);
let description = op.summary.clone().or_else(|| op.description.clone());
let input_schema = build_input_schema(op, components);
let (permission, requires_confirmation) = if let Some(mcp_meta) = &op.x_mcp {
let p = if mcp_meta.readonly == Some(true) {
"read".to_string()
} else if mcp_meta.write == Some(true) || !is_read_method(method) {
"write".to_string()
} else {
if is_read_method(method) {
"read"
} else {
"write"
}
.to_string()
};
let needs_confirm =
mcp_meta.require.is_some() || (p == "write" && mcp_meta.readonly != Some(true));
(p, needs_confirm)
} else {
let has_write = op.tags.iter().any(|t| t.eq_ignore_ascii_case("mcp-write"));
let has_ro = op
.tags
.iter()
.any(|t| t.eq_ignore_ascii_case("mcp-readonly"));
let req = op
.tags
.iter()
.any(|t| t.to_lowercase().starts_with("mcp-require"));
let p = if has_ro {
"read"
} else if has_write || !is_read_method(method) {
"write"
} else {
"read"
}
.to_string();
let c = req || (!has_ro && !is_read_method(method));
(p, c)
};
Some(McpTool {
name,
description,
input_schema,
output_schema: None,
tags: op.tags.clone(),
permission: Some(permission),
requires_confirmation: Some(requires_confirmation),
})
}
fn generate_tool_name(method: &str, path: &str, op: &Operation) -> String {
if let Some(oid) = &op.operation_id {
return sanitize_name(oid);
}
let mut slug = path
.trim_start_matches('/')
.replace(['/', '{', '}', ':'], "_")
.replace(['-', '.', ' '], "_");
while slug.contains("__") {
slug = slug.replace("__", "_");
}
let slug = slug.trim_matches('_').to_string();
let method_lower = method.to_lowercase();
if slug.is_empty() {
method_lower
} else {
format!("{}_{}", method_lower, slug)
}
}
fn sanitize_name(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_alphanumeric() || c == '_' {
c
} else {
'_'
}
})
.collect::<String>()
.trim_matches('_')
.to_string()
.to_lowercase()
}
fn build_input_schema(op: &Operation, components: Option<&Components>) -> serde_json::Value {
if let Some(body) = &op.request_body {
if let Some(schema_val) = extract_json_schema_from_body(body, components) {
return schema_val;
}
}
build_schema_from_parameters(&op.parameters, components)
}
fn extract_json_schema_from_body(
body: &RequestBody,
components: Option<&Components>,
) -> Option<serde_json::Value> {
let media = body
.content
.get("application/json")
.or_else(|| body.content.values().next())?;
if let Some(schema_ref) = &media.schema {
return Some(schema_ref_to_json(schema_ref, components));
}
None
}
fn build_schema_from_parameters(
params: &[Parameter],
components: Option<&Components>,
) -> serde_json::Value {
if params.is_empty() {
return serde_json::json!({
"type": "object",
"properties": {},
"additionalProperties": false
});
}
let mut properties = BTreeMap::new();
let mut required = Vec::new();
for param in params {
let name = param.name.clone();
let schema = if let Some(s) = ¶m.schema {
schema_ref_to_json(s, components)
} else {
serde_json::json!({"type": "string"})
};
if param.required {
required.push(name.clone());
}
properties.insert(name, schema);
}
let mut schema = serde_json::json!({
"type": "object",
"properties": properties,
});
if !required.is_empty() {
schema["required"] =
serde_json::to_value(required).expect("required field names must serialize");
}
schema["additionalProperties"] = serde_json::json!(false);
schema
}
fn schema_ref_to_json(
schema_ref: &SchemaRef,
components: Option<&Components>,
) -> serde_json::Value {
match schema_ref {
SchemaRef::Ref { reference } => {
if let Some(name) = reference.strip_prefix("#/components/schemas/") {
if let Some(components) = components {
if let Some(schema) = components.schemas.get(name) {
return serde_json::to_value(schema)
.unwrap_or_else(|_| serde_json::json!({ "$ref": reference }));
}
}
}
serde_json::json!({ "$ref": reference })
}
SchemaRef::Schema(boxed) => {
serde_json::to_value(boxed.as_ref()).unwrap_or(serde_json::json!({}))
}
SchemaRef::Inline(val) => val.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use rustapi_openapi::{OpenApiSpec, Operation};
fn make_minimal_spec() -> OpenApiSpec {
let mut spec = OpenApiSpec::new("Test API", "1.0.0");
let mut get_user = Operation::new();
get_user.summary = Some("Get user by ID".to_string());
get_user.tags = vec!["users".to_string(), "public".to_string()];
get_user.operation_id = Some("getUser".to_string());
let mut create_user = Operation::new();
create_user.summary = Some("Create a user".to_string());
create_user.tags = vec!["users".to_string()];
create_user.operation_id = Some("createUser".to_string());
let mut admin = Operation::new();
admin.summary = Some("Admin only".to_string());
admin.tags = vec!["admin".to_string()];
spec = spec
.path("/users/{id}", "GET", get_user)
.path("/users", "POST", create_user)
.path("/admin/users", "GET", admin);
spec
}
#[test]
fn extracts_tools_with_operation_id_as_name() {
let spec = make_minimal_spec();
let config = McpConfig::new().tool_policy(ToolPolicy::All);
let tools = extract_tools_from_spec(&spec, &config);
assert!(!tools.is_empty());
let names: Vec<_> = tools.iter().map(|t| t.name.as_str()).collect();
assert!(names.contains(&"getuser"));
assert!(names.contains(&"createuser"));
}
#[test]
fn respects_allowed_tags_filter() {
let spec = make_minimal_spec();
let config = McpConfig::new().allowed_tags(["public"]);
let tools = extract_tools_from_spec(&spec, &config);
let _tags: Vec<Vec<String>> = tools.iter().map(|t| t.tags.clone()).collect();
assert_eq!(tools.len(), 1);
assert!(tools[0].name.contains("getuser") || tools[0].tags.contains(&"public".to_string()));
}
#[test]
fn respects_path_prefix_filter() {
let spec = make_minimal_spec();
let config = McpConfig::new().allow_path_prefix("/users");
let tools = extract_tools_from_spec(&spec, &config);
assert!(tools.iter().all(|t| !t.name.contains("admin")));
}
#[test]
fn max_tools_limit_is_respected() {
let spec = make_minimal_spec();
let config = McpConfig::new().max_tools(1);
let tools = extract_tools_from_spec(&spec, &config);
assert!(tools.len() <= 1);
}
}