letswork 2.0.0__tar.gz → 2.0.2__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.0 → letswork-2.0.2}/PKG-INFO +2 -3
- {letswork-2.0.0 → letswork-2.0.2}/letswork/cli.py +49 -35
- {letswork-2.0.0 → letswork-2.0.2}/letswork/launcher.py +5 -19
- letswork-2.0.2/letswork/proxy.py +130 -0
- {letswork-2.0.0 → letswork-2.0.2}/letswork/remote_client.py +0 -9
- {letswork-2.0.0 → letswork-2.0.2}/letswork/server.py +8 -37
- {letswork-2.0.0 → letswork-2.0.2}/pyproject.toml +2 -4
- letswork-2.0.0/letswork/proxy.py +0 -109
- {letswork-2.0.0 → letswork-2.0.2}/.github/workflows/ci.yml +0 -0
- {letswork-2.0.0 → letswork-2.0.2}/.github/workflows/publish.yml +0 -0
- {letswork-2.0.0 → letswork-2.0.2}/.gitignore +0 -0
- {letswork-2.0.0 → letswork-2.0.2}/README.md +0 -0
- {letswork-2.0.0 → letswork-2.0.2}/docs/architecture.md +0 -0
- {letswork-2.0.0 → letswork-2.0.2}/docs/spec.md +0 -0
- {letswork-2.0.0 → letswork-2.0.2}/docs/tasks.md +0 -0
- {letswork-2.0.0 → letswork-2.0.2}/letswork/__init__.py +0 -0
- {letswork-2.0.0 → letswork-2.0.2}/letswork/approval.py +0 -0
- {letswork-2.0.0 → letswork-2.0.2}/letswork/auth.py +0 -0
- {letswork-2.0.0 → letswork-2.0.2}/letswork/events.py +0 -0
- {letswork-2.0.0 → letswork-2.0.2}/letswork/filelock.py +0 -0
- {letswork-2.0.0 → letswork-2.0.2}/letswork/tui/__init__.py +0 -0
- {letswork-2.0.0 → letswork-2.0.2}/letswork/tui/app.py +0 -0
- {letswork-2.0.0 → letswork-2.0.2}/letswork/tui/approval_panel.py +0 -0
- {letswork-2.0.0 → letswork-2.0.2}/letswork/tui/chat.py +0 -0
- {letswork-2.0.0 → letswork-2.0.2}/letswork/tui/chat_app.py +0 -0
- {letswork-2.0.0 → letswork-2.0.2}/letswork/tui/file_tree.py +0 -0
- {letswork-2.0.0 → letswork-2.0.2}/letswork/tui/file_viewer.py +0 -0
- {letswork-2.0.0 → letswork-2.0.2}/letswork/tunnel.py +0 -0
- {letswork-2.0.0 → letswork-2.0.2}/server.json +0 -0
- {letswork-2.0.0 → letswork-2.0.2}/tests/__init__.py +0 -0
- {letswork-2.0.0 → letswork-2.0.2}/tests/test_auth.py +0 -0
- {letswork-2.0.0 → letswork-2.0.2}/tests/test_filelock.py +0 -0
- {letswork-2.0.0 → letswork-2.0.2}/tests/test_server.py +0 -0
- {letswork-2.0.0 → letswork-2.0.2}/tests/test_tunnel.py +0 -0
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: letswork
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.2
|
|
4
4
|
Summary: Real-time collaborative coding via MCP — two developers, one codebase
|
|
5
5
|
Author: Sai Charan Rajoju
|
|
6
6
|
License-Expression: MIT
|
|
7
7
|
Requires-Python: >=3.10
|
|
8
8
|
Requires-Dist: click>=8.1.0
|
|
9
|
+
Requires-Dist: httpx>=0.27.0
|
|
9
10
|
Requires-Dist: mcp[cli]>=1.0.0
|
|
10
|
-
Requires-Dist: textual[syntax]>=0.50.0
|
|
11
|
-
Requires-Dist: websockets>=12.0
|
|
12
11
|
Provides-Extra: dev
|
|
13
12
|
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
14
13
|
Requires-Dist: types-requests; extra == 'dev'
|
|
@@ -15,13 +15,13 @@ def cli():
|
|
|
15
15
|
|
|
16
16
|
@cli.command()
|
|
17
17
|
@click.option("--port", default=8000, type=int, help="Port for the MCP server")
|
|
18
|
-
|
|
18
|
+
@click.option("--debug", is_flag=True, help="Show live tool call activity")
|
|
19
|
+
def start(port, debug):
|
|
19
20
|
"""Start a LetsWork collaboration session."""
|
|
20
|
-
|
|
21
|
+
import logging
|
|
22
|
+
from letswork.events import EventLog, EventType
|
|
21
23
|
from letswork.approval import ApprovalQueue
|
|
22
|
-
from letswork.launcher import launch_claude_code,
|
|
23
|
-
|
|
24
|
-
name = click.prompt("What's your name?")
|
|
24
|
+
from letswork.launcher import launch_claude_code, register_guest_mcp
|
|
25
25
|
|
|
26
26
|
project_root = os.getcwd()
|
|
27
27
|
server_module.project_root = project_root
|
|
@@ -31,20 +31,20 @@ def start(port):
|
|
|
31
31
|
server_module.register_user(host_token, "host")
|
|
32
32
|
server_module.register_user(guest_token, "guest")
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
event_log = EventLog()
|
|
35
|
+
server_module.event_log = event_log
|
|
35
36
|
server_module.approval_queue = ApprovalQueue(project_root)
|
|
36
37
|
|
|
37
38
|
# Start tunnel
|
|
38
39
|
try:
|
|
39
40
|
url, tunnel_process = start_tunnel(port)
|
|
40
41
|
except RuntimeError as e:
|
|
41
|
-
click.echo(f"
|
|
42
|
+
click.echo(f"[ERROR] Tunnel failed: {e}")
|
|
42
43
|
return
|
|
43
44
|
|
|
44
45
|
mcp_url = f"{url}/mcp"
|
|
45
46
|
|
|
46
|
-
# Silence MCP and uvicorn loggers
|
|
47
|
-
import logging
|
|
47
|
+
# Silence MCP and uvicorn loggers
|
|
48
48
|
for _name in ("uvicorn", "uvicorn.access", "uvicorn.error", "mcp", "mcp.server",
|
|
49
49
|
"mcp.server.streamable_http", "asyncio"):
|
|
50
50
|
logging.getLogger(_name).setLevel(logging.CRITICAL)
|
|
@@ -54,8 +54,7 @@ def start(port):
|
|
|
54
54
|
and server_module.app.settings.transport_security):
|
|
55
55
|
server_module.app.settings.transport_security.enable_dns_rebinding_protection = False
|
|
56
56
|
|
|
57
|
-
# Start MCP server in background thread
|
|
58
|
-
# pass log_config=None, which fully disables all uvicorn request logging.
|
|
57
|
+
# Start MCP server in background thread
|
|
59
58
|
def run_server():
|
|
60
59
|
import anyio
|
|
61
60
|
import uvicorn
|
|
@@ -75,9 +74,10 @@ def start(port):
|
|
|
75
74
|
|
|
76
75
|
threading.Thread(target=run_server, daemon=True).start()
|
|
77
76
|
|
|
78
|
-
# Wait until
|
|
77
|
+
# Wait until server is up
|
|
79
78
|
import urllib.request
|
|
80
79
|
import urllib.error
|
|
80
|
+
click.echo("[letswork] Starting MCP server...", err=True)
|
|
81
81
|
for _ in range(20):
|
|
82
82
|
try:
|
|
83
83
|
urllib.request.urlopen(f"http://127.0.0.1:{port}/mcp", timeout=1)
|
|
@@ -85,61 +85,75 @@ def start(port):
|
|
|
85
85
|
break # Got an HTTP response — server is up
|
|
86
86
|
except Exception:
|
|
87
87
|
time.sleep(0.5)
|
|
88
|
+
else:
|
|
89
|
+
click.echo("[ERROR] MCP server did not start in time. Try again.", err=True)
|
|
90
|
+
stop_tunnel(tunnel_process)
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
click.echo(f"[letswork] MCP server running on port {port}", err=True)
|
|
88
94
|
|
|
89
95
|
# Register letswork MCP for the host (needed for approvals)
|
|
96
|
+
click.echo("[letswork] Registering host MCP...", err=True)
|
|
90
97
|
register_guest_mcp(mcp_url, host_token)
|
|
91
98
|
|
|
92
99
|
# Open Claude Code in a new Terminal window
|
|
93
100
|
launch_claude_code(project_root, url, host_token)
|
|
94
101
|
|
|
95
|
-
#
|
|
96
|
-
launch_chat_window(f"http://127.0.0.1:{port}/mcp", host_token, "host", name, project_root)
|
|
97
|
-
|
|
98
|
-
# Print session info — share guest_token with collaborator
|
|
102
|
+
# Print session info
|
|
99
103
|
click.echo("")
|
|
100
104
|
click.echo("╔══════════════════════════════════════════════════╗")
|
|
101
105
|
click.echo("║ 🤝 LetsWork Session Active ║")
|
|
102
106
|
click.echo("║ ║")
|
|
103
|
-
click.echo(f"║
|
|
104
|
-
click.echo(f"║
|
|
107
|
+
click.echo(f"║ URL: {url}")
|
|
108
|
+
click.echo(f"║ Guest token: {guest_token}")
|
|
105
109
|
click.echo("║ ║")
|
|
106
|
-
click.echo("║ Share
|
|
107
|
-
click.echo("║ Press Ctrl+C to stop
|
|
110
|
+
click.echo("║ Share URL + Guest token with your collaborator. ║")
|
|
111
|
+
click.echo("║ Press Ctrl+C to stop. ║")
|
|
108
112
|
click.echo("╚══════════════════════════════════════════════════╝")
|
|
109
113
|
click.echo("")
|
|
110
114
|
|
|
115
|
+
if debug:
|
|
116
|
+
click.echo("[debug] Live activity log:")
|
|
117
|
+
|
|
118
|
+
def _log_event(event):
|
|
119
|
+
ts = event.timestamp.strftime("%H:%M:%S")
|
|
120
|
+
etype = event.event_type.value
|
|
121
|
+
click.echo(f" [{ts}] {etype} — {event.data}")
|
|
122
|
+
|
|
123
|
+
event_log.on_event(_log_event)
|
|
124
|
+
else:
|
|
125
|
+
click.echo("Tip: run with --debug to see live tool activity.")
|
|
126
|
+
click.echo("")
|
|
127
|
+
|
|
111
128
|
try:
|
|
112
129
|
while True:
|
|
113
130
|
time.sleep(1)
|
|
114
131
|
except KeyboardInterrupt:
|
|
115
|
-
click.echo("\
|
|
132
|
+
click.echo("\n[letswork] Shutting down...")
|
|
116
133
|
finally:
|
|
117
134
|
stop_tunnel(tunnel_process)
|
|
118
|
-
click.echo("Session ended.")
|
|
135
|
+
click.echo("[letswork] Session ended.")
|
|
119
136
|
|
|
120
137
|
|
|
121
138
|
@cli.command()
|
|
122
139
|
@click.argument("url")
|
|
123
|
-
@click.option("--token", prompt="Enter session token", help="
|
|
140
|
+
@click.option("--token", prompt="Enter session token", help="Guest token from the host")
|
|
124
141
|
def join(url, token):
|
|
125
142
|
"""Join a LetsWork session as a guest."""
|
|
126
|
-
from letswork.launcher import register_guest_mcp, launch_guest_claude_code
|
|
127
|
-
|
|
128
|
-
name = click.prompt("What's your name?")
|
|
143
|
+
from letswork.launcher import register_guest_mcp, launch_guest_claude_code
|
|
129
144
|
|
|
130
145
|
if not url.endswith("/mcp"):
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
146
|
+
mcp_url = url.rstrip("/") + "/mcp"
|
|
147
|
+
else:
|
|
148
|
+
mcp_url = url
|
|
134
149
|
|
|
135
|
-
|
|
136
|
-
register_guest_mcp(url, token)
|
|
150
|
+
click.echo(f"\n[letswork] Connecting to {mcp_url}...")
|
|
137
151
|
|
|
138
|
-
#
|
|
139
|
-
|
|
152
|
+
# Register MCP with Claude Code (via stdio proxy)
|
|
153
|
+
register_guest_mcp(mcp_url, token)
|
|
140
154
|
|
|
141
|
-
# Open
|
|
142
|
-
|
|
155
|
+
# Open Claude Code in a new Terminal window
|
|
156
|
+
launch_guest_claude_code(os.getcwd(), mcp_url, token)
|
|
143
157
|
|
|
144
158
|
click.echo("✅ Claude Code is opening with LetsWork MCP connected.")
|
|
145
159
|
click.echo(" You can close this terminal.")
|
|
@@ -74,8 +74,13 @@ def register_guest_mcp(url: str, token: str) -> None:
|
|
|
74
74
|
Uses stdio transport (always works) rather than HTTP transport (unreliable
|
|
75
75
|
over Cloudflare SSE). The proxy forwards tool calls to the host via HTTP.
|
|
76
76
|
"""
|
|
77
|
+
if not shutil.which("claude"):
|
|
78
|
+
print("⚠️ Claude Code not found. Install: npm install -g @anthropic-ai/claude-code")
|
|
79
|
+
return
|
|
80
|
+
|
|
77
81
|
proxy_path = shutil.which("letswork-proxy")
|
|
78
82
|
if not proxy_path:
|
|
83
|
+
print("⚠️ letswork-proxy not found. Try: pip install --upgrade letswork")
|
|
79
84
|
return
|
|
80
85
|
|
|
81
86
|
# Remove any stale entry from all scopes
|
|
@@ -105,22 +110,3 @@ def launch_guest_claude_code(project_root: str, url: str, token: str) -> None:
|
|
|
105
110
|
print("Open a new terminal, cd to your project, and run: claude")
|
|
106
111
|
|
|
107
112
|
|
|
108
|
-
def launch_chat_window(url: str, token: str, role: str, name: str, project_root: str) -> None:
|
|
109
|
-
"""Open a new terminal window running the LetsWork chat window."""
|
|
110
|
-
args = (
|
|
111
|
-
f" --url {shlex.quote(url)}"
|
|
112
|
-
f" --token {shlex.quote(token)}"
|
|
113
|
-
f" --role {role}"
|
|
114
|
-
f" --name {shlex.quote(name)}"
|
|
115
|
-
)
|
|
116
|
-
# Prefer the installed script (resolves correctly when venv is active).
|
|
117
|
-
# Fall back to the Python that's running this process.
|
|
118
|
-
chat_bin = shutil.which("letswork-chat")
|
|
119
|
-
if chat_bin:
|
|
120
|
-
cmd = shlex.quote(chat_bin) + args
|
|
121
|
-
else:
|
|
122
|
-
python = shlex.quote(sys.executable)
|
|
123
|
-
cmd = f"{python} -m letswork.tui.chat_app{args}"
|
|
124
|
-
launched = _open_terminal(cmd, project_root)
|
|
125
|
-
if not launched:
|
|
126
|
-
print(f"Run in a new terminal: {cmd}")
|
|
@@ -0,0 +1,130 @@
|
|
|
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
|
+
Usage (done automatically by `letswork join`):
|
|
9
|
+
claude mcp add letswork -- letswork-proxy --url <URL> --token <TOKEN>
|
|
10
|
+
"""
|
|
11
|
+
import sys
|
|
12
|
+
import asyncio
|
|
13
|
+
import argparse
|
|
14
|
+
import logging
|
|
15
|
+
from mcp.server import Server
|
|
16
|
+
from mcp.server.stdio import stdio_server
|
|
17
|
+
from mcp.client.streamable_http import streamablehttp_client
|
|
18
|
+
from mcp import ClientSession, types
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger("letswork.proxy")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _setup_logging(debug: bool) -> None:
|
|
24
|
+
level = logging.DEBUG if debug else logging.WARNING
|
|
25
|
+
logging.basicConfig(
|
|
26
|
+
stream=sys.stderr,
|
|
27
|
+
level=level,
|
|
28
|
+
format="[proxy %(levelname)s] %(message)s",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def make_proxy_server(base_url: str, token: str) -> tuple:
|
|
33
|
+
"""Create an MCP Server that forwards calls to the remote host via a proper session."""
|
|
34
|
+
url = base_url.rstrip("/")
|
|
35
|
+
if not url.endswith("/mcp"):
|
|
36
|
+
url = url + "/mcp"
|
|
37
|
+
|
|
38
|
+
server = Server("letswork-proxy")
|
|
39
|
+
# Shared session state — populated once the client connects
|
|
40
|
+
_session: ClientSession | None = None
|
|
41
|
+
|
|
42
|
+
async def _get_session() -> ClientSession:
|
|
43
|
+
nonlocal _session
|
|
44
|
+
if _session is None:
|
|
45
|
+
raise RuntimeError("Not connected to host")
|
|
46
|
+
return _session
|
|
47
|
+
|
|
48
|
+
@server.list_tools()
|
|
49
|
+
async def list_tools() -> list[types.Tool]:
|
|
50
|
+
session = await _get_session()
|
|
51
|
+
result = await session.list_tools()
|
|
52
|
+
tools = []
|
|
53
|
+
for t in result.tools:
|
|
54
|
+
schema = t.inputSchema if t.inputSchema else {"type": "object", "properties": {}}
|
|
55
|
+
# Strip 'token' — 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.description or "",
|
|
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
|
+
session = await _get_session()
|
|
75
|
+
# Inject token automatically
|
|
76
|
+
arguments = {**arguments, "token": token}
|
|
77
|
+
log.debug(f"→ tool call: {name}({list(k for k in arguments if k != 'token')})")
|
|
78
|
+
try:
|
|
79
|
+
result = await session.call_tool(name, arguments)
|
|
80
|
+
except Exception as e:
|
|
81
|
+
log.error(f"✗ tool call {name} failed: {e}")
|
|
82
|
+
raise
|
|
83
|
+
out = []
|
|
84
|
+
for item in result.content:
|
|
85
|
+
if item.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
|
+
log.debug(f"← {name} OK")
|
|
90
|
+
return out
|
|
91
|
+
|
|
92
|
+
async def run(read_stream, write_stream):
|
|
93
|
+
nonlocal _session
|
|
94
|
+
log.debug(f"Connecting to host at {url}")
|
|
95
|
+
try:
|
|
96
|
+
async with streamablehttp_client(url) as (host_read, host_write, _):
|
|
97
|
+
async with ClientSession(host_read, host_write) as session:
|
|
98
|
+
await session.initialize()
|
|
99
|
+
_session = session
|
|
100
|
+
log.debug("Connected to host MCP server")
|
|
101
|
+
await server.run(
|
|
102
|
+
read_stream, write_stream,
|
|
103
|
+
server.create_initialization_options(),
|
|
104
|
+
)
|
|
105
|
+
except Exception as e:
|
|
106
|
+
log.error(f"Proxy connection failed: {e}")
|
|
107
|
+
raise
|
|
108
|
+
|
|
109
|
+
return server, run
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def _main(url: str, token: str, debug: bool) -> None:
|
|
113
|
+
_setup_logging(debug)
|
|
114
|
+
log.debug(f"Starting proxy → {url}")
|
|
115
|
+
_server, run = make_proxy_server(url, token)
|
|
116
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
117
|
+
await run(read_stream, write_stream)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def main() -> None:
|
|
121
|
+
parser = argparse.ArgumentParser(description="LetsWork MCP stdio proxy")
|
|
122
|
+
parser.add_argument("--url", required=True, help="Host MCP URL")
|
|
123
|
+
parser.add_argument("--token", required=True, help="Session token")
|
|
124
|
+
parser.add_argument("--debug", action="store_true", help="Enable debug logging")
|
|
125
|
+
args = parser.parse_args()
|
|
126
|
+
asyncio.run(_main(args.url, args.token, args.debug))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
if __name__ == "__main__":
|
|
130
|
+
main()
|
|
@@ -111,14 +111,5 @@ class RemoteClient:
|
|
|
111
111
|
def unlock_file(self, path: str) -> str:
|
|
112
112
|
return self._call_tool("unlock_file", {"token": self.token, "path": path})
|
|
113
113
|
|
|
114
|
-
def register_name(self, display_name: str) -> str:
|
|
115
|
-
return self._call_tool("register_name", {"token": self.token, "display_name": display_name})
|
|
116
|
-
|
|
117
|
-
def send_message(self, message: str) -> str:
|
|
118
|
-
return self._call_tool("send_message", {"token": self.token, "message": message})
|
|
119
|
-
|
|
120
|
-
def get_events(self, since_index: int = 0) -> str:
|
|
121
|
-
return self._call_tool("get_events", {"token": self.token, "since_index": since_index})
|
|
122
|
-
|
|
123
114
|
def get_status(self) -> str:
|
|
124
115
|
return self._call_tool("get_status", {"token": self.token})
|
|
@@ -37,6 +37,14 @@ def safe_resolve(path: str, root: str) -> str:
|
|
|
37
37
|
raise ValueError("Access denied: path outside project directory")
|
|
38
38
|
return resolved
|
|
39
39
|
|
|
40
|
+
@app.tool()
|
|
41
|
+
def ping(token: str) -> str:
|
|
42
|
+
if not check_auth(token):
|
|
43
|
+
raise ValueError("Unauthorized: invalid token")
|
|
44
|
+
user_id = get_user(token)
|
|
45
|
+
return f"pong — connected to {project_root} as {user_id}"
|
|
46
|
+
|
|
47
|
+
|
|
40
48
|
@app.tool()
|
|
41
49
|
def list_files(token: str, path: str = ".") -> str:
|
|
42
50
|
if not check_auth(token):
|
|
@@ -207,43 +215,6 @@ def get_status(token: str) -> str:
|
|
|
207
215
|
return "\n".join(status_lines)
|
|
208
216
|
|
|
209
217
|
|
|
210
|
-
@app.tool()
|
|
211
|
-
def register_name(token: str, display_name: str) -> str:
|
|
212
|
-
if not check_auth(token):
|
|
213
|
-
raise ValueError("Unauthorized: invalid token")
|
|
214
|
-
user_id = get_user(token)
|
|
215
|
-
token_to_user[token] = display_name
|
|
216
|
-
event_log.emit(EventType.CONNECTION, display_name, {})
|
|
217
|
-
return f"Registered as {display_name} (was {user_id})"
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
@app.tool()
|
|
221
|
-
def send_message(token: str, message: str) -> str:
|
|
222
|
-
if not check_auth(token):
|
|
223
|
-
raise ValueError("Unauthorized: invalid token")
|
|
224
|
-
user_id = get_user(token)
|
|
225
|
-
if not message.strip():
|
|
226
|
-
raise ValueError("Message cannot be empty")
|
|
227
|
-
event_log.emit(EventType.CHAT_MESSAGE, user_id, {"message": message})
|
|
228
|
-
return f"Message sent by {user_id}"
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
@app.tool()
|
|
232
|
-
def get_events(token: str, since_index: int = 0) -> str:
|
|
233
|
-
if not check_auth(token):
|
|
234
|
-
raise ValueError("Unauthorized: invalid token")
|
|
235
|
-
if since_index >= len(event_log._events):
|
|
236
|
-
return "no_new_events"
|
|
237
|
-
|
|
238
|
-
new_events = event_log._events[since_index:]
|
|
239
|
-
result_lines = []
|
|
240
|
-
for event in new_events:
|
|
241
|
-
result_lines.append(event.message)
|
|
242
|
-
|
|
243
|
-
result_lines.append(f"__INDEX__:{len(event_log._events)}")
|
|
244
|
-
return "\n".join(result_lines)
|
|
245
|
-
|
|
246
|
-
|
|
247
218
|
@app.tool()
|
|
248
219
|
def get_pending_changes(token: str) -> str:
|
|
249
220
|
if not check_auth(token):
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "letswork"
|
|
7
|
-
version = "2.0.
|
|
7
|
+
version = "2.0.2"
|
|
8
8
|
description = "Real-time collaborative coding via MCP — two developers, one codebase"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -13,14 +13,12 @@ authors = [{ name = "Sai Charan Rajoju" }]
|
|
|
13
13
|
dependencies = [
|
|
14
14
|
"mcp[cli]>=1.0.0",
|
|
15
15
|
"click>=8.1.0",
|
|
16
|
-
"
|
|
17
|
-
"websockets>=12.0"
|
|
16
|
+
"httpx>=0.27.0",
|
|
18
17
|
]
|
|
19
18
|
|
|
20
19
|
[project.scripts]
|
|
21
20
|
letswork = "letswork.cli:cli"
|
|
22
21
|
letswork-proxy = "letswork.proxy:main"
|
|
23
|
-
letswork-chat = "letswork.tui.chat_app:main"
|
|
24
22
|
|
|
25
23
|
[tool.hatch.build.targets.wheel]
|
|
26
24
|
packages = ["letswork"]
|
letswork-2.0.0/letswork/proxy.py
DELETED
|
@@ -1,109 +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 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()
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|