150 lines
5.8 KiB
TypeScript
150 lines
5.8 KiB
TypeScript
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
|
// Drive the same SDK handler with the official SDK client; authorization remains in the app caller.
|
|
|
|
import { buildRegistry, forbidden } from "@conusai-kit/core"
|
|
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"
|
|
import { describe, expect, it, vi } from "vitest"
|
|
import { createMcpHandler } from "../apps/web/src/server/mcp.js"
|
|
import { appModules } from "../modules/index.js"
|
|
|
|
const registry = buildRegistry([...appModules])
|
|
const principal = { method: "bearer", actor: { userId: "user-a" } } as never
|
|
|
|
function harness(
|
|
options: {
|
|
authenticated?: boolean
|
|
call?: (name: string, args: unknown) => Promise<unknown>
|
|
csrf?: () => void
|
|
} = {},
|
|
) {
|
|
const callByName = vi.fn(options.call ?? (async () => ({ notes: [], hasMore: false })))
|
|
const checkCsrf = vi.fn(options.csrf ?? (() => undefined))
|
|
const handle = createMcpHandler({
|
|
registry,
|
|
contextFor: async () => ({
|
|
principal: options.authenticated === false ? null : principal,
|
|
caller: { callByName } as never,
|
|
}),
|
|
checkCsrf,
|
|
})
|
|
return { handle, callByName, checkCsrf }
|
|
}
|
|
|
|
function rawRequest(body: unknown, extra: Record<string, string> = {}) {
|
|
return new Request("https://app.example.test/api/mcp", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json", accept: "application/json, text/event-stream", ...extra },
|
|
body: JSON.stringify(body),
|
|
})
|
|
}
|
|
|
|
function connect(handle: ReturnType<typeof createMcpHandler>, requestId = "sdk-request") {
|
|
const transport = new StreamableHTTPClientTransport(new URL("https://app.example.test/api/mcp"), {
|
|
fetch: (url, init) => handle(new Request(url, init), requestId),
|
|
})
|
|
const client = new Client({ name: "app-template-test", version: "1.0.0" }, { versionNegotiation: { mode: "auto" } })
|
|
return { client, transport }
|
|
}
|
|
|
|
describe("the stateless MCP endpoint", () => {
|
|
it("negotiates with the official client, lists opted-in tools, and calls through the scoped caller", async () => {
|
|
const output = { notes: [], hasMore: false }
|
|
const { handle, callByName, checkCsrf } = harness({ call: async () => output })
|
|
const { client, transport } = connect(handle)
|
|
try {
|
|
await client.connect(transport)
|
|
expect(client.getProtocolEra()).toBe("modern")
|
|
|
|
const listed = await client.listTools()
|
|
expect(listed.tools.map((tool) => tool.name)).toEqual(["agent.run.status", "knowledge.search", "notes.note.list"])
|
|
expect(listed.tools.find((tool) => tool.name === "notes.note.list")?.inputSchema.required).toEqual([
|
|
"org",
|
|
"workspace",
|
|
"project",
|
|
])
|
|
|
|
const result = await client.callTool({
|
|
name: "notes.note.list",
|
|
arguments: { org: "org-a", workspace: "ws-a", project: "project-a" },
|
|
})
|
|
expect(callByName).toHaveBeenCalledWith("notes.note.list", {
|
|
org: "org-a",
|
|
workspace: "ws-a",
|
|
project: "project-a",
|
|
})
|
|
expect(checkCsrf.mock.calls.length).toBeGreaterThanOrEqual(3)
|
|
expect(result.isError).not.toBe(true)
|
|
expect(result.structuredContent).toEqual(output)
|
|
} finally {
|
|
await client.close()
|
|
}
|
|
})
|
|
|
|
it("refuses unauthenticated requests before SDK protocol handling", async () => {
|
|
const { handle, callByName } = harness({ authenticated: false })
|
|
const response = await handle(rawRequest({ jsonrpc: "2.0", id: 4, method: "initialize" }), "request-4")
|
|
expect(response.status).toBe(401)
|
|
expect(response.headers.get("www-authenticate")).toBe("Bearer")
|
|
expect(callByName).not.toHaveBeenCalled()
|
|
expect(JSON.stringify(await response.json())).not.toContain("user-a")
|
|
})
|
|
|
|
it("applies the same session CSRF refusal and does not disclose its detail", async () => {
|
|
const { handle } = harness({
|
|
csrf: () => {
|
|
throw forbidden("sensitive csrf detail")
|
|
},
|
|
})
|
|
const response = await handle(rawRequest({ jsonrpc: "2.0", id: 5, method: "initialize" }), "request-5")
|
|
expect(response.status).toBe(403)
|
|
expect(JSON.stringify(await response.json())).not.toContain("sensitive csrf detail")
|
|
})
|
|
|
|
it("returns a safe tool error when kernel authorization refuses the caller", async () => {
|
|
const call = vi.fn(async (_name: string, args: unknown) => {
|
|
if (typeof args === "object" && args !== null && "org" in args && args.org === "other-org") {
|
|
throw forbidden("tenant-private note data")
|
|
}
|
|
return { notes: [], hasMore: false }
|
|
})
|
|
const { handle } = harness({
|
|
call,
|
|
})
|
|
const { client, transport } = connect(handle, "request-tool-refusal")
|
|
try {
|
|
await client.connect(transport)
|
|
const result = await client.callTool({
|
|
name: "notes.note.list",
|
|
arguments: { org: "other-org", workspace: "other-ws", project: "other-project" },
|
|
})
|
|
expect(result.isError).toBe(true)
|
|
expect(result.content[0]).toMatchObject({ type: "text", text: "The tool request was refused." })
|
|
expect(JSON.stringify(result)).not.toContain("tenant-private note data")
|
|
expect(call).toHaveBeenCalledWith("notes.note.list", {
|
|
org: "other-org",
|
|
workspace: "other-ws",
|
|
project: "other-project",
|
|
})
|
|
} finally {
|
|
await client.close()
|
|
}
|
|
})
|
|
|
|
it("refuses non-tool names and non-POST methods", async () => {
|
|
const { handle, callByName } = harness()
|
|
const { client, transport } = connect(handle, "request-7")
|
|
try {
|
|
await client.connect(transport)
|
|
await expect(client.callTool({ name: "notes.note.create", arguments: {} })).rejects.toMatchObject({
|
|
code: -32602,
|
|
})
|
|
expect(callByName).not.toHaveBeenCalled()
|
|
} finally {
|
|
await client.close()
|
|
}
|
|
|
|
const get = await handle(new Request("https://app.example.test/api/mcp"), "request-8")
|
|
expect(get.status).toBe(405)
|
|
})
|
|
})
|