Files
PaperClipAI/server/src/__tests__/mcp-http.test.ts
Dotta d387cc0ff0 feat(connections): add managed external MCP connectors (#12346)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Connection intents need secure provider implementations to complete
setup.
> - Some providers use managed OAuth or external credential brokers.
> - Those tokens must stay out of durable Paperclip state and fail
closed when refresh fails.
> - This pull request adds managed connector backends and the required
storage contract.
> - The benefit is safer provider setup with governed credential
lifecycles.

## Linked Issues or Issue Description

Refs #11965

This is stack 8 of 11. It depends on stack 7 and replaces another
reviewable part of #11965.

## What Changed

- Add managed Google Workspace and external connector backends.
- Add Vercel Connect support without storing provider bearer tokens.
- Add replay-safe migration 0232 and its generated snapshot.
- Fail closed and clear stale token bindings when organization OAuth
refresh needs reauthorization.

## Verification

- `pnpm --filter @paperclipai/server typecheck`
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/tool-access-service.test.ts`
- Result: 194 tests passed.
- `pnpm --filter @paperclipai/db check:migrations`
- `pnpm build`
- `pnpm exec vitest run --project @paperclipai/server
server/src/services/remote-url-credentials.test.ts` (5 passed, including
URL userinfo vault extraction)

## Risks

- Broker metadata errors can block provider setup.
- OAuth refresh failure disables the shared organization connection
until reauthorization.
- Migration 0232 is generated, ordered after 0231, and safe to replay.

> I checked `ROADMAP.md`. This stack continues the existing app
connection work from #11965 and does not duplicate another planned item.

## Model Used

OpenAI Codex, GPT-5. The runtime model ID and context window were not
exposed. The model used reasoning, tool use, and code execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have linked the public source pull request with `Refs #`
- [x] I have not referenced internal or instance-local Paperclip issues
or links
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-08-29 12:08:34 -05:00

99 lines
4.0 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
initializeMcpHttpSession,
MCP_HTTP_ACCEPT,
MCP_PROTOCOL_VERSION,
mcpHttpRequestHeaders,
parseMcpHttpResponseBody,
} from "../services/mcp-http.js";
describe("mcpHttpRequestHeaders", () => {
it("advertises both JSON and SSE on every request", () => {
expect(mcpHttpRequestHeaders()).toMatchObject({
"content-type": "application/json",
accept: "application/json, text/event-stream",
});
expect(MCP_HTTP_ACCEPT).toBe("application/json, text/event-stream");
});
it("preserves caller-supplied headers while keeping the required Accept value", () => {
expect(mcpHttpRequestHeaders({ Authorization: "Bearer x", accept: "application/json" })).toMatchObject({
accept: "application/json, text/event-stream",
Authorization: "Bearer x",
});
});
});
describe("initializeMcpHttpSession", () => {
it("returns the negotiated protocol and ephemeral session headers", async () => {
const requests: Array<{ headers: Headers; payload: Record<string, unknown> }> = [];
const sessionHeaders = await initializeMcpHttpSession({
requestId: "test-request",
headers: { Authorization: "Bearer token" },
send: async (init) => {
const payload = JSON.parse(String(init.body)) as Record<string, unknown>;
requests.push({ headers: new Headers(init.headers), payload });
if (payload.method === "initialize") {
return new Response(JSON.stringify({
jsonrpc: "2.0",
id: payload.id,
result: {
protocolVersion: MCP_PROTOCOL_VERSION,
capabilities: { tools: {} },
serverInfo: { name: "stateful-test", version: "1" },
},
}), {
status: 200,
headers: { "content-type": "application/json", "mcp-session-id": "session-123" },
});
}
return new Response(null, { status: 202 });
},
});
expect(requests.map(({ payload }) => payload.method)).toEqual([
"initialize",
"notifications/initialized",
]);
expect(requests[1]!.headers.get("authorization")).toBe("Bearer token");
expect(requests[1]!.headers.get("mcp-session-id")).toBe("session-123");
expect(requests[1]!.headers.get("mcp-protocol-version")).toBe(MCP_PROTOCOL_VERSION);
expect(sessionHeaders).toMatchObject({
Authorization: "Bearer token",
"Mcp-Session-Id": "session-123",
"MCP-Protocol-Version": MCP_PROTOCOL_VERSION,
});
});
});
describe("parseMcpHttpResponseBody", () => {
it("parses a plain application/json body", () => {
const payload = { jsonrpc: "2.0", id: "1", result: { tools: [] } };
expect(parseMcpHttpResponseBody(JSON.stringify(payload), "application/json")).toEqual(payload);
});
it("parses an SSE-framed body, extracting the JSON-RPC message", () => {
const payload = { jsonrpc: "2.0", id: "1", result: { tools: [{ name: "kv_get" }] } };
const body = `event: message\ndata: ${JSON.stringify(payload)}\n\n`;
expect(parseMcpHttpResponseBody(body, "text/event-stream; charset=utf-8")).toEqual(payload);
});
it("skips non-JSON-RPC SSE events and returns the response message", () => {
const ping = "event: ping\ndata: {\"type\":\"ping\"}";
const message = { jsonrpc: "2.0", id: "1", result: { ok: true } };
const body = `${ping}\n\nevent: message\ndata: ${JSON.stringify(message)}\n\n`;
expect(parseMcpHttpResponseBody(body, "text/event-stream")).toEqual(message);
});
it("handles multi-line SSE data fields", () => {
const payload = { jsonrpc: "2.0", id: "1", result: { note: "line" } };
const json = JSON.stringify(payload, null, 2);
const body = `data: ${json.split("\n").join("\ndata: ")}\n\n`;
expect(parseMcpHttpResponseBody(body, "text/event-stream")).toEqual(payload);
});
it("throws when an SSE stream carries no data events", () => {
expect(() => parseMcpHttpResponseBody("event: ping\n\n", "text/event-stream")).toThrow();
});
});