longrun-mcp-proxy 1.2.0__tar.gz

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.
Files changed (26) hide show
  1. longrun_mcp_proxy-1.2.0/PKG-INFO +6 -0
  2. longrun_mcp_proxy-1.2.0/README.md +172 -0
  3. longrun_mcp_proxy-1.2.0/pyproject.toml +22 -0
  4. longrun_mcp_proxy-1.2.0/setup.cfg +4 -0
  5. longrun_mcp_proxy-1.2.0/src/longrun_mcp_proxy/__init__.py +23 -0
  6. longrun_mcp_proxy-1.2.0/src/longrun_mcp_proxy/__main__.py +5 -0
  7. longrun_mcp_proxy-1.2.0/src/longrun_mcp_proxy/cli.py +174 -0
  8. longrun_mcp_proxy-1.2.0/src/longrun_mcp_proxy/extras/__init__.py +1 -0
  9. longrun_mcp_proxy-1.2.0/src/longrun_mcp_proxy/extras/xcode_approver.py +51 -0
  10. longrun_mcp_proxy-1.2.0/src/longrun_mcp_proxy/extras/xcode_defaults.py +56 -0
  11. longrun_mcp_proxy-1.2.0/src/longrun_mcp_proxy/job_store.py +52 -0
  12. longrun_mcp_proxy-1.2.0/src/longrun_mcp_proxy/middleware.py +48 -0
  13. longrun_mcp_proxy-1.2.0/src/longrun_mcp_proxy/output_filter.py +54 -0
  14. longrun_mcp_proxy-1.2.0/src/longrun_mcp_proxy/proxy_persistent.py +393 -0
  15. longrun_mcp_proxy-1.2.0/src/longrun_mcp_proxy/proxy_stdio.py +295 -0
  16. longrun_mcp_proxy-1.2.0/src/longrun_mcp_proxy.egg-info/PKG-INFO +6 -0
  17. longrun_mcp_proxy-1.2.0/src/longrun_mcp_proxy.egg-info/SOURCES.txt +24 -0
  18. longrun_mcp_proxy-1.2.0/src/longrun_mcp_proxy.egg-info/dependency_links.txt +1 -0
  19. longrun_mcp_proxy-1.2.0/src/longrun_mcp_proxy.egg-info/entry_points.txt +2 -0
  20. longrun_mcp_proxy-1.2.0/src/longrun_mcp_proxy.egg-info/requires.txt +1 -0
  21. longrun_mcp_proxy-1.2.0/src/longrun_mcp_proxy.egg-info/top_level.txt +1 -0
  22. longrun_mcp_proxy-1.2.0/tests/test_cli.py +47 -0
  23. longrun_mcp_proxy-1.2.0/tests/test_job_store.py +40 -0
  24. longrun_mcp_proxy-1.2.0/tests/test_middleware.py +85 -0
  25. longrun_mcp_proxy-1.2.0/tests/test_output_filter.py +34 -0
  26. longrun_mcp_proxy-1.2.0/tests/test_proxy_stdio.py +41 -0
