Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,8 @@ The server requires Stacklet credentials configured through one of:

**Server Settings:**
Additional configuration via environment variables with `STACKLET_MCP_` prefix:
- `STACKLET_MCP_DOWNLOADS_PATH` (default: system temp directory) - Directory for storing query result files
- `STACKLET_MCP_DOWNLOADS_ENABLED` (default: true) - Write query result files; set false for hosted deployments
- `STACKLET_MCP_DOWNLOADS_PATH` (default: system temp directory) - Directory for storing query result and dataset export files
- `STACKLET_MCP_DOWNLOADS_ENABLED` (default: true) - Write query result and dataset export files; set false for hosted deployments
- `STACKLET_MCP_ASSETDB_DATASOURCE_NAME` (default: AssetDB) - Name the data source id is looked up from on first use
- `STACKLET_MCP_ASSETDB_DATASOURCE` (default: unset) - AssetDB data source ID, when a deployment needs to name one explicitly; unset means look it up by name
- `STACKLET_MCP_ASSETDB_ALLOW_SAVE` (default: false) - Enable query save/update functionality
Expand All @@ -153,6 +153,11 @@ available by exactly one of two routes, depending on `downloads_enabled`:
attached to the tool response as a resource. Hosted deployments must disable downloads —
a caller elsewhere cannot read the server's filesystem, so the files would only accumulate.

Dataset export tools follow the same switch. Enabled, a completed export is downloaded with
the caller's token to `export_*` in the downloads directory (`full_results_saved_to`).
Disabled, the tool returns the link with a `download_note` to open it in a signed-in browser.
An expired export returns only a `download_note` either way.

**External Dependencies:**
- Documentation files are fetched from the live Stacklet docs service at runtime
- Redash endpoint is derived by replacing "api." with "redash." in the platform endpoint
Expand Down
6 changes: 6 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@

### Changes

- **Dataset exports download with your credentials**: Stacklet now requires sign-in to
download an export, and only the user who started it can. When the server writes files,
`platform_dataset_lookup` and `platform_dataset_export` download the finished export with
your token and return the local path in `full_results_saved_to`. A hosted server returns
the link with a `download_note` telling you to open it in your signed-in browser.

### Fixes

---
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ When the MCP is run from an agent, those can be set in the `"env"` section of th

The following variables are available:

