feat(mcp): add WorldQuant authentication recovery tools
Deploy production / deploy (push) Successful in 52s

This commit is contained in:
yuxuanhui
2026-09-10 10:26:44 +08:00
parent d4c33d0124
commit f67b4d99b7
6 changed files with 142 additions and 4 deletions
+3 -1
View File
@@ -21,6 +21,8 @@ from ..research_access.service import ResearchAccess, ResearchError
# Name, schema, business method, required scope, description. No generic arbitrary HTTP tool.
TOOLS = {
"get_worldquant_connection": (c.ConnectionReference, "connection", "research:read", "读取 WorldQuant 连接状态及可选认证 job_id 的进度,不发起认证;人工验证在网页完成。"),
"authenticate_worldquant": (c.Authentication, "authenticate", "research:refresh", "使用服务端已保存凭据连接或重新认证 WorldQuant,返回 job_id;action=connect(默认)或人工验证后 verify。用 get_worldquant_connection 查询,不接收密码,不修改账户配置。"),
"get_research_capabilities": (c.Empty, "capabilities", "research:read", "读取直接研究能力、完整设置 schema 和调度阻塞,不代表平台剩余额度。"),
"search_catalog": (c.CatalogSearch, "catalog", "research:read", "分页查询指定范围的数据集或字段元数据;无缓存不等于无数据,不隐式刷新。"),
"get_research_metadata": (c.Metadata, "metadata", "research:read", "读取范围、设置快照、算子定义或字段可用性;未知不认定通过。"),
@@ -66,7 +68,7 @@ class MCPResearchServer:
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", "check_self_correlation"}))
openWorldHint=method in {"refresh", "submit", "metadata", "check_self_correlation", "authenticate"}))
for name, (schema, method, scope, description) in TOOLS.items()
if scope in principal.scopes and "research:read" in principal.scopes])
+8
View File
@@ -18,6 +18,14 @@ class Empty(Contract):
pass
class Authentication(Contract):
action: Literal["connect", "verify"] = "connect"
class ConnectionReference(Contract):
job_id: RunId | None = None
class Page(Contract):
limit: int = Field(default=25, ge=1, le=100)
offset: int = Field(default=0, ge=0)
+46
View File
@@ -17,6 +17,7 @@ from ..catalog.platform import platform_options, validate_platform_scope
from ..catalog.research_metadata import ResearchMetadata, availability_key
from ..catalog.service import Catalog
from ..correlation import MIN_SAMPLES, THRESHOLD, WINDOW_YEARS
from ..jobs import AUTH_KINDS
from ..models import Account, Alpha, BacktestItem, Job, JobItem, ResearchRequest, SimulationAttempt, now
from ..research.serialization import encode_snapshot
from ..research.workspace_contracts import FieldAvailabilityInput
@@ -59,6 +60,51 @@ class ResearchAccess:
"platform_check": False,
}}
async def connection(self, args):
"""Read bounded connection evidence; never return credentials or session cookies."""
account = await self.db.get(Account, self.principal.account_id)
if not account or account.wq_user_id != self.principal.wq_user_id:
raise ResearchError("ACCOUNT_MISMATCH", "平台账户绑定已变化")
job = None
if args.job_id:
job = await self.db.get(Job, args.job_id)
if not job or job.kind not in AUTH_KINDS:
raise ResearchError("NOT_FOUND", "认证任务不存在")
return {"connection_status": account.connection_status,
"session_authenticated": self.client.authenticated,
"credentials_configured": bool(account.email and account.password_encrypted),
"requires_human_verification": account.connection_status == "verification_required",
"web_url": f"{self.public_origin}/",
"job": {"job_id": job.id, "kind": job.kind, "status": job.status} if job else None,
"next_step": "需要人工验证时在网页账户连接面板完成,再调用 authenticate_worldquant(action=verify)"}
async def authenticate(self, args):
"""Queue authentication using saved credentials, sharing the HTTP account lock.
The caller commits the job and audit together before waking the runner.
Human challenges remain in the browser; no password or challenge URL is exposed.
"""
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", "平台账户绑定已变化")
if not account.email or not account.password_encrypted:
raise ResearchError("CREDENTIALS_NOT_CONFIGURED", "请先在网页保存 WorldQuant 账户配置")
job = await self.db.scalar(select(Job).where(
Job.kind.in_(AUTH_KINDS), Job.status.in_(("running", "queued"))))
if not job:
# Preserve a pending challenge instead of replacing its server-side session.
if args.action == "connect" and account.connection_status == "verification_required":
raise ResearchError("VERIFICATION_REQUIRED", "请在网页完成人工验证,再调用 action=verify")
job = Job(id=str(uuid4()), kind=args.action, payload={}, checkpoint={})
self.db.add(job)
if args.action == "connect":
account.connection_status, account.connection_error = "connecting", None
await self.db.flush()
self.wake = "jobs"
return {"job_id": job.id, "status": job.status, "action": job.kind,
"read_with": "get_worldquant_connection", "web_url": f"{self.public_origin}/"}
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"}),
+77 -1
View File
@@ -162,7 +162,7 @@ async def test_official_sdk_client_and_error_contract(mcp_app):
async with ClientSession(streams[0], streams[1]) as client:
await client.initialize()
listed = await client.list_tools()
assert len(listed.tools) == 13
assert len(listed.tools) == 15
caps = await client.call_tool("get_research_capabilities", {})
assert caps.structured_content["max_candidates"] == 100
result = await client.call_tool("submit_backtests", submission())
@@ -383,3 +383,79 @@ async def test_self_correlation_read_only_scope_and_invalid_inputs(mcp_app):
assert missing.is_error and missing.structured_content["error"]["code"] == "NOT_FOUND"
async with mcp_app.state.sessions() as db:
assert await db.scalar(select(func.count()).select_from(Job)) == 0
async def test_worldquant_authentication_queue_and_recovery(mcp_app):
from app.models import Account, Job
principal, _ = await credentials(mcp_app)
runner = mcp_app.state.runner
runner.client.disconnect()
async with mcp_app.state.sessions.begin() as db:
account = await db.get(Account, 1)
account.connection_status = "disconnected"
before = await invoke(mcp_app, principal, "get_worldquant_connection")
assert before["credentials_configured"] and not before["session_authenticated"]
runner.wake.clear()
started = await invoke(mcp_app, principal, "authenticate_worldquant")
assert started["status"] == "queued" and runner.wake.is_set()
again = await invoke(mcp_app, principal, "authenticate_worldquant")
assert again["job_id"] == started["job_id"]
await runner.execute(started["job_id"])
done = await invoke(mcp_app, principal, "get_worldquant_connection", {"job_id": started["job_id"]})
assert done["job"]["status"] == "completed"
assert done["connection_status"] == "connected" and done["session_authenticated"]
async with mcp_app.state.sessions() as db:
assert await db.scalar(select(func.count()).select_from(Job)) == 1
audit = await db.scalar(select(MCPAudit).where(MCPAudit.tool == "authenticate_worldquant"))
assert audit.business_id == started["job_id"]
assert "synthetic-platform-secret" not in str(done)
assert "password" not in str(done) and "verification_url" not in str(done)
async def test_worldquant_authentication_permissions_and_challenge(mcp_app):
from fastapi import HTTPException
from app.models import Account, Job
from app.worldquant import VerificationRequired
readonly, _ = await credentials(mcp_app, {"research:read"})
await invoke(mcp_app, readonly, "get_worldquant_connection")
with pytest.raises(HTTPException) as denied:
await mcp_app.state.mcp.invoke(readonly, "authenticate_worldquant", {})
assert denied.value.status_code == 403
principal, _ = await credentials(mcp_app)
invalid = await mcp_app.state.mcp.invoke(principal, "authenticate_worldquant", {"password": "untrusted"})
assert invalid.is_error and invalid.structured_content["error"]["code"] == "INVALID_INPUT"
runner = mcp_app.state.runner
original = runner.client.authenticate
async def challenge(*args, **kwargs):
runner.client.verification_url = "https://api.worldquantbrain.com/authentication/test-challenge"
raise VerificationRequired(runner.client.verification_url)
runner.client.authenticate = challenge
started = await invoke(mcp_app, principal, "authenticate_worldquant")
await runner.execute(started["job_id"])
waiting = await invoke(mcp_app, principal, "get_worldquant_connection", {"job_id": started["job_id"]})
assert waiting["requires_human_verification"] and waiting["job"]["status"] == "waiting_auth"
blocked = await mcp_app.state.mcp.invoke(principal, "authenticate_worldquant", {})
assert blocked.structured_content["error"]["code"] == "VERIFICATION_REQUIRED"
runner.client.authenticate = original
# Simulate completion of the human challenge; keep normal profile/identity verification.
async def verified():
runner.client.verification_url = None
await original("synthetic@example.com", "synthetic-platform-secret", force=True)
runner.client.verify = verified
resumed = await invoke(mcp_app, principal, "authenticate_worldquant", {"action": "verify"})
await runner.execute(resumed["job_id"])
done = await invoke(mcp_app, principal, "get_worldquant_connection", {"job_id": resumed["job_id"]})
assert done["connection_status"] == "connected" and done["job"]["status"] == "completed"
async with mcp_app.state.sessions.begin() as db:
account = await db.get(Account, 1)
account.password_encrypted = None
db.add(Job(id="unrelated", kind="pnl_refresh"))
missing = await mcp_app.state.mcp.invoke(principal, "authenticate_worldquant", {})
assert missing.structured_content["error"]["code"] == "CREDENTIALS_NOT_CONFIGURED"
wrong = await mcp_app.state.mcp.invoke(principal, "get_worldquant_connection", {"job_id": "unrelated"})
assert wrong.structured_content["error"]["code"] == "NOT_FOUND"