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.
Files changed (26) hide show
  1. {letswork-2.0.5 → letswork-2.0.6}/PKG-INFO +1 -1
  2. letswork-2.0.6/letswork/proxy.py +109 -0
  3. {letswork-2.0.5 → letswork-2.0.6}/pyproject.toml +1 -1
  4. letswork-2.0.5/letswork/proxy.py +0 -167
  5. {letswork-2.0.5 → letswork-2.0.6}/.github/workflows/ci.yml +0 -0
  6. {letswork-2.0.5 → letswork-2.0.6}/.github/workflows/publish.yml +0 -0
  7. {letswork-2.0.5 → letswork-2.0.6}/.gitignore +0 -0
  8. {letswork-2.0.5 → letswork-2.0.6}/README.md +0 -0
  9. {letswork-2.0.5 → letswork-2.0.6}/docs/architecture.md +0 -0
  10. {letswork-2.0.5 → letswork-2.0.6}/docs/spec.md +0 -0
  11. {letswork-2.0.5 → letswork-2.0.6}/docs/tasks.md +0 -0
  12. {letswork-2.0.5 → letswork-2.0.6}/letswork/__init__.py +0 -0
  13. {letswork-2.0.5 → letswork-2.0.6}/letswork/approval.py +0 -0
  14. {letswork-2.0.5 → letswork-2.0.6}/letswork/auth.py +0 -0
  15. {letswork-2.0.5 → letswork-2.0.6}/letswork/cli.py +0 -0
  16. {letswork-2.0.5 → letswork-2.0.6}/letswork/events.py +0 -0
  17. {letswork-2.0.5 → letswork-2.0.6}/letswork/filelock.py +0 -0
  18. {letswork-2.0.5 → letswork-2.0.6}/letswork/launcher.py +0 -0
  19. {letswork-2.0.5 → letswork-2.0.6}/letswork/server.py +0 -0
  20. {letswork-2.0.5 → letswork-2.0.6}/letswork/tunnel.py +0 -0
  21. {letswork-2.0.5 → letswork-2.0.6}/server.json +0 -0
  22. {letswork-2.0.5 → letswork-2.0.6}/tests/__init__.py +0 -0
  23. {letswork-2.0.5 → letswork-2.0.6}/tests/test_auth.py +0 -0
  24. {letswork-2.0.5 → letswork-2.0.6}/tests/test_filelock.py +0 -0
  25. {letswork-2.0.5 → letswork-2.0.6}/tests/test_server.py +0 -0
  26. {letswork-2.0.5 → letswork-2.0.6}/tests/test_tunnel.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: letswork
3
- Version: 2.0.5
3
+ Version: 2.0.6
4
4
  Summary: Real-time collaborative coding via MCP — two developers, one codebase
5
5
  Author: Sai Charan Rajoju
6
6
  License-Expression: MIT
@@ -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()
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "letswork"
7
- version = "2.0.5"
7
+ version = "2.0.6"
8
8
  description = "Real-time collaborative coding via MCP — two developers, one codebase"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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