Skip to content

API Reference

This page provides detailed API documentation for the MCP Compressor package.

Main Module

Main entry point for the MCP Compressor CLI.

This module provides the CLI interface for running the MCP Compressor proxy server, which wraps existing MCP servers and compresses their tool descriptions to reduce token consumption.

clear_oauth(all_tokens=False)

Clear stored OAuth tokens for all servers.

Removes cached OAuth tokens so the next connection will re-authenticate. By default the encryption key is preserved so new tokens are stored under the same key. Pass --all to also remove the key itself.

Source code in mcp_compressor/main.py
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
@clear_oauth_app.callback(invoke_without_command=True)
def clear_oauth(
    all_tokens: Annotated[
        bool,
        typer.Option("--all", help="Also delete the encryption key, forcing full re-authentication next run."),
    ] = False,
) -> None:
    """Clear stored OAuth tokens for all servers.

    Removes cached OAuth tokens so the next connection will re-authenticate.
    By default the encryption key is preserved so new tokens are stored under
    the same key.  Pass --all to also remove the key itself.
    """
    token_dir = _OAUTH_CONFIG_DIR / "oauth-tokens"
    key_file = _OAUTH_CONFIG_DIR / ".key"
    removed: list[str] = []

    if token_dir.exists():
        shutil.rmtree(token_dir)
        removed.append(str(token_dir))

    if all_tokens and key_file.exists():
        key_file.unlink()
        removed.append(str(key_file))

    if removed:
        print("Removed:")
        for path in removed:
            print(f"  {path}")
        # Also clear from keyring if present
        if all_tokens:
            with contextlib.suppress(Exception):
                keyring.delete_password(_KEYRING_SERVICE, _KEYRING_USERNAME)
                print(f"  keyring entry: {_KEYRING_SERVICE} / {_KEYRING_USERNAME}")
        print("OAuth credentials cleared. You will be prompted to authenticate on next connection.")
    else:
        print("No stored OAuth credentials found.")

entrypoint()

Main entrypoint for the mcp-compressor CLI.

Handles the 'clear-oauth' subcommand manually before delegating to the main Typer app, so that 'mcp-compressor ' works without a subcommand.

Source code in mcp_compressor/main.py
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
def entrypoint() -> None:
    """Main entrypoint for the mcp-compressor CLI.

    Handles the 'clear-oauth' subcommand manually before delegating to the
    main Typer app, so that 'mcp-compressor <url>' works without a subcommand.
    """
    if len(sys.argv) > 1 and sys.argv[1] == "clear-oauth":
        sys.argv = [sys.argv[0], *sys.argv[2:]]
        clear_oauth_app()
    else:
        app()

main(ctx, command_or_url_list, cwd=None, env_list=None, header_list=None, timeout=10.0, compression_level=CompressionLevel.MEDIUM, server_name=None, log_level=LogLevel.ERROR, toonify=False, cli_mode=False, just_bash=False, cli_port=None, include_tools=None, exclude_tools=None, version=False)

Run the MCP Compressor proxy server.

This is the main entry point for the CLI application. It connects to an MCP server (via stdio, HTTP, or SSE) and wraps it with a compressed tool interface.

