mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-09 06:15:21 +02:00
## 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
99 lines
4.0 KiB
TypeScript
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();
|
|
});
|
|
});
|