mirror of
https://github.com/oxynote/oxynote.git
synced 2026-10-17 02:49:18 +02:00
add mcp e2e tests
This commit is contained in:
parent
488fc32406
commit
c9a7edc485
|
|
@ -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
104
e2e/helpers/mcp.ts
Normal 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}`
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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: {} },
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user