feat(mcp): add WorldQuant authentication recovery tools
Deploy production / deploy (push) Successful in 52s
Deploy production / deploy (push) Successful in 52s
This commit is contained in:
@@ -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])
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}),
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user