letswork 2.0.5__tar.gz → 2.0.6__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.
- {letswork-2.0.5 → letswork-2.0.6}/PKG-INFO +1 -1
- letswork-2.0.6/letswork/proxy.py +109 -0
- {letswork-2.0.5 → letswork-2.0.6}/pyproject.toml +1 -1
- letswork-2.0.5/letswork/proxy.py +0 -167
- {letswork-2.0.5 → letswork-2.0.6}/.github/workflows/ci.yml +0 -0
- {letswork-2.0.5 → letswork-2.0.6}/.github/workflows/publish.yml +0 -0
- {letswork-2.0.5 → letswork-2.0.6}/.gitignore +0 -0
- {letswork-2.0.5 → letswork-2.0.6}/README.md +0 -0
- {letswork-2.0.5 → letswork-2.0.6}/docs/architecture.md +0 -0
- {letswork-2.0.5 → letswork-2.0.6}/docs/spec.md +0 -0
- {letswork-2.0.5 → letswork-2.0.6}/docs/tasks.md +0 -0
- {letswork-2.0.5 → letswork-2.0.6}/letswork/__init__.py +0 -0
- {letswork-2.0.5 → letswork-2.0.6}/letswork/approval.py +0 -0
- {letswork-2.0.5 → letswork-2.0.6}/letswork/auth.py +0 -0
- {letswork-2.0.5 → letswork-2.0.6}/letswork/cli.py +0 -0
- {letswork-2.0.5 → letswork-2.0.6}/letswork/events.py +0 -0
- {letswork-2.0.5 → letswork-2.0.6}/letswork/filelock.py +0 -0
- {letswork-2.0.5 → letswork-2.0.6}/letswork/launcher.py +0 -0
- {letswork-2.0.5 → letswork-2.0.6}/letswork/server.py +0 -0
- {letswork-2.0.5 → letswork-2.0.6}/letswork/tunnel.py +0 -0
- {letswork-2.0.5 → letswork-2.0.6}/server.json +0 -0
- {letswork-2.0.5 → letswork-2.0.6}/tests/__init__.py +0 -0
- {letswork-2.0.5 → letswork-2.0.6}/tests/test_auth.py +0 -0
- {letswork-2.0.5 → letswork-2.0.6}/tests/test_filelock.py +0 -0
- {letswork-2.0.5 → letswork-2.0.6}/tests/test_server.py +0 -0
- {letswork-2.0.5 → letswork-2.0.6}/tests/test_tunnel.py +0 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LetsWork stdio MCP proxy.
|
|
3
|
+
|
|
4
|
+
Claude Code connects to this as a stdio MCP server (reliable, no streaming issues).
|
|
5
|
+
This proxy forwards all tool calls to the host's HTTP MCP server over Cloudflare.
|
|
6
|
+
|
|
7
|
+
Usage (done automatically by `letswork join`):
|
|
8
|
+
claude mcp add letswork -- letswork-proxy --url <URL> --token <TOKEN>
|
|
9
|
+
"""
|
|
10
|
+
import sys
|
|
11
|
+
import asyncio
|
|
12
|
+
import argparse
|
|
13
|
+
import httpx
|
|
14
|
+
from mcp.server import Server
|
|
15
|
+
from mcp.server.stdio import stdio_server
|
|
16
|
+
from mcp import types
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def make_proxy_server(base_url: str, token: str) -> Server:
|
|
20
|
+
"""Create an MCP Server that forwards calls to the remote host."""
|
|
21
|
+
# Ensure URL ends with /mcp
|
|
22
|
+
url = base_url.rstrip("/")
|
|
23
|
+
if not url.endswith("/mcp"):
|
|
24
|
+
url = url + "/mcp"
|
|
25
|
+
|
|
26
|
+
server = Server("letswork-proxy")
|
|
27
|
+
|
|
28
|
+
async def _http_post(payload: dict) -> dict:
|
|
29
|
+
async with httpx.AsyncClient(timeout=30) as client:
|
|
30
|
+
r = await client.post(
|
|
31
|
+
url,
|
|
32
|
+
json=payload,
|
|
33
|
+
headers={
|
|
34
|
+
"Content-Type": "application/json",
|
|
35
|
+
"Accept": "application/json, text/event-stream",
|
|
36
|
+
},
|
|
37
|
+
)
|
|
38
|
+
r.raise_for_status()
|
|
39
|
+
# Parse SSE response: find 'data: {...}' line
|
|
40
|
+
for line in r.text.splitlines():
|
|
41
|
+
if line.startswith("data: "):
|
|
42
|
+
import json
|
|
43
|
+
return json.loads(line[6:])
|
|
44
|
+
raise RuntimeError("No data in response")
|
|
45
|
+
|
|
46
|
+
@server.list_tools()
|
|
47
|
+
async def list_tools() -> list[types.Tool]:
|
|
48
|
+
resp = await _http_post({
|
|
49
|
+
"jsonrpc": "2.0", "id": 1,
|
|
50
|
+
"method": "tools/list", "params": {},
|
|
51
|
+
})
|
|
52
|
+
tools = []
|
|
53
|
+
for t in resp.get("result", {}).get("tools", []):
|
|
54
|
+
schema = t.get("inputSchema", {"type": "object", "properties": {}})
|
|
55
|
+
# Strip 'token' from schema — proxy injects it automatically
|
|
56
|
+
schema = dict(schema)
|
|
57
|
+
props = dict(schema.get("properties", {}))
|
|
58
|
+
props.pop("token", None)
|
|
59
|
+
schema["properties"] = props
|
|
60
|
+
required = [r for r in schema.get("required", []) if r != "token"]
|
|
61
|
+
if required:
|
|
62
|
+
schema["required"] = required
|
|
63
|
+
elif "required" in schema:
|
|
64
|
+
del schema["required"]
|
|
65
|
+
tools.append(types.Tool(
|
|
66
|
+
name=t["name"],
|
|
67
|
+
description=t.get("description", ""),
|
|
68
|
+
inputSchema=schema,
|
|
69
|
+
))
|
|
70
|
+
return tools
|
|
71
|
+
|
|
72
|
+
@server.call_tool()
|
|
73
|
+
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
|
|
74
|
+
# Inject token automatically so guest doesn't have to think about it
|
|
75
|
+
arguments = {**arguments, "token": token}
|
|
76
|
+
resp = await _http_post({
|
|
77
|
+
"jsonrpc": "2.0", "id": 1,
|
|
78
|
+
"method": "tools/call",
|
|
79
|
+
"params": {"name": name, "arguments": arguments},
|
|
80
|
+
})
|
|
81
|
+
result = resp.get("result", {})
|
|
82
|
+
content = result.get("content", [])
|
|
83
|
+
out = []
|
|
84
|
+
for item in content:
|
|
85
|
+
if item.get("type") == "text":
|
|
86
|
+
out.append(types.TextContent(type="text", text=item["text"]))
|
|
87
|
+
if not out:
|
|
88
|
+
out.append(types.TextContent(type="text", text=str(result)))
|
|
89
|
+
return out
|
|
90
|
+
|
|
91
|
+
return server
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def _run(url: str, token: str) -> None:
|
|
95
|
+
server = make_proxy_server(url, token)
|
|
96
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
97
|
+
await server.run(read_stream, write_stream, server.create_initialization_options())
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def main() -> None:
|
|
101
|
+
parser = argparse.ArgumentParser(description="LetsWork MCP stdio proxy")
|
|
102
|
+
parser.add_argument("--url", required=True, help="Host MCP URL")
|
|
103
|
+
parser.add_argument("--token", required=True, help="Session token")
|
|
104
|
+
args = parser.parse_args()
|
|
105
|
+
asyncio.run(_run(args.url, args.token))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
if __name__ == "__main__":
|
|
109
|
+
main()
|
letswork-2.0.5/letswork/proxy.py
DELETED
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
LetsWork stdio MCP proxy.
|
|
3
|
-
|
|
4
|
-
Claude Code connects to this as a stdio MCP server (reliable, no streaming issues).
|
|
5
|
-
This proxy forwards all tool calls to the host's HTTP MCP server using a proper
|
|
6
|
-
MCP client session (required by FastMCP's streamable HTTP transport).
|
|
7
|
-
|
|
8
|
-
Reconnects automatically if the tunnel drops. Sends a keepalive ping every 30s
|
|
9
|
-
to prevent Cloudflare from closing idle connections.
|
|
10
|
-
|
|
11
|
-
Usage (done automatically by `letswork join`):
|
|
12
|
-
claude mcp add letswork -- letswork-proxy --url <URL> --token <TOKEN>
|
|
13
|
-
"""
|
|
14
|
-
import sys
|
|
15
|
-
import asyncio
|
|
16
|
-
import argparse
|
|
17
|
-
import logging
|
|
18
|
-
from mcp.server import Server
|
|
19
|
-
from mcp.server.stdio import stdio_server
|
|
20
|
-
from mcp.client.streamable_http import streamablehttp_client
|
|
21
|
-
from mcp import ClientSession, types
|
|
22
|
-
|
|
23
|
-
log = logging.getLogger("letswork.proxy")
|
|
24
|
-
|
|
25
|
-
_RECONNECT_DELAYS = [1, 2, 5, 10, 30] # seconds between attempts
|
|
26
|
-
_KEEPALIVE_INTERVAL = 30 # seconds between keepalive pings
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def _setup_logging(debug: bool) -> None:
|
|
30
|
-
level = logging.DEBUG if debug else logging.WARNING
|
|
31
|
-
logging.basicConfig(
|
|
32
|
-
stream=sys.stderr,
|
|
33
|
-
level=level,
|
|
34
|
-
format="[proxy %(levelname)s] %(message)s",
|
|
35
|
-
)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def make_proxy_server(base_url: str, token: str) -> tuple:
|
|
39
|
-
"""Create an MCP Server that forwards calls to the remote host via a proper session."""
|
|
40
|
-
url = base_url.rstrip("/")
|
|
41
|
-
if not url.endswith("/mcp"):
|
|
42
|
-
url = url + "/mcp"
|
|
43
|
-
|
|
44
|
-
server = Server("letswork-proxy")
|
|
45
|
-
_session: ClientSession | None = None
|
|
46
|
-
_session_lock = asyncio.Lock()
|
|
47
|
-
|
|
48
|
-
async def _get_session() -> ClientSession:
|
|
49
|
-
if _session is None:
|
|
50
|
-
raise RuntimeError("Not connected to host — reconnecting, please retry in a moment")
|
|
51
|
-
return _session
|
|
52
|
-
|
|
53
|
-
@server.list_tools()
|
|
54
|
-
async def list_tools() -> list[types.Tool]:
|
|
55
|
-
session = await _get_session()
|
|
56
|
-
result = await session.list_tools()
|
|
57
|
-
tools = []
|
|
58
|
-
for t in result.tools:
|
|
59
|
-
schema = t.inputSchema if t.inputSchema else {"type": "object", "properties": {}}
|
|
60
|
-
schema = dict(schema)
|
|
61
|
-
props = dict(schema.get("properties", {}))
|
|
62
|
-
props.pop("token", None)
|
|
63
|
-
schema["properties"] = props
|
|
64
|
-
required = [r for r in schema.get("required", []) if r != "token"]
|
|
65
|
-
if required:
|
|
66
|
-
schema["required"] = required
|
|
67
|
-
elif "required" in schema:
|
|
68
|
-
del schema["required"]
|
|
69
|
-
tools.append(types.Tool(
|
|
70
|
-
name=t.name,
|
|
71
|
-
description=t.description or "",
|
|
72
|
-
inputSchema=schema,
|
|
73
|
-
))
|
|
74
|
-
return tools
|
|
75
|
-
|
|
76
|
-
@server.call_tool()
|
|
77
|
-
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
|
|
78
|
-
session = await _get_session()
|
|
79
|
-
arguments = {**arguments, "token": token}
|
|
80
|
-
log.debug(f"→ {name}({[k for k in arguments if k != 'token']})")
|
|
81
|
-
try:
|
|
82
|
-
result = await session.call_tool(name, arguments)
|
|
83
|
-
except Exception as e:
|
|
84
|
-
log.error(f"✗ {name} failed: {e}")
|
|
85
|
-
raise
|
|
86
|
-
out = [types.TextContent(type="text", text=item.text)
|
|
87
|
-
for item in result.content if item.type == "text"]
|
|
88
|
-
if not out:
|
|
89
|
-
out.append(types.TextContent(type="text", text=str(result)))
|
|
90
|
-
log.debug(f"← {name} OK")
|
|
91
|
-
return out
|
|
92
|
-
|
|
93
|
-
async def _keepalive(session: ClientSession) -> None:
|
|
94
|
-
"""Ping host every 30s so Cloudflare doesn't close the idle connection."""
|
|
95
|
-
while True:
|
|
96
|
-
await asyncio.sleep(_KEEPALIVE_INTERVAL)
|
|
97
|
-
try:
|
|
98
|
-
await session.call_tool("ping", {"token": token})
|
|
99
|
-
log.debug("keepalive ping OK")
|
|
100
|
-
except Exception as e:
|
|
101
|
-
log.debug(f"keepalive ping failed: {e}")
|
|
102
|
-
break # session is dead — let the outer loop reconnect
|
|
103
|
-
|
|
104
|
-
async def _connect_loop(read_stream, write_stream) -> None:
|
|
105
|
-
nonlocal _session
|
|
106
|
-
attempt = 0
|
|
107
|
-
first = True
|
|
108
|
-
|
|
109
|
-
while True:
|
|
110
|
-
try:
|
|
111
|
-
log.debug(f"Connecting to host at {url} (attempt {attempt + 1})")
|
|
112
|
-
async with streamablehttp_client(url) as (host_read, host_write, _):
|
|
113
|
-
async with ClientSession(host_read, host_write) as session:
|
|
114
|
-
await session.initialize()
|
|
115
|
-
async with _session_lock:
|
|
116
|
-
_session = session
|
|
117
|
-
attempt = 0
|
|
118
|
-
log.debug("Connected to host MCP server")
|
|
119
|
-
|
|
120
|
-
if first:
|
|
121
|
-
first = False
|
|
122
|
-
# Start serving stdio on first connect
|
|
123
|
-
asyncio.ensure_future(
|
|
124
|
-
server.run(read_stream, write_stream,
|
|
125
|
-
server.create_initialization_options())
|
|
126
|
-
)
|
|
127
|
-
|
|
128
|
-
# Run keepalive until connection drops
|
|
129
|
-
await _keepalive(session)
|
|
130
|
-
|
|
131
|
-
except Exception as e:
|
|
132
|
-
async with _session_lock:
|
|
133
|
-
_session = None
|
|
134
|
-
if first:
|
|
135
|
-
# Failed on very first attempt — bail out, Claude Code will show error
|
|
136
|
-
log.error(f"Initial connection failed: {e}")
|
|
137
|
-
raise
|
|
138
|
-
delay = _RECONNECT_DELAYS[min(attempt, len(_RECONNECT_DELAYS) - 1)]
|
|
139
|
-
attempt += 1
|
|
140
|
-
log.debug(f"Disconnected ({e}), retrying in {delay}s...")
|
|
141
|
-
await asyncio.sleep(delay)
|
|
142
|
-
|
|
143
|
-
async def run(read_stream, write_stream):
|
|
144
|
-
await _connect_loop(read_stream, write_stream)
|
|
145
|
-
|
|
146
|
-
return server, run
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
async def _main(url: str, token: str, debug: bool) -> None:
|
|
150
|
-
_setup_logging(debug)
|
|
151
|
-
log.debug(f"Starting proxy → {url}")
|
|
152
|
-
_server, run = make_proxy_server(url, token)
|
|
153
|
-
async with stdio_server() as (read_stream, write_stream):
|
|
154
|
-
await run(read_stream, write_stream)
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
def main() -> None:
|
|
158
|
-
parser = argparse.ArgumentParser(description="LetsWork MCP stdio proxy")
|
|
159
|
-
parser.add_argument("--url", required=True, help="Host MCP URL")
|
|
160
|
-
parser.add_argument("--token", required=True, help="Session token")
|
|
161
|
-
parser.add_argument("--debug", action="store_true", help="Enable debug logging")
|
|
162
|
-
args = parser.parse_args()
|
|
163
|
-
asyncio.run(_main(args.url, args.token, args.debug))
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
if __name__ == "__main__":
|
|
167
|
-
main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|