Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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 thread
ericallam marked this conversation as resolved.
);
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 });
}
);
21 changes: 21 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,30 @@ 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) => {
const escaped = s.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(`\\bFROM\\s+${escaped}\\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" };
Comment thread
ericallam marked this conversation as resolved.
},
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
Loading
Loading