mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-05 16:29:51 +00:00
feat(cli): support inline mcp add args
This commit is contained in:
parent
a9c115c220
commit
2ca7478b8a
4 changed files with 388 additions and 19 deletions
|
|
@ -1,5 +1,5 @@
|
|||
import { cmd } from "./cmd"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import { effectCmd, fail } from "../effect-cmd"
|
||||
import { Cause } from "effect"
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
||||
|
|
@ -55,6 +55,26 @@ function isMcpRemote(config: McpEntry): config is McpRemote {
|
|||
return isMcpConfigured(config) && config.type === "remote"
|
||||
}
|
||||
|
||||
type McpAddArgs = {
|
||||
_?: Array<string | number>
|
||||
"--"?: string[]
|
||||
name?: string
|
||||
urlOrCommand?: string[]
|
||||
type?: "local" | "remote"
|
||||
env?: string[]
|
||||
header?: string[]
|
||||
scope?: "project" | "global"
|
||||
global?: boolean
|
||||
enabled?: boolean
|
||||
timeout?: number
|
||||
oauth?: boolean
|
||||
oauthClientId?: string
|
||||
oauthClientSecret?: string
|
||||
oauthScope?: string
|
||||
oauthCallbackPort?: number
|
||||
oauthRedirectUri?: string
|
||||
}
|
||||
|
||||
function configuredServers(config: Config.Info) {
|
||||
return Object.entries(config.mcp ?? {}).filter((entry): entry is [string, McpConfigured] => isMcpConfigured(entry[1]))
|
||||
}
|
||||
|
|
@ -436,27 +456,102 @@ async function addMcpToConfig(name: string, mcpConfig: ConfigMCP.Info, configPat
|
|||
}
|
||||
|
||||
export const McpAddCommand = effectCmd({
|
||||
command: "add",
|
||||
command: "add [name] [urlOrCommand..]",
|
||||
describe: "add an MCP server",
|
||||
handler: Effect.fn("Cli.mcp.add")(function* () {
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.parserConfiguration({ "unknown-options-as-args": true })
|
||||
.positional("name", {
|
||||
describe: "name of the MCP server",
|
||||
type: "string",
|
||||
})
|
||||
.positional("urlOrCommand", {
|
||||
describe: "URL for remote servers or command for local servers",
|
||||
type: "string",
|
||||
array: true,
|
||||
default: [],
|
||||
})
|
||||
.option("type", {
|
||||
describe: "server type: local or remote",
|
||||
type: "string",
|
||||
choices: ["local", "remote"] as const,
|
||||
})
|
||||
.option("env", {
|
||||
describe: "environment variable for local servers (KEY=VALUE)",
|
||||
type: "string",
|
||||
array: true,
|
||||
})
|
||||
.option("header", {
|
||||
describe: "HTTP header for remote servers (KEY=VALUE or 'KEY: VALUE')",
|
||||
type: "string",
|
||||
array: true,
|
||||
})
|
||||
.option("scope", {
|
||||
describe: "where to save the server",
|
||||
type: "string",
|
||||
choices: ["project", "global"] as const,
|
||||
})
|
||||
.option("global", {
|
||||
alias: ["g"],
|
||||
describe: "save to global config",
|
||||
type: "boolean",
|
||||
})
|
||||
.option("enabled", {
|
||||
describe: "enable or disable the server on startup",
|
||||
type: "boolean",
|
||||
})
|
||||
.option("timeout", {
|
||||
describe: "timeout in milliseconds for MCP server requests",
|
||||
type: "number",
|
||||
})
|
||||
.option("oauth", {
|
||||
describe: "enable OAuth for remote servers, or use --no-oauth to disable auto-detection",
|
||||
type: "boolean",
|
||||
})
|
||||
.option("oauth-client-id", {
|
||||
describe: "OAuth client ID for remote servers",
|
||||
type: "string",
|
||||
})
|
||||
.option("oauth-client-secret", {
|
||||
describe: "OAuth client secret for remote servers",
|
||||
type: "string",
|
||||
})
|
||||
.option("oauth-scope", {
|
||||
describe: "OAuth scopes to request for remote servers",
|
||||
type: "string",
|
||||
})
|
||||
.option("oauth-callback-port", {
|
||||
describe: "OAuth local callback port for remote servers",
|
||||
type: "number",
|
||||
})
|
||||
.option("oauth-redirect-uri", {
|
||||
describe: "OAuth redirect URI for remote servers",
|
||||
type: "string",
|
||||
}),
|
||||
handler: Effect.fn("Cli.mcp.add")(function* (args: McpAddArgs) {
|
||||
const maybeCtx = yield* InstanceRef
|
||||
if (!maybeCtx) return yield* Effect.die("InstanceRef not provided")
|
||||
const ctx = maybeCtx
|
||||
const urlOrCommand = mcpAddUrlOrCommand(args)
|
||||
const inlineConfig = parseInlineMcpAdd(args, urlOrCommand)
|
||||
if (inlineConfig && "error" in inlineConfig) return yield* fail(inlineConfig.error)
|
||||
if (args.global && args.scope === "project") return yield* fail("--global cannot be combined with --scope project")
|
||||
yield* Effect.promise(async () => {
|
||||
UI.empty()
|
||||
prompts.intro("Add MCP server")
|
||||
|
||||
const project = ctx.project
|
||||
|
||||
// Resolve config paths eagerly for hints
|
||||
const [projectConfigPath, globalConfigPath] = await Promise.all([
|
||||
resolveConfigPath(ctx.worktree),
|
||||
resolveConfigPath(Global.Path.config, true),
|
||||
])
|
||||
|
||||
// Determine scope
|
||||
let configPath = globalConfigPath
|
||||
if (project.vcs === "git") {
|
||||
const configPath = await (async () => {
|
||||
if (args.global || args.scope === "global") return globalConfigPath
|
||||
if (args.scope === "project") return projectConfigPath
|
||||
if (inlineConfig) return project.vcs === "git" ? projectConfigPath : globalConfigPath
|
||||
if (project.vcs !== "git") return globalConfigPath
|
||||
const scopeResult = await prompts.select({
|
||||
message: "Location",
|
||||
options: [
|
||||
|
|
@ -473,7 +568,14 @@ export const McpAddCommand = effectCmd({
|
|||
],
|
||||
})
|
||||
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
|
||||
configPath = scopeResult
|
||||
return scopeResult
|
||||
})()
|
||||
|
||||
if (inlineConfig) {
|
||||
await addMcpToConfig(args.name!.trim(), inlineConfig.config, configPath)
|
||||
prompts.log.success(`MCP server "${args.name!.trim()}" added to ${configPath}`)
|
||||
prompts.outro("MCP server added successfully")
|
||||
return
|
||||
}
|
||||
|
||||
const name = await prompts.text({
|
||||
|
|
@ -599,6 +701,148 @@ export const McpAddCommand = effectCmd({
|
|||
}),
|
||||
})
|
||||
|
||||
function mcpAddUrlOrCommand(args: McpAddArgs) {
|
||||
const addIndex = args._?.lastIndexOf("add") ?? -1
|
||||
return [
|
||||
...(args.urlOrCommand ?? []),
|
||||
...(addIndex === -1 || !args._ ? [] : args._.slice(addIndex + 1).map(String)),
|
||||
...(args["--"] ?? []),
|
||||
]
|
||||
}
|
||||
|
||||
function parseInlineMcpAdd(
|
||||
args: McpAddArgs,
|
||||
urlOrCommand: string[],
|
||||
): { config: ConfigMCP.Info } | { error: string } | undefined {
|
||||
if (!hasInlineMcpAdd(args, urlOrCommand)) return undefined
|
||||
const name = args.name?.trim()
|
||||
if (!name) return { error: "MCP server name is required" }
|
||||
if (urlOrCommand.length === 0) return { error: "URL or command is required" }
|
||||
if (args.timeout !== undefined && (!Number.isInteger(args.timeout) || args.timeout <= 0)) {
|
||||
return { error: "--timeout must be a positive integer" }
|
||||
}
|
||||
|
||||
const type = args.type ?? (urlOrCommand.length === 1 && URL.canParse(urlOrCommand[0]) ? "remote" : "local")
|
||||
if (type === "local") return parseInlineLocalMcp(args, urlOrCommand)
|
||||
return parseInlineRemoteMcp(args, urlOrCommand)
|
||||
}
|
||||
|
||||
function hasInlineMcpAdd(args: McpAddArgs, urlOrCommand: string[]) {
|
||||
return !!(
|
||||
args.name ||
|
||||
urlOrCommand.length > 0 ||
|
||||
args.type ||
|
||||
args.env?.length ||
|
||||
args.header?.length ||
|
||||
args.enabled !== undefined ||
|
||||
args.timeout !== undefined ||
|
||||
args.oauth !== undefined ||
|
||||
args.oauthClientId ||
|
||||
args.oauthClientSecret ||
|
||||
args.oauthScope ||
|
||||
args.oauthCallbackPort !== undefined ||
|
||||
args.oauthRedirectUri
|
||||
)
|
||||
}
|
||||
|
||||
function parseInlineLocalMcp(args: McpAddArgs, command: string[]): { config: ConfigMCP.Info } | { error: string } {
|
||||
if (args.header?.length) return { error: "--header can only be used with --type remote" }
|
||||
if (hasOAuthOptions(args)) return { error: "OAuth options can only be used with --type remote" }
|
||||
const environment = parseEnv(args.env)
|
||||
if ("error" in environment) return environment
|
||||
return {
|
||||
config: {
|
||||
type: "local",
|
||||
command,
|
||||
...(environment.value && { environment: environment.value }),
|
||||
...(args.enabled !== undefined && { enabled: args.enabled }),
|
||||
...(args.timeout !== undefined && { timeout: args.timeout }),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function parseInlineRemoteMcp(args: McpAddArgs, url: string[]): { config: ConfigMCP.Info } | { error: string } {
|
||||
if (url.length !== 1) return { error: "Remote MCP servers require exactly one URL" }
|
||||
if (!URL.canParse(url[0])) return { error: "Remote MCP server URL is invalid" }
|
||||
if (args.env?.length) return { error: "--env can only be used with --type local" }
|
||||
if (
|
||||
args.oauthCallbackPort !== undefined &&
|
||||
(!Number.isInteger(args.oauthCallbackPort) || args.oauthCallbackPort < 1 || args.oauthCallbackPort > 65535)
|
||||
) {
|
||||
return { error: "--oauth-callback-port must be an integer between 1 and 65535" }
|
||||
}
|
||||
if (args.oauth === false && hasOAuthConfigOptions(args)) {
|
||||
return { error: "--no-oauth cannot be combined with OAuth options" }
|
||||
}
|
||||
const headers = parseHeader(args.header)
|
||||
if ("error" in headers) return headers
|
||||
const oauth = parseOAuth(args)
|
||||
return {
|
||||
config: {
|
||||
type: "remote",
|
||||
url: url[0],
|
||||
...(headers.value && { headers: headers.value }),
|
||||
...(args.enabled !== undefined && { enabled: args.enabled }),
|
||||
...(args.timeout !== undefined && { timeout: args.timeout }),
|
||||
...(oauth !== undefined && { oauth }),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function parseEnv(entries?: string[]): { value?: Record<string, string> } | { error: string } {
|
||||
if (!entries?.length) return {}
|
||||
const parsed = entries.map((entry) => {
|
||||
const index = entry.indexOf("=")
|
||||
const key = entry.slice(0, index).trim()
|
||||
if (index <= 0 || !key) return { error: "--env must be in KEY=VALUE format" }
|
||||
return { key, value: entry.slice(index + 1) }
|
||||
})
|
||||
const invalid = parsed.find((entry): entry is { error: string } => "error" in entry)
|
||||
if (invalid) return invalid
|
||||
return { value: Object.fromEntries(parsed.map((entry) => [entry.key, entry.value])) }
|
||||
}
|
||||
|
||||
function parseHeader(entries?: string[]): { value?: Record<string, string> } | { error: string } {
|
||||
if (!entries?.length) return {}
|
||||
const parsed = entries.map((entry) => {
|
||||
const colon = entry.indexOf(":")
|
||||
const equals = entry.indexOf("=")
|
||||
const index = colon === -1 ? equals : equals === -1 ? colon : Math.min(colon, equals)
|
||||
const key = entry.slice(0, index).trim()
|
||||
if (index <= 0 || !key) return { error: "--header must be in KEY=VALUE or 'KEY: VALUE' format" }
|
||||
return { key, value: entry.slice(index + 1).trim() }
|
||||
})
|
||||
const invalid = parsed.find((entry): entry is { error: string } => "error" in entry)
|
||||
if (invalid) return invalid
|
||||
return { value: Object.fromEntries(parsed.map((entry) => [entry.key, entry.value])) }
|
||||
}
|
||||
|
||||
function hasOAuthOptions(args: McpAddArgs) {
|
||||
return !!(args.oauth !== undefined || hasOAuthConfigOptions(args))
|
||||
}
|
||||
|
||||
function hasOAuthConfigOptions(args: McpAddArgs) {
|
||||
return !!(
|
||||
args.oauthClientId ||
|
||||
args.oauthClientSecret ||
|
||||
args.oauthScope ||
|
||||
args.oauthCallbackPort !== undefined ||
|
||||
args.oauthRedirectUri
|
||||
)
|
||||
}
|
||||
|
||||
function parseOAuth(args: McpAddArgs): ConfigMCP.Remote["oauth"] | undefined {
|
||||
if (args.oauth === false) return false
|
||||
if (!hasOAuthOptions(args)) return undefined
|
||||
return {
|
||||
...(args.oauthClientId && { clientId: args.oauthClientId }),
|
||||
...(args.oauthClientSecret && { clientSecret: args.oauthClientSecret }),
|
||||
...(args.oauthScope && { scope: args.oauthScope }),
|
||||
...(args.oauthCallbackPort !== undefined && { callbackPort: args.oauthCallbackPort }),
|
||||
...(args.oauthRedirectUri && { redirectUri: args.oauthRedirectUri }),
|
||||
}
|
||||
}
|
||||
|
||||
export const McpDebugCommand = effectCmd({
|
||||
command: "debug <name>",
|
||||
describe: "debug OAuth connection for an MCP server",
|
||||
|
|
|
|||
|
|
@ -27,11 +27,11 @@ exports[`opencode CLI help-text snapshots every documented command emits stable
|
|||
manage MCP (Model Context Protocol) servers
|
||||
|
||||
Commands:
|
||||
opencode mcp add add an MCP server
|
||||
opencode mcp list list MCP servers and their status [aliases: ls]
|
||||
opencode mcp auth [name] authenticate with an OAuth-enabled MCP server
|
||||
opencode mcp logout [name] remove OAuth credentials for an MCP server
|
||||
opencode mcp debug <name> debug OAuth connection for an MCP server
|
||||
opencode mcp add [name] [urlOrCommand..] add an MCP server
|
||||
opencode mcp list list MCP servers and their status [aliases: ls]
|
||||
opencode mcp auth [name] authenticate with an OAuth-enabled MCP server
|
||||
opencode mcp logout [name] remove OAuth credentials for an MCP server
|
||||
opencode mcp debug <name> debug OAuth connection for an MCP server
|
||||
|
||||
Options:
|
||||
-h, --help show help [boolean]
|
||||
|
|
@ -425,16 +425,34 @@ Options:
|
|||
`;
|
||||
|
||||
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mcp add --help 1`] = `
|
||||
"opencode mcp add
|
||||
"opencode mcp add [name] [urlOrCommand..]
|
||||
|
||||
add an MCP server
|
||||
|
||||
Positionals:
|
||||
name name of the MCP server [string]
|
||||
urlOrCommand URL for remote servers or command for local servers [array] [default: []]
|
||||
|
||||
Options:
|
||||
-h, --help show help [boolean]
|
||||
-v, --version show version number [boolean]
|
||||
--print-logs print logs to stderr [boolean]
|
||||
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||
--pure run without external plugins [boolean]"
|
||||
-h, --help show help [boolean]
|
||||
-v, --version show version number [boolean]
|
||||
--print-logs print logs to stderr [boolean]
|
||||
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||
--pure run without external plugins [boolean]
|
||||
--type server type: local or remote [string] [choices: "local", "remote"]
|
||||
--env environment variable for local servers (KEY=VALUE) [array]
|
||||
--header HTTP header for remote servers (KEY=VALUE or 'KEY: VALUE') [array]
|
||||
--scope where to save the server [string] [choices: "project", "global"]
|
||||
-g, --global save to global config [boolean]
|
||||
--enabled enable or disable the server on startup [boolean]
|
||||
--timeout timeout in milliseconds for MCP server requests [number]
|
||||
--oauth enable OAuth for remote servers, or use --no-oauth to disable
|
||||
auto-detection [boolean]
|
||||
--oauth-client-id OAuth client ID for remote servers [string]
|
||||
--oauth-client-secret OAuth client secret for remote servers [string]
|
||||
--oauth-scope OAuth scopes to request for remote servers [string]
|
||||
--oauth-callback-port OAuth local callback port for remote servers [number]
|
||||
--oauth-redirect-uri OAuth redirect URI for remote servers [string]"
|
||||
`;
|
||||
|
||||
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mcp auth --help 1`] = `
|
||||
|
|
|
|||
89
packages/opencode/test/cli/mcp.test.ts
Normal file
89
packages/opencode/test/cli/mcp.test.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import path from "node:path"
|
||||
import { cliIt } from "../lib/cli-process"
|
||||
|
||||
describe("opencode mcp", () => {
|
||||
cliIt.live(
|
||||
"adds MCP servers from inline arguments",
|
||||
({ home, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* opencode.spawn([
|
||||
"mcp",
|
||||
"add",
|
||||
"github",
|
||||
"https://api.githubcopilot.com/mcp",
|
||||
"--type",
|
||||
"remote",
|
||||
"--header",
|
||||
"Authorization: Bearer test-token",
|
||||
"--global",
|
||||
])
|
||||
opencode.expectExit(result, 0, "opencode mcp add remote")
|
||||
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(home, ".config/opencode/opencode.json")).json())).toEqual(
|
||||
{
|
||||
mcp: {
|
||||
github: {
|
||||
type: "remote",
|
||||
url: "https://api.githubcopilot.com/mcp",
|
||||
headers: {
|
||||
Authorization: "Bearer test-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const local = yield* opencode.spawn([
|
||||
"mcp",
|
||||
"add",
|
||||
"everything",
|
||||
"npx",
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-everything",
|
||||
"--env",
|
||||
"FOO=bar",
|
||||
"--global",
|
||||
])
|
||||
opencode.expectExit(local, 0, "opencode mcp add local")
|
||||
|
||||
expect(
|
||||
yield* Effect.promise(() => Bun.file(path.join(home, ".config/opencode/opencode.json")).json()),
|
||||
).toMatchObject({
|
||||
mcp: {
|
||||
everything: {
|
||||
type: "local",
|
||||
command: ["npx", "-y", "@modelcontextprotocol/server-everything"],
|
||||
environment: {
|
||||
FOO: "bar",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const noOAuth = yield* opencode.spawn([
|
||||
"mcp",
|
||||
"add",
|
||||
"public",
|
||||
"https://example.com/mcp",
|
||||
"--no-oauth",
|
||||
"--global",
|
||||
])
|
||||
opencode.expectExit(noOAuth, 0, "opencode mcp add no oauth")
|
||||
|
||||
expect(
|
||||
yield* Effect.promise(() => Bun.file(path.join(home, ".config/opencode/opencode.json")).json()),
|
||||
).toMatchObject({
|
||||
mcp: {
|
||||
public: {
|
||||
type: "remote",
|
||||
url: "https://example.com/mcp",
|
||||
oauth: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
120_000,
|
||||
)
|
||||
})
|
||||
|
|
@ -44,6 +44,24 @@ You can also disable a server by setting `enabled` to `false`. This is useful if
|
|||
|
||||
---
|
||||
|
||||
## CLI
|
||||
|
||||
You can add MCP servers from the command line with `opencode mcp add`.
|
||||
|
||||
```bash
|
||||
# Local server
|
||||
opencode mcp add mcp_everything npx -y @modelcontextprotocol/server-everything
|
||||
|
||||
# Remote server
|
||||
opencode mcp add github https://api.githubcopilot.com/mcp \
|
||||
--type remote \
|
||||
--header "Authorization: Bearer YOUR_GITHUB_PAT"
|
||||
```
|
||||
|
||||
Use `--env KEY=VALUE` for local server environment variables and repeat it for multiple values. Use `--global` to save to global config; otherwise the command saves to the current project when run inside a git repository.
|
||||
|
||||
---
|
||||
|
||||
### Overriding remote defaults
|
||||
|
||||
Organizations can provide default MCP servers via their `.well-known/opencode` endpoint. These servers may be disabled by default, allowing users to opt-in to the ones they need.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue