Skip to content

Commit dbbe9f7

Browse files
authored
feat(cli): Expand and improve the MCP server and dev CLI command (#3224)
## Summary Major expansion of the MCP server (14 → 25 tools), context efficiency optimizations, new API endpoints, and a fix for the dev CLI leaking build directories on disk. ### New MCP tools - **Query & analytics**: `get_query_schema`, `query`, `list_dashboards`, `run_dashboard_query` — query your data using TRQL directly from AI assistants - **Profile management**: `whoami`, `list_profiles`, `switch_profile` — see and switch CLI profiles per-project (persisted to `.trigger/mcp.json`) - **Dev server control**: `start_dev_server`, `stop_dev_server`, `dev_server_status` — start/stop `trigger dev` and stream build output - **Task introspection**: `get_task_schema` — get payload schema for a specific task (split out from `get_current_worker` to reduce context) ### New API endpoints - `GET /api/v1/query/schema` — discover TRQL tables and columns (server-driven, multi-table) - `GET /api/v1/query/dashboards` — list built-in dashboard widgets and their queries ### New features - **`--readonly` flag** — hides write tools (`deploy`, `trigger_task`, `cancel_run`) so agents can't make changes - **`read:query` JWT scope** — new authorization scope for query endpoints, with per-table granularity (`read:query:runs`, `read:query:llm_metrics`, etc.) - **Paginated trace output** — `get_run_details` now paginates trace events via cursor, caching the full trace in a temp file so subsequent pages don't re-fetch - **MCP tool annotations** — all tools now have `readOnlyHint`/`destructiveHint` annotations for clients that support them - **Project-scoped profile persistence** — `switch_profile` saves to `.trigger/mcp.json` (gitignored), automatically loaded on next MCP server start ### Context optimizations - `get_query_schema` requires a table name — returns one table's schema instead of all tables (60-80% fewer tokens) - `get_current_worker` no longer inlines payload schemas — use `get_task_schema` for specific tasks - Query results formatted as text tables instead of JSON (~50% fewer tokens for flat data) - `cancel_run`, `list_deploys`, `list_preview_branches` formatted as text instead of raw `JSON.stringify()` - Schema and dashboard API responses cached (1hr and 5min respectively) ### Bug fixes - Fixed `search_docs` failing due to renamed upstream Mintlify tool (`SearchTriggerDev` → `search_trigger_dev`) - Fixed `list_deploys` failing when deployments have null `runtime`/`runtimeVersion` fields (fixes #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 `/api/v1/query` not accepting JWT auth (added `allowJWT: true`) ### Dev CLI build directory fix The dev CLI was leaking `build-*` directories in `.trigger/tmp/` on every rebuild, accumulating hundreds of MB over time (842MB observed). Three layers of protection added: 1. **During session**: deprecated workers are pruned (capped at 2 retained) when no active runs reference them, preventing unbounded accumulation 2. **On SIGKILL/crash**: the watchdog process now cleans up `.trigger/tmp/` when it detects the parent CLI was killed 3. **On next startup**: existing `clearTmpDirs()` wipes any remaining orphans ## Test plan - [ ] `pnpm run mcp:smoke` — 17 automated smoke tests for all read-only MCP tools - [ ] `pnpm run mcp:test list` — verify 25 tools registered (21 in `--readonly` mode) - [ ] `pnpm run mcp:test --readonly list` — verify write tools hidden - [ ] Manual: start dev server, trigger task, rebuild multiple times, verify build dirs stay capped at 4 - [ ] Manual: SIGKILL the dev CLI, verify watchdog cleans up `.trigger/tmp/` - [ ] Verify new API endpoints return correct data: `GET /api/v1/query/schema`, `GET /api/v1/query/dashboards` 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent f98e274 commit dbbe9f7

32 files changed

+2110
-58
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"trigger.dev": patch
3+
---
4+
5+
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).
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/core": patch
3+
---
4+
5+
Fix `list_deploys` MCP tool failing when deployments have null `runtime` or `runtimeVersion` fields.

