🪢 test: Prove MCP OAuth Resume End to End (#15485)

This commit is contained in:
Danny Avila 2026-09-01 18:38:39 -04:00 • committed by GitHub
parent 5b75c79831
commit dfb264811c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 217 additions and 3 deletions

View file

@ -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:

View file

@ -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
? [
{

View file

@ -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`);
});

View file

@ -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<AgentDetail>(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<AgentDetail>(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<GenerationStatus>(
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',
});
}
}
});
});

View file

@ -27,10 +27,13 @@ function getOAuthToolCall(toolCalls: unknown): Record<string, unknown> | 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<string, unknown> | undefined;
if (typeof candidate?.name === 'string' && candidate.name.startsWith(OAUTH_TOOL_PREFIX)) {
return candidate;
}
}
return undefined;
}
function getPreferredString(primary: unknown, fallback: unknown): string | undefined {