scenario-navigator 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- scenario_navigator-0.1.0/.gitignore +2 -0
- scenario_navigator-0.1.0/PKG-INFO +95 -0
- scenario_navigator-0.1.0/README.md +80 -0
- scenario_navigator-0.1.0/pyproject.toml +24 -0
- scenario_navigator-0.1.0/scenario_navigator_sdk/__init__.py +253 -0
- scenario_navigator-0.1.0/scenario_navigator_sdk/mcp_server.py +66 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: scenario-navigator
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the Scenario Navigator API — news scenario in, ranked stock impact out.
|
|
5
|
+
Project-URL: Homepage, https://scenarionavigator.io
|
|
6
|
+
Project-URL: Documentation, https://scenarionavigator.io/docs
|
|
7
|
+
Author-email: Scenario Navigator <partners@scenarionavigator.io>
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: api,finance,llm,news,stocks
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Requires-Dist: requests>=2.28
|
|
12
|
+
Provides-Extra: mcp
|
|
13
|
+
Requires-Dist: mcp>=1.0; extra == 'mcp'
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# scenario-navigator
|
|
17
|
+
|
|
18
|
+
Official Python SDK for the [Scenario Navigator API](https://scenarionavigator.io) — news scenario in, ranked stock impact out.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install scenario-navigator
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Quickstart (no signup)
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from scenario_navigator_sdk import Client
|
|
30
|
+
|
|
31
|
+
client = Client.with_trial_token() # free 1-hour trial key
|
|
32
|
+
analysis = client.analyze("OPEC announces a surprise 2M barrel production cut")
|
|
33
|
+
|
|
34
|
+
for stock in analysis.benefiting:
|
|
35
|
+
print(stock.stock, stock.confidence, "—", stock.reason)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
With a partner key: `Client(api_key="sn_live_...")`.
|
|
39
|
+
|
|
40
|
+
## Depths
|
|
41
|
+
|
|
42
|
+
| depth | latency | notes |
|
|
43
|
+
|---|---|---|
|
|
44
|
+
| `instant` | seconds | synchronous, no web search, confidence caps at `medium`, `preliminary=True` |
|
|
45
|
+
| `fast` (default) | ~1 min | full ensemble with live web search |
|
|
46
|
+
| `deep` | 2–3 min | deepest research pass |
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
quick = client.analyze("Fed cuts rates 50bps", depth="instant")
|
|
50
|
+
deep = client.analyze("Fed cuts rates 50bps", depth="deep", timeout=400)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## The instant read path: the feed
|
|
54
|
+
|
|
55
|
+
Trending market scenarios are pre-analyzed around the clock — no submission, <100ms:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
feed = client.feed(limit=10, include_analysis=True)
|
|
59
|
+
for item in feed["items"]:
|
|
60
|
+
print(item["text"], item["analysis"])
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Webhooks
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
hook = client.create_webhook("https://example.com/sn-webhook")
|
|
67
|
+
print(hook["secret"]) # shown once — store it
|
|
68
|
+
|
|
69
|
+
# In your webhook handler:
|
|
70
|
+
from scenario_navigator_sdk import verify_webhook_signature
|
|
71
|
+
ok = verify_webhook_signature(request_body_bytes,
|
|
72
|
+
request.headers["X-SN-Signature"],
|
|
73
|
+
secret=hook["secret"])
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Account
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
client.usage() # today / month-to-date / 30-day series + your limits
|
|
80
|
+
client.rotate_key() # new key returned; old key lives 24h
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## MCP server (AI agents)
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
pip install "scenario-navigator[mcp]"
|
|
87
|
+
SCEN_NAV_TOKEN=sn_live_... python -m scenario_navigator_sdk.mcp_server
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Exposes `analyze_scenario` and `trending_scenarios` tools over MCP stdio.
|
|
91
|
+
|
|
92
|
+
## Errors
|
|
93
|
+
|
|
94
|
+
All failures raise `ApiError` with `status_code`, `code` (e.g. `rate_limited`,
|
|
95
|
+
`quota_exceeded`), and `request_id` — include the `request_id` in support requests.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# scenario-navigator
|
|
2
|
+
|
|
3
|
+
Official Python SDK for the [Scenario Navigator API](https://scenarionavigator.io) — news scenario in, ranked stock impact out.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install scenario-navigator
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quickstart (no signup)
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from scenario_navigator_sdk import Client
|
|
15
|
+
|
|
16
|
+
client = Client.with_trial_token() # free 1-hour trial key
|
|
17
|
+
analysis = client.analyze("OPEC announces a surprise 2M barrel production cut")
|
|
18
|
+
|
|
19
|
+
for stock in analysis.benefiting:
|
|
20
|
+
print(stock.stock, stock.confidence, "—", stock.reason)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
With a partner key: `Client(api_key="sn_live_...")`.
|
|
24
|
+
|
|
25
|
+
## Depths
|
|
26
|
+
|
|
27
|
+
| depth | latency | notes |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| `instant` | seconds | synchronous, no web search, confidence caps at `medium`, `preliminary=True` |
|
|
30
|
+
| `fast` (default) | ~1 min | full ensemble with live web search |
|
|
31
|
+
| `deep` | 2–3 min | deepest research pass |
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
quick = client.analyze("Fed cuts rates 50bps", depth="instant")
|
|
35
|
+
deep = client.analyze("Fed cuts rates 50bps", depth="deep", timeout=400)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## The instant read path: the feed
|
|
39
|
+
|
|
40
|
+
Trending market scenarios are pre-analyzed around the clock — no submission, <100ms:
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
feed = client.feed(limit=10, include_analysis=True)
|
|
44
|
+
for item in feed["items"]:
|
|
45
|
+
print(item["text"], item["analysis"])
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Webhooks
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
hook = client.create_webhook("https://example.com/sn-webhook")
|
|
52
|
+
print(hook["secret"]) # shown once — store it
|
|
53
|
+
|
|
54
|
+
# In your webhook handler:
|
|
55
|
+
from scenario_navigator_sdk import verify_webhook_signature
|
|
56
|
+
ok = verify_webhook_signature(request_body_bytes,
|
|
57
|
+
request.headers["X-SN-Signature"],
|
|
58
|
+
secret=hook["secret"])
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Account
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
client.usage() # today / month-to-date / 30-day series + your limits
|
|
65
|
+
client.rotate_key() # new key returned; old key lives 24h
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## MCP server (AI agents)
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pip install "scenario-navigator[mcp]"
|
|
72
|
+
SCEN_NAV_TOKEN=sn_live_... python -m scenario_navigator_sdk.mcp_server
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Exposes `analyze_scenario` and `trending_scenarios` tools over MCP stdio.
|
|
76
|
+
|
|
77
|
+
## Errors
|
|
78
|
+
|
|
79
|
+
All failures raise `ApiError` with `status_code`, `code` (e.g. `rate_limited`,
|
|
80
|
+
`quota_exceeded`), and `request_id` — include the `request_id` in support requests.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "scenario-navigator"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python SDK for the Scenario Navigator API — news scenario in, ranked stock impact out."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Scenario Navigator", email = "partners@scenarionavigator.io" }]
|
|
13
|
+
dependencies = ["requests>=2.28"]
|
|
14
|
+
keywords = ["finance", "stocks", "news", "llm", "api"]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
mcp = ["mcp>=1.0"]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://scenarionavigator.io"
|
|
21
|
+
Documentation = "https://scenarionavigator.io/docs"
|
|
22
|
+
|
|
23
|
+
[tool.hatch.build.targets.wheel]
|
|
24
|
+
packages = ["scenario_navigator_sdk"]
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Scenario Navigator Python SDK.
|
|
2
|
+
|
|
3
|
+
from scenario_navigator_sdk import Client
|
|
4
|
+
|
|
5
|
+
client = Client(api_key="sn_live_...") # or a trial token
|
|
6
|
+
analysis = client.analyze("OPEC announces a surprise 2M barrel cut")
|
|
7
|
+
for stock in analysis.benefiting:
|
|
8
|
+
print(stock.stock, stock.confidence, stock.reason)
|
|
9
|
+
|
|
10
|
+
No key yet? ``Client.with_trial_token()`` mints a free 1-hour trial key.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import hmac
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Any, Dict, List, Optional
|
|
19
|
+
|
|
20
|
+
import requests
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Client",
|
|
24
|
+
"Analysis",
|
|
25
|
+
"Stock",
|
|
26
|
+
"ApiError",
|
|
27
|
+
"verify_webhook_signature",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
DEFAULT_BASE_URL = "https://api.scenarionavigator.io"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ApiError(Exception):
|
|
34
|
+
"""API returned an error. Carries status_code, code, and request_id."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, status_code: int, detail: Any, code: str = "error",
|
|
37
|
+
request_id: Optional[str] = None):
|
|
38
|
+
self.status_code = status_code
|
|
39
|
+
self.detail = detail
|
|
40
|
+
self.code = code
|
|
41
|
+
self.request_id = request_id
|
|
42
|
+
super().__init__(f"[{status_code} {code}] {detail} (request_id={request_id})")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class Stock:
|
|
47
|
+
stock: str
|
|
48
|
+
name: Optional[str] = None
|
|
49
|
+
reason: Optional[str] = None
|
|
50
|
+
confidence: str = "low" # high | medium | low
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class Analysis:
|
|
55
|
+
benefiting: List[Stock] = field(default_factory=list)
|
|
56
|
+
hurting: List[Stock] = field(default_factory=list)
|
|
57
|
+
depth: Optional[str] = None
|
|
58
|
+
preliminary: bool = False
|
|
59
|
+
slug: Optional[str] = None
|
|
60
|
+
scenario_id: Optional[str] = None
|
|
61
|
+
job_id: Optional[str] = None
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def _from_results(cls, results: Dict[str, Any], **extra) -> "Analysis":
|
|
65
|
+
def stocks(items):
|
|
66
|
+
return [
|
|
67
|
+
Stock(
|
|
68
|
+
stock=i.get("stock", ""),
|
|
69
|
+
name=i.get("name"),
|
|
70
|
+
reason=i.get("reason"),
|
|
71
|
+
confidence=i.get("confidence", "low"),
|
|
72
|
+
)
|
|
73
|
+
for i in (items or [])
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
return cls(
|
|
77
|
+
benefiting=stocks(results.get("benefiting")),
|
|
78
|
+
hurting=stocks(results.get("hurting")),
|
|
79
|
+
**extra,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Client:
|
|
84
|
+
"""Synchronous client for the Scenario Navigator public API."""
|
|
85
|
+
|
|
86
|
+
def __init__(self, api_key: str, base_url: str = DEFAULT_BASE_URL,
|
|
87
|
+
timeout: float = 30.0, session: Optional[requests.Session] = None):
|
|
88
|
+
self.api_key = api_key
|
|
89
|
+
self.base_url = base_url.rstrip("/")
|
|
90
|
+
self.timeout = timeout
|
|
91
|
+
self._session = session or requests.Session()
|
|
92
|
+
|
|
93
|
+
# ------------------------------------------------------------------ util
|
|
94
|
+
@classmethod
|
|
95
|
+
def with_trial_token(cls, base_url: str = DEFAULT_BASE_URL) -> "Client":
|
|
96
|
+
"""Mint a free trial key (1 hour, limited runs) and return a Client."""
|
|
97
|
+
resp = requests.post(f"{base_url.rstrip('/')}/api/public/v1/trial-token",
|
|
98
|
+
timeout=15)
|
|
99
|
+
if resp.status_code != 200:
|
|
100
|
+
raise ApiError(resp.status_code, resp.text)
|
|
101
|
+
return cls(resp.json()["token"], base_url=base_url)
|
|
102
|
+
|
|
103
|
+
def _request(self, method: str, path: str, **kwargs) -> Dict[str, Any]:
|
|
104
|
+
headers = kwargs.pop("headers", {})
|
|
105
|
+
headers.setdefault("Authorization", f"Bearer {self.api_key}")
|
|
106
|
+
# allow_redirects=False: the API's duplicate-scenario 303 must reach
|
|
107
|
+
# this handler, not be silently followed as a GET by requests.
|
|
108
|
+
resp = self._session.request(
|
|
109
|
+
method, f"{self.base_url}{path}", headers=headers,
|
|
110
|
+
timeout=self.timeout, allow_redirects=False, **kwargs,
|
|
111
|
+
)
|
|
112
|
+
if resp.status_code == 303:
|
|
113
|
+
# Duplicate scenario — surface the existing slug.
|
|
114
|
+
location = resp.headers.get("Location", "")
|
|
115
|
+
raise ApiError(303, f"Duplicate scenario: {location}", code="duplicate")
|
|
116
|
+
try:
|
|
117
|
+
body = resp.json()
|
|
118
|
+
except ValueError:
|
|
119
|
+
body = {"detail": resp.text}
|
|
120
|
+
if resp.status_code >= 400:
|
|
121
|
+
err = body.get("error", {}) if isinstance(body, dict) else {}
|
|
122
|
+
raise ApiError(
|
|
123
|
+
resp.status_code,
|
|
124
|
+
body.get("detail") if isinstance(body, dict) else body,
|
|
125
|
+
code=err.get("code", "error"),
|
|
126
|
+
request_id=err.get("request_id"),
|
|
127
|
+
)
|
|
128
|
+
return body
|
|
129
|
+
|
|
130
|
+
# ------------------------------------------------------------- analysis
|
|
131
|
+
def analyze(self, text: str, depth: str = "fast", *, wait: bool = True,
|
|
132
|
+
timeout: float = 300.0, poll_interval: float = 3.0,
|
|
133
|
+
visibility: Optional[str] = None,
|
|
134
|
+
idempotency_key: Optional[str] = None) -> Analysis:
|
|
135
|
+
"""Submit a scenario. depth: "instant" (sync, seconds), "fast", "deep".
|
|
136
|
+
|
|
137
|
+
With wait=True (default) the async depths poll until completion and
|
|
138
|
+
return the finished Analysis. With wait=False, returns an Analysis
|
|
139
|
+
carrying only job_id/slug/scenario_id — poll with get_job().
|
|
140
|
+
"""
|
|
141
|
+
payload: Dict[str, Any] = {"text": text, "depth": depth}
|
|
142
|
+
if visibility:
|
|
143
|
+
payload["visibility"] = visibility
|
|
144
|
+
headers = {}
|
|
145
|
+
if idempotency_key:
|
|
146
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
body = self._request("POST", "/api/public/v1/scenarios",
|
|
150
|
+
json=payload, headers=headers)
|
|
151
|
+
except ApiError as exc:
|
|
152
|
+
if exc.code != "duplicate":
|
|
153
|
+
raise
|
|
154
|
+
# Duplicate: the Location points at the existing scenario —
|
|
155
|
+
# return its saved analysis instead of failing.
|
|
156
|
+
slug = str(exc.detail).rstrip("/").rsplit("/", 1)[-1]
|
|
157
|
+
existing = self.get_scenario(slug)
|
|
158
|
+
return Analysis._from_results(
|
|
159
|
+
existing.get("analysis_results") or {},
|
|
160
|
+
depth=depth, slug=slug,
|
|
161
|
+
scenario_id=existing.get("id"),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
if depth == "instant":
|
|
165
|
+
results = (body.get("result") or {}).get("results") or {}
|
|
166
|
+
return Analysis._from_results(
|
|
167
|
+
results, depth="instant",
|
|
168
|
+
preliminary=bool(body.get("preliminary")),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
stub = Analysis(job_id=body.get("job_id"), slug=body.get("slug"),
|
|
172
|
+
scenario_id=body.get("scenario_id"), depth=depth)
|
|
173
|
+
if not wait:
|
|
174
|
+
return stub
|
|
175
|
+
|
|
176
|
+
deadline = time.monotonic() + timeout
|
|
177
|
+
while time.monotonic() < deadline:
|
|
178
|
+
job = self.get_job(stub.job_id)
|
|
179
|
+
status = job.get("status")
|
|
180
|
+
if status == "SUCCESS":
|
|
181
|
+
results = (job.get("result") or {}).get("results") or {}
|
|
182
|
+
return Analysis._from_results(
|
|
183
|
+
results, depth=depth, job_id=stub.job_id,
|
|
184
|
+
slug=stub.slug, scenario_id=stub.scenario_id,
|
|
185
|
+
)
|
|
186
|
+
if status == "FAILURE":
|
|
187
|
+
raise ApiError(500, (job.get("result") or {}).get(
|
|
188
|
+
"error", "Analysis failed"), code="analysis_failed")
|
|
189
|
+
time.sleep(poll_interval)
|
|
190
|
+
raise ApiError(408, f"Timed out after {timeout}s waiting for job "
|
|
191
|
+
f"{stub.job_id}", code="timeout")
|
|
192
|
+
|
|
193
|
+
def get_job(self, job_id: str) -> Dict[str, Any]:
|
|
194
|
+
return self._request("GET", f"/api/public/v1/job/{job_id}")
|
|
195
|
+
|
|
196
|
+
def get_scenario(self, slug: str) -> Dict[str, Any]:
|
|
197
|
+
return self._request("GET", f"/api/public/v1/scenarios/{slug}")
|
|
198
|
+
|
|
199
|
+
# ------------------------------------------------------------------ feed
|
|
200
|
+
def feed(self, *, limit: int = 25, cursor: Optional[str] = None,
|
|
201
|
+
include_analysis: bool = False) -> Dict[str, Any]:
|
|
202
|
+
"""Pre-analyzed trending scenarios — the <100ms read path."""
|
|
203
|
+
params: Dict[str, Any] = {"limit": limit}
|
|
204
|
+
if cursor:
|
|
205
|
+
params["cursor"] = cursor
|
|
206
|
+
if include_analysis:
|
|
207
|
+
params["include"] = "analysis"
|
|
208
|
+
return self._request("GET", "/api/public/v1/feed/scenarios",
|
|
209
|
+
params=params)
|
|
210
|
+
|
|
211
|
+
# ----------------------------------------------------------------- account
|
|
212
|
+
def usage(self) -> Dict[str, Any]:
|
|
213
|
+
return self._request("GET", "/api/public/v1/usage")
|
|
214
|
+
|
|
215
|
+
def rotate_key(self) -> Dict[str, Any]:
|
|
216
|
+
"""Mint a replacement key (old key lives 24h). Update self.api_key."""
|
|
217
|
+
body = self._request("POST", "/api/public/v1/keys/rotate")
|
|
218
|
+
self.api_key = body["token"]
|
|
219
|
+
return body
|
|
220
|
+
|
|
221
|
+
# ---------------------------------------------------------------- webhooks
|
|
222
|
+
def create_webhook(self, url: str) -> Dict[str, Any]:
|
|
223
|
+
return self._request("POST", "/api/public/v1/webhooks",
|
|
224
|
+
json={"url": url})
|
|
225
|
+
|
|
226
|
+
def list_webhooks(self) -> Dict[str, Any]:
|
|
227
|
+
return self._request("GET", "/api/public/v1/webhooks")
|
|
228
|
+
|
|
229
|
+
def delete_webhook(self, webhook_id: str) -> Dict[str, Any]:
|
|
230
|
+
return self._request("DELETE",
|
|
231
|
+
f"/api/public/v1/webhooks/{webhook_id}")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def verify_webhook_signature(body: bytes, signature_header: str, secret: str,
|
|
235
|
+
tolerance_seconds: int = 300) -> bool:
|
|
236
|
+
"""Verify X-SN-Signature ("t=<unix>,v1=<hex>") for a webhook delivery.
|
|
237
|
+
|
|
238
|
+
if not verify_webhook_signature(request.body, sig_header, secret):
|
|
239
|
+
return 400
|
|
240
|
+
"""
|
|
241
|
+
try:
|
|
242
|
+
parts = dict(p.split("=", 1) for p in signature_header.split(","))
|
|
243
|
+
ts = int(parts["t"])
|
|
244
|
+
provided = parts["v1"]
|
|
245
|
+
except (KeyError, ValueError, AttributeError):
|
|
246
|
+
return False
|
|
247
|
+
if abs(time.time() - ts) > tolerance_seconds:
|
|
248
|
+
return False
|
|
249
|
+
if isinstance(body, str):
|
|
250
|
+
body = body.encode()
|
|
251
|
+
expected = hmac.new(secret.encode(), b"%d." % ts + body,
|
|
252
|
+
hashlib.sha256).hexdigest()
|
|
253
|
+
return hmac.compare_digest(expected, provided)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""MCP server for Scenario Navigator — agent-native access to the API.
|
|
2
|
+
|
|
3
|
+
Run (requires `pip install "scenario-navigator[mcp]"`):
|
|
4
|
+
|
|
5
|
+
SCEN_NAV_TOKEN=sn_live_... python -m scenario_navigator_sdk.mcp_server
|
|
6
|
+
|
|
7
|
+
Or in an MCP client config:
|
|
8
|
+
|
|
9
|
+
{"command": "python", "args": ["-m", "scenario_navigator_sdk.mcp_server"],
|
|
10
|
+
"env": {"SCEN_NAV_TOKEN": "sn_live_..."}}
|
|
11
|
+
|
|
12
|
+
Without SCEN_NAV_TOKEN a free trial key is minted automatically.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
|
|
18
|
+
from . import Client
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
from mcp.server.fastmcp import FastMCP
|
|
22
|
+
except ImportError as exc: # pragma: no cover
|
|
23
|
+
raise SystemExit(
|
|
24
|
+
'MCP extra not installed — pip install "scenario-navigator[mcp]"'
|
|
25
|
+
) from exc
|
|
26
|
+
|
|
27
|
+
mcp = FastMCP("scenario-navigator")
|
|
28
|
+
|
|
29
|
+
_client: Client | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _get_client() -> Client:
|
|
33
|
+
global _client
|
|
34
|
+
if _client is None:
|
|
35
|
+
token = os.environ.get("SCEN_NAV_TOKEN")
|
|
36
|
+
base = os.environ.get("SCEN_NAV_BASE_URL", "https://api.scenarionavigator.io")
|
|
37
|
+
_client = (
|
|
38
|
+
Client(token, base_url=base) if token else Client.with_trial_token(base)
|
|
39
|
+
)
|
|
40
|
+
return _client
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@mcp.tool()
|
|
44
|
+
def analyze_scenario(text: str, depth: str = "fast") -> dict:
|
|
45
|
+
"""Analyze a news scenario: returns ranked benefiting/hurting US stocks
|
|
46
|
+
with confidence labels (high/medium/low). depth: "instant" (seconds,
|
|
47
|
+
preliminary), "fast" (~1 min), or "deep" (2-3 min, deepest research)."""
|
|
48
|
+
analysis = _get_client().analyze(text, depth=depth)
|
|
49
|
+
return {
|
|
50
|
+
"depth": analysis.depth,
|
|
51
|
+
"preliminary": analysis.preliminary,
|
|
52
|
+
"benefiting": [vars(s) for s in analysis.benefiting],
|
|
53
|
+
"hurting": [vars(s) for s in analysis.hurting],
|
|
54
|
+
"slug": analysis.slug,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@mcp.tool()
|
|
59
|
+
def trending_scenarios(limit: int = 10, include_analysis: bool = True) -> dict:
|
|
60
|
+
"""Latest pre-analyzed trending market scenarios (instant, no submission
|
|
61
|
+
needed). Each item can include its ranked winners/losers inline."""
|
|
62
|
+
return _get_client().feed(limit=limit, include_analysis=include_analysis)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
if __name__ == "__main__":
|
|
66
|
+
mcp.run()
|