mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-28 01:32:17 +00:00
* test(integration): drop the contracts.json manifest and the covers requirement Groups live as a GROUPS literal in run.py, the browser expectations move next to the browser tests, and the runner fails only on pytest failure, collection errors or a selected file that collects zero tests. The covers marker stays registered for the existing tests but is no longer checked. The mcp directory gets its own group Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * ci(integration): run mcp as its own shard with xdist and a peer proxy Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * ci(integration): INTEGRATION_COVERAGE=1 runs the proxy under coverage for the MCP modules The mcp shard sets it. The proxy and its peer start under coverage run in parallel mode, get SIGTERM after the tests so coverage flushes, and the combined text and HTML reports land in the suite results that CircleCI already stores as artifacts Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * ci(integration): let the test proxy flush coverage when uvicorn re-raises SIGTERM Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): add SSE, stdio, scripted, OpenAPI and OAuth 2.1 MCP peer doubles Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): add MCP transport and access-control matrices Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): add MCP credential and OAuth flow coverage Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): add MCP LLM endpoint, accounting, guardrail, resilience and lifecycle coverage Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): stop the same-URL grant test from counting a late initialize as a leaked call and satisfy the test-tree lint Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): assert the REST denied-server listing is refused or empty Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): pin the REST denied-server listing to 403 access_denied Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: yuneng <yuneng@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
237 lines
12 KiB
Python
237 lines
12 KiB
Python
import uuid
|
|
from pathlib import Path
|
|
from typing import Final
|
|
|
|
import pytest
|
|
import yaml
|
|
from integration._support.client import Gateway, eventually
|
|
from integration._support.mcp import (
|
|
McpCaller,
|
|
call_tool,
|
|
delete_mcp,
|
|
forget_mcp,
|
|
mcp_peer,
|
|
register_mcp,
|
|
tool_calls,
|
|
tool_names,
|
|
)
|
|
from integration._support.process import owned_proxy
|
|
|
|
ADD: Final = {"a": 4, "b": 5}
|
|
|
|
|
|
def _servers(gateway: Gateway, key: str | None = None) -> dict[str, dict[str, object]]:
|
|
response: Final = gateway.client.get("/v1/mcp/server", headers={"x-litellm-api-key": key or gateway.key})
|
|
assert response.status_code == 200, response.text
|
|
return {server["server_id"]: server for server in response.json()}
|
|
|
|
|
|
def test_non_admin_key_cannot_create_edit_or_delete_servers(gateway: Gateway) -> None:
|
|
with mcp_peer() as peer, gateway.scenario() as scenario:
|
|
alias: Final = "mgmt" + uuid.uuid4().hex[:8]
|
|
identity: Final = register_mcp(scenario, peer, alias)
|
|
plain: Final = scenario.key(object_permission={"mcp_servers": [identity]})
|
|
headers: Final = {"x-litellm-api-key": plain}
|
|
created: Final = gateway.client.post(
|
|
"/v1/mcp/server",
|
|
json={"server_name": alias + "x", "alias": alias + "x", **peer.registration()},
|
|
headers=headers,
|
|
)
|
|
assert created.status_code == 403, created.text
|
|
edited: Final = gateway.client.put(
|
|
"/v1/mcp/server", json={"server_id": identity, "server_name": "hijacked"}, headers=headers
|
|
)
|
|
assert edited.status_code == 403, edited.text
|
|
deleted: Final = gateway.client.delete(f"/v1/mcp/server/{identity}", headers=headers)
|
|
assert deleted.status_code == 403, deleted.text
|
|
assert _servers(gateway)[identity]["server_name"] == alias
|
|
assert call_tool(gateway, plain, identity, tool_names(gateway, plain, identity)["add"], ADD).status_code == 200
|
|
|
|
|
|
def test_secrets_never_appear_in_server_listing_or_detail(gateway: Gateway) -> None:
|
|
with mcp_peer() as peer, gateway.scenario() as scenario:
|
|
secret: Final = "shh-" + uuid.uuid4().hex
|
|
header_secret: Final = "hdr-" + uuid.uuid4().hex
|
|
alias: Final = "mgmt" + uuid.uuid4().hex[:8]
|
|
identity: Final = register_mcp(
|
|
scenario,
|
|
peer,
|
|
alias,
|
|
auth_type="bearer_token",
|
|
credentials={"auth_value": secret},
|
|
static_headers={"X-Integration-Secret": header_secret},
|
|
)
|
|
viewer: Final = scenario.key(object_permission={"mcp_servers": [identity]})
|
|
for key in (gateway.key, viewer):
|
|
listing: Final = gateway.client.get("/v1/mcp/server", headers={"x-litellm-api-key": key})
|
|
detail: Final = gateway.client.get(f"/v1/mcp/server/{identity}", headers={"x-litellm-api-key": key})
|
|
assert listing.status_code == 200 and detail.status_code == 200, (listing.text, detail.text)
|
|
assert secret not in listing.text + detail.text, key == gateway.key
|
|
viewed: Final = gateway.client.get("/v1/mcp/server", headers={"x-litellm-api-key": viewer})
|
|
assert header_secret not in viewed.text, viewed.text
|
|
peer.drain()
|
|
assert (
|
|
call_tool(gateway, viewer, identity, tool_names(gateway, viewer, identity)["add"], ADD).status_code == 200
|
|
)
|
|
sent: Final = tool_calls(peer.drain())
|
|
assert [call["headers"][b"authorization"] for call in sent] == [f"Bearer {secret}".encode()]
|
|
assert [call["headers"][b"x-integration-secret"] for call in sent] == [header_secret.encode()]
|
|
|
|
|
|
def test_edit_url_moves_calls_to_the_new_peer_without_touching_grants(gateway: Gateway) -> None:
|
|
with mcp_peer() as first, mcp_peer() as second, gateway.scenario() as scenario:
|
|
alias: Final = "mgmt" + uuid.uuid4().hex[:8]
|
|
identity: Final = register_mcp(scenario, first, alias)
|
|
key: Final = scenario.key(object_permission={"mcp_servers": [identity]})
|
|
name: Final = tool_names(gateway, key, identity)["add"]
|
|
assert call_tool(gateway, key, identity, name, ADD).status_code == 200
|
|
assert len(tool_calls(first.drain())) == 1
|
|
moved: Final = gateway.request("PUT", "/v1/mcp/server", {"server_id": identity, "url": second.url})
|
|
assert moved.status_code == 202, moved.text
|
|
second.drain()
|
|
response: Final = eventually(
|
|
lambda: call_tool(gateway, key, identity, name, ADD),
|
|
lambda value: value.status_code == 200 and len(tool_calls(second.drain())) == 1,
|
|
)
|
|
assert response.json()["content"][0]["text"] == "9", response.text
|
|
assert tool_calls(first.drain()) == ()
|
|
|
|
|
|
def test_delete_removes_listing_calls_and_database_row(gateway: Gateway) -> None:
|
|
with mcp_peer() as peer, gateway.scenario() as scenario:
|
|
alias: Final = "mgmt" + uuid.uuid4().hex[:8]
|
|
identity: Final = register_mcp(scenario, peer, alias)
|
|
key: Final = scenario.key(object_permission={"mcp_servers": [identity]})
|
|
name: Final = tool_names(gateway, key, identity)["add"]
|
|
delete_mcp(gateway, identity)
|
|
assert identity not in _servers(gateway)
|
|
listing: Final = gateway.client.get(
|
|
"/mcp-rest/tools/list", headers={"x-litellm-api-key": key}, params={"server_id": identity}
|
|
)
|
|
assert listing.status_code >= 400 or listing.json() == [], listing.text
|
|
peer.drain()
|
|
response: Final = call_tool(gateway, key, identity, name, ADD)
|
|
assert response.status_code >= 400, response.text
|
|
assert tool_calls(peer.drain()) == ()
|
|
caller: Final = McpCaller(gateway, key, "server_mcp", alias)
|
|
assert caller.list_tools().tools == (), caller.list_tools().raw
|
|
|
|
|
|
def test_duplicate_alias_is_rejected_so_tool_prefixes_cannot_collide(gateway: Gateway) -> None:
|
|
with mcp_peer() as peer, gateway.scenario() as scenario:
|
|
alias: Final = "mgmt" + uuid.uuid4().hex[:8]
|
|
register_mcp(scenario, peer, alias)
|
|
duplicate: Final = gateway.request(
|
|
"POST", "/v1/mcp/server", {"server_name": alias, "alias": alias, **peer.registration()}
|
|
)
|
|
if duplicate.status_code == 201:
|
|
scenario.cleanups.callback(forget_mcp, gateway, duplicate.json()["server_id"])
|
|
pytest.skip("BUG: POST /v1/mcp/server accepts a duplicate alias, so two servers share one tool prefix")
|
|
assert duplicate.status_code == 400, duplicate.text
|
|
|
|
|
|
def test_invalid_registrations_are_rejected(gateway: Gateway) -> None:
|
|
with mcp_peer() as peer, gateway.scenario() as scenario:
|
|
alias: Final = "mgmt" + uuid.uuid4().hex[:8]
|
|
register_mcp(scenario, peer, alias)
|
|
no_url: Final = gateway.request("POST", "/v1/mcp/server", {"server_name": alias + "b", "transport": "http"})
|
|
assert no_url.status_code in (400, 422), no_url.text
|
|
bad_command: Final = gateway.request(
|
|
"POST",
|
|
"/v1/mcp/server",
|
|
{"server_name": alias + "c", "transport": "stdio", "command": "/bin/sh", "args": ["-c", "true"]},
|
|
)
|
|
assert bad_command.status_code in (400, 422), bad_command.text
|
|
hyphenless: Final = gateway.request(
|
|
"POST", "/v1/mcp/server", {"server_name": "bad name!", **peer.registration()}
|
|
)
|
|
assert hyphenless.status_code in (400, 422), hyphenless.text
|
|
assert len([s for s in _servers(gateway).values() if str(s["server_name"]).startswith(alias)]) == 1
|
|
|
|
|
|
def test_access_group_membership_follows_edits(gateway: Gateway) -> None:
|
|
with mcp_peer() as peer, gateway.scenario() as scenario:
|
|
group: Final = "grp" + uuid.uuid4().hex[:8]
|
|
alias: Final = "mgmt" + uuid.uuid4().hex[:8]
|
|
identity: Final = register_mcp(scenario, peer, alias, mcp_access_groups=[group])
|
|
key: Final = scenario.key(object_permission={"mcp_access_groups": [group]})
|
|
groups: Final = gateway.client.get("/v1/mcp/access_groups", headers={"x-litellm-api-key": gateway.key})
|
|
assert groups.status_code == 200 and group in groups.text, groups.text
|
|
assert "add" in tool_names(gateway, key, identity)
|
|
removed: Final = gateway.request("PUT", "/v1/mcp/server", {"server_id": identity, "mcp_access_groups": []})
|
|
assert removed.status_code == 202, removed.text
|
|
eventually(
|
|
lambda: gateway.client.get(
|
|
"/mcp-rest/tools/list", headers={"x-litellm-api-key": key}, params={"server_id": identity}
|
|
),
|
|
lambda value: value.status_code >= 400 or value.json() == [],
|
|
)
|
|
peer.drain()
|
|
denied: Final = call_tool(gateway, key, identity, f"{alias}-add", ADD)
|
|
assert denied.status_code >= 400, denied.text
|
|
assert tool_calls(peer.drain()) == ()
|
|
|
|
|
|
def test_peer_worker_observes_create_edit_and_delete_without_restart(gateway: Gateway, peer: Gateway) -> None:
|
|
with mcp_peer() as first, mcp_peer() as second, gateway.scenario() as scenario:
|
|
alias: Final = "mgmt" + uuid.uuid4().hex[:8]
|
|
identity: Final = register_mcp(scenario, first, alias)
|
|
key: Final = scenario.key(object_permission={"mcp_servers": [identity]})
|
|
eventually(
|
|
lambda: peer.client.get(
|
|
"/mcp-rest/tools/list", headers={"x-litellm-api-key": key}, params={"server_id": identity}
|
|
),
|
|
lambda value: value.status_code == 200 and value.json() != [],
|
|
seconds=40,
|
|
)
|
|
names: Final = tool_names(peer, key, identity)
|
|
assert call_tool(peer, key, identity, names["add"], ADD).status_code == 200
|
|
assert len(tool_calls(first.drain())) == 1
|
|
moved: Final = gateway.request("PUT", "/v1/mcp/server", {"server_id": identity, "url": second.url})
|
|
assert moved.status_code == 202, moved.text
|
|
eventually(
|
|
lambda: call_tool(peer, key, identity, names["add"], ADD),
|
|
lambda value: value.status_code == 200 and len(tool_calls(second.drain())) == 1,
|
|
seconds=40,
|
|
)
|
|
delete_mcp(gateway, identity)
|
|
eventually(
|
|
lambda: peer.client.get(
|
|
"/mcp-rest/tools/list", headers={"x-litellm-api-key": key}, params={"server_id": identity}
|
|
),
|
|
lambda value: value.status_code >= 400 or value.json() == [],
|
|
seconds=40,
|
|
)
|
|
second.drain()
|
|
assert call_tool(peer, key, identity, names["add"], ADD).status_code >= 400
|
|
assert tool_calls(second.drain()) == ()
|
|
|
|
|
|
def test_config_declared_server_behaves_like_database_server_but_is_read_only(gateway: Gateway, tmp_path: Path) -> None:
|
|
with mcp_peer() as declared_peer, mcp_peer() as database_peer:
|
|
config: Final = yaml.safe_load((Path(__file__).resolve().parents[1] / "proxy_config.yaml").read_text())
|
|
declared: Final = "declared" + uuid.uuid4().hex[:8]
|
|
config["mcp_servers"] = {declared: {**declared_peer.registration(), "static_headers": {"X-From": "config"}}}
|
|
path: Final = tmp_path / "mcp.yaml"
|
|
path.write_text(yaml.safe_dump(config))
|
|
with owned_proxy(gateway, tmp_path, {}, config=path) as candidate, candidate.scenario() as scenario:
|
|
servers: Final = _servers(candidate)
|
|
declared_id: Final = next(identity for identity, s in servers.items() if s["server_name"] == declared)
|
|
created: Final = register_mcp(scenario, database_peer, "database" + uuid.uuid4().hex[:8])
|
|
key: Final = scenario.key(object_permission={"mcp_servers": [declared_id, created]})
|
|
declared_names: Final = tool_names(candidate, key, declared_id)
|
|
assert set(declared_names) == set(tool_names(candidate, key, created)) == {"add", "multiply", "fail"}
|
|
declared_peer.drain()
|
|
response: Final = call_tool(candidate, key, declared_id, declared_names["add"], ADD)
|
|
assert response.status_code == 200 and response.json()["content"][0]["text"] == "9", response.text
|
|
sent: Final = tool_calls(declared_peer.drain())
|
|
assert [call["headers"][b"x-from"] for call in sent] == [b"config"]
|
|
edited: Final = candidate.request(
|
|
"PUT", "/v1/mcp/server", {"server_id": declared_id, "url": database_peer.url}
|
|
)
|
|
assert edited.status_code >= 400, edited.text
|
|
deleted: Final = candidate.request("DELETE", f"/v1/mcp/server/{declared_id}")
|
|
assert deleted.status_code >= 400, deleted.text
|
|
assert declared_id in _servers(candidate)
|
|
assert call_tool(candidate, key, declared_id, declared_names["add"], ADD).status_code == 200
|
|
assert len(tool_calls(declared_peer.drain())) == 1 and tool_calls(database_peer.drain()) == ()
|