Skip to main content

mcp_compressor_core/server/
compressed.rs

1//! `CompressedServer` — the top-level object that owns the backend client,
2//! tool cache, and compression engine, and exposes them via a frontend MCP server.
3//!
4//! This file exposes the high-level runtime API used by integration tests,
5//! language bindings, and the standalone Rust CLI.
6
7use rmcp::model::{
8    CallToolRequestParams, Content, GetPromptRequestParams, GetPromptResult, RawContent,
9    ReadResourceRequestParams, ResourceContents,
10};
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14use crate::compression::engine::{CompressionEngine, Tool};
15use crate::compression::CompressionLevel;
16use crate::config::topology::MCPConfig;
17use crate::server::backend::BackendServerConfig;
18use crate::server::connect::{connect_backend, ConnectedBackend};
19use crate::Error;
20
21/// Frontend tool-surface mode exposed by the proxy.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum ProxyTransformMode {
24    /// Normal compressed MCP mode: get_tool_schema/invoke_tool/(list_tools at max).
25    CompressedTools,
26    /// CLI mode: expose one help tool per configured server and route generated clients through /exec.
27    Cli,
28    /// Just Bash mode: expose one bash tool plus per-server help tools.
29    JustBash,
30}
31
32/// How upstream backend servers are supplied to the runtime.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum BackendConfigSource {
35    /// Direct command/argv input, e.g. `python alpha_server.py`.
36    Command,
37    /// JSON MCP config input with one `mcpServers` entry.
38    SingleServerJsonConfig,
39    /// JSON MCP config input with multiple `mcpServers` entries.
40    MultiServerJsonConfig,
41}
42
43/// Compression/runtime options shared by single-server and multi-server modes.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct CompressedServerConfig {
46    pub level: CompressionLevel,
47    pub server_name: Option<String>,
48    pub include_tools: Vec<String>,
49    pub exclude_tools: Vec<String>,
50    pub toonify: bool,
51    pub transform_mode: ProxyTransformMode,
52    pub config_source: BackendConfigSource,
53}
54
55impl Default for CompressedServerConfig {
56    fn default() -> Self {
57        Self {
58            level: CompressionLevel::default(),
59            server_name: None,
60            include_tools: Vec::new(),
61            exclude_tools: Vec::new(),
62            toonify: false,
63            transform_mode: ProxyTransformMode::CompressedTools,
64            config_source: BackendConfigSource::Command,
65        }
66    }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct JustBashProviderSpec {
71    pub provider_name: String,
72    pub help_tool_name: String,
73    pub tools: Vec<JustBashCommandSpec>,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct JustBashCommandSpec {
78    pub command_name: String,
79    pub backend_tool_name: String,
80    pub description: Option<String>,
81    pub input_schema: Value,
82    pub invoke_tool_name: String,
83}
84
85/// Connected compressor runtime.
86#[derive(Debug)]
87pub struct CompressedServer {
88    config: CompressedServerConfig,
89    backends: Vec<ConnectedBackend>,
90}
91
92impl CompressedServer {
93    /// Connect to one upstream stdio MCP server.
94    pub async fn connect_stdio(
95        config: CompressedServerConfig,
96        backend: BackendServerConfig,
97    ) -> Result<Self, Error> {
98        let public_name = config
99            .server_name
100            .clone()
101            .unwrap_or_else(|| backend.name.clone());
102        let backend = connect_backend(
103            backend,
104            public_name,
105            &config.include_tools,
106            &config.exclude_tools,
107        )
108        .await?;
109        Ok(Self {
110            config,
111            backends: vec![backend],
112        })
113    }
114
115    /// Connect to multiple upstream stdio MCP servers.
116    pub async fn connect_multi_stdio(
117        config: CompressedServerConfig,
118        backends: Vec<BackendServerConfig>,
119    ) -> Result<Self, Error> {
120        let suite_prefix = config.server_name.clone();
121        let mut connected = Vec::with_capacity(backends.len());
122        for backend in backends {
123            let public_name = match &suite_prefix {
124                Some(prefix) => format!("{prefix}_{}", backend.name),
125                None => backend.name.clone(),
126            };
127            connected.push(
128                connect_backend(
129                    backend,
130                    public_name,
131                    &config.include_tools,
132                    &config.exclude_tools,
133                )
134                .await?,
135            );
136        }
137        Ok(Self {
138            config,
139            backends: connected,
140        })
141    }
142
143    /// Connect using a JSON MCP config document containing one or more `mcpServers` entries.
144    pub async fn connect_mcp_config_json(
145        config: CompressedServerConfig,
146        mcp_config_json: &str,
147    ) -> Result<Self, Error> {
148        let mcp_config = MCPConfig::from_json(mcp_config_json)?;
149        let mut backends = Vec::new();
150        for name in mcp_config.server_names() {
151            let server = mcp_config
152                .server(&name)
153                .ok_or_else(|| Error::Config(format!("server not found: {name}")))?;
154            backends.push(
155                BackendServerConfig::new(name, server.command.clone(), server.args.clone())
156                    .with_env(server.env.clone()),
157            );
158        }
159
160        if backends.len() == 1 {
161            let backend = backends.into_iter().next().expect("one backend exists");
162            let public_name = config.server_name.clone().unwrap_or_default();
163            let backend = connect_backend(
164                backend,
165                public_name,
166                &config.include_tools,
167                &config.exclude_tools,
168            )
169            .await?;
170            Ok(Self {
171                config,
172                backends: vec![backend],
173            })
174        } else {
175            Self::connect_multi_stdio(config, backends).await
176        }
177    }
178
179    /// Return the frontend MCP tools exposed to callers.
180    pub async fn list_frontend_tools(&self) -> Result<Vec<Tool>, Error> {
181        if self.config.transform_mode == ProxyTransformMode::JustBash {
182            return Ok(self.just_bash_tools());
183        }
184        if self.config.transform_mode == ProxyTransformMode::Cli {
185            return Ok(self.cli_help_tools());
186        }
187        let mut tools = Vec::new();
188        for backend in &self.backends {
189            let prefix = self.wrapper_prefix(backend);
190            tools.push(wrapper_tool(
191                format!("{prefix}get_tool_schema"),
192                &self.get_tool_schema_description(backend),
193            ));
194            tools.push(wrapper_tool(
195                format!("{prefix}invoke_tool"),
196                &self.invoke_tool_description(backend),
197            ));
198            if self.config.level == CompressionLevel::Max {
199                tools.push(wrapper_tool(
200                    format!("{prefix}list_tools"),
201                    "List compressed backend tools.",
202                ));
203            }
204        }
205        Ok(tools)
206    }
207
208    fn get_tool_schema_description(&self, backend: &ConnectedBackend) -> String {
209        format!(
210            "Get the input schema for a specific tool from the {} toolset.\n\nAvailable tools are:\n{}",
211            backend.public_name,
212            self.frontend_tool_listing(backend)
213        )
214    }
215
216    fn invoke_tool_description(&self, backend: &ConnectedBackend) -> String {
217        format!(
218            "Invoke a tool from the {} toolset. Use get_tool_schema first when you need the full input schema.",
219            backend.public_name
220        )
221    }
222
223    fn frontend_tool_listing(&self, backend: &ConnectedBackend) -> String {
224        let listing = self.engine().format_listing(&backend.tools);
225        if listing.is_empty() {
226            backend
227                .tools
228                .iter()
229                .map(|tool| format!("<tool>{}</tool>", tool.name))
230                .collect::<Vec<_>>()
231                .join("\n")
232        } else {
233            listing
234        }
235    }
236
237    fn engine(&self) -> crate::compression::engine::CompressionEngine {
238        crate::compression::engine::CompressionEngine::new(self.config.level.clone())
239    }
240
241    /// Return the default backend server name when a single unambiguous default exists.
242    pub fn compression_level(&self) -> &CompressionLevel {
243        &self.config.level
244    }
245
246    pub fn default_server_name(&self) -> Option<&str> {
247        self.config.server_name.as_deref().or_else(|| {
248            if self.backends.len() == 1 {
249                Some(self.backends[0].public_name.as_str())
250            } else {
251                None
252            }
253        })
254    }
255
256    /// Return backend tool metadata for client generation and language bindings.
257    pub fn backend_tools(&self) -> Vec<Tool> {
258        self.backends
259            .iter()
260            .flat_map(|backend| backend.tools.iter().cloned())
261            .collect()
262    }
263
264    /// Return backend tool metadata grouped by public backend server name.
265    pub fn backend_tools_by_server(&self) -> Vec<(String, Tool)> {
266        self.backends
267            .iter()
268            .flat_map(|backend| {
269                backend
270                    .tools
271                    .iter()
272                    .cloned()
273                    .map(|tool| (backend.public_name.clone(), tool))
274            })
275            .collect()
276    }
277
278    /// Return the full backend schema for a tool via the compressed wrapper API.
279    pub async fn get_tool_schema(
280        &self,
281        _wrapper_tool_name: &str,
282        backend_tool_name: &str,
283    ) -> Result<String, Error> {
284        let backend = self.backend_for_wrapper(_wrapper_tool_name)?;
285        let tool = backend
286            .tools
287            .iter()
288            .find(|tool| tool.name == backend_tool_name)
289            .ok_or_else(|| Error::ToolNotFound(backend_tool_name.to_string()))?;
290        Ok(CompressionEngine::format_schema_response(tool))
291    }
292
293    /// List backend tools via the max-compression `list_tools` wrapper.
294    pub async fn list_backend_tools(&self, wrapper_tool_name: &str) -> Result<String, Error> {
295        let backend = self.backend_for_wrapper(wrapper_tool_name)?;
296        let engine = CompressionEngine::new(CompressionLevel::High);
297        Ok(engine
298            .format_listing(&backend.tools)
299            .lines()
300            .collect::<Vec<_>>()
301            .join("\n"))
302    }
303
304    /// Invoke a backend tool via the compressed wrapper API.
305    pub async fn invoke_tool(
306        &self,
307        _wrapper_tool_name: &str,
308        backend_tool_name: &str,
309        tool_input: Value,
310    ) -> Result<String, Error> {
311        let backend = self.backend_for_wrapper(_wrapper_tool_name)?;
312        self.invoke_backend(backend, backend_tool_name, tool_input)
313            .await
314    }
315
316    /// List frontend resources, including pass-through backend resources and
317    /// compressor-owned uncompressed-tool-list resources.
318    pub async fn list_resources(&self) -> Result<Vec<String>, Error> {
319        let mut resources = Vec::new();
320        for backend in &self.backends {
321            resources.extend(backend.resources.clone());
322            resources.push(format!(
323                "compressor://{}/uncompressed-tools",
324                backend.public_name
325            ));
326        }
327        Ok(resources)
328    }
329
330    /// Read a frontend resource by URI.
331    pub async fn read_resource(&self, uri: &str) -> Result<String, Error> {
332        for backend in &self.backends {
333            if uri == format!("compressor://{}/uncompressed-tools", backend.public_name) {
334                return serde_json::to_string_pretty(&backend.tools).map_err(Error::from);
335            }
336        }
337        let backend = self
338            .backends
339            .iter()
340            .find(|backend| backend.resources.iter().any(|resource| resource == uri))
341            .ok_or_else(|| Error::ToolNotFound(uri.to_string()))?;
342        let result = backend
343            .client
344            .read_resource(ReadResourceRequestParams::new(uri))
345            .await
346            .map_err(|error| Error::Config(error.to_string()))?;
347        Ok(resource_contents_to_string(result.contents))
348    }
349
350    /// List frontend prompts passed through from backend servers.
351    pub async fn list_prompts(&self) -> Result<Vec<String>, Error> {
352        Ok(self
353            .backends
354            .iter()
355            .flat_map(|backend| backend.prompts.iter().map(|prompt| prompt.name.clone()))
356            .collect())
357    }
358
359    /// Fetch a prompt from the backend that owns it.
360    pub async fn get_prompt(
361        &self,
362        name: &str,
363        arguments: Option<serde_json::Map<String, Value>>,
364    ) -> Result<GetPromptResult, Error> {
365        let backend = self
366            .backends
367            .iter()
368            .find(|backend| backend.prompts.iter().any(|prompt| prompt.name == name))
369            .ok_or_else(|| Error::ToolNotFound(name.to_string()))?;
370        let mut request = GetPromptRequestParams::new(name);
371        if let Some(arguments) = arguments {
372            request = request.with_arguments(arguments);
373        }
374        backend
375            .client
376            .get_prompt(request)
377            .await
378            .map_err(|error| Error::Config(error.to_string()))
379    }
380
381    /// Return backend tools when the runtime has exactly one backend.
382    pub fn single_backend_tools(&self) -> Result<Vec<Tool>, Error> {
383        self.backends
384            .first()
385            .filter(|_| self.backends.len() == 1)
386            .map(|backend| backend.tools.clone())
387            .ok_or_else(|| Error::Config("expected exactly one backend".to_string()))
388    }
389
390    /// Invoke a backend tool directly when the runtime has exactly one backend.
391    ///
392    /// This is used by generated proxy clients, which call `/exec` with the
393    /// backend tool name directly rather than the MCP wrapper tool name.
394    pub fn just_bash_provider_specs(&self) -> Vec<JustBashProviderSpec> {
395        self.backends
396            .iter()
397            .map(|backend| {
398                let invoke_tool_name = format!("{}invoke_tool", self.wrapper_prefix(backend));
399                JustBashProviderSpec {
400                    provider_name: backend.public_name.clone(),
401                    help_tool_name: format!("{}_help", backend.public_name),
402                    tools: backend
403                        .tools
404                        .iter()
405                        .map(|tool| JustBashCommandSpec {
406                            command_name: crate::cli::mapping::tool_name_to_subcommand(&tool.name),
407                            backend_tool_name: tool.name.clone(),
408                            description: tool.description.clone(),
409                            input_schema: tool.input_schema.clone(),
410                            invoke_tool_name: invoke_tool_name.clone(),
411                        })
412                        .collect(),
413                }
414            })
415            .collect()
416    }
417
418    pub async fn invoke_single_backend_tool(
419        &self,
420        backend_tool_name: &str,
421        tool_input: Value,
422    ) -> Result<String, Error> {
423        let backend = self
424            .backends
425            .first()
426            .filter(|_| self.backends.len() == 1)
427            .ok_or_else(|| Error::ToolNotFound(backend_tool_name.to_string()))?;
428        self.invoke_backend(backend, backend_tool_name, tool_input)
429            .await
430    }
431
432    async fn invoke_backend(
433        &self,
434        backend: &ConnectedBackend,
435        backend_tool_name: &str,
436        tool_input: Value,
437    ) -> Result<String, Error> {
438        if !backend
439            .tools
440            .iter()
441            .any(|tool| tool.name == backend_tool_name)
442        {
443            return Err(Error::ToolNotFound(backend_tool_name.to_string()));
444        }
445        let arguments = match tool_input {
446            Value::Object(map) => Some(map),
447            _ => None,
448        };
449        let mut params = CallToolRequestParams::new(backend_tool_name.to_string());
450        if let Some(arguments) = arguments {
451            params = params.with_arguments(arguments);
452        }
453        let result = backend
454            .client
455            .call_tool(params)
456            .await
457            .map_err(|error| Error::Config(error.to_string()))?;
458        let output = call_tool_result_to_string(result);
459        Ok(self.maybe_toonify_output(&output))
460    }
461
462    fn maybe_toonify_output(&self, output: &str) -> String {
463        if !self.config.toonify {
464            return output.to_string();
465        }
466        let Ok(value) = serde_json::from_str::<Value>(output) else {
467            return output.to_string();
468        };
469        toon_format::encode(&value, &toon_format::EncodeOptions::default())
470            .unwrap_or_else(|_| output.to_string())
471    }
472
473    fn cli_help_tools(&self) -> Vec<Tool> {
474        self.backends
475            .iter()
476            .map(|backend| {
477                Tool::new(
478                    format!("{}_help", backend.public_name),
479                    Some(format_backend_help(backend)),
480                    serde_json::json!({"type": "object", "properties": {}}),
481                )
482            })
483            .collect()
484    }
485
486    fn just_bash_tools(&self) -> Vec<Tool> {
487        let mut tools = Vec::new();
488        let names = self
489            .backends
490            .iter()
491            .map(|backend| backend.public_name.as_str())
492            .collect::<Vec<_>>()
493            .join(", ");
494        tools.push(Tool::new(
495            "bash_tool",
496            Some(format!(
497                "Register backend MCP tools as custom commands in a language-hosted just-bash instance. Providers: {names}. When relevant, prefer TOON output for compact representation."
498            )),
499            serde_json::json!({
500                "type": "object",
501                "properties": {
502                    "command": {"type": "string", "description": "Command text interpreted by the host language's just-bash implementation"}
503                },
504                "required": ["command"]
505            }),
506        ));
507        tools.extend(self.cli_help_tools());
508        tools
509    }
510
511    fn wrapper_prefix(&self, backend: &ConnectedBackend) -> String {
512        if backend.public_name.is_empty() {
513            String::new()
514        } else {
515            format!("{}_", backend.public_name)
516        }
517    }
518
519    fn backend_for_wrapper(&self, wrapper_tool_name: &str) -> Result<&ConnectedBackend, Error> {
520        if self.backends.len() == 1 && self.backends[0].public_name.is_empty() {
521            return Ok(&self.backends[0]);
522        }
523        self.backends
524            .iter()
525            .find(|backend| wrapper_tool_name.starts_with(&self.wrapper_prefix(backend)))
526            .ok_or_else(|| Error::ToolNotFound(wrapper_tool_name.to_string()))
527    }
528}
529
530fn format_backend_help(backend: &ConnectedBackend) -> String {
531    let mut lines = vec![format!(
532        "{} - the {} toolset",
533        backend.public_name, backend.public_name
534    )];
535    lines.push(String::new());
536    lines.push("When relevant, outputs from this CLI will prefer using the TOON format for more efficient representation of data.".to_string());
537    lines.push(String::new());
538    lines.push("SUBCOMMANDS:".to_string());
539    for tool in &backend.tools {
540        let subcommand = crate::cli::mapping::tool_name_to_subcommand(&tool.name);
541        let description = tool.description.as_deref().unwrap_or_default();
542        lines.push(format!("  {subcommand:<35} {description}"));
543    }
544    lines.join("\n")
545}
546
547fn wrapper_tool(name: String, description: &str) -> Tool {
548    Tool::new(
549        name,
550        Some(description.to_string()),
551        serde_json::json!({
552            "type": "object",
553            "properties": {}
554        }),
555    )
556}
557
558fn call_tool_result_to_string(result: rmcp::model::CallToolResult) -> String {
559    if let Some(structured) = result.structured_content {
560        return value_to_string(&structured);
561    }
562
563    result
564        .content
565        .into_iter()
566        .map(content_to_string)
567        .collect::<Vec<_>>()
568        .join("\n")
569}
570
571fn content_to_string(content: Content) -> String {
572    match content.raw {
573        RawContent::Text(text) => text.text,
574        RawContent::Image(image) => image.data,
575        RawContent::Resource(resource) => resource_contents_to_string(vec![resource.resource]),
576        RawContent::Audio(audio) => audio.data,
577        RawContent::ResourceLink(resource) => resource.uri,
578    }
579}
580
581fn resource_contents_to_string(contents: Vec<ResourceContents>) -> String {
582    contents
583        .into_iter()
584        .map(|content| match content {
585            ResourceContents::TextResourceContents { text, .. } => text,
586            ResourceContents::BlobResourceContents { blob, .. } => blob,
587        })
588        .collect::<Vec<_>>()
589        .join("\n")
590}
591
592fn value_to_string(value: &Value) -> String {
593    match value {
594        Value::String(value) => value.clone(),
595        Value::Object(map) if map.len() == 1 && map.contains_key("result") => {
596            value_to_string(&map["result"])
597        }
598        _ => value.to_string(),
599    }
600}