Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ Positive:
</docker-install>
<routes>
<route>
<url>mcp</url>
<url>^/mcp</url>
<verb>POST,GET,DELETE</verb>
<access_level>USER</access_level>
<headers_to_exclude>[]</headers_to_exclude>
Expand Down
47 changes: 47 additions & 0 deletions ex_app/lib/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,52 @@
mcp.add_middleware(ToolListMiddleware(mcp))
http_mcp_app = mcp.http_app("/", transport="http", stateless_http=True)


MCP_METHOD_NOT_ALLOWED = json.dumps({
"jsonrpc": "2.0",
"id": "server-error",
"error": {"code": -32600, "message": "Method Not Allowed: this server does not offer an SSE stream"},
}).encode()


class MCPTransportMiddleware:
"""Smooth over two rough edges of the mounted MCP app.

1. The MCP app is mounted at /mcp and serves "/", so Starlette answers a bare
/mcp with a 307 whose Location is rebuilt from the forwarded Host, dropping
the AppAPI proxy prefix. MCP clients follow redirects, land on Nextcloud
itself and get a 404, which the MCP SDK surfaces as "Session terminated".
Serve /mcp directly instead of redirecting to /mcp/.
2. We run the MCP app stateless, so a standalone GET stream can never carry
anything: every request gets its own transport and server-initiated
messages go out over that request's own SSE stream. Left to the SDK the
GET opens a stream that never emits and never closes, pinning a proxy
connection per client. Answer 405 instead, which clients handle as
"no SSE stream offered here".
Comment on lines +60 to +65

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose this is because using stateful would make the harp -> context_agent connection stateful so even when user -> harp connection changes, when the user changes, the same connection harp -> context_agent stays and may leak info?
or can we use stateful MCP connections?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One reason for not doing stateful MCP is that the stateful connections would have to reach through the proxy chain which is unreliable and costly. Another reason is that we do not actuallly have any state to keep server-side, so it doesn't make much sense, IMO.

"""

def __init__(self, app):
self.app = app

async def __call__(self, scope, receive, send):
if scope["type"] == "http" and scope.get("path") in ("/mcp", "/mcp/"):
if scope["method"] == "GET":
await send({
"type": "http.response.start",
"status": 405,
"headers": [
(b"content-type", b"application/json"),
(b"content-length", str(len(MCP_METHOD_NOT_ALLOWED)).encode()),
(b"allow", b"POST, DELETE"),
],
})
await send({"type": "http.response.body", "body": MCP_METHOD_NOT_ALLOWED})
return
if scope["path"] == "/mcp":
scope = dict(scope, path="/mcp/", raw_path=b"/mcp/")
await self.app(scope, receive, send)
Comment thread
kyteinsky marked this conversation as resolved.


fast_app = FastAPI(lifespan=http_mcp_app.lifespan)

app_enabled = Event()
Expand Down Expand Up @@ -278,6 +324,7 @@ async def wait_for_task(interval = None):


APP.mount("/mcp", http_mcp_app)
APP.add_middleware(MCPTransportMiddleware)

if __name__ == "__main__":
# Wrapper around `uvicorn.run`.
Expand Down
Loading