89 lines
2.9 KiB
Python
89 lines
2.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Verify a Streamable HTTP MCP catalogue without invoking any tool."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import json
|
|
|
|
from mcp import ClientSession
|
|
from mcp.client.streamable_http import streamablehttp_client
|
|
|
|
|
|
def invalid_patterns(value, path: str = "schema") -> list[str]:
|
|
issues = []
|
|
if isinstance(value, dict):
|
|
pattern = value.get("pattern")
|
|
if isinstance(pattern, str) and not (
|
|
pattern.startswith("^") and pattern.endswith("$")
|
|
):
|
|
issues.append(f"{path}.pattern={pattern!r}")
|
|
for key, item in value.items():
|
|
issues.extend(invalid_patterns(item, f"{path}.{key}"))
|
|
elif isinstance(value, list):
|
|
for index, item in enumerate(value):
|
|
issues.extend(invalid_patterns(item, f"{path}[{index}]"))
|
|
return issues
|
|
|
|
|
|
async def verify(
|
|
url: str,
|
|
required: set[str],
|
|
forbidden: set[str],
|
|
max_tools: int,
|
|
max_schema_chars: int,
|
|
timeout: float,
|
|
) -> None:
|
|
async with asyncio.timeout(timeout):
|
|
async with streamablehttp_client(url) as (read_stream, write_stream, _):
|
|
async with ClientSession(read_stream, write_stream) as session:
|
|
await session.initialize()
|
|
response = await session.list_tools()
|
|
names = {tool.name for tool in response.tools}
|
|
schemas = [tool.inputSchema for tool in response.tools]
|
|
schema_chars = len(json.dumps(schemas, ensure_ascii=False, separators=(",", ":")))
|
|
pattern_issues = [
|
|
issue
|
|
for index, schema in enumerate(schemas)
|
|
for issue in invalid_patterns(schema, f"tools[{index}]")
|
|
]
|
|
missing = required - names
|
|
exposed = forbidden & names
|
|
if missing or exposed or len(names) > max_tools or schema_chars > max_schema_chars or pattern_issues:
|
|
raise SystemExit(
|
|
f"MCP catalogue mismatch: missing={sorted(missing)}, "
|
|
f"forbidden={sorted(exposed)}, count={len(names)}/{max_tools}, "
|
|
f"schema_chars={schema_chars}/{max_schema_chars}, "
|
|
f"invalid_patterns={pattern_issues}, actual={sorted(names)}"
|
|
)
|
|
print(
|
|
f"MCP_CATALOG_OK count={len(names)} schema_chars={schema_chars} tools="
|
|
+ ",".join(sorted(names))
|
|
)
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("url")
|
|
parser.add_argument("--require", action="append", default=[])
|
|
parser.add_argument("--forbid", action="append", default=[])
|
|
parser.add_argument("--max-tools", type=int, default=64)
|
|
parser.add_argument("--max-schema-chars", type=int, default=100_000)
|
|
parser.add_argument("--timeout", type=float, default=10.0)
|
|
args = parser.parse_args()
|
|
asyncio.run(
|
|
verify(
|
|
args.url,
|
|
set(args.require),
|
|
set(args.forbid),
|
|
args.max_tools,
|
|
args.max_schema_chars,
|
|
args.timeout,
|
|
)
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|