Source code in mcp_compressor/main.py
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
@app.command()
def main(
    ctx: typer.Context,
    command_or_url_list: Annotated[
        list[str],
        typer.Argument(
            ...,
            metavar="COMMAND_OR_URL",
            help=(
                "The backend to wrap: either a remote MCP URL, a stdio command plus arguments, or an MCP config "
                "JSON string with one or more servers. Example stdio usage: uvx mcp-server-fetch"
            ),
        ),
    ],
    cwd: Annotated[
        str | None,
        typer.Option(
            ...,
            "--cwd",
            help="The working directory to use when running stdio MCP servers.",
        ),
    ] = None,
    env_list: Annotated[
        list[str] | None,
        typer.Option(
            ...,
            "--env",
            "-e",
            help=(
                "Environment variables to set when running stdio MCP servers, in the form VAR_NAME=VALUE. Can be used "
                "multiple times. Supports environment variable expansion with ${VAR_NAME} syntax."
            ),
        ),
    ] = None,
    header_list: Annotated[
        list[str] | None,
        typer.Option(
            ...,
            "--header",
            "-H",
            help=(
                "Headers to use for remote (HTTP/SSE) MCP server connections, in the form Header-Name=Header-Value. "
                "Can be use multiple times. Supports environment variable expansion with ${VAR_NAME} syntax."
            ),
        ),
    ] = None,
    timeout: Annotated[
        float,
        typer.Option(
            ...,
            "--timeout",
            "-t",
            help="The timeout in seconds for connecting to the MCP server and making requests.",
        ),
    ] = 10.0,
    compression_level: Annotated[
        CompressionLevel,
        typer.Option(
            ...,
            "--compression-level",
            "-c",
            help=("The level of compression to apply to tool the tools descriptions of the wrapped MCP server."),
            case_sensitive=False,
        ),
    ] = CompressionLevel.MEDIUM,
    server_name: Annotated[
        str | None,
        typer.Option(
            ...,
            "--server-name",
            "-n",
            help=(
                "Optional custom name to prefix the wrapper tool names (get_tool_schema, invoke_tool, list_tools). "
                "The name will be sanitized to conform to MCP tool name specifications (only A-Z, a-z, 0-9, _, -, .)."
            ),
        ),
    ] = None,
    log_level: Annotated[
        LogLevel,
        typer.Option(
            ...,
            "--log-level",
            "-l",
            help=(
                "The logging level. Used for both the MCP Compressor server and the underlying MCP server if it is a "
                "stdio server."
            ),
            case_sensitive=False,
        ),
    ] = LogLevel.ERROR,
    toonify: Annotated[
        bool,
        typer.Option(..., "--toonify", help="Convert JSON tool responses to TOON format automatically."),
    ] = False,
    cli_mode: Annotated[
        bool,
        typer.Option(
            ...,
            "--cli-mode",
            help=(
                "Start in CLI mode: expose a single help MCP tool, start a local HTTP bridge, "
                "and generate a shell script for interacting with the wrapped server via CLI. "
                "--toonify is automatically enabled in this mode."
            ),
        ),
    ] = False,
    just_bash: Annotated[
        bool,
        typer.Option(
            ...,
            "--just-bash",
            help=(
                "Start in just-bash mode: expose a single 'bash' MCP tool powered by "
                "just-bash, with all backend server tools available as custom commands. "
                "--toonify is automatically enabled in this mode."
            ),
        ),
    ] = False,
    cli_port: Annotated[
        int | None,
        typer.Option(
            ...,
            "--cli-port",
            help="Port for the local CLI bridge HTTP server (default: random free port).",
        ),
    ] = None,
    include_tools: Annotated[
        str | None,
        typer.Option(
            ...,
            "--include-tools",
            help=("Comma-separated list of wrapped server tool names to expose. If omitted, all tools are included."),
        ),
    ] = None,
    exclude_tools: Annotated[
        str | None,
        typer.Option(
            ...,
            "--exclude-tools",
            help="Comma-separated list of wrapped server tool names to hide.",
        ),
    ] = None,
    version: Annotated[
        bool,
        typer.Option(
            "--version",
            "-V",
            help="Show the version and exit.",
            callback=_version_callback,
            is_eager=True,
            expose_value=False,
        ),
    ] = False,
):
    """Run the MCP Compressor proxy server.

    This is the main entry point for the CLI application. It connects to an MCP server
    (via stdio, HTTP, or SSE) and wraps it with a compressed tool interface.
    """
    configure_logging(log_level)

    resolved_server_name = server_name
    try:
        parsed_config = _parse_mcp_config_json(command_or_url_list)
    except ValueError as exc:
        raise typer.BadParameter(str(exc), param_hint="'COMMAND_OR_URL'") from exc

    if parsed_config is not None:
        _check_conflicting_config_options(ctx)
        resolved_server_name = _resolve_config_server_name(parsed_config, server_name, cli_mode)

    if (
        (cli_mode or just_bash)
        and resolved_server_name is None
        and (parsed_config is None or len(parsed_config.mcpServers) == 1)
    ):
        raise typer.BadParameter(
            "--server-name is required when using --cli-mode or --just-bash.", param_hint="'--server-name'"
        )
    if compression_level == CompressionLevel.MAX and resolved_server_name is None:
        raise typer.BadParameter(
            "--server-name is required when using --compression-level=max.", param_hint="'--server-name'"
        )

    if threading.current_thread() is threading.main_thread():
        shutting_down = False

        def _handle_interrupt(signum: int, frame: object) -> None:
            nonlocal shutting_down
            if shutting_down:
                logger.debug("Ignoring additional interrupt signal during shutdown")
                return
            shutting_down = True
            logger.info("Server stopped")
            # Terminate child processes (stdio backend server) to avoid zombies
            with contextlib.suppress(Exception):
                current = psutil.Process()
                for child in current.children(recursive=True):
                    with contextlib.suppress(Exception):
                        child.terminate()
            # os._exit(0) bypasses daemon thread join hangs (both stdio stdin-read
            # threads and HTTP transport threads can block interpreter shutdown)
            os._exit(0)

        signal.signal(signal.SIGINT, _handle_interrupt)
        signal.signal(signal.SIGTERM, _handle_interrupt)

    asyncio.run(
        _async_main(
            command_or_url_list=command_or_url_list,
            cwd=cwd,
            env_list=env_list,
            header_list=header_list,
            timeout=timeout,
            compression_level=compression_level,
            server_name=resolved_server_name,
            log_level=log_level,
            toonify=toonify or cli_mode or just_bash,
            cli_mode=cli_mode,
            just_bash=just_bash,
            cli_port=cli_port,
            include_tools=_parse_tool_name_list(include_tools),
            exclude_tools=_parse_tool_name_list(exclude_tools),
        )
    )

Tools Module

Tool compression helpers built on FastMCP v3 transforms.

This module provides a transform-first implementation that replaces the visible tool catalog with a compressed wrapper interface while keeping backend tools available for passthrough access.

CompressedTools

Bases: CatalogTransform

Transform that replaces the tool catalog with compressed wrapper tools.

In normal mode it exposes two or three public wrapper tools: - get_tool_schema: Retrieves the full schema for a specific tool - invoke_tool: Executes a tool with the provided arguments - list_tools: (optional) Lists all available tools with brief descriptions (only if compression level is MAX)

