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.
- longrun_mcp_proxy-1.2.0/PKG-INFO +6 -0
- longrun_mcp_proxy-1.2.0/README.md +172 -0
- longrun_mcp_proxy-1.2.0/pyproject.toml +22 -0
- longrun_mcp_proxy-1.2.0/setup.cfg +4 -0
- longrun_mcp_proxy-1.2.0/src/longrun_mcp_proxy/__init__.py +23 -0
- longrun_mcp_proxy-1.2.0/src/longrun_mcp_proxy/__main__.py +5 -0
- longrun_mcp_proxy-1.2.0/src/longrun_mcp_proxy/cli.py +174 -0
- longrun_mcp_proxy-1.2.0/src/longrun_mcp_proxy/extras/__init__.py +1 -0
- longrun_mcp_proxy-1.2.0/src/longrun_mcp_proxy/extras/xcode_approver.py +51 -0
- longrun_mcp_proxy-1.2.0/src/longrun_mcp_proxy/extras/xcode_defaults.py +56 -0
- longrun_mcp_proxy-1.2.0/src/longrun_mcp_proxy/job_store.py +52 -0
- longrun_mcp_proxy-1.2.0/src/longrun_mcp_proxy/middleware.py +48 -0
- longrun_mcp_proxy-1.2.0/src/longrun_mcp_proxy/output_filter.py +54 -0
- longrun_mcp_proxy-1.2.0/src/longrun_mcp_proxy/proxy_persistent.py +393 -0
- longrun_mcp_proxy-1.2.0/src/longrun_mcp_proxy/proxy_stdio.py +295 -0
- longrun_mcp_proxy-1.2.0/src/longrun_mcp_proxy.egg-info/PKG-INFO +6 -0
- longrun_mcp_proxy-1.2.0/src/longrun_mcp_proxy.egg-info/SOURCES.txt +24 -0
- longrun_mcp_proxy-1.2.0/src/longrun_mcp_proxy.egg-info/dependency_links.txt +1 -0
- longrun_mcp_proxy-1.2.0/src/longrun_mcp_proxy.egg-info/entry_points.txt +2 -0
- longrun_mcp_proxy-1.2.0/src/longrun_mcp_proxy.egg-info/requires.txt +1 -0
- longrun_mcp_proxy-1.2.0/src/longrun_mcp_proxy.egg-info/top_level.txt +1 -0
- longrun_mcp_proxy-1.2.0/tests/test_cli.py +47 -0
- longrun_mcp_proxy-1.2.0/tests/test_job_store.py +40 -0
- longrun_mcp_proxy-1.2.0/tests/test_middleware.py +85 -0
- longrun_mcp_proxy-1.2.0/tests/test_output_filter.py +34 -0
- longrun_mcp_proxy-1.2.0/tests/test_proxy_stdio.py +41 -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,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,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
|