add mcp e2e tests

This commit is contained in:
swithek 2026-08-25 14:26:41 +02:00
parent 488fc32406
commit c9a7edc485
5 changed files with 886 additions and 0 deletions

View File

@ -3,3 +3,13 @@
export const BASE_URL = "http://localhost:18080"
export const MAILPIT_URL = "http://localhost:18025"
export const REALTIME_WS_URL = `ws://localhost:18080/auth-realtime/hocuspocus`
// the mcp surface's canonical url, which is also the OAuth resource
// (RFC 8707) a token is bound to.
export const MCP_URL = `${BASE_URL}/core/api/mcp`
// where the authorization server sends the browser back with the code.
// It is mailpit's origin: an address in the stack that is not the app
// under test, answers, and leaves the query string alone — the only
// things a redirect target has to do here. A loopback http address also
// needs the client to register as native; a web client would have to
// offer https.
export const MCP_REDIRECT_URL = "http://127.0.0.1:18025/"

104
e2e/helpers/mcp.ts Normal file
View File

@ -0,0 +1,104 @@
import { createHash, randomUUID } from "node:crypto"
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import { expect, type APIRequestContext, type Page } from "@playwright/test"
import { BASE_URL, MCP_REDIRECT_URL, MCP_URL } from "./config"
import { t } from "./i18n"
import { visit } from "./page"
interface AuthorizationServer {
authorization_endpoint: string
token_endpoint: string
registration_endpoint: string
}
// authorizeMCPClient walks the whole flow a real MCP client walks:
// discover the authorization server, register for a client id (RFC
// 7591), send the signed-in user through the consent screen, and trade
// the resulting code for a token with PKCE. Nothing is minted behind the
// product's back, so the token that comes back carries whatever
// organization the user actually granted it for.
export async function authorizeMCPClient(
page: Page,
request: APIRequestContext,
scope = "documents:read",
): Promise<string> {
const metadata = await request.get(
`${BASE_URL}/.well-known/oauth-authorization-server`,
)
expect(metadata.ok()).toBe(true)
const server = (await metadata.json()) as AuthorizationServer
const registration = await request.post(server.registration_endpoint, {
data: {
client_name: `e2e-${randomUUID()}`,
application_type: "native",
redirect_uris: [MCP_REDIRECT_URL],
grant_types: ["authorization_code"],
response_types: ["code"],
token_endpoint_auth_method: "none",
},
})
expect(registration.ok()).toBe(true)
const client = (await registration.json()) as { client_id: string }
const verifier = `${randomUUID()}${randomUUID()}`
const challenge = createHash("sha256").update(verifier).digest("base64url")
const authorize = new URL(server.authorization_endpoint)
authorize.searchParams.set("response_type", "code")
authorize.searchParams.set("client_id", client.client_id)
authorize.searchParams.set("redirect_uri", MCP_REDIRECT_URL)
authorize.searchParams.set("scope", scope)
authorize.searchParams.set("code_challenge", challenge)
authorize.searchParams.set("code_challenge_method", "S256")
authorize.searchParams.set("state", randomUUID())
authorize.searchParams.set("resource", MCP_URL)
await visit(page, authorize.toString())
await page.getByRole("button", { name: t("oauth.consent.approve") }).click()
await page.waitForURL(/[?&]code=/)
const code = new URL(page.url()).searchParams.get("code")
expect(code).not.toBeNull()
const token = await request.post(server.token_endpoint, {
form: {
grant_type: "authorization_code",
code: code ?? "",
redirect_uri: MCP_REDIRECT_URL,
client_id: client.client_id,
code_verifier: verifier,
resource: MCP_URL,
},
})
expect(token.ok()).toBe(true)
const granted = (await token.json()) as { access_token: string }
return granted.access_token
}
// connectMCPClient points the official MCP client at core's surface with
// the given bearer token, which is what a third-party client does with
// the token the flow above produced.
export async function connectMCPClient(token: string): Promise<Client> {
const client = new Client({ name: "oxynote-e2e", version: "1.0.0" })
await client.connect(
new StreamableHTTPClientTransport(new URL(MCP_URL), {
requestInit: {
headers: { authorization: `Bearer ${token}` },
},
}),
)
return client
}
// documentResourceURI is how the mcp surface addresses a document.
export function documentResourceURI(id: string): string {
return `oxynote://documents/${id}`
}

View File

@ -22,6 +22,7 @@
"devDependencies": {
"@eslint/js": "^9.33.0",
"@hocuspocus/provider": "^3.4.4",
"@modelcontextprotocol/sdk": "^1.30.0",
"@playwright/test": "^1.62.1",
"@types/node": "^20.11.17",
"@types/ws": "^8.18.1",

File diff suppressed because it is too large Load Diff

View File

@ -8,6 +8,11 @@ import {
import { inviteTeamMember } from "../helpers/collaboration"
import { waitForEditor } from "../helpers/editor"
import { t } from "../helpers/i18n"
import {
authorizeMCPClient,
connectMCPClient,
documentResourceURI,
} from "../helpers/mcp"
import { visit } from "../helpers/page"
import { documentAccepted } from "../helpers/realtime"
import { openSettings } from "../helpers/settings"
@ -202,6 +207,72 @@ test.describe("access", () => {
await other.context.close()
})
test("lists only the granting workspace's documents over mcp", async ({
page,
request,
browser,
}) => {
// two tenants, plus a registration, a consent and a token
// exchange on top of the usual setup
test.slow()
await signUpWithWorkspace(page, request)
const own = documentId(page)
const other = await signUpWithSeparateWorkspace(browser, request)
const foreign = documentId(other.page)
const client = await connectMCPClient(
await authorizeMCPClient(page, request),
)
const { resources } = await client.listResources()
const uris = resources.map((resource) => resource.uri)
// the token was granted by a user of the first workspace, so the
// organization it is bound to is the only one it can see
expect(uris).toContain(documentResourceURI(own))
expect(uris).not.toContain(documentResourceURI(foreign))
await client.close()
await other.context.close()
})
test("refuses to read another workspace's document over mcp", async ({
page,
request,
browser,
}) => {
test.slow()
await signUpWithWorkspace(page, request)
const own = documentId(page)
const other = await signUpWithSeparateWorkspace(browser, request)
const foreign = documentId(other.page)
const client = await connectMCPClient(
await authorizeMCPClient(page, request),
)
// the control: the same call on the token's own document has to
// come back, or the refusal below would say nothing about
// scoping and everything about a read that never works
const readable = await client.readResource({
uri: documentResourceURI(own),
})
expect(readable.contents).not.toHaveLength(0)
// named directly rather than picked off the listing: a scoped
// listing is no protection if the read behind it answers for
// any id it is handed
await expect(
client.readResource({ uri: documentResourceURI(foreign) }),
).rejects.toThrow()
await client.close()
await other.context.close()
})
const BEARERS: { name: string; headers: Record<string, string> }[] = [
{ name: "no authorization header", headers: {} },
{