diff --git a/extensions/cli/spec/mcp.md b/extensions/cli/spec/mcp.md index aefd196080b..1ba40eb5fb7 100644 --- a/extensions/cli/spec/mcp.md +++ b/extensions/cli/spec/mcp.md @@ -4,6 +4,24 @@ Model Context Protocol is a protocol for giving models access to resources and tools. MCP servers can run locally (stdio) or be remote (http/streamable/etc). See https://modelcontextprotocol.io/specification. The Continue CLI uses MCP to extend model's capabilities with MCP prompts and tools. MCP server configurations are stored in the `mcpServers` field of an assistant/config.yaml configuration. +## Secret Resolution + +MCP server configurations often require secrets (API keys, tokens, etc.) referenced using template variables like `${{ secrets.API_KEY }}`. The CLI resolves these secrets in the following order: + +1. **Organization/Package Secrets**: First attempts to resolve secrets through the Continue API for organization or package-level secrets +2. **Local Environment Variables**: Falls back to local environment variables if: + - The API secret exists but isn't accessible locally (e.g., in devboxes or restricted environments) + - The secret isn't found in organization/package secrets + +Local environment variables are checked in this priority order: + +- `process.env` (runtime environment variables) +- `~/.continue/.env` +- `/.continue/.env` +- `/.env` + +This fallback mechanism ensures MCP servers can start successfully in environments where organization secrets aren't accessible, such as development containers or CI/CD pipelines, by allowing environment variables to provide the required credentials. + ## MCP Service The CLI has an MCP Service should manage connections to MCP servers and provide state to the terminal app regarding MCP server connections. diff --git a/extensions/cli/src/CLIPlatformClient.ts b/extensions/cli/src/CLIPlatformClient.ts index 2f7c2dbfe54..2f004639729 100644 --- a/extensions/cli/src/CLIPlatformClient.ts +++ b/extensions/cli/src/CLIPlatformClient.ts @@ -105,9 +105,10 @@ export class CLIPlatformClient implements PlatformClient { }, }); - // Merge API results into our results array + // Merge API results into our results array - but only if they have an actual value + // API can return found=true for secrets that exist but aren't accessible locally for (let i = 0; i < apiResults.length; i++) { - if (apiResults[i]?.found) { + if (apiResults[i]?.found && "value" in apiResults[i]) { results[i] = apiResults[i]; } } @@ -119,9 +120,10 @@ export class CLIPlatformClient implements PlatformClient { ); } - // For any secret that wasn't found via API, look in local .env files + // For any secret without a value (not just "not found"), look in local .env files + // This allows environment variables to override org/package secrets that aren't accessible for (let i = 0; i < fqsns.length; i++) { - if (!results[i]?.found) { + if (!results[i] || !("value" in results[i]!)) { const secretFromEnv = this.findSecretInLocalEnvFiles(fqsns[i]); if (secretFromEnv?.found) { results[i] = secretFromEnv; diff --git a/extensions/cli/src/index.ts b/extensions/cli/src/index.ts index 4735f416fab..76fef86d0e7 100644 --- a/extensions/cli/src/index.ts +++ b/extensions/cli/src/index.ts @@ -91,19 +91,32 @@ export function shouldShowExitMessage(): boolean { // Add global error handlers to prevent uncaught errors from crashing the process process.on("unhandledRejection", (reason, promise) => { - logger.error("Unhandled Rejection at:", { promise, reason }); - sentryService.captureException( - reason instanceof Error ? reason : new Error(String(reason)), - { - promise: String(promise), - }, - ); + // Extract useful information from the reason + const errorDetails = { + promiseString: String(promise), + reasonType: typeof reason, + reasonConstructor: reason?.constructor?.name, + }; + + // If reason is an Error, use it directly for better stack traces + if (reason instanceof Error) { + logger.error("Unhandled Promise Rejection", reason, errorDetails); + } else { + // Convert non-Error reasons to Error for consistent handling + const error = new Error(`Unhandled rejection: ${String(reason)}`); + logger.error("Unhandled Promise Rejection", error, { + ...errorDetails, + originalReason: String(reason), + }); + } + + // Note: Sentry capture is handled by logger.error() above // Don't exit the process, just log the error }); process.on("uncaughtException", (error) => { logger.error("Uncaught Exception:", error); - sentryService.captureException(error); + // Note: Sentry capture is handled by logger.error() above // Don't exit the process, just log the error }); diff --git a/extensions/cli/src/services/MCPService.ts b/extensions/cli/src/services/MCPService.ts index 01f449eed9d..3860259aec0 100644 --- a/extensions/cli/src/services/MCPService.ts +++ b/extensions/cli/src/services/MCPService.ts @@ -1,7 +1,4 @@ -import { - decodeSecretLocation, - getTemplateVariables, -} from "@continuedev/config-yaml"; +import { decodeFQSN, getTemplateVariables } from "@continuedev/config-yaml"; import { type AssistantConfig } from "@continuedev/sdk"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; @@ -318,7 +315,7 @@ export class MCPService const vars = getTemplateVariables(JSON.stringify(serverConfig)); const secretVars = vars.filter((v) => v.startsWith("secrets.")); const unrendered = secretVars.map((v) => { - return decodeSecretLocation(v.replace("secrets.", "")).secretName; + return decodeFQSN(v.replace("secrets.", "")).secretName; }); try { diff --git a/extensions/cli/src/util/logger.ts b/extensions/cli/src/util/logger.ts index 8f32e67fe63..69a672c6596 100644 --- a/extensions/cli/src/util/logger.ts +++ b/extensions/cli/src/util/logger.ts @@ -44,6 +44,19 @@ function createReplacer() { seen.add(value); } + // Handle Error objects explicitly - extract useful properties + if (value instanceof Error) { + const errorObj: any = { + name: value.name, + message: value.message, + stack: value.stack, + }; + if (value.cause) { + errorObj.cause = value.cause; + } + return errorObj; + } + // Handle functions if (typeof value === "function") { return "[Function]"; diff --git a/packages/config-yaml/src/load/unroll.test.ts b/packages/config-yaml/src/load/unroll.test.ts index 9a308dd9be7..3357e9cfc3e 100644 --- a/packages/config-yaml/src/load/unroll.test.ts +++ b/packages/config-yaml/src/load/unroll.test.ts @@ -1,5 +1,7 @@ import { PackageIdentifier } from "../interfaces/slugs.js"; import { + fillTemplateVariables, + getTemplateVariables, parseMarkdownRuleOrAssistantUnrolled, replaceInputsWithSecrets, } from "./unroll.js"; @@ -274,3 +276,73 @@ data: expect(result).not.toContain("inputs.dbPassword"); }); }); + +describe("getTemplateVariables edge cases", () => { + it("handles undefined input gracefully", () => { + const result = getTemplateVariables(undefined as any); + expect(result).toEqual([]); + }); + + it("handles null input gracefully", () => { + const result = getTemplateVariables(null as any); + expect(result).toEqual([]); + }); + + it("handles non-string input gracefully", () => { + const result = getTemplateVariables(123 as any); + expect(result).toEqual([]); + }); + + it("handles empty string correctly", () => { + const result = getTemplateVariables(""); + expect(result).toEqual([]); + }); + + it("extracts template variables from valid input", () => { + const result = getTemplateVariables("\${{ secrets.apiKey }}"); + expect(result).toEqual(["secrets.apiKey"]); + }); + + it("extracts multiple template variables", () => { + const result = getTemplateVariables( + "\${{ secrets.key1 }} and \${{ inputs.key2 }}", + ); + expect(result).toContain("secrets.key1"); + expect(result).toContain("inputs.key2"); + expect(result.length).toBe(2); + }); +}); + +describe("fillTemplateVariables edge cases", () => { + it("handles undefined input gracefully", () => { + const result = fillTemplateVariables(undefined as any, {}); + expect(result).toBe(""); + }); + + it("handles null input gracefully", () => { + const result = fillTemplateVariables(null as any, {}); + expect(result).toBe(""); + }); + + it("handles non-string input gracefully", () => { + const result = fillTemplateVariables(123 as any, {}); + expect(result).toBe(""); + }); + + it("handles empty string correctly", () => { + const result = fillTemplateVariables("", {}); + expect(result).toBe(""); + }); + + it("fills template variables with valid input", () => { + const result = fillTemplateVariables("\${{ secrets.apiKey }}", { + "secrets.apiKey": "my-secret-key", + }); + expect(result).toBe("my-secret-key"); + }); + + it("leaves unfilled variables unchanged", () => { + const result = fillTemplateVariables("\${{ secrets.apiKey }}", {}); + expect(result).toBe("\${{ secrets.apiKey }}"); + }); +}); diff --git a/packages/config-yaml/src/load/unroll.ts b/packages/config-yaml/src/load/unroll.ts index eb27949653c..49476925ea2 100644 --- a/packages/config-yaml/src/load/unroll.ts +++ b/packages/config-yaml/src/load/unroll.ts @@ -86,6 +86,11 @@ export function parseBlock(configYaml: string): Block { export const TEMPLATE_VAR_REGEX = /\${{[\s]*([^}\s]+)[\s]*}}/g; export function getTemplateVariables(templatedYaml: string): string[] { + // Defensive guard against undefined/null/non-string values + if (!templatedYaml || typeof templatedYaml !== "string") { + return []; + } + const variables = new Set(); const matches = templatedYaml.matchAll(TEMPLATE_VAR_REGEX); for (const match of matches) { @@ -98,6 +103,11 @@ export function fillTemplateVariables( templatedYaml: string, data: { [key: string]: string }, ): string { + // Defensive guard against undefined/null/non-string values + if (!templatedYaml || typeof templatedYaml !== "string") { + return ""; + } + return templatedYaml.replace(TEMPLATE_VAR_REGEX, (match, variableName) => { // Inject data if (variableName in data) { @@ -701,7 +711,7 @@ function injectLocalSourceFile( export async function resolveBlock( id: PackageIdentifier, - inputs: Record | undefined, + inputs: Record | undefined, registry: Registry, ): Promise { // Retrieve block raw yaml @@ -777,11 +787,19 @@ function parseYamlOrMarkdownRule( } function inputsToFQSNs( - inputs: Record, + inputs: Record, blockIdentifier: PackageIdentifier, ): Record { const renderedInputs: Record = {}; for (const [key, value] of Object.entries(inputs)) { + // Skip undefined, null, or non-string values + if (value === undefined || value === null || typeof value !== "string") { + console.warn( + `Skipping input "${key}" with invalid value type: ${typeof value}. Expected string.`, + ); + continue; + } + renderedInputs[key] = renderTemplateData(value, { secrets: extractFQSNMap(value, [blockIdentifier]), });