use std::path::PathBuf;
use clap::Args;
#[derive(Args)]
pub struct McpArgs {
#[arg(long, short)]
pub config: Option<PathBuf>,
#[arg(long, default_value = "stdio")]
pub transport: String,
#[arg(long, default_value = "127.0.0.1")]
pub host: String,
#[arg(long, default_value_t = 8011)]
pub port: u16,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct ParseParams {
pub source: String,
pub language: String,
pub format: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct ProcessParams {
pub source: String,
pub language: String,
pub all: Option<bool>,
pub structure: Option<bool>,
pub imports: Option<bool>,
pub exports: Option<bool>,
pub comments: Option<bool>,
pub symbols: Option<bool>,
pub docstrings: Option<bool>,
pub diagnostics: Option<bool>,
pub data_extraction: Option<bool>,
pub chunk_max_size: Option<usize>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct DetectLanguageParams {
pub path: Option<String>,
pub content: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct ListLanguagesParams {
pub source: Option<String>,
pub filter: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct InfoParams {
pub language: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct DownloadParams {
pub languages: Option<Vec<String>>,
pub all: Option<bool>,
pub groups: Option<Vec<String>>,
pub fresh: Option<bool>,
}
use rmcp::{
RoleServer, ServerHandler,
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
model::*,
service::RequestContext,
tool, tool_handler, tool_router,
};
#[derive(Clone)]
pub struct TsPackMcp {
#[allow(dead_code)]
tool_router: ToolRouter<TsPackMcp>,
}
#[tool_router]
impl TsPackMcp {
fn new() -> Self {
Self {
tool_router: Self::tool_router(),
}
}
#[tool(
description = "Parse source code with a tree-sitter grammar. Returns the syntax tree as sexp or JSON.",
annotations(
title = "Parse",
read_only_hint = true,
idempotent_hint = true,
open_world_hint = false
)
)]
fn parse(&self, Parameters(params): Parameters<ParseParams>) -> Result<CallToolResult, rmcp::ErrorData> {
use tree_sitter_language_pack::get_parser;
let mut parser = get_parser(¶ms.language)
.map_err(|e| rmcp::ErrorData::invalid_params(format!("Language error: {e}"), None))?;
let tree = parser
.parse_bytes(params.source.as_bytes())
.ok_or_else(|| rmcp::ErrorData::internal_error("Parser returned no tree", None))?;
let format = params.format.as_deref().unwrap_or("sexp");
let response = match format {
"json" => {
let sexp = tree.root_node().to_sexp();
serde_json::to_string_pretty(&serde_json::json!({
"language": params.language,
"sexp": sexp,
"has_errors": tree.root_node().has_error(),
}))
.unwrap_or_default()
}
_ => tree.root_node().to_sexp(),
};
Ok(CallToolResult::success(vec![ContentBlock::text(response)]))
}
#[tool(
description = "Run the code-intelligence pipeline on source code. Extracts structure, imports, exports, symbols, and more.",
annotations(
title = "Process",
read_only_hint = true,
idempotent_hint = true,
open_world_hint = false
)
)]
fn process(&self, Parameters(params): Parameters<ProcessParams>) -> Result<CallToolResult, rmcp::ErrorData> {
use tree_sitter_language_pack::{ProcessConfig, process};
let mut config = ProcessConfig::new(params.language);
if params.all.unwrap_or(false) {
config = config.all();
}
if let Some(v) = params.structure {
config.structure = v;
}
if let Some(v) = params.imports {
config.imports = v;
}
if let Some(v) = params.exports {
config.exports = v;
}
if let Some(v) = params.comments {
config.comments = v;
}
if let Some(v) = params.symbols {
config.symbols = v;
}
if let Some(v) = params.docstrings {
config.docstrings = v;
}
if let Some(v) = params.diagnostics {
config.diagnostics = v;
}
if let Some(v) = params.data_extraction {
config.data_extraction = v;
}
if let Some(sz) = params.chunk_max_size {
config.chunk_max_size = Some(sz);
}
let result =
process(¶ms.source, &config).map_err(|e| rmcp::ErrorData::invalid_params(e.to_string(), None))?;
let response = serde_json::to_string_pretty(&result).unwrap_or_default();
Ok(CallToolResult::success(vec![ContentBlock::text(response)]))
}
#[tool(
description = "Detect the language for a file path or source content. Returns the detected language name.",
annotations(
title = "Detect Language",
read_only_hint = true,
idempotent_hint = true,
open_world_hint = false
)
)]
fn detect_language(
&self,
Parameters(params): Parameters<DetectLanguageParams>,
) -> Result<CallToolResult, rmcp::ErrorData> {
use tree_sitter_language_pack::{detect_language_from_content, detect_language_from_path};
let detected = params
.path
.as_deref()
.and_then(detect_language_from_path)
.or_else(|| params.content.as_deref().and_then(detect_language_from_content));
let response = serde_json::to_string_pretty(&serde_json::json!({
"language": detected,
"path": params.path,
}))
.unwrap_or_default();
Ok(CallToolResult::success(vec![ContentBlock::text(response)]))
}
#[tool(
description = "List languages. source: 'available' (default), 'downloaded', or 'manifest'. Optional substring filter.",
// ~keep `open_world_hint = true`: the `manifest` source fetches the remote download manifest.
annotations(
title = "List Languages",
read_only_hint = true,
idempotent_hint = true,
open_world_hint = true
)
)]
fn list_languages(
&self,
Parameters(params): Parameters<ListLanguagesParams>,
) -> Result<CallToolResult, rmcp::ErrorData> {
use tree_sitter_language_pack::{available_languages, downloaded_languages, manifest_languages};
let source = params.source.as_deref().unwrap_or("available");
let langs: Vec<String> = match source {
"downloaded" => downloaded_languages(),
"manifest" => manifest_languages().map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?,
_ => available_languages(),
};
let filtered: Vec<&String> = if let Some(ref f) = params.filter {
langs.iter().filter(|l| l.contains(f.as_str())).collect()
} else {
langs.iter().collect()
};
let response = serde_json::to_string_pretty(&serde_json::json!({
"source": source,
"filter": params.filter,
"count": filtered.len(),
"languages": filtered,
}))
.unwrap_or_default();
Ok(CallToolResult::success(vec![ContentBlock::text(response)]))
}
#[tool(
description = "Show whether a language is known, downloaded, and its cache path.",
annotations(
title = "Language Info",
read_only_hint = true,
idempotent_hint = true,
open_world_hint = false
)
)]
fn info(&self, Parameters(params): Parameters<InfoParams>) -> Result<CallToolResult, rmcp::ErrorData> {
use tree_sitter_language_pack::{cache_dir, downloaded_languages, has_language};
let known = has_language(¶ms.language);
let is_downloaded = downloaded_languages().contains(¶ms.language);
let cache = cache_dir().map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
let response = serde_json::to_string_pretty(&serde_json::json!({
"language": params.language,
"known": known,
"downloaded": is_downloaded,
"cache_dir": cache,
}))
.unwrap_or_default();
Ok(CallToolResult::success(vec![ContentBlock::text(response)]))
}
#[tool(
description = "Download parser libraries from the remote registry. Pass languages list, groups, or all=true. \
Set fresh=true to clean the cache first.",
// ~keep Network fetch is additive/idempotent; only `fresh` performs explicit cache cleanup.
annotations(
title = "Download",
read_only_hint = false,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = true
)
)]
fn download(&self, Parameters(params): Parameters<DownloadParams>) -> Result<CallToolResult, rmcp::ErrorData> {
use tree_sitter_language_pack::{clean_cache, download, download_all, download_group};
if params.fresh.unwrap_or(false) {
clean_cache().map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
}
let count = if params.all.unwrap_or(false) {
download_all().map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?
} else if let Some(ref groups) = params.groups
&& !groups.is_empty()
{
let mut last = 0;
for group in groups {
last = download_group(group).map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
}
last
} else if let Some(ref languages) = params.languages {
if languages.is_empty() {
return Err(rmcp::ErrorData::invalid_params(
"Provide at least one language name, one or more groups, or set all=true",
None,
));
}
let refs: Vec<&str> = languages.iter().map(String::as_str).collect();
download(&refs).map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?
} else {
return Err(rmcp::ErrorData::invalid_params(
"Provide languages, groups, or all=true",
None,
));
};
let response = serde_json::to_string_pretty(&serde_json::json!({
"languages_available": count,
}))
.unwrap_or_default();
Ok(CallToolResult::success(vec![ContentBlock::text(response)]))
}
#[tool(
description = "Return the effective parser cache directory path.",
annotations(
title = "Cache Directory",
read_only_hint = true,
idempotent_hint = true,
open_world_hint = false
)
)]
fn cache_dir(&self) -> Result<CallToolResult, rmcp::ErrorData> {
use tree_sitter_language_pack::cache_dir;
let dir = cache_dir().map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
let response = serde_json::to_string_pretty(&serde_json::json!({ "cache_dir": dir })).unwrap_or_default();
Ok(CallToolResult::success(vec![ContentBlock::text(response)]))
}
#[tool(
description = "Delete all cached parser libraries from the cache directory.",
// ~keep Cache deletion is destructive but idempotent and has no network access.
annotations(
title = "Clean Cache",
read_only_hint = false,
destructive_hint = true,
idempotent_hint = true,
open_world_hint = false
)
)]
fn clean_cache(&self) -> Result<CallToolResult, rmcp::ErrorData> {
use tree_sitter_language_pack::{cache_dir, clean_cache};
clean_cache().map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
let dir = cache_dir().map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
let response = serde_json::to_string_pretty(&serde_json::json!({
"cache_dir": dir,
"status": "cleared",
}))
.unwrap_or_default();
Ok(CallToolResult::success(vec![ContentBlock::text(response)]))
}
}
impl TsPackMcp {
const LANGUAGE_URI_PREFIX: &'static str = "ts-pack://language/";
fn list_resources_inner(&self) -> ListResourcesResult {
let available = Resource::new("ts-pack://languages", "available-languages")
.with_title("Available languages")
.with_description("Every language available to this build of the pack.")
.with_mime_type("application/json");
let downloaded = Resource::new("ts-pack://languages/downloaded", "downloaded-languages")
.with_title("Downloaded languages")
.with_description("Languages whose parser libraries are already cached locally.")
.with_mime_type("application/json");
ListResourcesResult::with_all_items(vec![available, downloaded])
}
fn list_resource_templates_inner(&self) -> ListResourceTemplatesResult {
let template = ResourceTemplate::new("ts-pack://language/{name}", "language-info")
.with_title("Language info")
.with_description("Per-language status: known, downloaded, and cache directory.")
.with_mime_type("application/json");
ListResourceTemplatesResult::with_all_items(vec![template])
}
fn read_resource_inner(&self, uri: &str) -> Result<ReadResourceResult, rmcp::ErrorData> {
use tree_sitter_language_pack::{available_languages, cache_dir, downloaded_languages, has_language};
let json = match uri {
"ts-pack://languages" => {
let langs = available_languages();
serde_json::json!({ "count": langs.len(), "languages": langs })
}
"ts-pack://languages/downloaded" => {
let langs = downloaded_languages();
serde_json::json!({ "count": langs.len(), "languages": langs })
}
other if other.starts_with(Self::LANGUAGE_URI_PREFIX) => {
let name = &other[Self::LANGUAGE_URI_PREFIX.len()..];
let cache = cache_dir().map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
serde_json::json!({
"language": name,
"known": has_language(name),
"downloaded": downloaded_languages().iter().any(|l| l == name),
"cache_dir": cache,
})
}
_ => {
return Err(rmcp::ErrorData::invalid_params(
format!("Unknown resource URI: {uri}"),
None,
));
}
};
let text = serde_json::to_string_pretty(&json).unwrap_or_default();
Ok(ReadResourceResult::new(vec![
ResourceContents::text(text, uri).with_mime_type("application/json"),
]))
}
fn list_prompts_inner(&self) -> ListPromptsResult {
let prompt = Prompt::new(
"analyze-code",
Some("Analyze a source file's structure, imports, exports, and symbols using the pack's tools."),
Some(vec![
PromptArgument::new("language")
.with_description("Language name (supports completion).")
.with_required(true),
PromptArgument::new("focus")
.with_description("Optional area to emphasize, e.g. 'security' or 'public API'.")
.with_required(false),
]),
);
ListPromptsResult::with_all_items(vec![prompt])
}
fn get_prompt_inner(&self, name: &str, arguments: Option<JsonObject>) -> Result<GetPromptResult, rmcp::ErrorData> {
if name != "analyze-code" {
return Err(rmcp::ErrorData::invalid_params(format!("Unknown prompt: {name}"), None));
}
let args = arguments.unwrap_or_default();
let language = args
.get("language")
.and_then(|v| v.as_str())
.unwrap_or("the file's language");
let focus = args.get("focus").and_then(|v| v.as_str()).unwrap_or("");
let mut text = format!(
"Analyze the following {language} source file. Call the 'process' tool with all=true to extract \
its structure, imports, exports, symbols, and diagnostics, then summarize the design, key \
entry points, and any issues."
);
if !focus.is_empty() {
text.push_str(&format!(" Pay particular attention to: {focus}."));
}
let message = PromptMessage::new_text(Role::User, text);
Ok(GetPromptResult::new(vec![message]).with_description("Code analysis workflow"))
}
fn complete_inner(
&self,
reference: &Reference,
argument: &ArgumentInfo,
) -> Result<CompleteResult, rmcp::ErrorData> {
use tree_sitter_language_pack::available_languages;
let completes_language = match reference {
Reference::Prompt(prompt) => prompt.name == "analyze-code" && argument.name == "language",
Reference::Resource(resource) => {
resource.uri.starts_with(Self::LANGUAGE_URI_PREFIX) && argument.name == "name"
}
_ => false,
};
if !completes_language {
return Ok(CompleteResult::default());
}
let prefix = argument.value.as_str();
let mut values: Vec<String> = available_languages()
.into_iter()
.filter(|lang| lang.starts_with(prefix))
.collect();
let total = values.len() as u32;
values.truncate(CompletionInfo::MAX_VALUES);
let has_more = (values.len() as u32) < total;
let completion = CompletionInfo::with_pagination(values, Some(total), has_more)
.map_err(|e| rmcp::ErrorData::internal_error(e, None))?;
Ok(CompleteResult::new(completion))
}
}
#[tool_handler]
impl ServerHandler for TsPackMcp {
fn get_info(&self) -> ServerInfo {
let capabilities = ServerCapabilities::builder()
.enable_tools()
.enable_resources()
.enable_prompts()
.enable_completions()
.build();
let server_info = Implementation::new("ts-pack-mcp", env!("CARGO_PKG_VERSION"))
.with_title("Tree-Sitter Language Pack MCP Server")
.with_description(
"MCP server for the tree-sitter language pack. \
Parse source code, run code-intelligence analysis, detect languages, \
list and download parsers, and manage the cache.",
)
.with_website_url("https://github.com/xberg-io/tree-sitter-language-pack");
InitializeResult::new(capabilities)
.with_server_info(server_info)
.with_instructions(
"Use 'parse' to get a syntax tree (sexp or JSON). \
Use 'process' to extract structure, imports, exports, symbols, comments, and diagnostics. \
Use 'detect_language' to identify a language from a file path or source snippet. \
Use 'list_languages' to see available, downloaded, or manifest languages. \
Use 'info' to check whether a specific language is downloaded. \
Use 'download' to fetch parser libraries (by name, groups, or all; fresh=true to re-fetch). \
Use 'cache_dir' to query the cache directory and 'clean_cache' to delete all cached parsers. \
Read 'ts-pack://languages' or the 'ts-pack://language/{name}' template for the catalog. \
Use the 'analyze-code' prompt for a ready-made analysis workflow.",
)
}
async fn list_resources(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListResourcesResult, rmcp::ErrorData> {
Ok(self.list_resources_inner())
}
async fn list_resource_templates(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListResourceTemplatesResult, rmcp::ErrorData> {
Ok(self.list_resource_templates_inner())
}
async fn read_resource(
&self,
request: ReadResourceRequestParams,
_context: RequestContext<RoleServer>,
) -> Result<ReadResourceResult, rmcp::ErrorData> {
self.read_resource_inner(&request.uri)
}
async fn list_prompts(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListPromptsResult, rmcp::ErrorData> {
Ok(self.list_prompts_inner())
}
async fn get_prompt(
&self,
request: GetPromptRequestParams,
_context: RequestContext<RoleServer>,
) -> Result<GetPromptResult, rmcp::ErrorData> {
self.get_prompt_inner(&request.name, request.arguments)
}
async fn complete(
&self,
request: CompleteRequestParams,
_context: RequestContext<RoleServer>,
) -> Result<CompleteResult, rmcp::ErrorData> {
self.complete_inner(&request.r#ref, &request.argument)
}
}
pub async fn run(args: McpArgs) -> Result<(), String> {
use rmcp::ServiceExt;
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.with_writer(std::io::stderr)
.init();
if let Some(ref path) = args.config {
let config = tree_sitter_language_pack::PackConfig::from_toml_file(path)
.map_err(|e| format!("Failed to load config '{}': {e}", path.display()))?;
tree_sitter_language_pack::init(&config).map_err(|e| format!("Init failed: {e}"))?;
}
match args.transport.as_str() {
"stdio" => {
tracing::info!("starting ts-pack MCP server (stdio transport)");
let service = TsPackMcp::new()
.serve(rmcp::transport::stdio())
.await
.map_err(|e| format!("MCP stdio serve failed: {e}"))?;
service.waiting().await.map_err(|e| format!("MCP server error: {e}"))?;
}
"http" => {
use rmcp::transport::streamable_http_server::{StreamableHttpService, session::local::LocalSessionManager};
let addr: std::net::SocketAddr = format!("{}:{}", args.host, args.port)
.parse()
.map_err(|e| format!("invalid MCP listen address: {e}"))?;
let http_service = StreamableHttpService::new(
|| Ok(TsPackMcp::new()),
LocalSessionManager::default().into(),
Default::default(),
);
let router = axum::Router::new().nest_service("/mcp", http_service);
tracing::info!("starting ts-pack MCP server (HTTP transport) on {addr}");
let listener = tokio::net::TcpListener::bind(addr)
.await
.map_err(|e| format!("failed to bind MCP HTTP {addr}: {e}"))?;
axum::serve(listener, router)
.await
.map_err(|e| format!("MCP HTTP server error: {e}"))?;
}
other => return Err(format!("unknown transport '{other}', use 'stdio' or 'http'")),
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tool_router_has_all_tools() {
let router = TsPackMcp::tool_router();
for name in [
"parse",
"process",
"detect_language",
"list_languages",
"info",
"download",
"cache_dir",
"clean_cache",
] {
assert!(router.has_route(name), "Expected tool '{name}' to be registered");
}
}
#[test]
fn test_server_info_fields() {
let server = TsPackMcp::new();
let info = server.get_info();
assert_eq!(info.server_info.name, "ts-pack-mcp");
assert_eq!(info.server_info.version, env!("CARGO_PKG_VERSION"));
assert!(info.capabilities.tools.is_some(), "tools capability advertised");
assert!(info.capabilities.resources.is_some(), "resources capability advertised");
assert!(info.capabilities.prompts.is_some(), "prompts capability advertised");
assert!(
info.capabilities.completions.is_some(),
"completions capability advertised"
);
assert!(info.instructions.is_some());
}
#[test]
fn test_list_resources_exposes_catalogs() {
let server = TsPackMcp::new();
let result = server.list_resources_inner();
let uris: Vec<&str> = result.resources.iter().map(|r| r.uri.as_str()).collect();
assert!(
uris.contains(&"ts-pack://languages"),
"available catalog resource present"
);
assert!(
uris.contains(&"ts-pack://languages/downloaded"),
"downloaded catalog resource present"
);
}
#[test]
fn test_list_resource_templates_exposes_language_template() {
let server = TsPackMcp::new();
let result = server.list_resource_templates_inner();
assert!(
result
.resource_templates
.iter()
.any(|t| t.uri_template == "ts-pack://language/{name}"),
"per-language template present"
);
}
#[test]
fn test_read_resource_available_languages() {
let server = TsPackMcp::new();
let result = server
.read_resource_inner("ts-pack://languages")
.expect("read should succeed");
let ResourceContents::TextResourceContents { text, .. } = &result.contents[0] else {
panic!("expected text contents");
};
let parsed: serde_json::Value = serde_json::from_str(text).expect("valid JSON");
let count = parsed["count"].as_u64().expect("count is a number");
let languages = parsed["languages"].as_array().expect("languages is an array");
assert_eq!(count as usize, languages.len(), "count matches the array length");
}
#[test]
fn test_read_resource_language_template() {
let server = TsPackMcp::new();
let result = server
.read_resource_inner("ts-pack://language/python")
.expect("read should succeed");
let ResourceContents::TextResourceContents { text, .. } = &result.contents[0] else {
panic!("expected text contents");
};
let parsed: serde_json::Value = serde_json::from_str(text).expect("valid JSON");
assert_eq!(parsed["language"], "python");
assert!(parsed["known"].is_boolean());
}
#[test]
fn test_read_resource_unknown_uri_errors() {
let server = TsPackMcp::new();
assert!(server.read_resource_inner("ts-pack://nope").is_err());
}
#[test]
fn test_list_prompts_exposes_analyze_code() {
let server = TsPackMcp::new();
let result = server.list_prompts_inner();
assert!(result.prompts.iter().any(|p| p.name == "analyze-code"));
}
#[test]
fn test_get_prompt_renders_language_and_focus() {
let server = TsPackMcp::new();
let mut args = serde_json::Map::new();
args.insert("language".to_string(), serde_json::json!("rust"));
args.insert("focus".to_string(), serde_json::json!("security"));
let result = server
.get_prompt_inner("analyze-code", Some(args))
.expect("render should succeed");
let ContentBlock::Text(text_content) = &result.messages[0].content else {
panic!("expected text message");
};
let text = &text_content.text;
assert!(text.contains("rust"), "language interpolated");
assert!(text.contains("security"), "focus interpolated");
}
#[test]
fn test_get_prompt_unknown_errors() {
let server = TsPackMcp::new();
assert!(server.get_prompt_inner("nope", None).is_err());
}
#[test]
fn test_complete_language_prefix() {
let server = TsPackMcp::new();
let reference = Reference::for_resource("ts-pack://language/{name}");
let argument = ArgumentInfo::new("name", "py");
let result = server
.complete_inner(&reference, &argument)
.expect("complete should succeed");
assert!(
result.completion.values.iter().all(|v| v.starts_with("py")),
"all completions match the prefix"
);
assert!(result.completion.total.is_some(), "completion reports a total count");
}
#[test]
fn test_complete_ignores_unrelated_reference() {
let server = TsPackMcp::new();
let reference = Reference::for_prompt("other");
let argument = ArgumentInfo::new("language", "py");
let result = server
.complete_inner(&reference, &argument)
.expect("complete should succeed");
assert!(
result.completion.values.is_empty(),
"no completions for unrelated prompt"
);
}
#[test]
fn test_list_languages_available() {
let server = TsPackMcp::new();
let result = server.list_languages(Parameters(ListLanguagesParams {
source: None,
filter: None,
}));
assert!(result.is_ok());
}
#[test]
fn test_list_languages_with_filter() {
let server = TsPackMcp::new();
let result = server.list_languages(Parameters(ListLanguagesParams {
source: Some("available".to_string()),
filter: Some("python".to_string()),
}));
assert!(result.is_ok());
let call = result.unwrap();
if let Some(ContentBlock::Text(text_content)) = call.content.first() {
let parsed: serde_json::Value = serde_json::from_str(&text_content.text).expect("Should be valid JSON");
assert_eq!(parsed["filter"], "python");
assert_eq!(parsed["source"], "available");
assert!(parsed["count"].is_number());
}
}
#[test]
fn test_cache_dir_returns_path() {
let server = TsPackMcp::new();
let result = server.cache_dir();
assert!(result.is_ok());
let call = result.unwrap();
if let Some(ContentBlock::Text(text_content)) = call.content.first() {
let parsed: serde_json::Value = serde_json::from_str(&text_content.text).expect("Should be valid JSON");
assert!(parsed["cache_dir"].is_string());
}
}
#[test]
fn test_detect_language_from_path() {
let server = TsPackMcp::new();
let result = server.detect_language(Parameters(DetectLanguageParams {
path: Some("main.py".to_string()),
content: None,
}));
assert!(result.is_ok());
let call = result.unwrap();
if let Some(ContentBlock::Text(text_content)) = call.content.first() {
let parsed: serde_json::Value = serde_json::from_str(&text_content.text).expect("Should be valid JSON");
assert_eq!(parsed["language"], "python");
}
}
#[test]
fn test_download_requires_params() {
let server = TsPackMcp::new();
let result = server.download(Parameters(DownloadParams {
languages: None,
all: None,
groups: None,
fresh: None,
}));
assert!(result.is_err());
}
}