Skip to content

Commit f8e2dd1

Browse files
committed
MCP auth and list_projects tool
1 parent ce7420c commit f8e2dd1

File tree

11 files changed

+1328
-23
lines changed

11 files changed

+1328
-23
lines changed

packages/cli-v3/.mcp.log

Lines changed: 1019 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ try {
7979
// Add/update trigger.dev entry
8080
config.mcpServers['trigger'] = {
8181
command: nodePath,
82-
args: [cliPath, 'mcp', '--log-file', logFile]
82+
args: [cliPath, 'mcp', '--log-file', logFile, '--api-url', 'http://localhost:3030']
8383
};
8484
8585
// Write back to file with proper formatting

packages/cli-v3/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@
7777
"test:e2e": "vitest --run -c ./e2e/vitest.config.ts",
7878
"update-version": "tsx ../../scripts/updateVersion.ts",
7979
"install-mcp": "./install-mcp.sh",
80-
"inspector": "npx @modelcontextprotocol/inspector dist/esm/index.js mcp --log-file .mcp.log"
80+
"inspector": "npx @modelcontextprotocol/inspector dist/esm/index.js mcp --log-file .mcp.log --api-url http://localhost:3030"
8181
},
8282
"dependencies": {
8383
"@clack/prompts": "^0.10.0",

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,7 @@ export async function login(options?: LoginOptions): Promise<LoginResult> {
346346
});
347347
}
348348