It also exposes a resource (compressor://uncompressed-tools) that returns the upstream server's original list_tools payload in machine-readable JSON form.

In CLI mode it exposes a single help tool (_help) instead of the wrapper tool catalog.

Source code in mcp_compressor/tools.py
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
class CompressedTools(CatalogTransform):
    """Transform that replaces the tool catalog with compressed wrapper tools.

    In normal mode it exposes two or three public wrapper tools:
    - get_tool_schema: Retrieves the full schema for a specific tool
    - invoke_tool: Executes a tool with the provided arguments
    - list_tools: (optional) Lists all available tools with brief descriptions (only if compression level is MAX)

    It also exposes a resource (``compressor://uncompressed-tools``) that returns the upstream server's original
    list_tools payload in machine-readable JSON form.

    In CLI mode it exposes a single help tool (<server_name>_help) instead of the wrapper tool catalog.
    """

    def __init__(
        self,
        proxy_server: FastMCP,
        compression_level: CompressionLevel,
        server_name: str | None = None,
        toonify: bool = False,
        cli_mode: bool = False,
        cli_name: str | None = None,
        include_tools: Sequence[str] | None = None,
        exclude_tools: Sequence[str] | None = None,
    ) -> None:
        super().__init__()
        self._proxy_server = proxy_server
        self._compression_level = compression_level
        self._tool_name_prefix = f"{server_name}_" if server_name else ""
        self._server_description = f"the {server_name} toolset" if server_name else "this toolset"
        self._toonify = toonify
        self._cli_mode = cli_mode
        self._cli_name = cli_name or (server_name or "mcp")
        self._include_tools = set(include_tools or [])
        self._exclude_tools = set(exclude_tools or [])
        self._cached_backend_tools: dict[str, Tool] | None = None
        self._tool_cache_lock: asyncio.Lock = asyncio.Lock()
        self._help_tool_name = sanitize_tool_name(f"{server_name}_help" if server_name else "help")
        self._get_schema_tool_name = sanitize_tool_name(f"{self._tool_name_prefix}get_tool_schema")
        self._invoke_tool_name = sanitize_tool_name(f"{self._tool_name_prefix}invoke_tool")
        self._invoke_tool_alias_name = sanitize_tool_name("invoke_tool")
        self._list_tools_name = sanitize_tool_name(f"{self._tool_name_prefix}list_tools")
        self._uncompressed_tools_resource_uri = (
            f"compressor://{server_name}/uncompressed-tools" if server_name else "compressor://uncompressed-tools"
        )

    @property
    def invoke_tool_names(self) -> set[str]:
        """All invoke_tool wrapper names, including the hidden alias."""
        return {self._invoke_tool_name, self._invoke_tool_alias_name}

    def should_toonify_tool(self, tool_name: str) -> bool:
        """Return whether direct calls to a tool should be toonified."""
        if not self._toonify:
            return False
        return tool_name not in self._wrapper_tool_names()

    def _wrapper_tool_names(self) -> set[str]:
        if self._cli_mode:
            return {self._help_tool_name}
        tool_names = {self._get_schema_tool_name, self._invoke_tool_name, self._invoke_tool_alias_name}
        if self._compression_level == CompressionLevel.MAX:
            tool_names.add(self._list_tools_name)
        return tool_names

    async def configure_server(self) -> None:
        """Attach the transform and any small compatibility middleware to the server."""
        await self._configure_backend_tool_visibility()
        self._proxy_server.add_transform(self)
        if not self._cli_mode:
            self._proxy_server.add_middleware(InvokeToolCompatibilityMiddleware(self))

    async def _configure_backend_tool_visibility(self) -> None:
        """Apply FastMCP visibility rules for backend tool allow/deny filtering."""
        all_tools = await self._proxy_server.list_tools(run_middleware=False)
        filters_applied = False
        if self._include_tools:
            all_tool_names = {tool.name for tool in all_tools}
            names_to_disable = all_tool_names - self._include_tools
            if names_to_disable:
                self._proxy_server.disable(names=names_to_disable, components={"tool"})
                filters_applied = True
        if self._exclude_tools:
            self._proxy_server.disable(names=self._exclude_tools, components={"tool"})
            filters_applied = True
        # Warm the tool cache after visibility rules are applied so the cache
        # reflects the filtered tool set that clients will actually see.
        # Re-fetch only when filters changed the visible set; otherwise reuse the
        # list we already have (avoids a redundant backend round-trip).
        if filters_applied:
            visible_tools = await self._proxy_server.list_tools(run_middleware=False)
        else:
            visible_tools = all_tools
        self._cached_backend_tools = {tool.name: tool for tool in visible_tools}

    async def transform_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]:
        """Replace the visible tool catalog with compressed wrapper tools."""
        effective_tools: Sequence[Tool] = (
            list(self._cached_backend_tools.values()) if self._cached_backend_tools is not None else tools
        )
        if self._cli_mode:
            return [self._make_help_tool(await self._build_cli_description_from(effective_tools))]

        visible_tools = [
            self._make_get_schema_tool(
                await self._get_tool_descriptions_from(effective_tools, self._compression_level)
            ),
            self._make_invoke_tool(self._invoke_tool_name),
        ]
        if self._compression_level == CompressionLevel.MAX:
            visible_tools.append(self._make_list_tools_tool())
        return visible_tools

    async def get_tool(self, name: str, call_next: GetToolNext, *, version: Any | None = None) -> Tool | None:
        """Return synthetic wrapper tools and delegate backend tool lookups unchanged."""
        if self._cli_mode and name == self._help_tool_name:
            return self._make_help_tool()
        if name == self._get_schema_tool_name:
            return self._make_get_schema_tool()
        if name in self.invoke_tool_names:
            return self._make_invoke_tool(name)
        if name == self._list_tools_name and self._compression_level == CompressionLevel.MAX:
            return self._make_list_tools_tool()
        return await call_next(name, version=version)

    async def transform_resources(self, resources: Sequence[Resource]) -> Sequence[Resource]:
        """Append the synthetic uncompressed-tools resource in normal mode."""
        if self._cli_mode:
            return resources
        return [*resources, self._make_uncompressed_tools_resource()]

    async def get_resource(
        self, uri: str, call_next: GetResourceNext, *, version: Any | None = None
    ) -> Resource | None:
        """Return the synthetic resource when requested, else delegate."""
        if not self._cli_mode and uri == self._uncompressed_tools_resource_uri:
            return self._make_uncompressed_tools_resource()
        return await call_next(uri, version=version)

    async def list_tools_tool(self, ctx: Context | None = None) -> str:
        """List all available tools in {server_description}."""
        if ctx is None:
            async with Context(fastmcp=self._proxy_server) as active_ctx:
                return await self.list_tools_tool(active_ctx)
        backend_tools = await self._get_backend_tools(ctx)
        return await self._get_tool_descriptions_from(list(backend_tools.values()), CompressionLevel.MEDIUM)

    async def get_tool_schema(self, tool_name: str, ctx: Context | None = None) -> str:
        """Get the input schema for a specific tool from {server_description}."""
        if ctx is None:
            async with Context(fastmcp=self._proxy_server) as active_ctx:
                return await self.get_tool_schema(tool_name, active_ctx)
        tool = await self._get_backend_tool(ctx, tool_name)
        tool_description = self._format_tool_description(tool, CompressionLevel.LOW)
        return tool_description + "\n\n" + json.dumps(tool.parameters, indent=2)

    async def invoke_tool(
        self,
        tool_name: str,
        tool_input: dict[str, Any] | None = None,
        quiet: bool = False,
        ctx: Context | None = None,
    ) -> ToolResult:
        """Invoke a backend tool from the compressed catalog."""
        if ctx is None:
            async with Context(fastmcp=self._proxy_server) as active_ctx:
                return await self.invoke_tool(tool_name, tool_input, quiet, active_ctx)
        tool = await self._get_backend_tool(ctx, tool_name)
        try:
            if isinstance(tool, ProxyTool):
                tool_result = await tool.run(tool_input or {}, context=ctx)
            else:
                tool_result = await tool.run(tool_input or {})
        except ValidationError as exc:
            raise ToolError(await self._format_validation_error(ctx, tool_name, str(exc))) from exc
        except ToolError as exc:
            if self._is_validation_error_message(str(exc)):
                raise ToolError(await self._format_validation_error(ctx, tool_name, str(exc))) from exc
            raise
        if self._toonify:
            tool_result = self._toonify_tool_result(tool_result)
        if not quiet:
            return tool_result
        if len(tool_result.content) == 1 and isinstance(tool_result.content[0], TextContent):
            return_text = tool_result.content[0].text
            if len(return_text) < QUIET_MODE_THRESHOLD:
                return tool_result
            preview_length = QUIET_MODE_THRESHOLD // 2
            return_text = (
                return_text[:preview_length]
                + "\n...\n(truncated due to quiet mode)\n...\n"
                + return_text[-preview_length:]
            )
        else:
            return_text = f"Successfully executed tool '{tool.name}' without output."
        return ToolResult(content=[TextContent(type="text", text=return_text)])

    async def list_uncompressed_tools(self, ctx: Context | None = None) -> str:
        """Return the upstream server's original list_tools payload as JSON."""
        if ctx is None:
            async with Context(fastmcp=self._proxy_server) as active_ctx:
                return await self.list_uncompressed_tools(active_ctx)
        backend_tools = await self._get_backend_tools(ctx)
        return json.dumps([tool.to_mcp_tool().model_dump(mode="json") for tool in backend_tools.values()], indent=2)

    async def get_backend_tools(self) -> dict[str, Tool]:
        """Return the current backend tool catalog keyed by name."""
        async with Context(fastmcp=self._proxy_server) as ctx:
            return await self._get_backend_tools(ctx)

    async def get_compression_stats(self) -> dict[str, Any]:
        """Get statistics about the compression of tool descriptions."""
        backend_tools = await self.get_backend_tools()
        original_tool_count = len(backend_tools)
        original_schema_size = sum(
            len(json.dumps(tool.parameters)) + len(json.dumps(tool.output_schema)) + len(tool.description or "")
            for tool in backend_tools.values()
        )
        compressed_schema_sizes: dict[CompressionLevel | str, int] = {}
        for compression_level in [
            CompressionLevel.LOW,
            CompressionLevel.MEDIUM,
            CompressionLevel.HIGH,
            CompressionLevel.MAX,
        ]:
            compressed_schema_sizes[compression_level] = sum(
                len(self._format_tool_description(tool, compression_level)) for tool in backend_tools.values()
            )
        compressed_schema_sizes["cli"] = len(await self._build_cli_description())
        return {
            "original_tool_count": original_tool_count,
            "compressed_tool_count": original_tool_count,
            "original_schema_size": original_schema_size,
            "compressed_schema_sizes": compressed_schema_sizes,
        }

    async def _build_cli_description(self) -> str:
        """Build the full help description for CLI mode."""
        backend_tools = await self.get_backend_tools()
        return await self._build_cli_description_from(list(backend_tools.values()))

    async def _build_cli_description_from(self, tools: Sequence[Tool]) -> str:
        _, on_path = find_script_dir()
        return build_help_tool_description(self._cli_name, self._server_description, list(tools), on_path=on_path)

    async def _get_tool_descriptions_from(self, tools: Sequence[Tool], compression_level: CompressionLevel) -> str:
        """Generate formatted tool descriptions for a set of tools."""
        if compression_level == CompressionLevel.MAX:
            return ""
        return "\n".join(self._format_tool_description(tool, compression_level) for tool in tools)

    async def _get_backend_tools(self, ctx: Context) -> dict[str, Tool]:
        """Retrieve backend tools from cache, fetching from backend on first call.

        The tool catalog is cached on first access (normally at startup via ``configure_server()``) so subsequent
        operations — invoke_tool, get_tool_schema, list_uncompressed_tools, etc. — do not make a live backend call every
        time.  Use ``invalidate_tool_cache()`` to force a refresh if the backend tool catalog changes at runtime.
        """
        if self._cached_backend_tools is not None:
            return self._cached_backend_tools
        async with self._tool_cache_lock:
            # Double-checked locking: another coroutine may have filled the cache
            # while we waited for the lock.
            if self._cached_backend_tools is not None:
                return self._cached_backend_tools
            logger.debug("Tool cache is empty; fetching backend tool catalog.")
            self._cached_backend_tools = {
                tool.name: tool for tool in await self.get_tool_catalog(ctx, run_middleware=False)
            }
        return self._cached_backend_tools

    def invalidate_tool_cache(self) -> None:
        """Invalidate the cached backend tool catalog.

        The next call to any method that needs the backend tool list will
        re-fetch it from the backend server.
        """
        self._cached_backend_tools = None

    async def _get_backend_tool(self, ctx: Context, tool_name: str) -> Tool:
        """Retrieve a specific backend tool from the proxy server."""
        backend_tools = await self._get_backend_tools(ctx)
        tool = backend_tools.get(tool_name)
        if tool is None:
            available_tools = tuple(sorted(backend_tools))
            logger.error(f"Tool '{tool_name}' not found in backend tools. Available tools: {available_tools}")
            raise ToolNotFoundError(tool_name, available_tools)
        return tool

    async def _format_validation_error(self, ctx: Context, tool_name: str, error_message: str) -> str:
        """Format a validation failure with the tool schema for client guidance."""
        tool_schema = await self.get_tool_schema(tool_name, ctx)
        return (
            f"Tool '{tool_name}' input validation failed: {error_message}\n\n"
            f"Here is the result of get_tool_schema('{tool_name}'):\n{tool_schema}"
        )

    def _make_help_tool(self, description: str | None = None) -> Tool:
        async def help_tool() -> str:
            return await self._build_cli_description()

        return Tool.from_function(
            help_tool,
            name=self._help_tool_name,
            description=description or f"Get help for the '{self._cli_name}' CLI. Lists all available subcommands.",
        )

    def _make_get_schema_tool(self, tool_descriptions: str | None = None) -> Tool:
        description = (
            f"Get the input schema for a specific tool from {self._server_description}.\n\n"
            f"Available tools are:\n{tool_descriptions or '{tool_descriptions}'}"
        )
        return Tool.from_function(self.get_tool_schema, name=self._get_schema_tool_name, description=description)

    def _make_invoke_tool(self, tool_name: str) -> Tool:
        description = f"Invoke a tool from {self._server_description}."
        return Tool.from_function(self.invoke_tool, name=tool_name, description=description)

    def _make_list_tools_tool(self) -> Tool:
        description = f"List all available tools in {self._server_description}."
        return Tool.from_function(self.list_tools_tool, name=self._list_tools_name, description=description)

    def _make_uncompressed_tools_resource(self) -> Resource:
        return Resource.from_function(
            self.list_uncompressed_tools,
            uri=self._uncompressed_tools_resource_uri,
            description="The upstream server's original uncompressed tool list as JSON.",
            mime_type="application/json",
        )

    def _format_tool_description(self, tool: Tool, compression_level: CompressionLevel) -> str:
        """Format a single tool's description based on the compression level."""
        tool_name = tool.name
        if compression_level == CompressionLevel.MAX:
            return f"<tool>{tool_name}</tool>"
        tool_arg_names = list(tool.parameters.get("properties", {}))
        tool_description = (tool.description or "").strip()
        if compression_level == CompressionLevel.HIGH:
            tool_description = ""
        elif tool_description and compression_level == CompressionLevel.MEDIUM:
            tool_description = tool_description.splitlines()[0].split(".")[0]
        tool_description = ": " + tool_description if tool_description else ""
        return f"<tool>{tool_name}({', '.join(tool_arg_names)}){tool_description}</tool>"

    def _is_validation_error_message(self, error_message: str) -> bool:
        """Return whether a tool error message appears to be an input validation failure."""
        lowered_message = error_message.lower()
        return "validation error" in lowered_message or "missing required argument" in lowered_message

    def _toonify_tool_result(self, tool_result: ToolResult) -> ToolResult:
        """Convert JSON text content blocks in a tool result to TOON format."""
        converted_content: list[ContentBlock] = []
        content_changed = False
        for content_block in tool_result.content:
            if isinstance(content_block, TextContent):
                converted_text = self._toonify_json_text(content_block.text)
                if converted_text != content_block.text:
                    content_changed = True
                    converted_content.append(TextContent(type="text", text=converted_text))
                    continue
            converted_content.append(content_block)
        if not content_changed:
            return tool_result
        return ToolResult(
            content=converted_content,
            structured_content=tool_result.structured_content,
            meta=tool_result.meta,
        )

    def _toonify_json_text(self, text: str) -> str:
        """Convert a JSON object/array string to TOON; pass through other text unchanged."""
        try:
            parsed = json.loads(text)
        except json.JSONDecodeError:
            return text
        if not isinstance(parsed, dict | list):
            return text
        return toons.dumps(parsed)

