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 +2 -0
- mcp_huddle/__main__.py +193 -0
- mcp_huddle/acp.py +57 -0
- mcp_huddle/bus.py +957 -0
- mcp_huddle/hooks/claude-check.sh +12 -0
- mcp_huddle/hooks/gemini-check.sh +11 -0
- mcp_huddle/hooks/session-end.sh +9 -0
- mcp_huddle/mimo_runner.py +180 -0
- mcp_huddle/openai_compatible_runner.py +219 -0
- mcp_huddle/py.typed +0 -0
- mcp_huddle/server.py +1529 -0
- mcp_huddle/spawn.py +947 -0
- mcp_huddle/static/dashboard.css +1512 -0
- mcp_huddle/static/dashboard.html +91 -0
- mcp_huddle/static/dashboard.js +1362 -0
- mcp_huddle-0.3.0.dist-info/METADATA +292 -0
- mcp_huddle-0.3.0.dist-info/RECORD +20 -0
- mcp_huddle-0.3.0.dist-info/WHEEL +4 -0
- mcp_huddle-0.3.0.dist-info/entry_points.txt +2 -0
- mcp_huddle-0.3.0.dist-info/licenses/LICENSE +21 -0
mcp_huddle/__init__.py
ADDED
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
|
+
)
|