Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions extensions/cli/spec/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
- `<workspace>/.continue/.env`
- `<workspace>/.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.
Expand Down
10 changes: 6 additions & 4 deletions extensions/cli/src/CLIPlatformClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}
}
Expand All @@ -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;
Expand Down
29 changes: 21 additions & 8 deletions extensions/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Nov 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passing the Error into logger.error causes it to call sentryService.captureException, so the explicit capture below now fires twice per rejection, producing duplicate Sentry events.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At extensions/cli/src/index.ts, line 103:

<comment>Passing the Error into `logger.error` causes it to call `sentryService.captureException`, so the explicit capture below now fires twice per rejection, producing duplicate Sentry events.</comment>

<file context>
@@ -91,7 +91,26 @@ export function shouldShowExitMessage(): boolean {
+
+  // If reason is an Error, use it directly for better stack traces
+  if (reason instanceof Error) {
+    logger.error(&quot;Unhandled Promise Rejection&quot;, reason, errorDetails);
+  } else {
+    // Convert non-Error reasons to Error for consistent handling
</file context>

✅ Addressed in 4ec2b1b

Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Nov 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passing the Error object into logger.error causes that helper to capture the exception with Sentry while the handler still calls sentryService.captureException, so every unhandled rejection is reported twice. Remove one of the captures or log the details without treating the argument as an Error to avoid duplicate alerts.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At extensions/cli/src/index.ts, line 103:

<comment>Passing the Error object into `logger.error` causes that helper to capture the exception with Sentry while the handler still calls `sentryService.captureException`, so every unhandled rejection is reported twice. Remove one of the captures or log the details without treating the argument as an `Error` to avoid duplicate alerts.</comment>

<file context>
@@ -91,7 +91,26 @@ export function shouldShowExitMessage(): boolean {
+
+  // If reason is an Error, use it directly for better stack traces
+  if (reason instanceof Error) {
+    logger.error(&quot;Unhandled Promise Rejection&quot;, reason, errorDetails);
+  } else {
+    // Convert non-Error reasons to Error for consistent handling
</file context>

✅ Addressed in 4ec2b1b

} 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
});

Expand Down
7 changes: 2 additions & 5 deletions extensions/cli/src/services/MCPService.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 {
Expand Down
13 changes: 13 additions & 0 deletions extensions/cli/src/util/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]";
Expand Down
72 changes: 72 additions & 0 deletions packages/config-yaml/src/load/unroll.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { PackageIdentifier } from "../interfaces/slugs.js";
import {
fillTemplateVariables,
getTemplateVariables,
parseMarkdownRuleOrAssistantUnrolled,
replaceInputsWithSecrets,
} from "./unroll.js";
Expand Down Expand Up @@ -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 }}");
});
});
22 changes: 20 additions & 2 deletions packages/config-yaml/src/load/unroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
const matches = templatedYaml.matchAll(TEMPLATE_VAR_REGEX);
for (const match of matches) {
Expand All @@ -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) {
Expand Down Expand Up @@ -701,7 +711,7 @@ function injectLocalSourceFile(

export async function resolveBlock(
id: PackageIdentifier,
inputs: Record<string, string> | undefined,
inputs: Record<string, string | undefined> | undefined,
registry: Registry,
): Promise<AssistantUnrolled> {
// Retrieve block raw yaml
Expand Down Expand Up @@ -777,11 +787,19 @@ function parseYamlOrMarkdownRule<T>(
}

function inputsToFQSNs(
inputs: Record<string, string>,
inputs: Record<string, string | undefined>,
blockIdentifier: PackageIdentifier,
): Record<string, string> {
const renderedInputs: Record<string, string> = {};
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]),
});
Expand Down
Loading