@@ -3,13 +3,272 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"
33import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
44import * as prompts from "@clack/prompts"
55import { 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
714export 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+
13272export 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