bca-mcp 0.2.3__py3-none-any.whl
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.
- bca_mcp/__init__.py +30 -0
- bca_mcp/__main__.py +23 -0
- bca_mcp/cli/__init__.py +11 -0
- bca_mcp/cli/config.py +61 -0
- bca_mcp/cli/main.py +411 -0
- bca_mcp/cli/render.py +117 -0
- bca_mcp/cli/runner.py +52 -0
- bca_mcp/client.py +234 -0
- bca_mcp/errors.py +75 -0
- bca_mcp/server.py +424 -0
- bca_mcp/tools/__init__.py +44 -0
- bca_mcp/tools/agent_jobs.py +357 -0
- bca_mcp/tools/content.py +168 -0
- bca_mcp/tools/extended.py +1237 -0
- bca_mcp/tools/fundamentals.py +218 -0
- bca_mcp/tools/get_entity.py +62 -0
- bca_mcp/tools/get_explainer.py +59 -0
- bca_mcp/tools/indicators.py +234 -0
- bca_mcp/tools/market.py +171 -0
- bca_mcp/tools/onchain.py +169 -0
- bca_mcp/tools/search_news.py +86 -0
- bca_mcp/tools/sentiment.py +192 -0
- bca_mcp/types.py +138 -0
- bca_mcp-0.2.3.dist-info/METADATA +236 -0
- bca_mcp-0.2.3.dist-info/RECORD +28 -0
- bca_mcp-0.2.3.dist-info/WHEEL +4 -0
- bca_mcp-0.2.3.dist-info/entry_points.txt +3 -0
- bca_mcp-0.2.3.dist-info/licenses/LICENSE +21 -0
bca_mcp/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Blockchain Academics MCP server (Python).
|
|
2
|
+
|
|
3
|
+
Sibling of `@blockchainacademics/mcp` — same tool surface, same REST API,
|
|
4
|
+
same attribution contract.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from bca_mcp.client import BcaClient, get_client, set_client
|
|
8
|
+
from bca_mcp.errors import (
|
|
9
|
+
BcaAuthError,
|
|
10
|
+
BcaBadRequestError,
|
|
11
|
+
BcaError,
|
|
12
|
+
BcaNetworkError,
|
|
13
|
+
BcaRateLimitError,
|
|
14
|
+
BcaUpstreamError,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__version__ = "0.2.3"
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"__version__",
|
|
21
|
+
"BcaClient",
|
|
22
|
+
"get_client",
|
|
23
|
+
"set_client",
|
|
24
|
+
"BcaError",
|
|
25
|
+
"BcaAuthError",
|
|
26
|
+
"BcaBadRequestError",
|
|
27
|
+
"BcaNetworkError",
|
|
28
|
+
"BcaRateLimitError",
|
|
29
|
+
"BcaUpstreamError",
|
|
30
|
+
]
|
bca_mcp/__main__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Entry point — `python -m bca_mcp` or `bca-mcp` runs the stdio server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from bca_mcp.server import run_stdio
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main() -> None:
|
|
12
|
+
try:
|
|
13
|
+
asyncio.run(run_stdio())
|
|
14
|
+
except KeyboardInterrupt:
|
|
15
|
+
sys.exit(0)
|
|
16
|
+
except Exception as err: # noqa: BLE001
|
|
17
|
+
# Fatal startup → stderr so the host sees it without corrupting stdio.
|
|
18
|
+
sys.stderr.write(f"[bca-mcp] fatal: {err!r}\n")
|
|
19
|
+
sys.exit(1)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
if __name__ == "__main__":
|
|
23
|
+
main()
|
bca_mcp/cli/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""`bca` command-line interface — bundled with the bca-mcp package.
|
|
2
|
+
|
|
3
|
+
Human-facing counterpart to the MCP server. Talks to the same REST API
|
|
4
|
+
(`api.blockchainacademics.com`) via the same `BcaClient`. Distribution:
|
|
5
|
+
`pipx install bca-mcp` OR `pip install bca-mcp` exposes the `bca` binary
|
|
6
|
+
alongside the `bca-mcp` MCP server entry point.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from bca_mcp.cli.main import app
|
|
10
|
+
|
|
11
|
+
__all__ = ["app"]
|
bca_mcp/cli/config.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""CLI config file at `~/.bca/config.toml` — stores API key + base URL.
|
|
2
|
+
|
|
3
|
+
Env vars (`BCA_API_KEY`, `BCA_API_BASE`) take precedence over stored values.
|
|
4
|
+
Config is chmod 600 — plaintext key on disk, user-scoped.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
if sys.version_info >= (3, 11):
|
|
15
|
+
import tomllib
|
|
16
|
+
else: # pragma: no cover
|
|
17
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
18
|
+
|
|
19
|
+
import tomli_w
|
|
20
|
+
|
|
21
|
+
CONFIG_DIR = Path.home() / ".bca"
|
|
22
|
+
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def read_config() -> dict[str, Any]:
|
|
26
|
+
if not CONFIG_FILE.exists():
|
|
27
|
+
return {}
|
|
28
|
+
try:
|
|
29
|
+
return tomllib.loads(CONFIG_FILE.read_text(encoding="utf-8"))
|
|
30
|
+
except (tomllib.TOMLDecodeError, OSError):
|
|
31
|
+
return {}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def write_config(data: dict[str, Any]) -> None:
|
|
35
|
+
CONFIG_DIR.mkdir(mode=0o700, exist_ok=True)
|
|
36
|
+
CONFIG_FILE.write_bytes(tomli_w.dumps(data).encode("utf-8"))
|
|
37
|
+
try:
|
|
38
|
+
CONFIG_FILE.chmod(0o600)
|
|
39
|
+
except OSError: # pragma: no cover — non-POSIX fallback
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def apply_env_defaults() -> None:
|
|
44
|
+
"""Load config into env vars unless env already set.
|
|
45
|
+
|
|
46
|
+
Called at the top of every CLI command so `BcaClient()` (which reads
|
|
47
|
+
env) picks up stored values transparently. Env wins — no clobbering.
|
|
48
|
+
"""
|
|
49
|
+
cfg = read_config()
|
|
50
|
+
if not os.environ.get("BCA_API_KEY") and cfg.get("api_key"):
|
|
51
|
+
os.environ["BCA_API_KEY"] = str(cfg["api_key"])
|
|
52
|
+
if not os.environ.get("BCA_API_BASE") and cfg.get("api_base"):
|
|
53
|
+
os.environ["BCA_API_BASE"] = str(cfg["api_base"])
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def mask_key(key: str | None) -> str:
|
|
57
|
+
if not key:
|
|
58
|
+
return "(not set)"
|
|
59
|
+
if len(key) < 12:
|
|
60
|
+
return "***"
|
|
61
|
+
return f"{key[:8]}…{key[-4:]}"
|
bca_mcp/cli/main.py
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
"""`bca` CLI — human-facing counterpart to the MCP server.
|
|
2
|
+
|
|
3
|
+
Each subcommand reuses `bca_mcp.client.BcaClient` + existing tool wrappers,
|
|
4
|
+
so the CLI stays in lockstep with MCP tool behavior and envelope parsing.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any, Optional
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from bca_mcp.cli.config import (
|
|
16
|
+
CONFIG_FILE,
|
|
17
|
+
apply_env_defaults,
|
|
18
|
+
mask_key,
|
|
19
|
+
read_config,
|
|
20
|
+
write_config,
|
|
21
|
+
)
|
|
22
|
+
from bca_mcp.cli.render import (
|
|
23
|
+
as_json,
|
|
24
|
+
cite_footer,
|
|
25
|
+
console,
|
|
26
|
+
entity_panel,
|
|
27
|
+
explainer_render,
|
|
28
|
+
indicator_panel,
|
|
29
|
+
markdown_render,
|
|
30
|
+
news_table,
|
|
31
|
+
price_table,
|
|
32
|
+
unwrap,
|
|
33
|
+
)
|
|
34
|
+
from bca_mcp.cli.runner import err_console, handle_errors, run_async
|
|
35
|
+
|
|
36
|
+
app = typer.Typer(
|
|
37
|
+
name="bca",
|
|
38
|
+
help="Blockchain Academics CLI — crypto intelligence for terminals + scripts.",
|
|
39
|
+
no_args_is_help=True,
|
|
40
|
+
add_completion=False,
|
|
41
|
+
)
|
|
42
|
+
config_app = typer.Typer(help="Config management.")
|
|
43
|
+
news_app = typer.Typer(help="News search + retrieval.")
|
|
44
|
+
market_app = typer.Typer(help="Market data (prices, overview).")
|
|
45
|
+
app.add_typer(config_app, name="config")
|
|
46
|
+
app.add_typer(news_app, name="news")
|
|
47
|
+
app.add_typer(market_app, name="market")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ---------- core commands ----------
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@app.command()
|
|
54
|
+
def login(
|
|
55
|
+
api_key: Optional[str] = typer.Option(
|
|
56
|
+
None,
|
|
57
|
+
"--api-key",
|
|
58
|
+
"-k",
|
|
59
|
+
help="API key; if omitted, prompted interactively.",
|
|
60
|
+
),
|
|
61
|
+
api_base: Optional[str] = typer.Option(
|
|
62
|
+
None,
|
|
63
|
+
"--api-base",
|
|
64
|
+
help="Override API base URL (default https://api.blockchainacademics.com).",
|
|
65
|
+
),
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Store your BCA API key in ~/.bca/config.toml (chmod 600)."""
|
|
68
|
+
import os
|
|
69
|
+
|
|
70
|
+
cfg = read_config()
|
|
71
|
+
if api_key is None:
|
|
72
|
+
api_key = typer.prompt("BCA API key", hide_input=True)
|
|
73
|
+
if not api_key.strip():
|
|
74
|
+
err_console.print("[red]Empty key; aborting.[/red]")
|
|
75
|
+
raise typer.Exit(code=1)
|
|
76
|
+
cfg["api_key"] = api_key.strip()
|
|
77
|
+
if api_base:
|
|
78
|
+
cleaned = api_base.strip().rstrip("/")
|
|
79
|
+
# HIGH: refuse to persist a non-HTTPS base URL unless the operator
|
|
80
|
+
# has explicitly opted in via BCA_ALLOW_INSECURE_BASE=1. Mirrors the
|
|
81
|
+
# same gate inside BcaClient so CLI config and runtime agree.
|
|
82
|
+
if (
|
|
83
|
+
not cleaned.startswith("https://")
|
|
84
|
+
and os.environ.get("BCA_ALLOW_INSECURE_BASE") != "1"
|
|
85
|
+
):
|
|
86
|
+
err_console.print(
|
|
87
|
+
f"[red]Refusing to save non-HTTPS api_base='{cleaned}'.[/red] "
|
|
88
|
+
"Set BCA_ALLOW_INSECURE_BASE=1 to override for local dev."
|
|
89
|
+
)
|
|
90
|
+
raise typer.Exit(code=1)
|
|
91
|
+
cfg["api_base"] = cleaned
|
|
92
|
+
write_config(cfg)
|
|
93
|
+
console.print(f"[green]Saved[/green] to [bold]{CONFIG_FILE}[/bold]")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@config_app.command("show")
|
|
97
|
+
def config_show() -> None:
|
|
98
|
+
"""Show config location and masked key."""
|
|
99
|
+
cfg = read_config()
|
|
100
|
+
console.print(f"[bold]Config file:[/bold] {CONFIG_FILE}")
|
|
101
|
+
console.print(f"[bold]API key:[/bold] {mask_key(cfg.get('api_key'))}")
|
|
102
|
+
console.print(
|
|
103
|
+
f"[bold]API base:[/bold] "
|
|
104
|
+
f"{cfg.get('api_base') or 'https://api.blockchainacademics.com (default)'}"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@app.command()
|
|
109
|
+
@handle_errors
|
|
110
|
+
@run_async
|
|
111
|
+
async def version() -> None:
|
|
112
|
+
"""Show CLI version + live API version."""
|
|
113
|
+
from bca_mcp import __version__
|
|
114
|
+
|
|
115
|
+
console.print(f"[bold]bca-mcp[/bold] {__version__}")
|
|
116
|
+
apply_env_defaults()
|
|
117
|
+
from bca_mcp.client import BcaClient
|
|
118
|
+
|
|
119
|
+
client = BcaClient()
|
|
120
|
+
try:
|
|
121
|
+
res = await client.request("/v1/status")
|
|
122
|
+
data = unwrap(res)
|
|
123
|
+
if isinstance(data, dict):
|
|
124
|
+
api_ver = data.get("version") or data.get("commit") or "live"
|
|
125
|
+
console.print(f"[bold]api:[/bold] {api_ver}")
|
|
126
|
+
except Exception as exc: # noqa: BLE001
|
|
127
|
+
console.print(f"[yellow]API unreachable:[/yellow] {exc}")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ---------- news ----------
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@news_app.command("search")
|
|
134
|
+
@handle_errors
|
|
135
|
+
@run_async
|
|
136
|
+
async def news_search(
|
|
137
|
+
query: str = typer.Argument(..., help="Search query (1-512 chars)."),
|
|
138
|
+
limit: int = typer.Option(10, "--limit", "-n", min=1, max=50),
|
|
139
|
+
entity: Optional[str] = typer.Option(None, "--entity"),
|
|
140
|
+
topic: Optional[str] = typer.Option(None, "--topic"),
|
|
141
|
+
since: Optional[str] = typer.Option(None, "--since", help="ISO 8601."),
|
|
142
|
+
json_out: bool = typer.Option(False, "--json"),
|
|
143
|
+
) -> None:
|
|
144
|
+
"""Full-text search across the BCA corpus."""
|
|
145
|
+
apply_env_defaults()
|
|
146
|
+
from bca_mcp.tools import search_news
|
|
147
|
+
|
|
148
|
+
args = {"query": query, "limit": limit}
|
|
149
|
+
if entity:
|
|
150
|
+
args["entity"] = entity
|
|
151
|
+
if topic:
|
|
152
|
+
args["topic"] = topic
|
|
153
|
+
if since:
|
|
154
|
+
args["since"] = since
|
|
155
|
+
res = await search_news.run(args)
|
|
156
|
+
if json_out:
|
|
157
|
+
as_json(res)
|
|
158
|
+
return
|
|
159
|
+
data = unwrap(res)
|
|
160
|
+
articles = (
|
|
161
|
+
data.get("articles") if isinstance(data, dict) else data
|
|
162
|
+
) or (data if isinstance(data, list) else [])
|
|
163
|
+
if not articles:
|
|
164
|
+
console.print("[yellow]No results.[/yellow]")
|
|
165
|
+
return
|
|
166
|
+
news_table(articles)
|
|
167
|
+
cite_footer(res)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ---------- entity ----------
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@app.command()
|
|
174
|
+
@handle_errors
|
|
175
|
+
@run_async
|
|
176
|
+
async def entity(
|
|
177
|
+
slug: str = typer.Argument(..., help="Entity slug (e.g. 'ethereum')."),
|
|
178
|
+
json_out: bool = typer.Option(False, "--json"),
|
|
179
|
+
) -> None:
|
|
180
|
+
"""Entity dossier: chain, project, person, or ticker."""
|
|
181
|
+
apply_env_defaults()
|
|
182
|
+
from bca_mcp.tools import get_entity
|
|
183
|
+
|
|
184
|
+
res = await get_entity.run({"slug": slug})
|
|
185
|
+
if json_out:
|
|
186
|
+
as_json(res)
|
|
187
|
+
return
|
|
188
|
+
data = unwrap(res)
|
|
189
|
+
if isinstance(data, dict):
|
|
190
|
+
entity_panel(data)
|
|
191
|
+
else:
|
|
192
|
+
console.print(str(data))
|
|
193
|
+
cite_footer(res)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ---------- market ----------
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@app.command("price")
|
|
200
|
+
@handle_errors
|
|
201
|
+
@run_async
|
|
202
|
+
async def price(
|
|
203
|
+
tickers: str = typer.Argument(..., help="Comma-separated tickers, e.g. BTC,ETH,SOL"),
|
|
204
|
+
json_out: bool = typer.Option(False, "--json"),
|
|
205
|
+
) -> None:
|
|
206
|
+
"""Spot price + 24h change for one or more tickers."""
|
|
207
|
+
apply_env_defaults()
|
|
208
|
+
from bca_mcp.tools import market
|
|
209
|
+
|
|
210
|
+
ticker_list = [t.strip().upper() for t in tickers.split(",") if t.strip()]
|
|
211
|
+
if not ticker_list:
|
|
212
|
+
err_console.print("[red]No tickers provided.[/red]")
|
|
213
|
+
raise typer.Exit(code=1)
|
|
214
|
+
rows: list[dict[str, Any]] = []
|
|
215
|
+
last_envelope: dict[str, Any] = {}
|
|
216
|
+
for t in ticker_list:
|
|
217
|
+
try:
|
|
218
|
+
res = await market.run_get_price({"ticker": t})
|
|
219
|
+
except AttributeError:
|
|
220
|
+
# Fallback if tool module shape differs.
|
|
221
|
+
from bca_mcp.client import get_client
|
|
222
|
+
|
|
223
|
+
res = await get_client().request("/v1/prices", {"ticker": t})
|
|
224
|
+
last_envelope = res if isinstance(res, dict) else {}
|
|
225
|
+
data = unwrap(res)
|
|
226
|
+
if isinstance(data, dict):
|
|
227
|
+
data.setdefault("ticker", t)
|
|
228
|
+
rows.append(data)
|
|
229
|
+
elif isinstance(data, list):
|
|
230
|
+
rows.extend(d for d in data if isinstance(d, dict))
|
|
231
|
+
if json_out:
|
|
232
|
+
as_json(rows)
|
|
233
|
+
return
|
|
234
|
+
if not rows:
|
|
235
|
+
console.print("[yellow]No price data.[/yellow]")
|
|
236
|
+
return
|
|
237
|
+
price_table(rows)
|
|
238
|
+
cite_footer(last_envelope)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@market_app.command("overview")
|
|
242
|
+
@handle_errors
|
|
243
|
+
@run_async
|
|
244
|
+
async def market_overview(
|
|
245
|
+
limit: int = typer.Option(10, "--limit", "-n", min=1, max=50),
|
|
246
|
+
json_out: bool = typer.Option(False, "--json"),
|
|
247
|
+
) -> None:
|
|
248
|
+
"""Top-N market overview by market cap."""
|
|
249
|
+
apply_env_defaults()
|
|
250
|
+
from bca_mcp.client import get_client
|
|
251
|
+
|
|
252
|
+
res = await get_client().request("/v1/market/overview", {"limit": limit})
|
|
253
|
+
if json_out:
|
|
254
|
+
as_json(res)
|
|
255
|
+
return
|
|
256
|
+
data = unwrap(res)
|
|
257
|
+
rows = (
|
|
258
|
+
data.get("tokens") if isinstance(data, dict) else data
|
|
259
|
+
) or (data if isinstance(data, list) else [])
|
|
260
|
+
if not rows:
|
|
261
|
+
console.print("[yellow]No data.[/yellow]")
|
|
262
|
+
return
|
|
263
|
+
price_table(rows)
|
|
264
|
+
cite_footer(res)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# ---------- explainer ----------
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@app.command()
|
|
271
|
+
@handle_errors
|
|
272
|
+
@run_async
|
|
273
|
+
async def explainer(
|
|
274
|
+
slug: str = typer.Argument(..., help="Academy lesson slug."),
|
|
275
|
+
json_out: bool = typer.Option(False, "--json"),
|
|
276
|
+
) -> None:
|
|
277
|
+
"""Render an academy lesson as markdown."""
|
|
278
|
+
apply_env_defaults()
|
|
279
|
+
from bca_mcp.tools import get_explainer
|
|
280
|
+
|
|
281
|
+
res = await get_explainer.run({"slug": slug})
|
|
282
|
+
if json_out:
|
|
283
|
+
as_json(res)
|
|
284
|
+
return
|
|
285
|
+
data = unwrap(res)
|
|
286
|
+
if isinstance(data, dict):
|
|
287
|
+
explainer_render(data)
|
|
288
|
+
else:
|
|
289
|
+
console.print(str(data))
|
|
290
|
+
cite_footer(res)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# ---------- indicators ----------
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@app.command()
|
|
297
|
+
@handle_errors
|
|
298
|
+
@run_async
|
|
299
|
+
async def indicator(
|
|
300
|
+
name: str = typer.Argument(..., help="Indicator slug, e.g. coverage-index."),
|
|
301
|
+
entity: str = typer.Argument(..., help="Entity slug."),
|
|
302
|
+
window: str = typer.Option("30d", "--window", "-w", help="7d | 30d | 90d"),
|
|
303
|
+
json_out: bool = typer.Option(False, "--json"),
|
|
304
|
+
) -> None:
|
|
305
|
+
"""Read a proprietary indicator value."""
|
|
306
|
+
apply_env_defaults()
|
|
307
|
+
from bca_mcp.client import get_client
|
|
308
|
+
|
|
309
|
+
res = await get_client().request(
|
|
310
|
+
f"/v1/indicators/{name}",
|
|
311
|
+
{"entity": entity, "window": window},
|
|
312
|
+
)
|
|
313
|
+
if json_out:
|
|
314
|
+
as_json(res)
|
|
315
|
+
return
|
|
316
|
+
data = unwrap(res)
|
|
317
|
+
if isinstance(data, dict):
|
|
318
|
+
indicator_panel(name, entity, data)
|
|
319
|
+
else:
|
|
320
|
+
console.print(str(data))
|
|
321
|
+
cite_footer(res)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
# ---------- agent ----------
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@app.command()
|
|
328
|
+
@handle_errors
|
|
329
|
+
@run_async
|
|
330
|
+
async def agent(
|
|
331
|
+
skill: str = typer.Argument(
|
|
332
|
+
...,
|
|
333
|
+
help="Skill slug: due-diligence, tokenomics-model, summarize-whitepaper, "
|
|
334
|
+
"translate-contract, monitor-keyword.",
|
|
335
|
+
),
|
|
336
|
+
entity: Optional[str] = typer.Option(None, "--entity"),
|
|
337
|
+
url: Optional[str] = typer.Option(None, "--url"),
|
|
338
|
+
source: Optional[str] = typer.Option(None, "--source"),
|
|
339
|
+
target: Optional[str] = typer.Option(None, "--target"),
|
|
340
|
+
keyword: Optional[str] = typer.Option(None, "--keyword"),
|
|
341
|
+
timeout_s: int = typer.Option(120, "--timeout", help="Polling timeout seconds."),
|
|
342
|
+
json_out: bool = typer.Option(False, "--json"),
|
|
343
|
+
) -> None:
|
|
344
|
+
"""Kick off an async agent-backed skill job and poll until done."""
|
|
345
|
+
apply_env_defaults()
|
|
346
|
+
from bca_mcp.client import get_client
|
|
347
|
+
|
|
348
|
+
client = get_client()
|
|
349
|
+
payload: dict[str, Any] = {}
|
|
350
|
+
for k, v in (
|
|
351
|
+
("entity", entity),
|
|
352
|
+
("url", url),
|
|
353
|
+
("source_language", source),
|
|
354
|
+
("target_language", target),
|
|
355
|
+
("keyword", keyword),
|
|
356
|
+
):
|
|
357
|
+
if v is not None:
|
|
358
|
+
payload[k] = v
|
|
359
|
+
|
|
360
|
+
res = await client.post(f"/v1/agent-jobs/{skill}/run", payload)
|
|
361
|
+
data = unwrap(res)
|
|
362
|
+
job_id = (data or {}).get("job_id") if isinstance(data, dict) else None
|
|
363
|
+
if not job_id:
|
|
364
|
+
err_console.print(
|
|
365
|
+
"[red]No job_id returned.[/red] Raw response:"
|
|
366
|
+
)
|
|
367
|
+
as_json(res)
|
|
368
|
+
raise typer.Exit(code=1)
|
|
369
|
+
|
|
370
|
+
console.print(f"[cyan]Job[/cyan] {job_id} [dim]submitted — polling…[/dim]")
|
|
371
|
+
start = time.monotonic()
|
|
372
|
+
with console.status("[dim]Running skill…[/dim]", spinner="dots"):
|
|
373
|
+
while time.monotonic() - start < timeout_s:
|
|
374
|
+
poll = await client.request(f"/v1/agent-jobs/{job_id}")
|
|
375
|
+
poll_data = unwrap(poll) or {}
|
|
376
|
+
status = poll_data.get("status") if isinstance(poll_data, dict) else None
|
|
377
|
+
if status in ("completed", "success", "done"):
|
|
378
|
+
if json_out:
|
|
379
|
+
as_json(poll)
|
|
380
|
+
return
|
|
381
|
+
output = poll_data.get("output") if isinstance(poll_data, dict) else None
|
|
382
|
+
if isinstance(output, dict):
|
|
383
|
+
# Try common shapes: {markdown, summary, body}
|
|
384
|
+
body = (
|
|
385
|
+
output.get("markdown")
|
|
386
|
+
or output.get("summary")
|
|
387
|
+
or output.get("body")
|
|
388
|
+
)
|
|
389
|
+
if body:
|
|
390
|
+
markdown_render(str(body))
|
|
391
|
+
else:
|
|
392
|
+
as_json(output)
|
|
393
|
+
elif isinstance(output, str):
|
|
394
|
+
markdown_render(output)
|
|
395
|
+
else:
|
|
396
|
+
as_json(poll)
|
|
397
|
+
cite_footer(poll)
|
|
398
|
+
return
|
|
399
|
+
if status in ("failed", "error"):
|
|
400
|
+
err_console.print(
|
|
401
|
+
f"[red]Job {job_id} failed:[/red] "
|
|
402
|
+
f"{poll_data.get('error') if isinstance(poll_data, dict) else status}"
|
|
403
|
+
)
|
|
404
|
+
raise typer.Exit(code=1)
|
|
405
|
+
await asyncio.sleep(2)
|
|
406
|
+
err_console.print(f"[yellow]Timeout after {timeout_s}s.[/yellow] Job {job_id} still running.")
|
|
407
|
+
raise typer.Exit(code=124)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
if __name__ == "__main__": # pragma: no cover
|
|
411
|
+
app()
|
bca_mcp/cli/render.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Rich rendering helpers — tables, panels, markdown for CLI output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any, Iterable
|
|
7
|
+
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.markdown import Markdown
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def as_json(payload: Any) -> None:
|
|
17
|
+
"""Dump response verbatim (including envelope)."""
|
|
18
|
+
console.print_json(json.dumps(payload, default=str))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def unwrap(envelope: dict[str, Any]) -> Any:
|
|
22
|
+
"""Return envelope['data'] for rendering, falling back to envelope itself."""
|
|
23
|
+
if isinstance(envelope, dict) and "data" in envelope:
|
|
24
|
+
return envelope["data"]
|
|
25
|
+
return envelope
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def cite_footer(envelope: dict[str, Any]) -> None:
|
|
29
|
+
cite = envelope.get("cite_url") if isinstance(envelope, dict) else None
|
|
30
|
+
as_of = envelope.get("as_of") if isinstance(envelope, dict) else None
|
|
31
|
+
if cite or as_of:
|
|
32
|
+
bits: list[str] = []
|
|
33
|
+
if cite:
|
|
34
|
+
bits.append(f"[link={cite}]{cite}[/link]")
|
|
35
|
+
if as_of:
|
|
36
|
+
bits.append(f"as_of={as_of}")
|
|
37
|
+
console.print(f"[dim]{' · '.join(bits)}[/dim]")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def news_table(rows: Iterable[dict[str, Any]]) -> None:
|
|
41
|
+
table = Table(show_header=True, header_style="bold cyan")
|
|
42
|
+
table.add_column("Published")
|
|
43
|
+
table.add_column("Title")
|
|
44
|
+
table.add_column("Source")
|
|
45
|
+
table.add_column("Cite")
|
|
46
|
+
for row in rows:
|
|
47
|
+
table.add_row(
|
|
48
|
+
str(row.get("published_at") or "—")[:10],
|
|
49
|
+
str(row.get("title") or "—"),
|
|
50
|
+
str(row.get("source") or row.get("source_slug") or "—"),
|
|
51
|
+
str(row.get("cite_url") or row.get("url") or "—"),
|
|
52
|
+
)
|
|
53
|
+
console.print(table)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def price_table(rows: Iterable[dict[str, Any]]) -> None:
|
|
57
|
+
table = Table(show_header=True, header_style="bold cyan")
|
|
58
|
+
table.add_column("Ticker")
|
|
59
|
+
table.add_column("Price (USD)", justify="right")
|
|
60
|
+
table.add_column("24h %", justify="right")
|
|
61
|
+
table.add_column("Market cap", justify="right")
|
|
62
|
+
for row in rows:
|
|
63
|
+
ch = row.get("change_24h_percent") or row.get("change_24h")
|
|
64
|
+
ch_str = f"{ch:+.2f}%" if isinstance(ch, (int, float)) else "—"
|
|
65
|
+
color = "green" if isinstance(ch, (int, float)) and ch >= 0 else "red"
|
|
66
|
+
mc = row.get("market_cap_usd") or row.get("market_cap")
|
|
67
|
+
table.add_row(
|
|
68
|
+
str(row.get("ticker") or row.get("symbol") or "—").upper(),
|
|
69
|
+
f"${row.get('price_usd') or row.get('price') or '—'}",
|
|
70
|
+
f"[{color}]{ch_str}[/{color}]",
|
|
71
|
+
f"${mc:,.0f}" if isinstance(mc, (int, float)) else "—",
|
|
72
|
+
)
|
|
73
|
+
console.print(table)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def entity_panel(data: dict[str, Any]) -> None:
|
|
77
|
+
title = data.get("name") or data.get("slug") or "entity"
|
|
78
|
+
kind = data.get("kind") or "—"
|
|
79
|
+
body_lines: list[str] = [f"[bold]kind:[/bold] {kind}"]
|
|
80
|
+
for key in ("ticker", "chain", "website", "twitter", "github"):
|
|
81
|
+
val = data.get(key)
|
|
82
|
+
if val:
|
|
83
|
+
body_lines.append(f"[bold]{key}:[/bold] {val}")
|
|
84
|
+
if data.get("summary"):
|
|
85
|
+
body_lines.append("")
|
|
86
|
+
body_lines.append(str(data["summary"]))
|
|
87
|
+
console.print(
|
|
88
|
+
Panel.fit(
|
|
89
|
+
"\n".join(body_lines),
|
|
90
|
+
title=f"[bold cyan]{title}[/bold cyan]",
|
|
91
|
+
border_style="cyan",
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def indicator_panel(name: str, entity: str, data: dict[str, Any]) -> None:
|
|
97
|
+
value = data.get("value") if isinstance(data, dict) else data
|
|
98
|
+
window = data.get("window") if isinstance(data, dict) else "—"
|
|
99
|
+
console.print(
|
|
100
|
+
Panel.fit(
|
|
101
|
+
f"[bold]value:[/bold] {value}\n"
|
|
102
|
+
f"[bold]window:[/bold] {window}",
|
|
103
|
+
title=f"[bold magenta]{name}[/bold magenta] · {entity}",
|
|
104
|
+
border_style="magenta",
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def explainer_render(data: dict[str, Any]) -> None:
|
|
110
|
+
title = data.get("title") or data.get("slug") or "Explainer"
|
|
111
|
+
console.print(f"[bold cyan]{title}[/bold cyan]\n")
|
|
112
|
+
body = data.get("body") or data.get("content") or data.get("summary") or ""
|
|
113
|
+
console.print(Markdown(str(body)))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def markdown_render(text: str) -> None:
|
|
117
|
+
console.print(Markdown(text))
|