- `STACKLET_MCP_DOWNLOADS_PATH`: directory for storing query result files (default: system temp directory)
- `STACKLET_MCP_DOWNLOADS_PATH`: directory for storing query result and dataset export files (default: system temp directory)
- `STACKLET_MCP_ASSETDB_DATASOURCE_NAME`: the name of the AssetDB data source in Redash, which the id is looked up from on first use (default: `AssetDB`)
- `STACKLET_MCP_ASSETDB_DATASOURCE`: the datasource ID for AssetDB in Redash, for a deployment that needs to name one explicitly. Unset by default, meaning look it up by name — Redash assigns the id at creation, so it differs between deployments and cannot be assumed
- `STACKLET_MCP_ASSETDB_ALLOW_SAVE`: whether to enable write operations in AssetDB (default: `false`)
Expand Down
7 changes: 5 additions & 2 deletions stacklet/mcp/platform/dataset_info.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,10 @@ platform_dataset_export(

**Key status fields:** `dataset_id`, `success`, `download_url`, `available_until` (24hr expiry)

**Download:** `curl -L -o file.csv "download_url"` (follows S3 redirects)
**Download:** the link needs the credentials of the user who started the export, so do not fetch it yourself.
- `full_results_saved_to` set: the server already downloaded the file to that local path.
- `download_note` set and `available_until` still ahead: give the user `download_url` and the note. They open it in a browser where they're signed in to Stacklet.
- `available_until` passed: the link is dead. Start a new export instead of sharing it.

## Quick Examples

Expand All @@ -110,6 +113,6 @@ platform_dataset_export(

- Cannot use paging params (`first`, `after`) - export handles pagination
- `timeout=0` returns immediately; use `platform_dataset_lookup` to monitor
- Download URLs redirect to S3 - use `curl -L`
- Only the user who started an export can download it, while signed in
- Files expire after 24 hours
- Use GraphQL Node IDs for node-based exports
44 changes: 44 additions & 0 deletions stacklet/mcp/platform/graphql.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"""

import asyncio
import os
import re
import time

Expand All @@ -33,6 +34,7 @@
from ..settings import SETTINGS
from ..stacklet_auth import StackletCredentials
from ..utils.error import AnnotatedError
from ..utils.file import download_file
from .models import (
ConnectionExport,
ExportRequest,
Expand Down Expand Up @@ -225,8 +227,37 @@ async def _get_export(self, dataset_id: str) -> ConnectionExport:

# If no errors, data is at least guaranteed guaranteed truthy.
fields = cast(dict[str, Any], result.data)["node"]
if fields is None:
raise AnnotatedError(
problem=f"Dataset export {dataset_id} not found",
likely_cause="the ID is wrong, or the export belongs to another user",
next_steps="start a new export with platform_dataset_export",
)
return ConnectionExport(**fields)

async def download_export(self, dataset_id: str, url: str) -> str:
"""Stream an export file into the downloads directory, returning its path."""
try:
async with self.session.stream("GET", url) as response:
Comment thread
sontek marked this conversation as resolved.
Resolved
if not response.is_success:
await response.aread()
response.raise_for_status()
return await _save_stream(response)
except httpx.HTTPError as e:
detail = f"{e}\n{e.response.text}" if isinstance(e, httpx.HTTPStatusError) else str(e)
raise AnnotatedError(
problem=f"Downloading dataset export {dataset_id} failed",
likely_cause=(
"the export expired, these credentials cannot read it, "
"or the connection dropped"
),
next_steps=(
f"retry with platform_dataset_lookup, or give the user {url} "
"to open in a browser where they're signed in to Stacklet"
),
original_error=detail,
) from e

Q_GET_EXPORT = """
query getExport($id: ID!) {
node(id: $id) {
Expand Down Expand Up @@ -267,6 +298,19 @@ async def _query(self, query: str, variables: dict[str, Any]) -> GraphQLQueryRes
raise Exception(f"Unexpected response: {response.text}")


async def _save_stream(response: httpx.Response) -> str:
"""Write a response body to a new downloads CSV, removing it if anything fails."""
f = download_file("wb", "export_", ".csv")
try:
with f:
async for chunk in response.aiter_bytes():
await asyncio.to_thread(f.write, chunk)
except BaseException:
os.unlink(f.name)
raise
return f.name


def has_mutations(doc: DocumentNode) -> bool:
"""Return whether a GraphQL document calls mutations."""
operations = {dd.operation for dd in doc.definitions}
Expand Down
12 changes: 12 additions & 0 deletions stacklet/mcp/platform/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,15 @@ class ConnectionExport(BaseModel):
available_until: datetime | None = Field(
None, validation_alias="availableUntil", description="When download URL expires"
)

# A succeeded export with a download URL sets exactly one of these.
full_results_saved_to: str | None = Field(
None,
description=(
"Local path where the server saved the exported file. Null when the server "
"does not write files or the export has expired, in which case see download_note."
),
)
download_note: str | None = Field(
None, description="Why the server did not download the file, and what to do instead"
)
41 changes: 34 additions & 7 deletions stacklet/mcp/platform/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
# Copyright (c) 2025-2026 Stacklet, Inc.
#

from datetime import UTC, datetime
from typing import Annotated, Any

from fastmcp import Context
Expand All @@ -25,6 +26,26 @@
)


HOSTED_DOWNLOAD_NOTE = (
"Open this link in a browser where you're signed in to Stacklet. "
"It works only for you, until the export expires."
)
EXPIRED_DOWNLOAD_NOTE = "This export has expired. Start a new one with platform_dataset_export."


async def _deliver(client: PlatformClient, export: ConnectionExport) -> ConnectionExport:
"""Download a completed export's file, or say how to use its link instead."""
if not export.completed or not export.download_url:
return export
# The platform keeps returning the URL after the file has expired.
if export.available_until and export.available_until <= datetime.now(UTC):
return export.model_copy(update={"download_note": EXPIRED_DOWNLOAD_NOTE})
if not SETTINGS.downloads_enabled:
return export.model_copy(update={"download_note": HOSTED_DOWNLOAD_NOTE})
path = await client.download_export(export.dataset_id, export.download_url)
return export.model_copy(update={"full_results_saved_to": path})


def tools() -> list[Tool]:
"""List of available Platform tools."""
return [
Expand All @@ -37,7 +58,7 @@ def tools() -> list[Tool]:
# Exports don't change governance data, but they do start a job and
# write a file server-side, and each call starts another one.
make_tool(platform_dataset_export, read_only=False, destructive=False, idempotent=False),
make_tool(platform_dataset_lookup, read_only=True),
make_tool(platform_dataset_lookup, read_only=False, destructive=False, idempotent=False),
]


Expand Down Expand Up @@ -211,7 +232,10 @@ async def platform_dataset_export(
1. Define columns mapping GraphQL fields to CSV columns
2. Optionally add filters via params
3. Export runs asynchronously - use timeout=0 to return immediately
4. Use platform_dataset_lookup() to check progress and get download URL
4. Use platform_dataset_lookup() to check progress and get the result

A completed export's file is downloaded before the call returns when the server writes
files, so a large export takes longer than the timeout alone suggests.
"""
dataset_input = ExportRequest(
connection_field=connection_field,
Expand All @@ -222,7 +246,7 @@ async def platform_dataset_export(

client = PlatformClient.get(ctx)
dataset_id = await client.start_export(dataset_input)
return await client.wait_for_export(dataset_id, timeout)
return await _deliver(client, await client.wait_for_export(dataset_id, timeout))
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved


@json_guard
Expand Down Expand Up @@ -251,11 +275,14 @@ async def platform_dataset_lookup(

Export states:
- Processing: Export is running (shows progress if available)
- Complete: Ready for download (includes download_url and expiry time)
- Complete: Ready for download (includes download_url and expiry time, plus either
full_results_saved_to or download_note; an expired export has only the note)
- Failed: Export encountered an error

Set timeout > 0 to wait for completion, or timeout=0 for immediate status check.
Download URLs are temporary and expire after a few hours.
Set timeout > 0 to wait for completion, or timeout=0 for an immediate status check.
Every lookup of a completed export downloads its file again when the server writes
files, so the call takes as long as that download.
Download URLs expire after 24 hours and work only for the user who started the export.
"""
client = PlatformClient.get(ctx)
return await client.wait_for_export(dataset_id, timeout)
return await _deliver(client, await client.wait_for_export(dataset_id, timeout))
8 changes: 4 additions & 4 deletions stacklet/mcp/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ class Settings(BaseSettings):
downloads_enabled: bool = Field(
default=True,
description=(
"Write complete query results to downloads_path. Disable for hosted "
"deployments, where the caller cannot read the server's filesystem and "
"the files would accumulate unbounded; full results are then attached "
"to the tool response instead."
"Write complete query results and dataset exports to downloads_path. "
"Disable for hosted deployments, where the caller cannot read the server's "
"filesystem and the files would accumulate unbounded; query results are then "
"attached to the tool response, and exports return their link with a note."
),
)

Expand Down
Loading
Loading