@@ -0,0 +1,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: longrun-mcp-proxy
3
+ Version: 1.2.0
4
+ Summary: MCP proxy with async wrapper for long-running tools and persistent SSE mode
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: fastmcp>=2.0.0
@@ -0,0 +1,172 @@
1
+ # LongRunMCPProxy
2
+
3
+ MCP proxy that wraps downstream MCP servers and converts long-running tools into an async start/poll pattern — so they never hit the client's timeout (e.g. Cursor's 60-second limit).
4
+
5
+ ## Problem
6
+
7
+ AI coding agents (Cursor, Claude Code, VS Code Copilot) have built-in timeouts for MCP tool calls. Operations like Xcode builds or test runs can take minutes, causing the agent to drop the connection and lose results.
8
+
9
+ ## Solution
10
+
11
+ LongRunMCPProxy sits between the AI agent and the MCP server. It:
12
+
13
+ 1. Discovers downstream tools on startup
14
+ 2. Auto-detects known long-running tools (or uses an explicit list)
15
+ 3. Wraps them in an async pattern: `tool()` → returns `job_id` instantly, agent polls `check_job(job_id)` for the result
16
+ 4. Passes all other tools through unchanged
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ # Install globally (recommended — instant startup)
22
+ uv tool install "git+https://github.com/maximtart/LongRunMCPProxy.git@v1.1.0"
23
+
24
+ # Or run without installing
25
+ uvx --from "git+https://github.com/maximtart/LongRunMCPProxy.git@v1.1.0" longrun-mcp-proxy --help
26
+ ```
27
+
28
+ ### Updating
29
+
30
+ ```bash
31
+ uv tool install "git+https://github.com/maximtart/LongRunMCPProxy.git@vX.Y.Z"
32
+ ```
33
+
34
+ ## Modes
35
+
36
+ ### stdio (recommended)
37
+
38
+ For most MCP servers. Communicates with the AI agent via stdin/stdout.
39
+
40
+ ```bash
41
+ longrun-mcp-proxy stdio -- xcrun mcpbridge
42
+ longrun-mcp-proxy stdio -- npx -y xcodebuildmcp@latest mcp
43
+ ```
44
+
45
+ ### persistent
46
+
47
+ Starts an SSE server on a local port. Use when the downstream server requires `outputSchema` or when multiple clients need to connect.
48
+
49
+ ```bash
50
+ longrun-mcp-proxy persistent --port 8421 -- xcrun mcpbridge
51
+ ```
52
+
53
+ ## Auto-detection (v1.1.0+)
54
+
55
+ When `--async-tools` is not specified, the proxy automatically detects known long-running tools from the downstream server:
56
+
57
+ | Tool | Source |
58
+ |------|--------|
59
+ | `BuildProject` | Xcode native MCP |
60
+ | `RunAllTests` | Xcode native MCP |
61
+ | `RunSomeTests` | Xcode native MCP |
62
+ | `RenderPreview` | Xcode native MCP |
63
+ | `ExecuteSnippet` | Xcode native MCP |
64
+ | `build_sim` | xcodebuildmcp |
65
+ | `build_run_sim` | xcodebuildmcp |
66
+ | `test_sim` | xcodebuildmcp |
67
+ | `clean` | xcodebuildmcp |
68
+
69
+ You can still override with `--async-tools` if needed:
70
+
71
+ ```bash
72
+ longrun-mcp-proxy stdio --async-tools BuildProject,RunAllTests -- xcrun mcpbridge
73
+ ```
74
+
75
+ ## Configuration
76
+
77
+ ### Claude Code (`.mcp.json` in project root)
78
+
79
+ ```json
80
+ {
81
+ "mcpServers": {
82
+ "xcode": {
83
+ "command": "longrun-mcp-proxy",
84
+ "args": ["stdio", "--", "xcrun", "mcpbridge"]
85
+ },
86
+ "xcode-build": {
87
+ "command": "longrun-mcp-proxy",
88
+ "args": ["stdio", "--", "npx", "-y", "xcodebuildmcp@latest", "mcp"]
89
+ }
90
+ }
91
+ }
92
+ ```
93
+
94
+ ### VS Code (`.vscode/mcp.json`)
95
+
96
+ ```json
97
+ {
98
+ "servers": {
99
+ "xcode": {
100
+ "type": "stdio",
101
+ "command": "longrun-mcp-proxy",
102
+ "args": ["stdio", "--", "xcrun", "mcpbridge"]
103
+ },
104
+ "xcode-build": {
105
+ "type": "stdio",
106
+ "command": "longrun-mcp-proxy",
107
+ "args": ["stdio", "--", "npx", "-y", "xcodebuildmcp@latest", "mcp"]
108
+ }
109
+ }
110
+ }
111
+ ```
112
+
113
+ ### Cursor (`.cursor/mcp.json`)
114
+
115
+ ```json
116
+ {
117
+ "mcpServers": {
118
+ "xcode": {
119
+ "command": "longrun-mcp-proxy",
120
+ "args": ["stdio", "--", "xcrun", "mcpbridge"]
121
+ },
122
+ "xcode-build": {
123
+ "command": "longrun-mcp-proxy",
124
+ "args": ["stdio", "--", "npx", "-y", "xcodebuildmcp@latest", "mcp"]
125
+ }
126
+ }
127
+ }
128
+ ```
129
+
130
+ ## How it works
131
+
132
+ For async-wrapped tools, the agent sees:
133
+
134
+ ```
135
+ 1. Agent calls BuildProject(...)
136
+ 2. Proxy returns: {"job_id": "abc123", "status": "running"}
137
+ 3. Agent calls check_job(job_id="abc123")
138
+ 4. Proxy returns: {"status": "running", "elapsed_sec": 12.5}
139
+ ... agent keeps polling ...
140
+ 5. Proxy returns: {"status": "completed", "result": "Build succeeded."}
141
+ ```
142
+
143
+ Two extra tools are added automatically:
144
+ - `check_job(job_id)` — poll for result
145
+ - `cancel_job(job_id)` — cancel a running job
146
+
147
+ ## Persistent mode extras
148
+
149
+ ```bash
150
+ # Set Xcode MCP permission defaults (skip approval dialogs)
151
+ longrun-mcp-proxy persistent --xcode-defaults --port 8421 -- xcrun mcpbridge
152
+
153
+ # Auto-approve Xcode MCP permission dialogs via AppleScript
154
+ longrun-mcp-proxy persistent --auto-approve --port 8421 -- xcrun mcpbridge
155
+ ```
156
+
157
+ ## Options
158
+
159
+ | Flag | Mode | Description |
160
+ |------|------|-------------|
161
+ | `--async-tools TOOLS` | both | Comma-separated tool names to wrap (overrides auto-detect) |
162
+ | `-v, --verbose` | both | Enable debug logging |
163
+ | `--port PORT` | persistent | SSE server port (default: 8421) |
164
+ | `--host HOST` | persistent | SSE server host (default: 127.0.0.1) |
165
+ | `--name NAME` | persistent | Proxy server name |
166
+ | `--xcode-defaults` | persistent | Set Xcode permission defaults |
167
+ | `--auto-approve` | persistent | Auto-approve Xcode dialogs |
168
+
169
+ ## Requirements
170
+
171
+ - Python >= 3.11
172
+ - FastMCP >= 2.0.0
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "longrun-mcp-proxy"
7
+ version = "1.2.0"
8
+ description = "MCP proxy with async wrapper for long-running tools and persistent SSE mode"
9
+ requires-python = ">=3.11"
10
+ dependencies = [
11
+ "fastmcp>=2.0.0",
12
+ ]
13
+
14
+ [project.scripts]
15
+ longrun-mcp-proxy = "longrun_mcp_proxy.cli:main"
16
+
17
+ [tool.setuptools.packages.find]
18
+ where = ["src"]
19
+
20
+ [tool.pytest.ini_options]
21
+ asyncio_mode = "auto"
22
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,23 @@
1
+ """MCP proxy with async wrapper for long-running tools."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from longrun_mcp_proxy.job_store import Job, JobStore
6
+ from longrun_mcp_proxy.output_filter import filter_large_output
7
+ from longrun_mcp_proxy.proxy_stdio import build_proxy, connect_and_register
8
+ from longrun_mcp_proxy.proxy_persistent import (
9
+ PersistentDownstream,
10
+ start_persistent_proxy,
11
+ stop_persistent_proxy,
12
+ )
13
+
14
+ __all__ = [
15
+ "Job",
16
+ "JobStore",
17
+ "build_proxy",
18
+ "connect_and_register",
19
+ "filter_large_output",
20
+ "PersistentDownstream",
21
+ "start_persistent_proxy",
22
+ "stop_persistent_proxy",
23
+ ]
@@ -0,0 +1,5 @@
1
+ """Allow running as `python -m longrun_mcp_proxy`."""
2
+
3
+ from longrun_mcp_proxy.cli import main
4
+
5
+ main()
@@ -0,0 +1,174 @@
1
+ """CLI entry point for longrun-mcp-proxy.
2
+
3
+ Subcommands:
4
+ stdio — Stdio proxy (FastMCP create_proxy + middleware).
5
+ For servers WITHOUT outputSchema (e.g. XcodeBuildMCP).
6
+ persistent — Persistent SSE proxy with manual tool registration.
7
+ For servers WITH outputSchema (e.g. native Xcode MCP).
8
+
9
+ Usage:
10
+ longrun-mcp-proxy stdio --async-tools build_sim,test_sim \
11
+ -- npx -y xcodebuildmcp@latest mcp
12
+
13
+ longrun-mcp-proxy persistent --async-tools BuildProject,RunAllTests \
14
+ --port 8421 -- xcrun mcpbridge
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import asyncio
21
+ import logging
22
+ import sys
23
+
24
+
25
+ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
26
+ parser = argparse.ArgumentParser(
27
+ prog="longrun-mcp-proxy",
28
+ description="MCP proxy with async wrapper for long-running tools",
29
+ )
30
+ parser.add_argument(
31
+ "-v", "--verbose", action="store_true", help="Enable debug logging"
32
+ )
33
+
34
+ sub = parser.add_subparsers(dest="mode", required=True)
35
+
36
+ # --- stdio ---
37
+ stdio_p = sub.add_parser(
38
+ "stdio",
39
+ help="Stdio proxy (for servers without outputSchema)",
40
+ )
41
+ stdio_p.add_argument(
42
+ "--async-tools",
43
+ default="",
44
+ help="Comma-separated tool names to wrap in async start/poll pattern",
45
+ )
46
+ stdio_p.add_argument(
47
+ "command", nargs=argparse.REMAINDER, help="Downstream MCP server command"
48
+ )
49
+
50
+ # --- persistent ---
51
+ pers_p = sub.add_parser(
52
+ "persistent",
53
+ help="Persistent SSE proxy (for servers with outputSchema)",
54
+ )
55
+ pers_p.add_argument(
56
+ "--async-tools",
57
+ default="",
58
+ help="Comma-separated tool names to wrap in async start/poll pattern",
59
+ )
60
+ pers_p.add_argument(
61
+ "--port", type=int, default=8421, help="SSE server port (default: 8421)"
62
+ )
63
+ pers_p.add_argument(
64
+ "--host", default="127.0.0.1", help="SSE server host (default: 127.0.0.1)"
65
+ )
66
+ pers_p.add_argument(
67
+ "--name",
68
+ default="longrun-mcp-proxy",
69
+ help="Proxy server name (default: longrun-mcp-proxy)",
70
+ )
71
+ pers_p.add_argument(
72
+ "--xcode-defaults",
73
+ action="store_true",
74
+ help="Set Xcode MCP permission defaults before starting",
75
+ )
76
+ pers_p.add_argument(
77
+ "--auto-approve",
78
+ action="store_true",
79
+ help="Start AppleScript auto-approver for Xcode MCP dialogs",
80
+ )
81
+ pers_p.add_argument(
82
+ "command", nargs=argparse.REMAINDER, help="Downstream MCP server command"
83
+ )
84
+
85
+ args = parser.parse_args(argv)
86
+
87
+ # Strip leading '--' from command
88
+ if args.command and args.command[0] == "--":
89
+ args.command = args.command[1:]
90
+
91
+ if not args.command:
92
+ parser.error("No downstream command specified")
93
+
94
+ return args
95
+
96
+
97
+ def _run_stdio(args: argparse.Namespace) -> None:
98
+ """Run stdio proxy."""
99
+ from longrun_mcp_proxy.proxy_stdio import build_proxy, connect_and_register
100
+
101
+ async_tools = {t.strip() for t in args.async_tools.split(",") if t.strip()}
102
+
103
+ proxy = build_proxy(args.command, async_tools)
104
+
105
+ async def _main():
106
+ await connect_and_register(proxy)
107
+ await proxy.run_async(transport="stdio")
108
+
109
+ asyncio.run(_main())
110
+
111
+
112
+ def _run_persistent(args: argparse.Namespace) -> None:
113
+ """Run persistent SSE proxy."""
114
+ from longrun_mcp_proxy.proxy_persistent import (
115
+ start_persistent_proxy,
116
+ stop_persistent_proxy,
117
+ )
118
+
119
+ async_tools = {t.strip() for t in args.async_tools.split(",") if t.strip()}
120
+
121
+ if args.xcode_defaults:
122
+ from longrun_mcp_proxy.extras.xcode_defaults import set_xcode_mcp_defaults
123
+
124
+ set_xcode_mcp_defaults()
125
+
126
+ approver_proc = None
127
+ if args.auto_approve:
128
+ from longrun_mcp_proxy.extras.xcode_approver import start_auto_approver
129
+
130
+ approver_proc = start_auto_approver()
131
+
132
+ async def _main() -> None:
133
+ task = await start_persistent_proxy(
134
+ command=args.command,
135
+ async_tools=async_tools,
136
+ port=args.port,
137
+ host=args.host,
138
+ name=args.name,
139
+ )
140
+ try:
141
+ await task
142
+ except asyncio.CancelledError:
143
+ pass
144
+ finally:
145
+ await stop_persistent_proxy(task)
146
+
147
+ try:
148
+ asyncio.run(_main())
149
+ finally:
150
+ if approver_proc:
151
+ from longrun_mcp_proxy.extras.xcode_approver import stop_auto_approver
152
+
153
+ stop_auto_approver()
154
+
155
+
156
+ def main(argv: list[str] | None = None) -> None:
157
+ args = _parse_args(argv)
158
+
159
+ logging.basicConfig(
160
+ level=logging.DEBUG if args.verbose else logging.INFO,
161
+ format="%(asctime)s %(name)s %(levelname)s %(message)s",
162
+ )
163
+
164
+ if args.mode == "stdio":
165
+ _run_stdio(args)
166
+ elif args.mode == "persistent":
167
+ _run_persistent(args)
168
+ else:
169
+ print(f"Unknown mode: {args.mode}", file=sys.stderr)
170
+ sys.exit(1)
171
+
172
+
173
+ if __name__ == "__main__":
174
+ main()
@@ -0,0 +1 @@
1
+ """Xcode-specific extras for longrun-mcp-proxy."""
@@ -0,0 +1,51 @@
1
+ """Auto-approve Xcode MCP agent permission dialogs via AppleScript."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+ logger = logging.getLogger("longrun-mcp-proxy")
10
+
11
+ _approver_proc: subprocess.Popen | None = None
12
+
13
+ # AppleScript is bundled with the package
14
+ _SCRIPT_PATH = Path(__file__).parent.parent.parent.parent / "deploy" / "auto_approve_xcode_mcp.applescript"
15
+
16
+
17
+ def start_auto_approver(script_path: Path | None = None) -> subprocess.Popen | None:
18
+ """Start the AppleScript auto-approver for Xcode MCP dialogs.
19
+
20
+ Requires: macOS with Accessibility access for the calling process.
21
+ """
22
+ global _approver_proc
23
+ script = script_path or _SCRIPT_PATH
24
+ if not script.exists():
25
+ logger.warning("Auto-approver script not found: %s", script)
26
+ return None
27
+ try:
28
+ proc = subprocess.Popen(
29
+ ["osascript", str(script)],
30
+ stdout=subprocess.DEVNULL,
31
+ stderr=subprocess.DEVNULL,
32
+ )
33
+ _approver_proc = proc
34
+ logger.info("Auto-approver started (PID %d)", proc.pid)
35
+ return proc
36
+ except Exception as e:
37
+ logger.warning("Failed to start auto-approver: %s", e)
38
+ return None
39
+
40
+
41
+ def stop_auto_approver() -> None:
42
+ """Stop the auto-approver process."""
43
+ global _approver_proc
44
+ if _approver_proc and _approver_proc.poll() is None:
45
+ _approver_proc.terminate()
46
+ try:
47
+ _approver_proc.wait(timeout=3)
48
+ except subprocess.TimeoutExpired:
49
+ _approver_proc.kill()
50
+ logger.info("Auto-approver stopped")
51
+ _approver_proc = None
@@ -0,0 +1,56 @@
1
+ """Set Xcode defaults for MCP permissions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import subprocess
7
+
8
+ logger = logging.getLogger("longrun-mcp-proxy")
9
+
10
+ XCODE_MCP_DEFAULTS = (
11
+ "IDEAllowUnauthenticatedAgents",
12
+ "IDEChatAllowAgents",
13
+ "IDEChatAgenticChatSkipPermissions",
14
+ "IDEChatInternalAllowUntrustedAgentsWithoutUserInteraction",
15
+ "IDEChatSkipPermissionsForTools",
16
+ "IDEChatSkipPermissionsForTrustedTools",
17
+ )
18
+
19
+ # Default async tools for native Xcode MCP (xcrun mcpbridge)
20
+ XCODE_NATIVE_ASYNC_TOOLS = {
21
+ "BuildProject",
22
+ "RunAllTests",
23
+ "RunSomeTests",
24
+ "RenderPreview",
25
+ "ExecuteSnippet",
26
+ }
27
+
28
+ # Default async tools for XcodeBuildMCP (Sentry)
29
+ XCODE_BUILD_ASYNC_TOOLS = {
30
+ "build_sim",
31
+ "build_run_sim",
32
+ "test_sim",
33
+ "clean",
34
+ }
35
+
36
+ # Combined set of all known long-running tools for auto-detection.
37
+ # When --async-tools is not specified, proxy matches discovered downstream
38
+ # tool names against this set and wraps any matches automatically.
39
+ KNOWN_ASYNC_TOOLS = XCODE_NATIVE_ASYNC_TOOLS | XCODE_BUILD_ASYNC_TOOLS
40
+
41
+ # Tools that need a retry after completion to get a warmed-up result.
42
+ # For example, RenderPreview returns a screenshot before async images load;
43
+ # a second call after a delay returns the correct screenshot with cached images.
44
+ KNOWN_RETRY_TOOLS: dict[str, float] = {
45
+ "RenderPreview": 3.0, # seconds to wait before retry
46
+ }
47
+
48
+
49
+ def set_xcode_mcp_defaults() -> None:
50
+ """Set all known Xcode defaults that may help with MCP permissions."""
51
+ for key in XCODE_MCP_DEFAULTS:
52
+ subprocess.run(
53
+ ["defaults", "write", "com.apple.dt.Xcode", key, "-bool", "YES"],
54
+ capture_output=True,
55
+ )
56
+ logger.info("Xcode MCP defaults set (%d keys)", len(XCODE_MCP_DEFAULTS))
@@ -0,0 +1,52 @@
1
+ """In-memory job store for async-wrapped tool calls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ from dataclasses import dataclass, field
8
+ from uuid import uuid4
9
+
10
+ JOB_TTL_SEC = 600 # clean up completed jobs after 10 min
11
+
12
+
13
+ @dataclass
14
+ class Job:
15
+ id: str
16
+ tool_name: str
17
+ status: str = "running" # running | completed | failed
18
+ result: object | None = None
19
+ error: str | None = None
20
+ created_at: float = field(default_factory=time.time)
21
+ completed_at: float | None = None
22
+ _task: asyncio.Task | None = field(default=None, repr=False)
23
+
24
+
25
+ class JobStore:
26
+ """In-memory store for async jobs."""
27
+
28
+ def __init__(self) -> None:
29
+ self._jobs: dict[str, Job] = {}
30
+
31
+ def create(self, tool_name: str) -> Job:
32
+ job = Job(id=uuid4().hex[:12], tool_name=tool_name)
33
+ self._jobs[job.id] = job
34
+ return job
35
+
36
+ def get(self, job_id: str) -> Job | None:
37
+ self._cleanup()
38
+ return self._jobs.get(job_id)
39
+
40
+ def all(self) -> list[Job]:
41
+ self._cleanup()
42
+ return list(self._jobs.values())
43
+
44
+ def _cleanup(self) -> None:
45
+ now = time.time()
46
+ expired = [
47
+ jid
48
+ for jid, j in self._jobs.items()
49
+ if j.completed_at and now - j.completed_at > JOB_TTL_SEC
50
+ ]
51
+ for jid in expired:
52
+ del self._jobs[jid]
@@ -0,0 +1,48 @@
1
+ """Async wrapper middleware for long-running MCP tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext
8
+ from fastmcp.tools.tool import ToolResult
9
+ from mcp.types import CallToolRequestParams
10
+
11
+ from longrun_mcp_proxy.job_store import JobStore
12
+
13
+
14
+ class AsyncWrapperMiddleware(Middleware):
15
+ """Intercept designated tools and run them in background tasks."""
16
+
17
+ def __init__(self, async_tools: set[str], store: JobStore) -> None:
18
+ self._async_tools = async_tools
19
+ self._store = store
20
+
21
+ async def on_call_tool(
22
+ self,
23
+ context: MiddlewareContext[CallToolRequestParams],
24
+ call_next: CallNext[CallToolRequestParams, ToolResult],
25
+ ) -> ToolResult:
26
+ tool_name = context.message.name
27
+ if tool_name not in self._async_tools:
28
+ return await call_next(context)
29
+
30
+ job = self._store.create(tool_name)
31
+
32
+ async def _run() -> None:
33
+ try:
34
+ result = await call_next(context)
35
+ job.result = result
36
+ job.status = "completed"
37
+ except Exception as exc:
38
+ job.error = str(exc)
39
+ job.status = "failed"
40
+ job.completed_at = __import__("time").time()
41
+
42
+ job._task = asyncio.create_task(_run())
43
+
44
+ return ToolResult(
45
+ content=f"Job started: {job.id}\n"
46
+ f"Tool: {tool_name}\n"
47
+ f'Poll with check_job(job_id="{job.id}") to get the result.',
48
+ )
@@ -0,0 +1,54 @@
1
+ """Filter large build output to keep only diagnostic lines."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ MAX_RESULT_CHARS = 30_000
8
+
9
+ _DEFAULT_DIAG_RE = re.compile(
10
+ r"(?:error:|warning:|note:|\bfailure\b|\bBUILD )", re.IGNORECASE
11
+ )
12
+
13
+
14
+ def filter_large_output(
15
+ text: str,
16
+ max_chars: int = MAX_RESULT_CHARS,
17
+ pattern: re.Pattern | None = None,
18
+ ) -> str:
19
+ """Extract only diagnostic lines from large output.
20
+
21
+ If the output is small enough, return as-is. For large outputs,
22
+ keep only lines matching the diagnostic pattern.
23
+
24
+ Args:
25
+ text: The raw output text.
26
+ max_chars: Maximum characters to return.
27
+ pattern: Regex pattern for diagnostic lines. Defaults to
28
+ error:/warning:/note:/failure/BUILD.
29
+ """
30
+ if len(text) <= max_chars:
31
+ return text
32
+
33
+ diag_re = pattern or _DEFAULT_DIAG_RE
34
+ lines = text.split("\n")
35
+ filtered = [ln for ln in lines if diag_re.search(ln)]
36
+
37
+ if not filtered:
38
+ return text[: max_chars // 2] + "\n...\n" + text[-max_chars // 2 :]
39
+
40
+ result = "\n".join(filtered)
41
+ if len(result) <= max_chars:
42
+ return result
43
+
44
+ # Deduplicate identical messages, then truncate
45
+ seen: set[str] = set()
46
+ unique: list[str] = []
47
+ for ln in filtered:
48
+ if ln not in seen:
49
+ seen.add(ln)
50
+ unique.append(ln)
51
+ result = "\n".join(unique)
52
+ if len(result) > max_chars:
53
+ result = result[:max_chars] + f"\n... (truncated, {len(text)} total chars)"
54
+ return result