invoke_tool_names property

All invoke_tool wrapper names, including the hidden alias.

configure_server() async

Attach the transform and any small compatibility middleware to the server.

Source code in mcp_compressor/tools.py
153
154
155
156
157
158
async def configure_server(self) -> None:
    """Attach the transform and any small compatibility middleware to the server."""
    await self._configure_backend_tool_visibility()
    self._proxy_server.add_transform(self)
    if not self._cli_mode:
        self._proxy_server.add_middleware(InvokeToolCompatibilityMiddleware(self))

get_backend_tools() async

Return the current backend tool catalog keyed by name.

Source code in mcp_compressor/tools.py
293
294
295
296
async def get_backend_tools(self) -> dict[str, Tool]:
    """Return the current backend tool catalog keyed by name."""
    async with Context(fastmcp=self._proxy_server) as ctx:
        return await self._get_backend_tools(ctx)

get_compression_stats() async

Get statistics about the compression of tool descriptions.

Source code in mcp_compressor/tools.py
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
async def get_compression_stats(self) -> dict[str, Any]:
    """Get statistics about the compression of tool descriptions."""
    backend_tools = await self.get_backend_tools()
    original_tool_count = len(backend_tools)
    original_schema_size = sum(
        len(json.dumps(tool.parameters)) + len(json.dumps(tool.output_schema)) + len(tool.description or "")
        for tool in backend_tools.values()
    )
    compressed_schema_sizes: dict[CompressionLevel | str, int] = {}
    for compression_level in [
        CompressionLevel.LOW,
        CompressionLevel.MEDIUM,
        CompressionLevel.HIGH,
        CompressionLevel.MAX,
    ]:
        compressed_schema_sizes[compression_level] = sum(
            len(self._format_tool_description(tool, compression_level)) for tool in backend_tools.values()
        )
    compressed_schema_sizes["cli"] = len(await self._build_cli_description())
    return {
        "original_tool_count": original_tool_count,
        "compressed_tool_count": original_tool_count,
        "original_schema_size": original_schema_size,
        "compressed_schema_sizes": compressed_schema_sizes,
    }

