Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
dc1ba29
Merge pull request #4 from Lumen-Labs/dev
ChrisCoder9000 Jan 12, 2026
a233442
Merge pull request #6 from Lumen-Labs/development
ChrisCoder9000 Feb 1, 2026
8dc02d4
Merge pull request #8 from Lumen-Labs/development
ChrisCoder9000 Feb 2, 2026
72504a7
Merge pull request #9 from Lumen-Labs/development
ChrisCoder9000 Feb 3, 2026
e1ca99b
Merge pull request #10 from Lumen-Labs/development
ChrisCoder9000 Feb 5, 2026
3bdfc93
Merge pull request #11 from Lumen-Labs/development
ChrisCoder9000 Feb 5, 2026
87c5cf8
Merge pull request #12 from Lumen-Labs/development
ChrisCoder9000 Feb 7, 2026
3a62380
Merge pull request #13 from Lumen-Labs/development
ChrisCoder9000 Feb 9, 2026
01dc120
Merge pull request #14 from Lumen-Labs/development
ChrisCoder9000 Feb 9, 2026
c76d1e6
Merge pull request #15 from Lumen-Labs/development
ChrisCoder9000 Feb 9, 2026
4d0de85
Merge pull request #16 from Lumen-Labs/development
ChrisCoder9000 Feb 9, 2026
5349f2a
Merge pull request #17 from Lumen-Labs/development
ChrisCoder9000 Feb 9, 2026
e20db55
Merge pull request #18 from Lumen-Labs/development
ChrisCoder9000 Feb 9, 2026
a6768fc
Merge pull request #19 from Lumen-Labs/development
ChrisCoder9000 Feb 19, 2026
6fc2b54
Merge pull request #20 from Lumen-Labs/development
ChrisCoder9000 Feb 19, 2026
8bcfc60
Merge branch 'development'
ChrisCoder9000 Feb 19, 2026
abc804b
Merge branch 'development'
ChrisCoder9000 Feb 19, 2026
d045030
Merge branch 'development'
ChrisCoder9000 Feb 19, 2026
133c6c1
Merge branch 'development'
ChrisCoder9000 Feb 19, 2026
4b775f2
Merge branch 'development'
ChrisCoder9000 Feb 19, 2026
1e4cced
Merge branch 'development'
ChrisCoder9000 Feb 19, 2026
1518e6b
Merge branch 'development'
ChrisCoder9000 Feb 19, 2026
75e7577
Merge branch 'development'
ChrisCoder9000 Feb 19, 2026
e379e56
Merge branch 'development'
ChrisCoder9000 Feb 19, 2026
da161da
fix(mcp): removed DNS rebinding protection
ChrisCoder9000 Feb 20, 2026
34d75c4
fix(mcp): dns rebinding issue
ChrisCoder9000 Feb 20, 2026
a12b9c0
fix(mcp): fixed dns rebinding issue to manual
ChrisCoder9000 Feb 20, 2026
cf9b57a
bump version 2.7.10-dev
ChrisCoder9000 Feb 20, 2026
f0c2622
fix(mcp): dns rebinding issue fixed
ChrisCoder9000 Feb 20, 2026
5ff66bc
bump version 2.7.11-dev
ChrisCoder9000 Feb 20, 2026
fa457ae
fix(mcp): mounting app route
ChrisCoder9000 Feb 20, 2026
220c113
bump version 2.7.12-dev
ChrisCoder9000 Feb 20, 2026
5038b03
fix(mcp): async request handling
ChrisCoder9000 Feb 20, 2026
9e972ab
fix(mcp): requests handling
ChrisCoder9000 Feb 20, 2026
8b857a1
bump version 2.7.15-dev
ChrisCoder9000 Feb 20, 2026
10337ac
bump version 2.7.15-dev
ChrisCoder9000 Feb 20, 2026
5d6c876
Merge branch 'development'
ChrisCoder9000 Feb 22, 2026
4d4ba1c
bump version 2.7.16-dev
ChrisCoder9000 Feb 22, 2026
6a7b1e1
fix(mcp): routing
ChrisCoder9000 Feb 22, 2026
2a86cb5
Merge pull request #21 from Lumen-Labs/development
ChrisCoder9000 Feb 22, 2026
80d0a10
Merge pull request #22 from Lumen-Labs/development
ChrisCoder9000 Feb 22, 2026
1254631
Merge pull request #23 from Lumen-Labs/development
ChrisCoder9000 Mar 2, 2026
3e4c74c
fix(sibilings retrieval): fixed missing embeddings accessoring
ChrisCoder9000 Mar 4, 2026
8312be2
fix(sibilings retrieval): fixed missing embeddings accessoring
ChrisCoder9000 Mar 4, 2026
0cd74f9
bump version 2.8.1-dev
ChrisCoder9000 Mar 4, 2026
8718288
Merge pull request #24 from Lumen-Labs/development
ChrisCoder9000 Mar 8, 2026
a92a89d
Merge pull request #25 from Lumen-Labs/development
ChrisCoder9000 Mar 8, 2026
1738711
Merge pull request #26 from Lumen-Labs/development
ChrisCoder9000 Mar 8, 2026
74481c3
Merge pull request #27 from Lumen-Labs/development
ChrisCoder9000 Mar 9, 2026
4d98fc8
Merge pull request #28 from Lumen-Labs/development
ChrisCoder9000 Mar 9, 2026
2027d00
Merge pull request #29 from Lumen-Labs/development
ChrisCoder9000 Mar 9, 2026
0a4ee79
Merge pull request #30 from Lumen-Labs/development
ChrisCoder9000 Mar 9, 2026
4a6fd35
Merge pull request #31 from Lumen-Labs/development
ChrisCoder9000 Mar 10, 2026
2e4e8bf
Merge pull request #32 from Lumen-Labs/development
ChrisCoder9000 Mar 10, 2026
17d39f4
Merge pull request #33 from Lumen-Labs/development
ChrisCoder9000 Mar 10, 2026
ddfdaba
Merge pull request #34 from Lumen-Labs/development
ChrisCoder9000 Mar 14, 2026
2d2b79c
Merge pull request #35 from Lumen-Labs/development
ChrisCoder9000 Mar 14, 2026
653876a
Merge pull request #36 from Lumen-Labs/development
ChrisCoder9000 Mar 14, 2026
8edf6c1
Merge pull request #37 from Lumen-Labs/development
ChrisCoder9000 Mar 14, 2026
68e8abd
Merge pull request #38 from Lumen-Labs/development
ChrisCoder9000 Mar 14, 2026
55169a8
Merge pull request #39 from Lumen-Labs/development
ChrisCoder9000 Mar 15, 2026
1e32fcc
Merge pull request #40 from Lumen-Labs/development
ChrisCoder9000 Mar 22, 2026
5ca139b
Merge pull request #44 from Lumen-Labs/development
ChrisCoder9000 Mar 29, 2026
3e03cfc
Merge pull request #49 from Lumen-Labs/development
ChrisCoder9000 Apr 11, 2026
67f3db3
Merge pull request #50 from Lumen-Labs/development
ChrisCoder9000 Apr 11, 2026
3c6c872
Merge pull request #51 from Lumen-Labs/development
ChrisCoder9000 Apr 12, 2026
12954cb
Merge pull request #56 from Lumen-Labs/development
ChrisCoder9000 Apr 18, 2026
424c97c
Merge pull request #57 from Lumen-Labs/development
ChrisCoder9000 Apr 18, 2026
2ae52ab
Merge pull request #58 from Lumen-Labs/development
ChrisCoder9000 Apr 18, 2026
5768022
feat(mcp): optional OAuth server for MCP clients like Claude
cursoragent Apr 19, 2026
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: 2 additions & 0 deletions example-docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,8 @@ services:
env_file:
- /root/.env
environment:
MCP_OAUTH_ISSUER_URL: ${MCP_OAUTH_ISSUER_URL:-}
MCP_RESOURCE_SERVER_URL: ${MCP_RESOURCE_SERVER_URL:-}
BRAINAPI_PLUGINS: ${BRAINAPI_PLUGINS:-}
PLUGIN_REGISTRY_URL: ${PLUGIN_REGISTRY_URL:-}
PLUGIN_PUBLISHER_ID: ${PLUGIN_PUBLISHER_ID:-}
Expand Down
35 changes: 27 additions & 8 deletions src/services/mcp/app.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging
import os
from contextlib import asynccontextmanager
from pathlib import Path

