mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-21 17:44:11 +00:00
🪢 test: Prove MCP OAuth Resume End to End (#15485)
This commit is contained in:
parent
5b75c79831
commit
dfb264811c
5 changed files with 217 additions and 3 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
? [
|
||||
{
|
||||
|
|
|
|||
50
e2e/setup/fake-mcp-oauth-server.js
Normal file
50
e2e/setup/fake-mcp-oauth-server.js
Normal 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`);
|
||||
});
|
||||
132
e2e/specs/mock/mcp-oauth-resume.spec.ts
Normal file
132
e2e/specs/mock/mcp-oauth-resume.spec.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue