Compare commits
2 commits
dev
...
verify-mcp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b7a0b1a80 | ||
|
|
5ad089fc01 |
4 changed files with 135 additions and 5 deletions
|
|
@ -808,9 +808,6 @@ export const layer = Layer.effect(
|
||||||
oauthConfig?.redirectUri ??
|
oauthConfig?.redirectUri ??
|
||||||
(oauthConfig?.callbackPort ? `http://127.0.0.1:${oauthConfig.callbackPort}${OAUTH_CALLBACK_PATH}` : undefined)
|
(oauthConfig?.callbackPort ? `http://127.0.0.1:${oauthConfig.callbackPort}${OAUTH_CALLBACK_PATH}` : undefined)
|
||||||
|
|
||||||
// Start the callback server with custom redirectUri if configured
|
|
||||||
yield* Effect.promise(() => McpOAuthCallback.ensureRunning(effectiveRedirectUri))
|
|
||||||
|
|
||||||
const oauthState = Array.from(crypto.getRandomValues(new Uint8Array(32)))
|
const oauthState = Array.from(crypto.getRandomValues(new Uint8Array(32)))
|
||||||
.map((b) => b.toString(16).padStart(2, "0"))
|
.map((b) => b.toString(16).padStart(2, "0"))
|
||||||
.join("")
|
.join("")
|
||||||
|
|
@ -827,6 +824,7 @@ export const layer = Layer.effect(
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onRedirect: async (url) => {
|
onRedirect: async (url) => {
|
||||||
|
await McpOAuthCallback.ensureRunning(effectiveRedirectUri)
|
||||||
capturedUrl = url
|
capturedUrl = url
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -859,6 +857,7 @@ export const layer = Layer.effect(
|
||||||
})
|
})
|
||||||
|
|
||||||
const authenticate = Effect.fn("MCP.authenticate")(function* (mcpName: string) {
|
const authenticate = Effect.fn("MCP.authenticate")(function* (mcpName: string) {
|
||||||
|
const previousTokens = (yield* auth.get(mcpName))?.tokens
|
||||||
const result = yield* startAuth(mcpName)
|
const result = yield* startAuth(mcpName)
|
||||||
if (!result.authorizationUrl) {
|
if (!result.authorizationUrl) {
|
||||||
const client = "client" in result ? result.client : undefined
|
const client = "client" in result ? result.client : undefined
|
||||||
|
|
@ -876,6 +875,17 @@ export const layer = Layer.effect(
|
||||||
return { status: "failed", error: "Failed to get tools" } satisfies Status
|
return { status: "failed", error: "Failed to get tools" } satisfies Status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentTokens = (yield* auth.get(mcpName))?.tokens
|
||||||
|
if (!currentTokens || JSON.stringify(currentTokens) === JSON.stringify(previousTokens)) {
|
||||||
|
yield* Effect.tryPromise(() => client.close()).pipe(Effect.ignore)
|
||||||
|
yield* auth.clearOAuthState(mcpName)
|
||||||
|
return {
|
||||||
|
status: "failed",
|
||||||
|
error:
|
||||||
|
"The server did not issue a standard OAuth challenge. Anonymous MCP access remains available, but authentication was not completed. Verify the server's OAuth configuration or use credentials supported by the server.",
|
||||||
|
} satisfies Status
|
||||||
|
}
|
||||||
|
|
||||||
const s = yield* InstanceState.get(state)
|
const s = yield* InstanceState.get(state)
|
||||||
yield* auth.clearOAuthState(mcpName)
|
yield* auth.clearOAuthState(mcpName)
|
||||||
return yield* storeClient(s, mcpName, client, listed, client.getInstructions()?.trim(), mcpConfig.timeout)
|
return yield* storeClient(s, mcpName, client, listed, client.getInstructions()?.trim(), mcpConfig.timeout)
|
||||||
|
|
|
||||||
81
packages/opencode/test/fixture/mcp-oauth-anonymous.ts
Normal file
81
packages/opencode/test/fixture/mcp-oauth-anonymous.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { LATEST_PROTOCOL_VERSION } from "@modelcontextprotocol/sdk/types.js"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { MCP } from "../../src/mcp/index"
|
||||||
|
import { withTmpdirInstance } from "./fixture"
|
||||||
|
|
||||||
|
const server = Bun.serve({
|
||||||
|
hostname: "127.0.0.1",
|
||||||
|
port: 0,
|
||||||
|
async fetch(request): Promise<Response> {
|
||||||
|
if (request.method !== "POST") return new Response(null, { status: 405 })
|
||||||
|
|
||||||
|
const message = (await request.json()) as { id?: number; method: string }
|
||||||
|
if (message.method === "initialize") {
|
||||||
|
return Response.json({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: message.id,
|
||||||
|
result: {
|
||||||
|
protocolVersion: LATEST_PROTOCOL_VERSION,
|
||||||
|
capabilities: { tools: {} },
|
||||||
|
serverInfo: { name: "anonymous-oauth-test", version: "1" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (message.method === "notifications/initialized") return new Response(null, { status: 202 })
|
||||||
|
if (message.method === "tools/list") {
|
||||||
|
return Response.json({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: message.id,
|
||||||
|
result: {
|
||||||
|
tools: [{ name: "protected", inputSchema: { type: "object", properties: {} } }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (message.method === "tools/call") {
|
||||||
|
return new Response("Authentication required", {
|
||||||
|
status: 401,
|
||||||
|
headers: {
|
||||||
|
"WWW-Authenticate": `Bearer resource_metadata="${new URL("/.well-known/oauth-protected-resource", request.url)}"`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return Response.json({ jsonrpc: "2.0", id: message.id, error: { code: -32601, message: "Method not found" } })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await Effect.gen(function* () {
|
||||||
|
const mcp = yield* MCP.Service
|
||||||
|
const added = yield* mcp.add("anonymous-oauth", { type: "remote", url: server.url.toString() })
|
||||||
|
const initialTools = Object.keys(yield* mcp.tools())
|
||||||
|
const client = (yield* mcp.clients())["anonymous-oauth"]
|
||||||
|
const protectedToolFailed = yield* Effect.promise(() =>
|
||||||
|
client
|
||||||
|
.callTool({ name: "protected", arguments: {} })
|
||||||
|
.then(() => false)
|
||||||
|
.catch(() => true),
|
||||||
|
)
|
||||||
|
const auth = yield* mcp.authenticate("anonymous-oauth")
|
||||||
|
|
||||||
|
return {
|
||||||
|
initialStatus: "status" in added.status ? added.status.status : added.status["anonymous-oauth"]?.status,
|
||||||
|
initialTools,
|
||||||
|
protectedToolFailed,
|
||||||
|
authStatus: auth.status,
|
||||||
|
authError: auth.status === "failed" ? auth.error : undefined,
|
||||||
|
hasStoredTokens: yield* mcp.hasStoredTokens("anonymous-oauth"),
|
||||||
|
finalStatus: (yield* mcp.status())["anonymous-oauth"]?.status,
|
||||||
|
finalTools: Object.keys(yield* mcp.tools()),
|
||||||
|
}
|
||||||
|
}).pipe(
|
||||||
|
withTmpdirInstance({
|
||||||
|
config: { mcp: { "anonymous-oauth": { type: "remote", url: server.url.toString() } } },
|
||||||
|
}),
|
||||||
|
Effect.provide(MCP.defaultLayer),
|
||||||
|
Effect.scoped,
|
||||||
|
Effect.runPromise,
|
||||||
|
)
|
||||||
|
process.stdout.write(`MCP_OAUTH_RESULT=${JSON.stringify(result)}`)
|
||||||
|
} finally {
|
||||||
|
server.stop(true)
|
||||||
|
}
|
||||||
30
packages/opencode/test/mcp/oauth-anonymous.test.ts
Normal file
30
packages/opencode/test/mcp/oauth-anonymous.test.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import path from "node:path"
|
||||||
|
import { expect, test } from "bun:test"
|
||||||
|
|
||||||
|
test("explicit auth fails when anonymous initialize and catalog emit no OAuth challenge", async () => {
|
||||||
|
const child = Bun.spawn([process.execPath, path.join(import.meta.dir, "../fixture/mcp-oauth-anonymous.ts")], {
|
||||||
|
cwd: path.join(import.meta.dir, "../.."),
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
})
|
||||||
|
const [code, stdout, stderr] = await Promise.all([
|
||||||
|
child.exited,
|
||||||
|
Bun.readableStreamToText(child.stdout),
|
||||||
|
Bun.readableStreamToText(child.stderr),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(code, stderr).toBe(0)
|
||||||
|
const marker = "MCP_OAUTH_RESULT="
|
||||||
|
expect(stdout).toContain(marker)
|
||||||
|
expect(JSON.parse(stdout.slice(stdout.lastIndexOf(marker) + marker.length))).toEqual({
|
||||||
|
initialStatus: "connected",
|
||||||
|
initialTools: ["anonymous-oauth_protected"],
|
||||||
|
protectedToolFailed: true,
|
||||||
|
authStatus: "failed",
|
||||||
|
authError:
|
||||||
|
"The server did not issue a standard OAuth challenge. Anonymous MCP access remains available, but authentication was not completed. Verify the server's OAuth configuration or use credentials supported by the server.",
|
||||||
|
hasStoredTokens: false,
|
||||||
|
finalStatus: "connected",
|
||||||
|
finalTools: ["anonymous-oauth_protected"],
|
||||||
|
})
|
||||||
|
}, 30_000)
|
||||||
|
|
@ -21,6 +21,7 @@ const transportCalls: Array<{
|
||||||
// auth flow (which calls provider.state()) or a simple UnauthorizedError.
|
// auth flow (which calls provider.state()) or a simple UnauthorizedError.
|
||||||
let simulateAuthFlow = true
|
let simulateAuthFlow = true
|
||||||
let connectSucceedsImmediately = false
|
let connectSucceedsImmediately = false
|
||||||
|
let saveTokensOnConnect = false
|
||||||
let serverCapabilities: { tools?: object; resources?: object } = { tools: {} }
|
let serverCapabilities: { tools?: object; resources?: object } = { tools: {} }
|
||||||
let listToolsCalls = 0
|
let listToolsCalls = 0
|
||||||
|
|
||||||
|
|
@ -32,6 +33,7 @@ void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
||||||
state?: () => Promise<string>
|
state?: () => Promise<string>
|
||||||
redirectToAuthorization?: (url: URL) => Promise<void>
|
redirectToAuthorization?: (url: URL) => Promise<void>
|
||||||
saveCodeVerifier?: (v: string) => Promise<void>
|
saveCodeVerifier?: (v: string) => Promise<void>
|
||||||
|
saveTokens?: (tokens: { access_token: string; token_type: string }) => Promise<void>
|
||||||
}
|
}
|
||||||
| undefined
|
| undefined
|
||||||
constructor(url: URL, options?: { authProvider?: unknown }) {
|
constructor(url: URL, options?: { authProvider?: unknown }) {
|
||||||
|
|
@ -43,7 +45,11 @@ void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
async start() {
|
async start() {
|
||||||
if (connectSucceedsImmediately) return
|
if (connectSucceedsImmediately) {
|
||||||
|
if (saveTokensOnConnect)
|
||||||
|
await this.authProvider?.saveTokens?.({ access_token: "new-token", token_type: "bearer" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Simulate what the real SDK transport does on 401:
|
// Simulate what the real SDK transport does on 401:
|
||||||
// It calls auth() which eventually calls provider.state(), then
|
// It calls auth() which eventually calls provider.state(), then
|
||||||
|
|
@ -123,6 +129,7 @@ beforeEach(() => {
|
||||||
transportCalls.length = 0
|
transportCalls.length = 0
|
||||||
simulateAuthFlow = true
|
simulateAuthFlow = true
|
||||||
connectSucceedsImmediately = false
|
connectSucceedsImmediately = false
|
||||||
|
saveTokensOnConnect = false
|
||||||
serverCapabilities = { tools: {} }
|
serverCapabilities = { tools: {} }
|
||||||
listToolsCalls = 0
|
listToolsCalls = 0
|
||||||
})
|
})
|
||||||
|
|
@ -228,7 +235,7 @@ mcpTest.instance("state() returns existing state when one is saved", () =>
|
||||||
)
|
)
|
||||||
|
|
||||||
mcpTest.instance(
|
mcpTest.instance(
|
||||||
"authenticate() stores a connected client when auth completes without redirect",
|
"authenticate() stores a connected client when stored credentials connect without redirect",
|
||||||
() =>
|
() =>
|
||||||
MCP.Service.use((mcp) =>
|
MCP.Service.use((mcp) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|
@ -241,6 +248,7 @@ mcpTest.instance(
|
||||||
|
|
||||||
simulateAuthFlow = false
|
simulateAuthFlow = false
|
||||||
connectSucceedsImmediately = true
|
connectSucceedsImmediately = true
|
||||||
|
saveTokensOnConnect = true
|
||||||
|
|
||||||
const result = yield* mcp.authenticate("test-oauth-connect")
|
const result = yield* mcp.authenticate("test-oauth-connect")
|
||||||
expect(result.status).toBe("connected")
|
expect(result.status).toBe("connected")
|
||||||
|
|
@ -266,6 +274,7 @@ mcpTest.instance(
|
||||||
|
|
||||||
simulateAuthFlow = false
|
simulateAuthFlow = false
|
||||||
connectSucceedsImmediately = true
|
connectSucceedsImmediately = true
|
||||||
|
saveTokensOnConnect = true
|
||||||
serverCapabilities = { resources: {} }
|
serverCapabilities = { resources: {} }
|
||||||
|
|
||||||
const result = yield* mcp.authenticate("test-oauth-resources")
|
const result = yield* mcp.authenticate("test-oauth-resources")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue