Files
PaperClipAI/server/src/services/mcp-http.ts
T
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

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");
}