import dotenv
Expand All @@ -11,7 +12,7 @@
_project_root = Path(__file__).resolve().parent.parent.parent.parent
dotenv.load_dotenv(_project_root / ".env")

from src.services.mcp.main import auth_token_var, mcp
from src.services.mcp.main import auth_token_var, mcp, oauth_provider

PLUGINS_DIR = Path(os.getenv("PLUGINS_DIR", str(_project_root / "plugins")))

Expand Down Expand Up @@ -92,10 +93,17 @@ async def __call__(self, scope, receive, send):
token = brainpat.decode()
else:
raw = (headers.get(b"authorization") or b"").decode()
bearer = None
if raw.startswith("Bearer: "):
token = raw.removeprefix("Bearer: ").strip() or None
bearer = raw.removeprefix("Bearer: ").strip() or None
elif raw.startswith("Bearer "):
token = raw.removeprefix("Bearer ").strip() or None
bearer = raw.removeprefix("Bearer ").strip() or None
if bearer:
if oauth_provider:
pat = oauth_provider.get_pat_for_access_token(bearer)
token = pat if pat else bearer
else:
token = bearer
auth_token_var.set(token)
await self.app(scope, receive, send)

Expand All @@ -105,10 +113,21 @@ async def _health(_request):


async def _mcp_info(_request):
return JSONResponse(
{"service": "brainapi-mcp", "streamable_http": True, "path": "/mcp"},
status_code=200,
)
body = {
"service": "brainapi-mcp",
"streamable_http": True,
"path": "/mcp",
}
if oauth_provider:
body["oauth"] = True
body["oauth_consent_path"] = "/mcp-oauth/consent"
return JSONResponse(body, status_code=200)