.changeset/mcp-query-tools.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
"@trigger.dev/core": patch
3+
"trigger.dev": patch
4+
---
5+
6+
MCP server improvements: new tools, bug fixes, and new flags.
7+
8+
**New tools:**
9+
- `get_query_schema` — discover available TRQL tables and columns
10+
- `query` — execute TRQL queries against your data
11+
- `list_dashboards` — list built-in dashboards and their widgets
12+
- `run_dashboard_query` — execute a single dashboard widget query
13+
- `whoami` — show current profile, user, and API URL
14+
- `list_profiles` — list all configured CLI profiles
15+
- `switch_profile` — switch active profile for the MCP session
16+
- `start_dev_server` — start `trigger dev` in the background and stream output
17+
- `stop_dev_server` — stop the running dev server
18+
- `dev_server_status` — check dev server status and view recent logs
19+
20+
**New API endpoints:**
21+
- `GET /api/v1/query/schema` — query table schema discovery
22+
- `GET /api/v1/query/dashboards` — list built-in dashboards
23+
24+
**New features:**
25+
- `--readonly` flag hides write tools (`deploy`, `trigger_task`, `cancel_run`) so the AI cannot make changes
26+
- `read:query` JWT scope for query endpoint authorization
27+
- `get_run_details` trace output is now paginated with cursor support
28+
- MCP tool annotations (`readOnlyHint`, `destructiveHint`) for all tools
29+
30+
**Bug fixes:**
31+
- Fixed `search_docs` tool failing due to renamed upstream Mintlify tool (`SearchTriggerDev``search_trigger_dev`)
32+
- Fixed `list_deploys` failing when deployments have null `runtime`/`runtimeVersion` fields (#3139)
33+
- Fixed `list_preview_branches` crashing due to incorrect response shape access
34+
- Fixed `metrics` table column documented as `value` instead of `metric_value` in query docs
35+
- Fixed dev CLI leaking build directories on rebuild — deprecated workers now clean up their build dirs when their last run completes
36+
37+
**Context optimizations:**
38+
- `get_query_schema` now requires a table name and returns only one table's schema (was returning all tables)
39+
- `get_current_worker` no longer inlines payload schemas; use new `get_task_schema` tool instead
40+
- Query results formatted as text tables instead of JSON (~50% fewer tokens)
41+
- `cancel_run`, `list_deploys`, `list_preview_branches` formatted as text instead of raw JSON
42+
- Schema and dashboard API responses cached to avoid redundant fetches
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { json } from "@remix-run/server-runtime";
2+
import type { DashboardSummary, DashboardWidgetSummary } from "@trigger.dev/core/v3/schemas";
3+
import type { BuiltInDashboard } from "~/presenters/v3/MetricDashboardPresenter.server";
4+
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
5+
import { builtInDashboard } from "~/presenters/v3/BuiltInDashboards.server";
6+
7+
const BUILT_IN_DASHBOARD_KEYS = ["overview", "llm"];
8+
9+
function serializeDashboard(dashboard: BuiltInDashboard): DashboardSummary {
10+
const widgets: DashboardWidgetSummary[] = [];
11+
12+
if (dashboard.layout.version === "1") {
13+
for (const [id, widget] of Object.entries(dashboard.layout.widgets)) {
14+
// Skip title widgets — they're just section headers
15+
if (widget.display.type === "title") continue;
16+
17+
widgets.push({
18+
id,
19+
title: widget.title,
20+
query: widget.query,
21+
type: widget.display.type,
22+
});
23+
}
24+
}
25+
26+
return {
27+
key: dashboard.key,
28+
title: dashboard.title,
29+
widgets,
30+
};
31+
}
32+
33+
export const loader = createLoaderApiRoute(
34+
{
35+
allowJWT: true,
36+
corsStrategy: "all",
37+
findResource: async () => 1,
38+
authorization: {
39+
action: "read",
40+
resource: () => ({ query: "dashboards" }),
41+
superScopes: ["read:query", "read:all", "admin"],
42+
},
43+
},
44+
async () => {
45+
const dashboards = BUILT_IN_DASHBOARD_KEYS.map((key) => {
46+
try {
47+
return serializeDashboard(builtInDashboard(key));
48+
} catch {
49+
return null;
50+
}
51+
}).filter((d): d is DashboardSummary => d !== null);
52+
return json({ dashboards });
53+
}
54+
);
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { json } from "@remix-run/server-runtime";
2+
import type { ColumnSchema, TableSchema } from "@internal/tsql";
3+
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
4+
import { querySchemas } from "~/v3/querySchemas";
5+
6+
function serializeColumn(col: ColumnSchema) {
7+
const result: Record<string, unknown> = {
8+
name: col.name,
9+
type: col.type,
10+
};
11+
12+
if (col.description) {
13+
result.description = col.description;
14+
}
15+
if (col.example) {
16+
result.example = col.example;
17+
}
18+
if (col.allowedValues && col.allowedValues.length > 0) {
19+
if (col.valueMap) {
20+
result.allowedValues = Object.values(col.valueMap);
21+
} else {
22+
result.allowedValues = col.allowedValues;
23+
}
24+
}
25+
if (col.coreColumn) {
26+
result.coreColumn = true;
27+
}
28+
29+
return result;
30+
}
31+
32+
function serializeTable(table: TableSchema) {
33+
const columns = Object.values(table.columns).map(serializeColumn);
34+
35+
return {
36+
name: table.name,
37+
description: table.description,
38+
timeColumn: table.timeConstraint,
39+
columns,
40+
};
41+
}
42+
43+
export const loader = createLoaderApiRoute(
44+
{
45+
allowJWT: true,
46+
corsStrategy: "all",
47+
findResource: async () => 1,
48+
authorization: {
49+
action: "read",
50+
resource: () => ({ query: "schema" }),
51+
superScopes: ["read:query", "read:all", "admin"],
52+
},
53+
},
54+
async () => {
55+
const tables = querySchemas.map(serializeTable);
56+
return json({ tables });
57+
}
58+
);

apps/webapp/app/routes/api.v1.query.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server
55
import { executeQuery, type QueryScope } from "~/services/queryService.server";
66
import { logger } from "~/services/logger.server";
77
import { rowsToCSV } from "~/utils/dataExport";
8+
import { querySchemas } from "~/v3/querySchemas";
89

910
const BodySchema = z.object({
1011
query: z.string(),
@@ -15,10 +16,30 @@ const BodySchema = z.object({
1516
format: z.enum(["json", "csv"]).default("json"),
1617
});
1718

19+
/** Extract table names from a TRQL query for authorization */
20+
function detectTables(query: string): string[] {
21+
return querySchemas
22+
.filter((s) => {
23+
const escaped = s.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
24+
return new RegExp(`\\bFROM\\s+${escaped}\\b`, "i").test(query);
25+
})
26+
.map((s) => s.name);
27+
}
28+
1829
const { action, loader } = createActionApiRoute(
1930
{
2031
body: BodySchema,
32+
allowJWT: true,
2133
corsStrategy: "all",
34+
findResource: async () => 1,
35+
authorization: {
36+
action: "read",
37+
resource: (_, __, ___, body) => {
38+
const tables = detectTables(body.query);
39+
return { query: tables.length > 0 ? tables : "all" };
40+
},
41+
superScopes: ["read:query", "read:all", "admin"],
42+
},
2243
},
2344
async ({ body, authentication }) => {
2445
const { query, scope, period, from, to, format } = body;

apps/webapp/app/services/authorization.server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
export type AuthorizationAction = "read" | "write" | string; // Add more actions as needed
22

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

55
export type AuthorizationResources = {
66
[key in (typeof ResourceTypes)[number]]?: string | string[];

apps/webapp/test/authorization.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,84 @@ describe("checkAuthorization", () => {
310310
});
311311
});
312312

313+
describe("Query resource type", () => {
314+
it("should grant access with read:query super scope", () => {
315+
const entity: AuthorizationEntity = {
316+
type: "PUBLIC_JWT",
317+
scopes: ["read:query"],
318+
};
319+
const result = checkAuthorization(
320+
entity,
321+
"read",
322+
{ query: "runs" },
323+
["read:query", "read:all", "admin"]
324+
);
325+
expect(result.authorized).toBe(true);
326+
});
327+
328+
it("should grant access with table-specific query scope", () => {
329+
const entity: AuthorizationEntity = {
330+
type: "PUBLIC_JWT",
331+
scopes: ["read:query:runs"],
332+
};
333+
const result = checkAuthorization(entity, "read", { query: "runs" });
334+
expect(result.authorized).toBe(true);
335+
});
336+
337+
it("should deny access to different table with table-specific scope", () => {
338+
const entity: AuthorizationEntity = {
339+
type: "PUBLIC_JWT",
340+
scopes: ["read:query:runs"],
341+
};
342+
const result = checkAuthorization(entity, "read", { query: "llm_metrics" });
343+
expect(result.authorized).toBe(false);
344+
});
345+
346+
it("should grant access with general read:query scope to any table", () => {
347+
const entity: AuthorizationEntity = {
348+
type: "PUBLIC_JWT",
349+
scopes: ["read:query"],
350+
};
351+
352+
const runsResult = checkAuthorization(entity, "read", { query: "runs" });
353+
expect(runsResult.authorized).toBe(true);
354+
355+
const metricsResult = checkAuthorization(entity, "read", { query: "metrics" });
356+
expect(metricsResult.authorized).toBe(true);
357+
358+
const llmResult = checkAuthorization(entity, "read", { query: "llm_metrics" });
359+
expect(llmResult.authorized).toBe(true);
360+
});
361+
362+
it("should grant access to multiple tables when querying with super scope", () => {
363+
const entity: AuthorizationEntity = {
364+
type: "PUBLIC_JWT",
365+
scopes: ["read:query"],
366+
};
367+
const result = checkAuthorization(
368+
entity,
369+
"read",
370+
{ query: ["runs", "llm_metrics"] },
371+
["read:query", "read:all", "admin"]
372+
);
373+
expect(result.authorized).toBe(true);
374+
});
375+
376+
it("should grant access to schema with read:query scope", () => {
377+
const entity: AuthorizationEntity = {
378+
type: "PUBLIC_JWT",
379+
scopes: ["read:query"],
380+
};
381+
const result = checkAuthorization(
382+
entity,
383+
"read",
384+
{ query: "schema" },
385+
["read:query", "read:all", "admin"]
386+
);
387+
expect(result.authorized).toBe(true);
388+
});
389+
});
390+
313391
describe("Without super scope", () => {
314392
const entityWithoutSuperPermissions: AuthorizationEntity = {
315393
type: "PUBLIC_JWT",

packages/cli-v3/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,9 @@
7878
"test:e2e": "vitest --run -c ./e2e/vitest.config.ts",
7979
"update-version": "tsx ../../scripts/updateVersion.ts",
8080
"install-mcp": "./install-mcp.sh",
81-
"inspector": "npx @modelcontextprotocol/inspector dist/esm/index.js mcp --log-file .mcp.log --api-url http://localhost:3030"
81+
"inspector": "npx @modelcontextprotocol/inspector dist/esm/index.js mcp --log-file .mcp.log --api-url http://localhost:3030",
82+
"mcp:test": "tsx src/mcp/tools.test.ts",
83+
"mcp:smoke": "tsx src/mcp/smoke.test.ts"
8284
},
8385
"dependencies": {
8486
"@clack/prompts": "0.11.0",

packages/cli-v3/src/commands/mcp.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const McpCommandOptions = CommonCommandOptions.extend({
2020
projectRef: z.string().optional(),
2121
logFile: z.string().optional(),
2222
devOnly: z.boolean().default(false),
23+
readonly: z.boolean().default(false),
2324
rulesInstallManifestPath: z.string().optional(),
2425
rulesInstallBranch: z.string().optional(),
2526
});
@@ -36,6 +37,10 @@ export function configureMcpCommand(program: Command) {
3637
"--dev-only",
3738
"Only run the MCP server for the dev environment. Attempts to access other environments will fail."
3839
)
40+
.option(
41+
"--readonly",
42+
"Run in read-only mode. Write tools (deploy, trigger_task, cancel_run) are hidden from the AI."
43+
)
3944
.option("--log-file <log file>", "The file to log to")
4045
.addOption(
4146
new CommandOption(
@@ -97,6 +102,7 @@ export async function mcpCommand(options: McpCommandOptions) {
97102

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

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

116123
registerTools(context);

0 commit comments

Comments
 (0)