stackless-mcp 1.0.0b2__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.
- stackless_mcp/__init__.py +22 -0
- stackless_mcp/auth.py +273 -0
- stackless_mcp/client.py +318 -0
- stackless_mcp/server.py +1158 -0
- stackless_mcp/tool_handlers.py +35 -0
- stackless_mcp-1.0.0b2.dist-info/METADATA +324 -0
- stackless_mcp-1.0.0b2.dist-info/RECORD +10 -0
- stackless_mcp-1.0.0b2.dist-info/WHEEL +4 -0
- stackless_mcp-1.0.0b2.dist-info/entry_points.txt +3 -0
- stackless_mcp-1.0.0b2.dist-info/licenses/LICENSE +201 -0
stackless_mcp/server.py
ADDED
|
@@ -0,0 +1,1158 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import atexit
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from dotenv import load_dotenv
|
|
13
|
+
from fastmcp import FastMCP
|
|
14
|
+
|
|
15
|
+
from stackless_mcp import __version__
|
|
16
|
+
from stackless_mcp import tool_handlers as handlers
|
|
17
|
+
from stackless_mcp.auth import CredentialStore, StoredCredential
|
|
18
|
+
from stackless_mcp.auth import login as mcp_login
|
|
19
|
+
from stackless_mcp.auth import logout as mcp_logout
|
|
20
|
+
from stackless_mcp.client import DEFAULT_TIMEOUT_SECONDS, StacklessClient
|
|
21
|
+
|
|
22
|
+
mcp = FastMCP("stackless-mcp")
|
|
23
|
+
|
|
24
|
+
_client: StacklessClient | None = None
|
|
25
|
+
_client_config: dict[str, str | float] = {}
|
|
26
|
+
|
|
27
|
+
TOOLSET_ALL = "all"
|
|
28
|
+
TOOLSET_CORE = "core"
|
|
29
|
+
OPTIONAL_TOOLSETS = (
|
|
30
|
+
"catalog",
|
|
31
|
+
"runs",
|
|
32
|
+
"transformations",
|
|
33
|
+
"semantic",
|
|
34
|
+
"dashboards",
|
|
35
|
+
"lifecycle",
|
|
36
|
+
)
|
|
37
|
+
SELECTABLE_TOOLSETS = (TOOLSET_CORE, *OPTIONAL_TOOLSETS)
|
|
38
|
+
VALID_TOOLSETS = frozenset((TOOLSET_ALL, TOOLSET_CORE, *OPTIONAL_TOOLSETS))
|
|
39
|
+
DEFAULT_TOOLSETS = TOOLSET_ALL
|
|
40
|
+
|
|
41
|
+
_ToolFn = Callable[..., Any]
|
|
42
|
+
_TOOL_REGISTRY: dict[str, tuple[_ToolFn, frozenset[str]]] = {}
|
|
43
|
+
_active_toolsets: frozenset[str] = frozenset()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _mcp_tool(*toolsets: str) -> Callable[[_ToolFn], _ToolFn]:
|
|
47
|
+
normalized = frozenset(toolsets)
|
|
48
|
+
if not normalized:
|
|
49
|
+
raise ValueError("MCP tools must declare at least one toolset")
|
|
50
|
+
unknown = normalized - VALID_TOOLSETS
|
|
51
|
+
if unknown:
|
|
52
|
+
raise ValueError(f"Unknown MCP toolsets: {', '.join(sorted(unknown))}")
|
|
53
|
+
|
|
54
|
+
def decorator(fn: _ToolFn) -> _ToolFn:
|
|
55
|
+
if fn.__name__ in _TOOL_REGISTRY:
|
|
56
|
+
raise ValueError(f"Duplicate MCP tool registration: {fn.__name__}")
|
|
57
|
+
_TOOL_REGISTRY[fn.__name__] = (fn, normalized)
|
|
58
|
+
return fn
|
|
59
|
+
|
|
60
|
+
return decorator
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def parse_toolsets(value: str | None) -> frozenset[str]:
|
|
64
|
+
raw = value or DEFAULT_TOOLSETS
|
|
65
|
+
requested = {item.strip() for item in raw.split(",") if item.strip()}
|
|
66
|
+
if not requested or TOOLSET_ALL in requested:
|
|
67
|
+
if requested - {TOOLSET_ALL}:
|
|
68
|
+
raise ValueError("MCP toolset 'all' cannot be combined with other toolsets")
|
|
69
|
+
return frozenset((TOOLSET_CORE, *OPTIONAL_TOOLSETS))
|
|
70
|
+
unknown = requested - VALID_TOOLSETS
|
|
71
|
+
if unknown:
|
|
72
|
+
raise ValueError(
|
|
73
|
+
"Unknown MCP toolsets: "
|
|
74
|
+
+ ", ".join(sorted(unknown))
|
|
75
|
+
+ ". Valid values are: "
|
|
76
|
+
+ ", ".join((TOOLSET_ALL, *SELECTABLE_TOOLSETS))
|
|
77
|
+
)
|
|
78
|
+
return frozenset((TOOLSET_CORE, *requested))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def tool_names_for_toolsets(value: str | None) -> set[str]:
|
|
82
|
+
selected = parse_toolsets(value)
|
|
83
|
+
return {
|
|
84
|
+
name for name, (_, toolsets) in _TOOL_REGISTRY.items() if toolsets & selected
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _workflow_tool_names_for_toolsets(value: str | None) -> set[str]:
|
|
89
|
+
return tool_names_for_toolsets(value) - _core_tool_names()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _core_tool_names() -> set[str]:
|
|
93
|
+
return {
|
|
94
|
+
name
|
|
95
|
+
for name, (_, toolsets) in _TOOL_REGISTRY.items()
|
|
96
|
+
if TOOLSET_CORE in toolsets
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _validate_capabilities(payload: Any) -> tuple[str, frozenset[str]]:
|
|
101
|
+
if not isinstance(payload, dict):
|
|
102
|
+
raise ValueError("capabilities response is not an object")
|
|
103
|
+
server_version = payload.get("server_version")
|
|
104
|
+
tools = payload.get("tools")
|
|
105
|
+
if not isinstance(server_version, str) or not server_version:
|
|
106
|
+
raise ValueError("capabilities response is missing server_version")
|
|
107
|
+
if not isinstance(tools, list) or not all(isinstance(tool, str) for tool in tools):
|
|
108
|
+
raise ValueError("capabilities response tools must be a list of strings")
|
|
109
|
+
return server_version, frozenset(tools)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def registered_tool_names() -> set[str]:
|
|
113
|
+
return {tool.name for tool in mcp.list_tools()}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def configure_toolsets(
|
|
117
|
+
value: str | None = DEFAULT_TOOLSETS,
|
|
118
|
+
*,
|
|
119
|
+
server_supported_tools: frozenset[str] | set[str] | None = None,
|
|
120
|
+
) -> frozenset[str]:
|
|
121
|
+
global mcp, _active_toolsets
|
|
122
|
+
selected = parse_toolsets(value)
|
|
123
|
+
next_mcp = FastMCP("stackless-mcp")
|
|
124
|
+
for name, (fn, toolsets) in _TOOL_REGISTRY.items():
|
|
125
|
+
is_core_tool = TOOLSET_CORE in toolsets
|
|
126
|
+
is_supported = server_supported_tools is None or name in server_supported_tools
|
|
127
|
+
if is_core_tool or (toolsets & selected and is_supported):
|
|
128
|
+
next_mcp.add_tool(fn, tags=set(toolsets))
|
|
129
|
+
mcp = next_mcp
|
|
130
|
+
_active_toolsets = selected
|
|
131
|
+
return selected
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def configure_toolsets_from_server(
|
|
135
|
+
value: str | None = DEFAULT_TOOLSETS,
|
|
136
|
+
) -> frozenset[str]:
|
|
137
|
+
try:
|
|
138
|
+
_, server_supported_tools = _validate_capabilities(get_client().capabilities())
|
|
139
|
+
except Exception as exc:
|
|
140
|
+
print(
|
|
141
|
+
"Warning: Stackless server did not report MCP capabilities; "
|
|
142
|
+
f"registering core tools only. Discovery error: {exc}",
|
|
143
|
+
file=sys.stderr,
|
|
144
|
+
)
|
|
145
|
+
server_supported_tools = frozenset()
|
|
146
|
+
return configure_toolsets(value, server_supported_tools=server_supported_tools)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def configure_client(
|
|
150
|
+
*,
|
|
151
|
+
base_url: str,
|
|
152
|
+
token: str,
|
|
153
|
+
cookie: str,
|
|
154
|
+
timeout: float,
|
|
155
|
+
auth_storage: str = "keyring",
|
|
156
|
+
) -> None:
|
|
157
|
+
global _client, _client_config
|
|
158
|
+
if _client is not None:
|
|
159
|
+
_client.close()
|
|
160
|
+
_client = None
|
|
161
|
+
_client_config = {
|
|
162
|
+
"base_url": base_url,
|
|
163
|
+
"token": token,
|
|
164
|
+
"cookie": cookie,
|
|
165
|
+
"timeout": timeout,
|
|
166
|
+
"auth_storage": auth_storage,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _configured_client() -> StacklessClient:
|
|
171
|
+
base_url = str(_client_config.get("base_url") or "")
|
|
172
|
+
token = str(_client_config.get("token") or "")
|
|
173
|
+
cookie = str(_client_config.get("cookie") or "")
|
|
174
|
+
timeout = float(_client_config.get("timeout") or DEFAULT_TIMEOUT_SECONDS)
|
|
175
|
+
auth_storage = str(_client_config.get("auth_storage") or "keyring")
|
|
176
|
+
|
|
177
|
+
if not base_url:
|
|
178
|
+
raise ValueError(
|
|
179
|
+
"Stackless base URL is required. Pass --base-url or " "STACKLESS_BASE_URL."
|
|
180
|
+
)
|
|
181
|
+
if not token and not cookie:
|
|
182
|
+
credential = CredentialStore(auth_storage).load(base_url)
|
|
183
|
+
if credential:
|
|
184
|
+
token = credential.token
|
|
185
|
+
if not token and not cookie:
|
|
186
|
+
raise ValueError(
|
|
187
|
+
"Stackless auth is required. Run `stackless-mcp login --base-url ...`, "
|
|
188
|
+
"or pass --token/STACKLESS_TOKEN or --cookie/STACKLESS_COOKIE."
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
return StacklessClient(
|
|
192
|
+
base_url=base_url,
|
|
193
|
+
token=token,
|
|
194
|
+
cookie=cookie,
|
|
195
|
+
timeout=timeout,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def get_client() -> StacklessClient:
|
|
200
|
+
global _client
|
|
201
|
+
if _client is None:
|
|
202
|
+
if not _client_config:
|
|
203
|
+
configure_client(
|
|
204
|
+
base_url=os.environ.get("STACKLESS_BASE_URL", ""),
|
|
205
|
+
token=os.environ.get("STACKLESS_TOKEN", ""),
|
|
206
|
+
cookie=os.environ.get("STACKLESS_COOKIE", ""),
|
|
207
|
+
timeout=float(
|
|
208
|
+
os.environ.get(
|
|
209
|
+
"STACKLESS_TIMEOUT_SECONDS",
|
|
210
|
+
DEFAULT_TIMEOUT_SECONDS,
|
|
211
|
+
)
|
|
212
|
+
),
|
|
213
|
+
auth_storage=os.environ.get("STACKLESS_AUTH_STORAGE", "keyring"),
|
|
214
|
+
)
|
|
215
|
+
_client = _configured_client()
|
|
216
|
+
assert _client is not None
|
|
217
|
+
return _client
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@atexit.register
|
|
221
|
+
def _close_client() -> None:
|
|
222
|
+
if _client is not None:
|
|
223
|
+
_client.close()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@_mcp_tool(TOOLSET_CORE)
|
|
227
|
+
def check_stackless_connection() -> dict:
|
|
228
|
+
"""Check Stackless API auth with the configured Bearer token or ALB cookies."""
|
|
229
|
+
return handlers.check_stackless_connection(get_client())
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _run_workflow(
|
|
233
|
+
tool_name: str,
|
|
234
|
+
arguments: dict[str, Any],
|
|
235
|
+
idempotency_key: str | None = None,
|
|
236
|
+
confirmation: dict[str, Any] | None = None,
|
|
237
|
+
) -> dict:
|
|
238
|
+
return handlers.run_mcp_workflow(
|
|
239
|
+
get_client(),
|
|
240
|
+
tool_name,
|
|
241
|
+
arguments,
|
|
242
|
+
idempotency_key,
|
|
243
|
+
confirmation,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@_mcp_tool("catalog")
|
|
248
|
+
def find_relevant_data(
|
|
249
|
+
question: str,
|
|
250
|
+
filters: dict[str, Any] | None = None,
|
|
251
|
+
limit: int = 10,
|
|
252
|
+
) -> dict:
|
|
253
|
+
"""Find catalog assets relevant to a business question."""
|
|
254
|
+
return _run_workflow(
|
|
255
|
+
"find_relevant_data",
|
|
256
|
+
{"question": question, "filters": filters or {}, "limit": limit},
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@_mcp_tool("catalog")
|
|
261
|
+
def explain_stackless_asset(asset_ref: str, expand: bool = False) -> dict:
|
|
262
|
+
"""Explain a Stackless catalog asset, columns, and lineage handles."""
|
|
263
|
+
return _run_workflow(
|
|
264
|
+
"explain_stackless_asset",
|
|
265
|
+
{"asset_ref": asset_ref, "expand": expand},
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@_mcp_tool("catalog")
|
|
270
|
+
def list_stackless_assets(
|
|
271
|
+
asset_type: str | None = None,
|
|
272
|
+
domain: str | None = None,
|
|
273
|
+
tag: str | None = None,
|
|
274
|
+
limit: int = 25,
|
|
275
|
+
) -> dict:
|
|
276
|
+
"""List visible catalog assets filtered by type, domain, or tag."""
|
|
277
|
+
return _run_workflow(
|
|
278
|
+
"list_stackless_assets",
|
|
279
|
+
{
|
|
280
|
+
"asset_type": asset_type,
|
|
281
|
+
"domain": domain,
|
|
282
|
+
"tag": tag,
|
|
283
|
+
"limit": limit,
|
|
284
|
+
},
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@_mcp_tool("catalog")
|
|
289
|
+
def trace_stackless_lineage(
|
|
290
|
+
asset_ref: str,
|
|
291
|
+
direction: str = "upstream",
|
|
292
|
+
max_depth: int | None = None,
|
|
293
|
+
) -> dict:
|
|
294
|
+
"""Trace upstream or downstream lineage for a visible catalog asset."""
|
|
295
|
+
return _run_workflow(
|
|
296
|
+
"trace_stackless_lineage",
|
|
297
|
+
{
|
|
298
|
+
"asset_ref": asset_ref,
|
|
299
|
+
"direction": direction,
|
|
300
|
+
"max_depth": max_depth,
|
|
301
|
+
},
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@_mcp_tool("catalog")
|
|
306
|
+
def get_stackless_column_details(asset_ref: str) -> dict:
|
|
307
|
+
"""Get column metadata for a visible catalog asset."""
|
|
308
|
+
return _run_workflow(
|
|
309
|
+
"get_stackless_column_details",
|
|
310
|
+
{"asset_ref": asset_ref},
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
@_mcp_tool("catalog")
|
|
315
|
+
def refresh_stackless_catalog(mode: str = "light") -> dict:
|
|
316
|
+
"""Refresh the Stackless catalog snapshot in light or full mode."""
|
|
317
|
+
return _run_workflow("refresh_stackless_catalog", {"mode": mode})
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@_mcp_tool("catalog")
|
|
321
|
+
def get_stackless_connector_status(
|
|
322
|
+
connector_ref: str | None = None,
|
|
323
|
+
connector_name: str | None = None,
|
|
324
|
+
) -> dict:
|
|
325
|
+
"""Get Fivetran connector sync status from the catalog snapshot."""
|
|
326
|
+
return _run_workflow(
|
|
327
|
+
"get_stackless_connector_status",
|
|
328
|
+
{
|
|
329
|
+
"connector_ref": connector_ref,
|
|
330
|
+
"connector_name": connector_name,
|
|
331
|
+
},
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
@_mcp_tool("catalog")
|
|
336
|
+
def get_stackless_asset_freshness(
|
|
337
|
+
asset_ref: str,
|
|
338
|
+
max_depth: int | None = None,
|
|
339
|
+
) -> dict:
|
|
340
|
+
"""Summarize upstream freshness for a visible catalog asset."""
|
|
341
|
+
return _run_workflow(
|
|
342
|
+
"get_stackless_asset_freshness",
|
|
343
|
+
{"asset_ref": asset_ref, "max_depth": max_depth},
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@_mcp_tool("catalog")
|
|
348
|
+
def list_stackless_snowflake_schemas() -> dict:
|
|
349
|
+
"""List Snowflake schemas visible in the catalog snapshot."""
|
|
350
|
+
return _run_workflow("list_stackless_snowflake_schemas", {})
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
@_mcp_tool("catalog")
|
|
354
|
+
def query_snowflake(sql: str, schema_name: str = "") -> dict:
|
|
355
|
+
"""Run direct read-only Snowflake SQL through Stackless guardrails."""
|
|
356
|
+
return _run_workflow(
|
|
357
|
+
"query_snowflake",
|
|
358
|
+
{"sql": sql, "schema_name": schema_name},
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@_mcp_tool("runs")
|
|
363
|
+
def list_stackless_runs(
|
|
364
|
+
status: str | None = None,
|
|
365
|
+
limit: int = 20,
|
|
366
|
+
) -> dict:
|
|
367
|
+
"""List recent Stackless scheduler runs with stable run refs."""
|
|
368
|
+
return _run_workflow("list_stackless_runs", {"status": status, "limit": limit})
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
@_mcp_tool("runs")
|
|
372
|
+
def diagnose_stackless_run(
|
|
373
|
+
run_ref: str | None = None,
|
|
374
|
+
job_name: str | None = None,
|
|
375
|
+
) -> dict:
|
|
376
|
+
"""Diagnose a Stackless run from run ref or job name with redacted logs."""
|
|
377
|
+
return _run_workflow(
|
|
378
|
+
"diagnose_stackless_run",
|
|
379
|
+
{"run_ref": run_ref, "job_name": job_name},
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@_mcp_tool("transformations")
|
|
384
|
+
def draft_transformation_model(
|
|
385
|
+
goal: str,
|
|
386
|
+
parent_refs: list[dict[str, Any]] | None = None,
|
|
387
|
+
spec: dict[str, Any] | None = None,
|
|
388
|
+
name: str | None = None,
|
|
389
|
+
title: str | None = None,
|
|
390
|
+
description: str | None = None,
|
|
391
|
+
idempotency_key: str | None = None,
|
|
392
|
+
) -> dict:
|
|
393
|
+
"""Draft or scaffold a Stackless Transformation Model."""
|
|
394
|
+
return _run_workflow(
|
|
395
|
+
"draft_transformation_model",
|
|
396
|
+
{
|
|
397
|
+
"goal": goal,
|
|
398
|
+
"parent_refs": parent_refs or [],
|
|
399
|
+
"spec": spec,
|
|
400
|
+
"name": name,
|
|
401
|
+
"title": title,
|
|
402
|
+
"description": description,
|
|
403
|
+
},
|
|
404
|
+
idempotency_key,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
@_mcp_tool("transformations")
|
|
409
|
+
def validate_transformation_model_draft(draft_ref: str) -> dict:
|
|
410
|
+
"""Validate a Transformation Model draft without previewing or publishing."""
|
|
411
|
+
return _run_workflow(
|
|
412
|
+
"validate_transformation_model_draft",
|
|
413
|
+
{"draft_ref": draft_ref},
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
@_mcp_tool("transformations")
|
|
418
|
+
def preview_transformation_model_draft(
|
|
419
|
+
draft_ref: str,
|
|
420
|
+
idempotency_key: str,
|
|
421
|
+
mode: str = "view_preview",
|
|
422
|
+
) -> dict:
|
|
423
|
+
"""Queue a guarded preview for a Transformation Model draft."""
|
|
424
|
+
return _run_workflow(
|
|
425
|
+
"preview_transformation_model_draft",
|
|
426
|
+
{"draft_ref": draft_ref, "mode": mode},
|
|
427
|
+
idempotency_key,
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
@_mcp_tool("semantic")
|
|
432
|
+
def draft_semantic_model(
|
|
433
|
+
source_ref: str | None = None,
|
|
434
|
+
intent: str | None = None,
|
|
435
|
+
model: dict[str, Any] | None = None,
|
|
436
|
+
idempotency_key: str | None = None,
|
|
437
|
+
) -> dict:
|
|
438
|
+
"""Draft or scaffold a Stackless Semantic Model."""
|
|
439
|
+
return _run_workflow(
|
|
440
|
+
"draft_semantic_model",
|
|
441
|
+
{"source_ref": source_ref, "intent": intent, "model": model},
|
|
442
|
+
idempotency_key,
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
@_mcp_tool("semantic")
|
|
447
|
+
def validate_semantic_model_draft(
|
|
448
|
+
model_ref: str | None = None,
|
|
449
|
+
model: dict[str, Any] | None = None,
|
|
450
|
+
) -> dict:
|
|
451
|
+
"""Validate a Semantic Model draft or payload without publishing."""
|
|
452
|
+
return _run_workflow(
|
|
453
|
+
"validate_semantic_model_draft",
|
|
454
|
+
{"model_ref": model_ref, "model": model},
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
@_mcp_tool("semantic")
|
|
459
|
+
def preview_semantic_model_query(
|
|
460
|
+
model_ref: str,
|
|
461
|
+
query: dict[str, Any] | None = None,
|
|
462
|
+
) -> dict:
|
|
463
|
+
"""Run a Semantic Model preview query through Stackless guardrails."""
|
|
464
|
+
return _run_workflow(
|
|
465
|
+
"preview_semantic_model_query",
|
|
466
|
+
{"model_ref": model_ref, "query": query or {}},
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
@_mcp_tool("semantic")
|
|
471
|
+
def list_stackless_semantic_models(
|
|
472
|
+
status: str | None = None,
|
|
473
|
+
source_table_asset: str | None = None,
|
|
474
|
+
limit: int = 25,
|
|
475
|
+
) -> dict:
|
|
476
|
+
"""List Semantic Models visible in the Stackless webapp."""
|
|
477
|
+
return _run_workflow(
|
|
478
|
+
"list_stackless_semantic_models",
|
|
479
|
+
{
|
|
480
|
+
"status": status,
|
|
481
|
+
"source_table_asset": source_table_asset,
|
|
482
|
+
"limit": limit,
|
|
483
|
+
},
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
@_mcp_tool("semantic")
|
|
488
|
+
def explain_stackless_semantic_model(model_ref: str) -> dict:
|
|
489
|
+
"""Explain a Stackless Semantic Model and its queryable members."""
|
|
490
|
+
return _run_workflow(
|
|
491
|
+
"explain_stackless_semantic_model",
|
|
492
|
+
{"model_ref": model_ref},
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
@_mcp_tool("dashboards")
|
|
497
|
+
def list_stackless_dashboards(
|
|
498
|
+
status: str | None = None,
|
|
499
|
+
workspace_type: str | None = None,
|
|
500
|
+
provider: str | None = None,
|
|
501
|
+
conversation_id: str | None = None,
|
|
502
|
+
limit: int = 25,
|
|
503
|
+
) -> dict:
|
|
504
|
+
"""List dashboards visible in the Stackless webapp."""
|
|
505
|
+
return _run_workflow(
|
|
506
|
+
"list_stackless_dashboards",
|
|
507
|
+
{
|
|
508
|
+
"status": status,
|
|
509
|
+
"workspace_type": workspace_type,
|
|
510
|
+
"provider": provider,
|
|
511
|
+
"conversation_id": conversation_id,
|
|
512
|
+
"limit": limit,
|
|
513
|
+
},
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
@_mcp_tool("dashboards")
|
|
518
|
+
def get_stackless_dashboard(dashboard_ref: str) -> dict:
|
|
519
|
+
"""Get a dashboard, its custom spec, and the recommended MCP workflow."""
|
|
520
|
+
return _run_workflow(
|
|
521
|
+
"get_stackless_dashboard",
|
|
522
|
+
{"dashboard_ref": dashboard_ref},
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
@_mcp_tool("dashboards")
|
|
527
|
+
def diagnose_stackless_dashboard(
|
|
528
|
+
dashboard_ref: str,
|
|
529
|
+
request: dict[str, Any] | None = None,
|
|
530
|
+
max_widgets: int = 25,
|
|
531
|
+
) -> dict:
|
|
532
|
+
"""Diagnose custom dashboard widget hydration failures through Stackless/Cube."""
|
|
533
|
+
return _run_workflow(
|
|
534
|
+
"diagnose_stackless_dashboard",
|
|
535
|
+
{
|
|
536
|
+
"dashboard_ref": dashboard_ref,
|
|
537
|
+
"request": request or {},
|
|
538
|
+
"max_widgets": max_widgets,
|
|
539
|
+
},
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
@_mcp_tool("dashboards")
|
|
544
|
+
def draft_dashboard_from_goal(
|
|
545
|
+
goal: str,
|
|
546
|
+
model_refs: list[str] | None = None,
|
|
547
|
+
spec: dict[str, Any] | None = None,
|
|
548
|
+
title: str | None = None,
|
|
549
|
+
description: str | None = None,
|
|
550
|
+
idempotency_key: str | None = None,
|
|
551
|
+
) -> dict:
|
|
552
|
+
"""Draft or scaffold a Stackless custom dashboard."""
|
|
553
|
+
return _run_workflow(
|
|
554
|
+
"draft_dashboard_from_goal",
|
|
555
|
+
{
|
|
556
|
+
"goal": goal,
|
|
557
|
+
"model_refs": model_refs or [],
|
|
558
|
+
"spec": spec,
|
|
559
|
+
"title": title,
|
|
560
|
+
"description": description,
|
|
561
|
+
},
|
|
562
|
+
idempotency_key,
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
@_mcp_tool("dashboards")
|
|
567
|
+
def fork_stackless_dashboard(
|
|
568
|
+
dashboard_ref: str,
|
|
569
|
+
idempotency_key: str,
|
|
570
|
+
title: str | None = None,
|
|
571
|
+
description: str | None = None,
|
|
572
|
+
conversation_id: str | None = None,
|
|
573
|
+
) -> dict:
|
|
574
|
+
"""Fork a published Stackless custom dashboard into a private editable draft."""
|
|
575
|
+
return _run_workflow(
|
|
576
|
+
"fork_stackless_dashboard",
|
|
577
|
+
{
|
|
578
|
+
"dashboard_ref": dashboard_ref,
|
|
579
|
+
"title": title,
|
|
580
|
+
"description": description,
|
|
581
|
+
"conversation_id": conversation_id,
|
|
582
|
+
},
|
|
583
|
+
idempotency_key,
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
@_mcp_tool("dashboards")
|
|
588
|
+
def update_stackless_dashboard_draft(
|
|
589
|
+
dashboard_ref: str,
|
|
590
|
+
idempotency_key: str,
|
|
591
|
+
title: str | None = None,
|
|
592
|
+
description: str | None = None,
|
|
593
|
+
spec: dict[str, Any] | None = None,
|
|
594
|
+
conversation_id: str | None = None,
|
|
595
|
+
) -> dict:
|
|
596
|
+
"""Update a private Stackless custom dashboard draft after validation."""
|
|
597
|
+
return _run_workflow(
|
|
598
|
+
"update_stackless_dashboard_draft",
|
|
599
|
+
{
|
|
600
|
+
"dashboard_ref": dashboard_ref,
|
|
601
|
+
"title": title,
|
|
602
|
+
"description": description,
|
|
603
|
+
"spec": spec,
|
|
604
|
+
"conversation_id": conversation_id,
|
|
605
|
+
},
|
|
606
|
+
idempotency_key,
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
@_mcp_tool("dashboards")
|
|
611
|
+
def hydrate_dashboard(
|
|
612
|
+
dashboard_ref: str,
|
|
613
|
+
request: dict[str, Any] | None = None,
|
|
614
|
+
) -> dict:
|
|
615
|
+
"""Hydrate custom dashboard widgets through Stackless/Cube guardrails."""
|
|
616
|
+
return _run_workflow(
|
|
617
|
+
"hydrate_dashboard",
|
|
618
|
+
{"dashboard_ref": dashboard_ref, "request": request or {}},
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
@_mcp_tool("dashboards")
|
|
623
|
+
def validate_dashboard_draft(
|
|
624
|
+
dashboard_ref: str | None = None,
|
|
625
|
+
spec: dict[str, Any] | None = None,
|
|
626
|
+
) -> dict:
|
|
627
|
+
"""Validate a custom dashboard draft or spec without publishing."""
|
|
628
|
+
return _run_workflow(
|
|
629
|
+
"validate_dashboard_draft",
|
|
630
|
+
{"dashboard_ref": dashboard_ref, "spec": spec},
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
@_mcp_tool("dashboards")
|
|
635
|
+
def preview_dashboard(
|
|
636
|
+
dashboard_ref: str,
|
|
637
|
+
request: dict[str, Any] | None = None,
|
|
638
|
+
) -> dict:
|
|
639
|
+
"""Hydrate a custom dashboard preview through Stackless/Cube."""
|
|
640
|
+
return _run_workflow(
|
|
641
|
+
"preview_dashboard",
|
|
642
|
+
{"dashboard_ref": dashboard_ref, "request": request or {}},
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
@_mcp_tool("lifecycle")
|
|
647
|
+
def diff_stackless_draft(draft_ref: str, mode: str = "auto") -> dict:
|
|
648
|
+
"""Inspect draft diff/review output before risky publication."""
|
|
649
|
+
return _run_workflow("diff_stackless_draft", {"draft_ref": draft_ref, "mode": mode})
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
@_mcp_tool("lifecycle")
|
|
653
|
+
def publish_stackless_draft(
|
|
654
|
+
draft_ref: str,
|
|
655
|
+
confirmation: dict[str, Any],
|
|
656
|
+
idempotency_key: str,
|
|
657
|
+
publish_context: dict[str, Any] | None = None,
|
|
658
|
+
) -> dict:
|
|
659
|
+
"""Publish a Stackless draft only after explicit confirmation."""
|
|
660
|
+
return _run_workflow(
|
|
661
|
+
"publish_stackless_draft",
|
|
662
|
+
{"draft_ref": draft_ref, "publish_context": publish_context or {}},
|
|
663
|
+
idempotency_key,
|
|
664
|
+
confirmation,
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
@_mcp_tool("runs", "lifecycle")
|
|
669
|
+
def cancel_stackless_run(
|
|
670
|
+
run_ref: str,
|
|
671
|
+
confirmation: dict[str, Any],
|
|
672
|
+
idempotency_key: str,
|
|
673
|
+
) -> dict:
|
|
674
|
+
"""Cancel a pending or running Stackless run after explicit confirmation."""
|
|
675
|
+
return _run_workflow(
|
|
676
|
+
"cancel_stackless_run",
|
|
677
|
+
{"run_ref": run_ref},
|
|
678
|
+
idempotency_key,
|
|
679
|
+
confirmation,
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
@_mcp_tool("lifecycle")
|
|
684
|
+
def unpublish_stackless_resource(
|
|
685
|
+
resource_ref: str,
|
|
686
|
+
confirmation: dict[str, Any],
|
|
687
|
+
idempotency_key: str,
|
|
688
|
+
) -> dict:
|
|
689
|
+
"""Unpublish a Stackless resource after explicit confirmation."""
|
|
690
|
+
return _run_workflow(
|
|
691
|
+
"unpublish_stackless_resource",
|
|
692
|
+
{"resource_ref": resource_ref},
|
|
693
|
+
idempotency_key,
|
|
694
|
+
confirmation,
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
@_mcp_tool("lifecycle")
|
|
699
|
+
def delete_stackless_draft(
|
|
700
|
+
draft_ref: str,
|
|
701
|
+
confirmation: dict[str, Any],
|
|
702
|
+
idempotency_key: str,
|
|
703
|
+
) -> dict:
|
|
704
|
+
"""Delete a draft resource after explicit confirmation."""
|
|
705
|
+
return _run_workflow(
|
|
706
|
+
"delete_stackless_draft",
|
|
707
|
+
{"draft_ref": draft_ref},
|
|
708
|
+
idempotency_key,
|
|
709
|
+
confirmation,
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
@_mcp_tool(TOOLSET_CORE)
|
|
714
|
+
def get_stackless_operation(operation_id: str) -> dict:
|
|
715
|
+
"""Get MCP operation status from a returned operation_id."""
|
|
716
|
+
return handlers.get_mcp_operation(get_client(), operation_id)
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
configure_toolsets(os.environ.get("STACKLESS_TOOLSETS", DEFAULT_TOOLSETS))
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _parse_expires_at(value: str | None) -> datetime | None:
|
|
723
|
+
if not value:
|
|
724
|
+
return None
|
|
725
|
+
normalized = value.replace("Z", "+00:00")
|
|
726
|
+
parsed = datetime.fromisoformat(normalized)
|
|
727
|
+
if parsed.tzinfo is None:
|
|
728
|
+
return parsed.replace(tzinfo=timezone.utc)
|
|
729
|
+
return parsed.astimezone(timezone.utc)
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def _stored_credential_diagnostic(
|
|
733
|
+
*,
|
|
734
|
+
base_url: str,
|
|
735
|
+
auth_storage: str,
|
|
736
|
+
) -> tuple[StoredCredential | None, dict[str, Any], list[str]]:
|
|
737
|
+
try:
|
|
738
|
+
credential = CredentialStore(auth_storage).load(base_url)
|
|
739
|
+
except Exception as exc:
|
|
740
|
+
return (
|
|
741
|
+
None,
|
|
742
|
+
{
|
|
743
|
+
"mode": "stored_token",
|
|
744
|
+
"storage_backend": auth_storage,
|
|
745
|
+
"credential_present": False,
|
|
746
|
+
"token_present": False,
|
|
747
|
+
"expires_at": None,
|
|
748
|
+
"expiry_status": "unknown",
|
|
749
|
+
"error": str(exc),
|
|
750
|
+
},
|
|
751
|
+
[f"auth storage check failed: {exc}"],
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
if credential is None:
|
|
755
|
+
return (
|
|
756
|
+
None,
|
|
757
|
+
{
|
|
758
|
+
"mode": "stored_token",
|
|
759
|
+
"storage_backend": auth_storage,
|
|
760
|
+
"credential_present": False,
|
|
761
|
+
"token_present": False,
|
|
762
|
+
"expires_at": None,
|
|
763
|
+
"expiry_status": "missing",
|
|
764
|
+
},
|
|
765
|
+
["no token or cookie is configured"],
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
expires_at = credential.expires_at
|
|
769
|
+
expiry_status = "unknown"
|
|
770
|
+
errors: list[str] = []
|
|
771
|
+
if expires_at:
|
|
772
|
+
try:
|
|
773
|
+
expires_at_dt = _parse_expires_at(expires_at)
|
|
774
|
+
except ValueError:
|
|
775
|
+
expiry_status = "invalid"
|
|
776
|
+
errors.append("stored token expiry is invalid")
|
|
777
|
+
else:
|
|
778
|
+
if expires_at_dt is not None and expires_at_dt <= datetime.now(
|
|
779
|
+
timezone.utc
|
|
780
|
+
):
|
|
781
|
+
expiry_status = "expired"
|
|
782
|
+
errors.append("stored token is expired")
|
|
783
|
+
else:
|
|
784
|
+
expiry_status = "unexpired"
|
|
785
|
+
|
|
786
|
+
return (
|
|
787
|
+
credential,
|
|
788
|
+
{
|
|
789
|
+
"mode": "stored_token",
|
|
790
|
+
"storage_backend": auth_storage,
|
|
791
|
+
"credential_present": True,
|
|
792
|
+
"token_present": bool(credential.token),
|
|
793
|
+
"expires_at": expires_at,
|
|
794
|
+
"expiry_status": expiry_status,
|
|
795
|
+
"user_id": credential.user_id,
|
|
796
|
+
},
|
|
797
|
+
errors,
|
|
798
|
+
)
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def _auth_diagnostic(
|
|
802
|
+
*,
|
|
803
|
+
base_url: str,
|
|
804
|
+
auth_storage: str,
|
|
805
|
+
token: str | None = None,
|
|
806
|
+
cookie: str | None = None,
|
|
807
|
+
) -> tuple[str, str, dict[str, Any], list[str]]:
|
|
808
|
+
cli_token = (token or "").strip()
|
|
809
|
+
cli_cookie = (cookie or "").strip()
|
|
810
|
+
if cli_token:
|
|
811
|
+
return (
|
|
812
|
+
cli_token,
|
|
813
|
+
"",
|
|
814
|
+
{
|
|
815
|
+
"mode": "cli_token",
|
|
816
|
+
"storage_backend": auth_storage,
|
|
817
|
+
"credential_present": True,
|
|
818
|
+
"token_present": True,
|
|
819
|
+
"expires_at": None,
|
|
820
|
+
"expiry_status": "unknown",
|
|
821
|
+
},
|
|
822
|
+
[],
|
|
823
|
+
)
|
|
824
|
+
if cli_cookie:
|
|
825
|
+
return (
|
|
826
|
+
"",
|
|
827
|
+
cli_cookie,
|
|
828
|
+
{
|
|
829
|
+
"mode": "cli_cookie",
|
|
830
|
+
"storage_backend": auth_storage,
|
|
831
|
+
"credential_present": True,
|
|
832
|
+
"token_present": False,
|
|
833
|
+
"expires_at": None,
|
|
834
|
+
"expiry_status": "unknown",
|
|
835
|
+
},
|
|
836
|
+
[],
|
|
837
|
+
)
|
|
838
|
+
|
|
839
|
+
env_token = (os.environ.get("STACKLESS_TOKEN") or "").strip()
|
|
840
|
+
env_cookie = (os.environ.get("STACKLESS_COOKIE") or "").strip()
|
|
841
|
+
if env_token:
|
|
842
|
+
return (
|
|
843
|
+
env_token,
|
|
844
|
+
"",
|
|
845
|
+
{
|
|
846
|
+
"mode": "env_token",
|
|
847
|
+
"storage_backend": auth_storage,
|
|
848
|
+
"credential_present": True,
|
|
849
|
+
"token_present": True,
|
|
850
|
+
"expires_at": None,
|
|
851
|
+
"expiry_status": "unknown",
|
|
852
|
+
},
|
|
853
|
+
[],
|
|
854
|
+
)
|
|
855
|
+
if env_cookie:
|
|
856
|
+
return (
|
|
857
|
+
"",
|
|
858
|
+
env_cookie,
|
|
859
|
+
{
|
|
860
|
+
"mode": "env_cookie",
|
|
861
|
+
"storage_backend": auth_storage,
|
|
862
|
+
"credential_present": True,
|
|
863
|
+
"token_present": False,
|
|
864
|
+
"expires_at": None,
|
|
865
|
+
"expiry_status": "unknown",
|
|
866
|
+
},
|
|
867
|
+
[],
|
|
868
|
+
)
|
|
869
|
+
|
|
870
|
+
credential, auth, errors = _stored_credential_diagnostic(
|
|
871
|
+
base_url=base_url,
|
|
872
|
+
auth_storage=auth_storage,
|
|
873
|
+
)
|
|
874
|
+
return (credential.token if credential else "", "", auth, errors)
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def _doctor(args: argparse.Namespace) -> tuple[int, dict[str, Any]]:
|
|
878
|
+
base_url = (args.base_url or "").rstrip("/")
|
|
879
|
+
errors: list[str] = []
|
|
880
|
+
try:
|
|
881
|
+
selected_toolsets = sorted(parse_toolsets(args.toolsets))
|
|
882
|
+
except ValueError as exc:
|
|
883
|
+
selected_toolsets = []
|
|
884
|
+
errors.append(str(exc))
|
|
885
|
+
|
|
886
|
+
result: dict[str, Any] = {
|
|
887
|
+
"ok": False,
|
|
888
|
+
"base_url": base_url or None,
|
|
889
|
+
"client_version": __version__,
|
|
890
|
+
"auth_storage": args.auth_storage,
|
|
891
|
+
"toolsets": selected_toolsets,
|
|
892
|
+
"auth": {
|
|
893
|
+
"mode": "none",
|
|
894
|
+
"storage_backend": args.auth_storage,
|
|
895
|
+
"credential_present": False,
|
|
896
|
+
"token_present": False,
|
|
897
|
+
"expires_at": None,
|
|
898
|
+
"expiry_status": "unknown",
|
|
899
|
+
},
|
|
900
|
+
"connectivity": {
|
|
901
|
+
"ok": False,
|
|
902
|
+
"checked": False,
|
|
903
|
+
"error": None,
|
|
904
|
+
},
|
|
905
|
+
"capabilities": {
|
|
906
|
+
"ok": False,
|
|
907
|
+
"checked": False,
|
|
908
|
+
"server_version": None,
|
|
909
|
+
"server_supported_unknown_to_client": [],
|
|
910
|
+
"client_known_unsupported_by_server": [],
|
|
911
|
+
"error": None,
|
|
912
|
+
},
|
|
913
|
+
"errors": errors,
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
if not base_url:
|
|
917
|
+
result["errors"].append("--base-url or STACKLESS_BASE_URL is required")
|
|
918
|
+
return 1, result
|
|
919
|
+
|
|
920
|
+
token, cookie, auth, auth_errors = _auth_diagnostic(
|
|
921
|
+
base_url=base_url,
|
|
922
|
+
auth_storage=args.auth_storage,
|
|
923
|
+
token=getattr(args, "token", None),
|
|
924
|
+
cookie=getattr(args, "cookie", None),
|
|
925
|
+
)
|
|
926
|
+
result["auth"] = auth
|
|
927
|
+
result["errors"].extend(auth_errors)
|
|
928
|
+
if auth_errors:
|
|
929
|
+
return 1, result
|
|
930
|
+
|
|
931
|
+
try:
|
|
932
|
+
client = StacklessClient(
|
|
933
|
+
base_url=base_url,
|
|
934
|
+
token=token,
|
|
935
|
+
cookie=cookie,
|
|
936
|
+
timeout=args.timeout,
|
|
937
|
+
)
|
|
938
|
+
except Exception as exc:
|
|
939
|
+
result["connectivity"] = {
|
|
940
|
+
"ok": False,
|
|
941
|
+
"checked": False,
|
|
942
|
+
"error": str(exc),
|
|
943
|
+
}
|
|
944
|
+
result["errors"].append(str(exc))
|
|
945
|
+
return 1, result
|
|
946
|
+
|
|
947
|
+
try:
|
|
948
|
+
connectivity = handlers.check_stackless_connection(client)
|
|
949
|
+
except Exception as exc:
|
|
950
|
+
client.close()
|
|
951
|
+
result["connectivity"] = {
|
|
952
|
+
"ok": False,
|
|
953
|
+
"checked": True,
|
|
954
|
+
"error": str(exc),
|
|
955
|
+
}
|
|
956
|
+
result["errors"].append(f"connectivity check failed: {exc}")
|
|
957
|
+
return 1, result
|
|
958
|
+
|
|
959
|
+
if not isinstance(connectivity, dict):
|
|
960
|
+
connectivity = {"ok": bool(connectivity), "result": connectivity}
|
|
961
|
+
result["connectivity"] = {"checked": True, **connectivity}
|
|
962
|
+
if not connectivity.get("ok"):
|
|
963
|
+
client.close()
|
|
964
|
+
result["errors"].append("connectivity check failed")
|
|
965
|
+
return 1, result
|
|
966
|
+
|
|
967
|
+
try:
|
|
968
|
+
server_version, server_supported_tools = _validate_capabilities(
|
|
969
|
+
client.capabilities()
|
|
970
|
+
)
|
|
971
|
+
except Exception as exc:
|
|
972
|
+
result["capabilities"] = {
|
|
973
|
+
"ok": False,
|
|
974
|
+
"checked": True,
|
|
975
|
+
"server_version": None,
|
|
976
|
+
"server_supported_unknown_to_client": [],
|
|
977
|
+
"client_known_unsupported_by_server": [],
|
|
978
|
+
"error": str(exc),
|
|
979
|
+
}
|
|
980
|
+
else:
|
|
981
|
+
client_known_tools = _workflow_tool_names_for_toolsets(args.toolsets)
|
|
982
|
+
all_client_workflow_tools = _workflow_tool_names_for_toolsets(TOOLSET_ALL)
|
|
983
|
+
result["capabilities"] = {
|
|
984
|
+
"ok": True,
|
|
985
|
+
"checked": True,
|
|
986
|
+
"server_version": server_version,
|
|
987
|
+
"server_supported_unknown_to_client": sorted(
|
|
988
|
+
server_supported_tools - all_client_workflow_tools
|
|
989
|
+
),
|
|
990
|
+
"client_known_unsupported_by_server": sorted(
|
|
991
|
+
client_known_tools - server_supported_tools
|
|
992
|
+
),
|
|
993
|
+
"error": None,
|
|
994
|
+
}
|
|
995
|
+
finally:
|
|
996
|
+
client.close()
|
|
997
|
+
|
|
998
|
+
result["ok"] = not result["errors"]
|
|
999
|
+
return (0 if result["ok"] else 1), result
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def main() -> None:
|
|
1003
|
+
load_dotenv()
|
|
1004
|
+
if len(sys.argv) > 1 and sys.argv[1] in {
|
|
1005
|
+
"login",
|
|
1006
|
+
"logout",
|
|
1007
|
+
"reset-auth",
|
|
1008
|
+
"doctor",
|
|
1009
|
+
}:
|
|
1010
|
+
command = sys.argv[1]
|
|
1011
|
+
parser = argparse.ArgumentParser(description=f"stackless-mcp {command}")
|
|
1012
|
+
parser.add_argument("--base-url", default=os.environ.get("STACKLESS_BASE_URL"))
|
|
1013
|
+
parser.add_argument(
|
|
1014
|
+
"--auth-storage",
|
|
1015
|
+
choices=["keyring", "file"],
|
|
1016
|
+
default=os.environ.get("STACKLESS_AUTH_STORAGE", "keyring"),
|
|
1017
|
+
)
|
|
1018
|
+
parser.add_argument(
|
|
1019
|
+
"--timeout",
|
|
1020
|
+
type=float,
|
|
1021
|
+
default=float(
|
|
1022
|
+
os.environ.get("STACKLESS_TIMEOUT_SECONDS", DEFAULT_TIMEOUT_SECONDS)
|
|
1023
|
+
),
|
|
1024
|
+
)
|
|
1025
|
+
if command == "login":
|
|
1026
|
+
parser.add_argument(
|
|
1027
|
+
"--no-browser",
|
|
1028
|
+
action="store_true",
|
|
1029
|
+
help="Print the login URL instead of opening a browser.",
|
|
1030
|
+
)
|
|
1031
|
+
parser.add_argument("--client-name", default="stackless-mcp")
|
|
1032
|
+
if command == "doctor":
|
|
1033
|
+
parser.add_argument(
|
|
1034
|
+
"--token",
|
|
1035
|
+
default=None,
|
|
1036
|
+
help="Stackless Bearer token for this diagnostic run.",
|
|
1037
|
+
)
|
|
1038
|
+
parser.add_argument(
|
|
1039
|
+
"--cookie",
|
|
1040
|
+
default=None,
|
|
1041
|
+
help="Stackless Cookie header for this diagnostic run.",
|
|
1042
|
+
)
|
|
1043
|
+
parser.add_argument(
|
|
1044
|
+
"--toolsets",
|
|
1045
|
+
default=os.environ.get("STACKLESS_TOOLSETS", DEFAULT_TOOLSETS),
|
|
1046
|
+
help=(
|
|
1047
|
+
"Comma-separated MCP toolsets to register. Use 'all' or any of: "
|
|
1048
|
+
+ ", ".join(SELECTABLE_TOOLSETS)
|
|
1049
|
+
+ ". Core auth/operation tools are always registered."
|
|
1050
|
+
),
|
|
1051
|
+
)
|
|
1052
|
+
args = parser.parse_args(sys.argv[2:])
|
|
1053
|
+
if command == "doctor":
|
|
1054
|
+
exit_code, result = _doctor(args)
|
|
1055
|
+
print(json.dumps(result, indent=2, sort_keys=True))
|
|
1056
|
+
if exit_code:
|
|
1057
|
+
raise SystemExit(exit_code)
|
|
1058
|
+
return
|
|
1059
|
+
if not args.base_url:
|
|
1060
|
+
print("--base-url or STACKLESS_BASE_URL is required", file=sys.stderr)
|
|
1061
|
+
raise SystemExit(2)
|
|
1062
|
+
try:
|
|
1063
|
+
if command == "login":
|
|
1064
|
+
credential = mcp_login(
|
|
1065
|
+
base_url=args.base_url,
|
|
1066
|
+
storage=args.auth_storage,
|
|
1067
|
+
timeout=args.timeout,
|
|
1068
|
+
open_browser=not args.no_browser,
|
|
1069
|
+
client_name=args.client_name,
|
|
1070
|
+
)
|
|
1071
|
+
print(
|
|
1072
|
+
json.dumps(
|
|
1073
|
+
{
|
|
1074
|
+
"ok": True,
|
|
1075
|
+
"base_url": credential.base_url,
|
|
1076
|
+
"expires_at": credential.expires_at,
|
|
1077
|
+
"user_id": credential.user_id,
|
|
1078
|
+
},
|
|
1079
|
+
indent=2,
|
|
1080
|
+
sort_keys=True,
|
|
1081
|
+
)
|
|
1082
|
+
)
|
|
1083
|
+
elif command == "logout":
|
|
1084
|
+
revoked = mcp_logout(
|
|
1085
|
+
base_url=args.base_url,
|
|
1086
|
+
storage=args.auth_storage,
|
|
1087
|
+
timeout=args.timeout,
|
|
1088
|
+
)
|
|
1089
|
+
print(json.dumps({"ok": True, "revoked": revoked}, indent=2))
|
|
1090
|
+
else:
|
|
1091
|
+
CredentialStore(args.auth_storage).delete(args.base_url)
|
|
1092
|
+
print(json.dumps({"ok": True, "reset": True}, indent=2))
|
|
1093
|
+
except Exception as exc:
|
|
1094
|
+
print(str(exc), file=sys.stderr)
|
|
1095
|
+
raise SystemExit(1) from exc
|
|
1096
|
+
return
|
|
1097
|
+
|
|
1098
|
+
parser = argparse.ArgumentParser(description="Run the Stackless MCP server")
|
|
1099
|
+
parser.add_argument(
|
|
1100
|
+
"--version",
|
|
1101
|
+
action="version",
|
|
1102
|
+
version=f"stackless-mcp {__version__}",
|
|
1103
|
+
)
|
|
1104
|
+
parser.add_argument("--base-url", default=os.environ.get("STACKLESS_BASE_URL"))
|
|
1105
|
+
parser.add_argument("--token", default=os.environ.get("STACKLESS_TOKEN"))
|
|
1106
|
+
parser.add_argument("--cookie", default=os.environ.get("STACKLESS_COOKIE"))
|
|
1107
|
+
parser.add_argument(
|
|
1108
|
+
"--auth-storage",
|
|
1109
|
+
choices=["keyring", "file"],
|
|
1110
|
+
default=os.environ.get("STACKLESS_AUTH_STORAGE", "keyring"),
|
|
1111
|
+
)
|
|
1112
|
+
parser.add_argument(
|
|
1113
|
+
"--toolsets",
|
|
1114
|
+
default=os.environ.get("STACKLESS_TOOLSETS", DEFAULT_TOOLSETS),
|
|
1115
|
+
help=(
|
|
1116
|
+
"Comma-separated MCP toolsets to register. Use 'all' or any of: "
|
|
1117
|
+
+ ", ".join(SELECTABLE_TOOLSETS)
|
|
1118
|
+
+ ". Core auth/operation tools are always registered."
|
|
1119
|
+
),
|
|
1120
|
+
)
|
|
1121
|
+
parser.add_argument(
|
|
1122
|
+
"--timeout",
|
|
1123
|
+
type=float,
|
|
1124
|
+
default=float(
|
|
1125
|
+
os.environ.get("STACKLESS_TIMEOUT_SECONDS", DEFAULT_TIMEOUT_SECONDS)
|
|
1126
|
+
),
|
|
1127
|
+
)
|
|
1128
|
+
parser.add_argument(
|
|
1129
|
+
"--check",
|
|
1130
|
+
action="store_true",
|
|
1131
|
+
help="Check Stackless auth/API connectivity and exit without starting MCP.",
|
|
1132
|
+
)
|
|
1133
|
+
args = parser.parse_args()
|
|
1134
|
+
|
|
1135
|
+
configure_client(
|
|
1136
|
+
base_url=args.base_url or "",
|
|
1137
|
+
token=args.token or "",
|
|
1138
|
+
cookie=args.cookie or "",
|
|
1139
|
+
timeout=args.timeout,
|
|
1140
|
+
auth_storage=args.auth_storage,
|
|
1141
|
+
)
|
|
1142
|
+
try:
|
|
1143
|
+
configure_toolsets_from_server(args.toolsets)
|
|
1144
|
+
except ValueError as exc:
|
|
1145
|
+
parser.error(str(exc))
|
|
1146
|
+
if args.check:
|
|
1147
|
+
try:
|
|
1148
|
+
result = handlers.check_stackless_connection(get_client())
|
|
1149
|
+
except Exception as exc:
|
|
1150
|
+
print(str(exc), file=sys.stderr)
|
|
1151
|
+
raise SystemExit(1) from exc
|
|
1152
|
+
print(json.dumps(result, indent=2, sort_keys=True))
|
|
1153
|
+
return
|
|
1154
|
+
mcp.run(transport="stdio")
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
if __name__ == "__main__":
|
|
1158
|
+
main()
|