mcp-compressor 0.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ """MCP Compressor - An MCP server wrapper for reducing tokens consumed by MCP tools.
2
+
3
+ This package provides a proxy server that wraps existing MCP servers and compresses tool descriptions to reduce token
4
+ consumption. It supports multiple compression levels and various transport types (stdio, HTTP, SSE).
5
+ """
@@ -0,0 +1,102 @@
1
+ import shutil
2
+
3
+ from .types import CompressionLevel
4
+
5
+ TITLE = """\
6
+ \033[32m█▀▄▀█ █▀▀ █▀█ █▀▀ █▀█ █▀▄▀█ █▀█ █▀█ █▀▀ █▀▀ █▀▀ █▀█ █▀█\033[0m
7
+ \033[32m█ ▀ █ █▄▄ █▀▀ █▄▄ █▄█ █ ▀ █ █▀▀ █▀▄ ██▄ ▄▄█ ▄▄█ █▄█ █▀▄\033[0m\
8
+ """
9
+
10
+
11
+ def print_banner(
12
+ server_name: str | None, transport_type: str, stats: dict, compression_level: CompressionLevel
13
+ ) -> None:
14
+ """Print the startup banner with server information and compression stats.
15
+
16
+ Args:
17
+ server_name: The name of the backend server, if provided.
18
+ transport_type: The transport type being used (stdio, http, sse).
19
+ stats: Compression statistics from get_compression_stats().
20
+ compression_level: The compression level being used.
21
+ """
22
+ # Get terminal width
23
+ columns = min(shutil.get_terminal_size().columns, 80)
24
+ if columns < 63:
25
+ # Terminal too narrow to display banner properly
26
+ return
27
+
28
+ header = "╭" + "─" * (columns - 2) + "╮"
29
+ footer = "╰" + "─" * (columns - 2) + "╯"
30
+ separator = "├" + "─" * (columns - 2) + "┤"
31
+ blank_line = "│" + " " * (columns - 2) + "│"
32
+
33
+ banner = [header, blank_line]
34
+ for line in TITLE.splitlines():
35
+ banner.append(_pad_line(line, columns + 9, center=True))
36
+ if server_name:
37
+ banner.append(blank_line)
38
+ banner.append(_pad_line(f"\033[32m●\033[0m Backend server name: {server_name}", columns + 9))
39
+ banner.append(blank_line)
40
+ banner.append(_pad_line(f"\033[32m●\033[0m Backend server transport: {transport_type.upper()}", columns + 9))
41
+ banner.append(blank_line)
42
+ banner.append(_pad_line("\033[32m●\033[0m Docs: https://atlassian-labs.github.io/mcp-compressor/", columns + 9))
43
+ banner.append(blank_line)
44
+ banner.append(separator)
45
+ banner.append(blank_line)
46
+ banner.append(_pad_line(f"📊 Compression Statistics (current = {compression_level.capitalize()}):", columns - 1))
47
+ banner.append(blank_line)
48
+ for line in _format_compression_chart(stats, columns, compression_level):
49
+ banner.append(line)
50
+ banner.append(blank_line)
51
+ banner.append(footer)
52
+
53
+ print("\n".join(banner))
54
+
55
+
56
+ def _format_compression_chart(stats: dict, width: int, compression_level: CompressionLevel) -> list[str]:
57
+ """Format compression statistics as a visual bar chart.
58
+
59
+ Args:
60
+ stats: Dictionary containing compression statistics from get_compression_stats().
61
+ width: Total width of the chart area.
62
+ compression_level: The compression level being used.
63
+
64
+ Returns:
65
+ Formatted strings with bar chart visualization.
66
+ """
67
+ width -= 25
68
+ original_size = stats["original_schema_size"]
69
+ compressed_sizes = stats["compressed_schema_sizes"]
70
+
71
+ lines = []
72
+
73
+ # Original size bar (100%)
74
+ bar = "█" * width
75
+ lines.append(_pad_line(f"Original {bar} 100.0%", width + 25))
76
+
77
+ # Compressed size bars for each level
78
+ for level in [CompressionLevel.LOW, CompressionLevel.MEDIUM, CompressionLevel.HIGH, CompressionLevel.MAX]:
79
+ size = compressed_sizes[level]
80
+ ratio = size / original_size if original_size > 0 else 0
81
+ filled = int(ratio * width)
82
+ bar = "█" * filled + "░" * (width - filled)
83
+ pct = ratio * 100
84
+ label = f"{level.value.capitalize():<8}"
85
+ line = _pad_line(f"{label} {bar} {pct:5.1f}%", width + 25)
86
+ if level == compression_level:
87
+ # Use blue color for the current compression level
88
+ blue_end = line.find("░")
89
+ if blue_end == -1:
90
+ blue_end = len(line) - 2
91
+ line = line[:2] + "\033[1;32m" + line[2:blue_end] + "\033[0m" + line[blue_end:]
92
+ lines.append(line)
93
+
94
+ return lines
95
+
96
+
97
+ def _pad_line(line: str, total_width: int, center: bool = False) -> str:
98
+ if center:
99
+ padding_total = total_width - 6 - len(line)
100
+ padding_left = padding_total // 2
101
+ line = " " * padding_left + line
102
+ return "│ " + f"{line:<{total_width - 6}}" + " │"
mcp_compressor/main.py ADDED
@@ -0,0 +1,317 @@
1
+ """Main entry point for the MCP Compressor CLI.
2
+
3
+ This module provides the CLI interface for running the MCP Compressor proxy server, which wraps existing MCP servers and
4
+ compresses their tool descriptions to reduce token consumption.
5
+ """
6
+
7
+ import asyncio
8
+ import os
9
+ import sys
10
+ from collections.abc import AsyncGenerator
11
+ from contextlib import asynccontextmanager
12
+ from typing import Annotated, Literal, overload
13
+
14
+ import typer
15
+ from fastmcp import FastMCP
16
+ from fastmcp.client.transports import (
17
+ SSETransport,
18
+ StdioTransport,
19
+ StreamableHttpTransport,
20
+ infer_transport_type_from_url,
21
+ )
22
+ from fastmcp.server.proxy import ProxyClient
23
+ from loguru import logger
24
+ from loguru_logging_intercept import setup_loguru_logging_intercept
25
+
26
+ from .banner import print_banner
27
+ from .tools import CompressedTools
28
+ from .types import CompressionLevel, LogLevel, TransportType
29
+
30
+ app = typer.Typer(name="MCP Compressor", help="An MCP server wrapper for reducing tokens consumed by MCP tools.")
31
+
32
+
33
+ @app.command()
34
+ def main(
35
+ command_or_url_list: Annotated[
36
+ list[str],
37
+ typer.Argument(
38
+ ...,
39
+ metavar="COMMAND_OR_URL",
40
+ help=(
41
+ "The URL of the MCP server to connect to for streamable HTTP or SSE servers, or the command and "
42
+ "arguments to run for stdio servers. Example: uvx mcp-server-fetch"
43
+ ),
44
+ ),
45
+ ],
46
+ cwd: Annotated[
47
+ str | None,
48
+ typer.Option(
49
+ ...,
50
+ "--cwd",
51
+ help="The working directory to use when running stdio MCP servers.",
52
+ ),
53
+ ] = None,
54
+ env_list: Annotated[
55
+ list[str] | None,
56
+ typer.Option(
57
+ ...,
58
+ "--env",
59
+ "-e",
60
+ help=(
61
+ "Environment variables to set when running stdio MCP servers, in the form VAR_NAME=VALUE. Can be used "
62
+ "multiple times. Supports environment variable expansion with ${VAR_NAME} syntax."
63
+ ),
64
+ ),
65
+ ] = None,
66
+ header_list: Annotated[
67
+ list[str] | None,
68
+ typer.Option(
69
+ ...,
70
+ "--header",
71
+ "-H",
72
+ help=(
73
+ "Headers to use for remote (HTTP/SSE) MCP server connections, in the form Header-Name=Header-Value. "
74
+ "Can be use multiple times. Supports environment variable expansion with ${VAR_NAME} syntax."
75
+ ),
76
+ ),
77
+ ] = None,
78
+ timeout: Annotated[
79
+ float,
80
+ typer.Option(
81
+ ...,
82
+ "--timeout",
83
+ "-t",
84
+ help="The timeout in seconds for connecting to the MCP server and making requests.",
85
+ ),
86
+ ] = 10.0,
87
+ compression_level: Annotated[
88
+ CompressionLevel,
89
+ typer.Option(
90
+ ...,
91
+ "--compression-level",
92
+ "-c",
93
+ help=("The level of compression to apply to tool the tools descriptions of the wrapped MCP server."),
94
+ case_sensitive=False,
95
+ ),
96
+ ] = CompressionLevel.MEDIUM,
97
+ server_name: Annotated[
98
+ str | None,
99
+ typer.Option(
100
+ ...,
101
+ "--server-name",
102
+ "-n",
103
+ help=(
104
+ "Optional custom name to prefix the wrapper tool names (get_tool_schema, invoke_tool, list_tools). "
105
+ "The name will be sanitized to conform to MCP tool name specifications (only A-Z, a-z, 0-9, _, -, .)."
106
+ ),
107
+ ),
108
+ ] = None,
109
+ log_level: Annotated[
110
+ LogLevel,
111
+ typer.Option(
112
+ ...,
113
+ "--log-level",
114
+ "-l",
115
+ help=(
116
+ "The logging level. Used for both the MCP Compressor server and the underlying MCP server if it is a "
117
+ "stdio server."
118
+ ),
119
+ case_sensitive=False,
120
+ ),
121
+ ] = LogLevel.WARNING,
122
+ ):
123
+ """Run the MCP Compressor proxy server.
124
+
125
+ This is the main entry point for the CLI application. It connects to an MCP server
126
+ (via stdio, HTTP, or SSE) and wraps it with a compressed tool interface.
127
+ """
128
+ logger.remove()
129
+ logger.add(sys.stderr, level=log_level.value.upper())
130
+ setup_loguru_logging_intercept(modules=("fastmcp",))
131
+
132
+ asyncio.run(
133
+ _async_main(
134
+ command_or_url_list=command_or_url_list,
135
+ cwd=cwd,
136
+ env_list=env_list,
137
+ header_list=header_list,
138
+ timeout=timeout,
139
+ compression_level=compression_level,
140
+ server_name=server_name,
141
+ log_level=log_level,
142
+ )
143
+ )
144
+
145
+
146
+ async def _async_main(
147
+ command_or_url_list: list[str],
148
+ cwd: str | None,
149
+ env_list: list[str] | None,
150
+ header_list: list[str] | None,
151
+ timeout: float,
152
+ compression_level: CompressionLevel,
153
+ server_name: str | None,
154
+ log_level: LogLevel,
155
+ ) -> None:
156
+ """Run the MCP Compressor proxy server asynchronously."""
157
+ logger.info(f"Starting MCP Compressor with log level: {log_level.value}")
158
+
159
+ async with _server(
160
+ command_or_url_list=command_or_url_list,
161
+ cwd=cwd,
162
+ env_list=env_list,
163
+ header_list=header_list,
164
+ timeout=timeout,
165
+ compression_level=compression_level,
166
+ server_name=server_name,
167
+ ) as mcp:
168
+ logger.info("Starting MCP Compressor server")
169
+ await mcp.run_async(show_banner=False, log_level=log_level.value)
170
+
171
+
172
+ @asynccontextmanager
173
+ async def _server(
174
+ command_or_url_list: list[str],
175
+ cwd: str | None,
176
+ env_list: list[str] | None,
177
+ header_list: list[str] | None,
178
+ timeout: float,
179
+ compression_level: CompressionLevel,
180
+ server_name: str | None,
181
+ ) -> AsyncGenerator[FastMCP, None]:
182
+ if compression_level == CompressionLevel.MAX and server_name is None:
183
+ raise ValueError("server_name must be provided when using MAX compression level") # noqa: TRY003
184
+
185
+ command_or_url = " ".join(command_or_url_list)
186
+ transport_type = infer_transport_type_from_url(command_or_url) if command_or_url.startswith("http") else "stdio"
187
+ logger.info(f"Inferred transport type: {transport_type}")
188
+
189
+ # Handle different transport types
190
+ transport: TransportType
191
+ if transport_type == "stdio":
192
+ transport = _get_stdio_transport(
193
+ command=command_or_url_list[0], args=command_or_url_list[1:], cwd=cwd, env_list=env_list
194
+ )
195
+ elif transport_type == "http":
196
+ transport = _get_streamable_http_transport(url=command_or_url, header_list=header_list, timeout=timeout)
197
+ elif transport_type == "sse":
198
+ transport = _get_sse_transport(url=command_or_url, header_list=header_list, timeout=timeout)
199
+
200
+ # Start the MCP client with the selected transport
201
+ logger.info("Initializing proxy client")
202
+ async with ProxyClient(transport=transport, timeout=timeout) as client:
203
+ logger.info("Initalizing proxy server")
204
+ mcp = FastMCP.as_proxy(backend=client, name="MCP Compressor Proxy", version="0.1.0")
205
+ logger.info("Configuring compressed tools middleware")
206
+ compressed_tools = CompressedTools(mcp, compression_level=compression_level, server_name=server_name)
207
+ await compressed_tools.configure_server()
208
+ stats = await compressed_tools.get_compression_stats()
209
+ print_banner(server_name, transport_type, stats, compression_level)
210
+ yield mcp
211
+
212
+
213
+ def _interpolate_string(value: str) -> str:
214
+ """Interpolate environment variables in a single string.
215
+
216
+ Args:
217
+ value: A string that may contain environment variable references like ${VAR_NAME}.
218
+
219
+ Returns:
220
+ The string with interpolated environment variables. If a variable cannot be interpolated, it is left as-is
221
+ without interpolation.
222
+ """
223
+ try:
224
+ if not value or "${" not in value:
225
+ return value
226
+ # Replace ${VAR_NAME} with {VAR_NAME} and use format() with environment variables
227
+ return value.replace("${", "{").format(**os.environ)
228
+ except Exception as e:
229
+ logger.warning(f"Failed to interpolate environment variable {value}: {e}, using uninterpolated value")
230
+ return value
231
+
232
+
233
+ @overload
234
+ def _get_remote_transport(
235
+ url: str, header_list: list[str] | None, timeout: float, transport_type: Literal["http"]
236
+ ) -> StreamableHttpTransport: ...
237
+
238
+
239
+ @overload
240
+ def _get_remote_transport(
241
+ url: str, header_list: list[str] | None, timeout: float, transport_type: Literal["sse"]
242
+ ) -> SSETransport: ...
243
+
244
+
245
+ def _get_remote_transport(
246
+ url: str, header_list: list[str] | None, timeout: float, transport_type: Literal["http", "sse"]
247
+ ) -> StreamableHttpTransport | SSETransport:
248
+ """Create a remote transport (HTTP or SSE) with the specified configuration.
249
+
250
+ Args:
251
+ url: The URL of the remote MCP server.
252
+ header_list: Optional list of headers in Header-Name=Value format.
253
+ timeout: Timeout for SSE read operations.
254
+ transport_type: Either "http" for streamable HTTP or "sse" for server-sent events.
255
+
256
+ Returns:
257
+ Configured transport instance for the specified type.
258
+ """
259
+ header_dict: dict[str, str] = {}
260
+ if header_list:
261
+ for header in header_list:
262
+ key, val = header.split("=", 1)
263
+ header_dict[key] = _interpolate_string(val)
264
+ transport_cls = StreamableHttpTransport if transport_type == "http" else SSETransport
265
+ return transport_cls(url=url, headers=header_dict)
266
+
267
+
268
+ def _get_streamable_http_transport(url: str, header_list: list[str] | None, timeout: float) -> StreamableHttpTransport:
269
+ """Create a streamable HTTP transport for connecting to an MCP server.
270
+
271
+ Args:
272
+ url: The HTTP URL of the MCP server.
273
+ header_list: Optional list of headers in Header-Name=Value format.
274
+ timeout: Timeout for read operations.
275
+
276
+ Returns:
277
+ Configured StreamableHttpTransport instance.
278
+ """
279
+ return _get_remote_transport(url, header_list, timeout, transport_type="http")
280
+
281
+
282
+ def _get_sse_transport(url: str, header_list: list[str] | None, timeout: float) -> SSETransport:
283
+ """Create an SSE (Server-Sent Events) transport for connecting to an MCP server.
284
+
285
+ Args:
286
+ url: The SSE URL of the MCP server.
287
+ header_list: Optional list of headers in Header-Name=Value format.
288
+ timeout: Timeout for SSE read operations.
289
+
290
+ Returns:
291
+ Configured SSETransport instance.
292
+ """
293
+ return _get_remote_transport(url, header_list, timeout, transport_type="sse")
294
+
295
+
296
+ def _get_stdio_transport(command: str, args: list[str], cwd: str | None, env_list: list[str] | None) -> StdioTransport:
297
+ """Create a stdio transport for running a local MCP server as a subprocess.
298
+
299
+ Args:
300
+ command: The command to execute (e.g., "uvx", "python").
301
+ args: Arguments to pass to the command.
302
+ cwd: Optional working directory for the subprocess.
303
+ env_list: Optional list of environment variables in VAR=VALUE format.
304
+
305
+ Returns:
306
+ Configured StdioTransport instance.
307
+ """
308
+ env = {}
309
+ if env_list:
310
+ for var in env_list:
311
+ key, val = var.split("=", 1)
312
+ env[key] = _interpolate_string(val)
313
+ return StdioTransport(command=command, args=args, env=env, cwd=cwd)
314
+
315
+
316
+ if __name__ == "__main__":
317
+ app()