main
py 175 lines 5.2 KB
Raw
1 """Read-only MCP tool contracts over existing application capabilities."""
2 from __future__ import annotations
3
4 from typing import Any, Literal
5 from uuid import UUID
6
7 from pydantic import Field, field_validator
8
9 from app.application_client import ApplicationResearchReader
10 from app.contracts import (
11 McpAuthContext,
12 McpToolDefinition,
13 McpRiskClass,
14 McpToolExecution,
15 McpToolKind,
16 StrictContract,
17 )
18 from app.registry import McpToolRegistry
19
20
21 class CanonicalInstrumentInput(StrictContract):
22 global_instrument_id: UUID = Field(alias="globalInstrumentId")
23
24 @field_validator("global_instrument_id")
25 @classmethod
26 def non_zero_id(cls, value: UUID) -> UUID:
27 if value.int == 0:
28 raise ValueError("canonical globalInstrumentId is required")
29 return value
30
31
32 class CompanyAnalysisInput(CanonicalInstrumentInput):
33 allow_partial: bool = Field(default=False, alias="allowPartial")
34
35
36 class RecentNewsInput(CanonicalInstrumentInput):
37 days: int = Field(default=30, ge=1, le=30)
38
39
40 class SectorPerformanceInput(StrictContract):
41 region: Literal["USA", "EUROPE", "INDIA"]
42 sector: str = Field(min_length=1, max_length=120)
43 period: Literal["DAY", "WEEK", "MONTH", "YEAR"]
44 limit: int = Field(default=5, ge=1, le=20)
45
46 @field_validator("sector")
47 @classmethod
48 def non_blank_sector(cls, value: str) -> str:
49 normalized = value.strip()
50 if not normalized:
51 raise ValueError("sector is required")
52 return normalized
53
54
55 class EvidenceSearchInput(CanonicalInstrumentInput):
56 query: str = Field(min_length=2, max_length=200)
57 limit: int = Field(default=10, ge=1, le=20)
58
59 @field_validator("query")
60 @classmethod
61 def non_blank_query(cls, value: str) -> str:
62 return value.strip()
63
64
65 class WatchlistInput(StrictContract):
66 watchlist_id: UUID = Field(alias="watchlistId")
67
68
69 TOOL_MODELS: tuple[tuple[str, str, type[StrictContract], bool], ...] = (
70 (
71 "get_research_readiness",
72 "Read persisted Research Readiness for one canonical globalInstrumentId; never acquires data.",
73 CanonicalInstrumentInput,
74 False,
75 ),
76 (
77 "get_company_analysis",
78 "Return the existing STOCK_RULE_ENGINE_V1 analysis over durable data.",
79 CompanyAnalysisInput,
80 False,
81 ),
82 (
83 "get_financial_facts",
84 "Read persisted financial statement facts for one canonical globalInstrumentId.",
85 CanonicalInstrumentInput,
86 False,
87 ),
88 (
89 "get_quarterly_results",
90 "Read persisted quarterly results for one canonical globalInstrumentId.",
91 CanonicalInstrumentInput,
92 False,
93 ),
94 (
95 "get_shareholding",
96 "Read persisted shareholding snapshots for one canonical globalInstrumentId.",
97 CanonicalInstrumentInput,
98 False,
99 ),
100 (
101 "get_recent_news",
102 "Read persisted current news within a maximum rolling window of 30 days.",
103 RecentNewsInput,
104 False,
105 ),
106 (
107 "get_sector_performance",
108 "Read the existing durable Sector Performance result without population or provider calls.",
109 SectorPerformanceInput,
110 False,
111 ),
112 (
113 "search_research_evidence",
114 "Search persisted evidence metadata for one canonical globalInstrumentId.",
115 EvidenceSearchInput,
116 False,
117 ),
118 (
119 "get_watchlist",
120 "Read one authenticated user's persisted watchlist research projection.",
121 WatchlistInput,
122 True,
123 ),
124 )
125
126
127 def build_internal_tool_registry(reader: ApplicationResearchReader) -> McpToolRegistry:
128 registry = McpToolRegistry()
129 for name, description, input_model, requires_user in TOOL_MODELS:
130 registry.register(
131 McpToolDefinition(
132 name=name,
133 description=description,
134 input_model=input_model,
135 risk_class=McpRiskClass.SAFE_READ,
136 kind=McpToolKind.INTERNAL,
137 handler=_handler(reader, name),
138 required_scopes=("mcp:read", "watchlist:read")
139 if requires_user
140 else ("mcp:read",),
141 requires_user=requires_user,
142 )
143 )
144 return registry
145
146
147 def _handler(reader: ApplicationResearchReader, tool: str):
148 async def execute(arguments, auth: McpAuthContext, request_id: str) -> McpToolExecution:
149 payload: dict[str, Any] = arguments.model_dump(mode="json", by_alias=True)
150 result = await reader.invoke(tool, payload, auth, request_id)
151 generated_at = _generated_at(result)
152 rule_engine_version = (
153 str(result.get("ruleEngineVersion"))
154 if tool == "get_company_analysis" and result.get("ruleEngineVersion")
155 else None
156 )
157 return McpToolExecution(
158 data=result,
159 generated_at=generated_at,
160 rule_engine_version=rule_engine_version,
161 )
162
163 return execute
164
165
166 def _generated_at(result: dict[str, Any]):
167 from datetime import datetime
168
169 value = result.get("generatedAt") or result.get("asOf")
170 if not value:
171 return None
172 try:
173 return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
174 except ValueError:
175 return None