Skip to content

Commit 9303610

Browse files
committed
Enable HTTP transport for local MCP clients
Add a Streamable HTTP server path so MCP clients can use the workspace tool registry outside stdio while preserving explicit session setup and lightweight request gating. Constraint: MCP transport must stay local-first and preserve existing stdio behavior Rejected: Replacing stdio transport entirely | would break current MCP usage and remove the simpler local path Confidence: high Scope-risk: moderate Directive: Keep new MCP transports aligned with the shared handle_mcp_request contract and session semantics Tested: Reviewed staged CLI, server, docs, and transport test coverage for session, auth, origin, batch, SSE, and delete flows Not-tested: Automated test execution and live HTTP client interoperability
1 parent 92f41e9 commit 9303610

7 files changed

Lines changed: 593 additions & 3 deletions

File tree

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,20 @@ teaagent agent resume gpt <run_id>
104104

105105
The TUI `progress on` command streams audit events (iteration, tool calls, completion) during agent runs. The LLM adapter supports streaming via the `stream` parameter on `LLMRequest`.
106106

107+
### MCP Server
108+
109+
Expose the workspace tool pack to MCP clients over stdio JSON-RPC or Streamable HTTP:
110+
111+
```bash
112+
# stdio (default)
113+
teaagent mcp serve --root /path/to/repo
114+
115+
# Streamable HTTP on loopback (POST /mcp, GET /mcp SSE, DELETE /mcp)
116+
teaagent mcp serve --http --port 7330 --auth-token "$MCP_TOKEN"
117+
```
118+
119+
`initialize` issues a fresh `Mcp-Session-Id` header; every later request must echo it. Pass `--allowed-origin` (repeatable) to restrict browser callers. See [docs/cli.md](docs/cli.md#mcp-server) for full transport details.
120+
107121
## Development
108122

109123
```bash

docs/cli.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,22 @@ Serve the workspace tool pack to other MCP clients over stdio JSON-RPC:
303303
teaagent mcp serve --root /path/to/repo
304304
```
305305

306+
Or over Streamable HTTP transport on loopback:
307+
308+
```bash
309+
teaagent mcp serve --http --root /path/to/repo --port 7330
310+
```
311+
312+
Streamable HTTP details:
313+
314+
- POST `/mcp`: send one JSON-RPC request or a batch. Server responds with `application/json`.
315+
- GET `/mcp`: open a `text/event-stream` keep-alive (no server-initiated notifications yet).
316+
- DELETE `/mcp`: terminate the session.
317+
- `initialize` returns a fresh `Mcp-Session-Id` response header. Every later request must echo it.
318+
- Default bind is `127.0.0.1` only. Use `--host 0.0.0.0` deliberately and pair it with auth.
319+
- `--auth-token TOKEN` requires `Authorization: Bearer TOKEN` on every request.
320+
- `--allowed-origin URL` may be repeated to whitelist browser Origin headers. Default: allow all.
321+
306322
Supported methods: `initialize`, `tools/list`, `tools/call`. Each tool is exposed with its `inputSchema` and read-only / destructive / idempotent annotations. Tool errors are returned as `result.isError = true` rather than JSON-RPC errors so the client can recover.
307323

308324
## Subagent Delegation

docs/p2-scope.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,15 @@
55
- In-memory Graph RAG primitives for entity/relation traversal and document retrieval.
66
- Restricted Code Mode for deterministic local data manipulation with AST allow-list validation.
77
- Stateless MCP request/response envelopes that carry capabilities and shared state per request.
8+
- Streamable HTTP transport for the MCP server with `Mcp-Session-Id` sessions, optional bearer-token auth, and Origin allowlist (POST/GET/DELETE on `/mcp`).
89
- Managed-agent readiness checks for tool metadata, audit, budget, external state, and HITL gaps.
910
- Provider portability checks for tool calling, structured output, system prompt support, prompt caching, and context limits.
1011

1112
## Still Deferred
1213

1314
- Production GraphQLite deployment, migrations, and Cypher query tuning.
1415
- Production sandboxing for Code Mode using containers, V8 isolates, or a managed execution service.
15-
- Live MCP Streamable HTTP transport and OAuth 2.1 / DPoP enforcement.
16+
- OAuth 2.1 / DPoP enforcement on top of the Streamable HTTP transport.
1617
- Actual managed runtime integration with Anthropic, OpenAI, Google ADK, or Vertex Agent Engine.
1718
- Cross-provider live conformance tests.
1819

teaagent/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
check_llm_configuration,
4242
create_llm_adapter,
4343
)
44+
from teaagent.mcp_http import build_mcp_http_server, serve_mcp_http
4445
from teaagent.mcp_server import handle_mcp_request, serve_mcp_stdio
4546
from teaagent.memory import MemoryCatalog, MemoryEntry
4647
from teaagent.model_routing import ModelRoute, classify_task, route_model
@@ -137,6 +138,7 @@
137138
"available_providers",
138139
"assemble_agent_prompt",
139140
"build_aibom",
141+
"build_mcp_http_server",
140142
"build_task_spec",
141143
"build_workspace_tool_registry",
142144
"check_graphqlite_runtime",
@@ -156,6 +158,7 @@
156158
"route_model",
157159
"register_subagent_tool",
158160
"register_workspace_tools",
161+
"serve_mcp_http",
159162
"serve_mcp_stdio",
160163
"run_chat_agent",
161164
"run_eval",

