diff --git a/.changeset/tidy-clis-connect.md b/.changeset/tidy-clis-connect.md new file mode 100644 index 000000000..2ee414e2e --- /dev/null +++ b/.changeset/tidy-clis-connect.md @@ -0,0 +1,5 @@ +--- +"ctx7": minor +--- + +Add outbound-independent on-premise MCP setup through `ctx7 setup --base-url`, including secure interactive personal API-key entry and anonymous-auth discovery support. diff --git a/docs/clients/cli.mdx b/docs/clients/cli.mdx index dac8af723..82fdee02a 100644 --- a/docs/clients/cli.mdx +++ b/docs/clients/cli.mdx @@ -128,7 +128,8 @@ ctx7 setup --opencode # Target a specific install location (CLI + Skills mode) ctx7 setup --cli --claude # Claude Code (~/.claude/skills) ctx7 setup --cli --cursor # Cursor (~/.cursor/skills) -ctx7 setup --cli --universal # Universal (~/.agents/skills) +ctx7 setup --cli --codex # Codex (~/.agents/skills) +ctx7 setup --cli --opencode # OpenCode (~/.agents/skills) ctx7 setup --cli --antigravity # Antigravity (~/.agent/skills) # Configure for current project only (default is global) @@ -150,6 +151,31 @@ ctx7 setup --oauth Without `--api-key` or `--oauth`, setup runs the OAuth device flow: it shows a verification link and short code that you open on any device to sign in, so it works the same locally or on a remote, headless, or SSH host. The device flow returns an API key that setup uses for authentication. `--oauth` is MCP-only — use it when an IDE handles the auth flow on your behalf. +### On-premise setup + +Pass the root URL of your on-premise deployment with `--base-url`. Do not include `/mcp` or `/api`: + +```bash +# MCP authentication disabled on the deployment +ctx7 setup --mcp --base-url https://context7.internal.example --codex + +# MCP authentication enabled: the CLI requests the personal key with hidden input +ctx7 setup --mcp \ + --base-url https://context7.internal.example \ + --codex + +# Non-interactive automation +CONTEXT7_API_KEY=ctx7op-... ctx7 setup --mcp --yes \ + --base-url https://context7.internal.example \ + --codex +``` + +The CLI checks `/api/auth/mcp` before writing configuration. It either configures anonymous access or writes `Authorization: Bearer `. When authentication is enabled and no key has been provided, interactive setup requests the key with masked input. Use `--api-key` or `CONTEXT7_API_KEY` for non-interactive automation. On-premise setup uses Streamable HTTP and bundled rule/skill content, so the target does not need to serve Context7 Cloud skill endpoints. + +When `--base-url` targets a custom deployment, setup skips the npm update check and telemetry and does not fetch rules or skills from GitHub or Context7 Cloud. After the CLI package is installed, the only network request made by setup is to the configured deployment. In a restricted environment, mirror `ctx7` and its dependencies in an internal npm registry and run the installed `ctx7` binary instead of `npx ctx7@latest`. + +`--base-url` currently supports MCP setup only. It cannot be combined with `--oauth`, `--stdio`, or `--cli` for a custom deployment. + **What gets written — MCP mode:** | File | Purpose | @@ -221,7 +247,8 @@ export CONTEXT7_API_KEY=your_key | Feature | Required | | -------------------------------------------------------- | ---------------------------------------------------------------------------- | | `ctx7 library` / `ctx7 docs` | No — login gives higher rate limits | -| `ctx7 setup` | Yes — unless `--api-key` is passed (`--oauth` also skips login for MCP mode) | +| Hosted `ctx7 setup` | Yes — unless `--api-key` is passed (`--oauth` also skips login for MCP mode) | +| On-premise `ctx7 setup --mcp --base-url ...` | Only when MCP authentication is enabled on the deployment | --- diff --git a/docs/enterprise/api/authentication.mdx b/docs/enterprise/api/authentication.mdx index 4a5c87300..1cb40cefa 100644 --- a/docs/enterprise/api/authentication.mdx +++ b/docs/enterprise/api/authentication.mdx @@ -61,4 +61,6 @@ Certain operations can be allowed without authentication. Configure them from ** | Anonymous parse / refresh | Off | | Anonymous delete | Off | -The search (`GET /v2/libs/search`) and context (`GET /v2/context`) endpoints are always publicly accessible unless a global `API_KEY` environment variable is set, in which case every endpoint requires a bearer token. +MCP authentication is configured separately in **Settings > Authentication**. When enabled, external requests to `/mcp`, `/api/v2/libs/search`, and `/api/v2/context` require a personal API key. When disabled, those endpoints allow anonymous access. The local MCP bridge can still call the v2 endpoints over loopback. + +The legacy global `API_KEY` environment variable protects the broader HTTP API independently of personal MCP keys. New deployments should use dashboard users and personal keys for MCP clients. diff --git a/docs/enterprise/on-premise.mdx b/docs/enterprise/on-premise.mdx index 6ce04cd5e..78419b2e4 100644 --- a/docs/enterprise/on-premise.mdx +++ b/docs/enterprise/on-premise.mdx @@ -65,7 +65,7 @@ You can also add libraries via the REST API: ```bash curl -X POST http://localhost:3000/api/parse \ -H "Content-Type: application/json" \ - -d '{"url": "https://github.com/your-org/your-repo"}' + -d '{"repoUrl": "https://github.com/your-org/your-repo"}' ``` @@ -74,37 +74,92 @@ curl -X POST http://localhost:3000/api/parse \ ## Connecting Your AI Client -Point your MCP client at your deployment URL. Replace `https://context7.internal.yourcompany.com` with your actual host. +The Context7 CLI is the recommended way to connect an AI coding agent. Install it once on the machine that runs your client: -### Claude Code + + + ```bash + npm install --global ctx7@latest + ``` + + + ```bash + npm install --global ctx7 \ + --registry https://npm.internal.yourcompany.com + ``` + + + +For restricted or air-gapped environments, mirror the `ctx7` package and its dependencies in your approved internal npm registry. Once installed, on-premise setup does not contact npm, GitHub, or Context7 Cloud. It uses bundled rule and skill content and contacts only your deployment to discover its MCP authentication setting. + +Pass the root URL of your deployment (without `/mcp` or `/api`) and select the client: + + + + ```bash + ctx7 setup --mcp \ + --base-url https://context7.internal.yourcompany.com \ + --claude + ``` + + + ```bash + ctx7 setup --mcp \ + --base-url https://context7.internal.yourcompany.com \ + --cursor + ``` + + + ```bash + ctx7 setup --mcp \ + --base-url https://context7.internal.yourcompany.com \ + --vscode + ``` + + + ```bash + ctx7 setup --mcp \ + --base-url https://context7.internal.yourcompany.com \ + --codex + ``` + + + ```bash + ctx7 setup --mcp \ + --base-url https://context7.internal.yourcompany.com \ + --opencode + ``` + + + +Setup reads the deployment's MCP authentication setting. When MCP authentication is disabled, no credential is written. When it is enabled, the dashboard's Connect card presents two steps: sign in and click **Create API key**, then copy the command after the dashboard inserts the one-time-visible key. Select **Configure manually** to reveal the equivalent client-native configuration instead. If you run the base command without `--api-key`, the CLI requests the key with hidden input. + +For non-interactive automation, provide the personal key through the environment: ```bash -claude mcp add --scope user --transport http context7 https://context7.internal.yourcompany.com/mcp +CONTEXT7_API_KEY=ctx7op-... ctx7 setup --mcp --yes \ + --base-url https://context7.internal.yourcompany.com \ + --claude ``` -### Cursor +You can use `--api-key ctx7op-...` instead, but command-line arguments may be retained in shell history. Setup writes the MCP client configuration, a Context7 rule, and a bundled `context7-mcp` skill. The bundled files mean the deployment does not need to expose Context7 Cloud's skill APIs. -Add to `~/.cursor/mcp.json`: + + Custom `--base-url` setup uses the deployment's Streamable HTTP endpoint. Hosted OAuth, local stdio transport, and CLI-query mode are not available for on-premise targets. + -```json -{ - "mcpServers": { - "context7": { - "url": "https://context7.internal.yourcompany.com/mcp" - } - } -} -``` +### Manual configuration -### Opencode +If your client is not supported by `ctx7 setup`, point it at `/mcp`. When MCP authentication is enabled, send the personal key as a standard bearer token: ```json { - "mcp": { + "mcpServers": { "context7": { - "type": "remote", "url": "https://context7.internal.yourcompany.com/mcp", - "enabled": true + "headers": { + "Authorization": "Bearer ctx7op-..." + } } } } @@ -186,7 +241,7 @@ You only need tokens for the platforms you use. If you only parse GitLab repos, Admin credentials are set during first login (default: `admin` / `admin`). Change these immediately after setup via **Settings > Change Credentials**. -The Settings page lets you control which operations are available without authentication. +The Settings page lets you control which operations are available without authentication, including MCP and API search access. | Permission | Default | Description | |---|---|---| @@ -194,8 +249,9 @@ The Settings page lets you control which operations are available without authen | Allow anonymous refresh | Off | Allow unauthenticated users to refresh libraries | | Allow anonymous delete | Off | Allow unauthenticated users to delete libraries | | Allow anonymous support bundle | Off | Allow unauthenticated support bundle downloads | +| Require MCP authentication | Off | Require a personal API key for `/mcp` and external `/api/v2` search requests | -When a permission is off, the operation requires admin login. The MCP endpoint and search API are always publicly accessible. +When MCP authentication is enabled, each user creates personal keys from **Personal Settings > API Keys**. Clients send the key as `Authorization: Bearer `. ## Policies diff --git a/packages/cli/README.md b/packages/cli/README.md index 2c993a843..61661f4d7 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -92,6 +92,19 @@ ctx7 setup --api-key YOUR_API_KEY # Use OAuth endpoint (IDE handles auth flow) ctx7 setup --oauth +# Configure an on-premise deployment (pass the deployment root) +ctx7 setup --mcp --base-url https://context7.internal.example --codex + +# When on-premise MCP auth is enabled, the CLI prompts for the key with hidden input +ctx7 setup --mcp \ + --base-url https://context7.internal.example \ + --codex + +# For non-interactive automation, provide the key through the environment +CONTEXT7_API_KEY=ctx7op-... ctx7 setup --mcp --yes \ + --base-url https://context7.internal.example \ + --codex + # Configure for current project only (default is global) ctx7 setup --project @@ -99,6 +112,8 @@ ctx7 setup --project ctx7 setup --yes ``` +For a custom `--base-url`, setup discovers whether MCP authentication is enabled and writes either an anonymous HTTP entry or an `Authorization: Bearer` header. Interactive setup requests the personal key with masked input, keeping it out of shell history; `--api-key` and `CONTEXT7_API_KEY` remain available for automation. Setup uses bundled rule and skill content and skips the npm update check and telemetry, so after `ctx7` is installed it contacts only the configured deployment. Enterprise environments can mirror `ctx7` and its dependencies in an internal npm registry. Custom deployments support MCP HTTP setup; hosted OAuth, stdio, and CLI-query mode are not supported for on-premise targets. + ### Uninstall setup Remove the Context7 setup written by `ctx7 setup`. By default this removes both MCP setup and CLI setup for the selected agent. diff --git a/packages/cli/src/__tests__/setup-on-prem.test.ts b/packages/cli/src/__tests__/setup-on-prem.test.ts new file mode 100644 index 000000000..4aed3f297 --- /dev/null +++ b/packages/cli/src/__tests__/setup-on-prem.test.ts @@ -0,0 +1,217 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { mkdir, readFile, rm } from "fs/promises"; +import { tmpdir } from "os"; +import { join } from "path"; +import { Command } from "commander"; + +const promptMocks = vi.hoisted(() => ({ + password: vi.fn(), + select: vi.fn(), +})); + +vi.mock("@inquirer/prompts", () => promptMocks); + +import { registerSetupCommand } from "../commands/setup.js"; + +let originalCwd: string; +let tempDir: string; + +beforeEach(async () => { + process.exitCode = undefined; + originalCwd = process.cwd(); + tempDir = join(tmpdir(), `ctx7-on-prem-setup-${Date.now()}`); + await mkdir(tempDir, { recursive: true }); + process.chdir(tempDir); + vi.unstubAllEnvs(); + promptMocks.password.mockReset(); + promptMocks.select.mockReset(); + vi.stubEnv("CTX7_TELEMETRY_DISABLED", ""); + vi.stubEnv("CONTEXT7_API_KEY", ""); + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request) => { + const url = String(input); + if (url === "https://context7.internal.example/api/auth/mcp") { + return { + ok: true, + json: async () => ({ enabled: false }), + } as Response; + } + throw new Error(`Unexpected outbound request: ${url}`); + }) + ); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); +}); + +afterEach(async () => { + process.exitCode = undefined; + process.chdir(originalCwd); + await rm(tempDir, { recursive: true, force: true }); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +}); + +describe("on-premise setup network boundary", () => { + test("only contacts the configured deployment and uses bundled assets", async () => { + const program = new Command(); + program.exitOverride(); + registerSetupCommand(program); + + await program.parseAsync([ + "node", + "ctx7", + "setup", + "--mcp", + "--base-url", + "https://context7.internal.example", + "--codex", + "--project", + "--yes", + ]); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith( + "https://context7.internal.example/api/auth/mcp", + expect.objectContaining({ + headers: { Accept: "application/json" }, + redirect: "error", + }) + ); + + const config = await readFile(join(tempDir, ".codex", "config.toml"), "utf-8"); + expect(config).toContain('url = "https://context7.internal.example/mcp"'); + expect(config).not.toContain("Authorization"); + + const skill = await readFile( + join(tempDir, ".agents", "skills", "context7-mcp", "SKILL.md"), + "utf-8" + ); + expect(skill).toContain("name: context7-mcp"); + expect(skill).toContain("resolve-library-id"); + expect(await readFile(join(tempDir, "AGENTS.md"), "utf-8")).toContain("query-docs"); + }); + + test("prompts securely for an on-premise key when MCP auth is enabled", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ enabled: true }), + } as Response); + promptMocks.password.mockResolvedValue(" ctx7op-preview_secret "); + + const program = new Command(); + program.exitOverride(); + registerSetupCommand(program); + + await program.parseAsync([ + "node", + "ctx7", + "setup", + "--mcp", + "--base-url", + "https://context7.internal.example", + "--codex", + "--project", + ]); + + expect(promptMocks.password).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Personal API key (create one at https://context7.internal.example/account)", + mask: true, + validate: expect.any(Function), + }) + ); + + const config = await readFile(join(tempDir, ".codex", "config.toml"), "utf-8"); + expect(config).toContain('url = "https://context7.internal.example/mcp"'); + expect(config).toContain('Authorization = "Bearer ctx7op-preview_secret"'); + }); + + test("configures newly registered HTTP clients against the on-premise URL", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ enabled: true }), + } as Response); + + const program = new Command(); + program.exitOverride(); + registerSetupCommand(program); + + await program.parseAsync([ + "node", + "ctx7", + "setup", + "--mcp", + "--base-url", + "https://context7.internal.example", + "--vscode", + "--project", + "--yes", + "--api-key", + "ctx7op-preview_secret", + ]); + + const config = JSON.parse(await readFile(join(tempDir, ".vscode", "mcp.json"), "utf-8")) as { + servers: Record; + }; + expect(config.servers.context7).toEqual({ + type: "http", + url: "https://context7.internal.example/mcp", + headers: { Authorization: "Bearer ctx7op-preview_secret" }, + }); + expect( + await readFile(join(tempDir, ".github", "instructions", "context7.instructions.md"), "utf-8") + ).toContain('applyTo: "**"'); + }); + + test("does not prompt during non-interactive --yes setup", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ enabled: true }), + } as Response); + + const program = new Command(); + program.exitOverride(); + registerSetupCommand(program); + + await program.parseAsync([ + "node", + "ctx7", + "setup", + "--mcp", + "--base-url", + "https://context7.internal.example", + "--codex", + "--project", + "--yes", + ]); + + expect(promptMocks.password).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + await expect(readFile(join(tempDir, ".codex", "config.toml"), "utf-8")).rejects.toThrow(); + }); + + test("returns a failure status when authentication discovery is unreachable", async () => { + vi.mocked(fetch).mockRejectedValueOnce(new Error("connection refused")); + + const program = new Command(); + program.exitOverride(); + registerSetupCommand(program); + + await program.parseAsync([ + "node", + "ctx7", + "setup", + "--mcp", + "--base-url", + "https://context7.internal.example", + "--codex", + "--project", + "--yes", + ]); + + expect(process.exitCode).toBe(1); + await expect(readFile(join(tempDir, ".codex", "config.toml"), "utf-8")).rejects.toThrow(); + }); +}); diff --git a/packages/cli/src/__tests__/setup.test.ts b/packages/cli/src/__tests__/setup.test.ts index e95d5ad71..264c0c8ad 100644 --- a/packages/cli/src/__tests__/setup.test.ts +++ b/packages/cli/src/__tests__/setup.test.ts @@ -19,7 +19,17 @@ vi.stubGlobal( }) ); -import { getRuleContent } from "../setup/templates.js"; +import { + getBundledMcpSkillFiles, + getBundledRuleContent, + getRuleContent, +} from "../setup/templates.js"; +import { + getMcpUrl, + getOnPremMcpAuthStatus, + normalizeDeploymentBaseUrl, + resolveSetupDeployment, +} from "../setup/deployment.js"; import { mergeServerEntry, removeServerEntry, @@ -68,6 +78,76 @@ describe("getRuleContent", () => { }) ); }); + + test("provides bundled MCP rule and skill content for offline setup", () => { + expect(getBundledRuleContent("mcp", "claude")).toContain("resolve-library-id"); + const files = getBundledMcpSkillFiles(); + expect(files).toHaveLength(1); + expect(files[0].path).toBe("SKILL.md"); + expect(files[0].content).toContain("name: context7-mcp"); + expect(files[0].content).toContain("query-docs"); + }); +}); + +describe("custom Context7 deployments", () => { + test("normalizes deployment roots and rejects endpoint URLs", () => { + expect(normalizeDeploymentBaseUrl("https://context7.internal.example///")).toBe( + "https://context7.internal.example" + ); + expect(normalizeDeploymentBaseUrl("http://localhost:3000/context7/")).toBe( + "http://localhost:3000/context7" + ); + expect(() => normalizeDeploymentBaseUrl("https://example.com/mcp")).toThrow( + "without /mcp or /api" + ); + expect(() => normalizeDeploymentBaseUrl("file:///tmp/context7")).toThrow("http:// or https://"); + }); + + test("keeps hosted MCP routing and builds custom MCP URLs", () => { + const hosted = resolveSetupDeployment("https://context7.com/"); + const custom = resolveSetupDeployment("https://context7.internal.example/"); + expect(hosted.kind).toBe("hosted"); + expect(custom).toEqual({ + kind: "custom", + baseUrl: "https://context7.internal.example", + }); + expect(getMcpUrl(hosted, { mode: "oauth" })).toBe("https://mcp.context7.com/mcp/oauth"); + expect(getMcpUrl(custom, { mode: "none" })).toBe("https://context7.internal.example/mcp"); + }); + + test("discovers whether an on-premise deployment requires MCP authentication", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ enabled: true }), + } as Response); + + const deployment = resolveSetupDeployment("https://context7.internal.example/"); + expect(deployment.kind).toBe("custom"); + if (deployment.kind !== "custom") throw new Error("expected custom deployment"); + + await expect(getOnPremMcpAuthStatus(deployment)).resolves.toEqual({ + enabled: true, + }); + expect(fetch).toHaveBeenLastCalledWith( + "https://context7.internal.example/api/auth/mcp", + expect.objectContaining({ + headers: { Accept: "application/json" }, + redirect: "error", + }) + ); + }); + + test("rejects invalid authentication discovery responses", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ enabled: "yes" }), + } as Response); + + const deployment = resolveSetupDeployment("https://context7.internal.example"); + if (deployment.kind !== "custom") throw new Error("expected custom deployment"); + + await expect(getOnPremMcpAuthStatus(deployment)).rejects.toThrow("Invalid response"); + }); }); describe("mergeServerEntry", () => { @@ -618,6 +698,30 @@ describe("agent config integration", () => { const apiKeyAuth: AuthOptions = { mode: "api-key", apiKey: "sk-test-123" }; const oauthAuth: AuthOptions = { mode: "oauth" }; + const noAuth: AuthOptions = { mode: "none" }; + + test("all HTTP agents target an on-premise deployment and use bearer authentication", () => { + for (const agentName of ALL_AGENT_NAMES) { + const agent = getAgent(agentName); + const authenticated = agent.mcp.buildEntry( + apiKeyAuth, + "http", + "https://context7.internal.example/mcp" + ); + expect(JSON.stringify(authenticated)).toContain("https://context7.internal.example/mcp"); + expect(authenticated).toMatchObject({ + headers: { Authorization: "Bearer sk-test-123" }, + }); + + const anonymous = agent.mcp.buildEntry( + noAuth, + "http", + "https://context7.internal.example/mcp" + ); + expect(JSON.stringify(anonymous)).toContain("https://context7.internal.example/mcp"); + expect(anonymous).not.toHaveProperty("headers"); + } + }); describe("claude", () => { const agent = getAgent("claude"); diff --git a/packages/cli/src/__tests__/update-check.test.ts b/packages/cli/src/__tests__/update-check.test.ts index cc615d474..1cedb6153 100644 --- a/packages/cli/src/__tests__/update-check.test.ts +++ b/packages/cli/src/__tests__/update-check.test.ts @@ -293,9 +293,15 @@ describe("update notifications", () => { ).toBe(true); }); - test("skips notifier for json and version argv", () => { + test("skips notifier for json, version, and custom deployments", () => { expect(shouldSkipUpdateNotifier(["node", "ctx7", "library", "react", "--json"])).toBe(true); expect(shouldSkipUpdateNotifier(["node", "ctx7", "--version"])).toBe(true); + expect( + shouldSkipUpdateNotifier(["node", "ctx7", "setup", "--base-url", "https://context7.internal"]) + ).toBe(true); + expect(shouldSkipUpdateNotifier(["node", "ctx7", "--base-url=https://context7.internal"])).toBe( + true + ); expect(shouldSkipUpdateNotifier(["node", "ctx7", "skills", "list"])).toBe(false); }); }); diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts index 0721e1faa..6d6e1f015 100644 --- a/packages/cli/src/commands/setup.ts +++ b/packages/cli/src/commands/setup.ts @@ -1,7 +1,7 @@ import { Command } from "commander"; import pc from "picocolors"; import ora from "ora"; -import { select } from "@inquirer/prompts"; +import { password, select } from "@inquirer/prompts"; import { mkdir, readFile, writeFile } from "fs/promises"; import { dirname, join } from "path"; @@ -22,7 +22,18 @@ import { getAgent, detectAgents, } from "../setup/agents.js"; -import { customizeSkillFilesForAgent, getRuleContent } from "../setup/templates.js"; +import { + customizeSkillFilesForAgent, + getBundledMcpSkillFiles, + getBundledRuleContent, + getRuleContent, +} from "../setup/templates.js"; +import { + getMcpUrl, + getOnPremMcpAuthStatus, + resolveSetupDeployment, + type SetupDeployment, +} from "../setup/deployment.js"; import { readJsonConfig, mergeServerEntry, @@ -37,6 +48,12 @@ import { type Scope = "global" | "project"; type SetupMode = "mcp" | "cli"; +type SkillFile = { path: string; content: string }; + +interface McpSkillPayload { + files: SkillFile[]; + status: "installed" | "installed (bundled)" | "installed (bundled fallback)"; +} type SetupOptions = Partial> & { project?: boolean; @@ -46,6 +63,7 @@ type SetupOptions = Partial> & { cli?: boolean; mcp?: boolean; stdio?: boolean; + baseUrl?: string; }; function resolveTransport(options: SetupOptions): Transport { @@ -79,18 +97,88 @@ export function registerSetupCommand(program: Command): void { .option("--api-key ", "Use API key authentication") .option("--oauth", "Use OAuth endpoint (IDE handles auth flow)") .option("--stdio", "Configure the MCP server as a local stdio process (default: HTTP)") - .action(async (options: SetupOptions) => { - await setupCommand(options); + .option("--base-url ", "Use a custom Context7 deployment (for example, on-premise)") + .action(async (_options: SetupOptions, command: Command) => { + await setupCommand(command.optsWithGlobals()); }); } -async function resolveAuth(options: SetupOptions): Promise { - if (options.apiKey) return { mode: "api-key", apiKey: options.apiKey }; +async function promptForOnPremApiKey(deployment: SetupDeployment): Promise { + if (deployment.kind !== "custom") return null; + + try { + return await password({ + message: `Personal API key (create one at ${deployment.baseUrl}/account)`, + mask: true, + validate: (value) => value.trim().length > 0 || "API key is required", + }).then((value) => value.trim()); + } catch { + log.error("Setup cancelled before a personal API key was entered."); + return null; + } +} + +async function resolveAuth( + options: SetupOptions, + deployment: SetupDeployment +): Promise { + const apiKey = options.apiKey?.trim() || process.env.CONTEXT7_API_KEY?.trim(); + + if (deployment.kind === "custom") { + try { + const auth = await getOnPremMcpAuthStatus(deployment); + if (!auth.enabled) return { mode: "none" }; + } catch (err) { + log.error( + `Could not check MCP authentication at ${deployment.baseUrl}: ${err instanceof Error ? err.message : String(err)}` + ); + process.exitCode = 1; + return null; + } + + if (apiKey) return { mode: "api-key", apiKey }; + + if (!options.yes) { + const promptedApiKey = await promptForOnPremApiKey(deployment); + return promptedApiKey ? { mode: "api-key", apiKey: promptedApiKey } : null; + } + + log.error( + `MCP authentication is enabled at ${deployment.baseUrl}. Pass --api-key, set CONTEXT7_API_KEY, or rerun without --yes to enter a personal API key securely.` + ); + process.exitCode = 1; + return null; + } + + if (apiKey) return { mode: "api-key", apiKey }; if (options.oauth) return { mode: "oauth" }; - const apiKey = await resolveSetupApiKey(); - if (!apiKey) return null; - return { mode: "api-key", apiKey }; + const resolvedApiKey = await resolveSetupApiKey(); + if (!resolvedApiKey) return null; + return { mode: "api-key", apiKey: resolvedApiKey }; +} + +function getSetupValidationError( + mode: SetupMode, + options: SetupOptions, + deployment: SetupDeployment +): string | null { + if (deployment.kind === "custom") { + if (mode === "cli") { + return "--base-url currently supports MCP setup only. Use --mcp with an on-premise deployment."; + } + if (options.stdio) { + return "--stdio is not supported with --base-url. On-premise setup uses the HTTP /mcp endpoint."; + } + if (options.oauth) { + return "--oauth is only supported by hosted Context7. Use a personal on-premise API key."; + } + } + + if (mode === "mcp" && options.stdio && options.oauth) { + return "--stdio is incompatible with --oauth (OAuth uses the hosted HTTP endpoint)."; + } + return null; } async function resolveMode(options: SetupOptions): Promise { @@ -179,27 +267,26 @@ async function resolveAgents(options: SetupOptions, scope: Scope): Promise { const agent = getAgent(agentName); const rule = agent.rule; - const body = await getRuleContent(mode, agentName); if (rule.kind === "file") { - const content = `${rule.contentPrefix ?? ""}${body}`; + const ruleContent = `${rule.contentPrefix ?? ""}${content}`; const ruleDir = scope === "global" ? rule.dir("global") : join(process.cwd(), rule.dir("project")); const rulePath = join(ruleDir, rule.filename); await mkdir(dirname(rulePath), { recursive: true }); - await writeFile(rulePath, content, "utf-8"); + await writeFile(rulePath, ruleContent, "utf-8"); return { status: "installed", path: rulePath }; } const filePath = scope === "global" ? rule.file("global") : join(process.cwd(), rule.file("project")); const escapedMarker = rule.sectionMarker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const section = `${rule.sectionMarker}\n${body}${rule.sectionMarker}`; + const section = `${rule.sectionMarker}\n${content}${rule.sectionMarker}`; let existing = ""; try { @@ -222,6 +309,23 @@ async function installRule( return { status: "installed", path: filePath }; } +async function loadMcpSkillPayload(deployment: SetupDeployment): Promise { + const bundled = getBundledMcpSkillFiles(); + if (deployment.kind === "custom") { + return { files: bundled, status: "installed (bundled)" }; + } + + try { + const downloadData = await downloadSkill("/upstash/context7", "context7-mcp"); + if (downloadData.error || downloadData.files.length === 0) { + throw new Error(downloadData.error || "no files"); + } + return { files: downloadData.files, status: "installed" }; + } catch { + return { files: bundled, status: "installed (bundled fallback)" }; + } +} + /** * For stdio transport, preserve an existing `@upstash/context7-mcp` invocation * (e.g., `@upstash/context7-mcp@latest` or a user-pinned version) and only @@ -233,20 +337,24 @@ function resolveEntryToWrite( agent: ReturnType, auth: AuthOptions, transport: Transport, - existingEntry: Record | undefined + existingEntry: Record | undefined, + mcpUrl: string ): Record { if (transport === "stdio" && existingEntry && isStdioContext7Entry(existingEntry)) { const apiKey = auth.mode === "api-key" ? auth.apiKey : undefined; return patchStdioApiKey(existingEntry, apiKey); } - return agent.mcp.buildEntry(auth, transport); + return agent.mcp.buildEntry(auth, transport, mcpUrl); } async function setupAgent( agentName: SetupAgent, auth: AuthOptions, transport: Transport, - scope: Scope + scope: Scope, + deployment: SetupDeployment, + mcpUrl: string, + skillPayload: McpSkillPayload ): Promise<{ agent: string; mcpStatus: string; @@ -275,7 +383,7 @@ async function setupAgent( : await appendTomlServer( mcpPath, "context7", - resolveEntryToWrite(agent, auth, transport, undefined) + resolveEntryToWrite(agent, auth, transport, undefined, mcpUrl) ); mcpStatus = alreadyExists ? `reconfigured with ${AUTH_MODE_LABELS[auth.mode]}` @@ -286,7 +394,7 @@ async function setupAgent( transport === "stdio" ? getJsonServerEntry(existing, agent.mcp.configKey, "context7") : undefined; - const entry = resolveEntryToWrite(agent, auth, transport, existingJsonEntry); + const entry = resolveEntryToWrite(agent, auth, transport, existingJsonEntry, mcpUrl); const { config, alreadyExists } = mergeServerEntry( existing, agent.mcp.configKey, @@ -305,7 +413,11 @@ async function setupAgent( let ruleStatus: string; let rulePath: string; try { - const result = await installRule(agentName, "mcp", scope); + const ruleContent = + deployment.kind === "custom" + ? getBundledRuleContent("mcp", agentName) + : await getRuleContent("mcp", agentName); + const result = await installRule(agentName, scope, ruleContent); ruleStatus = result.status; rulePath = result.path; } catch (err) { @@ -321,12 +433,8 @@ async function setupAgent( let skillStatus: string; try { - const downloadData = await downloadSkill("/upstash/context7", agent.skill.name); - if (downloadData.error || downloadData.files.length === 0) { - throw new Error(downloadData.error || "no files"); - } - await installSkillFiles(agent.skill.name, downloadData.files, skillDir); - skillStatus = "installed"; + await installSkillFiles(agent.skill.name, skillPayload.files, skillDir); + skillStatus = skillPayload.status; } catch (err) { skillStatus = `failed: ${err instanceof Error ? err.message : String(err)}`; } @@ -344,8 +452,11 @@ async function setupAgent( function logSkillStatus(skillStatus: string, skillPath: string): void { const skillFailed = skillStatus.startsWith("failed:"); - const skillIcon = - skillStatus === "installed" ? pc.green("+") : skillFailed ? pc.red("✖") : pc.dim("~"); + const skillIcon = skillStatus.startsWith("installed") + ? pc.green("+") + : skillFailed + ? pc.red("✖") + : pc.dim("~"); log.plain(` ${skillIcon} Skill ${skillFailed ? "failed" : skillStatus}`); log.plain(` ${pc.dim(skillPath)}`); if (skillFailed) { @@ -358,14 +469,14 @@ function logSkillStatus(skillStatus: string, skillPath: string): void { } } -async function setupMcp(agents: SetupAgent[], options: SetupOptions, scope: Scope): Promise { +async function setupMcp( + agents: SetupAgent[], + options: SetupOptions, + scope: Scope, + deployment: SetupDeployment +): Promise { const transport = resolveTransport(options); - if (transport === "stdio" && options.oauth) { - log.error("--stdio is incompatible with --oauth (OAuth uses the hosted HTTP endpoint)."); - return; - } - - const auth = await resolveAuth(options); + const auth = await resolveAuth(options, deployment); if (!auth) { log.warn("Setup cancelled"); return; @@ -373,11 +484,15 @@ async function setupMcp(agents: SetupAgent[], options: SetupOptions, scope: Scop log.blank(); const spinner = ora("Setting up Context7...").start(); + const mcpUrl = getMcpUrl(deployment, auth); + const skillPayload = await loadMcpSkillPayload(deployment); const results = []; for (const agentName of agents) { spinner.text = `Setting up ${getAgent(agentName).displayName}...`; - results.push(await setupAgent(agentName, auth, transport, scope)); + results.push( + await setupAgent(agentName, auth, transport, scope, deployment, mcpUrl, skillPayload) + ); } spinner.succeed("Context7 setup complete"); @@ -398,8 +513,10 @@ async function setupMcp(agents: SetupAgent[], options: SetupOptions, scope: Scop } log.blank(); - trackEvent("setup", { agents, scope, authMode: auth.mode }); - trackEvent("install", { skills: ["/upstash/context7/context7-mcp"], ides: agents }); + if (deployment.kind === "hosted") { + trackEvent("setup", { agents, scope, authMode: auth.mode, deployment: "hosted" }); + trackEvent("install", { skills: ["/upstash/context7/context7-mcp"], ides: agents }); + } } async function setupCliAgent( @@ -426,7 +543,7 @@ async function setupCliAgent( let ruleStatus: string; let rulePath: string; try { - const result = await installRule(agentName, "cli", scope); + const result = await installRule(agentName, scope, await getRuleContent("cli", agentName)); ruleStatus = result.status; rulePath = result.path; } catch (err) { @@ -489,15 +606,24 @@ async function setupCli(options: SetupOptions): Promise { } async function setupCommand(options: SetupOptions): Promise { - trackEvent("command", { name: "setup" }); - try { const mode = await resolveMode(options); + const deployment = resolveSetupDeployment(options.baseUrl); + const validationError = getSetupValidationError(mode, options, deployment); + if (validationError) { + log.error(validationError); + process.exitCode = 1; + return; + } + if (deployment.kind === "hosted") { + trackEvent("command", { name: "setup" }); + } + if (mode === "mcp") { const scope: Scope = options.project ? "project" : "global"; const agents = await resolveAgents(options, scope); if (agents.length === 0) return; - await setupMcp(agents, options, scope); + await setupMcp(agents, options, scope, deployment); } else { await setupCli(options); } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 2544b9ffb..ab5339dc8 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -21,7 +21,7 @@ program .name("ctx7") .description("Context7 CLI - Fetch documentation context and configure Context7") .version(VERSION, "-v, --version") - .option("--base-url ") + .option("--base-url ", "Use a custom Context7 deployment URL") .hook("preAction", (thisCommand) => { const opts = thisCommand.opts(); if (opts.baseUrl) { @@ -43,6 +43,7 @@ Examples: ${brand.primary("npx ctx7 setup")} ${brand.primary("npx ctx7 setup --mcp")} ${brand.primary("npx ctx7 setup --cli")} + ${brand.primary("npx ctx7 setup --mcp --base-url https://context7.internal.example --codex")} ${brand.dim("# Remove Context7 setup")} ${brand.primary("npx ctx7 remove --cursor")} diff --git a/packages/cli/src/setup/agents.ts b/packages/cli/src/setup/agents.ts index 6bf5fde31..feaad3a59 100644 --- a/packages/cli/src/setup/agents.ts +++ b/packages/cli/src/setup/agents.ts @@ -2,7 +2,7 @@ import { access } from "fs/promises"; import { join } from "path"; import { homedir } from "os"; -export type AuthMode = "oauth" | "api-key"; +export type AuthMode = "oauth" | "api-key" | "none"; export type Transport = "http" | "stdio"; export interface AuthOptions { @@ -13,10 +13,11 @@ export interface AuthOptions { export const AUTH_MODE_LABELS: Record = { oauth: "OAuth", "api-key": "API Key", + none: "no authentication", }; -const MCP_BASE_URL = "https://mcp.context7.com"; export const STDIO_PACKAGE = "@upstash/context7-mcp"; +const HOSTED_MCP_BASE_URL = "https://mcp.context7.com"; function stdioArgs(auth: AuthOptions): string[] { const args = ["-y", STDIO_PACKAGE]; @@ -30,6 +31,10 @@ function stdioEntry(auth: AuthOptions): Record { return { command: "npx", args: stdioArgs(auth) }; } +function hostedMcpUrl(auth: AuthOptions): string { + return auth.mode === "oauth" ? `${HOSTED_MCP_BASE_URL}/mcp/oauth` : `${HOSTED_MCP_BASE_URL}/mcp`; +} + function claudeConfigDir(): string { return process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"); } @@ -85,7 +90,11 @@ export interface AgentConfig { projectPaths: string[]; globalPaths: string[]; configKey: string; - buildEntry: (auth: AuthOptions, transport: Transport) => Record; + buildEntry: ( + auth: AuthOptions, + transport: Transport, + mcpUrl?: string + ) => Record; }; rule: RuleType; skill: { @@ -98,10 +107,6 @@ export interface AgentConfig { }; } -function mcpUrl(auth: AuthOptions): string { - return auth.mode === "oauth" ? `${MCP_BASE_URL}/mcp/oauth` : `${MCP_BASE_URL}/mcp`; -} - /** * Attaches the API key as a standard `Authorization: Bearer` header. * @@ -133,10 +138,8 @@ const agents = { return [claudeGlobalMcpPath()]; }, configKey: "mcpServers", - buildEntry: (auth, transport) => - transport === "stdio" - ? stdioEntry(auth) - : withHeaders({ type: "http", url: mcpUrl(auth) }, auth), + buildEntry: (auth, transport, mcpUrl = hostedMcpUrl(auth)) => + transport === "stdio" ? stdioEntry(auth) : withHeaders({ type: "http", url: mcpUrl }, auth), }, rule: { kind: "file", @@ -163,8 +166,8 @@ const agents = { projectPaths: [join(".cursor", "mcp.json")], globalPaths: [join(homedir(), ".cursor", "mcp.json")], configKey: "mcpServers", - buildEntry: (auth, transport) => - transport === "stdio" ? stdioEntry(auth) : withHeaders({ url: mcpUrl(auth) }, auth), + buildEntry: (auth, transport, mcpUrl = hostedMcpUrl(auth)) => + transport === "stdio" ? stdioEntry(auth) : withHeaders({ url: mcpUrl }, auth), }, rule: { kind: "file", @@ -192,10 +195,10 @@ const agents = { return [join(resolveVscodeUserDir(), "mcp.json")]; }, configKey: "servers", - buildEntry: (auth, transport) => + buildEntry: (auth, transport, mcpUrl = hostedMcpUrl(auth)) => transport === "stdio" ? { type: "stdio", ...stdioEntry(auth) } - : withHeaders({ type: "http", url: mcpUrl(auth) }, auth), + : withHeaders({ type: "http", url: mcpUrl }, auth), }, rule: { kind: "file", @@ -227,10 +230,10 @@ const agents = { return [join(resolveDevinConfigDir(), "mcp_config.json")]; }, configKey: "mcpServers", - buildEntry: (auth, transport) => + buildEntry: (auth, transport, mcpUrl = hostedMcpUrl(auth)) => transport === "stdio" ? stdioEntry(auth) - : withHeaders({ transport: "http", url: mcpUrl(auth) }, auth), + : withHeaders({ transport: "http", url: mcpUrl }, auth), }, rule: { kind: "append", @@ -257,10 +260,10 @@ const agents = { projectPaths: [".mcp.json"], globalPaths: [join(homedir(), ".copilot", "mcp-config.json")], configKey: "mcpServers", - buildEntry: (auth, transport) => + buildEntry: (auth, transport, mcpUrl = hostedMcpUrl(auth)) => transport === "stdio" ? { type: "stdio", ...stdioEntry(auth), tools: ["*"] } - : withHeaders({ type: "http", url: mcpUrl(auth), tools: ["*"] }, auth), + : withHeaders({ type: "http", url: mcpUrl, tools: ["*"] }, auth), }, rule: { kind: "append", @@ -292,10 +295,10 @@ const agents = { join(homedir(), ".config", "opencode", ".opencode.jsonc"), ], configKey: "mcp", - buildEntry: (auth, transport) => + buildEntry: (auth, transport, mcpUrl = hostedMcpUrl(auth)) => transport === "stdio" ? { type: "local", command: ["npx", ...stdioArgs(auth)], enabled: true } - : withHeaders({ type: "remote", url: mcpUrl(auth), enabled: true }, auth), + : withHeaders({ type: "remote", url: mcpUrl, enabled: true }, auth), }, rule: { kind: "append", @@ -320,10 +323,8 @@ const agents = { projectPaths: [join(".codex", "config.toml")], globalPaths: [join(homedir(), ".codex", "config.toml")], configKey: "mcp_servers", - buildEntry: (auth, transport) => - transport === "stdio" - ? stdioEntry(auth) - : withHeaders({ type: "http", url: mcpUrl(auth) }, auth), + buildEntry: (auth, transport, mcpUrl = hostedMcpUrl(auth)) => + transport === "stdio" ? stdioEntry(auth) : withHeaders({ type: "http", url: mcpUrl }, auth), }, rule: { kind: "append", @@ -352,8 +353,8 @@ const agents = { projectPaths: [], globalPaths: [join(homedir(), ".gemini", "config", "mcp_config.json")], configKey: "mcpServers", - buildEntry: (auth, transport) => - transport === "stdio" ? stdioEntry(auth) : withHeaders({ serverUrl: mcpUrl(auth) }, auth), + buildEntry: (auth, transport, mcpUrl = hostedMcpUrl(auth)) => + transport === "stdio" ? stdioEntry(auth) : withHeaders({ serverUrl: mcpUrl }, auth), }, rule: { kind: "append", @@ -377,8 +378,8 @@ const agents = { projectPaths: [join(".gemini", "settings.json")], globalPaths: [join(homedir(), ".gemini", "settings.json")], configKey: "mcpServers", - buildEntry: (auth, transport) => - transport === "stdio" ? stdioEntry(auth) : withHeaders({ httpUrl: mcpUrl(auth) }, auth), + buildEntry: (auth, transport, mcpUrl = hostedMcpUrl(auth)) => + transport === "stdio" ? stdioEntry(auth) : withHeaders({ httpUrl: mcpUrl }, auth), }, rule: { kind: "append", diff --git a/packages/cli/src/setup/deployment.ts b/packages/cli/src/setup/deployment.ts new file mode 100644 index 000000000..909fb234d --- /dev/null +++ b/packages/cli/src/setup/deployment.ts @@ -0,0 +1,73 @@ +import type { AuthOptions } from "./agents.js"; + +export const HOSTED_CONTEXT7_BASE_URL = "https://context7.com"; +const HOSTED_MCP_BASE_URL = "https://mcp.context7.com"; + +export type SetupDeployment = + | { kind: "hosted"; baseUrl: typeof HOSTED_CONTEXT7_BASE_URL } + | { kind: "custom"; baseUrl: string }; +export type CustomSetupDeployment = Extract; + +export function normalizeDeploymentBaseUrl(input?: string): string { + const raw = input?.trim() || HOSTED_CONTEXT7_BASE_URL; + let url: URL; + + try { + url = new URL(raw); + } catch { + throw new Error(`Invalid Context7 base URL: ${raw}`); + } + + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("Context7 base URL must use http:// or https://"); + } + if (url.username || url.password) { + throw new Error("Context7 base URL must not contain credentials"); + } + if (url.search || url.hash) { + throw new Error("Context7 base URL must not contain a query string or fragment"); + } + + url.pathname = url.pathname.replace(/\/+$/, "") || "/"; + const normalized = url.toString().replace(/\/$/, ""); + if (url.pathname.endsWith("/mcp") || url.pathname.endsWith("/api")) { + throw new Error("Pass the Context7 deployment root, without /mcp or /api"); + } + return normalized; +} + +export function resolveSetupDeployment(input?: string): SetupDeployment { + const baseUrl = normalizeDeploymentBaseUrl(input); + return baseUrl === HOSTED_CONTEXT7_BASE_URL + ? { kind: "hosted", baseUrl: HOSTED_CONTEXT7_BASE_URL } + : { kind: "custom", baseUrl }; +} + +export function getMcpUrl(deployment: SetupDeployment, auth: AuthOptions): string { + if (deployment.kind === "hosted") { + return auth.mode === "oauth" + ? `${HOSTED_MCP_BASE_URL}/mcp/oauth` + : `${HOSTED_MCP_BASE_URL}/mcp`; + } + return `${deployment.baseUrl}/mcp`; +} + +export async function getOnPremMcpAuthStatus( + deployment: CustomSetupDeployment +): Promise<{ enabled: boolean }> { + const response = await fetch(`${deployment.baseUrl}/api/auth/mcp`, { + headers: { Accept: "application/json" }, + redirect: "error", + signal: AbortSignal.timeout(10_000), + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status} from ${deployment.baseUrl}/api/auth/mcp`); + } + + const body = (await response.json()) as { enabled?: unknown }; + if (typeof body.enabled !== "boolean") { + throw new Error(`Invalid response from ${deployment.baseUrl}/api/auth/mcp`); + } + return { enabled: body.enabled }; +} diff --git a/packages/cli/src/setup/templates.ts b/packages/cli/src/setup/templates.ts index 58318994c..cc47baecc 100644 --- a/packages/cli/src/setup/templates.ts +++ b/packages/cli/src/setup/templates.ts @@ -39,6 +39,14 @@ const CODEX_CLI_SANDBOX_GUIDANCE = `Run Context7 CLI requests outside Codex's de export type RuleMode = "mcp" | "cli"; +function customizeRuleContent(mode: RuleMode, agent: SetupAgent, body: string): string { + if (mode === "cli" && agent === "codex" && !body.includes(CODEX_CLI_SANDBOX_GUIDANCE)) { + body = `${body.trimEnd()}\n${CODEX_CLI_SANDBOX_GUIDANCE}\n`; + } + + return body; +} + async function fetchRule(filename: string, fallback: string): Promise { for (const base of GITHUB_RAW_URLS) { try { @@ -54,13 +62,21 @@ async function fetchRule(filename: string, fallback: string): Promise { export async function getRuleContent(mode: RuleMode, agent: SetupAgent): Promise { const [filename, fallback] = mode === "mcp" ? ["context7-mcp.md", FALLBACK_MCP] : ["context7-cli.md", FALLBACK_CLI]; - let body = await fetchRule(filename, fallback); + const body = await fetchRule(filename, fallback); + return customizeRuleContent(mode, agent, body); +} - if (mode === "cli" && agent === "codex" && !body.includes(CODEX_CLI_SANDBOX_GUIDANCE)) { - body = `${body.trimEnd()}\n${CODEX_CLI_SANDBOX_GUIDANCE}\n`; - } +export function getBundledRuleContent(mode: RuleMode, agent: SetupAgent): string { + return customizeRuleContent(mode, agent, mode === "mcp" ? FALLBACK_MCP : FALLBACK_CLI); +} - return body; +export function getBundledMcpSkillFiles(): Array<{ path: string; content: string }> { + return [ + { + path: "SKILL.md", + content: `---\nname: context7-mcp\ndescription: Fetch current library documentation with Context7 MCP tools.\n---\n\n# Context7 MCP\n\n${FALLBACK_MCP}`, + }, + ]; } export function customizeSkillFilesForAgent( diff --git a/packages/cli/src/utils/update-check.ts b/packages/cli/src/utils/update-check.ts index 808d1445d..a9cd331c6 100644 --- a/packages/cli/src/utils/update-check.ts +++ b/packages/cli/src/utils/update-check.ts @@ -296,5 +296,12 @@ export async function markUpdateNotificationShown( } export function shouldSkipUpdateNotifier(argv = process.argv): boolean { - return argv.includes("--json") || argv.includes("-v") || argv.includes("--version"); + return argv.some( + (arg) => + arg === "--json" || + arg === "-v" || + arg === "--version" || + arg === "--base-url" || + arg.startsWith("--base-url=") + ); }