stacklet-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,8 @@
1
+ # LICENSE HEADER MANAGED BY add-license-header
2
+ #
3
+ # Copyright (c) 2025 Stacklet, Inc.
4
+ #
5
+
6
+ """Stacklet MCP server."""
7
+
8
+ __version__ = "0.1.0"
@@ -0,0 +1,11 @@
1
+ # LICENSE HEADER MANAGED BY add-license-header
2
+ #
3
+ # Copyright (c) 2025 Stacklet, Inc.
4
+ #
5
+
6
+ """Entry point for running the server from the package."""
7
+
8
+ from .mcp import main
9
+
10
+
11
+ main()
@@ -0,0 +1,4 @@
1
+ # LICENSE HEADER MANAGED BY add-license-header
2
+ #
3
+ # Copyright (c) 2025 Stacklet, Inc.
4
+ #
@@ -0,0 +1,268 @@
1
+ # LICENSE HEADER MANAGED BY add-license-header
2
+ #
3
+ # Copyright (c) 2025 Stacklet, Inc.
4
+ #
5
+
6
+ import copy
7
+
8
+ from datetime import datetime
9
+ from enum import IntEnum, StrEnum
10
+ from typing import Any
11
+
12
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
13
+
14
+
15
+ class ExportFormat(StrEnum):
16
+ """Format for query result export."""
17
+
18
+ CSV = "csv"
19
+ JSON = "json"
20
+ TSV = "tsv"
21
+ XLSX = "xlsx"
22
+
23
+
24
+ class JobStatus(IntEnum):
25
+ """Status values for AssetDB query execution jobs."""
26
+
27
+ QUEUED = 1
28
+ STARTED = 2
29
+ FINISHED = 3
30
+ FAILED = 4
31
+ CANCELED = 5
32
+ DEFERRED = 6
33
+ SCHEDULED = 7
34
+
35
+ @property
36
+ def is_terminal(self) -> bool:
37
+ """Whether this job status represents a completed state (finished, failed, or canceled)."""
38
+ return self in (JobStatus.FINISHED, JobStatus.FAILED, JobStatus.CANCELED)
39
+
40
+
41
+ class Job(BaseModel):
42
+ """Redash job object for async query execution."""
43
+
44
+ id: str
45
+ status: JobStatus
46
+ error: str | None
47
+ query_result_id: int | None
48
+
49
+
50
+ class QueryArchiveResult(BaseModel):
51
+ """Result of archiving/deleting a query."""
52
+
53
+ success: bool
54
+ message: str
55
+ query_id: int
56
+
57
+
58
+ class User(BaseModel):
59
+ """Redash user object model."""
60
+
61
+ model_config = ConfigDict(extra="ignore")
62
+
63
+ id: int = Field(..., description="Unique user ID in the Redash system")
64
+ name: str | None = Field(None, description="User's display name")
65
+ email: str | None = Field(None, description="User's email address")
66
+
67
+
68
+ class Query(BaseModel):
69
+ """Redash query object model based on serialize_query output."""
70
+
71
+ model_config = ConfigDict(extra="ignore")
72
+
73
+ id: int = Field(..., description="Unique query ID in the Redash system")
74
+ latest_query_data_id: int | None = Field(
75
+ None, description="ID of the most recent query result data"
76
+ )
77
+ name: str = Field(..., description="Query display name")
78
+ description: str | None = Field(None, description="Query description or documentation")
79
+ query: str = Field(..., description="SQL query text")
80
+ api_key: str = Field(..., description="API key for accessing this query")
81
+ is_draft: bool = Field(..., description="Whether the query is in draft status")
82
+ updated_at: datetime = Field(..., description="Timestamp of last modification")
83
+ created_at: datetime = Field(..., description="Timestamp when query was created")
84
+ data_source_id: int = Field(..., description="ID of the data source this query runs against")
85
+ options: dict[str, Any] = Field(
86
+ ..., description="Query configuration options including parameters"
87
+ )
88
+ tags: list[str] = Field(..., description="List of tags for categorizing the query")
89
+ is_safe: bool = Field(..., description="Whether the query is considered safe to run")
90
+ user: User = Field(..., description="User who created the query")
91
+ last_modified_by: User | None = Field(None, description="User who last modified the query")
92
+ retrieved_at: datetime | None = Field(
93
+ None, description="Timestamp when query data was last retrieved"
94
+ )
95
+ runtime: float | None = Field(None, description="Last execution runtime in seconds")
96
+ is_favorite: bool = Field(..., description="Whether the query is marked as favorite")
97
+
98
+ @model_validator(mode="before")
99
+ @classmethod
100
+ def transform_user_fields(cls, data: Any) -> Any:
101
+ if not isinstance(data, dict):
102
+ return data
103
+
104
+ # Deep copy to avoid any mutation issues
105
+ data = copy.deepcopy(data)
106
+
107
+ # Handle user field - convert user_id to User object if needed
108
+ if "user_id" in data and "user" not in data:
109
+ data["user"] = {"id": data["user_id"]}
110
+
111
+ # Handle last_modified_by - convert last_modified_by_id to User object if needed
112
+ if "last_modified_by_id" in data and "last_modified_by" not in data:
113
+ if data["last_modified_by_id"] is not None:
114
+ data["last_modified_by"] = {"id": data["last_modified_by_id"]}
115
+ else:
116
+ data["last_modified_by"] = None
117
+
118
+ return data
119
+
120
+
121
+ class QueryListResponse(BaseModel):
122
+ """Raw response model for query list endpoint (internal use)."""
123
+
124
+ model_config = ConfigDict(extra="ignore")
125
+
126
+ count: int = Field(..., description="Total number of queries matching the search criteria")
127
+ page: int = Field(..., description="Current page number (1-based)")
128
+ page_size: int = Field(..., description="Number of queries per page")
129
+ results: list[Query] = Field(..., description="List of queries on the current page")
130
+
131
+
132
+ class QueryUpsert(BaseModel):
133
+ """Query data for create/update operations."""
134
+
135
+ name: str | None = Field(None, description="Query display name (required for new queries)")
136
+ query: str | None = Field(None, description="SQL query text (required for new queries)")
137
+ description: str | None = Field(None, description="Query description or documentation")
138
+ tags: list[str] | None = Field(None, description="List of tags for categorizing the query")
139
+ options: dict[str, Any] | None = Field(
140
+ None, description="Query configuration options including parameters"
141
+ )
142
+ is_draft: bool | None = Field(None, description="Whether the query should be in draft status")
143
+
144
+ def payload(self, data_source_id: int | None = None) -> dict[str, Any]:
145
+ """
146
+ Build API payload for query create/update.
147
+
148
+ Args:
149
+ data_source_id: Required data source ID for the query
150
+
151
+ Returns:
152
+ Payload dictionary with non-None values
153
+ """
154
+ payload = self.model_dump(exclude_none=True)
155
+ if data_source_id:
156
+ payload["data_source_id"] = data_source_id
157
+
158
+ return payload
159
+
160
+
161
+ class ToolQueryListPagination(BaseModel):
162
+ """Pagination metadata for query list responses."""
163
+
164
+ page: int = Field(..., description="Current page number (1-based)")
165
+ page_size: int = Field(..., description="Number of queries per page")
166
+ has_next_page: bool = Field(..., description="Whether there are more pages available")
167
+ total_count: int = Field(
168
+ ..., description="Total number of queries matching the search criteria"
169
+ )
170
+
171
+
172
+ class ToolQueryListItem(BaseModel):
173
+ """Simplified query information for list responses."""
174
+
175
+ id: int = Field(..., description="Unique query ID")
176
+ name: str = Field(..., description="Query display name")
177
+ description: str | None = Field(..., description="Query description or documentation")
178
+ has_parameters: bool = Field(..., description="Whether the query accepts parameters")
179
+ data_source_id: int = Field(..., description="ID of the data source this query runs against")
180
+ is_draft: bool = Field(..., description="Whether the query is in draft status")
181
+ is_favorite: bool = Field(..., description="Whether the query is marked as favorite")
182
+ tags: list[str] = Field(..., description="List of tags for categorizing the query")
183
+ user: User = Field(..., description="User who created the query")
184
+
185
+
186
+ class ToolQueryList(BaseModel):
187
+ """Complete response for query list operations."""
188
+
189
+ queries: list[ToolQueryListItem] = Field(..., description="List of queries on the current page")
190
+ pagination: ToolQueryListPagination = Field(..., description="Pagination information")
191
+
192
+
193
+ class QueryResultColumn(BaseModel):
194
+ """Column definition in a query result."""
195
+
196
+ name: str = Field(..., description="Column name")
197
+ type: str | None = Field(None, description="Column data type")
198
+ friendly_name: str | None = Field(None, description="Human-friendly column name")
199
+
200
+ model_config = ConfigDict(extra="ignore")
201
+
202
+
203
+ class QueryResultData(BaseModel):
204
+ """The data structure within a query result containing columns and rows."""
205
+
206
+ columns: list[QueryResultColumn] = Field(
207
+ ..., description="Column definitions for the query result"
208
+ )
209
+ rows: list[dict[str, Any]] = Field(
210
+ ..., description="Query result rows as key-value dictionaries"
211
+ )
212
+
213
+ model_config = ConfigDict(extra="ignore")
214
+
215
+
216
+ class QueryResult(BaseModel):
217
+ """Query result object as returned by Redash QueryResult.to_dict()."""
218
+
219
+ id: int = Field(..., description="Query result ID")
220
+ query: str = Field(..., description="The SQL query text that was executed")
221
+ data: QueryResultData = Field(..., description="Query result data with columns and rows")
222
+ data_source_id: int = Field(..., description="ID of the data source used")
223
+ runtime: float = Field(..., description="Query execution time in seconds")
224
+ retrieved_at: datetime = Field(..., description="When the query result was retrieved")
225
+
226
+ model_config = ConfigDict(extra="ignore")
227
+
228
+
229
+ class ToolQueryResultArtifact(BaseModel):
230
+ """Query download details for a data format."""
231
+
232
+ format: ExportFormat = Field(..., description="Export format for the query result download")
233
+ download_from: str = Field(..., description="URL to download the data in the specified format")
234
+
235
+
236
+ class ToolQueryResult(BaseModel):
237
+ """
238
+ Truncated query results suitable for LLMs, along with ways to get the full
239
+ result set for analysis with tools suited to that task.
240
+ """
241
+
242
+ result_id: int = Field(..., description="Query result id")
243
+ query_id: int | None = Field(None, description="Query id, if applicable")
244
+
245
+ # These fields come directly from the redash QueryResult.
246
+ query_text: str = Field(..., description="The SQL query text that was executed")
247
+ query_runtime: float = Field(..., description="Query execution duration in seconds")
248
+ query_timestamp: datetime = Field(..., description="Query execution finish timestamp")
249
+ columns: list[QueryResultColumn] = Field(
250
+ ..., description="Column definitions; sparse row dicts are keyed on column name"
251
+ )
252
+
253
+ # These fields are derived from the QueryResult rows.
254
+ row_count: int = Field(..., description="Total rows in the full query result")
255
+ some_rows: list[dict[str, Any]] = Field(
256
+ ..., description="Sample of up to 20 rows from the query result for preview"
257
+ )
258
+
259
+ # Complete result data is always saved locally for further analysis. (We don't *have*
260
+ # to do this on every path, but we do on *some*, so we choose consistency.)
261
+ full_results_saved_to: str = Field(
262
+ ..., description="Local path where complete result data was saved as JSON"
263
+ )
264
+
265
+ # Available only for saved queries (not ad-hoc queries) that have API keys.
266
+ alternate_formats: list[ToolQueryResultArtifact] | None = Field(
267
+ None, description="Download URLs for different formats, None for ad-hoc queries"
268
+ )
@@ -0,0 +1,268 @@
1
+ # LICENSE HEADER MANAGED BY add-license-header
2
+ #
3
+ # Copyright (c) 2025 Stacklet, Inc.
4
+ #
5
+
6
+ """
7
+ AssetDB client using Redash API with Stacklet authentication.
8
+ """
9
+
10
+ import asyncio
11
+ import time
12
+
13
+ from typing import Any, Self, cast
14
+ from urllib.parse import urljoin
15
+
16
+ import httpx
17
+
18
+ from fastmcp import Context
19
+
20
+ from ..lifespan import server_cached
21
+ from ..settings import SETTINGS
22
+ from ..stacklet_auth import StackletCredentials
23
+ from .models import ExportFormat, Job, Query, QueryListResponse, QueryResult, QueryUpsert
24
+
25
+
26
+ class AssetDBClient:
27
+ """Client for AssetDB interface via Redash API using Stacklet authentication."""
28
+
29
+ def __init__(self, credentials: StackletCredentials, data_source_id: int = 1) -> None:
30
+ """
31
+ Initialize AssetDB client with Stacklet credentials.
32
+
33
+ Args:
34
+ credentials: StackletCredentials object containing endpoint and id_token
35
+ data_source_id: ID of the Redash data source (default 1 for main AssetDB)
36
+ """
37
+ self.credentials = credentials
38
+ self.data_source_id = data_source_id
39
+
40
+ self.redash_url = self.credentials.service_endpoint("redash")
41
+ self.session = httpx.AsyncClient(
42
+ cookies={"stacklet-auth": credentials.identity_token}, timeout=60.0
43
+ )
44
+
45
+ @classmethod
46
+ def get(cls, ctx: Context) -> Self:
47
+ def construct() -> AssetDBClient:
48
+ return cls(StackletCredentials.get(ctx), SETTINGS.assetdb_datasource)
49
+
50
+ return cast(Self, server_cached(ctx, "ASSETDB_CLIENT", construct))
51
+
52
+ async def _make_request(self, method: str, endpoint: str, **kwargs: Any) -> Any:
53
+ """
54
+ Make a request to the Redash API with Stacklet authentication.
55
+
56
+ Args:
57
+ method: HTTP method (GET, POST, etc.)
58
+ endpoint: API endpoint path
59
+ **kwargs: Additional arguments for httpx
60
+
61
+ Returns:
62
+ Decoded response JSON
63
+ """
64
+ url = urljoin(self.redash_url, endpoint)
65
+ response = await self.session.request(method, url, **kwargs)
66
+ response.raise_for_status()
67
+ return response.json()
68
+
69
+ async def list_queries(
70
+ self,
71
+ page: int = 1,
72
+ page_size: int = 25,
73
+ search: str | None = None,
74
+ tags: list[str] | None = None,
75
+ ) -> QueryListResponse:
76
+ """
77
+ Get list of queries with search and sorting support.
78
+
79
+ Args:
80
+ page: Page number (1-based)
81
+ page_size: Number of queries per page
82
+ search: Search query names, descriptions, and SQL content
83
+ tags: Filter out queries not matching all tags
84
+
85
+ Returns:
86
+ Structured response with queries and pagination metadata
87
+ """
88
+ params: dict[str, Any] = {"page": page, "page_size": page_size}
89
+
90
+ if search:
91
+ params["q"] = search
92
+ if tags:
93
+ params["tags"] = tags
94
+
95
+ result = await self._make_request("GET", "api/queries", params=params)
96
+ return QueryListResponse(**result)
97
+
98
+ async def get_query(self, query_id: int) -> Query:
99
+ """
100
+ Get detailed information about a specific saved query.
101
+
102
+ Args:
103
+ query_id: ID of the query to retrieve
104
+
105
+ Returns:
106
+ Complete query object with SQL and parameters
107
+ """
108
+ result = await self._make_request("GET", f"api/queries/{query_id}")
109
+ return Query(**result)
110
+
111
+ async def execute_saved_query(
112
+ self,
113
+ query_id: int,
114
+ parameters: dict[str, Any] | None,
115
+ max_age: int,
116
+ timeout: int,
117
+ ) -> QueryResult:
118
+ """
119
+ Execute a saved query by ID, with caching control.
120
+
121
+ Args:
122
+ query_id: ID of the query
123
+ parameters: Optional parameters for the query
124
+ max_age: Maximum age of cached results in seconds (-1=any cached result, 0=always fresh)
125
+ timeout: Timeout in seconds for query execution (if not cached)
126
+
127
+ Returns:
128
+ Complete query result with data, columns, and metadata
129
+ """
130
+ payload = {"max_age": max_age, "parameters": parameters or {}}
131
+ return await self._execute_results(f"api/queries/{query_id}/results", payload, timeout)
132
+
133
+ async def execute_adhoc_query(self, query: str, max_age: int, timeout: int) -> QueryResult:
134
+ """
135
+ Execute an ad-hoc SQL query without saving it.
136
+
137
+ Args:
138
+ query: SQL query string to execute
139
+ timeout: Timeout in seconds for query execution
140
+
141
+ Returns:
142
+ Complete query result with data, columns, and metadata
143
+ """
144
+ payload = {
145
+ "query": query,
146
+ "data_source_id": self.data_source_id,
147
+ "max_age": max_age,
148
+ "parameters": {},
149
+ "apply_auto_limit": True,
150
+ }
151
+ return await self._execute_results("api/query_results", payload, timeout)
152
+
153
+ async def _execute_results(
154
+ self, endpoint: str, payload: dict[str, Any], timeout: int
155
+ ) -> QueryResult:
156
+ """
157
+ Execute query request and handle both sync and async results.
158
+
159
+ Args:
160
+ endpoint: API endpoint to POST the query to
161
+ payload: Query parameters and options
162
+ timeout: Maximum time to wait for async job completion
163
+
164
+ Returns:
165
+ Complete query result with data, columns, and metadata
166
+ """
167
+ # This will contain either a "job" or a full "query_result". Since we're
168
+ # sometimes stuck grabbing a whole result set any way, we may as well do
169
+ # it every time; this also lets us always return a preview of the result
170
+ # data even when it's large.
171
+ response = await self._make_request("POST", endpoint, json=payload)
172
+ if "query_result" in response:
173
+ return QueryResult(**response["query_result"])
174
+
175
+ job = Job(**response["job"])
176
+ result_id = await self._poll_job(job, timeout)
177
+ qr_response = await self._make_request("GET", f"api/query_results/{result_id}")
178
+ return QueryResult(**qr_response["query_result"])
179
+
180
+ async def _poll_job(self, job: Job, timeout: int) -> int:
181
+ """
182
+ Poll an async job until completion using exponential backoff.
183
+
184
+ Args:
185
+ job: Initial job object from query execution
186
+ timeout: Maximum time to wait before timing out
187
+
188
+ Returns:
189
+ Query result ID when job completes successfully
190
+ """
191
+ cutoff = time.monotonic() + timeout
192
+ interval_s = 2
193
+ while True:
194
+ job_result = await self._make_request("GET", f"api/jobs/{job.id}")
195
+ job = Job(**job_result["job"])
196
+ if job.query_result_id:
197
+ return job.query_result_id
198
+ elif job.status.is_terminal:
199
+ raise RuntimeError(f"Query execution failed: {job.error or 'Unknown error.'}")
200
+
201
+ remaining_s = cutoff - time.monotonic()
202
+ if remaining_s <= 0:
203
+ raise RuntimeError(f"Query execution timed out after {timeout} seconds")
204
+ await asyncio.sleep(min(interval_s, remaining_s))
205
+ interval_s *= 2
206
+
207
+ def get_query_result_urls(
208
+ self, query: Query, query_result: QueryResult
209
+ ) -> dict[ExportFormat, str]:
210
+ """
211
+ Return download URLs for a query result.
212
+
213
+ Args:
214
+ query_id: ID of the query the result refers to
215
+ result_id: ID of the query result to get downloads urls for
216
+ api_key: the API key for the query.
217
+
218
+ Returns:
219
+ Dictionary mapping download formats to their URLs
220
+ """
221
+ return {
222
+ fmt: urljoin(
223
+ self.redash_url,
224
+ f"api/queries/{query.id}/results/{query_result.id}.{fmt}?api_key={query.api_key}",
225
+ )
226
+ for fmt in ExportFormat
227
+ }
228
+
229
+ async def create_query(self, upsert: QueryUpsert) -> Query:
230
+ """
231
+ Create a new saved query.
232
+
233
+ Args:
234
+ upsert: QueryUpsert object with query data
235
+
236
+ Returns:
237
+ Complete query object with ID, timestamps, and metadata
238
+ """
239
+ payload = upsert.payload(data_source_id=self.data_source_id)
240
+ result = await self._make_request("POST", "api/queries", json=payload)
241
+ return Query(**result)
242
+
243
+ async def update_query(self, query_id: int, upsert: QueryUpsert) -> Query:
244
+ """
245
+ Update an existing saved query.
246
+
247
+ Args:
248
+ query_id: ID of the query to update
249
+ upsert: QueryUpsert object with query data to update
250
+
251
+ Returns:
252
+ Complete updated query object with ID, timestamps, and metadata
253
+ """
254
+ payload = upsert.payload()
255
+ result = await self._make_request("POST", f"api/queries/{query_id}", json=payload)
256
+ return Query(**result)
257
+
258
+ async def delete_query(self, query_id: int) -> None:
259
+ """
260
+ Archive a saved query.
261
+
262
+ This sets the query's is_archived flag to True and removes associated
263
+ visualizations and alerts, but preserves the query in the database.
264
+
265
+ Args:
266
+ query_id: ID of the query to archive
267
+ """
268
+ await self._make_request("DELETE", f"api/queries/{query_id}")