teaagent/cli.py

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
check_llm_configuration,
2121
create_llm_adapter,
2222
)
23+
from teaagent.mcp_http import DEFAULT_PORT as MCP_HTTP_DEFAULT_PORT
24+
from teaagent.mcp_http import serve_mcp_http
2325
from teaagent.mcp_server import serve_mcp_stdio
2426
from teaagent.memory import MemoryCatalog
2527
from teaagent.model_routing import route_model
@@ -300,8 +302,38 @@ def build_parser() -> argparse.ArgumentParser:
300302

301303
mcp = subparsers.add_parser("mcp", help="Run a local MCP-compatible server.")
302304
mcp_subparsers = mcp.add_subparsers(dest="mcp_command", required=True)
303-
mcp_serve = mcp_subparsers.add_parser("serve", help="Serve workspace tools over stdio JSON-RPC.")
305+
mcp_serve = mcp_subparsers.add_parser(
306+
"serve",
307+
help="Serve workspace tools over stdio JSON-RPC or Streamable HTTP.",
308+
)
304309
mcp_serve.add_argument("--root", default=".", help="Workspace root. Defaults to current directory.")
310+
mcp_serve.add_argument(
311+
"--http",
312+
action="store_true",
313+
help="Serve over Streamable HTTP transport instead of stdio JSON-RPC.",
314+
)
315+
mcp_serve.add_argument(
316+
"--host",
317+
default="127.0.0.1",
318+
help="HTTP bind host. Defaults to 127.0.0.1 (loopback only).",
319+
)
320+
mcp_serve.add_argument(
321+
"--port",
322+
type=int,
323+
default=MCP_HTTP_DEFAULT_PORT,
324+
help=f"HTTP port. Defaults to {MCP_HTTP_DEFAULT_PORT}.",
325+
)
326+
mcp_serve.add_argument(
327+
"--auth-token",
328+
default=None,
329+
help="Require this bearer token on every HTTP request.",
330+
)
331+
mcp_serve.add_argument(
332+
"--allowed-origin",
333+
action="append",
334+
default=[],
335+
help="Permit this Origin header. Can be repeated. Default: allow all origins.",
336+
)
305337
mcp_serve.set_defaults(func=mcp_serve_command)
306338

307339
workspace = subparsers.add_parser("workspace", help="Inspect workspace tool pack.")
@@ -569,7 +601,16 @@ def ultrawork_stop_command(args: argparse.Namespace) -> int:
569601

570602

