diff --git a/mcp-worker/src/apiClient.ts b/mcp-worker/src/apiClient.ts index dbf35b39..4103aa75 100644 --- a/mcp-worker/src/apiClient.ts +++ b/mcp-worker/src/apiClient.ts @@ -1,6 +1,11 @@ import type { UserProps, DevCycleJWTClaims } from './types' import { IDevCycleApiClient } from '../../src/mcp/api/interface' -import { getErrorMessage, ensureError } from '../../src/mcp/utils/api' +import { + getErrorMessage, + ensureError, + projectKeyFromArgs, + MISSING_PROJECT_KEY_ERROR, +} from '../../src/mcp/utils/api' import { setMCPHeaders, setMCPToolCommand } from '../../src/mcp/utils/headers' /** @@ -42,12 +47,10 @@ export class WorkerApiClient implements IDevCycleApiClient { requiresProject: boolean = true, ): Promise { const authToken = this.getAuthToken() - const projectKey = await this.getProjectKey() + const projectKey = await this.resolveProjectKey(args) if (requiresProject && !projectKey) { - throw new Error( - 'No project key found, please select a project using the select_project tool first.', - ) + throw new Error(MISSING_PROJECT_KEY_ERROR) } // Set MCP analytics headers for this specific tool operation @@ -96,7 +99,7 @@ export class WorkerApiClient implements IDevCycleApiClient { ) const orgId = this.getOrgId() - const projectKey = await this.getProjectKey() + const projectKey = await this.resolveProjectKey(args) const link = dashboardLink(orgId, projectKey, result) return { @@ -115,6 +118,19 @@ export class WorkerApiClient implements IDevCycleApiClient { return this.props.tokenSet.accessToken } + /** + * Resolve the project for a call: the tool's own projectKey argument wins, + * otherwise fall back to the session's selected project / JWT claims. + * + * The argument takes priority because a host may start a new MCP session + * per request, in which case nothing survives from a prior select_project. + */ + private async resolveProjectKey( + args: unknown, + ): Promise { + return projectKeyFromArgs(args) ?? (await this.getProjectKey()) + } + /** * Get the project key from McpAgent state first, then fall back to JWT claims */ diff --git a/mcp-worker/src/projectSelectionTools.ts b/mcp-worker/src/projectSelectionTools.ts index cc512872..f5402504 100644 --- a/mcp-worker/src/projectSelectionTools.ts +++ b/mcp-worker/src/projectSelectionTools.ts @@ -113,6 +113,7 @@ export function registerProjectSelectionTools( description: [ 'Select a project to use for subsequent MCP operations.', 'Call without parameters to list available projects.', + 'Selection is remembered per session. If your MCP host starts a new session per request, pass a projectKey argument to each tool instead.', 'Do not automatically select a project, ask the user which project they want to select.', 'Returns the current project, its environments, and SDK keys.', 'Include dashboard link in the response.', diff --git a/src/mcp/tools/featureTools.ts b/src/mcp/tools/featureTools.ts index c8f555f1..92ac239c 100644 --- a/src/mcp/tools/featureTools.ts +++ b/src/mcp/tools/featureTools.ts @@ -18,7 +18,11 @@ import { } from '../types' import { IDevCycleApiClient } from '../api/interface' import { DevCycleMCPServerInstance } from '../server' -import { handleZodiosValidationErrors } from '../utils/api' +import { + handleZodiosValidationErrors, + omitProjectKey, + MISSING_PROJECT_KEY_ERROR, +} from '../utils/api' import { dashboardLinks } from '../utils/dashboardLinks' import { fetchAiPromptsAndRules } from '../utils/github' import { CleanupFeatureArgsSchema } from '../types' @@ -33,12 +37,11 @@ export async function listFeaturesHandler( args, async (authToken: string, projectKey: string | undefined) => { if (!projectKey) { - throw new Error( - 'Project key is required for this operation. Please select a project using the select_project tool first.', - ) + throw new Error(MISSING_PROJECT_KEY_ERROR) } return await handleZodiosValidationErrors( - () => fetchFeatures(authToken, projectKey, args), + () => + fetchFeatures(authToken, projectKey, omitProjectKey(args)), 'listFeatures', ) }, @@ -59,12 +62,11 @@ export async function createFeatureHandler( args, async (authToken: string, projectKey: string | undefined) => { if (!projectKey) { - throw new Error( - 'Project key is required for this operation. Please select a project using the select_project tool first.', - ) + throw new Error(MISSING_PROJECT_KEY_ERROR) } return await handleZodiosValidationErrors( - () => createFeature(authToken, projectKey, args), + () => + createFeature(authToken, projectKey, omitProjectKey(args)), 'createFeature', ) }, @@ -82,16 +84,14 @@ export async function updateFeatureHandler( args: z.infer, apiClient: IDevCycleApiClient, ) { - const { key, ...updateData } = args + const { key, ...updateData } = omitProjectKey(args) return await apiClient.executeWithDashboardLink( 'updateFeature', args, async (authToken: string, projectKey: string | undefined) => { if (!projectKey) { - throw new Error( - 'Project key is required for this operation. Please select a project using the select_project tool first.', - ) + throw new Error(MISSING_PROJECT_KEY_ERROR) } return await handleZodiosValidationErrors( () => updateFeature(authToken, projectKey, key, updateData), @@ -112,16 +112,14 @@ export async function updateFeatureStatusHandler( args: z.infer, apiClient: IDevCycleApiClient, ) { - const { key, ...statusData } = args + const { key, ...statusData } = omitProjectKey(args) return await apiClient.executeWithDashboardLink( 'updateFeatureStatus', args, async (authToken: string, projectKey: string | undefined) => { if (!projectKey) { - throw new Error( - 'Project key is required for this operation. Please select a project using the select_project tool first.', - ) + throw new Error(MISSING_PROJECT_KEY_ERROR) } return await handleZodiosValidationErrors( () => @@ -148,9 +146,7 @@ export async function deleteFeatureHandler( args, async (authToken: string, projectKey: string | undefined) => { if (!projectKey) { - throw new Error( - 'Project key is required for this operation. Please select a project using the select_project tool first.', - ) + throw new Error(MISSING_PROJECT_KEY_ERROR) } await handleZodiosValidationErrors( () => deleteFeature(authToken, projectKey, args.key), @@ -173,11 +169,9 @@ export async function getFeatureAuditLogHistoryHandler( args, async (authToken: string, projectKey: string | undefined) => { if (!projectKey) { - throw new Error( - 'Project key is required for this operation. Please select a project using the select_project tool first.', - ) + throw new Error(MISSING_PROJECT_KEY_ERROR) } - const { feature_key, ...auditLogOptions } = args + const { feature_key, ...auditLogOptions } = omitProjectKey(args) return await handleZodiosValidationErrors( () => getFeatureAuditLogHistory( diff --git a/src/mcp/tools/localProjectTools.ts b/src/mcp/tools/localProjectTools.ts index 789d6e49..9907e640 100644 --- a/src/mcp/tools/localProjectTools.ts +++ b/src/mcp/tools/localProjectTools.ts @@ -115,6 +115,7 @@ export function registerLocalProjectTools( description: [ 'Select a project to use for subsequent MCP operations.', 'Call without parameters to list available projects.', + 'Selection is remembered per session. If your MCP host starts a new session per request, pass a projectKey argument to each tool instead.', 'Do not automatically select a project, ask the user which project they want to select.', 'This will update your local DevCycle configuration for the MCP and CLI (~/.config/devcycle/user.yml).', 'Returns the current project, its environments, and SDK keys.', diff --git a/src/mcp/tools/projectTools.ts b/src/mcp/tools/projectTools.ts index 0a575a63..316d48c1 100644 --- a/src/mcp/tools/projectTools.ts +++ b/src/mcp/tools/projectTools.ts @@ -1,4 +1,9 @@ -import { handleZodiosValidationErrors } from '../utils/api' +import { z } from 'zod' +import { + handleZodiosValidationErrors, + MISSING_PROJECT_KEY_ERROR, +} from '../utils/api' +import { ProjectScopedArgsSchema } from '../types' import { fetchProject } from '../../api/projects' import { fetchEnvironments } from '../../api/environments' import { IDevCycleApiClient } from '../api/interface' @@ -6,15 +11,16 @@ import { DevCycleMCPServerInstance } from '../server' import { formatProjectWithEnvironments } from '../utils/projectFormatting' import { dashboardLinks } from '../utils/dashboardLinks' -export async function getCurrentProjectHandler(apiClient: IDevCycleApiClient) { +export async function getCurrentProjectHandler( + args: z.infer, + apiClient: IDevCycleApiClient, +) { return await apiClient.executeWithDashboardLink( 'getCurrentProject', - null, + args, async (authToken: string, projectKey: string | undefined) => { if (!projectKey) { - throw new Error( - 'Project key is required for getting current project. Please select a project using the select_project tool first.', - ) + throw new Error(MISSING_PROJECT_KEY_ERROR) } // Fetch the current project details @@ -48,8 +54,8 @@ export function registerProjectTools( 'get_current_project', { description: [ - 'Get the currently selected project.', - 'Only call this tool if you have already selected a project using the select_project tool.', + 'Get a project, defaulting to the currently selected one.', + 'Only call this tool if you have already selected a project using the select_project tool, or you are passing a projectKey.', 'Include dashboard link in the response.', 'Returns the current project, its environments, and SDK keys.', ].join('\n'), @@ -57,10 +63,11 @@ export function registerProjectTools( title: 'Get Current Project', readOnlyHint: true, }, - inputSchema: {}, // No parameters needed + inputSchema: ProjectScopedArgsSchema.shape, }, - async () => { - return await getCurrentProjectHandler(apiClient) + async (args: unknown) => { + const validatedArgs = ProjectScopedArgsSchema.parse(args) + return await getCurrentProjectHandler(validatedArgs, apiClient) }, ) } diff --git a/src/mcp/tools/resultsTools.ts b/src/mcp/tools/resultsTools.ts index d4606709..c8818a6a 100644 --- a/src/mcp/tools/resultsTools.ts +++ b/src/mcp/tools/resultsTools.ts @@ -1,5 +1,9 @@ import { z } from 'zod' -import { handleZodiosValidationErrors } from '../utils/api' +import { + handleZodiosValidationErrors, + omitProjectKey, + MISSING_PROJECT_KEY_ERROR, +} from '../utils/api' import { fetchFeatureTotalEvaluations, fetchProjectTotalEvaluations, @@ -22,11 +26,9 @@ export async function getFeatureTotalEvaluationsHandler( args, async (authToken: string, projectKey: string | undefined) => { if (!projectKey) { - throw new Error( - 'Project key is required for this operation. Please select a project using the select_project tool first.', - ) + throw new Error(MISSING_PROJECT_KEY_ERROR) } - const { featureKey, ...apiQueries } = args + const { featureKey, ...apiQueries } = omitProjectKey(args) return await handleZodiosValidationErrors( () => @@ -57,12 +59,15 @@ export async function getProjectTotalEvaluationsHandler( args, async (authToken: string, projectKey: string | undefined) => { if (!projectKey) { - throw new Error( - 'Project key is required for this operation. Please select a project using the select_project tool first.', - ) + throw new Error(MISSING_PROJECT_KEY_ERROR) } return await handleZodiosValidationErrors( - () => fetchProjectTotalEvaluations(authToken, projectKey, args), + () => + fetchProjectTotalEvaluations( + authToken, + projectKey, + omitProjectKey(args), + ), 'fetchProjectTotalEvaluations', ) }, diff --git a/src/mcp/tools/selfTargetingTools.ts b/src/mcp/tools/selfTargetingTools.ts index 66aa49a8..a5bf792c 100644 --- a/src/mcp/tools/selfTargetingTools.ts +++ b/src/mcp/tools/selfTargetingTools.ts @@ -1,5 +1,8 @@ import { z } from 'zod' -import { handleZodiosValidationErrors } from '../utils/api' +import { + handleZodiosValidationErrors, + MISSING_PROJECT_KEY_ERROR, +} from '../utils/api' import { fetchUserProfile, updateUserProfile } from '../../api/userProfile' import { fetchProjectOverridesForUser, @@ -10,6 +13,7 @@ import { UpdateSelfTargetingIdentityArgsSchema, SetSelfTargetingOverrideArgsSchema, ClearSelfTargetingOverridesArgsSchema, + ProjectScopedArgsSchema, } from '../types' import { IDevCycleApiClient } from '../api/interface' import { DevCycleMCPServerInstance } from '../server' @@ -17,16 +21,15 @@ import { dashboardLinks } from '../utils/dashboardLinks' // Individual handler functions export async function getSelfTargetingIdentityHandler( + args: z.infer, apiClient: IDevCycleApiClient, ) { return await apiClient.executeWithDashboardLink( 'getSelfTargetingIdentity', - null, + args, async (authToken: string, projectKey: string | undefined) => { if (!projectKey) { - throw new Error( - 'Project key is required for this operation. Please select a project using the select_project tool first.', - ) + throw new Error(MISSING_PROJECT_KEY_ERROR) } return await handleZodiosValidationErrors( () => fetchUserProfile(authToken, projectKey), @@ -46,9 +49,7 @@ export async function updateSelfTargetingIdentityHandler( args, async (authToken: string, projectKey: string | undefined) => { if (!projectKey) { - throw new Error( - 'Project key is required for this operation. Please select a project using the select_project tool first.', - ) + throw new Error(MISSING_PROJECT_KEY_ERROR) } return await handleZodiosValidationErrors( () => @@ -67,16 +68,15 @@ export async function updateSelfTargetingIdentityHandler( } export async function listSelfTargetingOverridesHandler( + args: z.infer, apiClient: IDevCycleApiClient, ) { return await apiClient.executeWithDashboardLink( 'listSelfTargetingOverrides', - null, + args, async (authToken: string, projectKey: string | undefined) => { if (!projectKey) { - throw new Error( - 'Project key is required for this operation. Please select a project using the select_project tool first.', - ) + throw new Error(MISSING_PROJECT_KEY_ERROR) } return await handleZodiosValidationErrors( () => fetchProjectOverridesForUser(authToken, projectKey), @@ -96,9 +96,7 @@ export async function setSelfTargetingOverrideHandler( args, async (authToken: string, projectKey: string | undefined) => { if (!projectKey) { - throw new Error( - 'Project key is required for this operation. Please select a project using the select_project tool first.', - ) + throw new Error(MISSING_PROJECT_KEY_ERROR) } return await handleZodiosValidationErrors( () => @@ -122,9 +120,7 @@ export async function clearFeatureSelfTargetingOverridesHandler( args, async (authToken: string, projectKey: string | undefined) => { if (!projectKey) { - throw new Error( - 'Project key is required for this operation. Please select a project using the select_project tool first.', - ) + throw new Error(MISSING_PROJECT_KEY_ERROR) } await handleZodiosValidationErrors( () => @@ -164,10 +160,14 @@ export function registerSelfTargetingTools( title: 'Get Self-Targeting Identity', readOnlyHint: true, }, - inputSchema: {}, // No parameters needed + inputSchema: ProjectScopedArgsSchema.shape, }, - async () => { - return await getSelfTargetingIdentityHandler(apiClient) + async (args: unknown) => { + const validatedArgs = ProjectScopedArgsSchema.parse(args) + return await getSelfTargetingIdentityHandler( + validatedArgs, + apiClient, + ) }, ) @@ -205,10 +205,14 @@ export function registerSelfTargetingTools( title: 'List Self-Targeting Overrides', readOnlyHint: true, }, - inputSchema: {}, // No parameters needed + inputSchema: ProjectScopedArgsSchema.shape, }, - async () => { - return await listSelfTargetingOverridesHandler(apiClient) + async (args: unknown) => { + const validatedArgs = ProjectScopedArgsSchema.parse(args) + return await listSelfTargetingOverridesHandler( + validatedArgs, + apiClient, + ) }, ) diff --git a/src/mcp/tools/variableTools.ts b/src/mcp/tools/variableTools.ts index 036c1b38..8902ee7b 100644 --- a/src/mcp/tools/variableTools.ts +++ b/src/mcp/tools/variableTools.ts @@ -1,5 +1,9 @@ import { z } from 'zod' -import { handleZodiosValidationErrors } from '../utils/api' +import { + handleZodiosValidationErrors, + omitProjectKey, + MISSING_PROJECT_KEY_ERROR, +} from '../utils/api' import { fetchVariables, createVariable, @@ -26,12 +30,11 @@ export async function listVariablesHandler( args, async (authToken: string, projectKey: string | undefined) => { if (!projectKey) { - throw new Error( - 'Project key is required for this operation. Please select a project using the select_project tool first.', - ) + throw new Error(MISSING_PROJECT_KEY_ERROR) } return await handleZodiosValidationErrors( - () => fetchVariables(authToken, projectKey, args), + () => + fetchVariables(authToken, projectKey, omitProjectKey(args)), 'fetchVariables', ) }, @@ -48,12 +51,11 @@ export async function createVariableHandler( args, async (authToken: string, projectKey: string | undefined) => { if (!projectKey) { - throw new Error( - 'Project key is required for this operation. Please select a project using the select_project tool first.', - ) + throw new Error(MISSING_PROJECT_KEY_ERROR) } return await handleZodiosValidationErrors( - () => createVariable(authToken, projectKey, args), + () => + createVariable(authToken, projectKey, omitProjectKey(args)), 'createVariable', ) }, @@ -65,16 +67,14 @@ export async function updateVariableHandler( args: z.infer, apiClient: IDevCycleApiClient, ) { - const { key, ...updateData } = args + const { key, ...updateData } = omitProjectKey(args) return await apiClient.executeWithDashboardLink( 'updateVariable', args, async (authToken: string, projectKey: string | undefined) => { if (!projectKey) { - throw new Error( - 'Project key is required for this operation. Please select a project using the select_project tool first.', - ) + throw new Error(MISSING_PROJECT_KEY_ERROR) } return await handleZodiosValidationErrors( () => updateVariable(authToken, projectKey, key, updateData), @@ -94,9 +94,7 @@ export async function deleteVariableHandler( args, async (authToken: string, projectKey: string | undefined) => { if (!projectKey) { - throw new Error( - 'Project key is required for this operation. Please select a project using the select_project tool first.', - ) + throw new Error(MISSING_PROJECT_KEY_ERROR) } await handleZodiosValidationErrors( () => deleteVariable(authToken, projectKey, args.key), diff --git a/src/mcp/types.ts b/src/mcp/types.ts index 9d4e405e..3282899e 100644 --- a/src/mcp/types.ts +++ b/src/mcp/types.ts @@ -21,6 +21,26 @@ const VARIATION_VARIABLES_TYPE_HINT = 'Variable values must use native JSON types: true/false for booleans, numbers for numeric values, strings only for string values. e.g. { "boolVar": false, "numVar": 42, "stringVar": "value" }' // Zod schemas for MCP tool arguments + +/** + * Optional per-call project override, shared by every project-scoped tool. + * + * Tools resolve the project as: this argument first, then the project chosen + * via `select_project`. Passing it explicitly keeps a tool call self-contained, + * which is what hosts that start a new MCP session per request require. + */ +export const ProjectKeyArgShape = { + projectKey: z + .string() + .optional() + .describe( + 'Project to target. Defaults to the project set by select_project.', + ), +} + +/** Args for project-scoped tools that take no other parameters. */ +export const ProjectScopedArgsSchema = z.object(ProjectKeyArgShape) + export const VariableValidationSchema = z.object({ schemaType: z .enum(['enum', 'regex', 'jsonSchema']) @@ -48,6 +68,7 @@ export const VariableValidationSchema = z.object({ }) export const ListFeaturesArgsSchema = z.object({ + ...ProjectKeyArgShape, page: z .number() .min(1) @@ -97,6 +118,7 @@ export const ListFeaturesArgsSchema = z.object({ }) export const ListVariablesArgsSchema = z.object({ + ...ProjectKeyArgShape, page: z .number() .min(1) @@ -139,6 +161,7 @@ export const ListVariablesArgsSchema = z.object({ }) export const CreateVariableArgsSchema = CreateVariableDto.extend({ + ...ProjectKeyArgShape, key: CreateVariableDto.shape.key.describe('Unique variable key'), defaultValue: CreateVariableDto.shape.defaultValue.describe( 'Default value for the variable, the data type of the defaultValue must match the variable.type', @@ -156,6 +179,7 @@ export const CreateVariableArgsSchema = CreateVariableDto.extend({ }) export const UpdateVariableArgsSchema = UpdateVariableDto.extend({ + ...ProjectKeyArgShape, key: z .string() .max(100) @@ -175,10 +199,12 @@ export const UpdateVariableArgsSchema = UpdateVariableDto.extend({ }) export const DeleteVariableArgsSchema = z.object({ + ...ProjectKeyArgShape, key: z.string().describe('key to identify variable to delete'), }) export const DeleteFeatureArgsSchema = z.object({ + ...ProjectKeyArgShape, key: z.string().describe('key to identify feature to delete'), }) @@ -272,6 +298,7 @@ export const SetFeatureTargetingArgsSchema = z.object({ }) export const CreateFeatureArgsSchema = CreateFeatureDto.extend({ + ...ProjectKeyArgShape, key: CreateFeatureDto.shape.key.describe('Unique feature key'), variables: CreateFeatureDto.shape.variables.describe( 'Array of variables to create or reassociate with this feature', @@ -295,6 +322,7 @@ export const CreateFeatureArgsSchema = CreateFeatureDto.extend({ }) export const UpdateFeatureArgsSchema = UpdateFeatureDto.extend({ + ...ProjectKeyArgShape, key: z .string() .min(1) @@ -337,6 +365,7 @@ export const UpdateFeatureArgsSchema = UpdateFeatureDto.extend({ }) export const UpdateFeatureStatusArgsSchema = UpdateFeatureStatusDto.extend({ + ...ProjectKeyArgShape, key: z .string() .min(1) @@ -349,6 +378,7 @@ export const UpdateFeatureStatusArgsSchema = UpdateFeatureStatusDto.extend({ }) export const UpdateSelfTargetingIdentityArgsSchema = z.object({ + ...ProjectKeyArgShape, dvc_user_id: z .string() .describe( @@ -357,6 +387,7 @@ export const UpdateSelfTargetingIdentityArgsSchema = z.object({ }) export const SetSelfTargetingOverrideArgsSchema = z.object({ + ...ProjectKeyArgShape, feature_key: z.string().describe('Feature key to set override for'), environment_key: z.string().describe('Environment key to set override in'), variation_key: z @@ -365,6 +396,7 @@ export const SetSelfTargetingOverrideArgsSchema = z.object({ }) export const ClearSelfTargetingOverridesArgsSchema = z.object({ + ...ProjectKeyArgShape, feature_key: z.string().describe('Feature key to clear overrides for'), environment_key: z .string() @@ -414,6 +446,7 @@ export const UpdateFeatureTargetingArgsSchema = UpdateFeatureConfigDto.extend({ }) export const GetFeatureAuditLogHistoryArgsSchema = z.object({ + ...ProjectKeyArgShape, feature_key: z .string() .describe('Feature key to get audit log history for'), @@ -490,6 +523,7 @@ const BaseEvaluationQuerySchema = z.object({ // MCP argument schemas (using camelCase to match API) export const GetFeatureTotalEvaluationsArgsSchema = BaseEvaluationQuerySchema.extend({ + ...ProjectKeyArgShape, featureKey: z .string() .describe('Feature key to get evaluation data for'), @@ -497,13 +531,18 @@ export const GetFeatureTotalEvaluationsArgsSchema = variable: z.string().optional().describe('Variable key to filter by'), }) -export const GetProjectTotalEvaluationsArgsSchema = BaseEvaluationQuerySchema +export const GetProjectTotalEvaluationsArgsSchema = + BaseEvaluationQuerySchema.extend(ProjectKeyArgShape) -// API query schemas (same as MCP args since we use camelCase throughout) +// API query schemas (same as MCP args since we use camelCase throughout). +// projectKey is omitted: it selects the project in the request path, it is not a query param. export const FeatureTotalEvaluationsQuerySchema = - GetFeatureTotalEvaluationsArgsSchema.omit({ featureKey: true }) + GetFeatureTotalEvaluationsArgsSchema.omit({ + featureKey: true, + projectKey: true, + }) export const ProjectTotalEvaluationsQuerySchema = - GetProjectTotalEvaluationsArgsSchema + GetProjectTotalEvaluationsArgsSchema.omit({ projectKey: true }) export const ListCustomPropertiesArgsSchema = z.object({ page: z diff --git a/src/mcp/utils/api.test.ts b/src/mcp/utils/api.test.ts index c8397028..b11a7f84 100644 --- a/src/mcp/utils/api.test.ts +++ b/src/mcp/utils/api.test.ts @@ -1,7 +1,13 @@ import { expect } from '@oclif/test' import sinon from 'sinon' import * as assert from 'assert' -import { DevCycleApiClient, handleZodiosValidationErrors } from './api' +import { + DevCycleApiClient, + handleZodiosValidationErrors, + omitProjectKey, + projectKeyFromArgs, + MISSING_PROJECT_KEY_ERROR, +} from './api' import { DevCycleAuth } from './auth' import { setMCPToolCommand } from './headers' import { axiosClient, v2ApiClient } from '../../api/apiClient' @@ -79,7 +85,6 @@ describe('DevCycleApiClient', () => { const mockOperation = sinon.stub().resolves(mockResult) authStub.requireAuth.returns() - authStub.requireProject.returns() const result = await apiClient.executeWithLogging( 'testOperation', @@ -89,7 +94,6 @@ describe('DevCycleApiClient', () => { expect(result).to.deep.equal(mockResult) sinon.assert.calledOnce(authStub.requireAuth) - sinon.assert.calledOnce(authStub.requireProject) sinon.assert.calledWith( mockOperation, 'mock-auth-token', @@ -135,7 +139,6 @@ describe('DevCycleApiClient', () => { ) authStub.requireAuth.returns() - authStub.requireProject.returns() const result = await apiClient.executeWithDashboardLink( 'createFeature', @@ -158,6 +161,165 @@ describe('DevCycleApiClient', () => { ) }) }) + + describe('project key resolution', () => { + it('should prefer the projectKey argument over the selected project', async () => { + const mockOperation = sinon.stub().resolves({}) + authStub.requireAuth.returns() + + await apiClient.executeWithLogging( + 'testOperation', + { key: 'test-key', projectKey: 'arg-project' }, + mockOperation, + ) + + sinon.assert.calledWith( + mockOperation, + 'mock-auth-token', + 'arg-project', + ) + }) + + it('should fall back to the selected project when no argument is given', async () => { + const mockOperation = sinon.stub().resolves({}) + authStub.requireAuth.returns() + + await apiClient.executeWithLogging( + 'testOperation', + { key: 'test-key' }, + mockOperation, + ) + + sinon.assert.calledWith( + mockOperation, + 'mock-auth-token', + 'test-project', + ) + }) + + it('should ignore a blank projectKey argument', async () => { + const mockOperation = sinon.stub().resolves({}) + authStub.requireAuth.returns() + + await apiClient.executeWithLogging( + 'testOperation', + { projectKey: ' ' }, + mockOperation, + ) + + sinon.assert.calledWith( + mockOperation, + 'mock-auth-token', + 'test-project', + ) + }) + + it('should accept a projectKey argument when no project is selected', async () => { + const mockOperation = sinon.stub().resolves({}) + authStub.requireAuth.returns() + authStub.getProjectKey.returns('') + + await apiClient.executeWithLogging( + 'testOperation', + { projectKey: 'arg-project' }, + mockOperation, + ) + + sinon.assert.calledWith( + mockOperation, + 'mock-auth-token', + 'arg-project', + ) + }) + + it('should throw when a project is required and none can be resolved', async () => { + const mockOperation = sinon.stub().resolves({}) + authStub.requireAuth.returns() + authStub.getProjectKey.returns('') + + try { + await apiClient.executeWithLogging( + 'testOperation', + {}, + mockOperation, + ) + assert.fail('Expected function to throw') + } catch (error) { + expect((error as Error).message).to.equal( + MISSING_PROJECT_KEY_ERROR, + ) + sinon.assert.notCalled(mockOperation) + } + }) + + it('should not require a project when requiresProject is false', async () => { + const mockOperation = sinon.stub().resolves({}) + authStub.requireAuth.returns() + authStub.getProjectKey.returns('') + + await apiClient.executeWithLogging( + 'listProjects', + {}, + mockOperation, + false, + ) + + sinon.assert.calledWith(mockOperation, 'mock-auth-token', undefined) + }) + + it('should build the dashboard link from the projectKey argument', async () => { + const mockResult = { key: 'test-feature' } + const mockOperation = sinon.stub().resolves(mockResult) + const dashboardLinkGenerator = sinon.stub().returns('link') + authStub.requireAuth.returns() + + await apiClient.executeWithDashboardLink( + 'createFeature', + { key: 'test-feature', projectKey: 'arg-project' }, + mockOperation, + dashboardLinkGenerator, + ) + + sinon.assert.calledWith( + dashboardLinkGenerator, + 'test-org-id', + 'arg-project', + mockResult, + ) + }) + }) + + describe('projectKeyFromArgs', () => { + it('should read a projectKey argument', () => { + expect(projectKeyFromArgs({ projectKey: 'my-project' })).to.equal( + 'my-project', + ) + }) + + it('should return undefined for absent, blank or non-string values', () => { + expect(projectKeyFromArgs({})).to.equal(undefined) + expect(projectKeyFromArgs({ projectKey: ' ' })).to.equal(undefined) + expect(projectKeyFromArgs({ projectKey: 7 })).to.equal(undefined) + expect(projectKeyFromArgs(null)).to.equal(undefined) + }) + }) + + describe('omitProjectKey', () => { + it('should strip projectKey before args reach the API', () => { + expect( + omitProjectKey({ + key: 'my-feature', + projectKey: 'my-project', + }), + ).to.deep.equal({ key: 'my-feature' }) + }) + + it('should leave args without a projectKey untouched', () => { + expect(omitProjectKey({ key: 'my-feature' })).to.deep.equal({ + key: 'my-feature', + }) + }) + }) }) describe('Header Management', () => { diff --git a/src/mcp/utils/api.ts b/src/mcp/utils/api.ts index 73c25c02..51f39780 100644 --- a/src/mcp/utils/api.ts +++ b/src/mcp/utils/api.ts @@ -54,6 +54,39 @@ export async function handleZodiosValidationErrors( } } +/** + * Error surfaced when a tool needs a project and none could be resolved. + */ +export const MISSING_PROJECT_KEY_ERROR = [ + 'Project key is required for this operation.', + 'Either pass a "projectKey" argument to this tool, or select a project using the select_project tool first.', +].join('\n') + +/** + * Read the optional per-call project override out of a tool's arguments. + * Returns undefined when absent or blank so callers can fall back to the + * project selected via select_project. + */ +export function projectKeyFromArgs(args: unknown): string | undefined { + if (!args || typeof args !== 'object') return undefined + const value = (args as { projectKey?: unknown }).projectKey + if (typeof value !== 'string') return undefined + return value.trim() || undefined +} + +/** + * Strip the per-call project override before forwarding tool arguments to the + * API. The project is passed as a path parameter, so leaving it in the body or + * query string would fail request validation. + */ +export function omitProjectKey( + args: T, +): Omit { + const rest = { ...args } as T & { projectKey?: string } + delete rest.projectKey + return rest +} + export function getErrorMessage(error: unknown): string { if (error instanceof Error && error.message) { return error.message @@ -97,17 +130,16 @@ export class DevCycleApiClient implements IDevCycleApiClient { ): Promise { try { this.auth.requireAuth() - if (requiresProject) { - this.auth.requireProject() + + const projectKey = this.resolveProjectKey(args) + if (requiresProject && !projectKey) { + throw new Error(MISSING_PROJECT_KEY_ERROR) } // Set the specific MCP tool command in headers before making API calls setMCPToolCommand(operationName) - return await operation( - this.auth.getAuthToken(), - this.auth.getProjectKey(), - ) + return await operation(this.auth.getAuthToken(), projectKey) } catch (error) { console.error( `MCP ${operationName} error:`, @@ -143,12 +175,22 @@ export class DevCycleApiClient implements IDevCycleApiClient { const link = dashboardLink( this.auth.getOrgId(), - this.auth.getProjectKey(), + this.resolveProjectKey(args), result, ) return { result, dashboardLink: link } } + /** + * Resolve the project for a call: the tool's own projectKey argument wins, + * otherwise fall back to the project selected via select_project. + */ + private resolveProjectKey(args: unknown): string | undefined { + return ( + projectKeyFromArgs(args) || this.auth.getProjectKey() || undefined + ) + } + public getAuth(): DevCycleAuth { return this.auth }