349-
async function getPersonalAccessToken(apiClient: CliApiClient, authorizationCode: string) {
349+
export async function getPersonalAccessToken(apiClient: CliApiClient, authorizationCode: string) {
350350
return await tracer.startActiveSpan("getPersonalAccessToken", async (span) => {
351351
try {
352352
const token = await apiClient.getPersonalAccessToken(authorizationCode);

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

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import { performSearch } from "../mcp/mintlifyClient.js";
88
import { logger } from "../utilities/logger.js";
99
import { FileLogger } from "../mcp/logger.js";
1010
import { McpContext } from "../mcp/context.js";
11-
import { registerGetProjectDetailsTool } from "../mcp/tools.js";
11+
import { registerGetProjectDetailsTool, registerListProjectsTool } from "../mcp/tools.js";
12+
import { CLOUD_API_URL } from "../consts.js";
1213

1314
const McpCommandOptions = CommonCommandOptions.extend({
1415
projectRef: z.string().optional(),
@@ -34,18 +35,6 @@ export function configureMcpCommand(program: Command) {
3435
export async function mcpCommand(options: McpCommandOptions) {
3536
logger.loggerLevel = "none";
3637

37-
const authorization = await login({
38-
embedded: true,
39-
silent: true,
40-
defaultApiUrl: options.apiUrl,
41-
profile: options.profile,
42-
});
43-
44-
if (!authorization.ok) {
45-
process.exitCode = 1;
46-
return;
47-
}
48-
4938
const server = new McpServer({
5039
name: "triggerdev",
5140
version: "1.0.0",
@@ -57,11 +46,14 @@ export async function mcpCommand(options: McpCommandOptions) {
5746
: undefined;
5847

5948
const context = new McpContext(server, {
60-
login: authorization,
6149
projectRef: options.projectRef,
6250
fileLogger,
51+
apiUrl: options.apiUrl ?? CLOUD_API_URL,
52+
profile: options.profile,
6353
});
6454

55+
fileLogger?.log("running mcp command", { options });
56+
6557
server.registerTool(
6658
"search_docs",
6759
{
@@ -78,6 +70,7 @@ export async function mcpCommand(options: McpCommandOptions) {
7870
);
7971

8072
registerGetProjectDetailsTool(context);
73+
registerListProjectsTool(context);
8174

8275
// Start receiving messages on stdin and sending messages on stdout
8376
const transport = new StdioServerTransport();

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

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2+
import { env } from "std-env";
3+
import { CliApiClient } from "../apiClient.js";
4+
import { CLOUD_API_URL } from "../consts.js";
5+
import { readAuthConfigProfile, writeAuthConfigProfile } from "../utilities/configFiles.js";
6+
import {
7+
isPersonalAccessToken,
8+
NotPersonalAccessTokenError,
9+
} from "../utilities/isPersonalAccessToken.js";
10+
import { LoginResult } from "../utilities/session.js";
11+
import { getPersonalAccessToken } from "../commands/login.js";
12+
import open from "open";
13+
import pRetry from "p-retry";
14+
import { McpContext } from "./context.js";
15+
16+
export type McpAuthOptions = {
17+
server: McpServer;
18+
context: McpContext;
19+
defaultApiUrl?: string;
20+
profile?: string;
21+
};
22+
23+
export async function mcpAuth(options: McpAuthOptions): Promise<LoginResult> {
24+
const opts = {
25+
defaultApiUrl: CLOUD_API_URL,
26+
...options,
27+
};
28+
29+
const accessTokenFromEnv = env.TRIGGER_ACCESS_TOKEN;
30+
31+
if (accessTokenFromEnv) {
32+
if (!isPersonalAccessToken(accessTokenFromEnv)) {
33+
throw new NotPersonalAccessTokenError(
34+
"Your TRIGGER_ACCESS_TOKEN is not a Personal Access Token, they start with 'tr_pat_'. You can generate one here: https://cloud.trigger.dev/account/tokens"
35+
);
36+
}
37+
38+
const auth = {
39+
accessToken: accessTokenFromEnv,
40+
apiUrl: env.TRIGGER_API_URL ?? opts.defaultApiUrl ?? CLOUD_API_URL,
41+
};
42+
43+
const apiClient = new CliApiClient(auth.apiUrl, auth.accessToken);
44+
const userData = await apiClient.whoAmI();
45+
46+
if (!userData.success) {
47+
throw new Error(userData.error);
48+
}
49+
50+
return {
51+
ok: true as const,
52+
profile: options?.profile ?? "default",
53+
userId: userData.data.userId,
54+
email: userData.data.email,
55+
dashboardUrl: userData.data.dashboardUrl,
56+
auth: {
57+
accessToken: auth.accessToken,
58+
apiUrl: auth.apiUrl,
59+
},
60+
};
61+
}
62+
63+
const authConfig = readAuthConfigProfile(options?.profile);
64+
65+
if (authConfig && authConfig.accessToken) {
66+
const apiClient = new CliApiClient(
67+
authConfig.apiUrl ?? opts.defaultApiUrl,
68+
authConfig.accessToken
69+
);
70+
const userData = await apiClient.whoAmI();
71+
72+
if (!userData.success) {
73+
throw new Error(userData.error);
74+
}
75+
76+
return {
77+
ok: true as const,
78+
profile: options?.profile ?? "default",
79+
userId: userData.data.userId,
80+
email: userData.data.email,
81+
dashboardUrl: userData.data.dashboardUrl,
82+
auth: {
83+
accessToken: authConfig.accessToken,
84+
apiUrl: authConfig.apiUrl ?? opts.defaultApiUrl,
85+
},
86+
};
87+
}
88+
89+
const apiClient = new CliApiClient(authConfig?.apiUrl ?? opts.defaultApiUrl);
90+
91+
//generate authorization code
92+
const authorizationCodeResult = await createAuthorizationCode(apiClient);
93+
94+
// Only elicitInput if the client has the elicitation capability
95+
96+
// Elicit the user to visit the authorization code URL
97+
const allowLogin = await askForLoginPermission(opts.server, authorizationCodeResult.url);
98+
99+
if (!allowLogin) {
100+
return {
101+
ok: false as const,
102+
error: "User did not allow login",
103+
};
104+
}
105+
106+
// Open the authorization code URL in the browser
107+
await open(authorizationCodeResult.url);
108+
109+
// Poll for the personal access token
110+
const indexResult = await pRetry(
111+
() => getPersonalAccessToken(apiClient, authorizationCodeResult.authorizationCode),
112+
{
113+
//this means we're polling, same distance between each attempt
114+
factor: 1,
115+
retries: 60,
116+
minTimeout: 1000,
117+
}
118+
);
119+
120+
writeAuthConfigProfile(
121+
{ accessToken: indexResult.token, apiUrl: opts.defaultApiUrl },
122+
options?.profile
123+
);
124+
125+
const client = new CliApiClient(opts.defaultApiUrl, indexResult.token);
126+
const userData = await client.whoAmI();
127+
128+
if (!userData.success) {
129+
throw new Error(userData.error);
130+
}
131+
132+
return {
133+
ok: true as const,
134+
profile: options?.profile ?? "default",
135+
userId: userData.data.userId,
136+
email: userData.data.email,
137+
dashboardUrl: userData.data.dashboardUrl,
138+
auth: {
139+
accessToken: indexResult.token,
140+
apiUrl: opts.defaultApiUrl,
141+
},
142+
};
143+
}
144+
145+
async function createAuthorizationCode(apiClient: CliApiClient) {
146+
const authorizationCodeResult = await apiClient.createAuthorizationCode();
147+
148+
if (!authorizationCodeResult.success) {
149+
throw new Error(`Failed to create authorization code\n${authorizationCodeResult.error}`);
150+
}
151+
152+
return authorizationCodeResult.data;
153+
}
154+
155+
async function askForLoginPermission(server: McpServer, authorizationCodeUrl: string) {
156+
const capabilities = server.server.getClientCapabilities();
157+
158+
if (typeof capabilities?.elicitation !== "object") {
159+
return true;
160+
}
161+
162+
const result = await server.server.elicitInput({
163+
message: `You are not currently logged in. Would you like to login now? We'll automatically open the authorization code URL (${authorizationCodeUrl}) in your browser.`,
164+
requestedSchema: {
165+
type: "object",
166+
properties: {
167+
allowLogin: {
168+
type: "boolean",
169+
default: false,
170+
title: "Allow Login",
171+
description: "Whether to allow the user to login",
172+
},
173+
},
174+
required: ["allowLogin"],
175+
},
176+
});
177+
178+
return result.action === "accept" && result.content?.allowLogin;
179+
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
22
import { FileLogger } from "./logger.js";
3-
import { LoginResult } from "../utilities/session.js";
43

54
export type McpContextOptions = {
6-
login: LoginResult;
75
projectRef?: string;
86
fileLogger?: FileLogger;
7+
apiUrl?: string;
8+
profile?: string;
99
};
1010

1111
export class McpContext {

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { z } from "zod";
2+
3+
export const ProjectRefSchema = z
4+
.string()
5+
.describe(
6+
"The trigger.dev project ref, starts with proj_. We will attempt to automatically detect the project ref if running inside a directory that includes a trigger.config.ts file, or if you pass the --project-ref option to the MCP server."
7+
)
8+
.optional();

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

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,90 @@
1-
import z from "zod";
1+
import { GetProjectsResponseBody } from "@trigger.dev/core/v3/schemas";
2+
import { CliApiClient } from "../apiClient.js";
3+
import { mcpAuth } from "./auth.js";
24
import { McpContext } from "./context.js";
5+
import { ProjectRefSchema } from "./schemas.js";
6+
import { respondWithError } from "./utils.js";
37

48
export function registerGetProjectDetailsTool(context: McpContext) {
59
context.server.registerTool(
610
"get_project_details",
711
{
812
description: "Get the details of the project",
913
inputSchema: {
10-
projectRef: z.string().optional(),
14+
projectRef: ProjectRefSchema,
1115
},
1216
},
1317
async ({ projectRef }, extra) => {
18+
const auth = await mcpAuth({
19+
server: context.server,
20+
defaultApiUrl: context.options.apiUrl,
21+
profile: context.options.profile,
22+
context,
23+
});
24+
25+
if (!auth.ok) {
26+
throw new Error(auth.error);
27+
}
28+
1429
const roots = await context.server.server.listRoots();
1530

16-
context.logger?.log("get_project_details", { roots, projectRef, extra });
31+
context.logger?.log("get_project_details", { roots, projectRef, extra, auth });
1732

1833
return {
1934
content: [{ type: "text", text: "Not implemented" }],
2035
};
2136
}
2237
);
2338
}
39+
40+
export function registerListProjectsTool(context: McpContext) {
41+
context.server.registerTool(
42+
"list_projects",
43+
{
44+
description: "List all projects",
45+
outputSchema: {
46+
projects: GetProjectsResponseBody,
47+
},
48+
},
49+
async (_, extra) => {
50+
context.logger?.log("calling list_projects", { extra });
51+
52+
const auth = await mcpAuth({
53+
server: context.server,
54+
defaultApiUrl: context.options.apiUrl,
55+
profile: context.options.profile,
56+
context,
57+
});
58+
59+
if (!auth.ok) {
60+
return respondWithError(auth.error);
61+
}
62+
63+
const roots = await context.server.server.listRoots();
64+
65+
context.logger?.log("list_projects", { roots, extra, auth });
66+
67+
const cliApiClient = new CliApiClient(auth.auth.apiUrl, auth.auth.accessToken);
68+
69+
const projects = await cliApiClient.getProjects();
70+
71+
if (!projects.success) {
72+
return respondWithError(projects.error);
73+
}
74+
75+
context.logger?.log("list_projects", { projects: projects.data });
76+
77+
return {
78+
structuredContent: {
79+
projects: projects.data,
80+
},
81+
content: [
82+
{
83+
type: "text",
84+
text: JSON.stringify(projects.data, null, 2),
85+
},
86+
],
87+
};
88+
}
89+
);
90+
}

0 commit comments

Comments
 (0)