Skip to content

Commit 5aaed4c

Browse files
committed
feat: FastMCPNorthMiddleware for easier FastMCP integration
1 parent 974b087 commit 5aaed4c

File tree

3 files changed

+172
-0
lines changed

3 files changed

+172
-0
lines changed

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,33 @@ This repository provides code to enable your server to use authentication with N
2222
* You can access the user's identity (from the identity provider used with North).
2323
* **Debug mode** for detailed authentication logging and troubleshooting.
2424

25+
## FastMCP Integration
26+
27+
If you are building on top of `FastMCP` directly, you can install the lightweight middleware shipped with this SDK to capture North-specific context without enabling full authentication:
28+
29+
```python
30+
from fastmcp import FastMCP
31+
from north_mcp_python_sdk.middleware import (
32+
FastMCPNorthMiddleware,
33+
get_north_request_context,
34+
)
35+
36+
mcp = FastMCP("Demo")
37+
app = mcp.streamable_http_app()
38+
app.add_middleware(FastMCPNorthMiddleware)
39+
40+
41+
@mcp.tool()
42+
def echo(_: dict) -> dict:
43+
ctx = get_north_request_context()
44+
return {
45+
"user_id_token": ctx.user_id_token,
46+
"connector_tokens": ctx.connector_tokens,
47+
}
48+
```
49+
50+
The middleware reads the `X-North-ID-Token` header (if present) and parses Base64-encoded JSON from `X-North-Connector-Tokens`. It never returns a 401—it simply exposes these values through a context variable and `request.state.north_context` for downstream handlers.
51+
2552
## Examples
2653

2754
This repository contains example servers that you can use as a quickstart. You can find them in the [examples directory](https://github.com/cohere-ai/north-mcp-python-sdk/tree/main/examples).

src/north_mcp_python_sdk/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@
1313
NorthAuthenticationMiddleware,
1414
on_auth_error,
1515
)
16+
from .middleware import (
17+
FastMCPNorthMiddleware,
18+
NorthRequestContext,
19+
get_north_request_context,
20+
north_request_context_var,
21+
)
1622

1723

1824
def is_debug_mode() -> bool:
@@ -85,4 +91,8 @@ def _add_middleware(self, app: Starlette) -> None:
8591
__all__ = [
8692
"NorthMCPServer",
8793
"is_debug_mode",
94+
"FastMCPNorthMiddleware",
95+
"NorthRequestContext",
96+
"get_north_request_context",
97+
"north_request_context_var",
8898
]
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import base64
2+
import binascii
3+
import contextvars
4+
import json
5+
import logging
6+
from dataclasses import dataclass, field
7+
from typing import Dict, Optional
8+
9+
from starlette.middleware.base import BaseHTTPMiddleware
10+
from starlette.requests import Request
11+
from starlette.types import ASGIApp
12+
13+
14+
_DEFAULT_USER_ID_TOKEN_HEADER = "X-North-ID-Token"
15+
_DEFAULT_CONNECTOR_TOKENS_HEADER = "X-North-Connector-Tokens"
16+
17+
18+
@dataclass(frozen=True)
19+
class NorthRequestContext:
20+
"""Holds North-specific request context extracted from headers."""
21+
22+
user_id_token: Optional[str] = None
23+
connector_tokens: Dict[str, str] = field(default_factory=dict)
24+
25+
26+
north_request_context_var = contextvars.ContextVar[NorthRequestContext](
27+
"north_request_context",
28+
default=NorthRequestContext(),
29+
)
30+
31+
32+
def get_north_request_context() -> NorthRequestContext:
33+
"""
34+
Retrieve the North request context for the current request.
35+
36+
Returns:
37+
NorthRequestContext: The context extracted by FastMCPNorthMiddleware.
38+
"""
39+
return north_request_context_var.get()
40+
41+
42+
class FastMCPNorthMiddleware(BaseHTTPMiddleware):
43+
"""
44+
Lightweight middleware that extracts North request metadata from headers.
45+
46+
Unlike the full North authentication stack, this middleware never blocks
47+
requests. It simply captures context that can be leveraged inside FastMCP
48+
tools, prompts, or custom routes.
49+
"""
50+
51+
def __init__(
52+
self,
53+
app: ASGIApp,
54+
*,
55+
user_id_token_header: str = _DEFAULT_USER_ID_TOKEN_HEADER,
56+
connector_tokens_header: str = _DEFAULT_CONNECTOR_TOKENS_HEADER,
57+
debug: bool = False,
58+
) -> None:
59+
super().__init__(app)
60+
self._user_id_token_header = user_id_token_header
61+
self._connector_tokens_header = connector_tokens_header
62+
self._logger = logging.getLogger("NorthMCP.FastMCPNorthMiddleware")
63+
if debug:
64+
self._logger.setLevel(logging.DEBUG)
65+
self._debug = debug
66+
67+
def _parse_connector_tokens(self, raw_header: str) -> Dict[str, str]:
68+
"""
69+
Parse the connector tokens header, expected to be Base64-encoded JSON.
70+
71+
Returns an empty dict when the header cannot be decoded or does not
72+
resolve to a JSON object of string keys and values.
73+
"""
74+
if not raw_header:
75+
return {}
76+
77+
padding = (-len(raw_header)) % 4
78+
padded_value = raw_header + ("=" * padding)
79+
80+
try:
81+
decoded_bytes = base64.urlsafe_b64decode(padded_value)
82+
decoded_json = decoded_bytes.decode()
83+
parsed = json.loads(decoded_json)
84+
if isinstance(parsed, dict):
85+
return {
86+
str(key): str(value)
87+
for key, value in parsed.items()
88+
if isinstance(key, str) and isinstance(value, str)
89+
}
90+
except (ValueError, json.JSONDecodeError, binascii.Error) as exc:
91+
self._logger.debug(
92+
"Failed to decode connector tokens header: %s", exc
93+
)
94+
95+
return {}
96+
97+
async def dispatch(self, request: Request, call_next):
98+
user_id_token = request.headers.get(self._user_id_token_header)
99+
connector_tokens_header = request.headers.get(
100+
self._connector_tokens_header
101+
)
102+
connector_tokens = self._parse_connector_tokens(
103+
connector_tokens_header or ""
104+
)
105+
106+
if self._debug:
107+
self._logger.debug(
108+
"Extracted North context. Has user_id_token: %s, connectors: %s",
109+
bool(user_id_token),
110+
list(connector_tokens.keys()),
111+
)
112+
113+
context = NorthRequestContext(
114+
user_id_token=user_id_token,
115+
connector_tokens=connector_tokens,
116+
)
117+
118+
request.state.north_context = context
119+
token = north_request_context_var.set(context)
120+
121+
try:
122+
response = await call_next(request)
123+
finally:
124+
north_request_context_var.reset(token)
125+
# Request.state lives for the lifetime of the request; no cleanup needed.
126+
127+
return response
128+
129+
130+
__all__ = [
131+
"FastMCPNorthMiddleware",
132+
"NorthRequestContext",
133+
"get_north_request_context",
134+
"north_request_context_var",
135+
]

0 commit comments

Comments
 (0)