get_resource(uri, call_next, *, version=None) async

Return the synthetic resource when requested, else delegate.

Source code in mcp_compressor/tools.py
219
220
221
222
223
224
225
async def get_resource(
    self, uri: str, call_next: GetResourceNext, *, version: Any | None = None
) -> Resource | None:
    """Return the synthetic resource when requested, else delegate."""
    if not self._cli_mode and uri == self._uncompressed_tools_resource_uri:
        return self._make_uncompressed_tools_resource()
    return await call_next(uri, version=version)

get_tool(name, call_next, *, version=None) async

Return synthetic wrapper tools and delegate backend tool lookups unchanged.

Source code in mcp_compressor/tools.py
201
202
203
204
205
206
207
208
209
210
211
async def get_tool(self, name: str, call_next: GetToolNext, *, version: Any | None = None) -> Tool | None:
    """Return synthetic wrapper tools and delegate backend tool lookups unchanged."""
    if self._cli_mode and name == self._help_tool_name:
        return self._make_help_tool()
    if name == self._get_schema_tool_name:
        return self._make_get_schema_tool()
    if name in self.invoke_tool_names:
        return self._make_invoke_tool(name)
    if name == self._list_tools_name and self._compression_level == CompressionLevel.MAX:
        return self._make_list_tools_tool()
    return await call_next(name, version=version)

get_tool_schema(tool_name, ctx=None) async

Get the input schema for a specific tool from {server_description}.

Source code in mcp_compressor/tools.py
235
236
237
238
239
240
241
242
async def get_tool_schema(self, tool_name: str, ctx: Context | None = None) -> str:
    """Get the input schema for a specific tool from {server_description}."""
    if ctx is None:
        async with Context(fastmcp=self._proxy_server) as active_ctx:
            return await self.get_tool_schema(tool_name, active_ctx)
    tool = await self._get_backend_tool(ctx, tool_name)
    tool_description = self._format_tool_description(tool, CompressionLevel.LOW)
    return tool_description + "\n\n" + json.dumps(tool.parameters, indent=2)

invalidate_tool_cache()

Invalidate the cached backend tool catalog.

The next call to any method that needs the backend tool list will re-fetch it from the backend server.

Source code in mcp_compressor/tools.py
359
360
361
362
363
364
365
def invalidate_tool_cache(self) -> None:
    """Invalidate the cached backend tool catalog.

    The next call to any method that needs the backend tool list will
    re-fetch it from the backend server.
    """
    self._cached_backend_tools = None

invoke_tool(tool_name, tool_input=None, quiet=False, ctx=None) async

Invoke a backend tool from the compressed catalog.

Source code in mcp_compressor/tools.py
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
async def invoke_tool(
    self,
    tool_name: str,
    tool_input: dict[str, Any] | None = None,
    quiet: bool = False,
    ctx: Context | None = None,
) -> ToolResult:
    """Invoke a backend tool from the compressed catalog."""
    if ctx is None:
        async with Context(fastmcp=self._proxy_server) as active_ctx:
            return await self.invoke_tool(tool_name, tool_input, quiet, active_ctx)
    tool = await self._get_backend_tool(ctx, tool_name)
    try:
        if isinstance(tool, ProxyTool):
            tool_result = await tool.run(tool_input or {}, context=ctx)
        else:
            tool_result = await tool.run(tool_input or {})
    except ValidationError as exc:
        raise ToolError(await self._format_validation_error(ctx, tool_name, str(exc))) from exc
    except ToolError as exc:
        if self._is_validation_error_message(str(exc)):
            raise ToolError(await self._format_validation_error(ctx, tool_name, str(exc))) from exc
        raise
    if self._toonify:
        tool_result = self._toonify_tool_result(tool_result)
    if not quiet:
        return tool_result
    if len(tool_result.content) == 1 and isinstance(tool_result.content[0], TextContent):
        return_text = tool_result.content[0].text
        if len(return_text) < QUIET_MODE_THRESHOLD:
            return tool_result
        preview_length = QUIET_MODE_THRESHOLD // 2
        return_text = (
            return_text[:preview_length]
            + "\n...\n(truncated due to quiet mode)\n...\n"
            + return_text[-preview_length:]
        )
    else:
        return_text = f"Successfully executed tool '{tool.name}' without output."
    return ToolResult(content=[TextContent(type="text", text=return_text)])

