Skip to content
Open
28 changes: 16 additions & 12 deletions config/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@

from abc import ABC, abstractmethod
from dataclasses import dataclass
import contextvars
import os
import secrets
from pathlib import Path
from typing import Any, List, Literal, Optional, Protocol, runtime_checkable
from urllib.parse import quote
Expand Down Expand Up @@ -221,27 +223,29 @@ def resolve(self, ctx: Optional[Context], token: Optional[BzmToken]) -> SessionS
raise NotImplementedError


# Per tool call: args.session_scope_id, or a minted id when none is provided.
_tool_session_scope_id: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
"tool_session_scope_id", default=None
)


class DefaultSessionScopeResolver(SessionScopeResolverPort):
"""
Resolve scope from request/ctx metadata.
Resolve scope from args.session_scope_id (same chat).

Hosted HTTP receives `Mcp-Session-Id` via header.
Local stdio/docker falls back to FastMCP context session_id when available.
Otherwise mint an id so a shared MCP session cannot leak across chats.
"""

@staticmethod
def _resolve_session_id(ctx: Optional[Context]) -> str:
explicit = _tool_session_scope_id.get()
if explicit:
return explicit
if ctx is None:
return "default"
request = getattr(getattr(ctx, "request_context", None), "request", None)
if request is not None:
session_id = request.headers.get("mcp-session-id")
if session_id and session_id.strip():
return session_id.strip()
session_id = getattr(ctx, "session_id", None)
if session_id is not None and str(session_id).strip():
return str(session_id).strip()
return "default"
minted = secrets.token_hex(8)
_tool_session_scope_id.set(minted)
return minted

@staticmethod
def _resolve_user_id(token: Optional[BzmToken]) -> str:
Expand Down
4 changes: 3 additions & 1 deletion docs/hosted-http.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,5 +75,7 @@ On streamable-http, session partitions are stored via `HttpSessionStorageProvide

## Hosted MVP limitations

- Session dataframes/tasks live in the Storage Service keyed by `{user_id}/{mcp_session_id}`.
- Session dataframes/tasks live in the Storage Service at
`/session-partitions/{user_id}/{mcp_session_id}`. The second segment is
`args.session_scope_id` or a minted id — not the HTTP `Mcp-Session-Id` header.
- `upload_assets` and other local file lookup/upload paths are rejected. Use a local stdio or Docker MCP installation for those workflows, or wait for remote file access.
10 changes: 8 additions & 2 deletions docs/hosted-mvp-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,14 @@ async tasks share the same session partitions as dataframes.
Tool registrations call `run_tool_with_runtime(runtime, ...)` so tracing stays
unaware of dataframe types. There is no process-global dataframe store.

Partition key: `{user_id}/{mcp_session_id}` via `DefaultSessionScopeResolver`
(`Mcp-Session-Id` header, then FastMCP `ctx.session_id`).
Storage path: `{user_id}/{mcp_session_id}` via `DefaultSessionScopeResolver`.
The second segment is a **chat** key, not the HTTP `Mcp-Session-Id` header:

1. `args.session_scope_id` (reuse the id from a previous successful tool result)
2. otherwise a minted id for this tool call

`Mcp-Session-Id` / FastMCP `ctx.session_id` are transport session ids only. They
are not used as the dataframe/task partition key.

## Session Storage Service

Expand Down
1 change: 1 addition & 0 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,7 @@ def build_mcp_server(
- **Always confirm context**: Always identify and confirm workspace/project before operations.
- **Proactive Troubleshooting**: Use the skills for troubleshooting any detected issues.
- **Failure criteria**: The same field names appear when you read a test and when you configure failure criteria (`failure_criteria` on the test); the server handles BlazeMeter’s REST format internally. Use `failure_criteria_meta` for field definitions and KPI/condition catalogs. When describing criteria to the user, use `meta.general_labels`, `meta.rule_field_labels`, `meta.kpi_labels`, and `meta.condition_labels`; use raw metric and operator ids only inside tool calls. Use `configure_failure_criteria` only after user confirmation; it replaces all rules unless you merge from a prior read.
- **Session isolation**: Successful tool results include `session_scope_id`. Pass that same value as `args.session_scope_id` on later tool calls in this chat so tasks and dataframes stay in the same store. Do not reuse a `session_scope_id` from another chat.
"""
mcp = FastMCP(
"blazemeter-mcp",
Expand Down
7 changes: 7 additions & 0 deletions models/result.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class BaseResult(BaseModel):
tool_call_finished_at: Optional[str] = Field(description="ISO timestamp when tool action finished", default=None)
tool_call_duration_ms: Optional[int] = Field(description="Tool action duration in milliseconds", default=None)
debug: Optional[dict[str, Any]] = Field(description="Optional debug metrics for tool calls", default=None)
session_scope_id: Optional[str] = Field(description="Partition key used for tasks and dataframes in this tool call.",default=None,)

def append_warnings(self, messages: List[str]):
if not self.warning:
Expand Down Expand Up @@ -121,3 +122,9 @@ def debug(self) -> Optional[dict[str, Any]]:
if not isinstance(self.structuredContent, dict):
return None
return self.structuredContent.get("debug")

@property
def session_scope_id(self) -> Optional[str]:
if not isinstance(self.structuredContent, dict):
return None
return self.structuredContent.get("session_scope_id")
13 changes: 13 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ def run_async(coro):


def make_ctx(token: BzmToken, session_id: str):
from config.storage import _tool_session_scope_id

# Stand-in for args.session_scope_id so manager tests share a stable partition.
_tool_session_scope_id.set(session_id)
request_state = SimpleNamespace(
**{
BZM_TOKEN_STATE_ATTR: token,
Expand All @@ -44,6 +48,15 @@ def make_ctx(token: BzmToken, session_id: str):
)


@pytest.fixture(autouse=True)
def reset_tool_session_scope_id():
from config.storage import _tool_session_scope_id

token = _tool_session_scope_id.set(None)
yield
_tool_session_scope_id.reset(token)


@pytest.fixture(autouse=True)
def reset_dataframe_session_locks():
from tools import dataframe_manager as dataframe_manager_module
Expand Down
8 changes: 6 additions & 2 deletions tests/test_tools_manager_dataframes.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,14 @@


class TestResolveSessionScope:
def test_uses_token_id_and_ctx_session(self):
def test_uses_token_id_and_mints_when_no_chat_key(self):
token = BzmToken("api-key-id", "secret")
ctx = SimpleNamespace(session_id="mcp-abc")
assert resolve_session_scope(ctx, token) == SessionScope("api-key-id", "mcp-abc")
first = resolve_session_scope(ctx, token)
second = resolve_session_scope(ctx, token)
assert first.user_id == "api-key-id"
assert first.mcp_session_id != "mcp-abc"
assert first == second

def test_defaults_when_missing(self):
assert resolve_session_scope(None, None) == SessionScope("anonymous", "default")
Expand Down
12 changes: 11 additions & 1 deletion tools/mcp_entrypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

from config.blazemeter import SUPPORT_MESSAGE
from config.runtime import AppRuntime
from config.storage import _tool_session_scope_id
from config.token import BzmToken
from models.result import BaseResult
from tools.runtime_tools import run_tool_with_runtime
Expand Down Expand Up @@ -69,14 +70,18 @@ async def _tool(
action, args = normalize_action_args(arguments)
if not action:
return BaseResult(error="Missing required argument 'action' within tool arguments.")
explicit = args.pop("session_scope_id", None)
scope_token = _tool_session_scope_id.set(
explicit.strip() if isinstance(explicit, str) and explicit.strip() else None
)
runtime.configure_context(ctx)
token = runtime.auth.get_token(ctx)

async def _run() -> BaseResult:
return await dispatch(action, args, token, ctx)

try:
return await run_tool_with_runtime(
result = await run_tool_with_runtime(
runtime,
name,
action,
Expand All @@ -87,12 +92,17 @@ async def _run() -> BaseResult:
dataframe_excluded_actions=excluded_actions,
disable_dataframe_materialization=disable_materialization,
)
if isinstance(result, BaseResult) and not result.error:
result.session_scope_id = runtime.scope_resolver.resolve(ctx, token).mcp_session_id
return result
except httpx.HTTPStatusError:
return BaseResult(error=f"Error: {format_sanitized_traceback()}")
except Exception:
detail = format_sanitized_traceback()
if support_message:
return BaseResult(error=f"Error: {detail}\n{support_message}")
return BaseResult(error=f"Error: {detail}")
finally:
_tool_session_scope_id.reset(scope_token)

return _tool
9 changes: 6 additions & 3 deletions tools/tools_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -714,14 +714,16 @@ async def _dispatch(action, args, token, ctx):
- tasks_status: Lightweight task status by task ID (no task_result payload).
args(dict): task_id (str, required); wait_for_terminal_ms (int, optional);
poll_interval_ms (int, optional)
- tasks_list: List tasks for the current MCP session.
args(dict): status (str, optional); status_list (list[str], optional)
- tasks_list: List tasks for the current chat-scoped session.
args(dict): status (str, optional); status_list (list[str], optional);
session_scope_id (str, optional)
- tasks_cancel: Cancel a running/queued task on this worker when a local handle exists.
Hosted note: cancel is process-local; other workers may only record cancel in Storage.
args(dict): task_id (str, required)
- tasks_remove: Remove a task from the session registry.
args(dict): task_id (str, required)
- dataframes_list: List dataframes and metadata for the current MCP session.
- dataframes_list: List dataframes and metadata for the current chat-scoped session.
args(dict): session_scope_id (str, optional)
- dataframes_get: Get dataframe metadata and schema by dataframe ID.
args(dict): dataframe_id (str, required)
- dataframes_schema_groups: Group dataframe schemas for multi-dataframe queries.
Expand All @@ -739,6 +741,7 @@ async def _dispatch(action, args, token, ctx):
- **CRITICAL**: Before writing any dataframe SQL query, call `dataframes_sql_help` first.
- ORDER BY + LIMIT + OFFSET are mandatory in every dataframe query.
- After a long-running tool returns a task snapshot, poll with tasks_status then tasks_get.
- **CRITICAL**: Reuse `session_scope_id` from this chat's previous successful tool result in `args.session_scope_id`. Never copy a `session_scope_id` from another chat.
""",
dispatch=_dispatch,
excluded_actions=set(TOOLS_ACTIONS_SKIP_AUTO_DATAFRAME),
Expand Down