Compare commits
2 commits
dev
...
kit/unwrap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6af06c06c5 | ||
|
|
43e2617e72 |
8 changed files with 281 additions and 283 deletions
|
|
@ -5,7 +5,7 @@ import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||||
import * as prompts from "@clack/prompts"
|
import * as prompts from "@clack/prompts"
|
||||||
import { UI } from "../ui"
|
import { UI } from "../ui"
|
||||||
import { MCP } from "../../mcp"
|
import { MCP } from "../../mcp"
|
||||||
import { McpAuth } from "../../mcp/auth"
|
import { McpAuth } from "@/mcp/auth"
|
||||||
import { McpOAuthProvider } from "../../mcp/oauth-provider"
|
import { McpOAuthProvider } from "../../mcp/oauth-provider"
|
||||||
import { Config } from "../../config"
|
import { Config } from "../../config"
|
||||||
import { Instance } from "../../project/instance"
|
import { Instance } from "../../project/instance"
|
||||||
|
|
|
||||||
|
|
@ -4,35 +4,34 @@ import { Global } from "../global"
|
||||||
import { Effect, Layer, Context } from "effect"
|
import { Effect, Layer, Context } from "effect"
|
||||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||||
|
|
||||||
export namespace McpAuth {
|
export const Tokens = z.object({
|
||||||
export const Tokens = z.object({
|
|
||||||
accessToken: z.string(),
|
accessToken: z.string(),
|
||||||
refreshToken: z.string().optional(),
|
refreshToken: z.string().optional(),
|
||||||
expiresAt: z.number().optional(),
|
expiresAt: z.number().optional(),
|
||||||
scope: z.string().optional(),
|
scope: z.string().optional(),
|
||||||
})
|
})
|
||||||
export type Tokens = z.infer<typeof Tokens>
|
export type Tokens = z.infer<typeof Tokens>
|
||||||
|
|
||||||
export const ClientInfo = z.object({
|
export const ClientInfo = z.object({
|
||||||
clientId: z.string(),
|
clientId: z.string(),
|
||||||
clientSecret: z.string().optional(),
|
clientSecret: z.string().optional(),
|
||||||
clientIdIssuedAt: z.number().optional(),
|
clientIdIssuedAt: z.number().optional(),
|
||||||
clientSecretExpiresAt: z.number().optional(),
|
clientSecretExpiresAt: z.number().optional(),
|
||||||
})
|
})
|
||||||
export type ClientInfo = z.infer<typeof ClientInfo>
|
export type ClientInfo = z.infer<typeof ClientInfo>
|
||||||
|
|
||||||
export const Entry = z.object({
|
export const Entry = z.object({
|
||||||
tokens: Tokens.optional(),
|
tokens: Tokens.optional(),
|
||||||
clientInfo: ClientInfo.optional(),
|
clientInfo: ClientInfo.optional(),
|
||||||
codeVerifier: z.string().optional(),
|
codeVerifier: z.string().optional(),
|
||||||
oauthState: z.string().optional(),
|
oauthState: z.string().optional(),
|
||||||
serverUrl: z.string().optional(),
|
serverUrl: z.string().optional(),
|
||||||
})
|
})
|
||||||
export type Entry = z.infer<typeof Entry>
|
export type Entry = z.infer<typeof Entry>
|
||||||
|
|
||||||
const filepath = path.join(Global.Path.data, "mcp-auth.json")
|
const filepath = path.join(Global.Path.data, "mcp-auth.json")
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly all: () => Effect.Effect<Record<string, Entry>>
|
readonly all: () => Effect.Effect<Record<string, Entry>>
|
||||||
readonly get: (mcpName: string) => Effect.Effect<Entry | undefined>
|
readonly get: (mcpName: string) => Effect.Effect<Entry | undefined>
|
||||||
readonly getForUrl: (mcpName: string, serverUrl: string) => Effect.Effect<Entry | undefined>
|
readonly getForUrl: (mcpName: string, serverUrl: string) => Effect.Effect<Entry | undefined>
|
||||||
|
|
@ -46,11 +45,11 @@ export namespace McpAuth {
|
||||||
readonly getOAuthState: (mcpName: string) => Effect.Effect<string | undefined>
|
readonly getOAuthState: (mcpName: string) => Effect.Effect<string | undefined>
|
||||||
readonly clearOAuthState: (mcpName: string) => Effect.Effect<void>
|
readonly clearOAuthState: (mcpName: string) => Effect.Effect<void>
|
||||||
readonly isTokenExpired: (mcpName: string) => Effect.Effect<boolean | null>
|
readonly isTokenExpired: (mcpName: string) => Effect.Effect<boolean | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/McpAuth") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/McpAuth") {}
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* AppFileSystem.Service
|
||||||
|
|
@ -138,7 +137,7 @@ export namespace McpAuth {
|
||||||
isTokenExpired,
|
isTokenExpired,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||||
}
|
export * as McpAuth from "./auth"
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,8 @@ import { InstallationVersion } from "../installation/version"
|
||||||
import { withTimeout } from "@/util/timeout"
|
import { withTimeout } from "@/util/timeout"
|
||||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||||
import { McpOAuthProvider } from "./oauth-provider"
|
import { McpOAuthProvider } from "./oauth-provider"
|
||||||
import { McpOAuthCallback } from "./oauth-callback"
|
import * as McpOAuthCallback from "./oauth-callback"
|
||||||
import { McpAuth } from "./auth"
|
import * as McpAuth from "./auth"
|
||||||
import { BusEvent } from "../bus/bus-event"
|
import { BusEvent } from "../bus/bus-event"
|
||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
import { TuiEvent } from "@/cli/cmd/tui/event"
|
import { TuiEvent } from "@/cli/cmd/tui/event"
|
||||||
|
|
|
||||||
|
|
@ -56,25 +56,24 @@ interface PendingAuth {
|
||||||
timeout: ReturnType<typeof setTimeout>
|
timeout: ReturnType<typeof setTimeout>
|
||||||
}
|
}
|
||||||
|
|
||||||
export namespace McpOAuthCallback {
|
let server: ReturnType<typeof createServer> | undefined
|
||||||
let server: ReturnType<typeof createServer> | undefined
|
const pendingAuths = new Map<string, PendingAuth>()
|
||||||
const pendingAuths = new Map<string, PendingAuth>()
|
// Reverse index: mcpName → oauthState, so cancelPending(mcpName) can
|
||||||
// Reverse index: mcpName → oauthState, so cancelPending(mcpName) can
|
// find the right entry in pendingAuths (which is keyed by oauthState).
|
||||||
// find the right entry in pendingAuths (which is keyed by oauthState).
|
const mcpNameToState = new Map<string, string>()
|
||||||
const mcpNameToState = new Map<string, string>()
|
|
||||||
|
|
||||||
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
|
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
|
||||||
|
|
||||||
function cleanupStateIndex(oauthState: string) {
|
function cleanupStateIndex(oauthState: string) {
|
||||||
for (const [name, state] of mcpNameToState) {
|
for (const [name, state] of mcpNameToState) {
|
||||||
if (state === oauthState) {
|
if (state === oauthState) {
|
||||||
mcpNameToState.delete(name)
|
mcpNameToState.delete(name)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) {
|
function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) {
|
||||||
const url = new URL(req.url || "/", `http://localhost:${currentPort}`)
|
const url = new URL(req.url || "/", `http://localhost:${currentPort}`)
|
||||||
|
|
||||||
if (url.pathname !== currentPath) {
|
if (url.pathname !== currentPath) {
|
||||||
|
|
@ -137,9 +136,9 @@ export namespace McpOAuthCallback {
|
||||||
|
|
||||||
res.writeHead(200, { "Content-Type": "text/html" })
|
res.writeHead(200, { "Content-Type": "text/html" })
|
||||||
res.end(HTML_SUCCESS)
|
res.end(HTML_SUCCESS)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureRunning(redirectUri?: string): Promise<void> {
|
export async function ensureRunning(redirectUri?: string): Promise<void> {
|
||||||
// Parse the redirect URI to get port and path (uses defaults if not provided)
|
// Parse the redirect URI to get port and path (uses defaults if not provided)
|
||||||
const { port, path } = parseRedirectUri(redirectUri)
|
const { port, path } = parseRedirectUri(redirectUri)
|
||||||
|
|
||||||
|
|
@ -168,9 +167,9 @@ export namespace McpOAuthCallback {
|
||||||
})
|
})
|
||||||
server!.on("error", reject)
|
server!.on("error", reject)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function waitForCallback(oauthState: string, mcpName?: string): Promise<string> {
|
export function waitForCallback(oauthState: string, mcpName?: string): Promise<string> {
|
||||||
if (mcpName) mcpNameToState.set(mcpName, oauthState)
|
if (mcpName) mcpNameToState.set(mcpName, oauthState)
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
|
|
@ -183,9 +182,9 @@ export namespace McpOAuthCallback {
|
||||||
|
|
||||||
pendingAuths.set(oauthState, { resolve, reject, timeout })
|
pendingAuths.set(oauthState, { resolve, reject, timeout })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cancelPending(mcpName: string): void {
|
export function cancelPending(mcpName: string): void {
|
||||||
// Look up the oauthState for this mcpName via the reverse index
|
// Look up the oauthState for this mcpName via the reverse index
|
||||||
const oauthState = mcpNameToState.get(mcpName)
|
const oauthState = mcpNameToState.get(mcpName)
|
||||||
const key = oauthState ?? mcpName
|
const key = oauthState ?? mcpName
|
||||||
|
|
@ -196,9 +195,9 @@ export namespace McpOAuthCallback {
|
||||||
mcpNameToState.delete(mcpName)
|
mcpNameToState.delete(mcpName)
|
||||||
pending.reject(new Error("Authorization cancelled"))
|
pending.reject(new Error("Authorization cancelled"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function isPortInUse(port: number = OAUTH_CALLBACK_PORT): Promise<boolean> {
|
export async function isPortInUse(port: number = OAUTH_CALLBACK_PORT): Promise<boolean> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const socket = createConnection(port, "127.0.0.1")
|
const socket = createConnection(port, "127.0.0.1")
|
||||||
socket.on("connect", () => {
|
socket.on("connect", () => {
|
||||||
|
|
@ -209,9 +208,9 @@ export namespace McpOAuthCallback {
|
||||||
resolve(false)
|
resolve(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function stop(): Promise<void> {
|
export async function stop(): Promise<void> {
|
||||||
if (server) {
|
if (server) {
|
||||||
await new Promise<void>((resolve) => server!.close(() => resolve()))
|
await new Promise<void>((resolve) => server!.close(() => resolve()))
|
||||||
server = undefined
|
server = undefined
|
||||||
|
|
@ -224,9 +223,9 @@ export namespace McpOAuthCallback {
|
||||||
}
|
}
|
||||||
pendingAuths.clear()
|
pendingAuths.clear()
|
||||||
mcpNameToState.clear()
|
mcpNameToState.clear()
|
||||||
}
|
|
||||||
|
|
||||||
export function isRunning(): boolean {
|
|
||||||
return server !== undefined
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isRunning(): boolean {
|
||||||
|
return server !== undefined
|
||||||
|
}
|
||||||
|
export * as McpOAuthCallback from "./oauth-callback"
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import type {
|
||||||
OAuthClientInformationFull,
|
OAuthClientInformationFull,
|
||||||
} from "@modelcontextprotocol/sdk/shared/auth.js"
|
} from "@modelcontextprotocol/sdk/shared/auth.js"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { McpAuth } from "./auth"
|
import * as McpAuth from "./auth"
|
||||||
import { Log } from "../util"
|
import { Log } from "../util"
|
||||||
|
|
||||||
const log = Log.create({ service: "mcp.oauth" })
|
const log = Log.create({ service: "mcp.oauth" })
|
||||||
|
|
|
||||||
|
|
@ -641,7 +641,7 @@ test(
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
test("McpOAuthCallback.cancelPending is keyed by mcpName but pendingAuths uses oauthState", async () => {
|
test("McpOAuthCallback.cancelPending is keyed by mcpName but pendingAuths uses oauthState", async () => {
|
||||||
const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback")
|
const McpOAuthCallback = await import("../../src/mcp/oauth-callback")
|
||||||
|
|
||||||
// Register a pending auth with an oauthState key, associated to an mcpName
|
// Register a pending auth with an oauthState key, associated to an mcpName
|
||||||
const oauthState = "abc123hexstate"
|
const oauthState = "abc123hexstate"
|
||||||
|
|
|
||||||
|
|
@ -158,7 +158,7 @@ test("first connect to OAuth server shows needs_auth instead of failed", async (
|
||||||
|
|
||||||
test("state() generates a new state when none is saved", async () => {
|
test("state() generates a new state when none is saved", async () => {
|
||||||
const { McpOAuthProvider } = await import("../../src/mcp/oauth-provider")
|
const { McpOAuthProvider } = await import("../../src/mcp/oauth-provider")
|
||||||
const { McpAuth } = await import("../../src/mcp/auth")
|
const McpAuth = await import("../../src/mcp/auth")
|
||||||
|
|
||||||
await using tmp = await tmpdir()
|
await using tmp = await tmpdir()
|
||||||
|
|
||||||
|
|
@ -199,7 +199,7 @@ test("state() generates a new state when none is saved", async () => {
|
||||||
|
|
||||||
test("state() returns existing state when one is saved", async () => {
|
test("state() returns existing state when one is saved", async () => {
|
||||||
const { McpOAuthProvider } = await import("../../src/mcp/oauth-provider")
|
const { McpOAuthProvider } = await import("../../src/mcp/oauth-provider")
|
||||||
const { McpAuth } = await import("../../src/mcp/auth")
|
const McpAuth = await import("../../src/mcp/auth")
|
||||||
|
|
||||||
await using tmp = await tmpdir()
|
await using tmp = await tmpdir()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,7 @@ beforeEach(() => {
|
||||||
const { MCP } = await import("../../src/mcp/index")
|
const { MCP } = await import("../../src/mcp/index")
|
||||||
const { AppRuntime } = await import("../../src/effect/app-runtime")
|
const { AppRuntime } = await import("../../src/effect/app-runtime")
|
||||||
const { Bus } = await import("../../src/bus")
|
const { Bus } = await import("../../src/bus")
|
||||||
const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback")
|
const McpOAuthCallback = await import("../../src/mcp/oauth-callback")
|
||||||
const { Instance } = await import("../../src/project/instance")
|
const { Instance } = await import("../../src/project/instance")
|
||||||
const { tmpdir } = await import("../fixture/fixture")
|
const { tmpdir } = await import("../fixture/fixture")
|
||||||
const service = MCP.Service as unknown as Effect.Effect<MCPNS.Interface, never, never>
|
const service = MCP.Service as unknown as Effect.Effect<MCPNS.Interface, never, never>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue