mcp-huddle 0.3.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.
mcp_huddle/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """mcp-huddle — persistent multi-agent chat MCP server."""
2
+ __version__ = "0.3.0"
mcp_huddle/__main__.py ADDED
@@ -0,0 +1,193 @@
1
+ """Entry point for `python -m mcp_huddle` and the `mcp-huddle` console script.
2
+
3
+ Two modes:
4
+ default stdio transport — for MCP clients (Claude Code, Codex, Antigravity,
5
+ Claude Desktop). Spawned per-client; storage in ~/.mcp-huddle/rooms/
6
+ is shared across processes via file locks.
7
+ --http HTTP server + Liquid Glass dashboard on :8014. Run once manually
8
+ to watch rooms in a browser. Dashboard is the only difference —
9
+ the MCP tools are the same.
10
+ """
11
+ import argparse
12
+ import os
13
+ import socket
14
+ import sys
15
+
16
+ DEFAULT_PORT = 8014
17
+
18
+
19
+ def _version() -> str:
20
+ try:
21
+ from importlib.metadata import PackageNotFoundError, version
22
+
23
+ try:
24
+ return version("mcp-huddle")
25
+ except PackageNotFoundError:
26
+ pass
27
+ except Exception:
28
+ pass
29
+ try:
30
+ from . import __version__
31
+
32
+ return __version__
33
+ except Exception:
34
+ return "unknown"
35
+
36
+
37
+ def _resolve_port(cli_port: "int | None") -> int:
38
+ """Resolve the HTTP port from --port, then $PORT, then the default.
39
+
40
+ An invalid value never crashes: it prints a clear warning and falls back
41
+ to the default port.
42
+ """
43
+ if cli_port is not None:
44
+ # argparse already validated/typed this.
45
+ return cli_port
46
+
47
+ raw = os.environ.get("PORT")
48
+ if raw is None or raw == "":
49
+ return DEFAULT_PORT
50
+ try:
51
+ port = int(raw)
52
+ except (TypeError, ValueError):
53
+ print(
54
+ f"warning: invalid PORT={raw!r}, using default {DEFAULT_PORT}",
55
+ file=sys.stderr,
56
+ flush=True,
57
+ )
58
+ return DEFAULT_PORT
59
+ if not (1 <= port <= 65535):
60
+ print(
61
+ f"warning: PORT={port} out of range 1-65535, using default {DEFAULT_PORT}",
62
+ file=sys.stderr,
63
+ flush=True,
64
+ )
65
+ return DEFAULT_PORT
66
+ return port
67
+
68
+
69
+ def _port_arg(value: str) -> int:
70
+ try:
71
+ port = int(value)
72
+ except (TypeError, ValueError):
73
+ raise argparse.ArgumentTypeError(f"invalid port {value!r}: must be an integer")
74
+ if not (1 <= port <= 65535):
75
+ raise argparse.ArgumentTypeError(f"port {port} out of range 1-65535")
76
+ return port
77
+
78
+
79
+ def _install_hooks(dest: "str | None") -> None:
80
+ """Copy the bundled example hooks (Claude Code PostToolUse / Stop) to a
81
+ directory and print how to wire them in. pip can't run post-install code
82
+ safely, so this is the explicit opt-in step a user runs after installing.
83
+ """
84
+ import shutil
85
+ from pathlib import Path
86
+
87
+ src_dir = Path(__file__).parent / "hooks"
88
+ target = Path(dest).expanduser() if dest else (Path.home() / ".mcp-huddle" / "hooks")
89
+ target.mkdir(parents=True, exist_ok=True)
90
+ copied = []
91
+ for sh in sorted(src_dir.glob("*.sh")):
92
+ out = target / sh.name
93
+ shutil.copyfile(sh, out)
94
+ out.chmod(0o755)
95
+ copied.append(out)
96
+ print(f"Installed {len(copied)} hook(s) to {target}:")
97
+ for c in copied:
98
+ print(f" {c}")
99
+ example = """
100
+ Wire them into Claude Code (~/.claude/settings.json), e.g.:
101
+ "hooks": {
102
+ "PostToolUse": [{"hooks": [{"type":"command","command":"__T__/claude-check.sh"}]}],
103
+ "Stop": [{"hooks": [{"type":"command","command":"__T__/session-end.sh"}]}]
104
+ }
105
+ claude-check.sh surfaces pending huddle requests; session-end.sh closes this session's rooms on exit."""
106
+ print(example.replace("__T__", str(target)))
107
+
108
+
109
+ def main() -> None:
110
+ parser = argparse.ArgumentParser(
111
+ prog="mcp-huddle",
112
+ description=(
113
+ "Persistent multi-agent coordination rooms over MCP. "
114
+ "Default mode is stdio transport for MCP clients; --http serves a "
115
+ "browser dashboard."
116
+ ),
117
+ )
118
+ parser.add_argument(
119
+ "--version",
120
+ action="version",
121
+ version=f"mcp-huddle {_version()}",
122
+ )
123
+ parser.add_argument(
124
+ "--http",
125
+ action="store_true",
126
+ help="run the HTTP server + dashboard instead of stdio transport",
127
+ )
128
+ parser.add_argument(
129
+ "--install-hooks",
130
+ nargs="?",
131
+ const="",
132
+ default=None,
133
+ metavar="DIR",
134
+ help="copy the bundled Claude Code hooks to DIR (default ~/.mcp-huddle/hooks) and exit",
135
+ )
136
+ parser.add_argument(
137
+ "--port",
138
+ type=_port_arg,
139
+ default=None,
140
+ help=f"HTTP port (default: $PORT or {DEFAULT_PORT}); only used with --http",
141
+ )
142
+ args = parser.parse_args()
143
+
144
+ if args.install_hooks is not None:
145
+ _install_hooks(args.install_hooks or None)
146
+ return
147
+
148
+ use_http = args.http or bool(os.environ.get("MCP_HUDDLE_HTTP"))
149
+
150
+ if use_http:
151
+ import uvicorn
152
+
153
+ from .server import build_app
154
+
155
+ host = "127.0.0.1"
156
+ port = _resolve_port(args.port)
157
+
158
+ # Bind first so the "ready" message only prints once we are actually
159
+ # listening — never advertise a URL the server failed to bind.
160
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
161
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
162
+ try:
163
+ sock.bind((host, port))
164
+ except OSError as exc:
165
+ sock.close()
166
+ print(f"error: cannot bind {host}:{port}: {exc}", file=sys.stderr, flush=True)
167
+ sys.exit(1)
168
+ sock.listen()
169
+
170
+ print(f"mcp-huddle (HTTP + dashboard) on :{port}", flush=True)
171
+ print(f"Dashboard: http://{host}:{port}/dashboard", flush=True)
172
+
173
+ # One-line-per-agent discovery summary (which agents are enabled / why
174
+ # disabled) so the operator can see the roster at a glance.
175
+ try:
176
+ from . import spawn
177
+
178
+ spawn.log_discovery_summary()
179
+ except Exception:
180
+ pass
181
+
182
+ config = uvicorn.Config(build_app(), log_level="warning")
183
+ server = uvicorn.Server(config)
184
+ server.run(sockets=[sock])
185
+ else:
186
+ # stdio transport — JSON-RPC over stdin/stdout. Default for MCP clients.
187
+ from .server import mcp
188
+
189
+ mcp.run()
190
+
191
+
192
+ if __name__ == "__main__":
193
+ main()
mcp_huddle/acp.py ADDED
@@ -0,0 +1,57 @@
1
+ """Phase 2.5 stub: Gemini ACP (Agent Client Protocol) daemon integration.
2
+
3
+ ACP is JSON-RPC 2.0 over stdio. Spec: https://geminicli.com/docs/cli/acp-mode/
4
+
5
+ INTENT (when implemented):
6
+ * Spawn `gemini --acp` once as a long-running daemon (one process per Gemini
7
+ identity, multiplexed across many rooms)
8
+ * Manage a session per room via `newSession` / `loadSession`
9
+ * On respond_via_agent for Gemini: send `prompt` JSON-RPC to existing daemon
10
+ instead of forking a new `gemini -p` process
11
+ * Stream events from daemon stdout back to per-room agent log files (the
12
+ existing SSE endpoint will pick them up unchanged)
13
+
14
+ EXPECTED BENEFITS:
15
+ * 0ms cold start vs ~3-5s for fresh `gemini -p`
16
+ * Persistent per-room context without prepending huge digests
17
+ * Native streaming events (typing indicators, tool calls, partial responses)
18
+
19
+ WHY THIS IS A STUB:
20
+ * ACP requires async lifecycle (start daemon, monitor health, restart on
21
+ crash, correlate JSON-RPC ids ↔ pending awaits)
22
+ * Spec details (auth flow, capabilities negotiation, session mode/model
23
+ overrides) need careful implementation + integration testing
24
+ * Risk of half-broken implementation is high; Phase 1 + Phase 2 (Codex
25
+ resume) already deliver the main value.
26
+
27
+ NEXT STEPS for whoever picks this up:
28
+ 1. Implement `class AcpClient` with async send/recv JSON-RPC over stdio
29
+ 2. Add `initialize`, `newSession`, `prompt`, `cancel` method wrappers
30
+ 3. Module-level singleton: `get_gemini_daemon()` that lazy-spawns the daemon
31
+ 4. In server.respond_via_agent for Gemini: route to AcpClient.prompt()
32
+ 5. Wire daemon stdout into agent log file (so SSE picks it up)
33
+ 6. Lifecycle: tear down daemon on huddle server shutdown (lifespan context)
34
+ 7. Tests: mock `gemini --acp` with a fake JSON-RPC responder
35
+
36
+ Reference implementations:
37
+ * Zed: https://zed.dev/acp/agent/gemini-cli
38
+ * IntelliJ: https://glaforge.dev/posts/2026/02/01/...
39
+ * Spec: https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/acp-mode.md
40
+ """
41
+
42
+ class AcpNotImplemented(NotImplementedError):
43
+ """Raised when Phase 2.5 ACP integration is not yet wired up."""
44
+ pass
45
+
46
+
47
+ def gemini_acp_prompt(session_id: str | None, prompt: str) -> str:
48
+ """Stub for Phase 2.5: send a prompt to Gemini --acp daemon.
49
+
50
+ Currently raises AcpNotImplemented. Use respond_via_agent for Codex
51
+ (Phase 2 Codex resume works) or a fresh `gemini -p` spawn.
52
+ """
53
+ raise AcpNotImplemented(
54
+ "Gemini --acp daemon integration is Phase 2.5 (not yet implemented). "
55
+ "Use respond_via_agent with agent_name='Codex' for resume support, "
56
+ "or auto_spawn={'Gemini': '<fresh brief>'} for a new process."
57
+ )