From dfb264811c1110cd5c4c576b3f1a5f153525abd2 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 1 Sep 2026 18:38:39 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=AA=A2=20test:=20Prove=20MCP=20OAuth=20Re?= =?UTF-8?q?sume=20End=20to=20End=20(#15485)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/config/librechat.e2e.yaml | 13 +++ e2e/playwright.config.mock.ts | 16 +++ e2e/setup/fake-mcp-oauth-server.js | 50 +++++++++ e2e/specs/mock/mcp-oauth-resume.spec.ts | 132 ++++++++++++++++++++++++ packages/api/src/mcp/oauth/resume.ts | 9 +- 5 files changed, 217 insertions(+), 3 deletions(-) create mode 100644 e2e/setup/fake-mcp-oauth-server.js create mode 100644 e2e/specs/mock/mcp-oauth-resume.spec.ts diff --git a/e2e/config/librechat.e2e.yaml b/e2e/config/librechat.e2e.yaml index c654f49050..5052c0104b 100644 --- a/e2e/config/librechat.e2e.yaml +++ b/e2e/config/librechat.e2e.yaml @@ -35,6 +35,7 @@ mcpSettings: # admin override is honored by inspection/connection. stdio servers skip this check. allowedDomains: - https://allowed.example.com + - http://127.0.0.1:8767 # __E2E_DYNAMIC_MCP_ALLOWED_DOMAIN__ actions: @@ -57,6 +58,18 @@ mcpServers: title: E2E HTTP description: Local HTTP MCP fixture for allowlist-override e2e tests. timeout: 30000 + e2e-oauth: + type: streamable-http + url: http://127.0.0.1:8767/mcp + startup: false + title: E2E OAuth + description: Protected MCP fixture used to verify resumable OAuth prompts. + timeout: 30000 + requiresOAuth: true + oauth: + authorization_url: http://127.0.0.1:8767/authorize + token_url: http://127.0.0.1:8767/token + client_id: e2e-oauth-client # __E2E_DYNAMIC_MCP_NETWORK_SERVERS__ endpoints: diff --git a/e2e/playwright.config.mock.ts b/e2e/playwright.config.mock.ts index 0354cc7a69..f1d71cce92 100644 --- a/e2e/playwright.config.mock.ts +++ b/e2e/playwright.config.mock.ts @@ -13,9 +13,12 @@ const serverPath = path.resolve( replicaCount === 2 ? 'e2e/setup/start-server-cluster.js' : 'e2e/setup/start-server.js', ); const mcpHttpServerPath = path.resolve(rootPath, 'e2e/setup/fake-mcp-http-server.js'); +const mcpOAuthServerPath = path.resolve(rootPath, 'e2e/setup/fake-mcp-oauth-server.js'); const dynamicMcpServerPath = path.resolve(rootPath, 'e2e/setup/fake-mcp-dynamic-network-server.js'); /** Must match the `e2e-http` server URL in e2e/config/librechat.e2e.yaml. */ const MCP_HTTP_PORT = process.env.E2E_MCP_HTTP_PORT || '8765'; +/** Must match the protected OAuth MCP fixture in e2e/config/librechat.e2e.yaml. */ +const MCP_OAUTH_PORT = process.env.E2E_MCP_OAUTH_PORT || '8767'; /** Must match the dynamic Streamable HTTP and SSE URLs in the e2e config template. */ const MCP_DYNAMIC_PORT = process.env.E2E_MCP_DYNAMIC_PORT || '8766'; const MCP_STATE_PATH = @@ -248,6 +251,9 @@ function writeRuntimeMockConfig() { if (enableDynamicMcp && MCP_DYNAMIC_PORT !== '8766') { config = config.split('127.0.0.1:8766').join(`127.0.0.1:${MCP_DYNAMIC_PORT}`); } + if (MCP_OAUTH_PORT !== '8767') { + config = config.split('127.0.0.1:8767').join(`127.0.0.1:${MCP_OAUTH_PORT}`); + } fs.mkdirSync(path.dirname(configPath), { recursive: true }); fs.writeFileSync(configPath, config); if (enableDynamicMcp) { @@ -340,6 +346,16 @@ export default defineConfig({ timeout: 60_000, reuseExistingServer: false, }, + { + // Protected resource whose OAuth flow intentionally remains pending across navigation. + command: `node ${mcpOAuthServerPath}`, + cwd: rootPath, + env: { ...process.env, E2E_MCP_OAUTH_PORT: MCP_OAUTH_PORT }, + url: `http://127.0.0.1:${MCP_OAUTH_PORT}/`, + stdout: 'pipe', + timeout: 60_000, + reuseExistingServer: false, + }, ...(enableDynamicMcp ? [ { diff --git a/e2e/setup/fake-mcp-oauth-server.js b/e2e/setup/fake-mcp-oauth-server.js new file mode 100644 index 0000000000..a9f1a93772 --- /dev/null +++ b/e2e/setup/fake-mcp-oauth-server.js @@ -0,0 +1,50 @@ +#!/usr/bin/env node + +/** + * Protected MCP resource used by the resumable OAuth Playwright regression. + * LibreChat starts the configured OAuth flow before opening the resource when + * no user token exists, so the test deliberately leaves authorization pending. + */ + +const http = require('node:http'); + +const PORT = Number(process.env.E2E_MCP_OAUTH_PORT || 8767); +const HOST = '127.0.0.1'; + +const server = http.createServer((req, res) => { + const url = new URL(req.url, `http://${req.headers.host}`); + + if (url.pathname === '/' && req.method === 'GET') { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('ok'); + return; + } + + if (url.pathname === '/authorize' && req.method === 'GET') { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('Authorization intentionally remains pending for the E2E test.'); + return; + } + + if (url.pathname === '/token' && req.method === 'POST') { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'authorization_pending' })); + return; + } + + if (url.pathname === '/mcp') { + res.writeHead(401, { + 'Content-Type': 'application/json', + 'WWW-Authenticate': 'Bearer realm="e2e-oauth"', + }); + res.end(JSON.stringify({ error: 'invalid_token' })); + return; + } + + res.writeHead(404); + res.end(); +}); + +server.listen(PORT, HOST, () => { + console.log(`[e2e] fake OAuth MCP resource listening on http://${HOST}:${PORT}/mcp`); +}); diff --git a/e2e/specs/mock/mcp-oauth-resume.spec.ts b/e2e/specs/mock/mcp-oauth-resume.spec.ts new file mode 100644 index 0000000000..79fb86bff9 --- /dev/null +++ b/e2e/specs/mock/mcp-oauth-resume.spec.ts @@ -0,0 +1,132 @@ +import { expect, test } from '@playwright/test'; +import type { Agents } from 'librechat-data-provider'; +import type { AgentDetail } from './agents.helpers'; +import { openAgentBuilder, uniqueAgentName } from './agents.helpers'; +import { + MOCK_ENDPOINTS, + NEW_CHAT_PATH, + fetchJson, + getAccessToken, + replyPrompt, + requestJson, + sendMessage, + sendMessageAndWaitForCompletion, +} from './helpers'; + +const MCP_SERVER_NAME = 'e2e-oauth'; +const MCP_SERVER_TOOL_ID = `sys__server__sys_mcp_${MCP_SERVER_NAME}`; +const MCP_TOOL_ID = `echo_mcp_${MCP_SERVER_NAME}`; +const SIGN_IN_BUTTON = /Sign-in to 127\.0\.0\.1/i; + +type GenerationStatus = { + active?: boolean; + streamId?: string; + resumeState?: { + pendingOAuthPrompts?: Agents.PendingMCPOAuthPrompt[]; + }; +}; + +test.describe('MCP OAuth stream resume', () => { + test('restores one actionable OAuth prompt after reloading the stream', async ({ page }) => { + test.setTimeout(120000); + await page.goto(NEW_CHAT_PATH, { timeout: 10000 }); + + const token = await getAccessToken(page); + const agentName = uniqueAgentName('E2E OAuth Resume Agent'); + let agentId: string | undefined; + + try { + const agent = await requestJson(page, { + path: '/api/agents', + token, + method: 'POST', + body: { + name: agentName, + description: 'Verifies pending MCP OAuth state across resumable Agent streams.', + instructions: 'Keep the configured MCP server attached while authorization is pending.', + provider: MOCK_ENDPOINTS[0].label, + model: MOCK_ENDPOINTS[0].model, + tools: [], + }, + }); + agentId = agent.id; + + const form = await openAgentBuilder(page); + await form.getByRole('combobox', { name: 'Agent', exact: true }).click(); + await page.getByRole('option', { name: agentName }).click(); + await expect(form.getByLabel('Agent name')).toHaveValue(agentName); + await form.getByRole('button', { name: 'Select Agent' }).click(); + + await sendMessageAndWaitForCompletion(page, replyPrompt(`oauth-resume-${Date.now()}`)); + const persistedConversationId = new URL(page.url()).pathname.split('/').pop(); + expect(persistedConversationId).toMatch(/^[0-9a-f-]{36}$/i); + + await requestJson(page, { + path: `/api/agents/${encodeURIComponent(agentId)}`, + token, + method: 'PATCH', + body: { tools: [MCP_SERVER_TOOL_ID, MCP_TOOL_ID] }, + }); + + const admission = await sendMessage(page, 'Use the protected E2E OAuth MCP server.'); + expect(admission.ok()).toBeTruthy(); + const start = (await admission.json()) as { conversationId?: string }; + expect(start.conversationId).toBeTruthy(); + const conversationId = start.conversationId!; + expect(conversationId).toBe(persistedConversationId); + + const signIn = page.getByRole('button', { name: SIGN_IN_BUTTON }); + await expect(signIn).toBeVisible({ timeout: 30000 }); + await expect(signIn).toHaveCount(1); + + const status = await fetchJson( + page, + `/api/agents/chat/status/${encodeURIComponent(conversationId)}`, + token, + ); + expect(status.active).toBe(true); + expect(status.resumeState?.pendingOAuthPrompts).toEqual([ + expect.objectContaining({ + stepId: `step_oauth_login_${MCP_SERVER_NAME}`, + toolName: `oauth_mcp_${MCP_SERVER_NAME}`, + authURL: expect.stringContaining('/authorize'), + }), + ]); + + const resumeRequest = page.waitForRequest( + (request) => { + const url = new URL(request.url()); + return ( + request.method() === 'GET' && + url.pathname === `/api/agents/chat/stream/${conversationId}` && + url.searchParams.get('resume') === 'true' && + url.searchParams.get('generationProtocolVersion') === '2' && + url.searchParams.has('generationCreatedAt') + ); + }, + { timeout: 30000 }, + ); + await page.goto(`/c/${conversationId}`, { timeout: 10000 }); + const resumed = await resumeRequest; + expect(new URL(resumed.url()).searchParams.get('resume')).toBe('true'); + + await expect(page.getByRole('button', { name: SIGN_IN_BUTTON })).toBeVisible({ + timeout: 30000, + }); + await expect(page.getByRole('button', { name: SIGN_IN_BUTTON })).toHaveCount(1); + } finally { + const stop = page.getByRole('button', { name: 'Stop generating' }); + if (await stop.isVisible({ timeout: 1000 }).catch(() => false)) { + await stop.click(); + await expect(stop).toBeHidden({ timeout: 10000 }); + } + if (agentId) { + await requestJson<{ message?: string }>(page, { + path: `/api/agents/${encodeURIComponent(agentId)}`, + token, + method: 'DELETE', + }); + } + } + }); +}); diff --git a/packages/api/src/mcp/oauth/resume.ts b/packages/api/src/mcp/oauth/resume.ts index 672a28bb42..05eb1f28c9 100644 --- a/packages/api/src/mcp/oauth/resume.ts +++ b/packages/api/src/mcp/oauth/resume.ts @@ -27,10 +27,13 @@ function getOAuthToolCall(toolCalls: unknown): Record | undefin if (!Array.isArray(toolCalls)) { return undefined; } - return toolCalls.find((toolCall) => { + for (const toolCall of toolCalls) { const candidate = getRecord(toolCall); - return typeof candidate?.name === 'string' && candidate.name.startsWith(OAUTH_TOOL_PREFIX); - }) as Record | undefined; + if (typeof candidate?.name === 'string' && candidate.name.startsWith(OAUTH_TOOL_PREFIX)) { + return candidate; + } + } + return undefined; } function getPreferredString(primary: unknown, fallback: unknown): string | undefined {