Skip to content

Commit 6732201

Browse files
Apply PR #19042: fix+refactor(mcp): lifecycle tests, cancelPending fix, Effect migration
2 parents 7adf339 + 7bfe822 commit 6732201

5 files changed

Lines changed: 1350 additions & 660 deletions

File tree

packages/opencode/specs/effect-migration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,4 +175,4 @@ Still open and likely worth migrating:
175175
- [ ] `Provider`
176176
- [x] `Project`
177177
- [ ] `LSP`
178-
- [ ] `MCP`
178+
- [x] `MCP`

packages/opencode/src/mcp/auth.ts

Lines changed: 147 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import path from "path"
22
import z from "zod"
33
import { Global } from "../global"
4-
import { Filesystem } from "../util/filesystem"
4+
import { Effect, Layer, ServiceMap } from "effect"
5+
import { AppFileSystem } from "@/filesystem"
6+
import { makeRunPromise } from "@/effect/run-service"
57

68
export namespace McpAuth {
79
export const Tokens = z.object({
@@ -25,106 +27,155 @@ export namespace McpAuth {
2527
clientInfo: ClientInfo.optional(),
2628
codeVerifier: z.string().optional(),
2729
oauthState: z.string().optional(),
28-
serverUrl: z.string().optional(), // Track the URL these credentials are for
30+
serverUrl: z.string().optional(),
2931
})
3032
export type Entry = z.infer<typeof Entry>
3133

3234
const filepath = path.join(Global.Path.data, "mcp-auth.json")
3335

34-
export async function get(mcpName: string): Promise<Entry | undefined> {
35-
const data = await all()
36-
return data[mcpName]
36+
export interface Interface {
37+
readonly all: () => Effect.Effect<Record<string, Entry>>
38+
readonly get: (mcpName: string) => Effect.Effect<Entry | undefined>
39+
readonly getForUrl: (mcpName: string, serverUrl: string) => Effect.Effect<Entry | undefined>
40+
readonly set: (mcpName: string, entry: Entry, serverUrl?: string) => Effect.Effect<void>
41+
readonly remove: (mcpName: string) => Effect.Effect<void>
42+
readonly updateTokens: (mcpName: string, tokens: Tokens, serverUrl?: string) => Effect.Effect<void>
43+
readonly updateClientInfo: (mcpName: string, clientInfo: ClientInfo, serverUrl?: string) => Effect.Effect<void>
44+
readonly updateCodeVerifier: (mcpName: string, codeVerifier: string) => Effect.Effect<void>
45+
readonly clearCodeVerifier: (mcpName: string) => Effect.Effect<void>
46+
readonly updateOAuthState: (mcpName: string, oauthState: string) => Effect.Effect<void>
47+
readonly getOAuthState: (mcpName: string) => Effect.Effect<string | undefined>
48+
readonly clearOAuthState: (mcpName: string) => Effect.Effect<void>
49+
readonly isTokenExpired: (mcpName: string) => Effect.Effect<boolean | null>
3750
}
3851

39-
/**
40-
* Get auth entry and validate it's for the correct URL.
41-
* Returns undefined if URL has changed (credentials are invalid).
42-
*/
43-
export async function getForUrl(mcpName: string, serverUrl: string): Promise<Entry | undefined> {
44-
const entry = await get(mcpName)
45-
if (!entry) return undefined
46-
47-
// If no serverUrl is stored, this is from an old version - consider it invalid
48-
if (!entry.serverUrl) return undefined
49-
50-
// If URL has changed, credentials are invalid
51-
if (entry.serverUrl !== serverUrl) return undefined
52-
53-
return entry
54-
}
55-
56-
export async function all(): Promise<Record<string, Entry>> {
57-
return Filesystem.readJson<Record<string, Entry>>(filepath).catch(() => ({}))
58-
}
59-
60-
export async function set(mcpName: string, entry: Entry, serverUrl?: string): Promise<void> {
61-
const data = await all()
62-
// Always update serverUrl if provided
63-
if (serverUrl) {
64-
entry.serverUrl = serverUrl
65-
}
66-
await Filesystem.writeJson(filepath, { ...data, [mcpName]: entry }, 0o600)
67-
}
68-
69-
export async function remove(mcpName: string): Promise<void> {
70-
const data = await all()
71-
delete data[mcpName]
72-
await Filesystem.writeJson(filepath, data, 0o600)
73-
}
74-
75-
export async function updateTokens(mcpName: string, tokens: Tokens, serverUrl?: string): Promise<void> {
76-
const entry = (await get(mcpName)) ?? {}
77-
entry.tokens = tokens
78-
await set(mcpName, entry, serverUrl)
79-
}
80-
81-
export async function updateClientInfo(mcpName: string, clientInfo: ClientInfo, serverUrl?: string): Promise<void> {
82-
const entry = (await get(mcpName)) ?? {}
83-
entry.clientInfo = clientInfo
84-
await set(mcpName, entry, serverUrl)
85-
}
86-
87-
export async function updateCodeVerifier(mcpName: string, codeVerifier: string): Promise<void> {
88-
const entry = (await get(mcpName)) ?? {}
89-
entry.codeVerifier = codeVerifier
90-
await set(mcpName, entry)
91-
}
92-
93-
export async function clearCodeVerifier(mcpName: string): Promise<void> {
94-
const entry = await get(mcpName)
95-
if (entry) {
96-
delete entry.codeVerifier
97-
await set(mcpName, entry)
98-
}
99-
}
100-
101-
export async function updateOAuthState(mcpName: string, oauthState: string): Promise<void> {
102-
const entry = (await get(mcpName)) ?? {}
103-
entry.oauthState = oauthState
104-
await set(mcpName, entry)
105-
}
106-
107-
export async function getOAuthState(mcpName: string): Promise<string | undefined> {
108-
const entry = await get(mcpName)
109-
return entry?.oauthState
110-
}
111-
112-
export async function clearOAuthState(mcpName: string): Promise<void> {
113-
const entry = await get(mcpName)
114-
if (entry) {
115-
delete entry.oauthState
116-
await set(mcpName, entry)
117-
}
118-
}
119-
120-
/**
121-
* Check if stored tokens are expired.
122-
* Returns null if no tokens exist, false if no expiry or not expired, true if expired.
123-
*/
124-
export async function isTokenExpired(mcpName: string): Promise<boolean | null> {
125-
const entry = await get(mcpName)
126-
if (!entry?.tokens) return null
127-
if (!entry.tokens.expiresAt) return false
128-
return entry.tokens.expiresAt < Date.now() / 1000
129-
}
52+
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/McpAuth") {}
53+
54+
export const layer = Layer.effect(
55+
Service,
56+
Effect.gen(function* () {
57+
const fs = yield* AppFileSystem.Service
58+
59+
const all = Effect.fn("McpAuth.all")(function* () {
60+
return yield* fs.readJson(filepath).pipe(
61+
Effect.map((data) => data as Record<string, Entry>),
62+
Effect.catch(() => Effect.succeed({} as Record<string, Entry>)),
63+
)
64+
})
65+
66+
const get = Effect.fn("McpAuth.get")(function* (mcpName: string) {
67+
const data = yield* all()
68+
return data[mcpName]
69+
})
70+
71+
const getForUrl = Effect.fn("McpAuth.getForUrl")(function* (mcpName: string, serverUrl: string) {
72+
const entry = yield* get(mcpName)
73+
if (!entry) return undefined
74+
if (!entry.serverUrl) return undefined
75+
if (entry.serverUrl !== serverUrl) return undefined
76+
return entry
77+
})
78+
79+
const set = Effect.fn("McpAuth.set")(function* (mcpName: string, entry: Entry, serverUrl?: string) {
80+
const data = yield* all()
81+
if (serverUrl) entry.serverUrl = serverUrl
82+
yield* fs.writeJson(filepath, { ...data, [mcpName]: entry }, 0o600).pipe(Effect.orDie)
83+
})
84+
85+
const remove = Effect.fn("McpAuth.remove")(function* (mcpName: string) {
86+
const data = yield* all()
87+
delete data[mcpName]
88+
yield* fs.writeJson(filepath, data, 0o600).pipe(Effect.orDie)
89+
})
90+
91+
const updateField = <K extends keyof Entry>(field: K, spanName: string) =>
92+
Effect.fn(`McpAuth.${spanName}`)(function* (mcpName: string, value: NonNullable<Entry[K]>, serverUrl?: string) {
93+
const entry = (yield* get(mcpName)) ?? {}
94+
entry[field] = value
95+
yield* set(mcpName, entry, serverUrl)
96+
})
97+
98+
const clearField = <K extends keyof Entry>(field: K, spanName: string) =>
99+
Effect.fn(`McpAuth.${spanName}`)(function* (mcpName: string) {
100+
const entry = yield* get(mcpName)
101+
if (entry) {
102+
delete entry[field]
103+
yield* set(mcpName, entry)
104+
}
105+
})
106+
107+
const updateTokens = updateField("tokens", "updateTokens")
108+
const updateClientInfo = updateField("clientInfo", "updateClientInfo")
109+
const updateCodeVerifier = updateField("codeVerifier", "updateCodeVerifier")
110+
const updateOAuthState = updateField("oauthState", "updateOAuthState")
111+
const clearCodeVerifier = clearField("codeVerifier", "clearCodeVerifier")
112+
const clearOAuthState = clearField("oauthState", "clearOAuthState")
113+
114+
const getOAuthState = Effect.fn("McpAuth.getOAuthState")(function* (mcpName: string) {
115+
const entry = yield* get(mcpName)
116+
return entry?.oauthState
117+
})
118+
119+
const isTokenExpired = Effect.fn("McpAuth.isTokenExpired")(function* (mcpName: string) {
120+
const entry = yield* get(mcpName)
121+
if (!entry?.tokens) return null
122+
if (!entry.tokens.expiresAt) return false
123+
return entry.tokens.expiresAt < Date.now() / 1000
124+
})
125+
126+
return Service.of({
127+
all,
128+
get,
129+
getForUrl,
130+
set,
131+
remove,
132+
updateTokens,
133+
updateClientInfo,
134+
updateCodeVerifier,
135+
clearCodeVerifier,
136+
updateOAuthState,
137+
getOAuthState,
138+
clearOAuthState,
139+
isTokenExpired,
140+
})
141+
}),
142+
)
143+
144+
const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
145+
146+
const runPromise = makeRunPromise(Service, defaultLayer)
147+
148+
// Async facades for backward compat (used by McpOAuthProvider, CLI)
149+
150+
export const get = async (mcpName: string) => runPromise((svc) => svc.get(mcpName))
151+
152+
export const getForUrl = async (mcpName: string, serverUrl: string) =>
153+
runPromise((svc) => svc.getForUrl(mcpName, serverUrl))
154+
155+
export const all = async () => runPromise((svc) => svc.all())
156+
157+
export const set = async (mcpName: string, entry: Entry, serverUrl?: string) =>
158+
runPromise((svc) => svc.set(mcpName, entry, serverUrl))
159+
160+
export const remove = async (mcpName: string) => runPromise((svc) => svc.remove(mcpName))
161+
162+
export const updateTokens = async (mcpName: string, tokens: Tokens, serverUrl?: string) =>
163+
runPromise((svc) => svc.updateTokens(mcpName, tokens, serverUrl))
164+
165+
export const updateClientInfo = async (mcpName: string, clientInfo: ClientInfo, serverUrl?: string) =>
166+
runPromise((svc) => svc.updateClientInfo(mcpName, clientInfo, serverUrl))
167+
168+
export const updateCodeVerifier = async (mcpName: string, codeVerifier: string) =>
169+
runPromise((svc) => svc.updateCodeVerifier(mcpName, codeVerifier))
170+
171+
export const clearCodeVerifier = async (mcpName: string) => runPromise((svc) => svc.clearCodeVerifier(mcpName))
172+
173+
export const updateOAuthState = async (mcpName: string, oauthState: string) =>
174+
runPromise((svc) => svc.updateOAuthState(mcpName, oauthState))
175+
176+
export const getOAuthState = async (mcpName: string) => runPromise((svc) => svc.getOAuthState(mcpName))
177+
178+
export const clearOAuthState = async (mcpName: string) => runPromise((svc) => svc.clearOAuthState(mcpName))
179+
180+
export const isTokenExpired = async (mcpName: string) => runPromise((svc) => svc.isTokenExpired(mcpName))
130181
}

0 commit comments

Comments
 (0)