diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..44aaf0c75 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Mark auto-generated files so GitHub linguist ignores them in code stats +src/mcp/tools/installGuides.generated.ts linguist-generated diff --git a/.prettierignore b/.prettierignore index e03a8cec1..b8ee1e3da 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,3 +7,6 @@ oclif.manifest.json README.md **/*.snap test-utils/fixtures + +# Generated install guides enum +src/mcp/tools/installGuides.generated.ts diff --git a/README.md b/README.md index f5828f423..eb44d55ad 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,7 @@ $ npm install -g @devcycle/cli $ dvc COMMAND running command... $ dvc (--version) -@devcycle/cli/6.0.1 linux-x64 node-v22.18.0 +@devcycle/cli/6.0.1 darwin-arm64 node-v22.17.1 $ dvc --help [COMMAND] USAGE $ dvc COMMAND diff --git a/oclif.manifest.json b/oclif.manifest.json index 7b1ce8a71..8c9c8bfbc 100644 --- a/oclif.manifest.json +++ b/oclif.manifest.json @@ -1,5 +1,5 @@ { - "version": "6.0.0", + "version": "6.0.1", "commands": { "authCommand": { "id": "authCommand", diff --git a/package.json b/package.json index 18210732e..bd7f6b995 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "/oclif.manifest.json" ], "scripts": { - "build": "shx rm -rf dist && tsc -b && oclif manifest", + "build": "node scripts/fetch-install-prompts.js && shx rm -rf dist && tsc -b && oclif manifest", "build:tar": "oclif pack tarballs", "build:worker": "cd mcp-worker && yarn build", "deploy:worker": "cd mcp-worker && yarn deploy", diff --git a/scripts/fetch-install-prompts.js b/scripts/fetch-install-prompts.js new file mode 100644 index 000000000..e286b7cf6 --- /dev/null +++ b/scripts/fetch-install-prompts.js @@ -0,0 +1,127 @@ +#!/usr/bin/env node +/* + * Auto-generates src/mcp/tools/installGuides.generated.ts + * by listing all Markdown files under install-prompts/ (recursively) + * from the AI-Prompts-And-Rules repo on the main branch. + * + * Includes OpenFeature guides automatically. + */ + +const https = require('https') +const fs = require('fs') +const path = require('path') + +// Keep a single constant for clarity and reuse +const TREE_URL = 'https://api.github.com/repos/DevCycleHQ/AI-Prompts-And-Rules/git/trees/main?recursive=1' + +function fetchJson(url, headers = {}) { + const requestHeaders = { + 'User-Agent': 'devcycle-cli-build-script', + Accept: 'application/vnd.github+json', + ...headers, + } + return new Promise((resolve, reject) => { + const req = https.get(url, { headers: requestHeaders }, (res) => { + let data = '' + res.on('data', (chunk) => (data += chunk)) + res.on('end', () => { + if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { + try { + resolve(JSON.parse(data)) + } catch (err) { + reject(err) + } + } else { + reject(new Error(`HTTP ${res.statusCode}: ${data}`)) + } + }) + }) + req.on('error', reject) + req.end() + }) +} + +async function main() { + const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN + const headers = token ? { Authorization: `Bearer ${token}` } : {} + + const outFile = path.resolve( + __dirname, + '..', + 'src', + 'mcp', + 'tools', + 'installGuides.generated.ts', + ) + + try { + const tree = await fetchJson(TREE_URL, headers) + const all = Array.isArray(tree.tree) ? tree.tree : [] + + // Validate and sanitize file paths returned by GitHub API + const isValidPath = (filePath) => { + return ( + typeof filePath === 'string' && + filePath.length > 0 && + !filePath.includes('..') && + !path.isAbsolute(filePath) && + filePath.startsWith('install-prompts/') && + /^[a-zA-Z0-9_.\-\/]+$/.test(filePath) + ) + } + + const mdFiles = all + .filter((item) => item.type === 'blob') + .map((item) => item.path) + .filter(isValidPath) + .filter((p) => p.toLowerCase().endsWith('.md')) + + // Build safe slugs (relative paths within install-prompts without extension) + const slugSet = new Set() + for (const p of mdFiles) { + const raw = p.replace(/^install-prompts\//, '').replace(/\.md$/i, '') + // extra guards on the slug + if (!raw || raw.includes('..') || raw.startsWith('/')) continue + const cleaned = raw + // allow only safe characters (letters, numbers, dash, underscore, slash) + .replace(/[^a-zA-Z0-9_\-\/]/g, '') + // collapse multiple slashes + .replace(/\/+\/+/g, '/') + // trim leading/trailing slashes + .replace(/^\/+|\/+$/g, '') + if (cleaned) slugSet.add(cleaned) + } + + const slugs = Array.from(slugSet).sort((a, b) => a.localeCompare(b)) + + const content = `// AUTO-GENERATED BY scripts/fetch-install-prompts.js. DO NOT EDIT. +export const INSTALL_GUIDES = ${JSON.stringify(slugs, null, 2)} as const +export type InstallGuideId = typeof INSTALL_GUIDES[number] +` + + fs.writeFileSync(outFile, content, 'utf8') + console.log( + `Generated ${outFile} with ${slugs.length} install guide entries.`, + ) + } catch (err) { + const message = `[fetch-install-prompts] Failed to generate guides list: ${ + err && err.message ? err.message : err + }` + if (fs.existsSync(outFile)) { + console.warn( + `${message}. Existing generated file found at ${outFile}. Proceeding with previously generated data.`, + ) + return + } + // No previously generated file; fail immediately. + throw new Error( + `${message}. No existing generated file found. Cannot proceed.`, + ) + } +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) + diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 04dfd52da..f180c8275 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -97,6 +97,18 @@ export class DevCycleMCPServer { try { const result = await handler(args) + // If the handler returned a plain string, send it as-is + if (typeof result === 'string') { + return { + content: [ + { + type: 'text' as const, + text: result, + }, + ], + } + } + return { content: [ { diff --git a/src/mcp/tools/index.ts b/src/mcp/tools/index.ts index c35a556eb..c17762def 100644 --- a/src/mcp/tools/index.ts +++ b/src/mcp/tools/index.ts @@ -11,6 +11,7 @@ import { registerSelfTargetingTools } from './selfTargetingTools' import { registerVariableTools } from './variableTools' import { registerLocalProjectTools } from './localProjectTools' import { DevCycleApiClient } from '../utils/api' +import { registerInstallTools } from './installTools' /** * Register all DevCycle MCP tools with a server instance @@ -26,6 +27,7 @@ export function registerAllToolsWithServer( registerResultsTools(serverInstance, apiClient) registerSelfTargetingTools(serverInstance, apiClient) registerVariableTools(serverInstance, apiClient) + registerInstallTools(serverInstance) // Register local project selection tools only for local MCP (not worker) // We detect local MCP by checking if the apiClient is an instance of DevCycleApiClient diff --git a/src/mcp/tools/installGuides.generated.ts b/src/mcp/tools/installGuides.generated.ts new file mode 100644 index 000000000..3260796c1 --- /dev/null +++ b/src/mcp/tools/installGuides.generated.ts @@ -0,0 +1,33 @@ +// AUTO-GENERATED BY scripts/fetch-install-prompts.js. DO NOT EDIT. +export const INSTALL_GUIDES = [ + 'android', + 'android-openfeature', + 'angular', + 'dotnet', + 'dotnet-openfeature', + 'flutter', + 'go', + 'go-openfeature', + 'ios', + 'ios-openfeature', + 'java', + 'java-openfeature', + 'javascript', + 'javascript-openfeature', + 'nestjs', + 'nestjs-openfeature', + 'nextjs', + 'nodejs', + 'nodejs-openfeature', + 'php', + 'php-openfeature', + 'python', + 'python-openfeature', + 'react', + 'react-native', + 'react-openfeature', + 'roku', + 'ruby', + 'ruby-openfeature', +] as const +export type InstallGuideId = (typeof INSTALL_GUIDES)[number] diff --git a/src/mcp/tools/installTools.ts b/src/mcp/tools/installTools.ts new file mode 100644 index 000000000..5bb0b403e --- /dev/null +++ b/src/mcp/tools/installTools.ts @@ -0,0 +1,62 @@ +import axios from 'axios' +import { z } from 'zod' +import type { IDevCycleApiClient } from '../api/interface' +import type { DevCycleMCPServerInstance } from '../server' +import { INSTALL_GUIDES } from './installGuides.generated' + +const InstallGuideArgsSchema = z.object({ + guide: z.enum(INSTALL_GUIDES), +}) + +type InstallGuideArgs = z.infer + +async function fetchInstallGuideHandler(args: InstallGuideArgs) { + const trimmedGuide = args.guide.trim().replace(/^\/+|\/+$/g, '') + const fileName = trimmedGuide.endsWith('.md') + ? trimmedGuide + : `${trimmedGuide}.md` + const repoPath = `install-prompts/${fileName}` + const sourceUrl = `https://raw.githubusercontent.com/DevCycleHQ/AI-Prompts-And-Rules/main/${repoPath}` + + try { + const response = await axios.get(sourceUrl, { + responseType: 'text', + }) + return response.data as string + } catch (error: unknown) { + const status = axios.isAxiosError(error) + ? error.response?.status + : undefined + if (status === 404) { + throw new Error( + `Install guide "${fileName}" not found in install-prompts/. Check the filename (with or without .md).`, + ) + } + throw new Error( + 'Unable to fetch install guide from GitHub. Please retry.', + ) + } +} + +export function registerInstallTools( + serverInstance: DevCycleMCPServerInstance, +): void { + serverInstance.registerToolWithErrorHandling( + 'install_devcycle_sdk', + { + description: [ + 'Fetch DevCycle SDK installation instructions, and follow the instructions to install the DevCycle SDK.', + "Choose the guide that matches the application's language/framework.", + ].join('\n'), + annotations: { + title: 'Install DevCycle SDK', + readOnlyHint: true, + }, + inputSchema: InstallGuideArgsSchema.shape, + }, + async (args: unknown) => { + const validatedArgs = InstallGuideArgsSchema.parse(args) + return await fetchInstallGuideHandler(validatedArgs) + }, + ) +}