@asynccontextmanager
async def _lifespan(app):
async with _mcp_app.router.lifespan_context(_mcp_app):
yield


_custom_routes = [
Expand All @@ -119,5 +138,5 @@ async def _mcp_info(_request):
app = Starlette(
routes=_custom_routes + list(_mcp_app.routes),
middleware=[Middleware(AuthContextMiddleware)],
lifespan=_mcp_app.router.lifespan_context,
lifespan=_lifespan,
)
130 changes: 129 additions & 1 deletion src/services/mcp/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,16 @@
"""

import asyncio
import html
import os
from contextvars import ContextVar
from typing import Any

from mcp.server import FastMCP
from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions
from pydantic import AnyHttpUrl, AnyUrl
from starlette.requests import Request
from starlette.responses import HTMLResponse, RedirectResponse

from src.core.instances import (
data_adapter,
Expand All @@ -22,13 +28,135 @@
vector_store_adapter,
)
from src.lib.neo4j.client import _neo4j_client
from src.services.mcp.oauth_provider import BrainapiMcpOAuthProvider
from src.services.mcp.utils import guard_brainpat
from src.utils.vector_search import VectorSearchFacade

auth_token_var: ContextVar[str | None] = ContextVar("auth_token", default=None)
vector_search = VectorSearchFacade(vector_store_adapter)

mcp = FastMCP("brainapi-mcp", stateless_http=True, host="0.0.0.0")
_oauth_issuer = os.getenv("MCP_OAUTH_ISSUER_URL", "").strip()
_oauth_resource = os.getenv("MCP_RESOURCE_SERVER_URL", "").strip()
if _oauth_issuer and not _oauth_resource:
_oauth_resource = _oauth_issuer.rstrip("/") + "/mcp"

_oauth_scopes = [
s for s in os.getenv("MCP_OAUTH_SCOPES", "brainapi").strip().split() if s
]
if not _oauth_scopes:
_oauth_scopes = ["brainapi"]

_access_ttl = int(os.getenv("MCP_OAUTH_ACCESS_TOKEN_TTL", "3600"))
_refresh_ttl_raw = os.getenv("MCP_OAUTH_REFRESH_TOKEN_TTL", "").strip()
_refresh_ttl = int(_refresh_ttl_raw) if _refresh_ttl_raw else None
_code_ttl = int(os.getenv("MCP_OAUTH_AUTH_CODE_TTL", "600"))

oauth_provider: BrainapiMcpOAuthProvider | None = None
if _oauth_issuer:
oauth_provider = BrainapiMcpOAuthProvider(
issuer_url=_oauth_issuer,
resource_server_url=_oauth_resource,
valid_scopes=_oauth_scopes,
access_token_ttl_seconds=_access_ttl,
refresh_token_ttl_seconds=_refresh_ttl,
auth_code_ttl_seconds=_code_ttl,
)
_doc_url = os.getenv("MCP_OAUTH_SERVICE_DOCUMENTATION_URL", "").strip()
mcp = FastMCP(
"brainapi-mcp",
stateless_http=True,
host="0.0.0.0",
auth_server_provider=oauth_provider,
auth=AuthSettings(
issuer_url=AnyHttpUrl(_oauth_issuer),
resource_server_url=AnyHttpUrl(_oauth_resource),
service_documentation_url=AnyHttpUrl(_doc_url) if _doc_url else None,
client_registration_options=ClientRegistrationOptions(
enabled=True,
valid_scopes=_oauth_scopes,
default_scopes=_oauth_scopes,
),
),
)
else:
mcp = FastMCP("brainapi-mcp", stateless_http=True, host="0.0.0.0")


if oauth_provider:

async def _mcp_oauth_consent(request: Request) -> HTMLResponse | RedirectResponse:
if request.method == "GET":
q = request.query_params
client_id = q.get("client_id") or ""
redirect_uri = q.get("redirect_uri") or ""
code_challenge = q.get("code_challenge") or ""
scope = q.get("scope") or " ".join(_oauth_scopes)
resource = q.get("resource") or ""
state = q.get("state") or ""
if not (client_id and redirect_uri and code_challenge):
return HTMLResponse("Missing OAuth parameters", status_code=400)
client = await oauth_provider.get_client(client_id)
if not client:
return HTMLResponse("Unknown client_id", status_code=400)
esc = html.escape
form = f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Authorize BrainAPI MCP</title></head>
<body>
<h1>Connect to BrainAPI</h1>
<p>Enter your BrainPAT (personal access token for your brain). This authorizes the MCP client to act with that token.</p>
<form method="post" action="/mcp-oauth/consent">
<input type="hidden" name="client_id" value="{esc(client_id)}">
<input type="hidden" name="redirect_uri" value="{esc(redirect_uri)}">
<input type="hidden" name="code_challenge" value="{esc(code_challenge)}">
<input type="hidden" name="scope" value="{esc(scope)}">
<input type="hidden" name="resource" value="{esc(resource)}">
<input type="hidden" name="state" value="{esc(state)}">
<label for="brainpat">BrainPAT</label>
<input id="brainpat" name="brainpat" type="password" autocomplete="off" required style="width:100%;max-width:40em">
<button type="submit">Authorize</button>
</form>
</body></html>"""
return HTMLResponse(form)

form = await request.form()
client_id = str(form.get("client_id") or "")
redirect_uri = str(form.get("redirect_uri") or "")
code_challenge = str(form.get("code_challenge") or "")
scope_str = str(form.get("scope") or "")
resource = str(form.get("resource") or "") or None
state = str(form.get("state") or "") or None
brainpat = str(form.get("brainpat") or "").strip()
if not (client_id and redirect_uri and code_challenge and brainpat):
return HTMLResponse("Missing fields", status_code=400)
client = await oauth_provider.get_client(client_id)
if not client:
return HTMLResponse("Unknown client", status_code=400)
if guard_brainpat(brainpat) is False:
return HTMLResponse("Invalid BrainPAT", status_code=401)
scopes = scope_str.split() if scope_str.strip() else list(_oauth_scopes)
for s in scopes:
if s not in _oauth_scopes:
return HTMLResponse("Invalid scope", status_code=400)
try:
ru = AnyUrl(redirect_uri)
ru = client.validate_redirect_uri(ru)
except Exception:
return HTMLResponse("Invalid redirect_uri", status_code=400)
code = oauth_provider.issue_auth_code(
client_id=client_id,
redirect_uri=ru,
code_challenge=code_challenge,
scopes=scopes,
resource=resource,
state=state,
brainpat=brainpat,
)
url = oauth_provider.redirect_after_consent(
redirect_uri=redirect_uri, code=code, state=state
)
return RedirectResponse(url, status_code=302)

mcp.custom_route("/mcp-oauth/consent", methods=["GET", "POST"])(_mcp_oauth_consent)


@mcp.tool()
Expand Down
Loading