Skip to content

Commit 509e43d

Browse files
authored
feat(mcp): add OAuth authentication support for remote MCP servers (#5014)
1 parent e693192 commit 509e43d

File tree

14 files changed

+1511
-74
lines changed

14 files changed

+1511
-74
lines changed

bun.lock

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -473,7 +473,7 @@
473473
"diff": "8.0.2",
474474
"fuzzysort": "3.1.0",
475475
"hono": "4.10.7",
476-
"hono-openapi": "1.1.1",
476+
"hono-openapi": "1.1.2",
477477
"luxon": "3.6.1",
478478
"remeda": "2.26.0",
479479
"solid-js": "1.9.10",
@@ -2537,7 +2537,7 @@
25372537

25382538
"hono": ["[email protected]", "", {}, "sha512-icXIITfw/07Q88nLSkB9aiUrd8rYzSweK681Kjo/TSggaGbOX4RRyxxm71v+3PC8C/j+4rlxGeoTRxQDkaJkUw=="],
25392539

2540-
"hono-openapi": ["[email protected].1", "", { "peerDependencies": { "@hono/standard-validator": "^0.1.2", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.8", "@types/json-schema": "^7.0.15", "hono": "^4.8.3", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-AC3HNhZYPHhnZdSy2Je7GDoTTNxPos6rKRQKVDBbSilY3cWJPqsxRnN6zA4pU7tfxmQEMTqkiLXbw6sAaemB8Q=="],
2540+
"hono-openapi": ["[email protected].2", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.8.3", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-toUcO60MftRBxqcVyxsHNYs2m4vf4xkQaiARAucQx3TiBPDtMNNkoh+C4I1vAretQZiGyaLOZNWn1YxfSyUA5g=="],
25412541

25422542
"html-entities": ["[email protected]", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="],
25432543

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
"diff": "8.0.2",
3636
"ai": "5.0.97",
3737
"hono": "4.10.7",
38-
"hono-openapi": "1.1.1",
38+
"hono-openapi": "1.1.2",
3939
"fuzzysort": "3.1.0",
4040
"luxon": "3.6.1",
4141
"typescript": "5.8.2",

packages/opencode/src/cli/cmd/mcp.ts

Lines changed: 327 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,272 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"
33
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
44
import * as prompts from "@clack/prompts"
55
import { UI } from "../ui"
6+
import { MCP } from "../../mcp"
7+
import { McpAuth } from "../../mcp/auth"
8+
import { Config } from "../../config/config"
9+
import { Instance } from "../../project/instance"
10+
import path from "path"
11+
import os from "os"
12+
import { Global } from "../../global"
613

714
export const McpCommand = cmd({
815
command: "mcp",
9-
builder: (yargs) => yargs.command(McpAddCommand).demandCommand(),
16+
builder: (yargs) =>
17+
yargs
18+
.command(McpAddCommand)
19+
.command(McpListCommand)
20+
.command(McpAuthCommand)
21+
.command(McpLogoutCommand)
22+
.demandCommand(),
1023
async handler() {},
1124
})
1225

26+
export const McpListCommand = cmd({
27+
command: "list",
28+
aliases: ["ls"],
29+
describe: "list MCP servers and their status",
30+
async handler() {
31+
await Instance.provide({
32+
directory: process.cwd(),
33+
async fn() {
34+
UI.empty()
35+
prompts.intro("MCP Servers")
36+
37+
const config = await Config.get()
38+
const mcpServers = config.mcp ?? {}
39+
const statuses = await MCP.status()
40+
41+
if (Object.keys(mcpServers).length === 0) {
42+
prompts.log.warn("No MCP servers configured")
43+
prompts.outro("Add servers with: opencode mcp add")
44+
return
45+
}
46+
47+
for (const [name, serverConfig] of Object.entries(mcpServers)) {
48+
const status = statuses[name]
49+
const hasOAuth = serverConfig.type === "remote" && !!serverConfig.oauth
50+
const hasStoredTokens = await MCP.hasStoredTokens(name)
51+
52+
let statusIcon: string
53+
let statusText: string
54+
let hint = ""
55+
56+
if (!status) {
57+
statusIcon = "○"
58+
statusText = "not initialized"
59+
} else if (status.status === "connected") {
60+
statusIcon = "✓"
61+
statusText = "connected"
62+
if (hasOAuth && hasStoredTokens) {
63+
hint = " (OAuth)"
64+
}
65+
} else if (status.status === "disabled") {
66+
statusIcon = "○"
67+
statusText = "disabled"
68+
} else if (status.status === "needs_auth") {
69+
statusIcon = "⚠"
70+
statusText = "needs authentication"
71+
} else if (status.status === "needs_client_registration") {
72+
statusIcon = "✗"
73+
statusText = "needs client registration"
74+
hint = "\n " + status.error
75+
} else {
76+
statusIcon = "✗"
77+
statusText = "failed"
78+
hint = "\n " + status.error
79+
}
80+
81+
const typeHint = serverConfig.type === "remote" ? serverConfig.url : serverConfig.command.join(" ")
82+
prompts.log.info(
83+
`${statusIcon} ${name} ${UI.Style.TEXT_DIM}${statusText}${hint}\n ${UI.Style.TEXT_DIM}${typeHint}`,
84+
)
85+
}
86+
87+
prompts.outro(`${Object.keys(mcpServers).length} server(s)`)
88+
},
89+
})
90+
},
91+
})
92+
93+
export const McpAuthCommand = cmd({
94+
command: "auth [name]",
95+
describe: "authenticate with an OAuth-enabled MCP server",
96+
builder: (yargs) =>
97+
yargs.positional("name", {
98+
describe: "name of the MCP server",
99+
type: "string",
100+
}),
101+
async handler(args) {
102+
await Instance.provide({
103+
directory: process.cwd(),
104+
async fn() {
105+
UI.empty()
106+
prompts.intro("MCP OAuth Authentication")
107+
108+
const config = await Config.get()
109+
const mcpServers = config.mcp ?? {}
110+
111+
// Get OAuth-enabled servers
112+
const oauthServers = Object.entries(mcpServers).filter(([_, cfg]) => cfg.type === "remote" && !!cfg.oauth)
113+
114+
if (oauthServers.length === 0) {
115+
prompts.log.warn("No OAuth-enabled MCP servers configured")
116+
prompts.log.info("Add OAuth config to a remote MCP server in opencode.json:")
117+
prompts.log.info(`
118+
"mcp": {
119+
"my-server": {
120+
"type": "remote",
121+
"url": "https://example.com/mcp",
122+
"oauth": {
123+
"scope": "tools:read"
124+
}
125+
}
126+
}`)
127+
prompts.outro("Done")
128+
return
129+
}
130+
131+
let serverName = args.name
132+
if (!serverName) {
133+
const selected = await prompts.select({
134+
message: "Select MCP server to authenticate",
135+
options: oauthServers.map(([name, cfg]) => ({
136+
label: name,
137+
value: name,
138+
hint: cfg.type === "remote" ? cfg.url : undefined,
139+
})),
140+
})
141+
if (prompts.isCancel(selected)) throw new UI.CancelledError()
142+
serverName = selected
143+
}
144+
145+
const serverConfig = mcpServers[serverName]
146+
if (!serverConfig) {
147+
prompts.log.error(`MCP server not found: ${serverName}`)
148+
prompts.outro("Done")
149+
return
150+
}
151+
152+
if (serverConfig.type !== "remote" || !serverConfig.oauth) {
153+
prompts.log.error(`MCP server ${serverName} does not have OAuth configured`)
154+
prompts.outro("Done")
155+
return
156+
}
157+
158+
// Check if already authenticated
159+
const hasTokens = await MCP.hasStoredTokens(serverName)
160+
if (hasTokens) {
161+
const confirm = await prompts.confirm({
162+
message: `${serverName} already has stored credentials. Re-authenticate?`,
163+
})
164+
if (prompts.isCancel(confirm) || !confirm) {
165+
prompts.outro("Cancelled")
166+
return
167+
}
168+
}
169+
170+
const spinner = prompts.spinner()
171+
spinner.start("Starting OAuth flow...")
172+
173+
try {
174+
const status = await MCP.authenticate(serverName)
175+
176+
if (status.status === "connected") {
177+
spinner.stop("Authentication successful!")
178+
} else if (status.status === "needs_client_registration") {
179+
spinner.stop("Authentication failed", 1)
180+
prompts.log.error(status.error)
181+
prompts.log.info("Add clientId to your MCP server config:")
182+
prompts.log.info(`
183+
"mcp": {
184+
"${serverName}": {
185+
"type": "remote",
186+
"url": "${serverConfig.url}",
187+
"oauth": {
188+
"clientId": "your-client-id",
189+
"clientSecret": "your-client-secret"
190+
}
191+
}
192+
}`)
193+
} else if (status.status === "failed") {
194+
spinner.stop("Authentication failed", 1)
195+
prompts.log.error(status.error)
196+
} else {
197+
spinner.stop("Unexpected status: " + status.status, 1)
198+
}
199+
} catch (error) {
200+
spinner.stop("Authentication failed", 1)
201+
prompts.log.error(error instanceof Error ? error.message : String(error))
202+
}
203+
204+
prompts.outro("Done")
205+
},
206+
})
207+
},
208+
})
209+
210+
export const McpLogoutCommand = cmd({
211+
command: "logout [name]",
212+
describe: "remove OAuth credentials for an MCP server",
213+
builder: (yargs) =>
214+
yargs.positional("name", {
215+
describe: "name of the MCP server",
216+
type: "string",
217+
}),
218+
async handler(args) {
219+
await Instance.provide({
220+
directory: process.cwd(),
221+
async fn() {
222+
UI.empty()
223+
prompts.intro("MCP OAuth Logout")
224+
225+
const authPath = path.join(Global.Path.data, "mcp-auth.json")
226+
const credentials = await McpAuth.all()
227+
const serverNames = Object.keys(credentials)
228+
229+
if (serverNames.length === 0) {
230+
prompts.log.warn("No MCP OAuth credentials stored")
231+
prompts.outro("Done")
232+
return
233+
}
234+
235+
let serverName = args.name
236+
if (!serverName) {
237+
const selected = await prompts.select({
238+
message: "Select MCP server to logout",
239+
options: serverNames.map((name) => {
240+
const entry = credentials[name]
241+
const hasTokens = !!entry.tokens
242+
const hasClient = !!entry.clientInfo
243+
let hint = ""
244+
if (hasTokens && hasClient) hint = "tokens + client"
245+
else if (hasTokens) hint = "tokens"
246+
else if (hasClient) hint = "client registration"
247+
return {
248+
label: name,
249+
value: name,
250+
hint,
251+
}
252+
}),
253+
})
254+
if (prompts.isCancel(selected)) throw new UI.CancelledError()
255+
serverName = selected
256+
}
257+
258+
if (!credentials[serverName]) {
259+
prompts.log.error(`No credentials found for: ${serverName}`)
260+
prompts.outro("Done")
261+
return
262+
}
263+
264+
await MCP.removeAuth(serverName)
265+
prompts.log.success(`Removed OAuth credentials for ${serverName}`)
266+
prompts.outro("Done")
267+
},
268+
})
269+
},
270+
})
271+
13272
export const McpAddCommand = cmd({
14273
command: "add",
15274
describe: "add an MCP server",
@@ -66,13 +325,74 @@ export const McpAddCommand = cmd({
66325
})
67326
if (prompts.isCancel(url)) throw new UI.CancelledError()
68327

69-
const client = new Client({
70-
name: "opencode",
71-
version: "1.0.0",
328+
const useOAuth = await prompts.confirm({
329+
message: "Does this server require OAuth authentication?",
330+
initialValue: false,
72331
})
73-
const transport = new StreamableHTTPClientTransport(new URL(url))
74-
await client.connect(transport)
75-
prompts.log.info(`Remote MCP server "${name}" configured with URL: ${url}`)
332+
if (prompts.isCancel(useOAuth)) throw new UI.CancelledError()
333+
334+
if (useOAuth) {
335+
const hasClientId = await prompts.confirm({
336+
message: "Do you have a pre-registered client ID?",
337+
initialValue: false,
338+
})
339+
if (prompts.isCancel(hasClientId)) throw new UI.CancelledError()
340+
341+
if (hasClientId) {
342+
const clientId = await prompts.text({
343+
message: "Enter client ID",
344+
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
345+
})
346+
if (prompts.isCancel(clientId)) throw new UI.CancelledError()
347+
348+
const hasSecret = await prompts.confirm({
349+
message: "Do you have a client secret?",
350+
initialValue: false,
351+
})
352+
if (prompts.isCancel(hasSecret)) throw new UI.CancelledError()
353+
354+
let clientSecret: string | undefined
355+
if (hasSecret) {
356+
const secret = await prompts.password({
357+
message: "Enter client secret",
358+
})
359+
if (prompts.isCancel(secret)) throw new UI.CancelledError()
360+
clientSecret = secret
361+
}
362+
363+
prompts.log.info(`Remote MCP server "${name}" configured with OAuth (client ID: ${clientId})`)
364+
prompts.log.info("Add this to your opencode.json:")
365+
prompts.log.info(`
366+
"mcp": {
367+
"${name}": {
368+
"type": "remote",
369+
"url": "${url}",
370+
"oauth": {
371+
"clientId": "${clientId}"${clientSecret ? `,\n "clientSecret": "${clientSecret}"` : ""}
372+
}
373+
}
374+
}`)
375+
} else {
376+
prompts.log.info(`Remote MCP server "${name}" configured with OAuth (dynamic registration)`)
377+
prompts.log.info("Add this to your opencode.json:")
378+
prompts.log.info(`
379+
"mcp": {
380+
"${name}": {
381+
"type": "remote",
382+
"url": "${url}",
383+
"oauth": {}
384+
}
385+
}`)
386+
}
387+
} else {
388+
const client = new Client({
389+
name: "opencode",
390+
version: "1.0.0",
391+
})
392+
const transport = new StreamableHTTPClientTransport(new URL(url))
393+
await client.connect(transport)
394+
prompts.log.info(`Remote MCP server "${name}" configured with URL: ${url}`)
395+
}
76396
}
77397

78398
prompts.outro("MCP server added successfully")

0 commit comments

Comments
 (0)