list_tools_tool(ctx=None) async

List all available tools in {server_description}.

Source code in mcp_compressor/tools.py
227
228
229
230
231
232
233
async def list_tools_tool(self, ctx: Context | None = None) -> str:
    """List all available tools in {server_description}."""
    if ctx is None:
        async with Context(fastmcp=self._proxy_server) as active_ctx:
            return await self.list_tools_tool(active_ctx)
    backend_tools = await self._get_backend_tools(ctx)
    return await self._get_tool_descriptions_from(list(backend_tools.values()), CompressionLevel.MEDIUM)

list_uncompressed_tools(ctx=None) async

Return the upstream server's original list_tools payload as JSON.

Source code in mcp_compressor/tools.py
285
286
287
288
289
290
291
async def list_uncompressed_tools(self, ctx: Context | None = None) -> str:
    """Return the upstream server's original list_tools payload as JSON."""
    if ctx is None:
        async with Context(fastmcp=self._proxy_server) as active_ctx:
            return await self.list_uncompressed_tools(active_ctx)
    backend_tools = await self._get_backend_tools(ctx)
    return json.dumps([tool.to_mcp_tool().model_dump(mode="json") for tool in backend_tools.values()], indent=2)

should_toonify_tool(tool_name)

Return whether direct calls to a tool should be toonified.

Source code in mcp_compressor/tools.py
139
140
141
142
143
def should_toonify_tool(self, tool_name: str) -> bool:
    """Return whether direct calls to a tool should be toonified."""
    if not self._toonify:
        return False
    return tool_name not in self._wrapper_tool_names()

transform_resources(resources) async

Append the synthetic uncompressed-tools resource in normal mode.

Source code in mcp_compressor/tools.py
213
214
215
216
217
async def transform_resources(self, resources: Sequence[Resource]) -> Sequence[Resource]:
    """Append the synthetic uncompressed-tools resource in normal mode."""
    if self._cli_mode:
        return resources
    return [*resources, self._make_uncompressed_tools_resource()]

transform_tools(tools) async

Replace the visible tool catalog with compressed wrapper tools.

Source code in mcp_compressor/tools.py
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
async def transform_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]:
    """Replace the visible tool catalog with compressed wrapper tools."""
    effective_tools: Sequence[Tool] = (
        list(self._cached_backend_tools.values()) if self._cached_backend_tools is not None else tools
    )
    if self._cli_mode:
        return [self._make_help_tool(await self._build_cli_description_from(effective_tools))]

    visible_tools = [
        self._make_get_schema_tool(
            await self._get_tool_descriptions_from(effective_tools, self._compression_level)
        ),
        self._make_invoke_tool(self._invoke_tool_name),
    ]
    if self._compression_level == CompressionLevel.MAX:
        visible_tools.append(self._make_list_tools_tool())
    return visible_tools

