Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/fix-dev-build-dir-leak.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---

Fix dev CLI leaking build directories on rebuild, causing disk space accumulation. Deprecated workers are now pruned (capped at 2 retained) when no active runs reference them. The watchdog process also cleans up `.trigger/tmp/` when the dev CLI is killed ungracefully (e.g. SIGKILL from pnpm).
5 changes: 5 additions & 0 deletions .changeset/fix-list-deploys-nullable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---

Fix `list_deploys` MCP tool failing when deployments have null `runtime` or `runtimeVersion` fields.
42 changes: 42 additions & 0 deletions .changeset/mcp-query-tools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
"@trigger.dev/core": patch
"trigger.dev": patch
---

MCP server improvements: new tools, bug fixes, and new flags.

**New tools:**
- `get_query_schema` — discover available TRQL tables and columns
- `query` — execute TRQL queries against your data
- `list_dashboards` — list built-in dashboards and their widgets
- `run_dashboard_query` — execute a single dashboard widget query
- `whoami` — show current profile, user, and API URL
- `list_profiles` — list all configured CLI profiles
- `switch_profile` — switch active profile for the MCP session
- `start_dev_server` — start `trigger dev` in the background and stream output
- `stop_dev_server` — stop the running dev server
- `dev_server_status` — check dev server status and view recent logs

**New API endpoints:**
- `GET /api/v1/query/schema` — query table schema discovery
- `GET /api/v1/query/dashboards` — list built-in dashboards

**New features:**
- `--readonly` flag hides write tools (`deploy`, `trigger_task`, `cancel_run`) so the AI cannot make changes
- `read:query` JWT scope for query endpoint authorization
- `get_run_details` trace output is now paginated with cursor support
- MCP tool annotations (`readOnlyHint`, `destructiveHint`) for all tools

