diff --git a/src/index.ts b/src/index.ts index 0b330a8..0b6d2de 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,15 +27,44 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, + ListResourcesRequestSchema, + ReadResourceRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js'; import { loadConfig } from './config.js'; import { UnleashClient } from './unleash/client.js'; import { ServerContext, createLogger, handleToolError } from './context.js'; +import { + buildFeatureDevelopmentWorkflowDocument, + featureDevelopmentWorkflowResource, + isFeatureDevelopmentWorkflowUri, +} from './resources/featureDevelopmentWorkflow.js'; +import { + buildLocalChangeChecklistDocument, + isLocalChangeChecklistUri, + localChangeChecklistResource, +} from './resources/localChangeChecklist.js'; +import { + buildWorkspaceSummaryDocument, + isWorkspaceSummaryUri, + workspaceSummaryResource, +} from './resources/workspaceSummary.js'; +import { + backendGuardrailsResource, + buildBackendGuardrailsDocument, + isBackendGuardrailsUri, +} from './resources/backendGuardrails.js'; import { createFlag, createFlagTool } from './tools/createFlag.js'; import { evaluateChange, evaluateChangeTool } from './tools/evaluateChange.js'; import { wrapChange, wrapChangeTool } from './tools/wrapChange.js'; +import { + prepareLocalChange, + prepareLocalChangeTool, +} from './tools/prepareLocalChange.js'; +import { decideLocalFlow, decideLocalFlowTool } from './tools/decideLocalFlow.js'; +import { applyPatch, applyPatchTool } from './tools/applyPatch.js'; +import { runChecks, runChecksTool } from './tools/runChecks.js'; /** * Main entry point for the MCP server. @@ -60,15 +89,28 @@ async function main(): Promise { config.server.dryRun ); + const instructions = [ + 'Local-change policy:', + '1) When unsure, call decide_local_flow to confirm this is a local code change.', + '2) Always call prepare_local_change before editing files in this repository.', + '3) If the task is risky, user-facing, or mentions feature flags, call evaluate_change.', + '4) Use wrap_change when guarding code with an Unleash flag and summarize diffs with apply_patch as needed.', + '5) Before finishing, call run_checks (or run commands manually) to validate formatter, linter, and tests.', + ].join('\n'); + // Create MCP server const server = new Server( { name: 'unleash-mcp', version: '0.1.0', + description: + 'Purpose-driven Unleash feature flag assistant. Default workflow: decide_local_flow → prepare_local_change → evaluate_change → create_flag → wrap_change → run_checks. Whether you are touching a single file or planning a large refactor, start with prepare_local_change to gather local guardrails, then evaluate_change to score risk and steer next steps.', }, { capabilities: { tools: {}, + resources: {}, + instructions, }, } ); @@ -84,7 +126,15 @@ async function main(): Promise { // Register tool handlers server.setRequestHandler(ListToolsRequestSchema, async () => { return { - tools: [createFlagTool, evaluateChangeTool, wrapChangeTool], + tools: [ + decideLocalFlowTool, + prepareLocalChangeTool, + evaluateChangeTool, + createFlagTool, + wrapChangeTool, + applyPatchTool, + runChecksTool, + ], }; }); @@ -95,15 +145,27 @@ async function main(): Promise { logger.debug(`Tool called: ${name}`, args); switch (name) { - case 'create_flag': - return await createFlag(context, args, request.params._meta?.progressToken); + case 'decide_local_flow': + return await decideLocalFlow(context, args); + + case 'prepare_local_change': + return await prepareLocalChange(context, args); case 'evaluate_change': return await evaluateChange(context, args); + case 'create_flag': + return await createFlag(context, args, request.params._meta?.progressToken); + case 'wrap_change': return await wrapChange(context, args); + case 'apply_patch': + return await applyPatch(context, args); + + case 'run_checks': + return await runChecks(context, args); + default: throw new Error(`Unknown tool: ${name}`); } @@ -113,6 +175,86 @@ async function main(): Promise { } }); + // Register proactive guidance resource handlers + server.setRequestHandler(ListResourcesRequestSchema, async () => { + return { + resources: [ + workspaceSummaryResource, + backendGuardrailsResource, + localChangeChecklistResource, + featureDevelopmentWorkflowResource, + ], + }; + }); + + server.setRequestHandler(ReadResourceRequestSchema, async (request) => { + const { uri } = request.params; + + if (isWorkspaceSummaryUri(uri)) { + logger.debug(`Reading resource: ${uri}`); + return { + contents: [ + { + uri, + mimeType: workspaceSummaryResource.mimeType, + text: await buildWorkspaceSummaryDocument(), + }, + ], + }; + } + + if (isBackendGuardrailsUri(uri)) { + logger.debug(`Reading resource: ${uri}`); + return { + contents: [ + { + uri, + mimeType: backendGuardrailsResource.mimeType, + text: buildBackendGuardrailsDocument(), + }, + ], + }; + } + + if (isFeatureDevelopmentWorkflowUri(uri)) { + logger.debug(`Reading resource: ${uri}`); + return { + contents: [ + { + uri, + mimeType: featureDevelopmentWorkflowResource.mimeType, + text: buildFeatureDevelopmentWorkflowDocument(config), + }, + ], + }; + } + + if (isLocalChangeChecklistUri(uri)) { + logger.debug(`Reading resource: ${uri}`); + return { + contents: [ + { + uri, + mimeType: localChangeChecklistResource.mimeType, + text: buildLocalChangeChecklistDocument(config), + }, + ], + }; + } + + logger.warn(`Unknown resource requested: ${uri}`); + + return { + contents: [ + { + uri, + mimeType: 'text/plain', + text: `Resource not found: ${uri}. Available resources: unleash://workspace/summary, unleash://guides/backend-guardrails, unleash://guides/local-change-checklist, unleash://guides/feature-development-workflow`, + }, + ], + }; + }); + // Start server with stdio transport const transport = new StdioServerTransport(); await server.connect(transport); diff --git a/src/resources/backendGuardrails.ts b/src/resources/backendGuardrails.ts new file mode 100644 index 0000000..7410ed0 --- /dev/null +++ b/src/resources/backendGuardrails.ts @@ -0,0 +1,37 @@ +import { Resource } from '@modelcontextprotocol/sdk/types.js'; + +const BACKEND_GUARDRAILS_URI = 'unleash://guides/backend-guardrails'; + +export const backendGuardrailsResource: Resource = { + uri: BACKEND_GUARDRAILS_URI, + name: 'Backend Change Guardrails', + description: + 'Server-side checklist for edits in src/lib, src/unleash, or API layers. Highlights when to call prepare_local_change and evaluate_change before touching code.', + mimeType: 'text/markdown', +}; + +export function buildBackendGuardrailsDocument(): string { + const lines: string[] = [ + '# Backend Change Guardrails', + '', + 'When working on server-side code (e.g., `src/lib`, `src/unleash`, API controllers, services):', + '', + '1. **Call `prepare_local_change` first.** Capture the task summary and let the MCP list impacted files, repo commands, and guardrails before editing anything.', + '2. **Call `evaluate_change`** with the plan. Backend changes often affect rollout and risk; the tool will tell you if a feature flag is required.', + '3. **Check for existing flags** around the area. If wrapping new logic, plan to reuse or extend those flags before creating a new one.', + '4. **Use `wrap_change`** once a flag is confirmed so the snippet matches the project conventions.', + '5. **Summarize the diff with `apply_patch` and run `run_checks`** (formatter, linter, tests) before finishing.', + '', + 'Extra considerations:', + '', + '- Document rollout and cleanup expectations in the change description.', + '- Update or add backend tests to cover enabled/disabled flag paths.', + '- Watch for database or integration side effects that may require gradual rollout.', + ]; + + return lines.join('\n'); +} + +export function isBackendGuardrailsUri(uri: string): boolean { + return uri === BACKEND_GUARDRAILS_URI; +} diff --git a/src/resources/featureDevelopmentWorkflow.ts b/src/resources/featureDevelopmentWorkflow.ts new file mode 100644 index 0000000..536b966 --- /dev/null +++ b/src/resources/featureDevelopmentWorkflow.ts @@ -0,0 +1,66 @@ +import { Resource } from '@modelcontextprotocol/sdk/types.js'; +import { Config } from '../config.js'; + +const FEATURE_DEVELOPMENT_URI = 'unleash://guides/feature-development-workflow'; + +export const featureDevelopmentWorkflowResource: Resource = { + uri: FEATURE_DEVELOPMENT_URI, + name: 'Feature Development Workflow', + description: + 'Start here when you are implementing a feature or risky change. Explains when to call evaluate_change, create_flag, and wrap_change.', + mimeType: 'text/markdown', +}; + +export function buildFeatureDevelopmentWorkflowDocument(config: Config): string { + const lines: Array = [ + '# Feature Development with Unleash MCP', + '', + 'Use this workflow whenever you begin a product change. It keeps work safely gated behind Unleash feature flags and tells the LLM which tool to call next.', + '', + '> Planning anything from a small safety refactor to a platform-wide initiative? Capture the intent and known touchpoints in **`prepare_local_change`** and **`evaluate_change`** before writing code so you can outline discovery steps, flag needs, and rollout strategy up front—even if diff files are not ready yet.', + '', + '## 1. Assess the change first', + '', + '- Call **`prepare_local_change`** with the task summary to gather guardrails, repo commands, and suggested next steps.', + '- Follow up with **`evaluate_change`** as soon as a new feature or risky modification is mentioned.', + '- Provide repo, branch, files, and any risk notes so the evaluation can score the change.', + '- Follow the JSON result. When it says a flag is needed, move to creation immediately.', + '', + '## 2. Create the flag if required', + '', + '- Use **`create_flag`** with the recommended flag name, type, and description from the evaluation output.', + '- Keep descriptions explicit about rollout intent and clean-up expectations.', + config.unleash.defaultProject + ? `- Default project detected: \`${config.unleash.defaultProject}\`. Override only when the work belongs elsewhere.` + : undefined, + config.server.dryRun + ? '- Server is running in **dry-run** mode. The tool will validate inputs and log the request without calling Unleash.' + : undefined, + '', + '## 3. Wrap the implementation', + '', + '- Call **`wrap_change`** immediately after flag creation (or when reusing an existing flag).', + '- Provide the target file name and any code context so the tool can detect conventions and suggest matching templates.', + '- Apply the recommended snippet, then test the guarded code path.', + '', + '## 4. Close the loop', + '', + '- Confirm tests or QA plans cover both flag states.', + '- Record rollout decisions (gradual rollout, kill switches, cleanup owner).', + '- If the evaluation reported “no new flag”, document why and proceed without flag creation.', + '', + '### Quick Decision Checklist', + '', + '1. Did we already check for parent flags? → `evaluate_change` helps surface them.', + '2. Do we understand rollout impact? → The evaluation guidance cites Unleash best practices.', + '3. Are we mirroring existing code conventions? → `wrap_change` detects patterns when you pass code context.', + '', + 'Keep this workflow in mind: **prepare → evaluate → create → wrap → verify**. The LLM should reach for these tools without waiting for the user to ask.', + ]; + + return lines.filter((line): line is string => Boolean(line)).join('\n'); +} + +export function isFeatureDevelopmentWorkflowUri(uri: string): boolean { + return uri === FEATURE_DEVELOPMENT_URI; +} diff --git a/src/resources/localChangeChecklist.ts b/src/resources/localChangeChecklist.ts new file mode 100644 index 0000000..c164cf4 --- /dev/null +++ b/src/resources/localChangeChecklist.ts @@ -0,0 +1,47 @@ +import { Resource } from '@modelcontextprotocol/sdk/types.js'; +import { Config } from '../config.js'; + +const LOCAL_CHANGE_CHECKLIST_URI = 'unleash://guides/local-change-checklist'; + +export const localChangeChecklistResource: Resource = { + uri: LOCAL_CHANGE_CHECKLIST_URI, + name: 'Local Change Checklist', + description: + 'Checklist for preparing local modifications. Highlights prepare_local_change, evaluate_change, and repo guardrails to keep work safe.', + mimeType: 'text/markdown', +}; + +export function buildLocalChangeChecklistDocument(config: Config): string { + const lines: string[] = [ + '# Local Change Checklist', + '', + 'Follow this sequence before touching code:', + '', + '0. If you are unsure whether this is a local code change, call **`decide_local_flow`**.', + '1. **Call `prepare_local_change`** with the task summary to collect guardrails, suggested files, and test commands.', + '2. Review the output and open any referenced resources (project guardrails, documentation links).', + '3. **Call `evaluate_change`** with the planned work (even if you only have a proposal) to assess flag needs and risk.', + '4. If a flag is required, **call `create_flag`**, then **`wrap_change`** to get language-specific snippets.', + '5. Implement the smallest safe diff, run the recommended checks, and capture follow-up tasks.', + '', + '## Quick Hints', + '', + '- Keep scopes tight; prefer iterative changes gated behind the new flag.', + '- Surface risky or high-impact areas (auth, payments, migrations) in the tool inputs.', + '- Note cleanup expectations so rollout is planned from the start.', + '- Use `unleash://workspace/summary` to recall formatter/linter/test commands quickly.', + '', + config.unleash.defaultProject + ? `Default project: \`${config.unleash.defaultProject}\`` + : 'Set `UNLEASH_DEFAULT_PROJECT` to avoid passing projectId manually.', + config.server.dryRun + ? 'Running in **dry-run** mode: Unleash API calls are validated but not executed.' + : undefined, + ].filter((line): line is string => Boolean(line)); + + return lines.join('\n'); +} + +export function isLocalChangeChecklistUri(uri: string): boolean { + return uri === LOCAL_CHANGE_CHECKLIST_URI; +} diff --git a/src/resources/workspaceSummary.ts b/src/resources/workspaceSummary.ts new file mode 100644 index 0000000..f393372 --- /dev/null +++ b/src/resources/workspaceSummary.ts @@ -0,0 +1,62 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { Resource } from '@modelcontextprotocol/sdk/types.js'; + +const WORKSPACE_SUMMARY_URI = 'unleash://workspace/summary'; + +export const workspaceSummaryResource: Resource = { + uri: WORKSPACE_SUMMARY_URI, + name: 'Workspace Summary', + description: 'Snapshot of local repository signals (package manager, scripts, TypeScript usage) for quick reference.', + mimeType: 'text/markdown', +}; + +export async function buildWorkspaceSummaryDocument(): Promise { + const packageJsonPath = path.resolve(process.cwd(), 'package.json'); + let packageManager = 'unknown'; + let scripts: Record = {}; + let usesTypeScript = false; + + try { + const raw = await fs.readFile(packageJsonPath, 'utf-8'); + const pkg = JSON.parse(raw) as { + packageManager?: string; + scripts?: Record; + dependencies?: Record; + devDependencies?: Record; + }; + if (pkg.packageManager) { + packageManager = pkg.packageManager; + } + scripts = pkg.scripts ?? {}; + usesTypeScript = Boolean( + (pkg.dependencies && pkg.dependencies.typescript) || + (pkg.devDependencies && pkg.devDependencies.typescript) + ); + } catch (error) { + // No package.json or unreadable file; keep defaults + } + + const primaryScripts = ['format', 'lint', 'test', 'build']; + const scriptLines = primaryScripts + .filter(name => Boolean(scripts[name])) + .map(name => `- **${name}**: ${scripts[name]}`); + + const lines: string[] = [ + '# Workspace Summary', + '', + `Package manager: ${packageManager}`, + `TypeScript: ${usesTypeScript ? 'yes' : 'no'}`, + '', + scriptLines.length > 0 ? '## Key npm scripts' : '## Key npm scripts', + scriptLines.length > 0 ? scriptLines.join('\n') : '- (none detected)', + '', + 'Keep changes small and run formatter/linter/tests before completion.', + ]; + + return lines.join('\n'); +} + +export function isWorkspaceSummaryUri(uri: string): boolean { + return uri === WORKSPACE_SUMMARY_URI; +} diff --git a/src/tools/applyPatch.ts b/src/tools/applyPatch.ts new file mode 100644 index 0000000..ea4e884 --- /dev/null +++ b/src/tools/applyPatch.ts @@ -0,0 +1,97 @@ +import { z } from 'zod'; +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { ServerContext, handleToolError } from '../context.js'; + +const applyPatchSchema = z.object({ + patch: z.string().min(1, 'patch is required').describe('Unified diff to apply in this workspace'), +}); + +type ApplyPatchInput = z.infer; + +function extractFiles(patch: string): string[] { + const files = new Set(); + const diffHeader = /^diff --git a\/(.+?) b\/(.+)$/gm; + let match: RegExpExecArray | null; + while ((match = diffHeader.exec(patch)) !== null) { + files.add(match[2]); + } + const plusPlus = /^\+\+\+ b\/(.+)$/gm; + while ((match = plusPlus.exec(patch)) !== null) { + files.add(match[1]); + } + const minusMinus = /^--- a\/(.+)$/gm; + while ((match = minusMinus.exec(patch)) !== null) { + files.add(match[1]); + } + return Array.from(files); +} + +function countHunks(patch: string): number { + const hunks = patch.match(/^@@/gm); + return hunks ? hunks.length : 0; +} + +export async function applyPatch( + context: ServerContext, + args: unknown +): Promise { + try { + const input: ApplyPatchInput = applyPatchSchema.parse(args); + const files = extractFiles(input.patch); + const hunks = countHunks(input.patch); + + const summaryLines = [ + '# Patch Summary (no-op)', + '', + 'This server does not auto-apply patches. Review the summary below and apply the diff manually, then run local checks.', + '', + `Files referenced (${files.length}):`, + files.length > 0 ? files.map(file => `- ${file}`).join('\n') : '- (none detected)', + '', + `Hunks: ${hunks}`, + ]; + + const structuredContent = { + success: true, + applied: false, + files, + hunks, + message: + 'Patch not applied automatically. Use this summary and apply the diff with your preferred local workflow (e.g., git apply).', + }; + + return { + content: [ + { + type: 'text', + text: summaryLines.join('\n'), + }, + ], + structuredContent, + }; + } catch (error) { + return handleToolError(context, error, 'apply_patch'); + } +} + +export const applyPatchTool = { + name: 'apply_patch', + description: + 'Summarize a unified diff for this repository. Returns impacted files and hunk counts so you can apply it locally.', + annotations: { + title: 'Apply Patch Summary', + readOnlyHint: true, + idempotentHint: true, + openWorldHint: false, + }, + inputSchema: { + type: 'object', + properties: { + patch: { + type: 'string', + description: 'Unified diff generated from this workspace.', + }, + }, + required: ['patch'], + }, +}; diff --git a/src/tools/createFlag.ts b/src/tools/createFlag.ts index 691b8dc..977a116 100644 --- a/src/tools/createFlag.ts +++ b/src/tools/createFlag.ts @@ -142,7 +142,9 @@ export const createFlagTool = { name: 'create_flag', description: `Create a new feature flag in Unleash. -This tool creates a feature flag with the specified configuration. Choose the appropriate flag type: +Call this immediately when \`evaluate_change\` recommends a new flag so the workflow can continue into \`wrap_change\`. + +This tool creates a feature flag with the specified configuration so you can roll out the local change safely. Choose the appropriate flag type: - release: For gradual feature rollouts to users - experiment: For A/B tests and experiments - operational: For system behavior and operational toggles @@ -156,6 +158,13 @@ Best practices: 4. Plan for flag removal after successful rollout See: https://docs.getunleash.io/topics/feature-flags/best-practices-using-feature-flags-at-scale`, + annotations: { + title: '03 Create Flag', + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, inputSchema: { type: 'object', properties: { diff --git a/src/tools/decideLocalFlow.ts b/src/tools/decideLocalFlow.ts new file mode 100644 index 0000000..2ee04ac --- /dev/null +++ b/src/tools/decideLocalFlow.ts @@ -0,0 +1,121 @@ +import { z } from 'zod'; +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { ServerContext, handleToolError } from '../context.js'; + +const decideLocalFlowSchema = z.object({ + task: z.string().min(1, 'task is required').describe('User request or summary of the intended change'), +}); + +type DecideLocalFlowInput = z.infer; + +const LOCAL_KEYWORDS = [ + 'refactor', + 'rename', + 'update', + 'fix', + 'add', + 'delete', + 'test', + 'cleanup', + 'restructure', + 'implement', +]; + +const RISK_KEYWORDS = ['flag', 'feature flag', 'rollout', 'toggle', 'enable', 'disable']; + +function detectLocalIntent(task: string): boolean { + const lower = task.toLowerCase(); + return LOCAL_KEYWORDS.some(keyword => lower.includes(keyword)); +} + +function detectRiskIntent(task: string): boolean { + const lower = task.toLowerCase(); + return RISK_KEYWORDS.some(keyword => lower.includes(keyword)); +} + +export async function decideLocalFlow( + context: ServerContext, + args: unknown +): Promise { + try { + const input: DecideLocalFlowInput = decideLocalFlowSchema.parse(args); + const task = input.task.trim(); + + const localIntent = detectLocalIntent(task); + const riskIntent = detectRiskIntent(task); + + const recommendation = localIntent ? 'prepare_local_change' : null; + const nextTool = riskIntent ? 'evaluate_change' : recommendation; + + context.logger.debug('Decide local flow analysis', { + task, + localIntent, + riskIntent, + nextTool, + }); + + const reasons: string[] = []; + if (localIntent) { + reasons.push('Task mentions code-change verbs that usually mean editing local files.'); + } else { + reasons.push('Did not find explicit code-change verbs; double-check before editing.'); + } + if (riskIntent) { + reasons.push('Mentions feature flag or rollout concepts, so run evaluate_change to plan gating.'); + } + + const structuredContent = { + success: true, + useLocal: localIntent, + next: nextTool, + reasons, + }; + + const lines: string[] = [ + '# Local Flow Recommendation', + '', + `Task: ${task}`, + '', + `Use local workflow: ${localIntent ? 'yes' : 'unclear'}`, + `Next tool: ${nextTool ?? 'none (ask user for more detail)'}`, + '', + '## Reasons', + '', + ...reasons.map(reason => `- ${reason}`), + ]; + + return { + content: [ + { + type: 'text', + text: lines.join('\n'), + }, + ], + structuredContent, + }; + } catch (error) { + return handleToolError(context, error, 'decide_local_flow'); + } +} + +export const decideLocalFlowTool = { + name: 'decide_local_flow', + description: + 'Decide whether a request should use the local workflow. Returns whether to start in this repository and which tool to call next.', + annotations: { + title: '00 Decide Local Flow', + readOnlyHint: true, + idempotentHint: true, + openWorldHint: false, + }, + inputSchema: { + type: 'object', + properties: { + task: { + type: 'string', + description: 'Plain-language description of the requested change.', + }, + }, + required: ['task'], + }, +}; diff --git a/src/tools/evaluateChange.ts b/src/tools/evaluateChange.ts index 7709a0a..b3d8a0b 100644 --- a/src/tools/evaluateChange.ts +++ b/src/tools/evaluateChange.ts @@ -466,7 +466,11 @@ export async function evaluateChange( */ export const evaluateChangeTool = { name: 'evaluate_change', - description: `Provides comprehensive guidance for evaluating whether code changes require feature flags. + description: `Provides comprehensive guidance for evaluating whether local code changes require feature flags. + +Call this right after \`prepare_local_change\` so the server can judge risk and orchestrate follow-up tool calls automatically. + +Even when you only have a plan (from a small safety refactor to a full architectural overhaul), provide that description here—the tool will guide you to gather diffs, inspect context, and decide how to stage the work with feature flags before you start modifying files. This tool returns detailed evaluation guidelines including: - Workflow for systematic evaluation @@ -479,6 +483,7 @@ This tool returns detailed evaluation guidelines including: Use this tool when: - Starting work on a new feature or change +- Before editing local files for a refactor, cleanup, or risk-prone fix - Unsure if a feature flag is needed - Want guidance on rollout strategy - Need help choosing flag type @@ -490,6 +495,12 @@ When this tool determines a flag is needed, it provides explicit instructions to 3. Implement the wrapped code following the patterns The tool returns markdown-formatted guidance that helps you make informed decisions and take the correct next actions.`, + annotations: { + title: '02 Evaluate Change', + readOnlyHint: true, + idempotentHint: true, + openWorldHint: false, + }, inputSchema: { type: 'object', properties: { diff --git a/src/tools/prepareLocalChange.ts b/src/tools/prepareLocalChange.ts new file mode 100644 index 0000000..1a53a7f --- /dev/null +++ b/src/tools/prepareLocalChange.ts @@ -0,0 +1,183 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { z } from 'zod'; +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { ServerContext, handleToolError } from '../context.js'; + +const prepareLocalChangeSchema = z.object({ + task: z.string().min(1, 'task is required').describe('Summary of the requested change or refactor'), + hints: z + .array(z.string()) + .optional() + .describe('Optional hints such as suspected file paths or components involved'), +}); + +type PrepareLocalChangeInput = z.infer; + +type RepoSignals = { + formatter?: string; + linter?: string; + tests?: string; + build?: string; +}; + +async function discoverRepoSignals(): Promise { + try { + const packageJsonPath = path.resolve(process.cwd(), 'package.json'); + const raw = await fs.readFile(packageJsonPath, 'utf-8'); + const pkg = JSON.parse(raw) as { scripts?: Record }; + const scripts = pkg.scripts ?? {}; + + const signals: RepoSignals = {}; + if (scripts.format) { + signals.formatter = scripts.format; + } + if (scripts.lint) { + signals.linter = scripts.lint; + } + if (scripts.test) { + signals.tests = scripts.test; + } + if (scripts.build) { + signals.build = scripts.build; + } + return signals; + } catch { + // Silently ignore if package.json is unavailable + return {}; + } +} + +function toCommandList(signals: RepoSignals): string[] { + const commands: string[] = []; + if (signals.formatter) { + commands.push(`Formatter: ${signals.formatter}`); + } + if (signals.linter) { + commands.push(`Linter: ${signals.linter}`); + } + if (signals.tests) { + commands.push(`Tests: ${signals.tests}`); + } + if (signals.build) { + commands.push(`Build: ${signals.build}`); + } + return commands; +} + +export async function prepareLocalChange( + context: ServerContext, + args: unknown +): Promise { + try { + const input: PrepareLocalChangeInput = prepareLocalChangeSchema.parse(args); + + context.logger.info(`Preparing local change for task: ${input.task}`); + + const signals = await discoverRepoSignals(); + const commands = toCommandList(signals); + + const suggestedPlan = [ + 'Summarize the requested change and desired outcome.', + 'Call `evaluate_change` with this summary (include repository, files, risk) to assess flag requirements.', + 'Identify candidate files/components to touch and inspect existing flag coverage.', + 'Draft the smallest diff guarded by the relevant feature flag.', + 'Run formatter, linter, and tests before finalizing the change.', + ]; + + const nextTools = [ + { + name: 'evaluate_change', + reason: 'Score risk, detect parent flags, and decide whether to create or reuse a flag prior to editing files.', + }, + { + name: 'create_flag', + reason: 'Create the feature flag if the evaluation recommends introducing one.', + }, + { + name: 'wrap_change', + reason: 'Collect language-specific snippets to wrap the implementation safely.', + }, + ]; + + const textSections = [ + `# Local Change Preparation`, + '', + `**Task**: ${input.task}`, + '', + input.hints && input.hints.length > 0 + ? `**Hints**: ${input.hints.map(hint => `\`${hint}\``).join(', ')}` + : undefined, + '', + '## Recommended Flow', + '', + '1. Use this output to understand repo guardrails before editing files.', + '2. Call `evaluate_change` with this task summary to plan flag usage.', + '3. Review `unleash://guides/local-change-checklist` and `unleash://workspace/summary` for conventions.', + '4. Inspect existing flag usage around candidate files before drafting changes.', + '5. Apply the smallest safe diff, guarded by the recommended flag, and run local checks.', + '', + commands.length > 0 ? '## Repository Commands' : undefined, + commands.length > 0 ? commands.map(command => `- ${command}`).join('\n') : undefined, + '', + '## Next Tools', + '', + nextTools.map(tool => `- **${tool.name}**: ${tool.reason}`).join('\n'), + ].filter((section): section is string => Boolean(section)); + + const structuredContent = { + success: true, + task: input.task, + hints: input.hints ?? [], + plan: suggestedPlan, + repoCommands: signals, + nextTools, + resources: [ + 'unleash://workspace/summary', + 'unleash://guides/local-change-checklist', + 'unleash://guides/feature-development-workflow', + ], + nextActions: nextTools.map(tool => tool.name), + impactedFiles: input.hints ?? [], + }; + + return { + content: [ + { + type: 'text', + text: textSections.join('\n'), + }, + ], + structuredContent, + }; + } catch (error) { + return handleToolError(context, error, 'prepare_local_change'); + } +} + +export const prepareLocalChangeTool = { + name: 'prepare_local_change', + description: + 'Gather local guardrails in this repository before editing files. Returns repository commands, next actions, and guardrails for the local workflow.', + annotations: { + title: '01 Prepare Local Change', + readOnlyHint: true, + idempotentHint: true, + openWorldHint: false, + }, + inputSchema: { + type: 'object', + properties: { + task: { + type: 'string', + description: 'Summary of the requested change or refactor.', + }, + hints: { + type: 'array', + items: { type: 'string' }, + description: 'Optional hints such as suspected file paths or components involved.', + }, + }, + required: ['task'], + }, +}; diff --git a/src/tools/runChecks.ts b/src/tools/runChecks.ts new file mode 100644 index 0000000..27c3347 --- /dev/null +++ b/src/tools/runChecks.ts @@ -0,0 +1,96 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { z } from 'zod'; +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { ServerContext, handleToolError } from '../context.js'; + +const runChecksSchema = z + .object({}) + .describe('No parameters needed. Runs locally-defined check commands virtually and reports what should be run.'); + +function extractScripts(): Record { + const scripts: Record = {}; + try { + const packageJsonPath = path.resolve(process.cwd(), 'package.json'); + const raw = readFileSync(packageJsonPath, 'utf-8'); + const pkg = JSON.parse(raw) as { scripts?: Record }; + Object.assign(scripts, pkg.scripts ?? {}); + } catch { + // ignore + } + return scripts; +} + +function selectCommands(allScripts: Record): Record { + const candidates = ['format', 'lint', 'test', 'build', 'typecheck']; + const result: Record = {}; + for (const key of candidates) { + if (allScripts[key]) { + result[key] = allScripts[key]; + } + } + return result; +} + +export async function runChecks( + context: ServerContext, + args: unknown +): Promise { + try { + runChecksSchema.parse(args ?? {}); + + const scripts = extractScripts(); + const commands = selectCommands(scripts); + + const lines: string[] = [ + '# Local Check Summary (no-op)', + '', + 'This MCP server does not execute commands directly. Run the following locally after applying your changes:', + '', + ]; + + if (Object.keys(commands).length === 0) { + lines.push('- No formatter/linter/test scripts detected. Run your project-specific checks manually.'); + } else { + for (const [name, cmd] of Object.entries(commands)) { + lines.push(`- **${name}**: ${cmd}`); + } + } + + const structuredContent = { + success: true, + executed: false, + recommendations: commands, + message: + 'Commands were not executed automatically. Run the listed formatter, linter, and test commands locally before finalizing the change.', + }; + + return { + content: [ + { + type: 'text', + text: lines.join('\n'), + }, + ], + structuredContent, + }; + } catch (error) { + return handleToolError(context, error, 'run_checks'); + } +} + +export const runChecksTool = { + name: 'run_checks', + description: + 'Summarize which formatter, linter, and test commands should run in this repository before finalizing a change.', + annotations: { + title: 'Run Checks Summary', + readOnlyHint: true, + idempotentHint: true, + openWorldHint: false, + }, + inputSchema: { + type: 'object', + properties: {}, + }, +}; diff --git a/src/tools/wrapChange.ts b/src/tools/wrapChange.ts index 6805e10..6f2d4c1 100644 --- a/src/tools/wrapChange.ts +++ b/src/tools/wrapChange.ts @@ -500,7 +500,7 @@ function escapeRegExp(value: string): string { */ export const wrapChangeTool = { name: 'wrap_change', - description: `Generate code snippets and guidance for wrapping changes with feature flags. + description: `Generate code snippets and guidance for wrapping local changes with feature flags. This tool provides language-specific templates and instructions for protecting code changes with feature flags. It helps you: - Find existing feature flag patterns in your codebase @@ -521,12 +521,18 @@ Supported languages: The tool uses a prompt-based approach: it provides detailed instructions for searching your codebase for existing patterns and matching their conventions. If no patterns are found, it provides sensible defaults based on Unleash SDK documentation. Usage: -1. Call this tool with the flag name after creating a flag -2. Follow the search instructions to find existing patterns -3. Use the recommended template or match detected patterns +1. Call this tool with the flag name right after \`create_flag\` (or when reusing an existing flag) +2. Follow the search instructions to find existing patterns in this repository +3. Use the recommended template or match detected patterns locally 4. Test your implementation Best suited for use after evaluate_change recommends a flag and create_flag creates it.`, + annotations: { + title: '04 Wrap Change', + readOnlyHint: true, + idempotentHint: true, + openWorldHint: false, + }, inputSchema: { type: 'object', properties: {