feat: add MCP research access and browser key management
This commit is contained in:
+50
-6
@@ -4,11 +4,11 @@ import argparse
|
||||
import asyncio
|
||||
import getpass
|
||||
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy import delete, update
|
||||
|
||||
from .config import Settings
|
||||
from .db import create_database
|
||||
from .models import Admin, LoginSession
|
||||
from .models import Admin, LoginSession, MCPToken, now
|
||||
from .security import password_hasher
|
||||
|
||||
|
||||
@@ -21,13 +21,57 @@ async def reset_password():
|
||||
admin = await db.get(Admin, 1)
|
||||
admin.password_hash = password_hasher.hash(password)
|
||||
await db.execute(delete(LoginSession))
|
||||
await db.execute(update(MCPToken).where(MCPToken.revoked_at.is_(None)).values(revoked_at=now()))
|
||||
await db.commit()
|
||||
await engine.dispose()
|
||||
print("Admin password updated; all system sessions revoked.")
|
||||
print("Admin password updated; all system sessions and MCP tokens revoked.")
|
||||
|
||||
|
||||
async def token_command(args):
|
||||
import json
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from .mcp_api.auth import create_token
|
||||
from .models import MCPToken, now
|
||||
from .research.serialization import encode_snapshot
|
||||
|
||||
engine, sessions = create_database(Settings().database_url)
|
||||
try:
|
||||
async with sessions.begin() as db:
|
||||
if args.command == "mcp-token-create":
|
||||
row, secret = await create_token(db, args.name, args.scope, args.days)
|
||||
result = {"id": row.id, "name": row.name, "scopes": row.scopes,
|
||||
"expires_at": row.expires_at, "token": secret}
|
||||
elif args.command == "mcp-token-revoke":
|
||||
row = await db.get(MCPToken, args.token_id)
|
||||
if not row:
|
||||
raise ValueError("令牌不存在")
|
||||
row.revoked_at = row.revoked_at or now()
|
||||
result = {"id": row.id, "revoked": True}
|
||||
else:
|
||||
rows = list(await db.scalars(select(MCPToken).order_by(MCPToken.created_at.desc())))
|
||||
result = [{k: getattr(row, k) for k in
|
||||
("id", "name", "scopes", "created_at", "expires_at", "revoked_at")} for row in rows]
|
||||
# Reveal only after the transaction has committed successfully.
|
||||
print(json.dumps(encode_snapshot(result), ensure_ascii=False, indent=2))
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("command", choices=["reset-password"])
|
||||
parser.parse_args()
|
||||
asyncio.run(reset_password())
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
commands.add_parser("reset-password")
|
||||
create = commands.add_parser("mcp-token-create")
|
||||
create.add_argument("--name", required=True)
|
||||
create.add_argument("--scope", action="append", default=None)
|
||||
create.add_argument("--days", type=int, default=90)
|
||||
commands.add_parser("mcp-token-list")
|
||||
revoke = commands.add_parser("mcp-token-revoke")
|
||||
revoke.add_argument("token_id")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
asyncio.run(reset_password() if args.command == "reset-password" else token_command(args))
|
||||
except ValueError as exc:
|
||||
parser.error(str(exc))
|
||||
|
||||
@@ -25,6 +25,7 @@ class Settings(BaseSettings):
|
||||
request_timeout: float = 30
|
||||
retry_attempts: int = Field(default=4, ge=1, le=8)
|
||||
enable_runner: bool = True
|
||||
mcp_enabled: bool = False
|
||||
ai_request_limit: int = Field(default=12, ge=1, le=30)
|
||||
ai_tool_limit: int = Field(default=12, ge=1, le=100)
|
||||
ai_output_tokens: int = Field(default=4096, ge=128, le=32768)
|
||||
|
||||
+64
-3
@@ -5,7 +5,7 @@ import csv
|
||||
import io
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from contextlib import asynccontextmanager
|
||||
from contextlib import AsyncExitStack, asynccontextmanager
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Query, Request, Response
|
||||
@@ -24,6 +24,7 @@ from .catalog.routes import router as catalog_router
|
||||
from .config import Settings
|
||||
from .db import create_database
|
||||
from .jobs import AUTH_KINDS, Runner, create_job
|
||||
from .mcp_api.token_routes import router as mcp_token_router
|
||||
from .models import Account, Admin, BacktestConfig, Job, JobItem, LoginSession
|
||||
from .research.routes import router as research_router
|
||||
from .research.runtime import ResearchRuntime
|
||||
@@ -93,6 +94,12 @@ def create_app(settings=None, wq_client=None, ai_model_factory=None):
|
||||
ai_runtime = AIRuntime(sessions, settings, runner, ai_model_factory)
|
||||
research_runtime = ResearchRuntime(sessions, ai_runtime, runner)
|
||||
|
||||
mcp_runtime = None
|
||||
if settings.mcp_enabled:
|
||||
from .mcp_api.server import MCPResearchServer
|
||||
|
||||
mcp_runtime = MCPResearchServer(sessions, runner, settings)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
async with sessions() as db:
|
||||
@@ -104,7 +111,10 @@ def create_app(settings=None, wq_client=None, ai_model_factory=None):
|
||||
if settings.enable_runner:
|
||||
await runner.start()
|
||||
await research_runtime.start()
|
||||
yield
|
||||
async with AsyncExitStack() as stack:
|
||||
if mcp_runtime:
|
||||
await stack.enter_async_context(mcp_runtime.server.session_manager.run())
|
||||
yield
|
||||
if settings.enable_runner:
|
||||
await research_runtime.stop()
|
||||
await ai_runtime.stop()
|
||||
@@ -124,6 +134,7 @@ def create_app(settings=None, wq_client=None, ai_model_factory=None):
|
||||
app.state.settings = settings
|
||||
app.state.ai = ai_runtime
|
||||
app.state.research = research_runtime
|
||||
app.state.mcp = mcp_runtime
|
||||
login_failures = defaultdict(list)
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
@@ -139,7 +150,54 @@ def create_app(settings=None, wq_client=None, ai_model_factory=None):
|
||||
|
||||
@app.middleware("http")
|
||||
async def browser_security(request, call_next):
|
||||
if request.method not in ("GET", "HEAD", "OPTIONS"):
|
||||
is_mcp = request.url.path in ("/api/v1/mcp", "/api/v1/mcp/")
|
||||
if is_mcp:
|
||||
if not mcp_runtime:
|
||||
return JSONResponse({"detail": "MCP 未启用"}, status_code=404)
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from .mcp_api.auth import authenticate
|
||||
from .mcp_api.server import TOOLS
|
||||
|
||||
if request.headers.get("host", "").lower() != urlsplit(settings.public_origin).netloc.lower():
|
||||
return JSONResponse({"detail": "MCP Host 不被允许"}, status_code=403)
|
||||
origin = request.headers.get("origin")
|
||||
if origin and origin.rstrip("/") != settings.public_origin.rstrip("/"):
|
||||
return JSONResponse({"detail": "MCP Origin 不被允许"}, status_code=403)
|
||||
scheme, _, secret = request.headers.get("authorization", "").partition(" ")
|
||||
if scheme.lower() != "bearer" or not secret or len(secret) > 256:
|
||||
return JSONResponse({"detail": "需要 MCP Bearer 令牌"}, status_code=401,
|
||||
headers={"WWW-Authenticate": "Bearer"})
|
||||
try:
|
||||
async with sessions() as db:
|
||||
principal = await authenticate(db, secret)
|
||||
except HTTPException as exc:
|
||||
return JSONResponse({"detail": exc.detail}, status_code=exc.status_code,
|
||||
headers={"WWW-Authenticate": "Bearer"})
|
||||
request.state.mcp_principal = principal
|
||||
if "research:read" not in principal.scopes:
|
||||
return JSONResponse({"detail": "缺少读取权限"}, status_code=403)
|
||||
if request.method == "POST":
|
||||
body = bytearray()
|
||||
async for chunk in request.stream():
|
||||
body.extend(chunk)
|
||||
if len(body) > 4 * 1024 * 1024:
|
||||
return JSONResponse({"detail": "MCP 请求过大"}, status_code=413)
|
||||
# BaseHTTPMiddleware replays cached bytes to the SDK; never log this payload.
|
||||
request._body = bytes(body)
|
||||
try:
|
||||
import json
|
||||
|
||||
message = json.loads(body)
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return JSONResponse({"detail": "无效 JSON"}, status_code=400)
|
||||
if isinstance(message, dict) and message.get("method") == "tools/call":
|
||||
params = message.get("params")
|
||||
tool = params.get("name") if isinstance(params, dict) else None
|
||||
definition = TOOLS.get(tool) if isinstance(tool, str) else None
|
||||
if definition and definition[2] not in principal.scopes:
|
||||
return JSONResponse({"detail": "MCP 令牌缺少所需权限"}, status_code=403)
|
||||
elif request.method not in ("GET", "HEAD", "OPTIONS"):
|
||||
if request.headers.get("X-WQ-Request") != "1":
|
||||
return JSONResponse({"detail": "缺少请求校验头"}, status_code=403)
|
||||
origin = request.headers.get("Origin")
|
||||
@@ -410,6 +468,9 @@ def create_app(settings=None, wq_client=None, ai_model_factory=None):
|
||||
await notify_job(runner, "retry_job", result)
|
||||
return result
|
||||
|
||||
if mcp_runtime:
|
||||
app.mount("/api/v1/mcp", mcp_runtime.app)
|
||||
app.include_router(mcp_token_router)
|
||||
app.include_router(backtest_router)
|
||||
app.include_router(api)
|
||||
app.include_router(catalog_router)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Authenticated MCP transport; research behavior lives in research_access."""
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Personal access tokens are isolated from browser and upstream credentials."""
|
||||
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
|
||||
from ..models import Account, Admin, MCPToken, now
|
||||
from ..security import token_hash
|
||||
|
||||
SCOPES = frozenset({"research:read", "research:refresh", "backtests:execute", "backtests:control"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Principal:
|
||||
token_id: str
|
||||
admin_id: int
|
||||
account_id: int
|
||||
wq_user_id: str
|
||||
scopes: frozenset[str]
|
||||
|
||||
|
||||
async def create_token(db, name, scopes=None, days=90):
|
||||
"""Issue a token for the bound account; caller commits and reveals it once."""
|
||||
scopes = set(scopes if scopes is not None else ["research:read"])
|
||||
if not name.strip() or len(name) > 100 or not 1 <= days <= 365:
|
||||
raise ValueError("名称须为 1–100 字,有效期须为 1–365 天")
|
||||
if not scopes <= SCOPES or "research:read" not in scopes:
|
||||
raise ValueError("权限无效;所有令牌必须包含 research:read")
|
||||
account, admin = await db.get(Account, 1), await db.get(Admin, 1)
|
||||
if not account or not account.wq_user_id or not admin:
|
||||
raise ValueError("请先初始化系统并确认 WorldQuant 账户身份")
|
||||
secret = "wqmcp_" + secrets.token_urlsafe(32)
|
||||
row = MCPToken(
|
||||
id=str(uuid4()), token_hash=token_hash(secret), name=name.strip(), admin_id=admin.id,
|
||||
account_id=account.id, wq_user_id=account.wq_user_id, scopes=sorted(scopes),
|
||||
expires_at=now() + timedelta(days=days),
|
||||
)
|
||||
db.add(row)
|
||||
await db.flush()
|
||||
return row, secret
|
||||
|
||||
|
||||
async def authenticate(db, secret):
|
||||
"""Validate every request, including current account binding; return no secrets."""
|
||||
row = await db.scalar(select(MCPToken).where(MCPToken.token_hash == token_hash(secret)))
|
||||
if not row or row.revoked_at or row.expires_at.replace(tzinfo=row.expires_at.tzinfo or timezone.utc) <= now():
|
||||
raise HTTPException(401, "MCP 令牌无效或已过期")
|
||||
account, admin = await db.get(Account, row.account_id), await db.get(Admin, row.admin_id)
|
||||
if not account or not admin or account.id != 1 or account.wq_user_id != row.wq_user_id:
|
||||
raise HTTPException(401, "MCP 令牌账户绑定已失效")
|
||||
return Principal(row.id, row.admin_id, row.account_id, row.wq_user_id, frozenset(row.scopes))
|
||||
@@ -0,0 +1,127 @@
|
||||
"""MCP transport over shared research operations, with minimal durable audit evidence."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from uuid import uuid4
|
||||
|
||||
import anyio
|
||||
from fastapi import HTTPException
|
||||
from mcp import types
|
||||
from mcp.server.lowlevel import Server
|
||||
from mcp.server.transport_security import TransportSecuritySettings
|
||||
from pydantic import ValidationError
|
||||
|
||||
from ..alphas import sanitize
|
||||
from ..backtests.contracts import fingerprint
|
||||
from ..models import MCPAudit, now
|
||||
from ..research.serialization import encode_snapshot
|
||||
from ..research_access import contracts as c
|
||||
from ..research_access.service import ResearchAccess, ResearchError
|
||||
|
||||
# Name, schema, business method, required scope, description. No generic arbitrary HTTP tool.
|
||||
TOOLS = {
|
||||
"get_research_capabilities": (c.Empty, "capabilities", "research:read", "读取直接研究能力、完整设置 schema 和调度阻塞,不代表平台剩余额度。"),
|
||||
"search_catalog": (c.CatalogSearch, "catalog", "research:read", "分页查询指定范围的数据集或字段元数据;无缓存不等于无数据,不隐式刷新。"),
|
||||
"get_research_metadata": (c.Metadata, "metadata", "research:read", "读取范围、设置快照、算子定义或字段可用性;未知不认定通过。"),
|
||||
"refresh_research_data": (c.Refresh, "refresh", "research:refresh", "显式刷新目录、算子、设置、字段可用性或 PnL;不会创建模拟。任务返回 job_id。"),
|
||||
"get_refresh_job": (c.JobReference, "refresh_job", "research:read", "查询研究刷新任务的状态和产物引用。"),
|
||||
"search_backtests": (c.History, "history", "research:read", "分页查历史候选与固定设置;candidates 按完整输入精确匹配,不推断数学等价。"),
|
||||
"submit_backtests": (c.Submit, "submit", "backtests:execute", "执行用户已授权的固定批次,自动留痕并立即返回运行 ID。每项必须完整设置;重复默认拒绝,rerun 明确重跑。不需要研究资产。"),
|
||||
"get_backtest": (c.RunReference, "run", "research:read", "读取真实运行进度、提交数量和可选增量事件;受理不等于成功。"),
|
||||
"get_backtest_results": (c.Results, "results", "research:read", "分页读取固定快照指标、全部非通过检查及三层状态;缺失指标不补零。"),
|
||||
"get_backtest_artifact": (c.Artifact, "artifact", "research:read", "分页读取候选脱敏快照的顶层键值或独立采集的 PnL;缺缓存不自动刷新。"),
|
||||
"control_backtest": (c.Control, "control", "backtests:control", "对已授权运行暂停、继续、停止或恢复采集;不远程取消、不重提未知模拟。需要版本和幂等键。"),
|
||||
}
|
||||
|
||||
|
||||
def tool_result(data, error=False):
|
||||
data = encode_snapshot(data)
|
||||
return types.CallToolResult(content=[types.TextContent(type="text", text=json.dumps(data, ensure_ascii=False))],
|
||||
structuredContent=data, isError=error)
|
||||
|
||||
|
||||
class MCPResearchServer:
|
||||
def __init__(self, sessions, runner, settings):
|
||||
self.sessions, self.runner, self.settings = sessions, runner, settings
|
||||
# The existing deployment has one owner; this also gives SQLite test transactions a fair queue.
|
||||
self.mutation_lock = asyncio.Lock()
|
||||
self.server = Server("wq-alpha-research", version="1.0.0", on_list_tools=self.list_tools,
|
||||
on_call_tool=self.call_tool,
|
||||
instructions="自由探索,直接固定候选回测,无需先建研究资产。工具不安排定时研究;结果按运行 ID 查询。")
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
host = urlsplit(settings.public_origin).netloc
|
||||
self.app = self.server.streamable_http_app(
|
||||
streamable_http_path="/", stateless_http=True, json_response=True,
|
||||
transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=True,
|
||||
allowed_hosts=[host], allowed_origins=[settings.public_origin.rstrip("/")]),
|
||||
)
|
||||
|
||||
async def list_tools(self, ctx, params):
|
||||
principal = ctx.request.state.mcp_principal
|
||||
return types.ListToolsResult(tools=[types.Tool(name=name, description=description,
|
||||
inputSchema=schema.model_json_schema(), annotations=types.ToolAnnotations(
|
||||
readOnlyHint=scope == "research:read", destructiveHint=method == "control",
|
||||
idempotentHint=method in {"submit", "control"} or scope == "research:read",
|
||||
openWorldHint=method in {"refresh", "submit", "metadata"}))
|
||||
for name, (schema, method, scope, description) in TOOLS.items()
|
||||
if scope in principal.scopes and "research:read" in principal.scopes])
|
||||
|
||||
async def call_tool(self, ctx, params):
|
||||
principal = ctx.request.state.mcp_principal
|
||||
return await self.invoke(principal, params.name, params.arguments or {}, str(ctx.request_id or uuid4()))
|
||||
|
||||
async def invoke(self, principal, name, arguments, request_id=None):
|
||||
"""Invoke with a server-authenticated principal; atomic success audit and post-commit wake."""
|
||||
started = time.monotonic()
|
||||
request_id = request_id or str(uuid4())
|
||||
entry = TOOLS.get(name)
|
||||
if not entry:
|
||||
return tool_result({"error": ResearchError("UNKNOWN_TOOL", "工具不存在").data}, True)
|
||||
schema, method, scope, _ = entry
|
||||
if "research:read" not in principal.scopes or scope not in principal.scopes:
|
||||
raise HTTPException(403, "MCP 令牌缺少所需权限")
|
||||
digest = fingerprint(arguments)
|
||||
async with self.mutation_lock:
|
||||
# Disconnect does not roll back an already accepted operation or lose its wake-up.
|
||||
with anyio.CancelScope(shield=True):
|
||||
async with self.sessions.begin() as db:
|
||||
access = ResearchAccess(db, principal, self.runner.client, self.settings.public_origin)
|
||||
code, error = "OK", False
|
||||
try:
|
||||
async with db.begin_nested():
|
||||
args = schema.model_validate(arguments)
|
||||
async with asyncio.timeout(30 if method in {"refresh", "metadata"} else None):
|
||||
data = encode_snapshot(await getattr(access, method)(args))
|
||||
data.setdefault("_meta", {"schema_version": 1, "observed_at": now().isoformat(),
|
||||
"nulls": "null 表示来源未提供,不等于零", "source": "system"})
|
||||
except ValidationError as exc:
|
||||
code, error = "INVALID_INPUT", True
|
||||
data = {"error": ResearchError(code, "; ".join(
|
||||
f"{'.'.join(map(str, e['loc']))}: {e['msg']}" for e in exc.errors())).data}
|
||||
except TimeoutError:
|
||||
code, error = "UPSTREAM_TIMEOUT", True
|
||||
data = {"error": ResearchError(code, "元数据读取或刷新超时,未发布新快照", retryable=True).data}
|
||||
except ResearchError as exc:
|
||||
code, error, data = exc.data["code"], True, {"error": exc.data}
|
||||
except HTTPException as exc:
|
||||
code = {404: "NOT_FOUND", 409: "CONFLICT", 422: "INVALID_INPUT", 429: "RATE_LIMITED", 502: "UPSTREAM_ERROR"}.get(exc.status_code, "REQUEST_FAILED")
|
||||
error = True
|
||||
data = {"error": ResearchError(code, str(sanitize(exc.detail)),
|
||||
retryable=exc.status_code in {429, 502, 503},
|
||||
retry_after=(exc.headers or {}).get("Retry-After")).data}
|
||||
except Exception:
|
||||
# Never expose SQL parameters, exception reprs or credentials in unexpected errors.
|
||||
code, error = "INTERNAL_ERROR", True
|
||||
data = {"error": ResearchError(code, "研究操作失败;可使用原幂等键重试或查询历史", retryable=True).data}
|
||||
db.add(MCPAudit(id=str(uuid4()), token_id=principal.token_id, tool=name,
|
||||
request_id=fingerprint({"request_id": request_id}), input_digest=digest,
|
||||
business_id=data.get("backtest_run_id", data.get("job_id")),
|
||||
result_code=code, elapsed_ms=int((time.monotonic()-started)*1000)))
|
||||
if not error:
|
||||
if access.wake == "backtests":
|
||||
self.runner.backtests.wake.set()
|
||||
elif access.wake == "jobs":
|
||||
self.runner.wake.set()
|
||||
return tool_result(data, error)
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Cookie-authenticated PAT administration; MCP bearer tokens grant no access here."""
|
||||
|
||||
from datetime import timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from ..models import Account, MCPToken, now
|
||||
from ..security import require_auth
|
||||
from .auth import create_token
|
||||
|
||||
router = APIRouter(prefix="/api/v1/mcp-tokens", tags=["mcp-tokens"], dependencies=[Depends(require_auth)])
|
||||
|
||||
|
||||
class TokenInput(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
name: str = Field(min_length=1, max_length=100)
|
||||
days: int = Field(default=90, ge=1, le=365, strict=True)
|
||||
scopes: list[str] = Field(default_factory=lambda: ["research:read"], max_length=4)
|
||||
|
||||
|
||||
def token_output(row, account):
|
||||
"""Return public metadata only, including whether the current binding is usable."""
|
||||
def timestamp(value):
|
||||
return value.replace(tzinfo=value.tzinfo or timezone.utc) if value else None
|
||||
|
||||
expires = timestamp(row.expires_at)
|
||||
status = (
|
||||
"revoked" if row.revoked_at else
|
||||
"expired" if expires <= now() else
|
||||
"invalid_binding" if not account or account.wq_user_id != row.wq_user_id else
|
||||
"active"
|
||||
)
|
||||
return {
|
||||
"id": row.id, "name": row.name, "scopes": row.scopes,
|
||||
"created_at": timestamp(row.created_at), "expires_at": expires,
|
||||
"revoked_at": timestamp(row.revoked_at), "status": status,
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_tokens(request: Request, limit: int = Query(25, ge=1, le=100), offset: int = Query(0, ge=0)):
|
||||
async with request.app.state.sessions() as db:
|
||||
account = await db.get(Account, 1)
|
||||
owned = (MCPToken.admin_id == 1, MCPToken.account_id == 1)
|
||||
total = await db.scalar(select(func.count()).select_from(MCPToken).where(*owned))
|
||||
rows = await db.scalars(select(MCPToken).where(*owned).order_by(
|
||||
MCPToken.created_at.desc(), MCPToken.id.desc()).offset(offset).limit(limit))
|
||||
return {
|
||||
"items": [token_output(row, account) for row in rows], "total": total,
|
||||
"limit": limit, "offset": offset, "has_more": offset + limit < total,
|
||||
"enabled": request.app.state.settings.mcp_enabled,
|
||||
"endpoint": request.app.state.settings.public_origin.rstrip("/") + "/api/v1/mcp/",
|
||||
"can_create": bool(account and account.wq_user_id),
|
||||
}
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def issue_token(body: TokenInput, request: Request):
|
||||
# The existing single-admin browser session is the authority, never request-supplied IDs.
|
||||
async with request.app.state.sessions.begin() as db:
|
||||
try:
|
||||
row, secret = await create_token(db, body.name, body.scopes, body.days)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(422, str(exc)) from exc
|
||||
result = token_output(row, await db.get(Account, 1))
|
||||
# Do not expose the secret until the transaction successfully commits.
|
||||
return {**result, "token": secret}
|
||||
|
||||
|
||||
@router.post("/{token_id}/revoke")
|
||||
async def revoke_token(token_id: str, request: Request):
|
||||
async with request.app.state.sessions.begin() as db:
|
||||
row = await db.scalar(select(MCPToken).where(
|
||||
MCPToken.id == token_id, MCPToken.admin_id == 1, MCPToken.account_id == 1,
|
||||
).with_for_update())
|
||||
if not row:
|
||||
raise HTTPException(404, "MCP Key 不存在")
|
||||
row.revoked_at = row.revoked_at or now()
|
||||
result = token_output(row, await db.get(Account, 1))
|
||||
return result
|
||||
@@ -494,3 +494,49 @@ class ResearchStepRun(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||
__table_args__ = (UniqueConstraint("run_id", "node_id", "round"),)
|
||||
|
||||
|
||||
class MCPToken(Base):
|
||||
"""Revocable personal tokens; only the one-way digest is persisted."""
|
||||
|
||||
__tablename__ = "mcp_tokens"
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||
token_hash: Mapped[str] = mapped_column(String(64), unique=True)
|
||||
name: Mapped[str] = mapped_column(String(100))
|
||||
admin_id: Mapped[int] = mapped_column(ForeignKey("admins.id"))
|
||||
account_id: Mapped[int] = mapped_column(ForeignKey("accounts.id"))
|
||||
wq_user_id: Mapped[str] = mapped_column(String(100))
|
||||
scopes: Mapped[list] = mapped_column(JSON)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class ResearchRequest(Base):
|
||||
"""Successful atomic operations survive retries and credential rotation."""
|
||||
|
||||
__tablename__ = "research_requests"
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||
account_id: Mapped[int] = mapped_column(ForeignKey("accounts.id"))
|
||||
operation: Mapped[str] = mapped_column(String(50))
|
||||
idempotency_key: Mapped[str] = mapped_column(String(100))
|
||||
digest: Mapped[str] = mapped_column(String(64))
|
||||
business_id: Mapped[str] = mapped_column(String(36))
|
||||
response: Mapped[dict] = mapped_column(JSON)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||
__table_args__ = (UniqueConstraint("account_id", "operation", "idempotency_key"),)
|
||||
|
||||
|
||||
class MCPAudit(Base):
|
||||
"""Minimal call evidence, never raw arguments or authentication material."""
|
||||
|
||||
__tablename__ = "mcp_audits"
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||
token_id: Mapped[str] = mapped_column(ForeignKey("mcp_tokens.id"), index=True)
|
||||
tool: Mapped[str] = mapped_column(String(100))
|
||||
request_id: Mapped[str] = mapped_column(String(100))
|
||||
input_digest: Mapped[str] = mapped_column(String(64))
|
||||
business_id: Mapped[str | None] = mapped_column(String(100))
|
||||
result_code: Mapped[str] = mapped_column(String(60))
|
||||
elapsed_ms: Mapped[int] = mapped_column(Integer)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, index=True)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Direct research interface shared by trusted application adapters."""
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Bounded direct research inputs; unknown properties are rejected at the interface."""
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import Field, model_validator
|
||||
|
||||
from ..backtests.contracts import Candidate, SimulationSettings
|
||||
from ..catalog.contracts import CatalogFilters, Scope
|
||||
from ..schemas import Contract
|
||||
|
||||
Identifier = Annotated[str, Field(min_length=1, max_length=100)]
|
||||
RunId = Annotated[str, Field(min_length=1, max_length=36)]
|
||||
|
||||
|
||||
class Empty(Contract):
|
||||
pass
|
||||
|
||||
|
||||
class Page(Contract):
|
||||
limit: int = Field(default=25, ge=1, le=100)
|
||||
offset: int = Field(default=0, ge=0)
|
||||
|
||||
|
||||
class CompleteSettings(SimulationSettings):
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def complete(cls, value):
|
||||
if isinstance(value, dict) and set(cls.model_fields) - value.keys():
|
||||
raise ValueError("必须提供每项完整设置;先读取 get_research_capabilities")
|
||||
return value
|
||||
|
||||
model_config = {"json_schema_extra": {"required": list(SimulationSettings.model_fields)}}
|
||||
|
||||
|
||||
class DirectCandidate(Candidate):
|
||||
settings: CompleteSettings
|
||||
|
||||
|
||||
class Provenance(Contract):
|
||||
reference: str | None = Field(default=None, max_length=200)
|
||||
batch_id: str | None = Field(default=None, max_length=200)
|
||||
hypothesis: str | None = Field(default=None, max_length=2000)
|
||||
parent_run_id: RunId | None = None
|
||||
|
||||
|
||||
class Submit(Contract):
|
||||
name: str = Field(min_length=1, max_length=200)
|
||||
candidates: list[DirectCandidate] = Field(min_length=1, max_length=100)
|
||||
idempotency_key: Identifier
|
||||
duplicate_policy: Literal["reject", "rerun"] = "reject"
|
||||
source: Provenance = Field(default_factory=Provenance)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def unique_ids(self):
|
||||
if len({c.client_item_id for c in self.candidates}) != len(self.candidates):
|
||||
raise ValueError("client_item_id 必须唯一")
|
||||
return self
|
||||
|
||||
|
||||
class Control(Contract):
|
||||
run_id: RunId
|
||||
action: Literal["pause", "resume", "stop", "recover"]
|
||||
expected_version: int = Field(ge=1)
|
||||
idempotency_key: Identifier
|
||||
|
||||
|
||||
class CatalogSearch(Contract):
|
||||
filters: CatalogFilters
|
||||
dataset_id: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
|
||||
|
||||
class Scopes(Contract):
|
||||
kind: Literal["scopes"]
|
||||
|
||||
|
||||
class SettingOptions(Page):
|
||||
kind: Literal["settings"]
|
||||
|
||||
|
||||
class Operators(Page):
|
||||
kind: Literal["operators"]
|
||||
q: str = Field(default="", max_length=300)
|
||||
category: str | None = None
|
||||
|
||||
|
||||
class Availability(Contract):
|
||||
kind: Literal["field_availability"]
|
||||
field_id: Identifier
|
||||
scope: Scope
|
||||
|
||||
|
||||
class Metadata(Contract):
|
||||
query: Annotated[Scopes | SettingOptions | Operators | Availability, Field(discriminator="kind")]
|
||||
|
||||
|
||||
class CatalogRefresh(Contract):
|
||||
kind: Literal["catalog"]
|
||||
scope: Scope
|
||||
dataset_id: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
|
||||
|
||||
class OperatorsRefresh(Contract):
|
||||
kind: Literal["operators"]
|
||||
|
||||
|
||||
class SettingsRefresh(Contract):
|
||||
kind: Literal["settings"]
|
||||
|
||||
|
||||
class PnlRefresh(Contract):
|
||||
kind: Literal["pnl"]
|
||||
alpha_ids: list[Identifier] = Field(min_length=1, max_length=100)
|
||||
|
||||
|
||||
class Refresh(Contract):
|
||||
query: Annotated[
|
||||
CatalogRefresh | OperatorsRefresh | SettingsRefresh | Availability | PnlRefresh,
|
||||
Field(discriminator="kind"),
|
||||
]
|
||||
|
||||
|
||||
class JobReference(Page):
|
||||
job_id: RunId
|
||||
|
||||
|
||||
class History(Page):
|
||||
source: str | None = Field(default=None, max_length=100)
|
||||
reference: str | None = Field(default=None, max_length=200)
|
||||
status: str | None = Field(default=None, max_length=30)
|
||||
created_from: datetime | None = None
|
||||
created_to: datetime | None = None
|
||||
scope: Scope | None = None
|
||||
q: str = Field(default="", max_length=300)
|
||||
candidates: list[DirectCandidate] | None = Field(default=None, min_length=1, max_length=100)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def dates(self):
|
||||
for value in (self.created_from, self.created_to):
|
||||
if value and not value.tzinfo:
|
||||
raise ValueError("时间须包含时区")
|
||||
if self.created_from and self.created_to and self.created_from > self.created_to:
|
||||
raise ValueError("起始时间不能晚于结束时间")
|
||||
return self
|
||||
|
||||
|
||||
class RunReference(Contract):
|
||||
run_id: RunId
|
||||
after: int | None = Field(default=None, ge=0)
|
||||
event_limit: int = Field(default=25, ge=1, le=100)
|
||||
|
||||
|
||||
class Results(Page):
|
||||
run_id: RunId
|
||||
item_ids: list[RunId] | None = Field(default=None, min_length=1, max_length=100)
|
||||
|
||||
|
||||
class Artifact(Page):
|
||||
item_id: RunId
|
||||
kind: Literal["snapshot", "pnl"]
|
||||
date_from: date | None = None
|
||||
date_to: date | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def dates(self):
|
||||
if self.kind == "snapshot" and (self.date_from or self.date_to):
|
||||
raise ValueError("日期筛选仅用于 PnL")
|
||||
if self.date_from and self.date_to and self.date_from > self.date_to:
|
||||
raise ValueError("起始日期不能晚于结束日期")
|
||||
return self
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Historical evidence reads, independent of transport and current Alpha refreshes."""
|
||||
|
||||
from collections import Counter
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from ..alphas import number, sanitize
|
||||
from ..backtests.contracts import fingerprint
|
||||
from ..models import BacktestItem, BacktestResult, BacktestRun, Pnl
|
||||
from ..research.serialization import encode_snapshot
|
||||
|
||||
|
||||
def page(items, total, limit, offset):
|
||||
return {"items": items, "total": total, "limit": limit, "offset": offset,
|
||||
"has_more": offset + len(items) < total}
|
||||
|
||||
|
||||
def checks_summary(snapshot):
|
||||
"""Preserve unknown check values; missing checks can never mean passed."""
|
||||
checks = []
|
||||
for section in ("is", "os"):
|
||||
metrics = snapshot.get(section)
|
||||
if isinstance(metrics, dict) and "checks" in metrics:
|
||||
raw = metrics["checks"]
|
||||
checks.extend({"section": section, "raw": c} for c in (raw if isinstance(raw, list) else [raw]))
|
||||
if "checks" in snapshot:
|
||||
raw = snapshot["checks"]
|
||||
checks.extend({"section": "root", "raw": c} for c in (raw if isinstance(raw, list) else [raw]))
|
||||
counts = Counter({key: 0 for key in ("PASS", "FAIL", "PENDING", "WARNING", "UNKNOWN")})
|
||||
non_pass = []
|
||||
for check in checks:
|
||||
raw = check["raw"]
|
||||
value = raw.get("result", raw.get("status")) if isinstance(raw, dict) else None
|
||||
state = value if isinstance(value, str) and value in counts else "UNKNOWN"
|
||||
counts[state] += 1
|
||||
if state != "PASS":
|
||||
non_pass.append({**check, "status": state})
|
||||
return {"status": "unknown" if not checks else "reported", "counts": dict(counts),
|
||||
"total": len(checks), "non_pass": non_pass}
|
||||
|
||||
|
||||
def item_summary(item, result):
|
||||
snapshot = sanitize(result.snapshot) if result else {}
|
||||
metrics = {}
|
||||
for section in ("is", "os"):
|
||||
raw = snapshot.get(section)
|
||||
raw = raw if isinstance(raw, dict) else {}
|
||||
metrics[section] = {key: number(raw.get(key)) for key in
|
||||
("sharpe", "fitness", "returns", "turnover", "margin", "drawdown")}
|
||||
return encode_snapshot({
|
||||
**{k: getattr(item, k) for k in (
|
||||
"id", "run_id", "client_item_id", "expression", "settings", "attempt_id",
|
||||
"platform_status", "collection_status", "persistence_status", "simulation_id", "alpha_id",
|
||||
)},
|
||||
"error": sanitize(item.error), "metrics": metrics,
|
||||
"missing_metrics_reason": "来源未提供或非有限数字;null 不等于零",
|
||||
"checks": checks_summary(snapshot),
|
||||
"result": {"observed_at": result.observed_at, "complete": result.complete} if result else None,
|
||||
"artifact_reference": {"item_id": item.id},
|
||||
})
|
||||
|
||||
|
||||
class EvidenceQueries:
|
||||
def __init__(self, db):
|
||||
self.db = db
|
||||
|
||||
async def history(self, args):
|
||||
query = select(BacktestItem, BacktestResult, BacktestRun).join(
|
||||
BacktestRun, BacktestRun.id == BacktestItem.run_id
|
||||
).outerjoin(BacktestResult, BacktestResult.item_id == BacktestItem.id)
|
||||
for key in ("source", "reference"):
|
||||
value = getattr(args, key)
|
||||
if value is not None:
|
||||
query = query.where(BacktestRun.source["kind" if key == "source" else key].as_string() == value)
|
||||
if args.status:
|
||||
query = query.where(BacktestRun.status == args.status)
|
||||
if args.created_from:
|
||||
query = query.where(BacktestRun.created_at >= args.created_from)
|
||||
if args.created_to:
|
||||
query = query.where(BacktestRun.created_at <= args.created_to)
|
||||
if args.scope:
|
||||
for source, target in (("instrument_type", "instrumentType"), ("region", "region"), ("universe", "universe")):
|
||||
query = query.where(BacktestItem.settings[target].as_string() == getattr(args.scope, source))
|
||||
query = query.where(BacktestItem.settings["delay"].as_integer() == args.scope.delay)
|
||||
if args.q:
|
||||
escaped = args.q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
query = query.where(BacktestItem.expression.ilike(f"%{escaped}%", escape="\\"))
|
||||
matches = {}
|
||||
if args.candidates:
|
||||
for c in args.candidates:
|
||||
matches.setdefault(fingerprint(c.platform_input()), []).append(c.client_item_id)
|
||||
query = query.where(BacktestItem.fingerprint.in_(matches))
|
||||
total = await self.db.scalar(select(func.count()).select_from(query.subquery()))
|
||||
rows = (await self.db.execute(query.order_by(BacktestRun.created_at.desc(), BacktestRun.id, BacktestItem.ordinal)
|
||||
.limit(args.limit).offset(args.offset))).all()
|
||||
items = [{**item_summary(i, r), "source": run.source, "run_status": run.status,
|
||||
"created_at": run.created_at, "matched_candidates": matches.get(i.fingerprint, []),
|
||||
"match_type": "exact_input" if args.candidates else "filter"} for i, r, run in rows]
|
||||
return encode_snapshot(page(items, total, args.limit, args.offset))
|
||||
|
||||
async def results(self, args):
|
||||
query = select(BacktestItem, BacktestResult).outerjoin(
|
||||
BacktestResult, BacktestResult.item_id == BacktestItem.id
|
||||
).where(BacktestItem.run_id == args.run_id)
|
||||
if args.item_ids:
|
||||
query = query.where(BacktestItem.id.in_(args.item_ids))
|
||||
total = await self.db.scalar(select(func.count()).select_from(query.subquery()))
|
||||
rows = (await self.db.execute(query.order_by(BacktestItem.ordinal).limit(args.limit).offset(args.offset))).all()
|
||||
return {"backtest_run_id": args.run_id, **page([item_summary(i, r) for i, r in rows], total, args.limit, args.offset)}
|
||||
|
||||
async def artifact(self, args):
|
||||
from .service import ResearchError
|
||||
|
||||
item = await self.db.get(BacktestItem, args.item_id)
|
||||
if not item:
|
||||
raise ResearchError("NOT_FOUND", "候选不存在")
|
||||
result = await self.db.get(BacktestResult, item.id)
|
||||
if args.kind == "snapshot":
|
||||
# Top-level entries retain complete nested values; no hidden string/list truncation.
|
||||
entries = [{"key": k, "value": v} for k, v in sanitize(result.snapshot).items()] if result else []
|
||||
return encode_snapshot({"item_id": item.id, "kind": args.kind,
|
||||
"status": "available" if result else "not_available",
|
||||
"observed_at": result.observed_at if result else None,
|
||||
"complete": result.complete if result else False,
|
||||
**page(entries[args.offset:args.offset + args.limit], len(entries), args.limit, args.offset)})
|
||||
pnl = await self.db.get(Pnl, item.alpha_id) if item.alpha_id else None
|
||||
points = pnl.points if pnl else []
|
||||
points = [p for p in points if
|
||||
(not args.date_from or p["date"][:10] >= args.date_from.isoformat()) and
|
||||
(not args.date_to or p["date"][:10] <= args.date_to.isoformat())]
|
||||
return encode_snapshot({"item_id": item.id, "alpha_id": item.alpha_id, "kind": args.kind,
|
||||
"status": "available" if pnl else "not_cached", "fetched_at": pnl.fetched_at if pnl else None,
|
||||
"units": "供应商原始累计值;未提供货币或规模单位",
|
||||
**page(points[args.offset:args.offset + args.limit], len(points), args.limit, args.offset)})
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Direct research operations; caller owns authorization, transaction and wake-up.
|
||||
|
||||
The account row serializes mutations with existing HTTP starts. Request records,
|
||||
previews, runs and control events commit together; failed validation consumes no key.
|
||||
"""
|
||||
|
||||
from collections import Counter
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from ..backtests.contracts import ControlInput, DraftInput, PreviewInput, Source, StartInput, fingerprint
|
||||
from ..backtests.service import Backtests
|
||||
from ..business import Business
|
||||
from ..catalog.contracts import CatalogJobInput
|
||||
from ..catalog.platform import platform_options, validate_platform_scope
|
||||
from ..catalog.research_metadata import ResearchMetadata, availability_key
|
||||
from ..catalog.service import Catalog
|
||||
from ..models import Account, Alpha, BacktestItem, Job, JobItem, ResearchRequest, SimulationAttempt, now
|
||||
from ..research.serialization import encode_snapshot
|
||||
from ..research.workspace_contracts import FieldAvailabilityInput
|
||||
from ..schemas import JobInput
|
||||
from .contracts import DirectCandidate, History
|
||||
from .queries import EvidenceQueries, page
|
||||
|
||||
|
||||
class ResearchError(Exception):
|
||||
def __init__(self, code, message, *, retryable=False, retry_after=None, affected_items=None):
|
||||
super().__init__(message)
|
||||
self.data = {"code": code, "message": message, "retryable": retryable,
|
||||
"retry_after": retry_after, "affected_items": affected_items or []}
|
||||
|
||||
|
||||
class ResearchAccess:
|
||||
def __init__(self, db, principal, client, public_origin):
|
||||
self.db, self.principal, self.client = db, principal, client
|
||||
self.public_origin = public_origin.rstrip("/")
|
||||
self.backtests = Backtests(db)
|
||||
self.business = Business(db)
|
||||
self.evidence = EvidenceQueries(db)
|
||||
self.wake = None
|
||||
|
||||
def run_url(self, run_id):
|
||||
return f"{self.public_origin}/#backtests?run_id={run_id}"
|
||||
|
||||
async def capabilities(self, args):
|
||||
return {**await self.backtests.capabilities(), "max_candidates": 100,
|
||||
"settings_schema": DirectCandidate.model_json_schema(),
|
||||
"confirmation": "调用者须已获本批执行授权;直接提交后返回稳定运行 ID",
|
||||
"duplicate_policies": ["reject", "rerun"], "permissions": sorted(self.principal.scopes),
|
||||
"metadata_only": True, "actual_platform_allowance": None}
|
||||
|
||||
async def catalog(self, args):
|
||||
data = await Catalog(self.db).search(args.filters, args.dataset_id)
|
||||
return {**data, "scope": args.filters.model_dump(include={"region", "universe", "delay", "instrument_type"}),
|
||||
"status": "available" if data["collection_version"] else "not_cached",
|
||||
"has_more": data["offset"] + len(data["items"]) < data["total"]}
|
||||
|
||||
async def metadata(self, args):
|
||||
q = args.query
|
||||
metadata = ResearchMetadata(self.db)
|
||||
if q.kind == "scopes":
|
||||
return {"source": "worldquant_platform", **await platform_options(self.client)}
|
||||
if q.kind == "operators":
|
||||
data = await metadata.operators(q.q, q.category, limit=q.limit, offset=q.offset)
|
||||
return {**data, "status": "available" if data["fetched_at"] else "not_cached",
|
||||
"has_more": q.offset + len(data["items"]) < data["total"]}
|
||||
if q.kind == "settings":
|
||||
data = await metadata.get("settings")
|
||||
items = data["content"].get("items", [])
|
||||
return {"status": "available" if data["fetched_at"] else "not_cached",
|
||||
"fetched_at": data["fetched_at"], **page(items[q.offset:q.offset+q.limit], len(items), q.limit, q.offset)}
|
||||
data = await metadata.get(availability_key(q.field_id, q.scope))
|
||||
return {**data, "status": data["content"].get("status", "unknown")}
|
||||
|
||||
async def refresh(self, args):
|
||||
q = args.query
|
||||
metadata = ResearchMetadata(self.db, self.client)
|
||||
if q.kind == "catalog":
|
||||
await validate_platform_scope(self.client, q.scope)
|
||||
job = await Catalog(self.db).create_job(CatalogJobInput(scope=q.scope, dataset_id=q.dataset_id))
|
||||
self.wake = "jobs"
|
||||
return {"job_id": job.id, "status": job.status}
|
||||
if q.kind == "pnl":
|
||||
ids = sorted(set(q.alpha_ids))
|
||||
existing = set(await self.db.scalars(select(Alpha.id).where(Alpha.id.in_(ids))))
|
||||
if existing != set(ids):
|
||||
raise ResearchError("NOT_FOUND", "部分 Alpha 尚未同步", affected_items=sorted(set(ids)-existing))
|
||||
job = await self.business.create_sync_job(JobInput(kind="pnl_refresh", alpha_ids=ids))
|
||||
self.wake = "jobs"
|
||||
return {"job_id": job["id"], "status": job["status"]}
|
||||
if q.kind == "operators":
|
||||
data = await metadata.refresh_operators()
|
||||
elif q.kind == "settings":
|
||||
data = await metadata.refresh_settings()
|
||||
else:
|
||||
data = await metadata.refresh_availability(FieldAvailabilityInput(field_id=q.field_id, scope=q.scope))
|
||||
# Refresh acknowledgment is bounded; complete content is available through paged reads.
|
||||
return {"status": "completed", "key": data["key"], "fetched_at": data["fetched_at"],
|
||||
"read_with": "get_research_metadata"}
|
||||
|
||||
async def refresh_job(self, args):
|
||||
job = await self.db.get(Job, args.job_id)
|
||||
if not job or job.kind not in {"catalog_sync", "field_sync", "pnl_refresh"}:
|
||||
raise ResearchError("NOT_FOUND", "研究刷新任务不存在")
|
||||
result = await self.business.get_job_status(args.job_id)
|
||||
query = select(JobItem).where(JobItem.job_id == job.id, JobItem.error.is_not(None))
|
||||
total = await self.db.scalar(select(func.count()).select_from(query.subquery()))
|
||||
errors = list(await self.db.scalars(query.order_by(JobItem.alpha_id).limit(args.limit).offset(args.offset)))
|
||||
result.pop("errors", None)
|
||||
return {**result, "job_id": job.id, "artifact_reference": job.payload,
|
||||
"errors": page([{"alpha_id": e.alpha_id, "error": e.error} for e in errors], total, args.limit, args.offset)}
|
||||
|
||||
async def history(self, args):
|
||||
return await self.evidence.history(args)
|
||||
|
||||
async def previous(self, operation, args):
|
||||
# PostgreSQL row lock is shared with HTTP start and catalog/job creation.
|
||||
account = await self.db.scalar(select(Account).where(Account.id == self.principal.account_id).with_for_update())
|
||||
if not account or account.wq_user_id != self.principal.wq_user_id:
|
||||
raise ResearchError("ACCOUNT_MISMATCH", "平台账户绑定已变化")
|
||||
digest = fingerprint(args.model_dump(mode="json", exclude={"idempotency_key"}))
|
||||
row = await self.db.scalar(select(ResearchRequest).where(
|
||||
ResearchRequest.account_id == account.id, ResearchRequest.operation == operation,
|
||||
ResearchRequest.idempotency_key == args.idempotency_key))
|
||||
if row and row.digest != digest:
|
||||
raise ResearchError("IDEMPOTENCY_CONFLICT", "幂等键已用于不同内容")
|
||||
return row, digest
|
||||
|
||||
async def remember(self, operation, args, digest, result):
|
||||
result["_meta"] = {"schema_version": 1, "observed_at": now().isoformat(), "source": "system"}
|
||||
self.db.add(ResearchRequest(id=str(uuid4()), account_id=self.principal.account_id,
|
||||
operation=operation, idempotency_key=args.idempotency_key, digest=digest,
|
||||
business_id=result["backtest_run_id"], response=encode_snapshot(result)))
|
||||
await self.db.flush()
|
||||
self.wake = "backtests"
|
||||
return result
|
||||
|
||||
async def validate_settings(self, candidates):
|
||||
snapshot = await ResearchMetadata(self.db).get("settings")
|
||||
options = snapshot["content"].get("items", [])
|
||||
if not snapshot["fetched_at"] or not options:
|
||||
return {"settings_validation": "unknown", "reason": "设置快照未缓存;未验证平台组合", "field_validation": "unknown"}
|
||||
invalid = []
|
||||
for c in candidates:
|
||||
s = c.settings
|
||||
matches = [r for r in options if all(r.get(k) == v for k, v in {
|
||||
"instrument_type": s.instrumentType, "region": s.region, "universe": s.universe, "delay": s.delay}.items())]
|
||||
if not matches or all(r.get("neutralizations") and s.neutralization not in r["neutralizations"] for r in matches):
|
||||
invalid.append(c.client_item_id)
|
||||
if invalid:
|
||||
raise ResearchError("UNSUPPORTED_SETTINGS", "已缓存平台设置不支持这些组合;可显式刷新后重试", affected_items=invalid)
|
||||
return {"settings_validation": "cached", "fetched_at": snapshot["fetched_at"], "field_validation": "unknown"}
|
||||
|
||||
async def submit(self, args):
|
||||
previous, digest = await self.previous("submit_backtests", args)
|
||||
if previous:
|
||||
return previous.response
|
||||
seen, within = {}, []
|
||||
for c in args.candidates:
|
||||
h = fingerprint(c.platform_input())
|
||||
if h in seen:
|
||||
within.append({"client_item_id": c.client_item_id, "duplicate_of": seen[h]})
|
||||
seen[h] = c.client_item_id
|
||||
history = await self.evidence.history(History(candidates=args.candidates, limit=100))
|
||||
if args.duplicate_policy == "reject" and (within or history["total"]):
|
||||
raise ResearchError("DUPLICATE_INPUT", "发现完整输入重复;未创建运行。重跑须明确 duplicate_policy=rerun",
|
||||
affected_items={"within_batch": within, "history": history, "read_with": "search_backtests"})
|
||||
validation = await self.validate_settings(args.candidates)
|
||||
if args.source.parent_run_id:
|
||||
await self.backtests.run(args.source.parent_run_id)
|
||||
source = Source(kind="mcp", **args.source.model_dump())
|
||||
provenance = {"mcp_token_id": self.principal.token_id, "admin_id": self.principal.admin_id}
|
||||
# preserve_source prevents the Chatbox-specific generating context rewriting MCP provenance.
|
||||
backtests = Backtests(self.db, provenance)
|
||||
preview = await backtests.preview(PreviewInput(inline=DraftInput(
|
||||
name=args.name, source=source, candidates=args.candidates)), preserve_source=True)
|
||||
result = await backtests.start(StartInput(preview_id=preview["preview_id"],
|
||||
idempotency_key="mcp-" + str(uuid4())))
|
||||
result = {**result, "input_digest": digest, "batch_count": preview["batch_count"],
|
||||
"duplicates": {"within_batch": within, "historical_matches": history["total"]},
|
||||
"validation": validation, "web_url": self.run_url(result["backtest_run_id"])}
|
||||
return await self.remember("submit_backtests", args, digest, result)
|
||||
|
||||
async def run(self, args):
|
||||
result = await self.backtests.run(args.run_id)
|
||||
attempts = list(await self.db.scalars(select(SimulationAttempt).where(SimulationAttempt.run_id == args.run_id)))
|
||||
result["submission_counts"] = {
|
||||
"candidates": result["total"], "attempts": len(attempts),
|
||||
"post_requests": sum(a.submit_count for a in attempts),
|
||||
"confirmed_accepted_candidates": sum(len(a.payload) for a in attempts if a.progress_url),
|
||||
"unknown_acceptance_candidates": sum(len(a.payload) for a in attempts if a.error_code == "submission_unknown" or (a.state == "submitting" and not a.progress_url)),
|
||||
"actual_platform_consumption": None,
|
||||
}
|
||||
if args.after is not None:
|
||||
result["events"] = await self.backtests.events(args.run_id, args.after, args.event_limit)
|
||||
return {**result, "web_url": self.run_url(args.run_id)}
|
||||
|
||||
async def results(self, args):
|
||||
await self.backtests.run(args.run_id)
|
||||
if args.item_ids:
|
||||
found = set(await self.db.scalars(select(BacktestItem.id).where(
|
||||
BacktestItem.run_id == args.run_id, BacktestItem.id.in_(args.item_ids))))
|
||||
if found != set(args.item_ids):
|
||||
raise ResearchError("NOT_FOUND", "部分候选不属于此运行")
|
||||
return await self.evidence.results(args)
|
||||
|
||||
async def artifact(self, args):
|
||||
return await self.evidence.artifact(args)
|
||||
|
||||
async def control(self, args):
|
||||
previous, digest = await self.previous("control_backtest", args)
|
||||
if previous:
|
||||
return previous.response
|
||||
before = await self.backtests.run(args.run_id)
|
||||
states = list(await self.db.scalars(select(SimulationAttempt.state).where(SimulationAttempt.run_id == args.run_id)))
|
||||
result = await self.backtests.control(args.run_id, ControlInput(action=args.action, version=args.expected_version))
|
||||
result["impact"] = {"remote_cancelled": False, "attempts_before": dict(Counter(states)),
|
||||
"indefinite_account_block_cleared": args.action == "resume" and bool(before["scheduler"]["blocked_reason"])
|
||||
and before["scheduler"]["blocked_until"] is None,
|
||||
"note": "暂停/停止仅阻止后续提交;已提交模拟继续采集。recover 不重新提交。"}
|
||||
return await self.remember("control_backtest", args, digest, result)
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Personal tokens, atomic research requests and minimal MCP audit evidence."""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0010"
|
||||
down_revision = "0009"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"mcp_tokens",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("token_hash", sa.String(64), nullable=False, unique=True),
|
||||
sa.Column("name", sa.String(100), nullable=False),
|
||||
sa.Column("admin_id", sa.Integer(), sa.ForeignKey("admins.id"), nullable=False),
|
||||
sa.Column("account_id", sa.Integer(), sa.ForeignKey("accounts.id"), nullable=False),
|
||||
sa.Column("wq_user_id", sa.String(100), nullable=False),
|
||||
sa.Column("scopes", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("revoked_at", sa.DateTime(timezone=True)),
|
||||
)
|
||||
op.create_index("ix_mcp_tokens_expires_at", "mcp_tokens", ["expires_at"])
|
||||
op.create_table(
|
||||
"research_requests",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("account_id", sa.Integer(), sa.ForeignKey("accounts.id"), nullable=False),
|
||||
sa.Column("operation", sa.String(50), nullable=False),
|
||||
sa.Column("idempotency_key", sa.String(100), nullable=False),
|
||||
sa.Column("digest", sa.String(64), nullable=False),
|
||||
sa.Column("business_id", sa.String(36), nullable=False),
|
||||
sa.Column("response", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.UniqueConstraint("account_id", "operation", "idempotency_key"),
|
||||
)
|
||||
op.create_table(
|
||||
"mcp_audits",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("token_id", sa.String(36), sa.ForeignKey("mcp_tokens.id"), nullable=False),
|
||||
sa.Column("tool", sa.String(100), nullable=False),
|
||||
sa.Column("request_id", sa.String(100), nullable=False),
|
||||
sa.Column("input_digest", sa.String(64), nullable=False),
|
||||
sa.Column("business_id", sa.String(100)),
|
||||
sa.Column("result_code", sa.String(60), nullable=False),
|
||||
sa.Column("elapsed_ms", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
op.create_index("ix_mcp_audits_token_id", "mcp_audits", ["token_id"])
|
||||
op.create_index("ix_mcp_audits_created_at", "mcp_audits", ["created_at"])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table("mcp_audits")
|
||||
op.drop_table("research_requests")
|
||||
op.drop_table("mcp_tokens")
|
||||
@@ -14,6 +14,7 @@ dependencies = [
|
||||
"cryptography>=44,<50",
|
||||
"argon2-cffi>=23.1,<26",
|
||||
"pydantic-ai-slim[openai]==1.97.0",
|
||||
"mcp>=2,<3",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@@ -81,6 +81,7 @@ def create_test_app():
|
||||
_env_file=None,
|
||||
database_url=f"sqlite+aiosqlite:///{temporary.name}/test.db",
|
||||
admin_password=TEST_PASSWORD,
|
||||
mcp_enabled=True,
|
||||
encryption_key=Fernet.generate_key().decode(),
|
||||
public_origin=os.environ.get("BROWSER_TEST_ORIGIN", "http://127.0.0.1:5179"),
|
||||
)
|
||||
@@ -204,6 +205,18 @@ def create_test_app():
|
||||
application = create_app(
|
||||
settings, WqClient(settings, transport=httpx.MockTransport(upstream)), ai_model_factory=fake_model
|
||||
)
|
||||
# Test-only fixture, excluded from production images by .dockerignore.
|
||||
from fastapi import Depends
|
||||
|
||||
from app.mcp_api.auth import SCOPES, create_token
|
||||
from app.security import require_auth
|
||||
|
||||
@application.post("/api/v1/test/mcp-token", dependencies=[Depends(require_auth)])
|
||||
async def synthetic_token():
|
||||
async with application.state.sessions.begin() as db:
|
||||
_, token = await create_token(db, "browser-fixture", SCOPES)
|
||||
return {"token": token}
|
||||
|
||||
original_lifespan = application.router.lifespan_context
|
||||
|
||||
@asynccontextmanager
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Synthetic PostgreSQL-backed server for container proxy acceptance only."""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import httpx
|
||||
from fastapi import Depends
|
||||
|
||||
from app.config import Settings
|
||||
from app.main import create_app
|
||||
from app.mcp_api.auth import SCOPES, create_token
|
||||
from app.models import Account
|
||||
from app.security import cipher, require_auth
|
||||
from app.worldquant import WqClient
|
||||
from tests.backtest_fake import Platform
|
||||
|
||||
|
||||
def create_test_app():
|
||||
settings = Settings(_env_file=None, enable_runner=False, mcp_enabled=True)
|
||||
if not settings.database_url.endswith("/wq_mcp_test"):
|
||||
raise RuntimeError("Disposable test database required")
|
||||
platform = Platform()
|
||||
app = create_app(settings, WqClient(settings, transport=httpx.MockTransport(platform)))
|
||||
original = app.router.lifespan_context
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(application):
|
||||
async with original(application):
|
||||
async with app.state.sessions.begin() as db:
|
||||
account = await db.get(Account, 1)
|
||||
account.email = "synthetic@example.com"
|
||||
account.wq_user_id = "TEST_USER"
|
||||
account.connection_status = "connected"
|
||||
account.password_encrypted = cipher(settings).encrypt(platform.secret.encode()).decode()
|
||||
yield
|
||||
|
||||
app.router.lifespan_context = lifespan
|
||||
|
||||
@app.post("/api/v1/test/mcp-token", dependencies=[Depends(require_auth)])
|
||||
async def issue_fixture():
|
||||
async with app.state.sessions.begin() as db:
|
||||
_, token = await create_token(db, "container-fixture", SCOPES)
|
||||
return {"token": token}
|
||||
|
||||
return app
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Disposable PostgreSQL migration/concurrency acceptance; synthetic platform only.
|
||||
|
||||
MCP_TEST_DATABASE_URL must identify a local database named wq_mcp_test.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import httpx
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from cryptography.fernet import Fernet
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.alphas import upsert_alpha
|
||||
from app.config import Settings
|
||||
from app.db import create_database
|
||||
from app.main import create_app
|
||||
from app.mcp_api.auth import SCOPES, authenticate, create_token
|
||||
from app.models import BacktestEvent, BacktestPreview, BacktestRun, Research, ResearchRequest
|
||||
from app.research_access.contracts import Control, Submit
|
||||
from app.research_access.service import ResearchAccess, ResearchError
|
||||
from app.worldquant import WqClient
|
||||
from tests.backtest_fake import Platform
|
||||
from tests.test_backtests import candidate, setup
|
||||
|
||||
|
||||
async def seed(settings):
|
||||
engine, sessions = create_database(settings.database_url)
|
||||
async with sessions.begin() as db:
|
||||
await upsert_alpha(db, {"id": "legacy", "regular": {"code": "rank(close)"}})
|
||||
(await db.get(Research, "legacy")).note = "keep historical note"
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def verify(settings):
|
||||
app = create_app(settings, WqClient(settings, transport=httpx.MockTransport(Platform())))
|
||||
async with app.router.lifespan_context(app):
|
||||
await setup(app)
|
||||
async with app.state.sessions.begin() as db:
|
||||
_, secret = await create_token(db, "synthetic", SCOPES)
|
||||
principal = await authenticate(db, secret)
|
||||
args = Submit(name="concurrent", candidates=[candidate()], idempotency_key="same")
|
||||
async def submit(body):
|
||||
async with app.state.sessions.begin() as db:
|
||||
return await ResearchAccess(db, principal, app.state.runner.client, settings.public_origin).submit(body)
|
||||
a, b = await asyncio.gather(submit(args), submit(args))
|
||||
assert a == b
|
||||
rid = a["backtest_run_id"]
|
||||
async with app.state.sessions() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestRun)) == 1
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestPreview)) == 1
|
||||
assert await db.scalar(select(func.count()).select_from(ResearchRequest)) == 1
|
||||
assert (await db.get(Research, "legacy")).note == "keep historical note"
|
||||
try:
|
||||
await submit(args.model_copy(update={"name": "different"}))
|
||||
except ResearchError as exc:
|
||||
assert exc.data["code"] == "IDEMPOTENCY_CONFLICT"
|
||||
else:
|
||||
raise AssertionError("different payload accepted")
|
||||
control = Control(run_id=rid, action="pause", expected_version=1, idempotency_key="pause")
|
||||
async def pause():
|
||||
async with app.state.sessions.begin() as db:
|
||||
return await ResearchAccess(db, principal, app.state.runner.client, settings.public_origin).control(control)
|
||||
x, y = await asyncio.gather(pause(), pause())
|
||||
assert x == y
|
||||
async with app.state.sessions() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestEvent).where(BacktestEvent.kind == "control")) == 1
|
||||
# A fresh process-equivalent application returns the original response after restart.
|
||||
replacement = create_app(settings, WqClient(settings, transport=httpx.MockTransport(Platform())))
|
||||
async with replacement.router.lifespan_context(replacement):
|
||||
async with replacement.state.sessions.begin() as db:
|
||||
principal = await authenticate(db, secret)
|
||||
replay = await ResearchAccess(db, principal, replacement.state.runner.client, settings.public_origin).submit(args)
|
||||
assert replay == a
|
||||
print("PostgreSQL: migration preserves history; concurrent submit/control and restart replay passed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
url = os.environ["MCP_TEST_DATABASE_URL"]
|
||||
parsed = urlsplit(url)
|
||||
if parsed.hostname not in {"127.0.0.1", "localhost"} or parsed.path != "/wq_mcp_test":
|
||||
raise SystemExit("Refusing a non-local/non-disposable database")
|
||||
key = Fernet.generate_key().decode()
|
||||
os.environ.update(DATABASE_URL=url, ADMIN_PASSWORD="synthetic-admin-only", ENCRYPTION_KEY=key,
|
||||
WQ_EMAIL="", WQ_PASSWORD="")
|
||||
settings = Settings(_env_file=None, database_url=url, admin_password="synthetic-admin-only",
|
||||
encryption_key=key, enable_runner=False, mcp_enabled=True, public_origin="http://testserver")
|
||||
config = Config("alembic.ini")
|
||||
command.upgrade(config, "0009")
|
||||
asyncio.run(seed(settings))
|
||||
command.upgrade(config, "head")
|
||||
asyncio.run(verify(settings))
|
||||
command.downgrade(config, "0009")
|
||||
command.upgrade(config, "head")
|
||||
print("PostgreSQL: additive migration downgrade/re-upgrade passed")
|
||||
@@ -0,0 +1,280 @@
|
||||
"""MCP integration uses real database/transport and a synthetic WorldQuant only."""
|
||||
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.mcp_api.auth import SCOPES, authenticate, create_token
|
||||
from app.models import (
|
||||
BacktestPreview,
|
||||
BacktestResult,
|
||||
BacktestRun,
|
||||
MCPAudit,
|
||||
MCPToken,
|
||||
Pnl,
|
||||
now,
|
||||
)
|
||||
from tests.test_backtests import candidate, execute, setup
|
||||
|
||||
ENDPOINT = "/api/v1/mcp/"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def mcp_app(settings):
|
||||
from app.main import create_app
|
||||
from app.models import Base
|
||||
from app.worldquant import WqClient
|
||||
|
||||
settings.mcp_enabled = True
|
||||
def no_network(request):
|
||||
raise AssertionError("No real platform")
|
||||
application = create_app(settings, WqClient(settings, transport=httpx.MockTransport(no_network)))
|
||||
async with application.state.engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
ready, stop = asyncio.Event(), asyncio.Event()
|
||||
async def lifespan():
|
||||
async with application.router.lifespan_context(application):
|
||||
ready.set()
|
||||
await stop.wait()
|
||||
task = asyncio.create_task(lifespan())
|
||||
await ready.wait()
|
||||
try:
|
||||
await setup(application)
|
||||
yield application
|
||||
finally:
|
||||
stop.set()
|
||||
await task
|
||||
|
||||
|
||||
async def credentials(app, scopes=SCOPES):
|
||||
async with app.state.sessions.begin() as db:
|
||||
row, secret = await create_token(db, "synthetic test", scopes)
|
||||
principal = await authenticate(db, secret)
|
||||
return principal, secret
|
||||
|
||||
|
||||
def submission(key="batch-1", items=None, **extra):
|
||||
return {"name": "MCP batch", "candidates": items or [candidate()], "idempotency_key": key, **extra}
|
||||
|
||||
|
||||
async def invoke(app, principal, name, arguments=None):
|
||||
result = await app.state.mcp.invoke(principal, name, arguments or {})
|
||||
assert not result.is_error, result.structured_content
|
||||
return result.structured_content
|
||||
|
||||
|
||||
async def test_http_auth_and_discovery(mcp_app):
|
||||
principal, secret = await credentials(mcp_app, {"research:read"})
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=mcp_app), base_url="http://testserver") as client:
|
||||
payload = {"jsonrpc": "2.0", "id": 1, "method": "tools/list"}
|
||||
assert (await client.post(ENDPOINT, json=payload)).status_code == 401
|
||||
headers = {"Authorization": f"Bearer {secret}", "Accept": "application/json, text/event-stream"}
|
||||
response = await client.post(ENDPOINT, json=payload, headers=headers)
|
||||
assert response.status_code == 200, response.text
|
||||
names = {t["name"] for t in response.json()["result"]["tools"]}
|
||||
assert "search_backtests" in names and "submit_backtests" not in names
|
||||
denied = {"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "submit_backtests", "arguments": submission()}}
|
||||
assert (await client.post(ENDPOINT, json=denied, headers=headers)).status_code == 403
|
||||
assert (await client.get("/api/v1/account", headers=headers)).status_code == 401
|
||||
assert (await client.post(ENDPOINT, json=payload, headers=headers | {"Origin": "https://bad.test"})).status_code == 403
|
||||
assert (await client.post(ENDPOINT, json=payload, headers=headers | {"Host": "bad.test"})).status_code == 403
|
||||
async with mcp_app.state.sessions.begin() as db:
|
||||
row = await db.get(MCPToken, principal.token_id)
|
||||
row.revoked_at = now()
|
||||
assert (await client.post(ENDPOINT, json=payload, headers=headers)).status_code == 401
|
||||
|
||||
|
||||
async def test_submit_replay_duplicates_and_rotation(mcp_app):
|
||||
principal, _ = await credentials(mcp_app)
|
||||
result = await invoke(mcp_app, principal, "submit_backtests", submission())
|
||||
second, _ = await credentials(mcp_app)
|
||||
replay = await invoke(mcp_app, second, "submit_backtests", submission())
|
||||
assert replay == result
|
||||
conflict = await mcp_app.state.mcp.invoke(second, "submit_backtests", submission(items=[candidate(1)]))
|
||||
assert conflict.is_error and conflict.structured_content["error"]["code"] == "IDEMPOTENCY_CONFLICT"
|
||||
duplicate = await mcp_app.state.mcp.invoke(second, "submit_backtests", submission("different"))
|
||||
assert duplicate.structured_content["error"]["code"] == "DUPLICATE_INPUT"
|
||||
rerun = await invoke(mcp_app, second, "submit_backtests", submission("different", duplicate_policy="rerun"))
|
||||
assert rerun["backtest_run_id"] != result["backtest_run_id"]
|
||||
async with mcp_app.state.sessions() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestRun)) == 2
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestPreview)) == 2
|
||||
audits = list(await db.scalars(select(MCPAudit)))
|
||||
assert len(audits) == 5
|
||||
assert result["source"]["kind"] == "mcp"
|
||||
assert "#backtests?run_id=" in result["web_url"]
|
||||
|
||||
|
||||
async def test_validation_and_within_batch_atomic(mcp_app):
|
||||
principal, _ = await credentials(mcp_app)
|
||||
incomplete = candidate()
|
||||
incomplete["settings"] = {"region": "USA", "universe": "TOP3000", "delay": 1}
|
||||
result = await mcp_app.state.mcp.invoke(principal, "submit_backtests", submission(items=[incomplete]))
|
||||
assert result.structured_content["error"]["code"] == "INVALID_INPUT"
|
||||
result = await mcp_app.state.mcp.invoke(principal, "submit_backtests", submission(items=[candidate(), candidate() | {"client_item_id": "second"}]))
|
||||
assert result.structured_content["error"]["code"] == "DUPLICATE_INPUT"
|
||||
# Failed requests did not consume the key or leave previews.
|
||||
await invoke(mcp_app, principal, "submit_backtests", submission())
|
||||
async with mcp_app.state.sessions() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestPreview)) == 1
|
||||
|
||||
|
||||
async def test_results_control_and_evidence(mcp_app):
|
||||
principal, _ = await credentials(mcp_app)
|
||||
result = await invoke(mcp_app, principal, "submit_backtests", submission(items=[candidate(0), candidate(1)]))
|
||||
rid = result["backtest_run_id"]
|
||||
control = {"run_id": rid, "action": "pause", "expected_version": 1, "idempotency_key": "pause"}
|
||||
paused = await invoke(mcp_app, principal, "control_backtest", control)
|
||||
assert paused == await invoke(mcp_app, principal, "control_backtest", control)
|
||||
await invoke(mcp_app, principal, "control_backtest", control | {"action": "resume", "expected_version": 2, "idempotency_key": "resume"})
|
||||
await execute(mcp_app, mcp_app.state.runner.backtests, rid)
|
||||
data = await invoke(mcp_app, principal, "get_backtest_results", {"run_id": rid, "limit": 1})
|
||||
assert data["has_more"] and data["items"][0]["metrics"]["is"]["sharpe"] is None
|
||||
item = data["items"][0]
|
||||
async with mcp_app.state.sessions.begin() as db:
|
||||
saved = await db.get(BacktestResult, item["id"])
|
||||
saved.snapshot = {**saved.snapshot, "is": {"checks": [{"name": "a", "result": "FAIL"}, {"name": "b", "result": "NEW_STATUS"}]}}
|
||||
data = await invoke(mcp_app, principal, "get_backtest_results", {"run_id": rid})
|
||||
assert data["items"][0]["checks"]["counts"]["FAIL"] == 1
|
||||
assert data["items"][0]["checks"]["counts"]["UNKNOWN"] == 1
|
||||
pnl = await invoke(mcp_app, principal, "get_backtest_artifact", {"item_id": item["id"], "kind": "pnl"})
|
||||
assert pnl["status"] == "not_cached"
|
||||
snapshot = await invoke(mcp_app, principal, "get_backtest_artifact", {"item_id": item["id"], "kind": "snapshot", "limit": 1})
|
||||
assert snapshot["has_more"]
|
||||
history = await invoke(mcp_app, principal, "search_backtests", {"candidates": [candidate()], "source": "mcp"})
|
||||
assert history["total"] == 1 and history["items"][0]["match_type"] == "exact_input"
|
||||
progress = await invoke(mcp_app, principal, "get_backtest", {"run_id": rid, "after": 0, "event_limit": 1})
|
||||
assert progress["events"]["has_more"] and progress["submission_counts"]["actual_platform_consumption"] is None
|
||||
|
||||
|
||||
async def test_official_sdk_client_and_error_contract(mcp_app):
|
||||
import httpx2
|
||||
from mcp import ClientSession
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
|
||||
principal, secret = await credentials(mcp_app)
|
||||
async with httpx2.AsyncClient(transport=httpx2.ASGITransport(app=mcp_app),
|
||||
headers={"Authorization": f"Bearer {secret}"}) as http:
|
||||
async with streamable_http_client("http://testserver/api/v1/mcp/", http_client=http) as streams:
|
||||
async with ClientSession(streams[0], streams[1]) as client:
|
||||
await client.initialize()
|
||||
listed = await client.list_tools()
|
||||
assert len(listed.tools) == 11
|
||||
caps = await client.call_tool("get_research_capabilities", {})
|
||||
assert caps.structured_content["max_candidates"] == 100
|
||||
result = await client.call_tool("submit_backtests", submission())
|
||||
assert not result.is_error, result
|
||||
rid = result.structured_content["backtest_run_id"]
|
||||
await execute(mcp_app, mcp_app.state.runner.backtests, rid)
|
||||
results = await client.call_tool("get_backtest_results", {"run_id": rid})
|
||||
assert results.structured_content["items"][0]["persistence_status"] == "saved"
|
||||
bad = await client.call_tool("submit_backtests", submission("duplicate"))
|
||||
assert bad.is_error and bad.structured_content["error"]["code"] == "DUPLICATE_INPUT"
|
||||
await client.call_tool("get_backtest", {"run_id": rid})
|
||||
|
||||
|
||||
async def test_expiry_binding_disabled_and_audit_redaction(app, mcp_app):
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.models import Account
|
||||
|
||||
principal, secret = await credentials(mcp_app)
|
||||
await mcp_app.state.mcp.invoke(principal, "get_research_capabilities", {}, request_id=secret)
|
||||
async with mcp_app.state.sessions.begin() as db:
|
||||
audit = await db.scalar(select(MCPAudit))
|
||||
assert secret not in audit.request_id
|
||||
row = await db.get(MCPToken, principal.token_id)
|
||||
row.expires_at = now() - timedelta(seconds=1)
|
||||
async with mcp_app.state.sessions() as db:
|
||||
with pytest.raises(HTTPException):
|
||||
await authenticate(db, secret)
|
||||
_, another = await credentials(mcp_app)
|
||||
async with mcp_app.state.sessions.begin() as db:
|
||||
account = await db.get(Account, 1)
|
||||
account.wq_user_id = "CHANGED"
|
||||
async with mcp_app.state.sessions() as db:
|
||||
with pytest.raises(HTTPException):
|
||||
await authenticate(db, another)
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as client:
|
||||
assert (await client.post(ENDPOINT, json={})).status_code == 404
|
||||
|
||||
|
||||
async def test_metadata_refresh_and_pnl_pagination(mcp_app):
|
||||
from app.worldquant import WqClient
|
||||
from tests.research_metadata_fake import response
|
||||
|
||||
principal, _ = await credentials(mcp_app)
|
||||
caps = await invoke(mcp_app, principal, "get_research_metadata", {"query": {"kind": "settings"}})
|
||||
assert caps["status"] == "not_cached"
|
||||
await mcp_app.state.runner.client.close()
|
||||
from tests.backtest_fake import Platform
|
||||
platform = Platform()
|
||||
client = WqClient(mcp_app.state.settings, transport=httpx.MockTransport(lambda request: response(request) or platform(request)))
|
||||
await client.authenticate("synthetic@example.com", "synthetic-password")
|
||||
mcp_app.state.runner.client = client
|
||||
mcp_app.state.runner.backtests.client = client
|
||||
operators = await invoke(mcp_app, principal, "refresh_research_data", {"query": {"kind": "operators"}})
|
||||
assert operators["status"] == "completed"
|
||||
page = await invoke(mcp_app, principal, "get_research_metadata", {"query": {"kind": "operators", "limit": 1}})
|
||||
assert page["total"] == 2 and page["has_more"]
|
||||
await invoke(mcp_app, principal, "refresh_research_data", {"query": {"kind": "settings"}})
|
||||
await invoke(mcp_app, principal, "refresh_research_data", {"query": {"kind": "field_availability", "scope": {"region": "USA", "universe": "TOP3000", "delay": 1}, "field_id": "close"}})
|
||||
invalid = await mcp_app.state.mcp.invoke(principal, "submit_backtests", submission(items=[candidate(region="INVALID")]))
|
||||
assert invalid.structured_content["error"]["code"] == "UNSUPPORTED_SETTINGS"
|
||||
run = await invoke(mcp_app, principal, "submit_backtests", submission())
|
||||
await execute(mcp_app, mcp_app.state.runner.backtests, run["backtest_run_id"])
|
||||
result = await invoke(mcp_app, principal, "get_backtest_results", {"run_id": run["backtest_run_id"]})
|
||||
item = result["items"][0]
|
||||
async with mcp_app.state.sessions.begin() as db:
|
||||
db.add(Pnl(alpha_id=item["alpha_id"], raw={}, points=[{"date": f"2025-01-0{i}", "value": i} for i in range(1, 4)]))
|
||||
pnl = await invoke(mcp_app, principal, "get_backtest_artifact", {"item_id": item["id"], "kind": "pnl", "limit": 1, "date_from": "2025-01-02"})
|
||||
assert pnl["total"] == 2 and pnl["items"][0]["value"] == 2 and pnl["has_more"]
|
||||
|
||||
|
||||
async def test_lost_http_response_replays_without_new_run(mcp_app):
|
||||
import json
|
||||
|
||||
_, secret = await credentials(mcp_app)
|
||||
class LoseResponse(httpx.ASGITransport):
|
||||
dropped = False
|
||||
async def handle_async_request(self, request):
|
||||
response = await super().handle_async_request(request)
|
||||
if not self.dropped:
|
||||
self.dropped = True
|
||||
await response.aread()
|
||||
raise httpx.ReadError("synthetic lost response", request=request)
|
||||
return response
|
||||
transport = LoseResponse(app=mcp_app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver",
|
||||
headers={"Authorization": f"Bearer {secret}", "Accept": "application/json, text/event-stream"}) as client:
|
||||
payload = {"jsonrpc": "2.0", "id": "lost", "method": "tools/call",
|
||||
"params": {"name": "submit_backtests", "arguments": submission()}}
|
||||
with pytest.raises(httpx.ReadError):
|
||||
await client.post(ENDPOINT, json=payload)
|
||||
response = await client.post(ENDPOINT, json=payload)
|
||||
assert response.status_code == 200
|
||||
assert not response.json()["result"]["isError"]
|
||||
async with mcp_app.state.sessions() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestRun)) == 1
|
||||
audits = list(await db.scalars(select(MCPAudit)))
|
||||
assert all("wqmcp_" not in json.dumps(a.__dict__, default=str) for a in audits)
|
||||
|
||||
|
||||
async def test_refresh_job_error_pages_and_kind_isolation(mcp_app):
|
||||
from app.models import Job, JobItem
|
||||
|
||||
principal, _ = await credentials(mcp_app)
|
||||
async with mcp_app.state.sessions.begin() as db:
|
||||
db.add(Job(id="refresh", kind="pnl_refresh", failed=2))
|
||||
db.add(Job(id="auth", kind="connect"))
|
||||
await db.flush()
|
||||
db.add_all([JobItem(job_id="refresh", alpha_id=str(i), error="synthetic error") for i in range(2)])
|
||||
result = await invoke(mcp_app, principal, "get_refresh_job", {"job_id": "refresh", "limit": 1})
|
||||
assert result["errors"]["total"] == 2 and result["errors"]["has_more"]
|
||||
result = await invoke(mcp_app, principal, "get_refresh_job", {"job_id": "refresh", "offset": 1})
|
||||
assert result["errors"]["items"][0]["alpha_id"] == "1"
|
||||
error = await mcp_app.state.mcp.invoke(principal, "get_refresh_job", {"job_id": "auth"})
|
||||
assert error.structured_content["error"]["code"] == "NOT_FOUND"
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Browser PAT lifecycle and authentication boundaries, with no upstream calls."""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.mcp_api.auth import authenticate
|
||||
from app.models import Account, MCPToken, now
|
||||
|
||||
|
||||
async def test_token_browser_lifecycle(app, logged_in):
|
||||
client = logged_in
|
||||
# Administration remains available when the MCP transport is disabled.
|
||||
initial = (await client.get("/api/v1/mcp-tokens")).json()
|
||||
assert initial["enabled"] is False and initial["can_create"] is False
|
||||
assert (await client.post("/api/v1/mcp-tokens", json={"name": "reader"})).status_code == 422
|
||||
async with app.state.sessions.begin() as db:
|
||||
account = await db.get(Account, 1)
|
||||
account.wq_user_id = "synthetic-user"
|
||||
issued = await client.post("/api/v1/mcp-tokens", json={"name": " reader "})
|
||||
assert issued.status_code == 201
|
||||
assert issued.headers["cache-control"] == "no-store"
|
||||
token = issued.json()
|
||||
assert token["name"] == "reader" and token["scopes"] == ["research:read"]
|
||||
assert token["status"] == "active" and token["token"].startswith("wqmcp_")
|
||||
async with app.state.sessions() as db:
|
||||
principal = await authenticate(db, token["token"])
|
||||
row = await db.get(MCPToken, principal.token_id)
|
||||
assert row.token_hash != token["token"]
|
||||
assert 89 < (row.expires_at.replace(tzinfo=now().tzinfo) - now()).total_seconds() / 86400 <= 90
|
||||
page = await client.get("/api/v1/mcp-tokens?limit=1")
|
||||
assert token["token"] not in page.text and "token_hash" not in page.text
|
||||
assert page.json()["items"][0]["id"] == token["id"]
|
||||
second = await client.post("/api/v1/mcp-tokens", json={"name": "executor", "days": 1, "scopes": ["research:read", "backtests:execute"]})
|
||||
assert second.status_code == 201
|
||||
page = (await client.get("/api/v1/mcp-tokens?limit=1")).json()
|
||||
assert page["total"] == 2 and page["has_more"]
|
||||
assert (await client.get("/api/v1/mcp-tokens?limit=1&offset=1")).json()["items"][0]["id"] == token["id"]
|
||||
revoked = await client.post(f'/api/v1/mcp-tokens/{token["id"]}/revoke')
|
||||
again = await client.post(f'/api/v1/mcp-tokens/{token["id"]}/revoke')
|
||||
assert revoked.json() == again.json() and revoked.json()["status"] == "revoked"
|
||||
assert "token" not in revoked.json()
|
||||
async with app.state.sessions() as db:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await authenticate(db, token["token"])
|
||||
assert exc.value.status_code == 401
|
||||
assert (await client.post("/api/v1/mcp-tokens/missing/revoke")).status_code == 404
|
||||
|
||||
|
||||
async def test_token_browser_security_and_validation(app, logged_in):
|
||||
client = logged_in
|
||||
async with app.state.sessions.begin() as db:
|
||||
(await db.get(Account, 1)).wq_user_id = "synthetic-user"
|
||||
for body in [
|
||||
{"name": " "}, {"name": "x", "days": 0}, {"name": "x", "days": 366},
|
||||
{"name": "x", "days": 1.5}, {"name": "x", "scopes": ["backtests:execute"]},
|
||||
{"name": "x", "scopes": ["research:read", "admin"]}, {"name": "x", "account_id": 2},
|
||||
]:
|
||||
assert (await client.post("/api/v1/mcp-tokens", json=body)).status_code == 422
|
||||
assert (await client.post("/api/v1/mcp-tokens", json={"name": "x"}, headers={"X-WQ-Request": ""})).status_code == 403
|
||||
assert (await client.post("/api/v1/mcp-tokens", json={"name": "x"}, headers={"Origin": "https://evil.test"})).status_code == 403
|
||||
token = (await client.post("/api/v1/mcp-tokens", json={"name": "x"})).json()
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as outsider:
|
||||
headers = {"Authorization": f'Bearer {token["token"]}', "X-WQ-Request": "1"}
|
||||
assert (await outsider.get("/api/v1/mcp-tokens")).status_code == 401
|
||||
assert (await outsider.get("/api/v1/mcp-tokens", headers=headers)).status_code == 401
|
||||
assert (await outsider.post("/api/v1/mcp-tokens", json={"name": "escalation"}, headers=headers)).status_code == 401
|
||||
assert (await outsider.post(f'/api/v1/mcp-tokens/{token["id"]}/revoke', headers=headers)).status_code == 401
|
||||
async with app.state.sessions.begin() as db:
|
||||
(await db.get(Account, 1)).wq_user_id = "different-user"
|
||||
assert (await client.get("/api/v1/mcp-tokens")).json()["items"][0]["status"] == "invalid_binding"
|
||||
async with app.state.sessions.begin() as db:
|
||||
row = await db.scalar(select(MCPToken))
|
||||
row.expires_at = now() - timedelta(days=1)
|
||||
assert (await client.get("/api/v1/mcp-tokens")).json()["items"][0]["status"] == "expired"
|
||||
Generated
+159
@@ -105,6 +105,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "attrs"
|
||||
version = "26.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.7.22"
|
||||
@@ -424,6 +433,33 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/78/f7/18a1afcd64f35314b68c1f23afcd9994d0bc13e65cc77517afff4e83986d/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d613743df53199b1aa256a7d328340da6d7078aac7705a7db9d7a791e9cfd2", size = 343885, upload-time = "2026-06-29T13:05:12.087Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema"
|
||||
version = "4.26.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
{ name = "jsonschema-specifications" },
|
||||
{ name = "referencing" },
|
||||
{ name = "rpds-py" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema-specifications"
|
||||
version = "2025.9.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "referencing" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "logfire-api"
|
||||
version = "5.0.0"
|
||||
@@ -464,6 +500,44 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mcp"
|
||||
version = "2.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "httpx2" },
|
||||
{ name = "jsonschema" },
|
||||
{ name = "mcp-types" },
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pyjwt", extra = ["crypto"] },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "pywin32", marker = "sys_platform == 'win32'" },
|
||||
{ name = "sse-starlette" },
|
||||
{ name = "starlette" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/76/31/ac54fb0fdd5b37de704486e288bba4fbbb463f24cfcfedbede407b854513/mcp-2.2.0.tar.gz", hash = "sha256:2dc37ecb1974becdcebdbf7561e7c15a07dbbf20ba21ba16c3593b3038b3afbd", size = 4084129, upload-time = "2026-09-07T16:06:23.439Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/ff/8e7eade68b8a28f7da0ed1085544341b51f9c935dbf6b95c76b7edfea6a0/mcp-2.2.0-py3-none-any.whl", hash = "sha256:bde982589473a060ae145e3406e9a5333fe538c97229ba841f5a7f92be004f81", size = 365656, upload-time = "2026-09-07T16:06:19.711Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mcp-types"
|
||||
version = "2.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ae/91/762d7755d971aff8a28d75f7961656148edf27875c8026e6385aaab08ae7/mcp_types-2.2.0.tar.gz", hash = "sha256:d3ed53703ddd10d9c6399f29d322bb66f3f67ab41348ac8556ba23e07fedefad", size = 65892, upload-time = "2026-09-07T16:06:25.187Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/d7/6ffba5d8cd5dd9b8a19478875c50e04945314ba5074e84d749283f27f62d/mcp_types-2.2.0-py3-none-any.whl", hash = "sha256:ea476b73ee86709ab5abc9452385ed36cc05907e582355622e294595c9a04f13", size = 69106, upload-time = "2026-09-07T16:06:21.461Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openai"
|
||||
version = "3.8.0"
|
||||
@@ -627,6 +701,20 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
crypto = [
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.1.1"
|
||||
@@ -665,6 +753,25 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.32"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pywin32"
|
||||
version = "312"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
@@ -683,6 +790,20 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "referencing"
|
||||
version = "0.37.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
{ name = "rpds-py" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "2026.9.3"
|
||||
@@ -722,6 +843,29 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rpds-py"
|
||||
version = "2026.6.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.16.6"
|
||||
@@ -781,6 +925,19 @@ asyncio = [
|
||||
{ name = "greenlet" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sse-starlette"
|
||||
version = "3.4.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "starlette" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2b/54/6767bb789b2f2fed6e0f953df949cd39dc263a384c1b65a95232598621d6/sse_starlette-3.4.11.tar.gz", hash = "sha256:1bae716c02f3e6f294be41ff333220692dae7c3cbab077c900f159676719dade", size = 34972, upload-time = "2026-09-05T12:11:04.607Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/98/6a/2ba3ed4a69babf3afdddf7d8314a48d87562c0a442206bbc2a1b50d5efc0/sse_starlette-3.4.11-py3-none-any.whl", hash = "sha256:c7b2244bdff016fe7f64e10075e89a3e6bbf899649cc89b0fe884b5545042453", size = 17122, upload-time = "2026-09-05T12:11:03.195Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "1.6.0"
|
||||
@@ -954,6 +1111,7 @@ dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "mcp" },
|
||||
{ name = "pydantic-ai-slim", extra = ["openai"] },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "sqlalchemy", extra = ["asyncio"] },
|
||||
@@ -976,6 +1134,7 @@ requires-dist = [
|
||||
{ name = "cryptography", specifier = ">=44,<50" },
|
||||
{ name = "fastapi", specifier = ">=0.115,<1" },
|
||||
{ name = "httpx", specifier = ">=0.28,<1" },
|
||||
{ name = "mcp", specifier = ">=2,<3" },
|
||||
{ name = "pydantic-ai-slim", extras = ["openai"], specifier = "==1.97.0" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.8,<3" },
|
||||
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.38,<2.1" },
|
||||
|
||||
Reference in New Issue
Block a user