Skip to content

Commit 4913557

Browse files
rgambeegithub-actions[bot]
authored andcommitted
Identify client interface in session metadata (#5223)
Right now, we don't have a good way to identify which sessions were created via Portal, the SDK, Claude Code or Claude.ai. This adds extra info to the session metadata in the DB to help us identify the user's surface. The engine API changes are backwards compatible, so existing SDK users aren't forced to upgrade. Sourced from commit 57221d3bb0fb3a42b7b11b16cc137795ea2413d5
1 parent 47c0c4f commit 4913557

8 files changed

Lines changed: 83 additions & 50 deletions

File tree

futuresearch-mcp/src/futuresearch_mcp/app.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import logging
44
from contextlib import asynccontextmanager
5+
from importlib.metadata import version
56

67
from futuresearch.api_utils import create_client as _create_sdk_client
78
from futuresearch.generated.api.billing.get_billing_balance_billing_get import (
@@ -41,13 +42,16 @@ async def http_lifespan(_server: FastMCP):
4142
redis_client = get_redis_client()
4243
await redis_client.ping() # pyright: ignore[reportGeneralTypeIssues]
4344

45+
sdk_version = version("futuresearch")
46+
4447
def _http_client_factory() -> AuthenticatedClient:
4548
access_token = get_access_token()
4649
if access_token is None:
4750
raise RuntimeError("Not authenticated")
4851
return AuthenticatedClient(
4952
base_url=settings.futuresearch_api_url,
5053
token=access_token.token,
54+
headers={"X-SDK-Version": f"futuresearch-python/{sdk_version}"},
5155
raise_on_unexpected_status=True,
5256
follow_redirects=True,
5357
)

futuresearch-mcp/src/futuresearch_mcp/http_config.py

Lines changed: 5 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
from __future__ import annotations
44

5-
import contextvars
65
import logging
76
import os
87
import time as _time
@@ -28,35 +27,13 @@
2827
SecurityHeadersMiddleware,
2928
)
3029
from futuresearch_mcp.redis_store import get_redis_client
30+
from futuresearch_mcp.request_context import request_context
3131
from futuresearch_mcp.routes import api_download, api_progress
3232
from futuresearch_mcp.templates import UNIFIED_HTML
3333
from futuresearch_mcp.uploads import proxy_upload
3434

3535
logger = logging.getLogger(__name__)
3636

37-
# ── User-Agent propagation ────────────────────────────────────────────
38-
# In stateless HTTP mode there is no MCP initialize handshake, so
39-
# ctx.session.client_params is always None. We propagate the HTTP
40-
# User-Agent header via a ContextVar so tool functions can still
41-
# distinguish clients (e.g. Claude Code vs Claude.ai).
42-
_user_agent_var: contextvars.ContextVar[str] = contextvars.ContextVar(
43-
"user_agent", default=""
44-
)
45-
46-
_conversation_id_var: contextvars.ContextVar[str] = contextvars.ContextVar(
47-
"conversation_id", default=""
48-
)
49-
50-
51-
def get_user_agent() -> str:
52-
"""Return the User-Agent of the current HTTP request (empty in stdio mode)."""
53-
return _user_agent_var.get()
54-
55-
56-
def get_conversation_id() -> str:
57-
"""Return the X-Conversation-Id of the current HTTP request (empty if absent)."""
58-
return _conversation_id_var.get()
59-
6037

6138
def configure_http_mode(
6239
*,
@@ -216,13 +193,10 @@ async def dispatch(self, request, call_next):
216193
if request.url.path == "/health":
217194
return await call_next(request)
218195

219-
# Propagate User-Agent so downstream tool code can detect the client
220-
# even in stateless HTTP mode (no MCP initialize → no client_params).
221-
ua_token = _user_agent_var.set(request.headers.get("user-agent", ""))
222-
cc_token = _conversation_id_var.set(
223-
request.headers.get("x-conversation-id", "")
224-
)
225-
try:
196+
with request_context(
197+
user_agent=request.headers.get("user-agent", ""),
198+
conversation_id=request.headers.get("x-conversation-id", ""),
199+
):
226200
start = _time.monotonic()
227201
response = await call_next(request)
228202
elapsed_ms = (_time.monotonic() - start) * 1000
@@ -246,9 +220,6 @@ async def dispatch(self, request, call_next):
246220
request.headers.get("user-agent", "-"),
247221
)
248222
return response
249-
finally:
250-
_user_agent_var.reset(ua_token)
251-
_conversation_id_var.reset(cc_token)
252223

253224

254225
def _add_middleware(
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""Per-request context propagation via contextvars.
2+
3+
In stateless HTTP mode there is no MCP initialize handshake, so
4+
ctx.session.client_params is always None. The HTTP middleware propagates
5+
the User-Agent and X-Conversation-Id headers via context vars so that
6+
tool functions can still distinguish clients.
7+
"""
8+
9+
import contextvars
10+
from collections.abc import Generator
11+
from contextlib import contextmanager
12+
13+
_user_agent_var: contextvars.ContextVar[str] = contextvars.ContextVar(
14+
"user_agent", default=""
15+
)
16+
17+
_conversation_id_var: contextvars.ContextVar[str] = contextvars.ContextVar(
18+
"conversation_id", default=""
19+
)
20+
21+
22+
def get_user_agent() -> str:
23+
"""Return the User-Agent of the current HTTP request (empty in stdio mode)."""
24+
return _user_agent_var.get()
25+
26+
27+
def get_conversation_id() -> str:
28+
"""Return the X-Conversation-Id of the current HTTP request (empty if absent)."""
29+
return _conversation_id_var.get()
30+
31+
32+
@contextmanager
33+
def request_context(
34+
user_agent: str,
35+
conversation_id: str,
36+
) -> Generator[None]:
37+
"""Set per-request context vars for the duration of the block."""
38+
ua_token = _user_agent_var.set(user_agent)
39+
cc_token = _conversation_id_var.set(conversation_id)
40+
try:
41+
yield
42+
finally:
43+
_user_agent_var.reset(ua_token)
44+
_conversation_id_var.reset(cc_token)

futuresearch-mcp/src/futuresearch_mcp/tool_helpers.py

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828

2929
from futuresearch_mcp import redis_store
3030
from futuresearch_mcp.config import settings
31+
from futuresearch_mcp.request_context import get_conversation_id, get_user_agent
3132

3233
logger = logging.getLogger(__name__)
3334

@@ -57,19 +58,33 @@ class SessionContext:
5758

5859

5960
def _get_client(ctx: FuturesearchContext) -> AuthenticatedClient:
60-
"""Get an FutureSearch API client from the FastMCP lifespan context."""
61-
return ctx.request_context.lifespan_context.client_factory()
61+
"""Get a FutureSearch API client with MCP client identity headers."""
62+
client = ctx.request_context.lifespan_context.client_factory()
63+
extra_headers = _extract_client_headers(ctx)
64+
logger.debug(f"Setting extra headers to {extra_headers}")
65+
if extra_headers:
66+
client = client.with_headers(extra_headers)
67+
return client
68+
69+
70+
def _extract_client_headers(ctx: FuturesearchContext) -> dict[str, str]:
71+
"""Build X-MCP-Client-* headers from MCP client params or User-Agent."""
72+
headers: dict[str, str] = {}
73+
cp = ctx.session.client_params
74+
if cp and cp.clientInfo:
75+
if cp.clientInfo.name:
76+
headers["X-MCP-Client-Name"] = cp.clientInfo.name
77+
if cp.clientInfo.version:
78+
headers["X-MCP-Client-Version"] = cp.clientInfo.version
79+
elif user_agent := get_user_agent():
80+
headers["X-MCP-Client-Name"] = user_agent
81+
return headers
6282

6383

6484
def _get_conversation_id() -> str | None:
6585
"""Get the conversation ID from the current HTTP request context, if any."""
66-
try:
67-
from futuresearch_mcp.http_config import get_conversation_id # noqa: PLC0415
68-
69-
val = get_conversation_id()
70-
return val if val else None
71-
except Exception:
72-
return None
86+
val = get_conversation_id()
87+
return val if val else None
7388

7489

7590
def create_linked_session(
@@ -88,8 +103,6 @@ def log_client_info(ctx: FuturesearchContext, tool_name: str) -> None:
88103
if not cp:
89104
# Stateless HTTP mode — no MCP initialize handshake.
90105
# Fall back to User-Agent from the HTTP request.
91-
from futuresearch_mcp.http_config import get_user_agent # noqa: PLC0415
92-
93106
ua = get_user_agent()
94107
logger.info(
95108
"[%s] client_params=None (stateless) ua=%s",
@@ -180,8 +193,6 @@ def _widgets_from_user_agent() -> bool:
180193
Only clients we have confirmed can render widgets get them; unknown UAs
181194
default to text-only to avoid wasting context tokens on unsupported UIs.
182195
"""
183-
from futuresearch_mcp.http_config import get_user_agent # noqa: PLC0415
184-
185196
ua = get_user_agent().lower()
186197

187198
# Whitelist of UA substrings for clients that support widgets.
@@ -203,8 +214,6 @@ def _widgets_from_user_agent() -> bool:
203214

204215
def is_internal_client() -> bool:
205216
"""Return True if the request comes from FutureSearch's own app."""
206-
from futuresearch_mcp.http_config import get_user_agent # noqa: PLC0415
207-
208217
return "futuresearch" in get_user_agent().lower()
209218

210219

futuresearch-mcp/tests/test_server.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ def _make_mock_client():
169169
client.__aenter__ = AsyncMock(return_value=client)
170170
client.__aexit__ = AsyncMock(return_value=None)
171171
client.token = "fake-token"
172+
client.with_headers = MagicMock(return_value=client)
172173
return client
173174

174175

futuresearch-mcp/tests/test_stdio_content.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ def _make_mock_client():
146146
client.token = "fake-token"
147147
client.__aenter__ = AsyncMock(return_value=client)
148148
client.__aexit__ = AsyncMock(return_value=None)
149+
client.with_headers = MagicMock(return_value=client)
149150
return client
150151

151152

futuresearch-mcp/tests/test_ua_detection.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from futuresearch_mcp.tool_helpers import _widgets_from_user_agent, is_internal_client
88

9-
_UA_PATCH = "futuresearch_mcp.http_config.get_user_agent"
9+
_UA_PATCH = "futuresearch_mcp.tool_helpers.get_user_agent"
1010

1111

1212
class TestWidgetsFromUserAgent:

src/futuresearch/api_utils.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import os
2+
from importlib.metadata import version
23
from typing import TypeVar
34

45
from futuresearch.constants import DEFAULT_FUTURESEARCH_API_URL, FuturesearchError
@@ -35,9 +36,11 @@ def create_client() -> AuthenticatedClient:
3536
or os.environ.get("EVERYROW_API_URL")
3637
or DEFAULT_FUTURESEARCH_API_URL
3738
)
39+
sdk_version = version("futuresearch")
3840
return AuthenticatedClient(
3941
base_url=api_url,
4042
token=api_key,
43+
headers={"X-SDK-Version": f"futuresearch-python/{sdk_version}"},
4144
raise_on_unexpected_status=True,
4245
follow_redirects=True,
4346
)

0 commit comments

Comments
 (0)