Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions src/services/mcp/McpOAuthClientProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,11 +243,12 @@ export class McpOAuthClientProvider implements OAuthClientProvider {

private async _doStartCallbackServer(): Promise<void> {
this._closed = false
const { port: configuredPort, host } = getCallbackAddress(this._configuredRedirectUri)
const { port: configuredPort, host, path } = getCallbackAddress(this._configuredRedirectUri)
const { server, port, result, cancel } = await startCallbackServer(
configuredPort ?? this._port,
this._state,
host,
path,
)
this._server = server
this._port = port
Expand Down Expand Up @@ -670,17 +671,22 @@ function resolveResourceIndicator(configured: McpOAuthResource | undefined, disc
return discovered ?? null
}

function getCallbackAddress(redirectUri?: string): { port?: number; host: "localhost" | "127.0.0.1" } {
if (!redirectUri) return { host: "127.0.0.1" }
function getCallbackAddress(redirectUri?: string): {
port?: number
host: "localhost" | "127.0.0.1"
path: string
} {
if (!redirectUri) return { host: "127.0.0.1", path: "/callback" }
const url = new URL(redirectUri)
if (url.hostname !== "localhost" && url.hostname !== "127.0.0.1") {
throw new Error("MCP OAuth redirectUri must use a loopback host")
}
if (url.protocol !== "http:") {
throw new Error("MCP OAuth redirectUri must use HTTP on a loopback host")
}
if (url.pathname !== "/callback") {
throw new Error("MCP OAuth redirectUri path must be /callback")
// Authorization servers with pre-registered clients may require a specific path.
if (url.pathname === "/") {
throw new Error("MCP OAuth redirectUri must specify a callback path")
}
if (url.username || url.password || url.search || url.hash) {
throw new Error("MCP OAuth redirectUri must not contain credentials, a query, or a fragment")
Expand All @@ -689,5 +695,5 @@ function getCallbackAddress(redirectUri?: string): { port?: number; host: "local
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error("MCP OAuth redirectUri must specify a valid port")
}
return { port, host: url.hostname }
return { port, host: url.hostname, path: url.pathname }
}
33 changes: 28 additions & 5 deletions src/services/mcp/__tests__/McpOAuthClientProvider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,16 +231,39 @@ describe("McpOAuthClientProvider", () => {
await provider.openBrowser()
const openedUri = vi.mocked(vscode.env.openExternal).mock.calls[0][0].toString()
expect(new URL(openedUri).searchParams.get("scope")).toBe("openid profile offline_access")
expect(startCallbackServer).toHaveBeenCalledWith(33418, expect.any(String), callbackHost)
expect(startCallbackServer).toHaveBeenCalledWith(33418, expect.any(String), callbackHost, "/callback")
await provider.close()
},
)

it("should listen on the configured callback path for a static client", async () => {
setupCallbackServerMock()
const redirectUri = "http://127.0.0.1:19876/mcp/oauth/callback"
const provider = await McpOAuthClientProvider.create(
"https://example.com/mcp",
createMockSecretStorage(),
undefined,
{ clientId: "registered-client", redirectUri },
)

expect(provider.redirectUrl).toBe(redirectUri)
expect(await provider.clientInformation()).toMatchObject({ redirect_uris: [redirectUri] })

await provider.redirectToAuthorization(new URL("https://auth.example.com/authorize"))
expect(startCallbackServer).toHaveBeenCalledWith(
19876,
expect.any(String),
"127.0.0.1",
"/mcp/oauth/callback",
)
await provider.close()
})

it.each([
["https://example.com:33418/callback", "must use a loopback host"],
["http://example.com:33418/callback", "must use a loopback host"],
["https://localhost:33418/callback", "must use HTTP on a loopback host"],
["http://localhost:33418/wrong", "path must be /callback"],
["http://localhost:33418/", "must specify a callback path"],
["http://localhost:33418/callback?x=1", "must not contain credentials, a query, or a fragment"],
["http://localhost:33418/callback#fragment", "must not contain credentials, a query, or a fragment"],
["http://u:p@localhost:33418/callback", "must not contain credentials, a query, or a fragment"],
Expand Down Expand Up @@ -563,7 +586,7 @@ describe("McpOAuthClientProvider", () => {
const secretStorage = createMockSecretStorage()
const serverUrl = "https://example.com/mcp"
const clientId = "registered-client"
const discoveredResource = "https://discovered.example.com/"
const discoveredResource = "https://example.com/mcp"
await secretStorage.saveOAuthData(
serverUrl,
{
Expand Down Expand Up @@ -614,7 +637,7 @@ describe("McpOAuthClientProvider", () => {
},
clientId,
)
mockOAuthDiscovery("https://discovered.example.com/")
mockOAuthDiscovery("https://example.com/mcp")
const provider = await McpOAuthClientProvider.create(serverUrl, secretStorage, undefined, {
clientId,
resource: explicitResource,
Expand Down Expand Up @@ -1398,7 +1421,7 @@ describe("McpOAuthClientProvider", () => {
// reads redirect_uris, so the advertised URI carries the real port and never :0.
expect(startCallbackServer).toHaveBeenCalledTimes(1)
// Port 0 asks the OS for an ephemeral port; the mock binds it to 12345.
expect(startCallbackServer).toHaveBeenCalledWith(0, expect.any(String), "127.0.0.1")
expect(startCallbackServer).toHaveBeenCalledWith(0, expect.any(String), "127.0.0.1", "/callback")
expect(provider.redirectUrl).toBe("http://127.0.0.1:12345/callback")
expect(provider.redirectUrl).not.toContain(":0/")
expect(provider.clientMetadata.redirect_uris).toEqual(["http://127.0.0.1:12345/callback"])
Expand Down
17 changes: 17 additions & 0 deletions src/services/mcp/utils/__tests__/callbackServer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,23 @@ describe("startCallbackServer", () => {
await cancellation
})

it("should resolve only on the configured callback path", async () => {
const mockServer = createMockServer()

vi.mocked(http.createServer).mockReturnValue(mockServer as unknown as http.Server)

const { result } = await startCallbackServer(19876, undefined, "127.0.0.1", "/mcp/oauth/callback")
const requestCall = mockServer.on.mock.calls.find((call) => call[0] === "request")
const requestHandler = requestCall ? requestCall[1] : vi.fn()

const defaultPathRes = createMockResponse()
requestHandler({ url: "/callback?code=wrong-path", method: "GET" }, defaultPathRes)
expect(defaultPathRes.writeHead).toHaveBeenCalledWith(404)

requestHandler({ url: "/mcp/oauth/callback?code=test-code", method: "GET" }, createMockResponse())
expect((await result).code).toBe("test-code")
})

it("should reject invalid state", async () => {
const mockServer = createMockServer()

Expand Down
Loading
Loading