60e0ffc959
Upgrade checks / Notify on failure (push) Has been cancelled
Upgrade checks / Close issue on success (push) Has been cancelled
Schema Crash Test / Real-world schema crash test (232K schemas) (push) Has been cancelled
Run static analysis / static_analysis (push) Has been cancelled
Tests / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Tests / Tests with lowest-direct dependencies (push) Has been cancelled
Tests / MCP conformance tests (push) Has been cancelled
Tests / Integration tests (push) Has been cancelled
Tests / Package install smoke (push) Has been cancelled
Upgrade checks / Static analysis (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Upgrade checks / Integration tests (push) Has been cancelled
Update MCPServerConfig Schema / update-config-schema (push) Has been cancelled
Update SDK Documentation / update-sdk-docs (push) Has been cancelled
150 lines
4.9 KiB
Python
150 lines
4.9 KiB
Python
"""
|
|
Tests for authorization-based task isolation (CRITICAL SECURITY).
|
|
|
|
Ensures that tasks are properly scoped to authorization identity and clients
|
|
cannot access each other's tasks.
|
|
"""
|
|
|
|
import pytest
|
|
from mcp.server.auth.middleware.auth_context import (
|
|
auth_context_var,
|
|
)
|
|
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
|
|
|
|
from fastmcp import FastMCP
|
|
from fastmcp.client import Client
|
|
from fastmcp.server.auth import AccessToken
|
|
|
|
|
|
@pytest.fixture
|
|
def task_server():
|
|
"""Create a server with background tasks enabled."""
|
|
mcp = FastMCP("security-test-server")
|
|
|
|
@mcp.tool(task=True)
|
|
async def secret_tool(data: str) -> str:
|
|
"""A tool that processes sensitive data."""
|
|
return f"Secret result: {data}"
|
|
|
|
return mcp
|
|
|
|
|
|
async def test_same_client_can_access_all_its_tasks(task_server: FastMCP):
|
|
"""A single authenticated client can access all tasks it created."""
|
|
token = AccessToken(
|
|
token="token-a",
|
|
client_id="client-a",
|
|
scopes=["read"],
|
|
)
|
|
reset = auth_context_var.set(AuthenticatedUser(token))
|
|
try:
|
|
async with Client(task_server) as client:
|
|
task1 = await client.call_tool(
|
|
"secret_tool", {"data": "first"}, task=True, task_id="task-1"
|
|
)
|
|
task2 = await client.call_tool(
|
|
"secret_tool", {"data": "second"}, task=True, task_id="task-2"
|
|
)
|
|
|
|
await task1.wait(timeout=2.0)
|
|
await task2.wait(timeout=2.0)
|
|
|
|
result1 = await task1.result()
|
|
result2 = await task2.result()
|
|
|
|
assert "first" in str(result1.data)
|
|
assert "second" in str(result2.data)
|
|
finally:
|
|
auth_context_var.reset(reset)
|
|
|
|
|
|
async def test_unauthenticated_client_can_access_its_tasks(task_server: FastMCP):
|
|
"""An unauthenticated client can access tasks it created (by task ID)."""
|
|
async with Client(task_server) as client:
|
|
task = await client.call_tool(
|
|
"secret_tool", {"data": "hello"}, task=True, task_id="my-task"
|
|
)
|
|
await task.wait(timeout=2.0)
|
|
result = await task.result()
|
|
assert "hello" in str(result.data)
|
|
|
|
|
|
def _set_auth(client_id: str, sub: str | None = None):
|
|
"""Install an auth context for a given client_id/sub. Returns the reset token."""
|
|
claims = {"sub": sub} if sub else {}
|
|
token = AccessToken(
|
|
token=f"token-{client_id}-{sub or ''}",
|
|
client_id=client_id,
|
|
scopes=["read"],
|
|
claims=claims,
|
|
)
|
|
return auth_context_var.set(AuthenticatedUser(token))
|
|
|
|
|
|
async def _submit_task_id(client: Client, data: str) -> str:
|
|
"""Submit a background task and return its server-assigned task id."""
|
|
task = await client.call_tool("secret_tool", {"data": data}, task=True)
|
|
await task.wait(timeout=2.0)
|
|
return task.task_id
|
|
|
|
|
|
async def test_distinct_clients_cannot_access_each_others_tasks(
|
|
task_server: FastMCP,
|
|
):
|
|
"""Two distinct authenticated clients live in disjoint scopes — looking up
|
|
a peer's task id returns 'not found'."""
|
|
reset = _set_auth("client-a")
|
|
try:
|
|
async with Client(task_server) as client_a:
|
|
task_id = await _submit_task_id(client_a, "client-a-secret")
|
|
finally:
|
|
auth_context_var.reset(reset)
|
|
|
|
reset = _set_auth("client-b")
|
|
try:
|
|
async with Client(task_server) as client_b:
|
|
with pytest.raises(Exception, match="not found"):
|
|
await client_b.get_task_status(task_id)
|
|
finally:
|
|
auth_context_var.reset(reset)
|
|
|
|
|
|
async def test_distinct_subs_same_client_id_cannot_access_each_others_tasks(
|
|
task_server: FastMCP,
|
|
):
|
|
"""Fixed-OAuth case: two users share a client_id but have distinct ``sub``
|
|
claims. The ``sub``-aware scope must still isolate them."""
|
|
shared_client = "shared-oauth-app"
|
|
|
|
reset = _set_auth(shared_client, sub="user-alice")
|
|
try:
|
|
async with Client(task_server) as alice:
|
|
task_id = await _submit_task_id(alice, "alice-secret")
|
|
finally:
|
|
auth_context_var.reset(reset)
|
|
|
|
reset = _set_auth(shared_client, sub="user-bob")
|
|
try:
|
|
async with Client(task_server) as bob:
|
|
with pytest.raises(Exception, match="not found"):
|
|
await bob.get_task_status(task_id)
|
|
finally:
|
|
auth_context_var.reset(reset)
|
|
|
|
|
|
async def test_authenticated_and_anonymous_keyspaces_are_disjoint(
|
|
task_server: FastMCP,
|
|
):
|
|
"""An anonymous client must not be able to read an authenticated client's
|
|
tasks (and vice versa) even when colliding on task id."""
|
|
reset = _set_auth("client-a")
|
|
try:
|
|
async with Client(task_server) as authed:
|
|
authed_task_id = await _submit_task_id(authed, "authed-secret")
|
|
finally:
|
|
auth_context_var.reset(reset)
|
|
|
|
async with Client(task_server) as anon:
|
|
with pytest.raises(Exception, match="not found"):
|
|
await anon.get_task_status(authed_task_id)
|