**Bug fixes:**
- Fixed `search_docs` tool failing due to renamed upstream Mintlify tool (`SearchTriggerDev` → `search_trigger_dev`)
- Fixed `list_deploys` failing when deployments have null `runtime`/`runtimeVersion` fields (#3139)
- Fixed `list_preview_branches` crashing due to incorrect response shape access
- Fixed `metrics` table column documented as `value` instead of `metric_value` in query docs
- Fixed dev CLI leaking build directories on rebuild — deprecated workers now clean up their build dirs when their last run completes

**Context optimizations:**
- `get_query_schema` now requires a table name and returns only one table's schema (was returning all tables)
- `get_current_worker` no longer inlines payload schemas; use new `get_task_schema` tool instead
- Query results formatted as text tables instead of JSON (~50% fewer tokens)
- `cancel_run`, `list_deploys`, `list_preview_branches` formatted as text instead of raw JSON
- Schema and dashboard API responses cached to avoid redundant fetches
54 changes: 54 additions & 0 deletions apps/webapp/app/routes/api.v1.query.dashboards._index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { json } from "@remix-run/server-runtime";
import type { DashboardSummary, DashboardWidgetSummary } from "@trigger.dev/core/v3/schemas";
import type { BuiltInDashboard } from "~/presenters/v3/MetricDashboardPresenter.server";
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { builtInDashboard } from "~/presenters/v3/BuiltInDashboards.server";

const BUILT_IN_DASHBOARD_KEYS = ["overview", "llm"];

function serializeDashboard(dashboard: BuiltInDashboard): DashboardSummary {
const widgets: DashboardWidgetSummary[] = [];

if (dashboard.layout.version === "1") {
for (const [id, widget] of Object.entries(dashboard.layout.widgets)) {
// Skip title widgets — they're just section headers
if (widget.display.type === "title") continue;

widgets.push({
id,
title: widget.title,
query: widget.query,
type: widget.display.type,
});
}
}

return {
key: dashboard.key,
title: dashboard.title,
widgets,
};
}

export const loader = createLoaderApiRoute(
{
allowJWT: true,
corsStrategy: "all",
findResource: async () => 1,
authorization: {
action: "read",
resource: () => ({ query: "dashboards" }),
superScopes: ["read:query", "read:all", "admin"],
},
},
async () => {
const dashboards = BUILT_IN_DASHBOARD_KEYS.map((key) => {
try {
return serializeDashboard(builtInDashboard(key));
} catch {
return null;
}
}).filter((d): d is DashboardSummary => d !== null);
return json({ dashboards });
}
Comment on lines +52 to +53
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, find and examine the file
find . -name "api.v1.query.dashboards._index.ts" -type f

Repository: triggerdotdev/trigger.dev

Length of output: 127


🏁 Script executed:

# List core schema files to understand structure
fd -e ts -e tsx "schemas" packages/core --type f | head -20

Repository: triggerdotdev/trigger.dev

Length of output: 262


🏁 Script executed:

# Search for ListDashboardsResponseBody in the codebase
rg "ListDashboardsResponseBody" --type ts --type tsx

Repository: triggerdotdev/trigger.dev

Length of output: 96


🏁 Script executed:

# Read the file to see the actual code
cat -n apps/webapp/app/routes/api.v1.query.dashboards._index.ts

Repository: triggerdotdev/trigger.dev

Length of output: 2057


🏁 Script executed:

# Search for ListDashboardsResponseBody with correct syntax
rg "ListDashboardsResponseBody" --type ts

Repository: triggerdotdev/trigger.dev

Length of output: 868


🏁 Script executed:

# Check the ListDashboardsResponseBody schema definition
cat -n packages/core/src/v3/schemas/query.ts | grep -A 5 "ListDashboardsResponseBody"

Repository: triggerdotdev/trigger.dev

Length of output: 507


🏁 Script executed:

# Check other routes in apps/webapp for validation patterns
rg "\.parse\(" apps/webapp/app/routes --type ts -B 2 -A 2 | head -40

Repository: triggerdotdev/trigger.dev

Length of output: 3271


🏁 Script executed:

# Check if response body validation is done in other routes
rg "json\(" apps/webapp/app/routes --type ts -B 3 -A 1 | grep -E "(parse|parse\()" -B 3 | head -50

Repository: triggerdotdev/trigger.dev

Length of output: 3534


🏁 Script executed:

# Look for response schema validation patterns in apps/webapp routes
rg "ResponseBody.*parse" apps/webapp/app/routes --type ts -B 2 -A 2

Repository: triggerdotdev/trigger.dev

Length of output: 51


🏁 Script executed:

# Check if there are patterns of validating response bodies before json() in webapp routes
rg "json\(" apps/webapp/app/routes --type ts -B 5 | grep -E "(ResponseBody|response.*parse|\.parse.*json)" -B 3 -A 1

Repository: triggerdotdev/trigger.dev

Length of output: 4513


🏁 Script executed:

# Check the schemas file to see what's exported and available
head -150 packages/core/src/v3/schemas/query.ts | tail -50

Repository: triggerdotdev/trigger.dev

Length of output: 1214


🏁 Script executed:

# Look at similar dashboard routes to see if they validate responses
find apps/webapp/app/routes -name "*dashboard*" -type f

Repository: triggerdotdev/trigger.dev

Length of output: 601


🏁 Script executed:

# Check the similar dashboard route to see if it validates responses
cat -n apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.widgets.tsx | head -150

Repository: triggerdotdev/trigger.dev

Length of output: 178


🏁 Script executed:

# Check for response validation patterns in the webapp more broadly
rg "\.parse\(" apps/webapp/app/routes --type ts -A 1 | grep -E "json\(" -B 2 | head -30

Repository: triggerdotdev/trigger.dev

Length of output: 530


🏁 Script executed:

# Check how ListDashboardsResponseBody is exported from `@trigger.dev/core/v3/schemas`
rg "export.*ListDashboardsResponseBody" packages/core --type ts

Repository: triggerdotdev/trigger.dev

Length of output: 282


🏁 Script executed:

# Check if the subpath export exists in core package.json
cat packages/core/package.json | grep -A 20 "exports"

Repository: triggerdotdev/trigger.dev

Length of output: 2527


🏁 Script executed:

# Check if ListDashboardsResponseBody is exported from v3/schemas/index.ts
grep "ListDashboardsResponseBody" packages/core/src/v3/schemas/index.ts

Repository: triggerdotdev/trigger.dev

Length of output: 51


🏁 Script executed:

# Check what query.ts exports are re-exported from index.ts
cat packages/core/src/v3/schemas/index.ts | grep -E "(export.*from|ListDashboards)"

Repository: triggerdotdev/trigger.dev

Length of output: 614


🏁 Script executed:

# Verify that query.js exports ListDashboardsResponseBody
head -5 packages/core/src/v3/schemas/query.js 2>/dev/null || echo "Checking TypeScript source..."
rg "^export" packages/core/src/v3/schemas/query.ts | head -20

Repository: triggerdotdev/trigger.dev

Length of output: 1386


🏁 Script executed:

# Confirm the import path will work by checking the v3/schemas export
cat packages/core/src/v3/index.ts | head -40

Repository: triggerdotdev/trigger.dev

Length of output: 1572


Add Zod validation to the response body.

This public API route returns a typed response but skips runtime validation. Per the coding guidelines for apps/webapp, validate the response using Zod before returning it to enforce contract safety.

Suggested change
-import type { DashboardSummary, DashboardWidgetSummary } from "@trigger.dev/core/v3/schemas";
+import {
+  ListDashboardsResponseBody,
+  type DashboardSummary,
+  type DashboardWidgetSummary,
+} from "@trigger.dev/core/v3/schemas";
@@
-    return json({ dashboards });
+    return json(ListDashboardsResponseBody.parse({ dashboards }));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/routes/api.v1.query.dashboards._index.ts` around lines 52 -
53, The route currently returns json({ dashboards }) without runtime validation;
add a Zod schema (e.g., DashboardSchema for each item and
DashboardsResponseSchema = z.object({ dashboards: z.array(DashboardSchema) }))
and validate the dashboards variable with DashboardsResponseSchema.parse or
safeParse before calling json. Import z from 'zod', validate the data produced
by whatever function populates dashboards (referencing the dashboards variable
and the json(...) return), and on validation failure throw or return an
appropriate error Response (500) so only validated data is returned.

);
58 changes: 58 additions & 0 deletions apps/webapp/app/routes/api.v1.query.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { json } from "@remix-run/server-runtime";
import type { ColumnSchema, TableSchema } from "@internal/tsql";
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { querySchemas } from "~/v3/querySchemas";

function serializeColumn(col: ColumnSchema) {
const result: Record<string, unknown> = {
name: col.name,
type: col.type,
};

if (col.description) {
result.description = col.description;
}
if (col.example) {
result.example = col.example;
}
if (col.allowedValues && col.allowedValues.length > 0) {
if (col.valueMap) {
result.allowedValues = Object.values(col.valueMap);
} else {
result.allowedValues = col.allowedValues;
}
}
if (col.coreColumn) {
result.coreColumn = true;
}

return result;
}

function serializeTable(table: TableSchema) {
const columns = Object.values(table.columns).map(serializeColumn);

return {
name: table.name,
description: table.description,
timeColumn: table.timeConstraint,
columns,
};
}

export const loader = createLoaderApiRoute(
{
allowJWT: true,
corsStrategy: "all",
findResource: async () => 1,
authorization: {
action: "read",
resource: () => ({ query: "schema" }),
superScopes: ["read:query", "read:all", "admin"],
},
},
async () => {
const tables = querySchemas.map(serializeTable);
return json({ tables });
}
);
18 changes: 18 additions & 0 deletions apps/webapp/app/routes/api.v1.query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server
import { executeQuery, type QueryScope } from "~/services/queryService.server";
import { logger } from "~/services/logger.server";
import { rowsToCSV } from "~/utils/dataExport";
import { querySchemas } from "~/v3/querySchemas";

const BodySchema = z.object({
query: z.string(),
Expand All @@ -15,10 +16,27 @@ const BodySchema = z.object({
format: z.enum(["json", "csv"]).default("json"),
});

/** Extract table names from a TRQL query for authorization */
function detectTables(query: string): string[] {
return querySchemas
.filter((s) => new RegExp(`\\bFROM\\s+${s.name}\\b`, "i").test(query))
.map((s) => s.name);
}

const { action, loader } = createActionApiRoute(
{
body: BodySchema,
allowJWT: true,
corsStrategy: "all",
findResource: async () => 1,
authorization: {
action: "read",
resource: (_, __, ___, body) => {
const tables = detectTables(body.query);
return { query: tables.length > 0 ? tables : "all" };
},
superScopes: ["read:query", "read:all", "admin"],
},
},
async ({ body, authentication }) => {
const { query, scope, period, from, to, format } = body;
Expand Down
2 changes: 1 addition & 1 deletion apps/webapp/app/services/authorization.server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export type AuthorizationAction = "read" | "write" | string; // Add more actions as needed

const ResourceTypes = ["tasks", "tags", "runs", "batch", "waitpoints", "deployments", "inputStreams"] as const;
const ResourceTypes = ["tasks", "tags", "runs", "batch", "waitpoints", "deployments", "inputStreams", "query"] as const;

export type AuthorizationResources = {
[key in (typeof ResourceTypes)[number]]?: string | string[];
Expand Down
78 changes: 78 additions & 0 deletions apps/webapp/test/authorization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,84 @@ describe("checkAuthorization", () => {
});
});

describe("Query resource type", () => {
it("should grant access with read:query super scope", () => {
const entity: AuthorizationEntity = {
type: "PUBLIC_JWT",
scopes: ["read:query"],
};
const result = checkAuthorization(
entity,
"read",
{ query: "runs" },
["read:query", "read:all", "admin"]
);
expect(result.authorized).toBe(true);
});

it("should grant access with table-specific query scope", () => {
const entity: AuthorizationEntity = {
type: "PUBLIC_JWT",
scopes: ["read:query:runs"],
};
const result = checkAuthorization(entity, "read", { query: "runs" });
expect(result.authorized).toBe(true);
});

it("should deny access to different table with table-specific scope", () => {
const entity: AuthorizationEntity = {
type: "PUBLIC_JWT",
scopes: ["read:query:runs"],
};
const result = checkAuthorization(entity, "read", { query: "llm_metrics" });
expect(result.authorized).toBe(false);
});

it("should grant access with general read:query scope to any table", () => {
const entity: AuthorizationEntity = {
type: "PUBLIC_JWT",
scopes: ["read:query"],
};

const runsResult = checkAuthorization(entity, "read", { query: "runs" });
expect(runsResult.authorized).toBe(true);

const metricsResult = checkAuthorization(entity, "read", { query: "metrics" });
expect(metricsResult.authorized).toBe(true);

const llmResult = checkAuthorization(entity, "read", { query: "llm_metrics" });
expect(llmResult.authorized).toBe(true);
});

it("should grant access to multiple tables when querying with super scope", () => {
const entity: AuthorizationEntity = {
type: "PUBLIC_JWT",
scopes: ["read:query"],
};
const result = checkAuthorization(
entity,
"read",
{ query: ["runs", "llm_metrics"] },
["read:query", "read:all", "admin"]
);
expect(result.authorized).toBe(true);
});

it("should grant access to schema with read:query scope", () => {
const entity: AuthorizationEntity = {
type: "PUBLIC_JWT",
scopes: ["read:query"],
};
const result = checkAuthorization(
entity,
"read",
{ query: "schema" },
["read:query", "read:all", "admin"]
);
expect(result.authorized).toBe(true);
});
});

describe("Without super scope", () => {
const entityWithoutSuperPermissions: AuthorizationEntity = {
type: "PUBLIC_JWT",
Expand Down
4 changes: 3 additions & 1 deletion packages/cli-v3/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,9 @@
"test:e2e": "vitest --run -c ./e2e/vitest.config.ts",
"update-version": "tsx ../../scripts/updateVersion.ts",
"install-mcp": "./install-mcp.sh",
"inspector": "npx @modelcontextprotocol/inspector dist/esm/index.js mcp --log-file .mcp.log --api-url http://localhost:3030"
"inspector": "npx @modelcontextprotocol/inspector dist/esm/index.js mcp --log-file .mcp.log --api-url http://localhost:3030",
"mcp:test": "tsx src/mcp/tools.test.ts",
"mcp:smoke": "tsx src/mcp/smoke.test.ts"
},
"dependencies": {
"@clack/prompts": "0.11.0",
Expand Down
7 changes: 7 additions & 0 deletions packages/cli-v3/src/commands/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const McpCommandOptions = CommonCommandOptions.extend({
projectRef: z.string().optional(),
logFile: z.string().optional(),
devOnly: z.boolean().default(false),
readonly: z.boolean().default(false),
rulesInstallManifestPath: z.string().optional(),
rulesInstallBranch: z.string().optional(),
});
Expand All @@ -36,6 +37,10 @@ export function configureMcpCommand(program: Command) {
"--dev-only",
"Only run the MCP server for the dev environment. Attempts to access other environments will fail."
)
.option(
"--readonly",
"Run in read-only mode. Write tools (deploy, trigger_task, cancel_run) are hidden from the AI."
)
.option("--log-file <log file>", "The file to log to")
.addOption(
new CommandOption(
Expand Down Expand Up @@ -97,6 +102,7 @@ export async function mcpCommand(options: McpCommandOptions) {

server.server.oninitialized = async () => {
fileLogger?.log("initialized mcp command", { options, argv: process.argv });
await context.loadProjectProfile();
};

// Start receiving messages on stdin and sending messages on stdout
Expand All @@ -111,6 +117,7 @@ export async function mcpCommand(options: McpCommandOptions) {
fileLogger,
apiUrl: options.apiUrl ?? CLOUD_API_URL,
profile: options.profile,
readonly: options.readonly,
});

registerTools(context);
Expand Down
3 changes: 3 additions & 0 deletions packages/cli-v3/src/dev/backgroundWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { prettyError } from "../utilities/cliOutput.js";
import { writeJSONFile } from "../utilities/fileSystem.js";
import { logger } from "../utilities/logger.js";
import type { Metafile } from "esbuild";
import { trail } from "agentcrumbs"; // @crumbs
const crumb = trail("cli"); // @crumbs

export type BackgroundWorkerOptions = {
env: Record<string, string>;
Expand Down Expand Up @@ -37,6 +39,7 @@ export class BackgroundWorker {
}

stop() {
crumb("BackgroundWorker.stop", { version: this.serverWorker?.version, outputPath: this.build.outputPath }); // @crumbs
logger.debug("[BackgroundWorker] Stopping worker", {
version: this.serverWorker?.version,
outputPath: this.build.outputPath,
Expand Down
Loading
Loading