mode-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.
mode_mcp/__init__.py
ADDED
mode_mcp/server.py
ADDED
|
@@ -0,0 +1,858 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Mode Analytics MCP Server
|
|
3
|
+
|
|
4
|
+
Browse reports and fetch results from Mode Analytics.
|
|
5
|
+
|
|
6
|
+
Env vars:
|
|
7
|
+
MODE_WORKSPACE — Mode workspace/account name
|
|
8
|
+
MODE_API_TOKEN — API token (from Mode > Settings > API Tokens)
|
|
9
|
+
MODE_API_SECRET — API secret
|
|
10
|
+
MODE_ENABLE_DELETES — "true" to enable delete_report and delete_query
|
|
11
|
+
(off by default)
|
|
12
|
+
MODE_READ_ONLY — "true" to disable every tool that writes to Mode
|
|
13
|
+
MODE_DISABLED_TOOLS — comma-separated tool names to disable, e.g.
|
|
14
|
+
"delete_report,delete_query"
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import hashlib
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
from collections.abc import Mapping
|
|
22
|
+
from contextlib import asynccontextmanager
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
import httpx
|
|
27
|
+
from mcp.server.mcpserver import Context, MCPServer
|
|
28
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
29
|
+
|
|
30
|
+
MODE_API_BASE = "https://app.mode.com/api"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class ModeContext:
|
|
35
|
+
"""Shared state created once at server startup."""
|
|
36
|
+
|
|
37
|
+
client: httpx.AsyncClient
|
|
38
|
+
workspace: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _get_config() -> tuple[str, str, str]:
|
|
42
|
+
workspace = os.environ.get("MODE_WORKSPACE", "")
|
|
43
|
+
token = os.environ.get("MODE_API_TOKEN", "")
|
|
44
|
+
secret = os.environ.get("MODE_API_SECRET", "")
|
|
45
|
+
if not all([workspace, token, secret]):
|
|
46
|
+
raise ValueError(
|
|
47
|
+
"Set MODE_WORKSPACE, MODE_API_TOKEN, and MODE_API_SECRET env vars"
|
|
48
|
+
)
|
|
49
|
+
for name, value in [
|
|
50
|
+
("MODE_WORKSPACE", workspace),
|
|
51
|
+
("MODE_API_TOKEN", token),
|
|
52
|
+
("MODE_API_SECRET", secret),
|
|
53
|
+
]:
|
|
54
|
+
if value.startswith("${"):
|
|
55
|
+
raise ValueError(
|
|
56
|
+
f"{name} is the literal text {value!r}. Your MCP client didn't expand "
|
|
57
|
+
f"the variable reference; check its docs for env var syntax or set the value directly."
|
|
58
|
+
)
|
|
59
|
+
return workspace, token, secret
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@asynccontextmanager
|
|
63
|
+
async def lifespan(server: MCPServer):
|
|
64
|
+
"""Create a shared httpx client for the lifetime of the server."""
|
|
65
|
+
workspace, token, secret = _get_config()
|
|
66
|
+
async with httpx.AsyncClient(
|
|
67
|
+
base_url=f"{MODE_API_BASE}/{workspace}",
|
|
68
|
+
auth=(token, secret),
|
|
69
|
+
headers={
|
|
70
|
+
"Content-Type": "application/json",
|
|
71
|
+
"Accept": "application/hal+json",
|
|
72
|
+
},
|
|
73
|
+
timeout=30.0,
|
|
74
|
+
) as client:
|
|
75
|
+
yield ModeContext(client=client, workspace=workspace)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
mcp = MCPServer("mode-analytics", lifespan=lifespan)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _mode(ctx: Context) -> ModeContext:
|
|
82
|
+
"""Extract the ModeContext from the request context."""
|
|
83
|
+
return ctx.request_context.lifespan_context
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _check(resp: httpx.Response) -> None:
|
|
87
|
+
"""Raise a ToolError with a friendly message if the response is an error."""
|
|
88
|
+
try:
|
|
89
|
+
resp.raise_for_status()
|
|
90
|
+
except httpx.HTTPStatusError as exc:
|
|
91
|
+
status = exc.response.status_code
|
|
92
|
+
detail = ""
|
|
93
|
+
try:
|
|
94
|
+
detail = exc.response.json().get("error", "")
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
if status == 404:
|
|
98
|
+
msg = "Resource not found."
|
|
99
|
+
elif status == 401:
|
|
100
|
+
msg = "Authentication failed — check MODE_API_TOKEN and MODE_API_SECRET."
|
|
101
|
+
elif status == 403:
|
|
102
|
+
msg = "Permission denied — your API token may lack access to this resource."
|
|
103
|
+
else:
|
|
104
|
+
msg = f"Mode API returned HTTP {status}."
|
|
105
|
+
if detail:
|
|
106
|
+
msg = f"{msg} Detail: {detail}"
|
|
107
|
+
raise ToolError(msg) from exc
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _embedded(data: dict[str, Any], key: str) -> list[dict[str, Any]]:
|
|
111
|
+
"""Extract a list from Mode's HAL+JSON _embedded envelope."""
|
|
112
|
+
return data.get("_embedded", {}).get(key, [])
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _creator(obj: dict[str, Any]) -> str | None:
|
|
116
|
+
"""Mode username from an object's creator link, e.g. "/api/jane_doe" -> "jane_doe".
|
|
117
|
+
|
|
118
|
+
Mode includes this on queries but not on reports.
|
|
119
|
+
"""
|
|
120
|
+
href = obj.get("_links", {}).get("creator", {}).get("href", "")
|
|
121
|
+
parts = [p for p in href.split("/") if p]
|
|
122
|
+
return parts[1] if len(parts) >= 2 and parts[0] == "api" else None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
# Browsing tools
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@mcp.tool()
|
|
132
|
+
async def list_data_sources(ctx: Context, limit: int = 50) -> list[dict[str, Any]]:
|
|
133
|
+
"""List database connections in the Mode workspace.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
limit: Max number of results to return (default 50).
|
|
137
|
+
"""
|
|
138
|
+
mode = _mode(ctx)
|
|
139
|
+
resp = await mode.client.get("/data_sources", params={"limit": limit})
|
|
140
|
+
_check(resp)
|
|
141
|
+
sources = _embedded(resp.json(), "data_sources")
|
|
142
|
+
return [
|
|
143
|
+
{
|
|
144
|
+
"id": s["id"],
|
|
145
|
+
"token": s["token"],
|
|
146
|
+
"name": s["name"],
|
|
147
|
+
"adapter": s.get("adapter"),
|
|
148
|
+
"database": s.get("database"),
|
|
149
|
+
}
|
|
150
|
+
for s in sources[:limit]
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@mcp.tool()
|
|
155
|
+
async def list_spaces(ctx: Context, limit: int = 50) -> list[dict[str, Any]]:
|
|
156
|
+
"""List spaces (collections) in the Mode workspace.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
limit: Max number of results to return (default 50).
|
|
160
|
+
"""
|
|
161
|
+
mode = _mode(ctx)
|
|
162
|
+
resp = await mode.client.get("/spaces", params={"limit": limit})
|
|
163
|
+
_check(resp)
|
|
164
|
+
spaces = _embedded(resp.json(), "spaces")
|
|
165
|
+
return [
|
|
166
|
+
{
|
|
167
|
+
"token": s["token"],
|
|
168
|
+
"name": s["name"],
|
|
169
|
+
"description": s.get("description", ""),
|
|
170
|
+
"state": s.get("state"),
|
|
171
|
+
}
|
|
172
|
+
for s in spaces[:limit]
|
|
173
|
+
]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@mcp.tool()
|
|
177
|
+
async def list_reports(
|
|
178
|
+
ctx: Context, space_token: str, limit: int = 50, include_archived: bool = False
|
|
179
|
+
) -> list[dict[str, Any]]:
|
|
180
|
+
"""List reports in a space. Archived reports are hidden unless include_archived.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
space_token: Space token (use list_spaces to find it).
|
|
184
|
+
limit: Max number of results to return (default 50).
|
|
185
|
+
include_archived: Also return archived reports (each has an `archived` flag).
|
|
186
|
+
"""
|
|
187
|
+
mode = _mode(ctx)
|
|
188
|
+
resp = await mode.client.get(
|
|
189
|
+
f"/spaces/{space_token}/reports", params={"limit": limit}
|
|
190
|
+
)
|
|
191
|
+
_check(resp)
|
|
192
|
+
reports = _embedded(resp.json(), "reports")
|
|
193
|
+
if not include_archived:
|
|
194
|
+
reports = [r for r in reports if not r.get("archived")]
|
|
195
|
+
return [
|
|
196
|
+
{
|
|
197
|
+
"token": r["token"],
|
|
198
|
+
"name": r["name"],
|
|
199
|
+
"description": r.get("description", ""),
|
|
200
|
+
"archived": bool(r.get("archived")),
|
|
201
|
+
"created_at": r.get("created_at"),
|
|
202
|
+
"last_run_at": r.get("last_run_at"),
|
|
203
|
+
}
|
|
204
|
+
for r in reports[:limit]
|
|
205
|
+
]
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@mcp.tool()
|
|
209
|
+
async def search_reports(
|
|
210
|
+
ctx: Context, query: str, limit: int = 50, include_archived: bool = False
|
|
211
|
+
) -> list[dict[str, Any]]:
|
|
212
|
+
"""Search for reports by name or description across all spaces.
|
|
213
|
+
|
|
214
|
+
Mode's REST API has no native report-search endpoint, so this fans out
|
|
215
|
+
across every space and filters client-side (case-insensitive substring
|
|
216
|
+
match on name and description). May be slow on large workspaces.
|
|
217
|
+
Archived reports are skipped unless include_archived.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
query: Search term to match against report names and descriptions.
|
|
221
|
+
limit: Max number of results to return (default 50).
|
|
222
|
+
include_archived: Also return archived reports (each has an `archived` flag).
|
|
223
|
+
"""
|
|
224
|
+
mode = _mode(ctx)
|
|
225
|
+
q = query.lower()
|
|
226
|
+
|
|
227
|
+
spaces_resp = await mode.client.get("/spaces", params={"limit": 100})
|
|
228
|
+
_check(spaces_resp)
|
|
229
|
+
spaces = _embedded(spaces_resp.json(), "spaces")
|
|
230
|
+
|
|
231
|
+
sem = asyncio.Semaphore(5)
|
|
232
|
+
|
|
233
|
+
async def _reports_in(space: dict[str, Any]) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
234
|
+
async with sem:
|
|
235
|
+
r = await mode.client.get(
|
|
236
|
+
f"/spaces/{space['token']}/reports", params={"limit": 500}
|
|
237
|
+
)
|
|
238
|
+
_check(r)
|
|
239
|
+
return space, _embedded(r.json(), "reports")
|
|
240
|
+
|
|
241
|
+
groups = await asyncio.gather(*[_reports_in(s) for s in spaces])
|
|
242
|
+
|
|
243
|
+
matches: list[dict[str, Any]] = []
|
|
244
|
+
for space, reports in groups:
|
|
245
|
+
for r in reports:
|
|
246
|
+
if r.get("archived") and not include_archived:
|
|
247
|
+
continue
|
|
248
|
+
name = (r.get("name") or "").lower()
|
|
249
|
+
desc = (r.get("description") or "").lower()
|
|
250
|
+
if q in name or q in desc:
|
|
251
|
+
matches.append(
|
|
252
|
+
{
|
|
253
|
+
"token": r["token"],
|
|
254
|
+
"name": r.get("name", ""),
|
|
255
|
+
"description": r.get("description", ""),
|
|
256
|
+
"space_token": space["token"],
|
|
257
|
+
"space_name": space.get("name", ""),
|
|
258
|
+
"archived": bool(r.get("archived")),
|
|
259
|
+
"created_at": r.get("created_at"),
|
|
260
|
+
"last_run_at": r.get("last_run_at"),
|
|
261
|
+
}
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
return matches[:limit]
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@mcp.tool()
|
|
268
|
+
async def get_report(ctx: Context, report_token: str) -> dict[str, Any]:
|
|
269
|
+
"""Get a report's details and its queries (SQL).
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
report_token: The report token.
|
|
273
|
+
"""
|
|
274
|
+
mode = _mode(ctx)
|
|
275
|
+
report_resp, queries_resp = await asyncio.gather(
|
|
276
|
+
mode.client.get(f"/reports/{report_token}"),
|
|
277
|
+
mode.client.get(f"/reports/{report_token}/queries"),
|
|
278
|
+
)
|
|
279
|
+
_check(report_resp)
|
|
280
|
+
_check(queries_resp)
|
|
281
|
+
|
|
282
|
+
report = report_resp.json()
|
|
283
|
+
queries = _embedded(queries_resp.json(), "queries")
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
"token": report["token"],
|
|
287
|
+
"name": report.get("name"),
|
|
288
|
+
"description": report.get("description", ""),
|
|
289
|
+
"archived": bool(report.get("archived")),
|
|
290
|
+
"created_at": report.get("created_at"),
|
|
291
|
+
"last_run_at": report.get("last_run_at"),
|
|
292
|
+
"queries": [
|
|
293
|
+
{
|
|
294
|
+
"token": q["token"],
|
|
295
|
+
"name": q.get("name", ""),
|
|
296
|
+
"creator": _creator(q),
|
|
297
|
+
"raw_query": q.get("raw_query", ""),
|
|
298
|
+
"data_source_id": q.get("data_source_id"),
|
|
299
|
+
}
|
|
300
|
+
for q in queries
|
|
301
|
+
],
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@mcp.tool()
|
|
306
|
+
async def list_report_runs(
|
|
307
|
+
ctx: Context, report_token: str, limit: int = 10
|
|
308
|
+
) -> list[dict[str, Any]]:
|
|
309
|
+
"""List recent runs for a report.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
report_token: The report token.
|
|
313
|
+
limit: Max number of runs to return (default 10).
|
|
314
|
+
"""
|
|
315
|
+
mode = _mode(ctx)
|
|
316
|
+
resp = await mode.client.get(
|
|
317
|
+
f"/reports/{report_token}/runs",
|
|
318
|
+
params={"order": "desc", "order_by": "created_at", "limit": limit},
|
|
319
|
+
)
|
|
320
|
+
_check(resp)
|
|
321
|
+
runs = _embedded(resp.json(), "report_runs")
|
|
322
|
+
return [
|
|
323
|
+
{
|
|
324
|
+
"token": r["token"],
|
|
325
|
+
"state": r.get("state"),
|
|
326
|
+
"created_at": r.get("created_at"),
|
|
327
|
+
"completed_at": r.get("completed_at"),
|
|
328
|
+
}
|
|
329
|
+
for r in runs[:limit]
|
|
330
|
+
]
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
# ---------------------------------------------------------------------------
|
|
334
|
+
# Creation tools
|
|
335
|
+
# ---------------------------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
@mcp.tool()
|
|
339
|
+
async def create_report(
|
|
340
|
+
ctx: Context,
|
|
341
|
+
space_token: str,
|
|
342
|
+
name: str,
|
|
343
|
+
description: str = "",
|
|
344
|
+
queries: list[dict[str, Any]] | None = None,
|
|
345
|
+
) -> dict[str, Any]:
|
|
346
|
+
"""Create a new report in a space, optionally with initial SQL queries.
|
|
347
|
+
|
|
348
|
+
This is a write operation — it creates a report visible to the workspace.
|
|
349
|
+
If any queries fail to add, the report is still created; inspect
|
|
350
|
+
`queries_added` and `queries_failed` in the result.
|
|
351
|
+
|
|
352
|
+
Args:
|
|
353
|
+
space_token: Space to create the report in (use list_spaces to find it).
|
|
354
|
+
name: Report name.
|
|
355
|
+
description: Optional report description.
|
|
356
|
+
queries: Optional list of queries to add. Each dict should have:
|
|
357
|
+
- raw_query (str): The SQL text.
|
|
358
|
+
- data_source_id (int): Data source ID (use list_data_sources).
|
|
359
|
+
- name (str, optional): Query name.
|
|
360
|
+
"""
|
|
361
|
+
mode = _mode(ctx)
|
|
362
|
+
resp = await mode.client.post(
|
|
363
|
+
"/reports",
|
|
364
|
+
json={"report": {"space_token": space_token}},
|
|
365
|
+
)
|
|
366
|
+
_check(resp)
|
|
367
|
+
report_token = resp.json()["token"]
|
|
368
|
+
|
|
369
|
+
# Mode's create endpoint ignores name and description; set them with a PATCH.
|
|
370
|
+
# The report already exists at this point, so a failure here must say which
|
|
371
|
+
# report was left untitled — otherwise it's orphaned with no way to find it.
|
|
372
|
+
try:
|
|
373
|
+
resp = await mode.client.patch(
|
|
374
|
+
f"/reports/{report_token}",
|
|
375
|
+
json={"report": {"name": name, "description": description}},
|
|
376
|
+
)
|
|
377
|
+
_check(resp)
|
|
378
|
+
except (ToolError, httpx.HTTPError) as exc:
|
|
379
|
+
raise ToolError(
|
|
380
|
+
f"Created report {report_token} but failed to set its name and "
|
|
381
|
+
f"description ({exc}). It exists untitled at "
|
|
382
|
+
f"https://app.mode.com/{mode.workspace}/reports/{report_token} — retry "
|
|
383
|
+
f"with update_report(report_token={report_token!r}, ...) or archive it."
|
|
384
|
+
) from exc
|
|
385
|
+
report = resp.json()
|
|
386
|
+
|
|
387
|
+
async def _add_one(q: dict[str, Any]) -> dict[str, Any]:
|
|
388
|
+
q_resp = await mode.client.post(
|
|
389
|
+
f"/reports/{report_token}/queries",
|
|
390
|
+
json={
|
|
391
|
+
"query": {
|
|
392
|
+
"raw_query": q["raw_query"],
|
|
393
|
+
"data_source_id": q["data_source_id"],
|
|
394
|
+
"name": q.get("name", ""),
|
|
395
|
+
}
|
|
396
|
+
},
|
|
397
|
+
)
|
|
398
|
+
_check(q_resp)
|
|
399
|
+
return q_resp.json()
|
|
400
|
+
|
|
401
|
+
# The report POST already succeeded; don't let one bad query raise and
|
|
402
|
+
# leave the caller without a handle on the partially-built report.
|
|
403
|
+
query_list = queries or []
|
|
404
|
+
results = await asyncio.gather(
|
|
405
|
+
*[_add_one(q) for q in query_list], return_exceptions=True
|
|
406
|
+
)
|
|
407
|
+
failures = [
|
|
408
|
+
{"name": q.get("name", ""), "error": str(r)}
|
|
409
|
+
for q, r in zip(query_list, results)
|
|
410
|
+
if isinstance(r, Exception)
|
|
411
|
+
]
|
|
412
|
+
added = [r for r in results if not isinstance(r, Exception)]
|
|
413
|
+
|
|
414
|
+
return {
|
|
415
|
+
"token": report_token,
|
|
416
|
+
"name": report.get("name"),
|
|
417
|
+
"description": report.get("description", ""),
|
|
418
|
+
"url": f"https://app.mode.com/{mode.workspace}/reports/{report_token}",
|
|
419
|
+
"queries_added": len(added),
|
|
420
|
+
"queries_failed": failures,
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
@mcp.tool()
|
|
425
|
+
async def update_report(
|
|
426
|
+
ctx: Context,
|
|
427
|
+
report_token: str,
|
|
428
|
+
name: str | None = None,
|
|
429
|
+
description: str | None = None,
|
|
430
|
+
space_token: str | None = None,
|
|
431
|
+
) -> dict[str, Any]:
|
|
432
|
+
"""Update a report's name, description, or move it to a different space.
|
|
433
|
+
|
|
434
|
+
This is a write operation — it modifies an existing report.
|
|
435
|
+
|
|
436
|
+
Args:
|
|
437
|
+
report_token: The report to update.
|
|
438
|
+
name: New report name. Omit to leave unchanged.
|
|
439
|
+
description: New description. Omit to leave unchanged.
|
|
440
|
+
space_token: Move report to this space. Omit to leave in current space.
|
|
441
|
+
"""
|
|
442
|
+
body: dict[str, Any] = {}
|
|
443
|
+
if name is not None:
|
|
444
|
+
body["name"] = name
|
|
445
|
+
if description is not None:
|
|
446
|
+
body["description"] = description
|
|
447
|
+
if space_token is not None:
|
|
448
|
+
body["space_token"] = space_token
|
|
449
|
+
|
|
450
|
+
if not body:
|
|
451
|
+
raise ToolError("Nothing to update — provide at least one of name, description, or space_token.")
|
|
452
|
+
|
|
453
|
+
mode = _mode(ctx)
|
|
454
|
+
resp = await mode.client.patch(
|
|
455
|
+
f"/reports/{report_token}",
|
|
456
|
+
json={"report": body},
|
|
457
|
+
)
|
|
458
|
+
_check(resp)
|
|
459
|
+
report = resp.json()
|
|
460
|
+
return {
|
|
461
|
+
"token": report["token"],
|
|
462
|
+
"name": report.get("name"),
|
|
463
|
+
"description": report.get("description", ""),
|
|
464
|
+
"url": f"https://app.mode.com/{mode.workspace}/reports/{report['token']}",
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _confirm_code(*parts: Any) -> str:
|
|
469
|
+
"""Short fingerprint of what a delete preview showed.
|
|
470
|
+
|
|
471
|
+
Deletes only proceed when the caller passes back the code from a preview,
|
|
472
|
+
so the preview can't be skipped, and a resource that changed since the
|
|
473
|
+
preview (renamed, SQL edited, queries added) won't be deleted by mistake.
|
|
474
|
+
"""
|
|
475
|
+
blob = json.dumps(parts, sort_keys=True, default=str)
|
|
476
|
+
return hashlib.sha256(blob.encode()).hexdigest()[:8]
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _verify_confirm_code(given: str, expected: str, tool: str) -> None:
|
|
480
|
+
if given.strip().lower() != expected:
|
|
481
|
+
raise ToolError(
|
|
482
|
+
f"confirm_code does not match. The resource may have changed since the "
|
|
483
|
+
f"preview, or the code is wrong. Call {tool} again without confirm_code "
|
|
484
|
+
f"to get a fresh preview, show it to the user, and only then delete."
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
@mcp.tool()
|
|
489
|
+
async def delete_query(
|
|
490
|
+
ctx: Context,
|
|
491
|
+
report_token: str,
|
|
492
|
+
query_token: str,
|
|
493
|
+
confirm_code: str | None = None,
|
|
494
|
+
) -> dict[str, Any]:
|
|
495
|
+
"""Permanently delete a query from a report. This cannot be undone.
|
|
496
|
+
|
|
497
|
+
Two steps, always:
|
|
498
|
+
1. Call without confirm_code. Returns a preview (query name, author, SQL) and a
|
|
499
|
+
confirm_code. Nothing is deleted.
|
|
500
|
+
2. Show the preview to the user and get their explicit approval. Only then
|
|
501
|
+
call again with the confirm_code from step 1.
|
|
502
|
+
|
|
503
|
+
Args:
|
|
504
|
+
report_token: The report containing the query.
|
|
505
|
+
query_token: The query to delete (use get_report to find query tokens).
|
|
506
|
+
confirm_code: The code returned by the preview call. Never guess it.
|
|
507
|
+
"""
|
|
508
|
+
mode = _mode(ctx)
|
|
509
|
+
resp = await mode.client.get(f"/reports/{report_token}/queries/{query_token}")
|
|
510
|
+
_check(resp)
|
|
511
|
+
q = resp.json()
|
|
512
|
+
code = _confirm_code(
|
|
513
|
+
query_token, q.get("name"), q.get("raw_query"), q.get("updated_at")
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
if confirm_code is None:
|
|
517
|
+
return {
|
|
518
|
+
"confirm_required": True,
|
|
519
|
+
"report_token": report_token,
|
|
520
|
+
"query_token": query_token,
|
|
521
|
+
"name": q.get("name", ""),
|
|
522
|
+
"creator": _creator(q),
|
|
523
|
+
"raw_query": q.get("raw_query", ""),
|
|
524
|
+
"confirm_code": code,
|
|
525
|
+
"message": (
|
|
526
|
+
"Nothing was deleted. Show this to the user; if they approve, "
|
|
527
|
+
"call delete_query again with this confirm_code."
|
|
528
|
+
),
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
_verify_confirm_code(confirm_code, code, "delete_query")
|
|
532
|
+
resp = await mode.client.delete(
|
|
533
|
+
f"/reports/{report_token}/queries/{query_token}"
|
|
534
|
+
)
|
|
535
|
+
_check(resp)
|
|
536
|
+
return {"deleted": True, "query_token": query_token, "name": q.get("name", "")}
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
@mcp.tool()
|
|
540
|
+
async def delete_report(
|
|
541
|
+
ctx: Context, report_token: str, confirm_code: str | None = None
|
|
542
|
+
) -> dict[str, Any]:
|
|
543
|
+
"""Permanently delete a report and all its queries. This cannot be undone.
|
|
544
|
+
|
|
545
|
+
Prefer archive_report unless the user explicitly wants a permanent delete —
|
|
546
|
+
archiving hides the report but can be reversed with unarchive_report.
|
|
547
|
+
|
|
548
|
+
Two steps, always:
|
|
549
|
+
1. Call without confirm_code. Returns a preview (name, queries and who wrote
|
|
550
|
+
them, whether it's already archived) and
|
|
551
|
+
a confirm_code. Nothing is deleted.
|
|
552
|
+
2. Show the preview to the user and get their explicit approval. Only then
|
|
553
|
+
call again with the confirm_code from step 1.
|
|
554
|
+
|
|
555
|
+
Args:
|
|
556
|
+
report_token: The report to delete (use list_reports to find tokens).
|
|
557
|
+
confirm_code: The code returned by the preview call. Never guess it.
|
|
558
|
+
"""
|
|
559
|
+
mode = _mode(ctx)
|
|
560
|
+
report_resp, queries_resp = await asyncio.gather(
|
|
561
|
+
mode.client.get(f"/reports/{report_token}"),
|
|
562
|
+
mode.client.get(f"/reports/{report_token}/queries"),
|
|
563
|
+
)
|
|
564
|
+
_check(report_resp)
|
|
565
|
+
_check(queries_resp)
|
|
566
|
+
|
|
567
|
+
report = report_resp.json()
|
|
568
|
+
queries = _embedded(queries_resp.json(), "queries")
|
|
569
|
+
code = _confirm_code(
|
|
570
|
+
report_token,
|
|
571
|
+
report.get("name"),
|
|
572
|
+
report.get("updated_at"),
|
|
573
|
+
sorted((q.get("token"), q.get("raw_query")) for q in queries),
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
if confirm_code is None:
|
|
577
|
+
return {
|
|
578
|
+
"confirm_required": True,
|
|
579
|
+
"report_token": report_token,
|
|
580
|
+
"name": report.get("name"),
|
|
581
|
+
"archived": bool(report.get("archived")),
|
|
582
|
+
"query_count": len(queries),
|
|
583
|
+
# Mode doesn't expose a report's creator, only each query's.
|
|
584
|
+
"queries": [
|
|
585
|
+
{"name": q.get("name", ""), "creator": _creator(q)} for q in queries
|
|
586
|
+
],
|
|
587
|
+
"url": f"https://app.mode.com/{mode.workspace}/reports/{report_token}",
|
|
588
|
+
"confirm_code": code,
|
|
589
|
+
"message": (
|
|
590
|
+
"Nothing was deleted. Show this to the user and suggest "
|
|
591
|
+
"archive_report as a reversible alternative; if they still want "
|
|
592
|
+
"a permanent delete, call delete_report again with this confirm_code."
|
|
593
|
+
),
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
_verify_confirm_code(confirm_code, code, "delete_report")
|
|
597
|
+
resp = await mode.client.delete(f"/reports/{report_token}")
|
|
598
|
+
_check(resp)
|
|
599
|
+
return {"deleted": True, "report_token": report_token, "name": report.get("name")}
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
@mcp.tool()
|
|
603
|
+
async def archive_report(ctx: Context, report_token: str) -> dict[str, Any]:
|
|
604
|
+
"""Archive a report, hiding it from view. Reversible with unarchive_report.
|
|
605
|
+
|
|
606
|
+
This is the safe alternative to delete_report.
|
|
607
|
+
|
|
608
|
+
Args:
|
|
609
|
+
report_token: The report to archive.
|
|
610
|
+
"""
|
|
611
|
+
mode = _mode(ctx)
|
|
612
|
+
resp = await mode.client.patch(f"/reports/{report_token}/archive")
|
|
613
|
+
_check(resp)
|
|
614
|
+
report = resp.json()
|
|
615
|
+
return {
|
|
616
|
+
"archived": True,
|
|
617
|
+
"report_token": report_token,
|
|
618
|
+
"name": report.get("name"),
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
@mcp.tool()
|
|
623
|
+
async def unarchive_report(ctx: Context, report_token: str) -> dict[str, Any]:
|
|
624
|
+
"""Restore an archived report.
|
|
625
|
+
|
|
626
|
+
Args:
|
|
627
|
+
report_token: The report to unarchive.
|
|
628
|
+
"""
|
|
629
|
+
mode = _mode(ctx)
|
|
630
|
+
resp = await mode.client.patch(f"/reports/{report_token}/unarchive")
|
|
631
|
+
_check(resp)
|
|
632
|
+
report = resp.json()
|
|
633
|
+
return {
|
|
634
|
+
"archived": False,
|
|
635
|
+
"report_token": report_token,
|
|
636
|
+
"name": report.get("name"),
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
@mcp.tool()
|
|
641
|
+
async def add_query(
|
|
642
|
+
ctx: Context,
|
|
643
|
+
report_token: str,
|
|
644
|
+
raw_query: str,
|
|
645
|
+
data_source_id: int,
|
|
646
|
+
name: str = "",
|
|
647
|
+
) -> dict[str, Any]:
|
|
648
|
+
"""Add a SQL query to an existing report.
|
|
649
|
+
|
|
650
|
+
This is a write operation — it adds a new query to the report.
|
|
651
|
+
|
|
652
|
+
Args:
|
|
653
|
+
report_token: The report to add the query to.
|
|
654
|
+
raw_query: SQL query text.
|
|
655
|
+
data_source_id: Data source ID (use list_data_sources to find it).
|
|
656
|
+
name: Optional query name.
|
|
657
|
+
"""
|
|
658
|
+
mode = _mode(ctx)
|
|
659
|
+
resp = await mode.client.post(
|
|
660
|
+
f"/reports/{report_token}/queries",
|
|
661
|
+
json={
|
|
662
|
+
"query": {
|
|
663
|
+
"raw_query": raw_query,
|
|
664
|
+
"data_source_id": data_source_id,
|
|
665
|
+
"name": name,
|
|
666
|
+
}
|
|
667
|
+
},
|
|
668
|
+
)
|
|
669
|
+
_check(resp)
|
|
670
|
+
q = resp.json()
|
|
671
|
+
return {
|
|
672
|
+
"token": q["token"],
|
|
673
|
+
"name": q.get("name", ""),
|
|
674
|
+
"raw_query": q.get("raw_query", ""),
|
|
675
|
+
"data_source_id": q.get("data_source_id"),
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
@mcp.tool()
|
|
680
|
+
async def update_query(
|
|
681
|
+
ctx: Context,
|
|
682
|
+
report_token: str,
|
|
683
|
+
query_token: str,
|
|
684
|
+
raw_query: str | None = None,
|
|
685
|
+
name: str | None = None,
|
|
686
|
+
data_source_id: int | None = None,
|
|
687
|
+
) -> dict[str, Any]:
|
|
688
|
+
"""Update a SQL query in a report.
|
|
689
|
+
|
|
690
|
+
This is a write operation — it modifies an existing query.
|
|
691
|
+
|
|
692
|
+
Args:
|
|
693
|
+
report_token: The report containing the query.
|
|
694
|
+
query_token: The query to update (use get_report to find query tokens).
|
|
695
|
+
raw_query: New SQL text. Omit to leave unchanged.
|
|
696
|
+
name: New query name. Omit to leave unchanged.
|
|
697
|
+
data_source_id: New data source ID. Omit to leave unchanged.
|
|
698
|
+
"""
|
|
699
|
+
body: dict[str, Any] = {}
|
|
700
|
+
if raw_query is not None:
|
|
701
|
+
body["raw_query"] = raw_query
|
|
702
|
+
if name is not None:
|
|
703
|
+
body["name"] = name
|
|
704
|
+
if data_source_id is not None:
|
|
705
|
+
body["data_source_id"] = data_source_id
|
|
706
|
+
|
|
707
|
+
if not body:
|
|
708
|
+
raise ToolError("Nothing to update — provide at least one of raw_query, name, or data_source_id.")
|
|
709
|
+
|
|
710
|
+
mode = _mode(ctx)
|
|
711
|
+
resp = await mode.client.patch(
|
|
712
|
+
f"/reports/{report_token}/queries/{query_token}",
|
|
713
|
+
json={"query": body},
|
|
714
|
+
)
|
|
715
|
+
_check(resp)
|
|
716
|
+
q = resp.json()
|
|
717
|
+
return {
|
|
718
|
+
"token": q["token"],
|
|
719
|
+
"name": q.get("name", ""),
|
|
720
|
+
"raw_query": q.get("raw_query", ""),
|
|
721
|
+
"data_source_id": q.get("data_source_id"),
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
# ---------------------------------------------------------------------------
|
|
726
|
+
# Results
|
|
727
|
+
# ---------------------------------------------------------------------------
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
async def _fetch_run_results(
|
|
731
|
+
client: httpx.AsyncClient,
|
|
732
|
+
report_token: str,
|
|
733
|
+
run_token: str,
|
|
734
|
+
max_rows_per_query: int | None = 500,
|
|
735
|
+
) -> list[dict[str, Any]]:
|
|
736
|
+
"""Fetch results for all query runs in a report run."""
|
|
737
|
+
qr_resp = await client.get(f"/reports/{report_token}/runs/{run_token}/query_runs")
|
|
738
|
+
_check(qr_resp)
|
|
739
|
+
query_runs = _embedded(qr_resp.json(), "query_runs")
|
|
740
|
+
|
|
741
|
+
async def _fetch_one(qr: dict[str, Any]) -> dict[str, Any]:
|
|
742
|
+
qr_token = qr["token"]
|
|
743
|
+
r = await client.get(
|
|
744
|
+
f"/reports/{report_token}/runs/{run_token}"
|
|
745
|
+
f"/query_runs/{qr_token}/results/content.json"
|
|
746
|
+
)
|
|
747
|
+
if r.status_code != 200:
|
|
748
|
+
return {
|
|
749
|
+
"query_name": qr.get("query_name", ""),
|
|
750
|
+
"state": qr.get("state"),
|
|
751
|
+
"error": f"HTTP {r.status_code}",
|
|
752
|
+
}
|
|
753
|
+
data = r.json()
|
|
754
|
+
out: dict[str, Any] = {
|
|
755
|
+
"query_name": qr.get("query_name", ""),
|
|
756
|
+
"state": qr.get("state"),
|
|
757
|
+
}
|
|
758
|
+
if isinstance(data, list):
|
|
759
|
+
total = len(data)
|
|
760
|
+
out["row_count"] = total
|
|
761
|
+
if max_rows_per_query is not None and total > max_rows_per_query:
|
|
762
|
+
out["data"] = data[:max_rows_per_query]
|
|
763
|
+
out["truncated"] = True
|
|
764
|
+
else:
|
|
765
|
+
out["data"] = data
|
|
766
|
+
out["truncated"] = False
|
|
767
|
+
else:
|
|
768
|
+
# Unexpected non-list payload — pass through unchanged.
|
|
769
|
+
out["data"] = data
|
|
770
|
+
return out
|
|
771
|
+
|
|
772
|
+
return list(await asyncio.gather(*[_fetch_one(qr) for qr in query_runs]))
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
@mcp.tool()
|
|
776
|
+
async def get_run_results(
|
|
777
|
+
ctx: Context,
|
|
778
|
+
report_token: str,
|
|
779
|
+
run_token: str | None = None,
|
|
780
|
+
max_rows_per_query: int | None = 500,
|
|
781
|
+
) -> list[dict[str, Any]]:
|
|
782
|
+
"""Get results from a report run. Defaults to the latest run.
|
|
783
|
+
|
|
784
|
+
Each query's rows are truncated to `max_rows_per_query` by default so a
|
|
785
|
+
single call can't blow past the agent's context window. When a query was
|
|
786
|
+
truncated, the result includes `truncated: true` and `row_count: <total>`
|
|
787
|
+
so you know to re-call with a higher limit (or null) if you need all rows.
|
|
788
|
+
|
|
789
|
+
Args:
|
|
790
|
+
report_token: The report token.
|
|
791
|
+
run_token: Specific run token. If omitted, uses the latest succeeded run.
|
|
792
|
+
max_rows_per_query: Max rows to return per query. Default 500. Pass null
|
|
793
|
+
to fetch every row — can be very large (hundreds of MB) for
|
|
794
|
+
dashboards with long time-series or wide cross-tabs.
|
|
795
|
+
"""
|
|
796
|
+
mode = _mode(ctx)
|
|
797
|
+
if not run_token:
|
|
798
|
+
runs_resp = await mode.client.get(
|
|
799
|
+
f"/reports/{report_token}/runs",
|
|
800
|
+
params={"order": "desc", "order_by": "created_at"},
|
|
801
|
+
)
|
|
802
|
+
_check(runs_resp)
|
|
803
|
+
runs = _embedded(runs_resp.json(), "report_runs")
|
|
804
|
+
succeeded = [r for r in runs if r.get("state") == "succeeded"]
|
|
805
|
+
if not succeeded:
|
|
806
|
+
raise ToolError("No succeeded runs found for this report.")
|
|
807
|
+
run_token = succeeded[0]["token"]
|
|
808
|
+
|
|
809
|
+
return await _fetch_run_results(
|
|
810
|
+
mode.client, report_token, run_token, max_rows_per_query
|
|
811
|
+
)
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
WRITE_TOOLS = frozenset({
|
|
816
|
+
"create_report",
|
|
817
|
+
"update_report",
|
|
818
|
+
"archive_report",
|
|
819
|
+
"unarchive_report",
|
|
820
|
+
"add_query",
|
|
821
|
+
"update_query",
|
|
822
|
+
"delete_query",
|
|
823
|
+
"delete_report",
|
|
824
|
+
})
|
|
825
|
+
|
|
826
|
+
DELETE_TOOLS = frozenset({"delete_query", "delete_report"})
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def _env_flag(env: Mapping[str, str], name: str) -> bool:
|
|
830
|
+
return env.get(name, "").strip().lower() in {"1", "true", "yes"}
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def _disabled_tools(env: Mapping[str, str], all_tools: set[str]) -> set[str]:
|
|
834
|
+
"""Work out which tools to disable from the MODE_* tool settings."""
|
|
835
|
+
disabled: set[str] = set()
|
|
836
|
+
if not _env_flag(env, "MODE_ENABLE_DELETES"):
|
|
837
|
+
disabled |= DELETE_TOOLS
|
|
838
|
+
if _env_flag(env, "MODE_READ_ONLY"):
|
|
839
|
+
disabled |= WRITE_TOOLS
|
|
840
|
+
names = {n.strip() for n in env.get("MODE_DISABLED_TOOLS", "").split(",") if n.strip()}
|
|
841
|
+
unknown = names - all_tools
|
|
842
|
+
if unknown:
|
|
843
|
+
raise ValueError(
|
|
844
|
+
f"MODE_DISABLED_TOOLS has unknown tool(s): {', '.join(sorted(unknown))}. "
|
|
845
|
+
f"Valid tools: {', '.join(sorted(all_tools))}"
|
|
846
|
+
)
|
|
847
|
+
return disabled | names
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def main() -> None:
|
|
851
|
+
all_tools = {t.name for t in asyncio.run(mcp.list_tools())}
|
|
852
|
+
for name in _disabled_tools(os.environ, all_tools):
|
|
853
|
+
mcp.remove_tool(name)
|
|
854
|
+
mcp.run()
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
if __name__ == "__main__":
|
|
858
|
+
main()
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: mode-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for Mode Analytics — browse reports, manage queries, and fetch results
|
|
5
|
+
Author: Natalie Mona Matthews
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: analytics,mcp,mode,sql
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Topic :: Database
|
|
17
|
+
Requires-Python: >=3.12
|
|
18
|
+
Requires-Dist: httpx>=0.27.0
|
|
19
|
+
Requires-Dist: mcp[cli]>=2.3.0
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# Mode Analytics MCP Server
|
|
23
|
+
|
|
24
|
+
An MCP server that lets you browse reports, manage queries, and fetch results from [Mode Analytics](https://mode.com) from any MCP-compatible AI assistant or agent.
|
|
25
|
+
|
|
26
|
+
## Tools
|
|
27
|
+
|
|
28
|
+
| Tool | Description |
|
|
29
|
+
|---|---|
|
|
30
|
+
| `list_data_sources` | List all database connections in the workspace |
|
|
31
|
+
| `list_spaces` | List all spaces (collections) |
|
|
32
|
+
| `list_reports` | List reports in a given space (archived ones hidden unless `include_archived=true`) |
|
|
33
|
+
| `search_reports` | Search for reports by name or description across all spaces (client-side; archived ones hidden unless `include_archived=true`) |
|
|
34
|
+
| `get_report` | Get report details including its SQL queries and who wrote each one |
|
|
35
|
+
| `list_report_runs` | List recent runs for a report (state, timestamps) |
|
|
36
|
+
| `get_run_results` | Fetch results from a specific or latest report run |
|
|
37
|
+
| `create_report` | Create a new report in a space, optionally with initial queries |
|
|
38
|
+
| `update_report` | Update a report's name, description, or move it to a different space |
|
|
39
|
+
| `archive_report` | Archive (hide) a report — reversible |
|
|
40
|
+
| `unarchive_report` | Restore an archived report |
|
|
41
|
+
| `delete_report` | Permanently delete a report (preview first, then confirm). Off unless `MODE_ENABLE_DELETES=true` |
|
|
42
|
+
| `add_query` | Add a SQL query to an existing report |
|
|
43
|
+
| `update_query` | Update a query's SQL, name, or data source |
|
|
44
|
+
| `delete_query` | Permanently delete a query (preview first, then confirm). Off unless `MODE_ENABLE_DELETES=true` |
|
|
45
|
+
|
|
46
|
+
## Setup
|
|
47
|
+
|
|
48
|
+
### 1. Get Mode API credentials
|
|
49
|
+
|
|
50
|
+
A Workspace admin creates the token in Mode under **Workspace Settings > Privacy & Security > API**: click the gear icon, choose **Create new API token**, and save the token and secret.
|
|
51
|
+
|
|
52
|
+
Mode no longer allows creating new *personal* API tokens (existing ones keep working). Workspace API tokens act with admin access to the whole Workspace, so treat them accordingly — see **Limitations / Safety** below.
|
|
53
|
+
|
|
54
|
+
### 2. Install uv
|
|
55
|
+
|
|
56
|
+
The server is launched with `uvx`, which comes with [uv](https://docs.astral.sh/uv/getting-started/installation/). Install it with `brew install uv` or `curl -LsSf https://astral.sh/uv/install.sh | sh`.
|
|
57
|
+
|
|
58
|
+
### 3. Add the server to your MCP client
|
|
59
|
+
|
|
60
|
+
The server runs over stdio via `uvx mode-mcp`. Add it to your MCP client's config; most clients use this `mcpServers` shape:
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
{
|
|
64
|
+
"mcpServers": {
|
|
65
|
+
"mode-analytics": {
|
|
66
|
+
"command": "uvx",
|
|
67
|
+
"args": ["mode-mcp"],
|
|
68
|
+
"env": {
|
|
69
|
+
"MODE_WORKSPACE": "your-workspace-name",
|
|
70
|
+
"MODE_API_TOKEN": "${MODE_API_TOKEN}",
|
|
71
|
+
"MODE_API_SECRET": "${MODE_API_SECRET}"
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`uvx` installs the package from PyPI on first run and reuses that version afterwards. To pick up new releases automatically, use `"args": ["mode-mcp@latest"]` instead.
|
|
79
|
+
|
|
80
|
+
The `${MODE_API_TOKEN}` / `${MODE_API_SECRET}` references are expanded from your shell environment by clients that support it, so no secrets land in the config file. Set them wherever you keep shell secrets, e.g. in `~/.zshrc`:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
export MODE_API_TOKEN="..."
|
|
84
|
+
export MODE_API_SECRET="..."
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Env var syntax differs by client
|
|
88
|
+
|
|
89
|
+
Some clients expand `${VAR}`, some use another syntax (e.g. `${env:VAR}`), and some don't expand references at all; check your client's docs. If a reference isn't expanded, the server stops at startup with an error naming the variable, rather than failing later with a confusing authentication error.
|
|
90
|
+
|
|
91
|
+
## Environment Variables
|
|
92
|
+
|
|
93
|
+
| Variable | Required | Description |
|
|
94
|
+
|---|---|---|
|
|
95
|
+
| `MODE_WORKSPACE` | Yes | Your Workspace's username, as in `app.mode.com/<workspace>/...` |
|
|
96
|
+
| `MODE_API_TOKEN` | Yes | API token |
|
|
97
|
+
| `MODE_API_SECRET` | Yes | API secret |
|
|
98
|
+
| `MODE_ENABLE_DELETES` | No | Set to `true` to enable `delete_report` and `delete_query`. Off by default |
|
|
99
|
+
| `MODE_READ_ONLY` | No | Set to `true` to disable every tool that writes to Mode (overrides `MODE_ENABLE_DELETES`) |
|
|
100
|
+
| `MODE_DISABLED_TOOLS` | No | Comma-separated tool names to disable, e.g. `create_report,update_query` |
|
|
101
|
+
|
|
102
|
+
### Choosing which tools are available
|
|
103
|
+
|
|
104
|
+
Disabled tools are never registered, so the agent doesn't see them at all.
|
|
105
|
+
|
|
106
|
+
- **Default:** everything except the two delete tools. The agent can browse, author, and archive, but can't permanently delete anything.
|
|
107
|
+
- **Allow deletes:** add `"MODE_ENABLE_DELETES": "true"` to `env`.
|
|
108
|
+
- **Read-only:** add `"MODE_READ_ONLY": "true"` to only allow browsing and fetching results.
|
|
109
|
+
- **Anything else:** list tool names in `MODE_DISABLED_TOOLS`.
|
|
110
|
+
|
|
111
|
+
A misspelled tool name in `MODE_DISABLED_TOOLS` stops the server with an error rather than silently leaving the tool enabled.
|
|
112
|
+
|
|
113
|
+
## Limitations / Safety
|
|
114
|
+
|
|
115
|
+
**No query execution.** The server deliberately does not expose a "run this query" tool. The Mode API has no endpoint for running a single query — the only way to execute SQL is `POST /reports/{token}/runs`, which runs **every** query in the report at once. On reports with many or expensive queries, that's slow, costly, and impossible to predict from the agent's side. Instead, the server focuses on **browsing**, **authoring**, and **fetching results**:
|
|
116
|
+
- Write and manage SQL queries through your agent, then run them in Mode's UI where costs and side effects are visible.
|
|
117
|
+
- Use `get_run_results` to pull results from completed runs back into the conversation.
|
|
118
|
+
|
|
119
|
+
**Deletes are permission-scoped, not owner-scoped.** The server does not check who created a report or query; what you can delete is whatever your API token is allowed to delete in Mode. With a personal token that's limited to your own permissions, but a Workspace API token has admin access and can delete **any** report or query in the Workspace. That's why the delete tools are off unless you set `MODE_ENABLE_DELETES=true`.
|
|
120
|
+
|
|
121
|
+
**Deletes can't skip the preview.** When enabled, `delete_report` and `delete_query` are always two calls:
|
|
122
|
+
1. The first call deletes nothing. It returns a preview (the report's name, whether it's already archived, and its queries with who wrote each one; or a query's name, author, and SQL) plus a short `confirm_code`. Mode's API doesn't say who created a report, only who wrote each query.
|
|
123
|
+
2. The delete only happens when the same tool is called again with that `confirm_code`. The code is a fingerprint of what the preview showed, so it can't be guessed, and if the report or query changed after the preview (renamed, SQL edited, queries added), the code no longer matches and the delete is refused.
|
|
124
|
+
|
|
125
|
+
**Archive instead of delete.** `archive_report` hides a report and `unarchive_report` brings it back. The delete tool tells the agent to suggest archiving first.
|
|
126
|
+
|
|
127
|
+
**Credentials never touch the committed config.** The `${MODE_API_TOKEN}` / `${MODE_API_SECRET}` references in your MCP config are expanded from your shell environment at launch (in clients that support it), so the file is safe to commit. Keep the raw secrets in env vars and don't commit them.
|
|
128
|
+
|
|
129
|
+
## Development
|
|
130
|
+
|
|
131
|
+
For maintainers (the source repo is private):
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
git clone git@github.com:nataliemona/mode_mcp.git
|
|
135
|
+
cd mode_mcp
|
|
136
|
+
uv sync --dev
|
|
137
|
+
uv run pytest
|
|
138
|
+
```
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
mode_mcp/__init__.py,sha256=qaMzzm7-EA7dJhGya2i7QsNsYU-xBA-5kSZ_tQwvYm4,53
|
|
2
|
+
mode_mcp/server.py,sha256=4ivmiG793aYkKT0XfW60A5vS7e-QOHF_3XcX6mUYP8Q,28597
|
|
3
|
+
mode_mcp-0.1.0.dist-info/METADATA,sha256=ILLy8rIo82fTAUzLzRpi9coOhpo3jZbvfs1r4g8smBs,7821
|
|
4
|
+
mode_mcp-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
5
|
+
mode_mcp-0.1.0.dist-info/entry_points.txt,sha256=10ql54QjedqwBSPNSb31BzJqCUTcF8MsQqcYxKNkAP4,43
|
|
6
|
+
mode_mcp-0.1.0.dist-info/licenses/LICENSE,sha256=ViVe6BNIQZgMdp5hKiAHoHzUZIu6kp8y1JbmgN2gTfM,1078
|
|
7
|
+
mode_mcp-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Natalie Mona Matthews
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|