InvokeToolCompatibilityMiddleware

Bases: Middleware

Small compatibility shim for flattened invoke_tool arguments and direct toonify.

Source code in mcp_compressor/tools.py
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
class InvokeToolCompatibilityMiddleware(Middleware):
    """Small compatibility shim for flattened invoke_tool arguments and direct toonify."""

    def __init__(self, compressed_tools: CompressedTools) -> None:
        self._compressed_tools = compressed_tools

    async def on_call_tool(
        self,
        context: MiddlewareContext[CallToolRequestParams],
        call_next: CallNext[CallToolRequestParams, ToolResult],
    ) -> ToolResult:
        tool_name = context.message.name
        tool_args = context.message.arguments or {}
        if tool_name in self._compressed_tools.invoke_tool_names and "tool_name" in tool_args:
            tool_input_raw = tool_args.get("tool_input")
            if isinstance(tool_input_raw, dict):
                # Structured call: {tool_name: "foo", tool_input: {...}}
                # tool_input may be an empty dict for zero-argument tools — that is valid.
                tool_input = tool_input_raw
            else:
                # tool_input is None or absent: check for flattened args
                # e.g. {tool_name: "add", "a": 5, "b": 3} (no tool_input wrapper)
                # Exclude meta-keys so they don't leak into the backend tool args.
                flat_input = {k: v for k, v in tool_args.items() if k not in {"tool_name", "quiet", "tool_input"}}
                tool_input = flat_input  # may be empty dict for zero-argument tools

            return await self._compressed_tools.invoke_tool(
                tool_name=tool_args["tool_name"],
                tool_input=tool_input,
                quiet=tool_args.get("quiet", False),
                ctx=context.fastmcp_context,
            )

        result = await call_next(context)
        if self._compressed_tools.should_toonify_tool(tool_name):
            return self._compressed_tools._toonify_tool_result(result)
        return result

ToolNotFoundError

Bases: ValueError

Exception raised when a requested tool is not found in the backend MCP server.

Source code in mcp_compressor/tools.py
39
40
41
42
43
44
45
46
class ToolNotFoundError(ValueError):
    """Exception raised when a requested tool is not found in the backend MCP server."""

    def __init__(self, tool_name: str, available_tools: Sequence[str]) -> None:
        self.tool_name = tool_name
        self.available_tools = tuple(available_tools)
        available_tools_text = ", ".join(self.available_tools) if self.available_tools else "(none)"
        super().__init__(f"Tool '{tool_name}' not found in backend MCP server. Available tools: {available_tools_text}")

sanitize_tool_name(name)

Sanitize a tool name to conform to MCP tool name specifications.

Source code in mcp_compressor/tools.py
468
469
470
471
472
473
def sanitize_tool_name(name: str) -> str:
    """Sanitize a tool name to conform to MCP tool name specifications."""
    sanitized = re.sub(r"[^A-Za-z0-9_\-.]", "_", name).lower()
    if not sanitized:
        raise ValueError("Tool name must contain at least one valid character after sanitization.")
    return sanitized[:128]

Types Module

Type definitions for MCP Compressor.

This module defines enumerations and type aliases used throughout the MCP Compressor package.

ToolResultBlock = str | Audio | File | Image module-attribute

Type alias for possible tool result content blocks (text, audio, file, or image).

TransportType = ClientTransport module-attribute

Type alias for supported MCP client transports.

CompressionLevel

Bases: str, Enum

Compression levels for tool descriptions in the wrapped MCP server.

Higher compression levels provide less verbose tool descriptions, reducing token usage. Lower compression levels provide more detailed information upfront.

Attributes:

Name Type Description
MAX

Maximum compression - exposes a list_tools function for viewing all tools.

HIGH

High compression - only tool names and parameter names, no descriptions.

MEDIUM

Medium compression - first sentence of tool descriptions only.

LOW

Low compression - complete tool descriptions and schemas.

Source code in mcp_compressor/types.py
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class CompressionLevel(str, Enum):
    """Compression levels for tool descriptions in the wrapped MCP server.

    Higher compression levels provide less verbose tool descriptions, reducing token usage.
    Lower compression levels provide more detailed information upfront.

    Attributes:
        MAX: Maximum compression - exposes a list_tools function for viewing all tools.
        HIGH: High compression - only tool names and parameter names, no descriptions.
        MEDIUM: Medium compression - first sentence of tool descriptions only.
        LOW: Low compression - complete tool descriptions and schemas.
    """

    MAX = "max"
    HIGH = "high"
    MEDIUM = "medium"
    LOW = "low"

LogLevel

Bases: str, Enum

Logging levels for the MCP Compressor server and wrapped MCP servers.

Source code in mcp_compressor/types.py
13
14
15
16
17
18
19
20
class LogLevel(str, Enum):
    """Logging levels for the MCP Compressor server and wrapped MCP servers."""

    DEBUG = "DEBUG"
    INFO = "INFO"
    WARNING = "WARNING"
    ERROR = "ERROR"
    CRITICAL = "CRITICAL"