571603
def mcp_serve_command(args: argparse.Namespace) -> int:
572-
return serve_mcp_stdio(build_workspace_tool_registry(args.root))
604+
registry = build_workspace_tool_registry(args.root)
605+
if args.http:
606+
return serve_mcp_http(
607+
registry,
608+
host=args.host,
609+
port=args.port,
610+
auth_token=args.auth_token,
611+
allowed_origins=args.allowed_origin or None,
612+
)
613+
return serve_mcp_stdio(registry)
573614

574615

575616
def workspace_tools_metadata(args: argparse.Namespace) -> int:

teaagent/mcp_http.py

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import secrets
5+
import threading
6+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
7+
from typing import Any, Optional
8+
9+
from teaagent.mcp_server import handle_mcp_request
10+
from teaagent.tools import ToolRegistry
11+
12+
MCP_PATH = "/mcp"
13+
SESSION_HEADER = "Mcp-Session-Id"
14+
DEFAULT_PORT = 7330
15+
16+
17+
class MCPSessionStore:
18+
"""In-memory session store for the Streamable HTTP transport."""
19+
20+
def __init__(self) -> None:
21+
self._lock = threading.Lock()
22+
self._sessions: set[str] = set()
23+
24+
def create(self) -> str:
25+
session_id = secrets.token_urlsafe(24)
26+
with self._lock:
27+
self._sessions.add(session_id)
28+
return session_id
29+
30+
def has(self, session_id: str) -> bool:
31+
with self._lock:
32+
return session_id in self._sessions
33+
34+
def remove(self, session_id: str) -> bool:
35+
with self._lock:
36+
if session_id in self._sessions:
37+
self._sessions.remove(session_id)
38+
return True
39+
return False
40+
41+
42+
def build_mcp_http_server(
43+
registry: ToolRegistry,
44+
*,
45+
host: str = "127.0.0.1",
46+
port: int = DEFAULT_PORT,
47+
auth_token: Optional[str] = None,
48+
allowed_origins: Optional[list[str]] = None,
49+
) -> tuple[ThreadingHTTPServer, MCPSessionStore]:
50+
sessions = MCPSessionStore()
51+
origins = frozenset(allowed_origins) if allowed_origins else None
52+
handler_cls = _make_handler(registry, sessions, auth_token, origins)
53+
server = ThreadingHTTPServer((host, port), handler_cls)
54+
return server, sessions
55+
56+
57+
def serve_mcp_http(
58+
registry: ToolRegistry,
59+
*,
60+
host: str = "127.0.0.1",
61+
port: int = DEFAULT_PORT,
62+
auth_token: Optional[str] = None,
63+
allowed_origins: Optional[list[str]] = None,
64+
) -> int:
65+
server, _sessions = build_mcp_http_server(
66+
registry,
67+
host=host,
68+
port=port,
69+
auth_token=auth_token,
70+
allowed_origins=allowed_origins,
71+
)
72+
try:
73+
server.serve_forever()
74+
except KeyboardInterrupt:
75+
pass
76+
finally:
77+
server.server_close()
78+
return 0
79+
80+
81+
def _make_handler(
82+
registry: ToolRegistry,
83+
sessions: MCPSessionStore,
84+
auth_token: Optional[str],
85+
allowed_origins: Optional[frozenset[str]],
86+
) -> type[BaseHTTPRequestHandler]:
87+
class MCPHandler(BaseHTTPRequestHandler):
88+
protocol_version = "HTTP/1.1"
89+
90+
def log_message(self, format: str, *args: Any) -> None: # noqa: A002
91+
return
92+
93+
def _check_auth(self) -> bool:
94+
if auth_token is None:
95+
return True
96+
header = self.headers.get("Authorization", "")
97+
if not header.startswith("Bearer "):
98+
return False
99+
return secrets.compare_digest(header[len("Bearer ") :], auth_token)
100+
101+
def _check_origin(self) -> bool:
102+
if allowed_origins is None:
103+
return True
104+
origin = self.headers.get("Origin")
105+
if origin is None:
106+
return True
107+
return origin in allowed_origins
108+
109+
def _send_json(
110+
self,
111+
status: int,
112+
payload: Any,
113+
*,
114+
extra_headers: Optional[dict[str, str]] = None,
115+
) -> None:
116+
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
117+
self.send_response(status)
118+
self.send_header("Content-Type", "application/json")
119+
self.send_header("Content-Length", str(len(body)))
120+
for key, value in (extra_headers or {}).items():
121+
self.send_header(key, value)
122+
self.end_headers()
123+
self.wfile.write(body)
124+
125+
def _send_status(self, status: int, message: Optional[str] = None) -> None:
126+
body = (message or "").encode("utf-8")
127+
self.send_response(status)
128+
self.send_header("Content-Type", "text/plain")
129+
self.send_header("Content-Length", str(len(body)))
130+
self.end_headers()
131+
if body:
132+
self.wfile.write(body)
133+
134+
def _gate(self) -> bool:
135+
if self.path != MCP_PATH:
136+
self._send_status(404, "not found")
137+
return False
138+
if not self._check_origin():
139+
self._send_status(403, "forbidden origin")
140+
return False
141+
if not self._check_auth():
142+
self._send_status(401, "unauthorized")
143+
return False
144+
return True
145+
146+
def _read_body(self) -> tuple[Optional[Any], Optional[str]]:
147+
length = int(self.headers.get("Content-Length", "0") or "0")
148+
if length <= 0:
149+
return None, "missing JSON-RPC body"
150+
raw = self.rfile.read(length)
151+
try:
152+
return json.loads(raw.decode("utf-8")), None
153+
except (json.JSONDecodeError, UnicodeDecodeError):
154+
return None, "invalid JSON"
155+
156+
def do_POST(self) -> None:
157+
if not self._gate():
158+
return
159+
payload, error = self._read_body()
160+
if error is not None:
161+
self._send_status(400, error)
162+
return
163+
if not isinstance(payload, (dict, list)):
164+
self._send_status(400, "JSON-RPC payload must be object or array")
165+
return
166+
167+
session_header = self.headers.get(SESSION_HEADER)
168+
169+
if isinstance(payload, dict) and payload.get("method") == "initialize":
170+
response = handle_mcp_request(registry, payload)
171+
if response is None:
172+
self._send_status(400, "initialize requires an id")
173+
return
174+
session_id = sessions.create()
175+
self._send_json(200, response, extra_headers={SESSION_HEADER: session_id})
176+
return
177+
178+
if not session_header or not sessions.has(session_header):
179+
self._send_status(400, "missing or invalid Mcp-Session-Id")
180+
return
181+
182+
if isinstance(payload, list):
183+
responses: list[dict[str, Any]] = []
184+
for item in payload:
185+
if not isinstance(item, dict):
186+
continue
187+
response = handle_mcp_request(registry, item)
188+
if response is not None:
189+
responses.append(response)
190+
if not responses:
191+
self._send_status(202)
192+
return
193+
self._send_json(200, responses)
194+
return
195+
196+
response = handle_mcp_request(registry, payload)
197+
if response is None:
198+
self._send_status(202)
199+
return
200+
self._send_json(200, response)
201+
202+
def do_GET(self) -> None:
203+
if not self._gate():
204+
return
205+
session_header = self.headers.get(SESSION_HEADER)
206+
if not session_header or not sessions.has(session_header):
207+
self._send_status(400, "missing or invalid Mcp-Session-Id")
208+
return
209+
self.send_response(200)
210+
self.send_header("Content-Type", "text/event-stream")
211+
self.send_header("Cache-Control", "no-cache")
212+
self.send_header("Connection", "close")
213+
self.end_headers()
214+
self.wfile.write(b": teaagent mcp stream\n\n")
215+
self.wfile.flush()
216+
217+
def do_DELETE(self) -> None:
218+
if not self._gate():
219+
return
220+
session_header = self.headers.get(SESSION_HEADER)
221+
if not session_header or not sessions.has(session_header):
222+
self._send_status(404, "session not found")
223+
return
224+
sessions.remove(session_header)
225+
self._send_status(204)
226+
227+
def do_OPTIONS(self) -> None:
228+
self._send_status(204)
229+
230+
return MCPHandler

0 commit comments

Comments
 (0)