letswork 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,120 @@
1
+ Metadata-Version: 2.4
2
+ Name: letswork
3
+ Version: 0.1.0
4
+ Summary: Real-time collaborative coding via MCP — two developers, one codebase
5
+ Author: Sai Charan Rajoju
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: click>=8.1.0
9
+ Requires-Dist: mcp[cli]>=1.0.0
10
+ Requires-Dist: requests>=2.28.0
11
+ Requires-Dist: textual[syntax]>=0.50.0
12
+ Requires-Dist: websockets>=12.0
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest>=7.0; extra == 'dev'
15
+ Description-Content-Type: text/markdown
16
+
17
+ # LetsWork
18
+
19
+ **Google Docs for AI-assisted coding** — real-time collaboration on a local codebase using two independent Claude subscriptions.
20
+
21
+ ## What is LetsWork?
22
+
23
+ LetsWork is an MCP (Model Context Protocol) server that lets two developers work on the same local codebase simultaneously, each using their own Claude. One developer hosts, the other connects — with file-level locking to prevent conflicts.
24
+
25
+ ## How It Works
26
+
27
+ 1. Developer A (Host) runs `letswork start` in their project folder
28
+ 2. A secure HTTPS tunnel is created automatically via Cloudflare
29
+ 3. A one-time URL + secret token is generated
30
+ 4. Developer A shares both with Developer B (Guest)
31
+ 5. Developer B connects: `claude mcp add letswork --transport http <url>`
32
+ 6. Both can now read, write, and list files — with lock protection
33
+
34
+ ## Quick Start
35
+
36
+ ### Install
37
+ ```bash
38
+ pip install letswork
39
+ ```
40
+
41
+ ### Requirements
42
+
43
+ - Python >= 3.10
44
+ - [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) installed and available in PATH
45
+ - Git (recommended for conflict safety)
46
+
47
+ ### Host (Developer A)
48
+ ```bash
49
+ cd /path/to/your/project
50
+ letswork start
51
+ ```
52
+
53
+ You'll see:
54
+ ╔══════════════════════════════════════════════════╗
55
+ ║ LetsWork Session Active ║
56
+ ║ ║
57
+ ║ URL: https://abc123.trycloudflare.com ║
58
+ ║ Token: a1b2c3d4e5f6... ║
59
+ ║ ║
60
+ ║ Share both with your collaborator. ║
61
+ ║ Press Ctrl+C to end session. ║
62
+ ╚══════════════════════════════════════════════════╝
63
+
64
+ Share the URL and token with your collaborator via Slack, Discord, or text.
65
+
66
+ ### Guest (Developer B)
67
+ ```bash
68
+ claude mcp add letswork --transport http <URL_FROM_HOST>
69
+ ```
70
+
71
+ Use the token when prompted. You now have full access to the shared codebase through your own Claude.
72
+
73
+ ## MCP Tools Available
74
+
75
+ | Tool | Description |
76
+ |------|-------------|
77
+ | `list_files` | List files and directories with lock status |
78
+ | `read_file` | Read file contents (1MB limit) |
79
+ | `write_file` | Write to a file (auto-locks if needed) |
80
+ | `lock_file` | Lock a file for exclusive editing |
81
+ | `unlock_file` | Release a file lock |
82
+ | `get_status` | Show session info and active locks |
83
+
84
+ ## Security
85
+
86
+ - Unguessable tunnel URL (random Cloudflare subdomain)
87
+ - Cryptographic secret token (second auth layer)
88
+ - All traffic encrypted via HTTPS
89
+ - Path traversal prevention (no access outside project root)
90
+ - No accounts, no signup, no persistent credentials
91
+
92
+ ## CLI Commands
93
+
94
+ | Command | Description |
95
+ |---------|-------------|
96
+ | `letswork start [--port PORT]` | Start session (default port: 8000) |
97
+ | `letswork stop` | Stop instructions (use Ctrl+C in v1) |
98
+ | `letswork status` | Status instructions (use get_status tool in v1) |
99
+
100
+ ## Architecture
101
+ Developer A's Machine:
102
+ [Local Codebase] ← [MCP Server] ← [Cloudflare Tunnel] ← HTTPS URL
103
+
104
+ Developer B connects here
105
+ with secret token
106
+
107
+ ## Constraints (v1)
108
+
109
+ - Maximum 2 users per session (Host + Guest)
110
+ - Text files only (no binary support)
111
+ - 1MB file size limit per operation
112
+ - File operations only (no shell access for Guest)
113
+
114
+ ## License
115
+
116
+ MIT
117
+
118
+ ---
119
+
120
+ Built with the [Model Context Protocol](https://modelcontextprotocol.io).
@@ -0,0 +1,22 @@
1
+ src/__init__.py,sha256=V-C7MVI9SEarXdwLzTU79-D5BNdcJEZ3j_c-cXJoEaY,59
2
+ src/approval.py,sha256=sUU00NUnSKHezX7dvbg1i1zvKboWE3_zkFnY0wvB1ug,2936
3
+ src/auth.py,sha256=iqVHXp6LnSZ5laRNx7kP5DwAW-XmrouANWTTGKdGMbo,370
4
+ src/cli.py,sha256=-yDc3C7pU8rpwHPLefugCuCoLQ-m4Ub6OXKWBGXowoU,4254
5
+ src/events.py,sha256=ajXpZzVvoj3XccDUakUmow0KXGgVV2y-6H8ABnYChmU,2758
6
+ src/filelock.py,sha256=eCgDvG9RkBb5E2IAQk0Vbf_r4kmcEuOmqu-o2S0cA6Q,1286
7
+ src/launcher.py,sha256=j3viEHVw1iZX-T7fx0E6aHfaGdGdcypsho_Kz6cDIX8,2137
8
+ src/remote_client.py,sha256=7ioO43Iqx0fDzBQKySZJ6xi6jXfhduFH-TNovk1r_HE,3429
9
+ src/server.py,sha256=nNRV6Ii9Nyo_ZOwTXLnZ4v7C-UNbt-ZbvP1IJI--67o,7449
10
+ src/tunnel.py,sha256=xjU9yq3EGV4gwEZSjGEPnRVmow_7t8Zf_Ek_0aB_2NY,1467
11
+ src/tui/__init__.py,sha256=b4agVr1NW13jOLM19ubh7ka-D5NUGQkHSSJjkM_mGco,30
12
+ src/tui/app.py,sha256=2SNSmKUmCkJ0PW5DwkngwNJMajmMLzOJxagc5i43wrU,10932
13
+ src/tui/approval_panel.py,sha256=-gA8dlBuD_iM0--Lycx6NRFWaGLVOtBG3mvIZQsho0g,3318
14
+ src/tui/chat.py,sha256=iaQPoiG5jlL2AazBJCwlrUWGhigH92lLLgr0MYL2i_4,1531
15
+ src/tui/file_tree.py,sha256=9CdIZLlyX792t63sVP-pEvhJ79FyHFVZByP-Z4tqAdg,2293
16
+ src/tui/file_viewer.py,sha256=iwqcGRbfxuk0NTKD50O7zrXKHuuWakviwy-euv5_jXM,5866
17
+ src/tui/guest_app.py,sha256=9_YWwOjUHwiIhlZjtBRLdwAqXNCjQjnry0JuRnGDtw8,8011
18
+ src/tui/terminal_panel.py,sha256=-h8wr5oQ6gdTLbkOPa8jkno6PbiFqHOgw3sGopgahXQ,2336
19
+ letswork-0.1.0.dist-info/METADATA,sha256=yM7cWa4RS5Xdkib7b2RWLTDxxbYnUJ1WZS_ZRxfoIlE,3993
20
+ letswork-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
21
+ letswork-0.1.0.dist-info/entry_points.txt,sha256=5Sbn4KIDeVd-hiKaNaOpTsR8cP9i_EG0Zew69hXxWAk,41
22
+ letswork-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ letswork = src.cli:cli
src/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """LetsWork — real-time collaborative coding via MCP."""
src/approval.py ADDED
@@ -0,0 +1,98 @@
1
+ import os
2
+ import uuid
3
+ import difflib
4
+ from enum import Enum
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime
7
+
8
+ class ApprovalStatus(str, Enum):
9
+ PENDING = "pending"
10
+ APPROVED = "approved"
11
+ REJECTED = "rejected"
12
+
13
+ @dataclass
14
+ class PendingChange:
15
+ id: str
16
+ user_id: str
17
+ path: str
18
+ new_content: str
19
+ old_content: str
20
+ status: ApprovalStatus = ApprovalStatus.PENDING
21
+ timestamp: datetime = field(default_factory=datetime.now)
22
+
23
+ class ApprovalQueue:
24
+ def __init__(self, project_root: str):
25
+ self.project_root = project_root
26
+ self._pending: dict[str, PendingChange] = {}
27
+ self._history: list[PendingChange] = []
28
+
29
+ def submit(self, user_id: str, path: str, new_content: str) -> PendingChange:
30
+ change_id = str(uuid.uuid4())[:8]
31
+ abs_path = os.path.join(self.project_root, path)
32
+
33
+ if os.path.isfile(abs_path):
34
+ with open(abs_path, "r", encoding="utf-8") as f:
35
+ old_content = f.read()
36
+ else:
37
+ old_content = ""
38
+
39
+ change = PendingChange(
40
+ id=change_id,
41
+ user_id=user_id,
42
+ path=path,
43
+ new_content=new_content,
44
+ old_content=old_content
45
+ )
46
+ self._pending[change_id] = change
47
+ return change
48
+
49
+ def approve(self, change_id: str) -> bool:
50
+ if change_id not in self._pending:
51
+ return False
52
+
53
+ change = self._pending[change_id]
54
+ abs_path = os.path.join(self.project_root, change.path)
55
+
56
+ dirname = os.path.dirname(abs_path)
57
+ if dirname:
58
+ os.makedirs(dirname, exist_ok=True)
59
+
60
+ with open(abs_path, "w", encoding="utf-8") as f:
61
+ f.write(change.new_content)
62
+
63
+ change.status = ApprovalStatus.APPROVED
64
+ self._history.append(change)
65
+ del self._pending[change_id]
66
+ return True
67
+
68
+ def reject(self, change_id: str) -> bool:
69
+ if change_id not in self._pending:
70
+ return False
71
+
72
+ change = self._pending[change_id]
73
+ change.status = ApprovalStatus.REJECTED
74
+ self._history.append(change)
75
+ del self._pending[change_id]
76
+ return True
77
+
78
+ def get_pending(self) -> list[PendingChange]:
79
+ return list(self._pending.values())
80
+
81
+ def get_diff(self, change_id: str) -> str:
82
+ if change_id not in self._pending:
83
+ return "Change not found"
84
+
85
+ change = self._pending[change_id]
86
+ diff_lines = list(difflib.unified_diff(
87
+ change.old_content.split("\n"),
88
+ change.new_content.split("\n"),
89
+ fromfile=f"a/{change.path}",
90
+ tofile=f"b/{change.path}",
91
+ lineterm=""
92
+ ))
93
+
94
+ diff_str = "\n".join(diff_lines)
95
+ if not diff_str:
96
+ return "No changes detected"
97
+
98
+ return diff_str
src/auth.py ADDED
@@ -0,0 +1,12 @@
1
+ import secrets
2
+ import hmac
3
+
4
+
5
+ def generate_token() -> str:
6
+ """Generate a cryptographically secure URL-safe token for session auth."""
7
+ return secrets.token_urlsafe(32)
8
+
9
+
10
+ def validate_token(provided: str, expected: str) -> bool:
11
+ """Validate a token using constant-time comparison to prevent timing attacks."""
12
+ return hmac.compare_digest(provided, expected)
src/cli.py ADDED
@@ -0,0 +1,132 @@
1
+ import os
2
+ import click
3
+ import src.server as server_module
4
+ from src.auth import generate_token
5
+ from src.tunnel import start_tunnel, stop_tunnel
6
+
7
+
8
+ @click.group()
9
+ def cli():
10
+ """LetsWork — real-time collaborative coding via MCP."""
11
+ pass
12
+
13
+
14
+ @cli.command()
15
+ @click.option("--port", default=8000, type=int, help="Port for the MCP server")
16
+ def start(port):
17
+ """Start a LetsWork collaboration session."""
18
+ import os
19
+ import threading
20
+ import src.server as server_module
21
+ from src.auth import generate_token
22
+ from src.tunnel import start_tunnel, stop_tunnel
23
+ from src.events import EventLog
24
+ from src.launcher import launch_with_claude_code
25
+
26
+ # Set up project root
27
+ project_root = os.getcwd()
28
+ server_module.project_root = project_root
29
+
30
+ # Generate token
31
+ token = generate_token()
32
+ server_module.session_token = token
33
+
34
+ # Set up event log
35
+ event_log = EventLog()
36
+ server_module.event_log = event_log
37
+
38
+ # Start tunnel
39
+ try:
40
+ url, tunnel_process = start_tunnel(port)
41
+ except RuntimeError as e:
42
+ click.echo(f"Error: {e}")
43
+ return
44
+
45
+ # Print session info
46
+ click.echo("")
47
+ click.echo("╔══════════════════════════════════════════════════╗")
48
+ click.echo("║ LetsWork Session Active ║")
49
+ click.echo("║ ║")
50
+ click.echo(f"║ URL: {url}/mcp")
51
+ click.echo(f"║ Token: {token}")
52
+ click.echo("║ ║")
53
+ click.echo("║ Share both with your collaborator. ║")
54
+ click.echo("╚══════════════════════════════════════════════════╝")
55
+ click.echo("")
56
+
57
+ # Start MCP server in a background thread
58
+ def run_server():
59
+ server_module.app.settings.host = "127.0.0.1"
60
+ server_module.app.settings.port = port
61
+ server_module.app.settings.stateless_http = True
62
+ if hasattr(server_module.app.settings, "transport_security") and server_module.app.settings.transport_security:
63
+ server_module.app.settings.transport_security.enable_dns_rebinding_protection = False
64
+ server_module.app.run(transport="streamable-http")
65
+
66
+ server_thread = threading.Thread(target=run_server, daemon=True)
67
+ server_thread.start()
68
+
69
+ # Give server a moment to start
70
+ import time
71
+ time.sleep(2)
72
+
73
+ # Launch tmux split with TUI + Claude Code
74
+ try:
75
+ launch_with_claude_code(project_root, url, token, port)
76
+ except KeyboardInterrupt:
77
+ click.echo("\nShutting down...")
78
+ finally:
79
+ stop_tunnel(tunnel_process)
80
+ click.echo("Session ended.")
81
+
82
+
83
+ @cli.command()
84
+ @click.argument("url")
85
+ @click.option("--token", prompt="Enter session token", help="Secret token from the host")
86
+ @click.option("--user", default="guest", help="Your username")
87
+ def join(url, token, user):
88
+ """Join a LetsWork session as a guest."""
89
+ import os
90
+ from src.events import EventLog
91
+ from src.approval import ApprovalQueue
92
+ from src.filelock import LockManager
93
+ from src.tui.app import LetsWorkApp
94
+
95
+ click.echo(f"Connecting to {url}...")
96
+ click.echo(f"User: {user}")
97
+ click.echo("")
98
+
99
+ # Guest TUI uses a local event log for display
100
+ event_log = EventLog()
101
+ lock_manager = LockManager()
102
+
103
+ # Guest doesn't have local project root — use a temp dir
104
+ # Files are accessed remotely through MCP tools
105
+ project_root = os.getcwd()
106
+
107
+ app = LetsWorkApp(
108
+ project_root=project_root,
109
+ lock_manager=lock_manager,
110
+ event_log=event_log,
111
+ approval_queue=None,
112
+ guest_mode=True,
113
+ mcp_url=url,
114
+ mcp_token=token,
115
+ user_id=user,
116
+ )
117
+ app.run()
118
+
119
+
120
+ @cli.command()
121
+ def stop():
122
+ click.echo("In v1, use Ctrl+C in the terminal running 'letswork start' to stop the session.")
123
+
124
+
125
+ @cli.command()
126
+ def status():
127
+ click.echo("In v1, use the get_status MCP tool to check session status.")
128
+ click.echo("A standalone status command will be available in v2.")
129
+
130
+
131
+ if __name__ == "__main__":
132
+ cli()
src/events.py ADDED
@@ -0,0 +1,80 @@
1
+ from enum import Enum
2
+ from dataclasses import dataclass, field
3
+ from datetime import datetime
4
+ from typing import Callable
5
+
6
+ class EventType(str, Enum):
7
+ CONNECTION = "connection"
8
+ DISCONNECTION = "disconnection"
9
+ FILE_READ = "file_read"
10
+ FILE_WRITE = "file_write"
11
+ FILE_LOCK = "file_lock"
12
+ FILE_UNLOCK = "file_unlock"
13
+ CHAT_MESSAGE = "chat_message"
14
+ FILE_TREE_REQUEST = "file_tree_request"
15
+
16
+ @dataclass
17
+ class Event:
18
+ timestamp: datetime
19
+ event_type: EventType
20
+ user_id: str
21
+ data: dict = field(default_factory=dict)
22
+ message: str = ""
23
+
24
+ class EventLog:
25
+ def __init__(self):
26
+ self._events: list[Event] = []
27
+ self._listeners: list[Callable] = []
28
+
29
+ def emit(self, event_type: EventType, user_id: str, data: dict | None = None) -> Event:
30
+ if data is None:
31
+ data = {}
32
+
33
+ message = self.format_event(event_type, user_id, data)
34
+ event = Event(
35
+ timestamp=datetime.now(),
36
+ event_type=event_type,
37
+ user_id=user_id,
38
+ data=data,
39
+ message=message
40
+ )
41
+ self._events.append(event)
42
+
43
+ for listener in self._listeners:
44
+ try:
45
+ listener(event)
46
+ except Exception:
47
+ pass
48
+
49
+ return event
50
+
51
+ def on_event(self, callback: Callable) -> None:
52
+ self._listeners.append(callback)
53
+
54
+ def get_recent(self, count: int = 50) -> list[Event]:
55
+ return self._events[-count:]
56
+
57
+ def format_event(self, event_type: EventType, user_id: str, data: dict | None = None) -> str:
58
+ if data is None:
59
+ data = {}
60
+
61
+ time = datetime.now().strftime("%H:%M:%S")
62
+
63
+ if event_type == EventType.CONNECTION:
64
+ return f"[{time}] ✅ {user_id} connected"
65
+ elif event_type == EventType.DISCONNECTION:
66
+ return f"[{time}] ❌ {user_id} disconnected"
67
+ elif event_type == EventType.FILE_READ:
68
+ return f"[{time}] 📖 {user_id} read {data.get('path', '?')}"
69
+ elif event_type == EventType.FILE_WRITE:
70
+ return f"[{time}] ✏️ {user_id} wrote {data.get('path', '?')}"
71
+ elif event_type == EventType.FILE_LOCK:
72
+ return f"[{time}] 🔒 {user_id} locked {data.get('path', '?')}"
73
+ elif event_type == EventType.FILE_UNLOCK:
74
+ return f"[{time}] 🔓 {user_id} unlocked {data.get('path', '?')}"
75
+ elif event_type == EventType.CHAT_MESSAGE:
76
+ return f"[{time}] 💬 {user_id}: {data.get('message', '')}"
77
+ elif event_type == EventType.FILE_TREE_REQUEST:
78
+ return f"[{time}] 📁 {user_id} viewed file tree"
79
+
80
+ return f"[{time}] {event_type} event by {user_id}"
src/filelock.py ADDED
@@ -0,0 +1,37 @@
1
+ class LockManager:
2
+ """Manages file-level locks for collaborative editing. Maps file paths to user IDs."""
3
+
4
+ def __init__(self):
5
+ self._locks: dict[str, str] = {}
6
+
7
+ def acquire_lock(self, path: str, user_id: str) -> bool:
8
+ """Acquire a lock on a file for the given user."""
9
+ if path not in self._locks:
10
+ self._locks[path] = user_id
11
+ return True
12
+ if self._locks[path] == user_id:
13
+ return True
14
+ return False
15
+
16
+ def release_lock(self, path: str, user_id: str) -> bool:
17
+ """Release a lock only if the user is the current owner."""
18
+ if path not in self._locks:
19
+ return False
20
+ if self._locks[path] != user_id:
21
+ return False
22
+ del self._locks[path]
23
+ return True
24
+
25
+ def get_locks(self) -> dict[str, str]:
26
+ """Return a copy of all active locks."""
27
+ return self._locks.copy()
28
+
29
+ def is_locked(self, path: str) -> tuple[bool, str | None]:
30
+ """Check if a file is locked. Returns (is_locked, holder_user_id)."""
31
+ if path in self._locks:
32
+ return (True, self._locks[path])
33
+ return (False, None)
34
+
35
+ def release_all(self) -> None:
36
+ """Release all locks. Used when session ends."""
37
+ self._locks.clear()
src/launcher.py ADDED
@@ -0,0 +1,45 @@
1
+ import subprocess
2
+ import shutil
3
+ import os
4
+ import sys
5
+
6
+
7
+ def launch_with_claude_code(project_root: str, tunnel_url: str, token: str, port: int) -> None:
8
+ """Open a new Terminal tab with Claude Code, then launch TUI in current terminal."""
9
+
10
+ if sys.platform == "darwin":
11
+ # Mac: open new Terminal tab with Claude Code
12
+ claude_path = shutil.which("claude")
13
+ if claude_path:
14
+ apple_script = f'''
15
+ tell application "Terminal"
16
+ activate
17
+ do script "cd {project_root} && clear && echo '╔══════════════════════════════════════════════════╗' && echo '║ 🤖 Claude Code — Connected to LetsWork ║' && echo '║ ║' && echo '║ MCP URL: {tunnel_url}/mcp' && echo '║ Token: {token}' && echo '║ ║' && echo '║ Try: list_files, read_file, write_file ║' && echo '╚══════════════════════════════════════════════════╝' && echo '' && claude"
18
+ end tell
19
+ '''
20
+ subprocess.Popen(["osascript", "-e", apple_script])
21
+ else:
22
+ print("⚠️ Claude Code not found. Install with: npm install -g @anthropic-ai/claude-code")
23
+ print(" Open a second terminal and run: claude")
24
+ else:
25
+ # Linux/Windows: just print instructions
26
+ print(f"Open a second terminal and run:")
27
+ print(f" cd {project_root}")
28
+ print(f" claude")
29
+ print(f"Claude Code will connect to your LetsWork MCP server automatically.")
30
+
31
+ # Launch TUI in current terminal
32
+ import src.server as server_module
33
+ from src.tui.app import LetsWorkApp
34
+
35
+ app = LetsWorkApp(
36
+ project_root=project_root,
37
+ lock_manager=server_module.lock_manager,
38
+ event_log=server_module.event_log,
39
+ )
40
+ app.run()
41
+
42
+
43
+ def kill_session() -> None:
44
+ """No-op."""
45
+ pass
src/remote_client.py ADDED
@@ -0,0 +1,89 @@
1
+ import requests
2
+ import json
3
+ import uuid
4
+
5
+
6
+ class RemoteClient:
7
+ """Calls LetsWork MCP tools over HTTP. Guest TUI uses this instead of local file access."""
8
+
9
+ def __init__(self, mcp_url: str, token: str):
10
+ self.mcp_url = mcp_url.rstrip("/")
11
+ self.token = token
12
+ self.session_id = None
13
+
14
+ def _call_mcp(self, method: str, params: dict = None) -> dict:
15
+ payload = {
16
+ "jsonrpc": "2.0",
17
+ "id": str(uuid.uuid4()),
18
+ "method": method,
19
+ "params": params or {}
20
+ }
21
+ headers = {
22
+ "Content-Type": "application/json",
23
+ "Accept": "application/json, text/event-stream",
24
+ }
25
+ if self.session_id:
26
+ headers["Mcp-Session-Id"] = self.session_id
27
+ try:
28
+ response = requests.post(self.mcp_url, json=payload, headers=headers, timeout=15)
29
+ if "Mcp-Session-Id" in response.headers:
30
+ self.session_id = response.headers["Mcp-Session-Id"]
31
+ content_type = response.headers.get("Content-Type", "")
32
+ if "text/event-stream" in content_type:
33
+ # Parse the last "data:" line as JSON
34
+ last_data = None
35
+ for line in response.text.splitlines():
36
+ if line.startswith("data:"):
37
+ last_data = line[len("data:"):].strip()
38
+ if last_data:
39
+ return json.loads(last_data)
40
+ return {}
41
+ return response.json()
42
+ except Exception:
43
+ return {}
44
+
45
+ def initialize(self) -> bool:
46
+ try:
47
+ self._call_mcp("initialize", {
48
+ "protocolVersion": "2025-03-26",
49
+ "capabilities": {},
50
+ "clientInfo": {"name": "letswork-guest", "version": "1.0"}
51
+ })
52
+ self._call_mcp("notifications/initialized", {})
53
+ return True
54
+ except Exception:
55
+ return False
56
+
57
+ def call_tool(self, tool_name: str, arguments: dict = None) -> str:
58
+ try:
59
+ result = self._call_mcp("tools/call", {"name": tool_name, "arguments": arguments or {}})
60
+ try:
61
+ return result["result"]["content"][0]["text"]
62
+ except (KeyError, IndexError, TypeError):
63
+ return str(result)
64
+ except requests.RequestException:
65
+ return "Error: connection failed"
66
+
67
+ def list_files(self, path: str = ".") -> str:
68
+ return self.call_tool("list_files", {"path": path})
69
+
70
+ def read_file(self, path: str) -> str:
71
+ return self.call_tool("read_file", {"path": path})
72
+
73
+ def write_file(self, path: str, content: str, user_id: str) -> str:
74
+ return self.call_tool("write_file", {"path": path, "content": content, "user_id": user_id})
75
+
76
+ def lock_file(self, path: str, user_id: str) -> str:
77
+ return self.call_tool("lock_file", {"path": path, "user_id": user_id})
78
+
79
+ def unlock_file(self, path: str, user_id: str) -> str:
80
+ return self.call_tool("unlock_file", {"path": path, "user_id": user_id})
81
+
82
+ def send_message(self, user_id: str, message: str) -> str:
83
+ return self.call_tool("send_message", {"user_id": user_id, "message": message})
84
+
85
+ def get_events(self, since_index: int = 0) -> str:
86
+ return self.call_tool("get_events", {"since_index": since_index})
87
+
88
+ def get_status(self) -> str:
89
+ return self.call_tool("get_status", {})