From dc00a80ec055cdcbb54b67079e268eedf91b6514 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Wed, 13 Aug 2025 17:13:10 -0400 Subject: [PATCH 1/9] feat: add install_devcycle_sdk MCP tool and build-time prompt fetcher --- .gitattributes | 2 + package.json | 2 +- scripts/fetch-install-prompts.js | 103 +++++++++++++++++++++++ src/mcp/server.ts | 12 +++ src/mcp/tools/index.ts | 2 + src/mcp/tools/installGuides.generated.ts | 33 ++++++++ src/mcp/tools/installTools.ts | 62 ++++++++++++++ 7 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 .gitattributes create mode 100644 scripts/fetch-install-prompts.js create mode 100644 src/mcp/tools/installGuides.generated.ts create mode 100644 src/mcp/tools/installTools.ts 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/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..a2fd84df0 --- /dev/null +++ b/scripts/fetch-install-prompts.js @@ -0,0 +1,103 @@ +#!/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-Sandbox/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 : [] + const mdFiles = all + .filter((item) => item.type === 'blob') + .map((item) => item.path) + .filter((p) => p.startsWith('install-prompts/')) + .filter((p) => p.toLowerCase().endsWith('.md')) + + const slugs = mdFiles + .map((p) => p.replace(/^install-prompts\//, '').replace(/\.md$/i, '')) + .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() + .then(() => {}) + .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..7b4e3635c 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, apiClient) // 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..57513ba00 --- /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..381906c6f --- /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-Sandbox/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, + _apiClient: IDevCycleApiClient, +): void { + void _apiClient + serverInstance.registerToolWithErrorHandling( + 'install_devcycle_sdk', + { + description: + 'Fetch and return the latest DevCycle install prompt Markdown from the install-prompts repository by guide name. Input: { guide: string }', + annotations: { + title: 'Install DevCycle SDK', + readOnlyHint: true, + }, + inputSchema: InstallGuideArgsSchema.shape, + }, + async (args: unknown) => { + const validatedArgs = InstallGuideArgsSchema.parse(args) + return await fetchInstallGuideHandler(validatedArgs) + }, + ) +} From 65ec03256fd9dba837d972927814aa5905e4edf1 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Thu, 14 Aug 2025 14:20:05 -0400 Subject: [PATCH 2/9] chore: build changes --- README.md | 2 +- oclif.manifest.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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", From d97918f283314602ecd09c23e5da8a6bdecaaf3b Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Thu, 14 Aug 2025 16:32:36 -0400 Subject: [PATCH 3/9] fix: harden build-time prompt fetcher against path traversal and unsafe slugs --- scripts/fetch-install-prompts.js | 35 ++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/scripts/fetch-install-prompts.js b/scripts/fetch-install-prompts.js index a2fd84df0..14e671ee1 100644 --- a/scripts/fetch-install-prompts.js +++ b/scripts/fetch-install-prompts.js @@ -57,15 +57,42 @@ async function main() { 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((p) => p.startsWith('install-prompts/')) + .filter(isValidPath) .filter((p) => p.toLowerCase().endsWith('.md')) - const slugs = mdFiles - .map((p) => p.replace(/^install-prompts\//, '').replace(/\.md$/i, '')) - .sort((a, b) => a.localeCompare(b)) + // 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(/\/+/, '/') + // 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 From 0d493d4e1d6d60402b658d0edadf6107ec7b319b Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Thu, 14 Aug 2025 16:39:24 -0400 Subject: [PATCH 4/9] chore: switch install prompt source to DevCycleHQ org and update runtime fetch URL --- scripts/fetch-install-prompts.js | 2 +- src/mcp/tools/installTools.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/fetch-install-prompts.js b/scripts/fetch-install-prompts.js index 14e671ee1..895d48b95 100644 --- a/scripts/fetch-install-prompts.js +++ b/scripts/fetch-install-prompts.js @@ -12,7 +12,7 @@ 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-Sandbox/AI-Prompts-And-Rules/git/trees/main?recursive=1' +const TREE_URL = 'https://api.github.com/repos/DevCycleHQ/AI-Prompts-And-Rules/git/trees/main?recursive=1' function fetchJson(url, headers = {}) { const requestHeaders = { diff --git a/src/mcp/tools/installTools.ts b/src/mcp/tools/installTools.ts index 381906c6f..951a3c520 100644 --- a/src/mcp/tools/installTools.ts +++ b/src/mcp/tools/installTools.ts @@ -16,7 +16,7 @@ async function fetchInstallGuideHandler(args: InstallGuideArgs) { ? trimmedGuide : `${trimmedGuide}.md` const repoPath = `install-prompts/${fileName}` - const sourceUrl = `https://raw.githubusercontent.com/DevCycleHQ-Sandbox/AI-Prompts-And-Rules/main/${repoPath}` + const sourceUrl = `https://raw.githubusercontent.com/DevCycleHQ/AI-Prompts-And-Rules/main/${repoPath}` try { const response = await axios.get(sourceUrl, { From 85dab56988ad793f1309d296b5f128b7c4ab520f Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Thu, 14 Aug 2025 16:43:42 -0400 Subject: [PATCH 5/9] fix: address review comments (remove unused apiClient param, global slash collapse) --- scripts/fetch-install-prompts.js | 2 +- src/mcp/tools/index.ts | 2 +- src/mcp/tools/installTools.ts | 2 -- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/scripts/fetch-install-prompts.js b/scripts/fetch-install-prompts.js index 895d48b95..936072822 100644 --- a/scripts/fetch-install-prompts.js +++ b/scripts/fetch-install-prompts.js @@ -86,7 +86,7 @@ async function main() { // allow only safe characters (letters, numbers, dash, underscore, slash) .replace(/[^a-zA-Z0-9_\-\/]/g, '') // collapse multiple slashes - .replace(/\/+/, '/') + .replace(/\/+\/+/g, '/') // trim leading/trailing slashes .replace(/^\/+|\/+$/g, '') if (cleaned) slugSet.add(cleaned) diff --git a/src/mcp/tools/index.ts b/src/mcp/tools/index.ts index 7b4e3635c..c17762def 100644 --- a/src/mcp/tools/index.ts +++ b/src/mcp/tools/index.ts @@ -27,7 +27,7 @@ export function registerAllToolsWithServer( registerResultsTools(serverInstance, apiClient) registerSelfTargetingTools(serverInstance, apiClient) registerVariableTools(serverInstance, apiClient) - registerInstallTools(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/installTools.ts b/src/mcp/tools/installTools.ts index 951a3c520..302adfb31 100644 --- a/src/mcp/tools/installTools.ts +++ b/src/mcp/tools/installTools.ts @@ -40,9 +40,7 @@ async function fetchInstallGuideHandler(args: InstallGuideArgs) { export function registerInstallTools( serverInstance: DevCycleMCPServerInstance, - _apiClient: IDevCycleApiClient, ): void { - void _apiClient serverInstance.registerToolWithErrorHandling( 'install_devcycle_sdk', { From ee74b1cc4b2733d9a0a4f96729dbd83f3f94a8ab Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Thu, 14 Aug 2025 16:43:55 -0400 Subject: [PATCH 6/9] chore: format --- src/mcp/tools/installGuides.generated.ts | 60 ++++++++++++------------ 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/src/mcp/tools/installGuides.generated.ts b/src/mcp/tools/installGuides.generated.ts index 57513ba00..3260796c1 100644 --- a/src/mcp/tools/installGuides.generated.ts +++ b/src/mcp/tools/installGuides.generated.ts @@ -1,33 +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" + '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] +export type InstallGuideId = (typeof INSTALL_GUIDES)[number] From a1bc07492f817745c2fce04f9a6ac045c4bb2210 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Thu, 14 Aug 2025 16:52:15 -0400 Subject: [PATCH 7/9] chore: keep generated installGuides.generated.ts in repo but exclude from Prettier --- .prettierignore | 3 +++ 1 file changed, 3 insertions(+) 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 From 720a508ed5146fabdd28864c65d8f0c4eb58cad0 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Thu, 14 Aug 2025 17:01:09 -0400 Subject: [PATCH 8/9] feat: update tool description --- src/mcp/tools/installTools.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/mcp/tools/installTools.ts b/src/mcp/tools/installTools.ts index 302adfb31..5bb0b403e 100644 --- a/src/mcp/tools/installTools.ts +++ b/src/mcp/tools/installTools.ts @@ -44,8 +44,10 @@ export function registerInstallTools( serverInstance.registerToolWithErrorHandling( 'install_devcycle_sdk', { - description: - 'Fetch and return the latest DevCycle install prompt Markdown from the install-prompts repository by guide name. Input: { guide: string }', + 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, From 7467855d2d23a1eaab73b6f000ab380f889b0858 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Thu, 14 Aug 2025 17:03:24 -0400 Subject: [PATCH 9/9] chore: simplify script exit flow by removing empty then() on main() --- scripts/fetch-install-prompts.js | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/scripts/fetch-install-prompts.js b/scripts/fetch-install-prompts.js index 936072822..e286b7cf6 100644 --- a/scripts/fetch-install-prompts.js +++ b/scripts/fetch-install-prompts.js @@ -120,11 +120,8 @@ export type InstallGuideId = typeof INSTALL_GUIDES[number] } } -main() - .then(() => {}) - .catch((err) => { - console.error(err) - process.exit(1) - }) - +main().catch((err) => { + console.error(err) + process.exit(1) +})