mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +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
169 lines
6.0 KiB
TypeScript
169 lines
6.0 KiB
TypeScript
// Helpers for talking to remote MCP servers over the Streamable HTTP transport.
|
|
//
|
|
// The MCP Streamable HTTP spec requires the client to advertise that it accepts
|
|
// BOTH a single JSON response and an SSE stream on every POST:
|
|
//
|
|
// Accept: application/json, text/event-stream
|
|
//
|
|
// Spec-compliant servers reject requests missing this header with 406 Not
|
|
// Acceptable, and when the header is present they are free to answer with an
|
|
// SSE stream (`event: message\ndata: {…}`) instead of a bare JSON body. So any
|
|
// code path that POSTs JSON-RPC to a remote `/mcp` endpoint must (a) send the
|
|
// Accept header and (b) be able to read an SSE-framed response.
|
|
|
|
/** The Accept header value required by the MCP Streamable HTTP transport. */
|
|
export const MCP_HTTP_ACCEPT = "application/json, text/event-stream";
|
|
export const MCP_PROTOCOL_VERSION = "2025-06-18";
|
|
|
|
/**
|
|
* Default headers for an MCP Streamable HTTP JSON-RPC POST. Caller-supplied
|
|
* headers (e.g. resolved credentials) are preserved, while the required
|
|
* Streamable HTTP Accept value is kept authoritative.
|
|
*/
|
|
export function mcpHttpRequestHeaders(extra?: Record<string, string>): Record<string, string> {
|
|
return {
|
|
"content-type": "application/json",
|
|
...extra,
|
|
accept: MCP_HTTP_ACCEPT,
|
|
};
|
|
}
|
|
|
|
export class McpHttpInitializationError extends Error {
|
|
constructor(
|
|
message: string,
|
|
readonly stage: "initialize" | "initialized_notification",
|
|
readonly status: number | null,
|
|
) {
|
|
super(message);
|
|
this.name = "McpHttpInitializationError";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Establish the short-lived Streamable HTTP session needed by stateful MCP
|
|
* servers. The returned headers belong only to the caller's next request; no
|
|
* session id is persisted with the connection or shared across operations.
|
|
*/
|
|
export async function initializeMcpHttpSession(input: {
|
|
send: (init: RequestInit) => Promise<Response>;
|
|
headers?: Record<string, string>;
|
|
requestId: string;
|
|
}): Promise<Record<string, string>> {
|
|
const initializeResponse = await input.send({
|
|
method: "POST",
|
|
headers: mcpHttpRequestHeaders(input.headers),
|
|
body: JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: `${input.requestId}-initialize`,
|
|
method: "initialize",
|
|
params: {
|
|
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
capabilities: {},
|
|
clientInfo: { name: "paperclip", version: "1" },
|
|
},
|
|
}),
|
|
});
|
|
if (!initializeResponse.ok) {
|
|
throw new McpHttpInitializationError(
|
|
`Remote MCP initialization returned HTTP ${initializeResponse.status}`,
|
|
"initialize",
|
|
initializeResponse.status,
|
|
);
|
|
}
|
|
let payload: unknown;
|
|
try {
|
|
payload = parseMcpHttpResponseBody(
|
|
await initializeResponse.text(),
|
|
initializeResponse.headers.get("content-type"),
|
|
);
|
|
} catch {
|
|
throw new McpHttpInitializationError("Remote MCP initialization returned an invalid response", "initialize", null);
|
|
}
|
|
const result = payload && typeof payload === "object" && "result" in payload
|
|
? (payload as { result?: unknown }).result
|
|
: null;
|
|
const resultRecord = result && typeof result === "object" ? result as Record<string, unknown> : null;
|
|
const protocolVersion = typeof resultRecord?.protocolVersion === "string" && resultRecord.protocolVersion
|
|
? resultRecord.protocolVersion
|
|
: MCP_PROTOCOL_VERSION;
|
|
const sessionId = initializeResponse.headers.get("mcp-session-id");
|
|
const sessionHeaders: Record<string, string> = {
|
|
...(input.headers ?? {}),
|
|
"MCP-Protocol-Version": protocolVersion,
|
|
...(sessionId ? { "Mcp-Session-Id": sessionId } : {}),
|
|
};
|
|
const initializedResponse = await input.send({
|
|
method: "POST",
|
|
headers: mcpHttpRequestHeaders(sessionHeaders),
|
|
body: JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
method: "notifications/initialized",
|
|
params: {},
|
|
}),
|
|
});
|
|
if (!initializedResponse.ok) {
|
|
throw new McpHttpInitializationError(
|
|
`Remote MCP initialized notification returned HTTP ${initializedResponse.status}`,
|
|
"initialized_notification",
|
|
initializedResponse.status,
|
|
);
|
|
}
|
|
return sessionHeaders;
|
|
}
|
|
|
|
function looksLikeJsonRpcMessage(value: unknown): boolean {
|
|
if (typeof value !== "object" || value === null) return false;
|
|
const record = value as Record<string, unknown>;
|
|
return "result" in record || "error" in record || "method" in record || "id" in record;
|
|
}
|
|
|
|
/**
|
|
* Parse the body of an MCP Streamable HTTP response into its JSON-RPC payload.
|
|
*
|
|
* Handles both response shapes the transport allows:
|
|
* - `application/json`: the body is the JSON-RPC message directly.
|
|
* - `text/event-stream`: one or more SSE events; we return the JSON payload of
|
|
* the first `data:` event that parses as a JSON-RPC message.
|
|
*
|
|
* Falls back to a plain JSON parse when the content type is unknown so we stay
|
|
* compatible with non-compliant servers that ignore the Accept header.
|
|
*/
|
|
export function parseMcpHttpResponseBody(bodyText: string, contentType: string | null): unknown {
|
|
const isEventStream = (contentType ?? "").toLowerCase().includes("text/event-stream");
|
|
if (!isEventStream) {
|
|
return JSON.parse(bodyText) as unknown;
|
|
}
|
|
|
|
// Split the SSE stream into events on blank lines, then collect each event's
|
|
// `data:` lines (which may span multiple lines per the SSE spec).
|
|
const events = bodyText.replace(/\r\n/g, "\n").split(/\n\n+/);
|
|
let lastError: unknown = null;
|
|
let firstParsed: unknown;
|
|
let sawData = false;
|
|
for (const event of events) {
|
|
const dataLines = event
|
|
.split("\n")
|
|
.filter((line) => line.startsWith("data:"))
|
|
.map((line) => line.slice("data:".length).replace(/^ /, ""));
|
|
if (dataLines.length === 0) continue;
|
|
const data = dataLines.join("\n");
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(data) as unknown;
|
|
} catch (error) {
|
|
lastError = error;
|
|
continue;
|
|
}
|
|
if (!sawData) {
|
|
firstParsed = parsed;
|
|
sawData = true;
|
|
}
|
|
if (looksLikeJsonRpcMessage(parsed)) {
|
|
return parsed;
|
|
}
|
|
}
|
|
if (sawData) return firstParsed;
|
|
if (lastError) throw lastError;
|
|
throw new SyntaxError("MCP SSE response contained no data events");
|
|
}
|