scope-analytics-mcp 0.1.0__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.
@@ -0,0 +1,99 @@
1
+ Metadata-Version: 2.4
2
+ Name: scope-analytics-mcp
3
+ Version: 0.1.0
4
+ Summary: Scope MCP server — install + query tools for AI coding agents (the Scope analytics connector)
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: mcp>=1.6
8
+ Requires-Dist: httpx>=0.24
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
11
+ Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
12
+
13
+ # Scope MCP server
14
+
15
+ <!-- mcp-name: io.github.wally827/scope-analytics-mcp -->
16
+
17
+ The unified Scope MCP server (PRODUCT.md §16) — one server an AI coding agent (Claude Code, Cursor,
18
+ ChatGPT, …) uses both to **install** Scope analytics in a project and to **query** it afterward. Scope
19
+ is the AI analyst for AI products; this is its programmatic surface.
20
+
21
+ ## Tools
22
+
23
+ ### Install / setup (run once, at install time)
24
+
25
+ | Tool | What it does |
26
+ |---|---|
27
+ | `scope_detect_stack` | Scans the codebase (local, no key) and reports what Scope would auto-instrument for your stack — backend framework, LLM SDKs, frontend, deploy platform — plus honest non-coverage. |
28
+ | `scope_install_frontend` | The exact frontend-SDK install steps tailored to your stack (build-time injection or the one-line script tag), with your project's **public** key embedded. |
29
+ | `scope_install_backend` | The exact backend-SDK install steps for your stack — the zero-code `scope-run` path plus the code-based middleware (FastAPI/Flask/Django). Honest when a path isn't shipped yet (e.g. a Node backend). |
30
+ | `scope_coverage_report` | Honest, tenant-scoped **data-flow** census: which event types/sources are flowing, quarantine state, identity stitching, deploy metadata, and notes on gaps. |
31
+ | `scope_verify_installation` | Quick "are events flowing right now?" check, for right after install. |
32
+
33
+ The install tools **propose** code changes; your agent shows them to you and applies them on your
34
+ confirmation (detect-and-confirm). The Scope MCP never silently writes your files, and your **secret**
35
+ key is never echoed into a snippet — only a placeholder.
36
+
37
+ ### Query (ongoing analysis)
38
+
39
+ | Tool | What it does |
40
+ |---|---|
41
+ | `scope_ask` | Natural-language analytics question → the analyst's full reasoned answer (findings + recommendations). The catch-all; runs a real server-side agent loop (can take a minute). |
42
+ | `scope_get_stats` | Headline counts — total events, unique users, the per-type breakdown, and a day-by-day trend — over an optional date range / event type. Instant and deterministic; the quick "what are the numbers?" before deciding whether to dig in. |
43
+ | `scope_query_events` | The raw event feed (most recent first), optionally filtered by type/user. |
44
+ | `scope_get_session` | One session's (or user's) events stitched across frontend + backend + LLM, in time order, with a small census. |
45
+ | `scope_list_metrics` | The project's metric definitions — the analyst's recorded recipes ("how we computed it last time"). |
46
+ | `scope_get_metric` | One metric's full recipe + provenance. |
47
+
48
+ All query tools are **read-only** and tenant-scoped to your key's project. Use the deterministic
49
+ primitives for instant lookups you compose yourself; use `scope_ask` for open-ended "why/what/how".
50
+
51
+ ## Configure (auth = your project SECRET key in the MCP config)
52
+
53
+ `env` keys:
54
+ - `SCOPE_API_KEY` — **required.** Your project's secret key (`sk_...`), from the Scope dashboard. (The
55
+ install tools also read your **public** key from the API; you don't configure it.)
56
+ - `SCOPE_API_BASE` — backend base URL (default: the Scope backend).
57
+ - `SCOPE_API_TIMEOUT` — request timeout seconds (default 90; the free-tier backend can cold-start, and
58
+ `scope_ask` runs a real agent loop, so its client floors the timeout at 180s).
59
+
60
+ ### Claude Code (`.mcp.json` or `claude mcp add`)
61
+
62
+ Published as **`scope-analytics-mcp`** on PyPI — `uvx` fetches + runs it on demand (no manual install):
63
+
64
+ ```json
65
+ {
66
+ "mcpServers": {
67
+ "scope": {
68
+ "command": "uvx",
69
+ "args": ["scope-analytics-mcp"],
70
+ "env": {
71
+ "SCOPE_API_KEY": "sk_...",
72
+ "SCOPE_API_BASE": "https://your-scope-backend"
73
+ }
74
+ }
75
+ }
76
+ }
77
+ ```
78
+
79
+ > **Running from a local checkout** (development): swap the command for your venv python and the import
80
+ > name — `"command": "/path/to/mcp-server/venv/bin/python", "args": ["-m", "scope_mcp"]` (the import
81
+ > package stays `scope_mcp`; the PyPI/`uvx` name is `scope-analytics-mcp`).
82
+
83
+ Then ask your agent things like:
84
+ - *"Use Scope to install analytics in this project."* (→ detect → install → verify)
85
+ - *"What does Scope cover for my app?"* (→ coverage report)
86
+ - *"Ask Scope why signups dropped this week."* (→ the analyst)
87
+ - *"Show me what user `u_123` did in their last session."* (→ session)
88
+
89
+ ## Develop
90
+
91
+ ```bash
92
+ python3.10 -m venv venv
93
+ venv/bin/pip install -e ".[dev]"
94
+ venv/bin/python -m pytest -q
95
+ ```
96
+
97
+ > Not yet shipped (Track C): the sharing tools (`scope_list_reports`, `scope_get_report`, …) and
98
+ > the cross-origin CORS opt-in action. (A multi-project `project_id` arg is intentionally *not*
99
+ > planned — configure one MCP server per project so each is cleanly tenant-isolated by its own key.)
@@ -0,0 +1,14 @@
1
+ scope_mcp/__init__.py,sha256=NWgUB8axTkLUz6BHtW8plxOwqsGiB2HA6WKUQFNuB1M,186
2
+ scope_mcp/__main__.py,sha256=3dYKHfmWsrdExFlTFlcR5a_icR9fAkn06Yh14TQkEd8,33
3
+ scope_mcp/client.py,sha256=R6U_ZMO2Xp-y5KBJ4E76bGJ56anaFFN0B32zpLg4Qdo,7456
4
+ scope_mcp/config.py,sha256=q8BiZ_XRLILVUEtCKOuJVKnYx-0N7yHv51a9_ikLcsc,1816
5
+ scope_mcp/detect.py,sha256=xvjQdnu5w2O5Mq7YidKG2R4EH051ujFJ31Ig579Lclc,19709
6
+ scope_mcp/format.py,sha256=nkN2UNYRITllEzRVi2CvaqGjHvl_aedOnQqvQ82Jilw,36995
7
+ scope_mcp/install.py,sha256=1LL3iwLXQmS8rREvR-SmcLnj0zWgzOCzJgXbDxY_phQ,13552
8
+ scope_mcp/integrations.py,sha256=XbW8YHNAsvzhNjPiaHBR6dlk_ASf2dp2P0umTFnup84,16612
9
+ scope_mcp/server.py,sha256=mZcJ8VCsoE5J_cIWpxpe4aoVEgQms4MrEBsQV2Jzm_E,20691
10
+ scope_analytics_mcp-0.1.0.dist-info/METADATA,sha256=iH6q0jRRDnfTCqEPeajbHH_P5VykC4fxrMXFjVVAfwg,5119
11
+ scope_analytics_mcp-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
12
+ scope_analytics_mcp-0.1.0.dist-info/entry_points.txt,sha256=ox9WSauoFkQvzrK8qwe-5H89qflccobyvOI97eKzUpI,62
13
+ scope_analytics_mcp-0.1.0.dist-info/top_level.txt,sha256=He4HrDQkHx_NcH71gc-gOOTLb3KDYtqbdEwcp85QW1I,10
14
+ scope_analytics_mcp-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ scope-analytics-mcp = scope_mcp.server:main
@@ -0,0 +1 @@
1
+ scope_mcp
scope_mcp/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Scope MCP server — install + (later) query + sharing tools for AI coding agents (PRODUCT.md §16)."""
2
+ from .server import main, mcp
3
+
4
+ __all__ = ["main", "mcp"]
5
+ __version__ = "0.1.0"
scope_mcp/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .server import main
2
+
3
+ main()
scope_mcp/client.py ADDED
@@ -0,0 +1,166 @@
1
+ """Thin async HTTP client around Scope's Developer API v1 (secret-key auth).
2
+
3
+ The MCP tools call this; it owns auth, error translation, and nothing else. Kept separate from the
4
+ server so it is trivially unit-testable with httpx's MockTransport (no network, no MCP runtime)."""
5
+ from __future__ import annotations
6
+
7
+ from typing import Any, Dict, Optional
8
+
9
+ import httpx
10
+
11
+ from .config import ScopeConfig
12
+
13
+
14
+ class ScopeApiError(Exception):
15
+ """A user-actionable failure talking to the Scope API (surfaced verbatim to the agent)."""
16
+
17
+
18
+ # The analyst tool (scope_ask) runs a real server-side LLM agent loop — far slower than the census
19
+ # endpoints. Give it a generous timeout floor (still overridable UPWARD via SCOPE_API_TIMEOUT).
20
+ ASK_TIMEOUT_SECONDS = 180.0
21
+
22
+
23
+ class ScopeApiClient:
24
+ def __init__(self, config: ScopeConfig, *, transport: Optional[httpx.AsyncBaseTransport] = None):
25
+ self._config = config
26
+ self._transport = transport # tests inject httpx.MockTransport
27
+
28
+ def _require_api_key(self) -> None:
29
+ if not self._config.api_key:
30
+ raise ScopeApiError(
31
+ "SCOPE_API_KEY is not set. Add your project's secret key (sk_...) to the MCP server's "
32
+ "env config."
33
+ )
34
+
35
+ def _parse(self, r: httpx.Response) -> Dict[str, Any]:
36
+ """Shared response handling: secret-key/status translation + JSON parse. Used by every verb
37
+ so error messages stay identical across tools."""
38
+ if r.status_code == 401:
39
+ raise ScopeApiError(
40
+ "Authentication failed (401). SCOPE_API_KEY must be a valid project SECRET key (sk_...)."
41
+ )
42
+ if r.status_code == 403:
43
+ raise ScopeApiError(
44
+ "Forbidden (403). The Developer API needs a SECRET key (sk_...), not a public key (pk_...)."
45
+ )
46
+ if r.status_code >= 400:
47
+ detail = _safe_detail(r)
48
+ raise ScopeApiError(f"Scope API error {r.status_code}: {detail}")
49
+ try:
50
+ return r.json()
51
+ except ValueError:
52
+ raise ScopeApiError(f"Scope API returned a non-JSON response ({r.status_code}).")
53
+
54
+ async def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
55
+ self._require_api_key()
56
+ url = f"{self._config.api_base}{path}"
57
+ headers = {"Authorization": f"Bearer {self._config.api_key}"}
58
+ clean = {k: v for k, v in (params or {}).items() if v is not None}
59
+ try:
60
+ async with httpx.AsyncClient(timeout=self._config.timeout, transport=self._transport) as c:
61
+ r = await c.get(url, headers=headers, params=clean)
62
+ except httpx.TimeoutException:
63
+ raise ScopeApiError(
64
+ f"Scope API timed out after {self._config.timeout:.0f}s calling {path}. The backend "
65
+ "may be cold-starting (free tier) — try again."
66
+ )
67
+ except httpx.HTTPError as e:
68
+ raise ScopeApiError(f"Could not reach the Scope API at {self._config.api_base}: {e}")
69
+ return self._parse(r)
70
+
71
+ async def _post(
72
+ self, path: str, json_body: Dict[str, Any], *, timeout: Optional[float] = None
73
+ ) -> Dict[str, Any]:
74
+ self._require_api_key()
75
+ url = f"{self._config.api_base}{path}"
76
+ headers = {"Authorization": f"Bearer {self._config.api_key}"}
77
+ eff_timeout = timeout if timeout is not None else self._config.timeout
78
+ try:
79
+ async with httpx.AsyncClient(timeout=eff_timeout, transport=self._transport) as c:
80
+ r = await c.post(url, headers=headers, json=json_body)
81
+ except httpx.TimeoutException:
82
+ raise ScopeApiError(
83
+ f"Scope API timed out after {eff_timeout:.0f}s calling {path}. The analyst can take a "
84
+ "while on a complex question (and the free-tier backend may be cold-starting) — try "
85
+ "again, or raise SCOPE_API_TIMEOUT."
86
+ )
87
+ except httpx.HTTPError as e:
88
+ raise ScopeApiError(f"Could not reach the Scope API at {self._config.api_base}: {e}")
89
+ return self._parse(r)
90
+
91
+ async def coverage_report(self, *, window_hours: Optional[int] = None) -> Dict[str, Any]:
92
+ return await self._get("/api/v1/installation/coverage", {"window_hours": window_hours})
93
+
94
+ async def verify_installation(self, *, window_minutes: Optional[int] = None) -> Dict[str, Any]:
95
+ return await self._get("/api/v1/installation/verify", {"window_minutes": window_minutes})
96
+
97
+ async def query_events(
98
+ self,
99
+ *,
100
+ limit: Optional[int] = None,
101
+ event_type: Optional[str] = None,
102
+ user_id: Optional[str] = None,
103
+ ) -> Dict[str, Any]:
104
+ return await self._get(
105
+ "/api/v1/analytics/events",
106
+ {"limit": limit, "event_type": event_type, "user_id": user_id},
107
+ )
108
+
109
+ async def ask(self, query: str) -> Dict[str, Any]:
110
+ """Ask the Scope analyst a natural-language question (POST /analytics/insights).
111
+
112
+ Runs the full agent server-side (a real LLM loop), so it's far slower than the census
113
+ endpoints — we give it a longer timeout floor (still overridable upward via SCOPE_API_TIMEOUT).
114
+ The endpoint runs the agent with user_present=False, so it is READ-ONLY (no durable writes)."""
115
+ timeout = max(self._config.timeout, ASK_TIMEOUT_SECONDS)
116
+ return await self._post("/api/v1/analytics/insights", {"query": query}, timeout=timeout)
117
+
118
+ async def get_session(
119
+ self,
120
+ *,
121
+ session_id: Optional[str] = None,
122
+ user_id: Optional[str] = None,
123
+ limit: Optional[int] = None,
124
+ ) -> Dict[str, Any]:
125
+ """Fetch one session's (or user's) events stitched across FE/BE/LLM, time-ascending."""
126
+ return await self._get(
127
+ "/api/v1/analytics/sessions",
128
+ {"session_id": session_id, "user_id": user_id, "limit": limit},
129
+ )
130
+
131
+ async def list_metrics(self) -> Dict[str, Any]:
132
+ """List the project's Metric Registry entries (names + one-line definitions)."""
133
+ return await self._get("/api/v1/analytics/metrics")
134
+
135
+ async def get_metric(self, name: str) -> Dict[str, Any]:
136
+ """Fetch one metric's full recipe + provenance by exact name."""
137
+ return await self._get("/api/v1/analytics/metrics/detail", {"name": name})
138
+
139
+ async def get_stats(
140
+ self,
141
+ *,
142
+ start_date: Optional[str] = None,
143
+ end_date: Optional[str] = None,
144
+ event_type: Optional[str] = None,
145
+ ) -> Dict[str, Any]:
146
+ """Fetch headline analytics counts + a daily trend (GET /analytics/stats). Dates are ISO
147
+ strings (e.g. "2026-05-01" or a full timestamp); the backend parses them."""
148
+ return await self._get(
149
+ "/api/v1/analytics/stats",
150
+ {"start_date": start_date, "end_date": end_date, "event_type": event_type},
151
+ )
152
+
153
+ async def get_project(self) -> Dict[str, Any]:
154
+ """Fetch the authenticated project (incl. its public key; the secret is never returned).
155
+ Used by scope_install_frontend to embed the project's pk_ in the install snippet."""
156
+ return await self._get("/api/v1/projects/current")
157
+
158
+
159
+ def _safe_detail(r: httpx.Response) -> str:
160
+ try:
161
+ body = r.json()
162
+ if isinstance(body, dict):
163
+ return str(body.get("error") or body.get("detail") or body)[:300]
164
+ except ValueError:
165
+ pass
166
+ return r.text[:300]
scope_mcp/config.py ADDED
@@ -0,0 +1,41 @@
1
+ """Configuration for the Scope MCP server (PRODUCT.md §16 auth model: API key in MCP config)."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from dataclasses import dataclass
6
+ from typing import Optional
7
+
8
+ # v1 default points at the verified staging backend (where the Coverage Report endpoints are live).
9
+ # Production users override via SCOPE_API_BASE. (Pre-launch: there is no canonical prod host yet.)
10
+ DEFAULT_API_BASE = "https://scopeai-backend-staging.onrender.com"
11
+
12
+
13
+ @dataclass
14
+ class ScopeConfig:
15
+ """Resolved from the MCP client's env block (the `env` map in the MCP config)."""
16
+ api_key: str
17
+ api_base: str
18
+ timeout: float
19
+ # Optional Scope DASHBOARD (frontend) base URL, e.g. https://app.scope.example. When set, the
20
+ # coverage tool can hand the user a clickable deep-link to approve pending domains (PRODUCT.md
21
+ # §16 — the MCP proposes; the user confirms in Scope's own UI). Optional because there is no
22
+ # canonical pre-launch dashboard host; when unset we fall back to an in-words instruction.
23
+ dashboard_url: Optional[str] = None
24
+
25
+ @classmethod
26
+ def from_env(cls) -> "ScopeConfig":
27
+ return cls(
28
+ api_key=os.getenv("SCOPE_API_KEY", "").strip(),
29
+ api_base=os.getenv("SCOPE_API_BASE", DEFAULT_API_BASE).strip().rstrip("/"),
30
+ timeout=_float_env("SCOPE_API_TIMEOUT", 90.0),
31
+ dashboard_url=(os.getenv("SCOPE_DASHBOARD_URL", "").strip().rstrip("/") or None),
32
+ )
33
+
34
+
35
+ def _float_env(name: str, default: float) -> float:
36
+ """Parse a float env var, falling back to default on a missing/garbage value (never crash at
37
+ import — a bad SCOPE_API_TIMEOUT shouldn't take the whole MCP server down)."""
38
+ try:
39
+ return float(os.getenv(name, default))
40
+ except (TypeError, ValueError):
41
+ return default