Skip to content

Commit d3dfdd4

Browse files
committed
api: fix MCP lifespan, streaming parser, --no-continue flag, MCP session protocol
1 parent 3832126 commit d3dfdd4

File tree

2 files changed

+131
-74
lines changed

2 files changed

+131
-74
lines changed

api_server.py

Lines changed: 75 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,22 @@
1414
from pydantic import BaseModel, ConfigDict, Field
1515

1616

17-
app = FastAPI()
17+
_mcp_lifespan_cm = None
18+
19+
20+
from contextlib import asynccontextmanager # noqa: E402
21+
22+
23+
@asynccontextmanager
24+
async def _lifespan(app):
25+
if _mcp_lifespan_cm:
26+
async with _mcp_lifespan_cm:
27+
yield
28+
else:
29+
yield
30+
31+
32+
app = FastAPI(lifespan=_lifespan)
1833

1934

2035
def _to_camel(name: str) -> str:
@@ -166,8 +181,8 @@ async def _run_claude_text(
166181
args = ["claude", "--dangerously-skip-permissions"]
167182
if resume:
168183
args += ["--resume", resume]
169-
elif no_continue:
170-
args.append("--no-continue")
184+
elif not no_continue:
185+
args.append("--continue")
171186
args += ["-p", prompt, "--output-format", "json"]
172187
if model:
173188
args += ["--model", model]
@@ -488,8 +503,8 @@ def _build_oai_run_args(
488503
no_continue: bool = True,
489504
) -> list[str]:
490505
args = ["claude", "--dangerously-skip-permissions"]
491-
if no_continue:
492-
args.append("--no-continue")
506+
if not no_continue:
507+
args.append("--continue")
493508
if streaming:
494509
args += ["-p", prompt, "--output-format", "stream-json", "--verbose"]
495510
else:
@@ -602,18 +617,20 @@ def _chunk(delta: dict, fr=None) -> str:
602617
except json.JSONDecodeError:
603618
continue
604619
etype = event.get("type", "")
605-
if etype == "message_start":
606-
model_name = event.get("message", {}).get("model", model_name)
607-
elif etype == "content_block_delta":
608-
delta = event.get("delta", {})
609-
if delta.get("type") == "text_delta":
610-
text = delta.get("text", "")
611-
if text:
612-
yield _chunk({"content": text})
613-
elif etype == "message_delta":
614-
stop = event.get("delta", {}).get("stop_reason")
615-
if stop:
616-
finish_reason = stop
620+
if etype == "assistant":
621+
msg = event.get("message", {})
622+
m = msg.get("model")
623+
if m:
624+
model_name = m
625+
for block in msg.get("content", []):
626+
if block.get("type") == "text":
627+
text = block.get("text", "")
628+
if text:
629+
yield _chunk({"content": text})
630+
elif etype == "result":
631+
sr = event.get("stop_reason", "")
632+
if sr:
633+
finish_reason = sr
617634

618635
await proc.wait()
619636
yield _chunk({}, finish_reason)
@@ -628,31 +645,7 @@ def _chunk(delta: dict, fr=None) -> str:
628645

629646
from mcp.server.fastmcp import FastMCP # noqa: E402
630647

631-
632-
class _MCPBearerAuth:
633-
"""Wrap an ASGI app with simple Bearer token auth."""
634-
635-
def __init__(self, asgi_app):
636-
self._app = asgi_app
637-
638-
async def __call__(self, scope, receive, send):
639-
if not API_TOKEN or scope["type"] not in ("http", "websocket"):
640-
await self._app(scope, receive, send)
641-
return
642-
headers = {k: v for k, v in scope.get("headers", [])}
643-
auth = headers.get(b"authorization", b"").decode()
644-
if auth != f"Bearer {API_TOKEN}":
645-
await send({
646-
"type": "http.response.start",
647-
"status": 401,
648-
"headers": [[b"content-type", b"application/json"]],
649-
})
650-
await send({"type": "http.response.body", "body": b'{"detail":"unauthorized"}'})
651-
return
652-
await self._app(scope, receive, send)
653-
654-
655-
_mcp = FastMCP("claude-code")
648+
_mcp = FastMCP("claude-code", streamable_http_path="/")
656649

657650

658651
@_mcp.tool()
@@ -774,7 +767,46 @@ async def delete_file(path: str) -> str:
774767
return json.dumps({"status": "ok", "path": path})
775768

776769

777-
app.mount("/mcp", _MCPBearerAuth(_mcp.http_app()))
770+
_mcp_app = _mcp.streamable_http_app()
771+
_mcp_lifespan_cm = _mcp_app.router.lifespan_context(_mcp_app)
772+
773+
774+
def _mcp_auth_check(scope) -> bool:
775+
if not API_TOKEN:
776+
return True
777+
headers = {k: v for k, v in scope.get("headers", [])}
778+
auth = headers.get(b"authorization", b"").decode()
779+
if auth == f"Bearer {API_TOKEN}":
780+
return True
781+
qs = scope.get("query_string", b"").decode()
782+
for part in qs.split("&"):
783+
if part.startswith("apiToken=") and part[9:] == API_TOKEN:
784+
return True
785+
return False
786+
787+
788+
class _MCPWithAuth:
789+
"""Route /mcp requests to the MCP ASGI handler with auth."""
790+
791+
def __init__(self, mcp_app):
792+
self._app = mcp_app
793+
794+
async def __call__(self, scope, receive, send):
795+
if scope["type"] not in ("http", "websocket"):
796+
await self._app(scope, receive, send)
797+
return
798+
if not _mcp_auth_check(scope):
799+
await send({
800+
"type": "http.response.start",
801+
"status": 401,
802+
"headers": [[b"content-type", b"application/json"]],
803+
})
804+
await send({"type": "http.response.body", "body": b'{"detail":"unauthorized"}'})
805+
return
806+
await self._app(scope, receive, send)
807+
808+
809+
app.mount("/mcp", _MCPWithAuth(_mcp_app))
778810

779811

780812
if __name__ == "__main__":

tests/test_api.sh

Lines changed: 56 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -391,14 +391,33 @@ test_api_openai_reasoning_effort() {
391391

392392
# ── MCP server ─────────────────────────────────────────────────────────────────
393393

394+
_MCP_ACCEPT="Accept: application/json, text/event-stream"
395+
_MCP_INIT='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'
396+
397+
# init MCP session: sets MCP_SESSION var
398+
_mcp_init() {
399+
local url="$1"
400+
MCP_SESSION=$(curl -s -D - -X POST "$url" \
401+
-H "Content-Type: application/json" -H "$_MCP_ACCEPT" \
402+
-d "$_MCP_INIT" | grep -i "mcp-session-id" | awk '{print $2}' | tr -d '\r\n')
403+
}
404+
405+
# send MCP JSON-RPC with session
406+
_mcp_call() {
407+
local url="$1" data="$2"
408+
curl -s -X POST "$url" \
409+
-H "Content-Type: application/json" -H "$_MCP_ACCEPT" \
410+
-H "mcp-session-id: $MCP_SESSION" \
411+
-d "$data"
412+
}
413+
394414
test_api_mcp_init() {
395415
_api_start "${API_CONTAINER}-mcp" || return 1
396416

397-
# MCP streamable HTTP: POST JSON-RPC initialize to /mcp
398-
local out code
399-
code=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$API_BASE/mcp" \
400-
-H "Content-Type: application/json" \
401-
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}')
417+
local code
418+
code=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$API_BASE/mcp/" \
419+
-H "Content-Type: application/json" -H "$_MCP_ACCEPT" \
420+
-d "$_MCP_INIT")
402421
assert_eq "$code" "200" "mcp initialize returns 200" || { _api_stop "${API_CONTAINER}-mcp"; return 1; }
403422

404423
echo "OK: api_mcp_init"
@@ -408,10 +427,11 @@ test_api_mcp_init() {
408427
test_api_mcp_tools_list() {
409428
_api_start "${API_CONTAINER}-mcp-tl" || return 1
410429

430+
_mcp_init "$API_BASE/mcp/"
431+
assert_not_empty "$MCP_SESSION" "mcp session id" || { _api_stop "${API_CONTAINER}-mcp-tl"; return 1; }
432+
411433
local out
412-
out=$(curl -sf -X POST "$API_BASE/mcp" \
413-
-H "Content-Type: application/json" \
414-
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}')
434+
out=$(_mcp_call "$API_BASE/mcp/" '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}')
415435
assert_contains "$out" "claude_run" "mcp tools/list has claude_run" || { _api_stop "${API_CONTAINER}-mcp-tl"; return 1; }
416436
assert_contains "$out" "list_files" "mcp tools/list has list_files" || { _api_stop "${API_CONTAINER}-mcp-tl"; return 1; }
417437
assert_contains "$out" "read_file" "mcp tools/list has read_file" || { _api_stop "${API_CONTAINER}-mcp-tl"; return 1; }
@@ -425,10 +445,11 @@ test_api_mcp_tools_list() {
425445
test_api_mcp_claude_run() {
426446
_api_start "${API_CONTAINER}-mcp-run" || return 1
427447

448+
_mcp_init "$API_BASE/mcp/"
449+
428450
local out
429-
out=$(curl -sf -X POST "$API_BASE/mcp" \
430-
-H "Content-Type: application/json" \
431-
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"claude_run","arguments":{"prompt":"respond with exactly MCPTEST","model":"'"$TEST_MODEL"'","no_continue":true}}}')
451+
out=$(_mcp_call "$API_BASE/mcp/" \
452+
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"claude_run","arguments":{"prompt":"respond with exactly MCPTEST","model":"'"$TEST_MODEL"'","no_continue":true}}}')
432453
assert_contains "$out" "MCPTEST" "mcp claude_run returns response" || { _api_stop "${API_CONTAINER}-mcp-run"; return 1; }
433454

434455
echo "OK: api_mcp_claude_run"
@@ -438,30 +459,28 @@ test_api_mcp_claude_run() {
438459
test_api_mcp_file_ops() {
439460
_api_start "${API_CONTAINER}-mcp-f" || return 1
440461

462+
_mcp_init "$API_BASE/mcp/"
463+
441464
local out
442465

443466
# write
444-
out=$(curl -sf -X POST "$API_BASE/mcp" \
445-
-H "Content-Type: application/json" \
446-
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"write_file","arguments":{"path":"mcptest.txt","content":"hello mcp"}}}')
467+
out=$(_mcp_call "$API_BASE/mcp/" \
468+
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"write_file","arguments":{"path":"mcptest.txt","content":"hello mcp"}}}')
447469
assert_contains "$out" "ok" "mcp write_file ok" || { _api_stop "${API_CONTAINER}-mcp-f"; return 1; }
448470

449471
# read
450-
out=$(curl -sf -X POST "$API_BASE/mcp" \
451-
-H "Content-Type: application/json" \
452-
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"read_file","arguments":{"path":"mcptest.txt"}}}')
472+
out=$(_mcp_call "$API_BASE/mcp/" \
473+
'{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"read_file","arguments":{"path":"mcptest.txt"}}}')
453474
assert_contains "$out" "hello mcp" "mcp read_file returns content" || { _api_stop "${API_CONTAINER}-mcp-f"; return 1; }
454475

455476
# list
456-
out=$(curl -sf -X POST "$API_BASE/mcp" \
457-
-H "Content-Type: application/json" \
458-
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_files","arguments":{}}}')
477+
out=$(_mcp_call "$API_BASE/mcp/" \
478+
'{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"list_files","arguments":{}}}')
459479
assert_contains "$out" "mcptest.txt" "mcp list_files shows written file" || { _api_stop "${API_CONTAINER}-mcp-f"; return 1; }
460480

461481
# delete
462-
out=$(curl -sf -X POST "$API_BASE/mcp" \
463-
-H "Content-Type: application/json" \
464-
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"delete_file","arguments":{"path":"mcptest.txt"}}}')
482+
out=$(_mcp_call "$API_BASE/mcp/" \
483+
'{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"delete_file","arguments":{"path":"mcptest.txt"}}}')
465484
assert_contains "$out" "ok" "mcp delete_file ok" || { _api_stop "${API_CONTAINER}-mcp-f"; return 1; }
466485

467486
echo "OK: api_mcp_file_ops"
@@ -473,17 +492,23 @@ test_api_mcp_auth() {
473492

474493
# no token → 401
475494
local code
476-
code=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$API_BASE/mcp" \
477-
-H "Content-Type: application/json" \
478-
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}')
495+
code=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$API_BASE/mcp/" \
496+
-H "Content-Type: application/json" -H "$_MCP_ACCEPT" \
497+
-d "$_MCP_INIT")
479498
assert_eq "$code" "401" "mcp no token returns 401" || { _api_stop "${API_CONTAINER}-mcp-auth"; return 1; }
480499

481-
# correct token → 200
482-
code=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$API_BASE/mcp" \
483-
-H "Content-Type: application/json" \
500+
# correct token via header → 200
501+
code=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$API_BASE/mcp/" \
502+
-H "Content-Type: application/json" -H "$_MCP_ACCEPT" \
484503
-H "Authorization: Bearer mcpsecret" \
485-
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}')
486-
assert_eq "$code" "200" "mcp correct token returns 200" || { _api_stop "${API_CONTAINER}-mcp-auth"; return 1; }
504+
-d "$_MCP_INIT")
505+
assert_eq "$code" "200" "mcp correct token via header returns 200" || { _api_stop "${API_CONTAINER}-mcp-auth"; return 1; }
506+
507+
# correct token via query param → 200
508+
code=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$API_BASE/mcp/?apiToken=mcpsecret" \
509+
-H "Content-Type: application/json" -H "$_MCP_ACCEPT" \
510+
-d "$_MCP_INIT")
511+
assert_eq "$code" "200" "mcp correct token via query param returns 200" || { _api_stop "${API_CONTAINER}-mcp-auth"; return 1; }
487512

488513
echo "OK: api_mcp_auth"
489514
_api_stop "${API_CONTAINER}-mcp-auth"

0 commit comments

Comments
 (0)