Compare commits
6 commits
dev
...
feat/mcp-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d97c4c040 | ||
|
|
8e0bceaea9 | ||
|
|
010d50e993 | ||
|
|
7f9fbac33c | ||
|
|
ebb101bd54 | ||
|
|
2ca7478b8a |
4 changed files with 291 additions and 15 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
import { cmd } from "./cmd"
|
import { cmd } from "./cmd"
|
||||||
import { effectCmd } from "../effect-cmd"
|
import { effectCmd, fail } from "../effect-cmd"
|
||||||
import { Cause } from "effect"
|
import { Cause } from "effect"
|
||||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
||||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
||||||
|
|
@ -55,6 +55,24 @@ function isMcpRemote(config: McpEntry): config is McpRemote {
|
||||||
return isMcpConfigured(config) && config.type === "remote"
|
return isMcpConfigured(config) && config.type === "remote"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type McpAddArgs = {
|
||||||
|
name?: string
|
||||||
|
args?: string[]
|
||||||
|
type?: "local" | "remote"
|
||||||
|
env?: string[]
|
||||||
|
header?: string[]
|
||||||
|
global?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type InlineMcpAdd = {
|
||||||
|
name?: string
|
||||||
|
positional: string[]
|
||||||
|
command: string[]
|
||||||
|
type?: "local" | "remote"
|
||||||
|
env?: string[]
|
||||||
|
header?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
function configuredServers(config: Config.Info) {
|
function configuredServers(config: Config.Info) {
|
||||||
return Object.entries(config.mcp ?? {}).filter((entry): entry is [string, McpConfigured] => isMcpConfigured(entry[1]))
|
return Object.entries(config.mcp ?? {}).filter((entry): entry is [string, McpConfigured] => isMcpConfigured(entry[1]))
|
||||||
}
|
}
|
||||||
|
|
@ -436,27 +454,79 @@ async function addMcpToConfig(name: string, mcpConfig: ConfigMCP.Info, configPat
|
||||||
}
|
}
|
||||||
|
|
||||||
export const McpAddCommand = effectCmd({
|
export const McpAddCommand = effectCmd({
|
||||||
command: "add",
|
command: "add [name] [args...]",
|
||||||
describe: "add an MCP server",
|
describe: "add an MCP server",
|
||||||
handler: Effect.fn("Cli.mcp.add")(function* () {
|
builder: (yargs) =>
|
||||||
|
yargs
|
||||||
|
.positional("name", {
|
||||||
|
describe: "name of the MCP server",
|
||||||
|
type: "string",
|
||||||
|
})
|
||||||
|
.positional("args", {
|
||||||
|
describe: "URL for remote 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("global", {
|
||||||
|
alias: ["g"],
|
||||||
|
describe: "save to global config",
|
||||||
|
type: "boolean",
|
||||||
|
}).epilogue(`Usage:
|
||||||
|
opencode mcp add <name> -- <command> [args...] (local MCP server)
|
||||||
|
opencode mcp add <name> --env KEY=VALUE -- <command> [args...] (local MCP server with env vars)
|
||||||
|
opencode mcp add <name> <url> (remote MCP server)
|
||||||
|
opencode mcp add <name> --header KEY=VALUE <url> (remote MCP server with headers)
|
||||||
|
opencode mcp add <name> --global <url> (save to global config)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
opencode mcp add context7 -- npx -y @upstash/context7-mcp
|
||||||
|
opencode mcp add local-env --env FOO=bar -- node server.js
|
||||||
|
opencode mcp add sg --header Authorization=token https://sg.example/mcp
|
||||||
|
opencode mcp add hugging-face https://huggingface.co/mcp`),
|
||||||
|
handler: Effect.fn("Cli.mcp.add")(function* (input) {
|
||||||
const maybeCtx = yield* InstanceRef
|
const maybeCtx = yield* InstanceRef
|
||||||
if (!maybeCtx) return yield* Effect.die("InstanceRef not provided")
|
if (!maybeCtx) return yield* Effect.die("InstanceRef not provided")
|
||||||
const ctx = maybeCtx
|
const ctx = maybeCtx
|
||||||
|
const inlineConfig = parseInlineMcpAdd({
|
||||||
|
name: input.name,
|
||||||
|
positional: input.args ?? [],
|
||||||
|
command: input["--"] ?? [],
|
||||||
|
type: input.type,
|
||||||
|
env: input.env,
|
||||||
|
header: input.header,
|
||||||
|
})
|
||||||
|
if (inlineConfig && "error" in inlineConfig) return yield* fail(inlineConfig.error)
|
||||||
yield* Effect.promise(async () => {
|
yield* Effect.promise(async () => {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
prompts.intro("Add MCP server")
|
prompts.intro("Add MCP server")
|
||||||
|
|
||||||
const project = ctx.project
|
const project = ctx.project
|
||||||
|
|
||||||
// Resolve config paths eagerly for hints
|
|
||||||
const [projectConfigPath, globalConfigPath] = await Promise.all([
|
const [projectConfigPath, globalConfigPath] = await Promise.all([
|
||||||
resolveConfigPath(ctx.worktree),
|
resolveConfigPath(ctx.worktree),
|
||||||
resolveConfigPath(Global.Path.config, true),
|
resolveConfigPath(Global.Path.config, true),
|
||||||
])
|
])
|
||||||
|
|
||||||
// Determine scope
|
const configPath = await (async () => {
|
||||||
let configPath = globalConfigPath
|
if (input.global) return globalConfigPath
|
||||||
if (project.vcs === "git") {
|
if (inlineConfig) return project.vcs === "git" ? projectConfigPath : globalConfigPath
|
||||||
|
if (project.vcs !== "git") return globalConfigPath
|
||||||
const scopeResult = await prompts.select({
|
const scopeResult = await prompts.select({
|
||||||
message: "Location",
|
message: "Location",
|
||||||
options: [
|
options: [
|
||||||
|
|
@ -473,7 +543,14 @@ export const McpAddCommand = effectCmd({
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
|
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
|
||||||
configPath = scopeResult
|
return scopeResult
|
||||||
|
})()
|
||||||
|
|
||||||
|
if (inlineConfig) {
|
||||||
|
await addMcpToConfig(inlineConfig.name, inlineConfig.config, configPath)
|
||||||
|
prompts.log.success(`MCP server "${inlineConfig.name}" added to ${configPath}`)
|
||||||
|
prompts.outro("MCP server added successfully")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const name = await prompts.text({
|
const name = await prompts.text({
|
||||||
|
|
@ -599,6 +676,94 @@ export const McpAddCommand = effectCmd({
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function parseInlineMcpAdd(
|
||||||
|
input: InlineMcpAdd,
|
||||||
|
): { name: string; config: ConfigMCP.Info } | { error: string } | undefined {
|
||||||
|
if (!hasInlineMcpAdd(input)) return undefined
|
||||||
|
const name = input.name?.trim()
|
||||||
|
if (!name) return { error: "MCP server name is required" }
|
||||||
|
const result = input.command.length > 0 ? parseInlineLocalMcp(input) : parseInlineRemoteMcp(input)
|
||||||
|
if ("error" in result) return result
|
||||||
|
return { name, config: result.config }
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasInlineMcpAdd(input: InlineMcpAdd) {
|
||||||
|
return !!(
|
||||||
|
input.name ||
|
||||||
|
input.positional.length > 0 ||
|
||||||
|
input.command.length > 0 ||
|
||||||
|
input.type ||
|
||||||
|
input.env?.length ||
|
||||||
|
input.header?.length
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseInlineLocalMcp(input: InlineMcpAdd): { config: ConfigMCP.Info } | { error: string } {
|
||||||
|
if (input.positional.length > 0) return { error: "Remote URL arguments cannot be combined with -- <command>" }
|
||||||
|
if (input.type === "remote") return { error: "-- <command> can only be used with --type local" }
|
||||||
|
if (input.header?.length) return { error: "--header can only be used with remote MCP servers" }
|
||||||
|
const environment = parseEnv(input.env)
|
||||||
|
if ("error" in environment) return environment
|
||||||
|
return {
|
||||||
|
config: {
|
||||||
|
type: "local",
|
||||||
|
command: input.command,
|
||||||
|
...(environment.value && { environment: environment.value }),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseInlineRemoteMcp(input: InlineMcpAdd): { config: ConfigMCP.Info } | { error: string } {
|
||||||
|
if (input.type === "local" || input.env?.length) return { error: "Local MCP commands must be passed after --" }
|
||||||
|
if (input.positional.length === 0) return { error: "URL or command is required" }
|
||||||
|
const wantsRemote = input.type === "remote" || !!input.header?.length
|
||||||
|
if (input.positional.length !== 1) {
|
||||||
|
return {
|
||||||
|
error: wantsRemote ? "Remote MCP servers require exactly one URL" : "Local MCP commands must be passed after --",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!URL.canParse(input.positional[0])) {
|
||||||
|
return { error: wantsRemote ? "Remote MCP server URL is invalid" : "Local MCP commands must be passed after --" }
|
||||||
|
}
|
||||||
|
const headers = parseHeader(input.header)
|
||||||
|
if ("error" in headers) return headers
|
||||||
|
return {
|
||||||
|
config: {
|
||||||
|
type: "remote",
|
||||||
|
url: input.positional[0],
|
||||||
|
...(headers.value && { headers: headers.value }),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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])) }
|
||||||
|
}
|
||||||
|
|
||||||
export const McpDebugCommand = effectCmd({
|
export const McpDebugCommand = effectCmd({
|
||||||
command: "debug <name>",
|
command: "debug <name>",
|
||||||
describe: "debug OAuth connection for an MCP server",
|
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
|
manage MCP (Model Context Protocol) servers
|
||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
opencode mcp add add an MCP server
|
opencode mcp add [name] [args...] add an MCP server
|
||||||
opencode mcp list list MCP servers and their status [aliases: ls]
|
opencode mcp list list MCP servers and their status [aliases: ls]
|
||||||
opencode mcp auth [name] authenticate with an OAuth-enabled MCP server
|
opencode mcp auth [name] authenticate with an OAuth-enabled MCP server
|
||||||
opencode mcp logout [name] remove OAuth credentials for an 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 debug <name> debug OAuth connection for an MCP server
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-h, --help show help [boolean]
|
-h, --help show help [boolean]
|
||||||
|
|
@ -425,16 +425,37 @@ Options:
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mcp add --help 1`] = `
|
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] [args...]
|
||||||
|
|
||||||
add an MCP server
|
add an MCP server
|
||||||
|
|
||||||
|
Positionals:
|
||||||
|
name name of the MCP server [string]
|
||||||
|
args URL for remote servers [array] [default: []]
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-h, --help show help [boolean]
|
-h, --help show help [boolean]
|
||||||
-v, --version show version number [boolean]
|
-v, --version show version number [boolean]
|
||||||
--print-logs print logs to stderr [boolean]
|
--print-logs print logs to stderr [boolean]
|
||||||
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||||
--pure run without external plugins [boolean]"
|
--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]
|
||||||
|
-g, --global save to global config [boolean]
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
opencode mcp add <name> -- <command> [args...] (local MCP server)
|
||||||
|
opencode mcp add <name> --env KEY=VALUE -- <command> [args...] (local MCP server with env vars)
|
||||||
|
opencode mcp add <name> <url> (remote MCP server)
|
||||||
|
opencode mcp add <name> --header KEY=VALUE <url> (remote MCP server with headers)
|
||||||
|
opencode mcp add <name> --global <url> (save to global config)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
opencode mcp add context7 -- npx -y @upstash/context7-mcp
|
||||||
|
opencode mcp add local-env --env FOO=bar -- node server.js
|
||||||
|
opencode mcp add sg --header Authorization=token https://sg.example/mcp
|
||||||
|
opencode mcp add hugging-face https://huggingface.co/mcp"
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mcp auth --help 1`] = `
|
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mcp auth --help 1`] = `
|
||||||
|
|
|
||||||
72
packages/opencode/test/cli/mcp.test.ts
Normal file
72
packages/opencode/test/cli/mcp.test.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
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",
|
||||||
|
"--env",
|
||||||
|
"FOO=bar",
|
||||||
|
"--global",
|
||||||
|
"--",
|
||||||
|
"npx",
|
||||||
|
"-y",
|
||||||
|
"@modelcontextprotocol/server-everything",
|
||||||
|
])
|
||||||
|
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 missingSeparator = yield* opencode.spawn(["mcp", "add", "bad-local", "node", "server.js", "--global"])
|
||||||
|
expect(missingSeparator.exitCode).not.toBe(0)
|
||||||
|
expect(missingSeparator.stderr).toContain("Local MCP commands must be passed after --")
|
||||||
|
}),
|
||||||
|
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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Overriding remote defaults
|
### 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.
|
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