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
22 changes: 21 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,27 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
## [0.7.0] - 2026-04-11

### Added

- New `--api-key <key>` CLI flag on `multicorn-proxy --wrap`. Lets users run the proxy without first creating a config file.
- New `MULTICORN_API_KEY` environment variable support. Resolves with priority `--api-key` flag > `MULTICORN_API_KEY` env var > `~/.multicorn/config.json`.
- New "Local MCP / Other" option in the `multicorn-proxy init` wizard. Skips the platform-specific setup steps and writes a minimal config suitable for wrapping any local MCP server with `--wrap`.
- SDK constructor now validates the API key format and rejects invalid keys (empty, wrong prefix, too short, or the literal placeholder `mcs_your_key_here`) with a clear error pointing at the settings page.

### Changed

- `multicorn-proxy init` platform menu now labels detected platforms as "detected locally" instead of "connected", with a dimmed dot icon instead of a green checkmark. The previous label implied account-level connection state, but the underlying detection only checks for local config files.
- Error message when no API key is configured now mentions all three sources: the `--api-key` flag, the `MULTICORN_API_KEY` environment variable, and the `npx multicorn-proxy init` config file path.
- All references to the API keys settings page now use the fragment URL `https://app.multicorn.ai/settings#api-keys` instead of the previous `/settings/api-keys` path which did not exist.

### Fixed

- `multicorn-proxy --wrap` now fails immediately at startup with a clear error if the configured API key is rejected by the Multicorn service. Previously the proxy logged "Agent resolved" and "Proxy ready" with empty agent state and only blocked tool calls at runtime, leaving users confused about why their setup was not working.
- `multicorn-proxy --wrap` now correctly accepts proxy flags (`--api-key`, `--base-url`, `--log-level`, `--dashboard-url`, `--agent-name`) when they appear between `--wrap` and the wrap command. Previously the parser bailed with "requires a command to run" because the early-exit guard rejected any flag-shaped token in that position before the stripping logic ran.
- `multicorn-proxy init` exit summary no longer renders a trailing dash for the "Local MCP / Other" option (which has no agent name). The summary line now reads `✓ Local MCP / Other` instead of `✓ Local MCP / Other -`.
- `multicorn-proxy init` no longer prints a misleading "Next steps" block referencing "Other MCP Agent" and `--agent-name` after the "Local MCP / Other" option. The "Try it" example printed inside the option 4 branch is sufficient guidance.

## [0.6.2] - 2026-04-09

Expand Down
144 changes: 108 additions & 36 deletions bin/multicorn-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,19 @@ import {
getAgentByPlatform,
getDefaultAgent,
isAllowedShieldApiBaseUrl,
DEFAULT_SHIELD_API_BASE_URL,
type ProxyConfig,
} from "../src/proxy/config.js";
import { createProxyServer } from "../src/proxy/index.js";
import { createLogger, isValidLogLevel, type LogLevel } from "../src/proxy/logger.js";
import {
createLogger,
isValidLogLevel,
type LogLevel,
type ProxyLogger,
} from "../src/proxy/logger.js";
import { deriveDashboardUrl } from "../src/proxy/consent.js";

interface CliArgs {
export interface CliArgs {
readonly subcommand: "init" | "wrap" | "help" | "agents" | "delete-agent";
readonly wrapCommand: string;
readonly wrapArgs: readonly string[];
Expand All @@ -34,9 +40,11 @@ interface CliArgs {
readonly dashboardUrl: string;
readonly agentName: string;
readonly deleteAgentName: string;
/** Set only when `--api-key` appears on the command line. */
readonly apiKey: string | undefined;
}

function parseArgs(argv: readonly string[]): CliArgs {
export function parseArgs(argv: readonly string[]): CliArgs {
const args = argv.slice(2);

let subcommand: CliArgs["subcommand"] = "help";
Expand All @@ -47,6 +55,7 @@ function parseArgs(argv: readonly string[]): CliArgs {
let dashboardUrl = "";
let agentName = "";
let deleteAgentName = "";
let apiKey: string | undefined = undefined;

for (let i = 0; i < args.length; i++) {
const arg = args[i];
Expand All @@ -67,49 +76,61 @@ function parseArgs(argv: readonly string[]): CliArgs {
i++;
} else if (arg === "--wrap") {
subcommand = "wrap";
const next = args[i + 1];
if (next === undefined || next.startsWith("-")) {
process.stderr.write("Error: --wrap requires a command to run.\n");
process.stderr.write("Example: npx multicorn-proxy --wrap my-mcp-server\n");
process.exit(1);
}
wrapCommand = next;
wrapArgs = args.slice(i + 2);

// Strip proxy-owned flags that ended up after --wrap so they
// aren't forwarded to the child process.
const cleaned: string[] = [];
for (let j = 0; j < wrapArgs.length; j++) {
const token = wrapArgs[j];
const tail = args.slice(i + 1);

// Strip proxy-owned flags from the tail. Once we encounter a
// non-flag token, everything from that point on belongs to the
// child command (even if later tokens look like flags).
const remaining: string[] = [];
for (let j = 0; j < tail.length; j++) {
const token = tail[j];
if (token === undefined) continue;
if (remaining.length > 0) {
remaining.push(token);
continue;
}
if (token === "--agent-name") {
const value = wrapArgs[j + 1];
const value = tail[j + 1];
if (value !== undefined) {
agentName = value;
j++;
}
} else if (token === "--log-level") {
const value = wrapArgs[j + 1];
const value = tail[j + 1];
if (value !== undefined && isValidLogLevel(value)) {
logLevel = value;
j++;
}
} else if (token === "--base-url") {
const value = wrapArgs[j + 1];
const value = tail[j + 1];
if (value !== undefined) {
baseUrl = value;
j++;
}
} else if (token === "--dashboard-url") {
const value = wrapArgs[j + 1];
const value = tail[j + 1];
if (value !== undefined) {
dashboardUrl = value;
j++;
}
} else if (token !== undefined) {
cleaned.push(token);
} else if (token === "--api-key") {
const value = tail[j + 1];
if (value !== undefined) {
apiKey = value;
j++;
}
} else {
remaining.push(token);
}
}
wrapArgs = cleaned;

if (remaining.length === 0) {
process.stderr.write("Error: --wrap requires a command to run.\n");
process.stderr.write("Example: npx multicorn-proxy --wrap my-mcp-server\n");
process.exit(1);
}
wrapCommand = remaining[0] ?? "";
wrapArgs = remaining.slice(1);
break;
} else if (arg === "--log-level") {
const next = args[i + 1];
Expand All @@ -135,6 +156,12 @@ function parseArgs(argv: readonly string[]): CliArgs {
agentName = next;
i++;
}
} else if (arg === "--api-key") {
const next = args[i + 1];
if (next !== undefined) {
apiKey = next;
i++;
}
}
}

Expand All @@ -147,6 +174,7 @@ function parseArgs(argv: readonly string[]): CliArgs {
dashboardUrl,
agentName,
deleteAgentName,
apiKey,
};
}

Expand All @@ -170,6 +198,7 @@ function printHelp(): void {
" Shield's permission layer.",
"",
"Options:",
" --api-key <key> Multicorn API key (overrides MULTICORN_API_KEY env var and config file)",
" --log-level <level> Log level: debug | info | warn | error (default: info)",
" --base-url <url> Multicorn API base URL (default: https://api.multicorn.ai)",
" --dashboard-url <url> Dashboard URL for consent page (default: derived from --base-url)",
Expand Down Expand Up @@ -242,13 +271,8 @@ async function main(): Promise<void> {
}
}

const config = await loadConfig();
if (config === null) {
process.stderr.write(
"No config found. Run `npx multicorn-proxy init` to set up your API key.\n",
);
process.exit(1);
}
// Resolve API key: CLI flag > env var > config file.
const config = await resolveWrapConfig(cli, logger);

const finalBaseUrl =
cli.baseUrl !== undefined && cli.baseUrl.length > 0 ? cli.baseUrl : config.baseUrl;
Expand Down Expand Up @@ -301,6 +325,44 @@ async function main(): Promise<void> {
await proxy.start();
}

/**
* Resolve the proxy config for --wrap mode.
* Priority: --api-key flag > MULTICORN_API_KEY env var > ~/.multicorn/config.json.
* When the key comes from flag or env, an in-memory config is built (nothing written to disk).
*/
export async function resolveWrapConfig(cli: CliArgs, logger: ProxyLogger): Promise<ProxyConfig> {
// 1. --api-key CLI flag
if (cli.apiKey !== undefined && cli.apiKey.length > 0) {
logger.debug("Using API key from --api-key flag.");
return {
apiKey: cli.apiKey,
baseUrl: cli.baseUrl ?? DEFAULT_SHIELD_API_BASE_URL,
};
}

// 2. MULTICORN_API_KEY env var
const envKey = process.env["MULTICORN_API_KEY"];
if (typeof envKey === "string" && envKey.length > 0) {
logger.debug("Using API key from MULTICORN_API_KEY environment variable.");
return {
apiKey: envKey,
baseUrl: cli.baseUrl ?? DEFAULT_SHIELD_API_BASE_URL,
};
}

// 3. Config file
const config = await loadConfig();
if (config !== null) {
return config;
}

process.stderr.write(
"No API key found. Provide one via the --api-key flag, the MULTICORN_API_KEY " +
"environment variable, or run `npx multicorn-proxy init` to set up a config file.\n",
);
process.exit(1);
}

function resolveWrapAgentName(cli: CliArgs, config: ProxyConfig): string {
if (cli.agentName.length > 0) {
return cli.agentName;
Expand Down Expand Up @@ -331,8 +393,18 @@ function deriveAgentName(command: string): string {
return base.replace(/\.[cm]?[jt]s$/, "");
}

main().catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`Fatal error: ${message}\n`);
process.exit(1);
});
// Only run main() when executed as a script, not when imported for testing.
const isDirectRun =
process.argv[1] !== undefined &&
(import.meta.url.endsWith(process.argv[1]) ||
import.meta.url === `file://${process.argv[1]}` ||
import.meta.url.endsWith("/multicorn-proxy.js") ||
import.meta.url.endsWith("/multicorn-proxy.ts"));

if (isDirectRun && process.env["VITEST"] === undefined) {
main().catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`Fatal error: ${message}\n`);
process.exit(1);
});
}
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
"api_key": {
"type": "string",
"title": "Shield API key",
"description": "Your Multicorn Shield API key. Create one at https://app.multicorn.ai/settings/api-keys",
"description": "Your Multicorn Shield API key. Create one at https://app.multicorn.ai/settings#api-keys",
"sensitive": true,
"required": true
},
Expand Down
48 changes: 29 additions & 19 deletions src/multicorn-shield.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import type {
// Test helpers

const VALID_KEY = "mcs_testkey123456";
const EXPECTED_KEY_ERROR =
"Invalid Multicorn Shield API key. Get your key at https://app.multicorn.ai/settings";

const mockFetch = vi.fn().mockResolvedValue({
ok: true,
Expand Down Expand Up @@ -86,21 +88,35 @@ describe("MulticornShield constructor", () => {
});

it("throws when the API key does not start with mcs_", () => {
expect(() => new MulticornShield({ apiKey: "sk_livekey123456" })).toThrow(
'must start with "mcs_"',
);
expect(() => new MulticornShield({ apiKey: "sk_livekey123456" })).toThrow(EXPECTED_KEY_ERROR);
});

it("throws when the API key is exactly the prefix with no key material", () => {
expect(() => new MulticornShield({ apiKey: "mcs_" })).toThrow("too short");
expect(() => new MulticornShield({ apiKey: "mcs_" })).toThrow(EXPECTED_KEY_ERROR);
});

it("throws when the API key is shorter than the minimum length", () => {
expect(() => new MulticornShield({ apiKey: "mcs_abc" })).toThrow("too short");
expect(() => new MulticornShield({ apiKey: "mcs_abc" })).toThrow(EXPECTED_KEY_ERROR);
});

it("throws when the API key is empty", () => {
expect(() => new MulticornShield({ apiKey: "" })).toThrow();
expect(() => new MulticornShield({ apiKey: "" })).toThrow(EXPECTED_KEY_ERROR);
});

it("throws when apiKey is undefined", () => {
expect(
() => new MulticornShield({ apiKey: undefined } as unknown as MulticornShieldConfig),
).toThrow(EXPECTED_KEY_ERROR);
});

it("throws when apiKey is a non-string value", () => {
expect(
() => new MulticornShield({ apiKey: 12345 } as unknown as MulticornShieldConfig),
).toThrow(EXPECTED_KEY_ERROR);
});

it("throws when apiKey is the literal placeholder mcs_your_key_here", () => {
expect(() => new MulticornShield({ apiKey: "mcs_your_key_here" })).toThrow(EXPECTED_KEY_ERROR);
});

it("accepts an optional baseUrl pointing to localhost", () => {
Expand Down Expand Up @@ -147,30 +163,24 @@ describe("MulticornShield constructor", () => {
expect(() => new MulticornShield({ apiKey: "mcs_ " })).not.toThrow();
});

it("rejects API key that is just the prefix repeated", () => {
it("accepts API key that is the prefix repeated to meet length", () => {
expect(() => new MulticornShield({ apiKey: "mcs_mcs_mcs_mcs_" })).not.toThrow();
});

it("rejects API key with wrong prefix casing", () => {
expect(() => new MulticornShield({ apiKey: "MCS_testkey123456" })).toThrow(
'must start with "mcs_"',
);
expect(() => new MulticornShield({ apiKey: "MCS_testkey123456" })).toThrow(EXPECTED_KEY_ERROR);
});

it("rejects API key with extra prefix characters", () => {
expect(() => new MulticornShield({ apiKey: "xmcs_testkey123456" })).toThrow(
'must start with "mcs_"',
);
expect(() => new MulticornShield({ apiKey: "xmcs_testkey123456" })).toThrow(EXPECTED_KEY_ERROR);
});

it("rejects API key with spaces before the prefix", () => {
expect(() => new MulticornShield({ apiKey: " mcs_testkey123456" })).toThrow(
'must start with "mcs_"',
);
expect(() => new MulticornShield({ apiKey: " mcs_testkey123456" })).toThrow(EXPECTED_KEY_ERROR);
});

it("rejects API key that is exactly 15 characters long", () => {
expect(() => new MulticornShield({ apiKey: "mcs_12345678901" })).toThrow("too short");
expect(() => new MulticornShield({ apiKey: "mcs_12345678901" })).toThrow(EXPECTED_KEY_ERROR);
});

it("accepts API key that is exactly 16 characters long", () => {
Expand All @@ -179,12 +189,12 @@ describe("MulticornShield constructor", () => {

it("rejects API key with no prefix at all", () => {
expect(() => new MulticornShield({ apiKey: "thisisavalidlengthkey" })).toThrow(
'must start with "mcs_"',
EXPECTED_KEY_ERROR,
);
});

it("rejects API key that contains only the prefix mcs_ with no material", () => {
expect(() => new MulticornShield({ apiKey: "mcs_" })).toThrow("too short");
expect(() => new MulticornShield({ apiKey: "mcs_" })).toThrow(EXPECTED_KEY_ERROR);
});
});

Expand Down
Loading
Loading