Files
openwork/apps/server/src/local-managed-mcp.ts
T

1305 lines
49 KiB
TypeScript

import {
createCipheriv,
createDecipheriv,
createHash,
createHmac,
randomBytes,
randomUUID,
timingSafeEqual,
} from "node:crypto";
import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { basename, dirname, join } from "node:path";
import {
OAuthError,
RegistrationRejectedError,
SdkHttpError,
} from "@modelcontextprotocol/client";
import type { OAuthDiscoveryState } from "@modelcontextprotocol/sdk/client/auth.js";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
import type { OAuthClientInformationMixed } from "@modelcontextprotocol/sdk/shared/auth.js";
import {
CallToolRequestSchema,
ErrorCode,
ListToolsRequestSchema,
McpError,
} from "@modelcontextprotocol/sdk/types.js";
import {
createEnterpriseMcpClient,
EnterpriseMcpClientError,
type EnterpriseMcpConnection,
type EnterpriseMcpDiagnosticEvent,
type EnterpriseMcpOAuthAuthorizationHandle,
type EnterpriseMcpOAuthClientRegistration,
type EnterpriseMcpOAuthCredential,
type EnterpriseMcpOAuthPersistence,
type EnterpriseMcpPersistenceContext,
type EnterpriseMcpRequestPhase,
} from "@openwork/enterprise-mcp-client";
import { ApiError } from "./errors.js";
import { sanitizeDiagnosticString } from "./diagnostic-sanitizer.js";
import { runtimeStorageDir } from "./runtime-db.js";
import {
readRuntimeOpencodeConfig,
runtimeMcpMap,
writeRuntimeOpencodeConfig,
} from "./runtime-opencode-config-store.js";
import type { ServerConfig } from "./types.js";
import { isRecord } from "./workspace-kv-store.js";
import {
assertLocalManagedMcpUrl,
createLocalManagedMcpGuardedFetch,
LocalManagedMcpPrivateUrlError,
} from "./local-managed-mcp-url-guard.js";
type LocalManagedMcpStatus = "needs_auth" | "connecting" | "connected" | "reconnect_required";
type StoredAuthorization = {
revision: string;
expiresAt: number;
codeVerifier: string;
clientRegistrationRevision?: string;
};
type StoredLocalManagedMcpConnection = {
id: string;
workspaceId: string;
name: string;
serverUrl: string;
enabled: boolean;
oauth: {
applicationType: "native" | "web";
requestedScopes?: string[];
authorizationServerIssuer?: string;
clientId?: string;
clientSecret?: string;
};
status: LocalManagedMcpStatus;
lastError?: string;
createdAt: number;
updatedAt: number;
clientRegistration?: EnterpriseMcpOAuthClientRegistration;
credential?: EnterpriseMcpOAuthCredential;
authorizations: Record<string, StoredAuthorization>;
discovery?: OAuthDiscoveryState;
};
type LocalManagedMcpVault = {
schemaVersion: 1;
connections: Record<string, StoredLocalManagedMcpConnection>;
};
type VaultEnvelope = {
schemaVersion: 1;
algorithm: "aes-256-gcm";
iv: string;
tag: string;
data: string;
};
/** Plaintext, non-secret projection of one stored connection (vault file v2). */
type LocalManagedMcpIndexEntry = {
id: string;
workspaceId: string;
name: string;
serverUrl: string;
enabled: boolean;
oauth: {
applicationType: "native" | "web";
requestedScopes?: string[];
authorizationServerIssuer?: string;
clientId?: string;
};
status: LocalManagedMcpStatus;
lastError?: string;
createdAt: number;
updatedAt: number;
hasCredential: boolean;
};
export type LocalManagedMcpVaultRecovery = {
at: number;
reason: string;
quarantinedTo: string;
};
/** Parsed on-disk vault file: v1 is a bare envelope, v2 adds the index. */
type VaultFileState = {
envelope: VaultEnvelope;
index: Record<string, LocalManagedMcpIndexEntry> | null;
lastRecovery: LocalManagedMcpVaultRecovery | null;
};
type LoadedVault = {
vault: LocalManagedMcpVault;
lastRecovery: LocalManagedMcpVaultRecovery | null;
};
export type LocalManagedMcpPublicConnection = {
name: string;
serverUrl: string;
enabled: boolean;
status: LocalManagedMcpStatus;
lastError: string | null;
hasCredential: boolean;
updatedAt: number;
};
export type CreateLocalManagedMcpInput = {
workspaceId: string;
name: string;
serverUrl: string;
oauth: {
applicationType?: "native" | "web";
requestedScopes?: string[];
authorizationServerIssuer?: string;
clientId?: string;
clientSecret?: string;
};
};
const VAULT_AAD = Buffer.from("openwork-local-managed-mcp-v1", "utf8");
const VAULT_RECOVERY_REASON = "secure_storage_changed";
const VAULT_RECOVERED_LAST_ERROR =
"Secure storage on this device changed, so saved sign-ins were cleared. Reconnect to restore this connection.";
const MANAGED_MCP_CONNECTION_FAILED_MESSAGE =
"OpenWork could not connect to this MCP server. Check its OAuth settings and availability, then try again.";
const EXTERNAL_HANDSHAKE_REQUEST_PHASES = new Set<EnterpriseMcpRequestPhase>([
"oauth-client-registration",
"mcp-discovery",
"mcp-initialize",
]);
const vaultQueueByPath = new Map<string, Promise<void>>();
const vaultKeyByConfig = new WeakMap<ServerConfig, Promise<Buffer>>();
const gatewaySecretByConfig = new WeakMap<ServerConfig, Buffer>();
const guardedFetch = createLocalManagedMcpGuardedFetch();
function emptyVault(): LocalManagedMcpVault {
return { schemaVersion: 1, connections: {} };
}
function vaultPath(config: ServerConfig): string {
return join(runtimeStorageDir(config), "local-managed-mcp-vault.json");
}
function secureVaultStorageUnavailable(): ApiError {
return new ApiError(
503,
"managed_mcp_secure_storage_unavailable",
"Secure storage for OpenWork-managed MCP credentials is unavailable. Start through OpenWork Desktop or set OPENWORK_ENCRYPTION_KEY.",
);
}
async function resolveVaultKey(config: ServerConfig): Promise<Buffer> {
if (config.localManagedMcpVaultKey) {
try {
const key = Buffer.from(await config.localManagedMcpVaultKey());
if (key.byteLength !== 32) throw new Error("invalid vault key length");
return key;
} catch {
throw secureVaultStorageUnavailable();
}
}
const configured = process.env.OPENWORK_ENCRYPTION_KEY?.trim();
if (configured) return createHash("sha256").update(configured).digest();
throw secureVaultStorageUnavailable();
}
async function vaultKey(config: ServerConfig): Promise<Buffer> {
let pending = vaultKeyByConfig.get(config);
if (!pending) {
pending = resolveVaultKey(config);
vaultKeyByConfig.set(config, pending);
}
try {
return Buffer.from(await pending);
} catch (error) {
vaultKeyByConfig.delete(config);
throw error;
}
}
function isVaultEnvelope(value: unknown): value is VaultEnvelope {
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
const record = value as Record<string, unknown>;
return record.schemaVersion === 1
&& record.algorithm === "aes-256-gcm"
&& typeof record.iv === "string"
&& typeof record.tag === "string"
&& typeof record.data === "string";
}
function isVault(value: unknown): value is LocalManagedMcpVault {
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
const record = value as Record<string, unknown>;
return record.schemaVersion === 1
&& typeof record.connections === "object"
&& record.connections !== null
&& !Array.isArray(record.connections);
}
function isConnectionStatus(value: unknown): value is LocalManagedMcpStatus {
return value === "needs_auth" || value === "connecting" || value === "connected" || value === "reconnect_required";
}
function isVaultIndexEntry(value: unknown): value is LocalManagedMcpIndexEntry {
if (!isRecord(value) || !isRecord(value.oauth)) return false;
const oauth = value.oauth;
return typeof value.id === "string"
&& typeof value.workspaceId === "string"
&& typeof value.name === "string"
&& typeof value.serverUrl === "string"
&& typeof value.enabled === "boolean"
&& (oauth.applicationType === "native" || oauth.applicationType === "web")
&& (oauth.requestedScopes === undefined
|| (Array.isArray(oauth.requestedScopes) && oauth.requestedScopes.every((scope) => typeof scope === "string")))
&& (oauth.authorizationServerIssuer === undefined || typeof oauth.authorizationServerIssuer === "string")
&& (oauth.clientId === undefined || typeof oauth.clientId === "string")
&& isConnectionStatus(value.status)
&& (value.lastError === undefined || typeof value.lastError === "string")
&& typeof value.createdAt === "number"
&& typeof value.updatedAt === "number"
&& typeof value.hasCredential === "boolean";
}
function isVaultRecovery(value: unknown): value is LocalManagedMcpVaultRecovery {
return isRecord(value)
&& typeof value.at === "number"
&& typeof value.reason === "string"
&& typeof value.quarantinedTo === "string";
}
function readVaultIndex(value: unknown): Record<string, LocalManagedMcpIndexEntry> {
if (!isRecord(value)) return {};
const index: Record<string, LocalManagedMcpIndexEntry> = {};
for (const [key, entry] of Object.entries(value)) {
if (isVaultIndexEntry(entry)) index[key] = entry;
}
return index;
}
/** Parse the vault file without decrypting. Accepts v1 (bare envelope) and v2. */
async function readVaultFileState(config: ServerConfig): Promise<VaultFileState | null> {
let raw: string;
try {
raw = await readFile(vaultPath(config), "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
}
const value: unknown = JSON.parse(raw);
if (isVaultEnvelope(value)) return { envelope: value, index: null, lastRecovery: null };
if (isRecord(value) && value.schemaVersion === 2 && isVaultEnvelope(value.vault)) {
return {
envelope: value.vault,
index: readVaultIndex(value.index),
lastRecovery: isVaultRecovery(value.lastRecovery) ? value.lastRecovery : null,
};
}
throw new Error("The local managed MCP vault envelope is invalid.");
}
function decryptVault(envelope: VaultEnvelope, key: Buffer): LocalManagedMcpVault {
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(envelope.iv, "base64"));
decipher.setAAD(VAULT_AAD);
decipher.setAuthTag(Buffer.from(envelope.tag, "base64"));
const plaintext = Buffer.concat([
decipher.update(Buffer.from(envelope.data, "base64")),
decipher.final(),
]).toString("utf8");
const value: unknown = JSON.parse(plaintext);
if (!isVault(value)) throw new Error("The local managed MCP vault payload is invalid.");
return value;
}
function vaultIndexEntry(connection: StoredLocalManagedMcpConnection): LocalManagedMcpIndexEntry {
return {
id: connection.id,
workspaceId: connection.workspaceId,
name: connection.name,
serverUrl: connection.serverUrl,
enabled: connection.enabled,
oauth: {
applicationType: connection.oauth.applicationType,
...(connection.oauth.requestedScopes ? { requestedScopes: connection.oauth.requestedScopes } : {}),
...(connection.oauth.authorizationServerIssuer ? { authorizationServerIssuer: connection.oauth.authorizationServerIssuer } : {}),
...(connection.oauth.clientId ? { clientId: connection.oauth.clientId } : {}),
},
status: connection.status,
...(connection.lastError === undefined ? {} : { lastError: connection.lastError }),
createdAt: connection.createdAt,
updatedAt: connection.updatedAt,
hasCredential: Boolean(connection.credential),
};
}
async function writeVault(
config: ServerConfig,
vault: LocalManagedMcpVault,
lastRecovery: LocalManagedMcpVaultRecovery | null,
): Promise<void> {
const path = vaultPath(config);
const key = await vaultKey(config);
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key, iv);
cipher.setAAD(VAULT_AAD);
const encrypted = Buffer.concat([cipher.update(JSON.stringify(vault), "utf8"), cipher.final()]);
const envelope: VaultEnvelope = {
schemaVersion: 1,
algorithm: "aes-256-gcm",
iv: iv.toString("base64"),
tag: cipher.getAuthTag().toString("base64"),
data: encrypted.toString("base64"),
};
const file = {
schemaVersion: 2,
index: Object.fromEntries(
Object.entries(vault.connections).map(([entryKey, connection]) => [entryKey, vaultIndexEntry(connection)]),
),
vault: envelope,
...(lastRecovery ? { lastRecovery } : {}),
};
await mkdir(dirname(path), { recursive: true });
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
await writeFile(temporary, `${JSON.stringify(file)}\n`, { encoding: "utf8", mode: 0o600 });
await rename(temporary, path);
await chmod(path, 0o600).catch(() => undefined);
}
async function withVaultQueue<T>(config: ServerConfig, run: () => Promise<T>): Promise<T> {
const path = vaultPath(config);
const previous = vaultQueueByPath.get(path) ?? Promise.resolve();
let resolveCurrent: (() => void) | undefined;
const current = new Promise<void>((resolve) => {
resolveCurrent = resolve;
});
vaultQueueByPath.set(path, previous.catch(() => undefined).then(() => current));
await previous.catch(() => undefined);
try {
return await run();
} finally {
resolveCurrent?.();
}
}
function rebuiltConnection(entry: LocalManagedMcpIndexEntry): StoredLocalManagedMcpConnection {
return {
id: entry.id,
workspaceId: entry.workspaceId,
name: entry.name,
serverUrl: entry.serverUrl,
enabled: entry.enabled,
oauth: {
applicationType: entry.oauth.applicationType,
...(entry.oauth.requestedScopes ? { requestedScopes: entry.oauth.requestedScopes } : {}),
...(entry.oauth.authorizationServerIssuer ? { authorizationServerIssuer: entry.oauth.authorizationServerIssuer } : {}),
...(entry.oauth.clientId ? { clientId: entry.oauth.clientId } : {}),
},
status: "reconnect_required",
lastError: VAULT_RECOVERED_LAST_ERROR,
createdAt: entry.createdAt,
updatedAt: entry.updatedAt,
authorizations: {},
};
}
function isManagedGatewayRuntimeEntry(entry: Record<string, unknown>): boolean {
return entry.type === "remote"
&& entry.oauth === false
&& typeof entry.url === "string"
&& entry.url.includes("/mcp/managed/");
}
/** Best effort: drop managed gateway runtime entries that lost their vault connection. */
async function pruneOrphanedManagedRuntimeEntries(config: ServerConfig, vault: LocalManagedMcpVault): Promise<void> {
for (const workspace of config.workspaces) {
try {
const mcp = runtimeMcpMap(await readRuntimeOpencodeConfig(config, workspace.id));
for (const [name, entry] of Object.entries(mcp)) {
if (!isManagedGatewayRuntimeEntry(entry)) continue;
if (vault.connections[connectionKey(workspace.id, name)]) continue;
await removeManagedRuntimeEntry(config, workspace.id, name);
}
} catch (error) {
console.warn(
`[managed-mcp] Failed to prune managed runtime entries for workspace ${workspace.id} after vault recovery: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
/**
* The key resolved but the stored envelope no longer authenticates: OS secure
* storage changed underneath the vault. Quarantine the unreadable file and
* rebuild the vault from the plaintext index (v2) or empty (v1) so members can
* reconnect instead of hitting raw crypto errors.
*/
function backupTimestamp(date: Date): string {
const parts = [
date.getFullYear(),
date.getMonth() + 1,
date.getDate(),
date.getHours(),
date.getMinutes(),
date.getSeconds(),
];
return parts.map((part, index) => String(part).padStart(index === 0 ? 4 : 2, "0")).join("");
}
async function recoverVaultLocked(config: ServerConfig, file: VaultFileState): Promise<LoadedVault> {
const path = vaultPath(config);
const backupName = `${basename(path)}.openwork-backup-${backupTimestamp(new Date())}`;
await rename(path, join(dirname(path), backupName));
const vault = emptyVault();
for (const [key, entry] of Object.entries(file.index ?? {})) {
vault.connections[key] = rebuiltConnection(entry);
}
const lastRecovery: LocalManagedMcpVaultRecovery = {
at: Date.now(),
reason: VAULT_RECOVERY_REASON,
quarantinedTo: backupName,
};
await writeVault(config, vault, lastRecovery);
if (!file.index) await pruneOrphanedManagedRuntimeEntries(config, vault);
return { vault, lastRecovery };
}
/** Only call while holding the per-path vault queue: may quarantine and rewrite. */
async function loadVaultLocked(config: ServerConfig): Promise<LoadedVault> {
const file = await readVaultFileState(config);
if (!file) return { vault: emptyVault(), lastRecovery: null };
const key = await vaultKey(config);
try {
return { vault: decryptVault(file.envelope, key), lastRecovery: file.lastRecovery };
} catch {
return recoverVaultLocked(config, file);
}
}
async function withVaultMutation<T>(
config: ServerConfig,
mutate: (vault: LocalManagedMcpVault) => Promise<T> | T,
shouldPersist: (result: T) => boolean = () => true,
): Promise<T> {
return withVaultQueue(config, async () => {
const { vault, lastRecovery } = await loadVaultLocked(config);
const result = await mutate(vault);
if (shouldPersist(result)) await writeVault(config, vault, lastRecovery);
return result;
});
}
async function withVaultRead<T>(config: ServerConfig, read: (vault: LocalManagedMcpVault) => T): Promise<T> {
return withVaultQueue(config, async () => read((await loadVaultLocked(config)).vault));
}
function connectionKey(workspaceId: string, name: string): string {
return `${workspaceId.length}:${workspaceId}${name}`;
}
function requireConnection(vault: LocalManagedMcpVault, workspaceId: string, name: string): StoredLocalManagedMcpConnection {
const connection = vault.connections[connectionKey(workspaceId, name)];
if (!connection) throw new ApiError(404, "managed_mcp_not_found", `Managed MCP ${name} was not found`);
return connection;
}
function publicConnection(connection: StoredLocalManagedMcpConnection): LocalManagedMcpPublicConnection {
return {
name: connection.name,
serverUrl: connection.serverUrl,
enabled: connection.enabled,
status: connection.status,
lastError: connection.lastError ?? null,
hasCredential: Boolean(connection.credential),
updatedAt: connection.updatedAt,
};
}
function authorizationStorageKey(key: Buffer, authorizationId: string): string {
return createHmac("sha256", key).update(authorizationId).digest("base64url");
}
function ensurePersistenceContext(context: EnterpriseMcpPersistenceContext): void {
if (context.signal.aborted || Date.now() >= context.commitExpiresAt) {
throw new Error("The managed MCP persistence deadline expired.");
}
}
function createPersistence(config: ServerConfig, workspaceId: string, name: string): EnterpriseMcpOAuthPersistence {
const loadConnection = () => withVaultRead(config, (vault) => requireConnection(vault, workspaceId, name));
return {
clientRegistrations: {
load: async (context) => {
ensurePersistenceContext(context);
const connection = await loadConnection();
if (connection.oauth.clientId) {
const clientInformation: OAuthClientInformationMixed = {
client_id: connection.oauth.clientId,
...(connection.oauth.clientSecret ? { client_secret: connection.oauth.clientSecret } : {}),
token_endpoint_auth_method: connection.oauth.clientSecret ? "client_secret_post" : "none",
};
return { clientInformation, revision: "pre-registered:1", source: "pre-registered" };
}
return connection.clientRegistration;
},
save: async ({ context, clientInformation, expiresAt, source }) => {
ensurePersistenceContext(context);
return withVaultMutation(config, (vault) => {
const connection = requireConnection(vault, workspaceId, name);
if (connection.clientRegistration) return connection.clientRegistration;
const registration: EnterpriseMcpOAuthClientRegistration = {
clientInformation,
revision: randomUUID(),
source,
...(expiresAt === undefined ? {} : { expiresAt }),
};
connection.clientRegistration = registration;
connection.updatedAt = Date.now();
return registration;
});
},
invalidate: async ({ context }) => {
ensurePersistenceContext(context);
await withVaultMutation(config, (vault) => {
const connection = requireConnection(vault, workspaceId, name);
delete connection.clientRegistration;
connection.updatedAt = Date.now();
});
},
},
credentials: {
load: async (context) => {
ensurePersistenceContext(context);
return (await loadConnection()).credential;
},
save: async ({ context, tokens, expiresAt, source, authorization, clientRegistrationRevision, expectedCredentialRevision }) => {
ensurePersistenceContext(context);
await withVaultMutation(config, async (vault) => {
ensurePersistenceContext(context);
const connection = requireConnection(vault, workspaceId, name);
if (source === "refresh" && expectedCredentialRevision !== connection.credential?.revision) {
throw new Error("The managed MCP credential changed during refresh.");
}
if (source === "authorization-code") {
if (!authorization) throw new Error("The managed MCP authorization transaction is missing.");
const key = await vaultKey(config);
const storedKey = authorizationStorageKey(key, authorization.id);
const stored = connection.authorizations[storedKey];
if (!stored || stored.revision !== authorization.revision) {
throw new Error("The managed MCP authorization transaction was already consumed.");
}
if (stored.clientRegistrationRevision !== clientRegistrationRevision) {
throw new Error("The managed MCP OAuth client changed during authorization.");
}
delete connection.authorizations[storedKey];
}
connection.credential = {
tokens,
revision: randomUUID(),
...(expiresAt === undefined ? {} : { expiresAt }),
};
connection.status = "connected";
delete connection.lastError;
connection.updatedAt = Date.now();
});
},
invalidate: async ({ context }) => {
ensurePersistenceContext(context);
await withVaultMutation(config, (vault) => {
const connection = requireConnection(vault, workspaceId, name);
delete connection.credential;
connection.status = "reconnect_required";
connection.updatedAt = Date.now();
});
},
},
authorizations: {
begin: async ({ context, id, codeVerifier, expiresAt, clientRegistrationRevision }) => {
ensurePersistenceContext(context);
const key = await vaultKey(config);
await withVaultMutation(config, (vault) => {
const connection = requireConnection(vault, workspaceId, name);
connection.authorizations[authorizationStorageKey(key, id)] = {
revision: randomUUID(),
expiresAt,
codeVerifier,
...(clientRegistrationRevision === undefined ? {} : { clientRegistrationRevision }),
};
connection.updatedAt = Date.now();
});
},
load: async ({ context, id }) => {
ensurePersistenceContext(context);
const key = await vaultKey(config);
const stored = await withVaultRead(config, (vault) => {
const connection = requireConnection(vault, workspaceId, name);
return connection.authorizations[authorizationStorageKey(key, id)];
});
if (!stored) return undefined;
const handle: EnterpriseMcpOAuthAuthorizationHandle = {
id,
revision: stored.revision,
expiresAt: stored.expiresAt,
...(stored.clientRegistrationRevision === undefined ? {} : { clientRegistrationRevision: stored.clientRegistrationRevision }),
};
return { handle, codeVerifier: stored.codeVerifier };
},
invalidate: async ({ context, id }) => {
ensurePersistenceContext(context);
const key = await vaultKey(config);
await withVaultMutation(config, (vault) => {
const connection = requireConnection(vault, workspaceId, name);
delete connection.authorizations[authorizationStorageKey(key, id)];
connection.updatedAt = Date.now();
});
},
},
discovery: {
load: async (context) => {
ensurePersistenceContext(context);
return (await loadConnection()).discovery;
},
save: async ({ context, state }) => {
ensurePersistenceContext(context);
await withVaultMutation(config, (vault) => {
const connection = requireConnection(vault, workspaceId, name);
connection.discovery = state;
connection.updatedAt = Date.now();
});
},
invalidate: async ({ context }) => {
ensurePersistenceContext(context);
await withVaultMutation(config, (vault) => {
const connection = requireConnection(vault, workspaceId, name);
delete connection.discovery;
connection.updatedAt = Date.now();
});
},
},
};
}
async function enterpriseConnection(config: ServerConfig, workspaceId: string, name: string): Promise<EnterpriseMcpConnection> {
const connection = await withVaultRead(config, (vault) => requireConnection(vault, workspaceId, name));
return {
id: connection.id,
serverUrl: connection.serverUrl,
authorization: {
type: "oauth",
persistence: createPersistence(config, workspaceId, name),
configuration: {
applicationType: connection.oauth.applicationType,
...(connection.oauth.requestedScopes ? { requestedScopes: connection.oauth.requestedScopes } : {}),
...(connection.oauth.authorizationServerIssuer ? { authorizationServerIssuer: connection.oauth.authorizationServerIssuer } : {}),
},
},
};
}
function enterpriseClient(diagnostics?: EnterpriseMcpDiagnosticEvent[]) {
return createEnterpriseMcpClient({
fetch: guardedFetch,
clientName: "OpenWork Local MCP Gateway",
clientVersion: "1.0.0",
operationTimeoutMs: 45_000,
...(diagnostics ? { diagnosticSink: (event) => diagnostics.push(event) } : {}),
});
}
function serverOrigin(config: ServerConfig): string {
return `http://127.0.0.1:${config.port}`;
}
export function localManagedMcpCallbackUrl(config: ServerConfig): string {
return `${serverOrigin(config)}/mcp/oauth/callback`;
}
function gatewaySecret(config: ServerConfig): Buffer {
const existing = gatewaySecretByConfig.get(config);
if (existing) return existing;
const secret = randomBytes(32);
gatewaySecretByConfig.set(config, secret);
return secret;
}
function gatewayToken(config: ServerConfig, workspaceId: string, name: string): string {
return createHmac("sha256", gatewaySecret(config)).update(`${workspaceId}\0${name}`).digest("base64url");
}
function gatewayPath(workspaceId: string, name: string): string {
return `/mcp/managed/${encodeURIComponent(workspaceId)}/${encodeURIComponent(name)}`;
}
function runtimeConfig(config: ServerConfig, workspaceId: string, name: string, enabled: boolean): Record<string, unknown> {
return {
type: "remote",
url: `${serverOrigin(config)}${gatewayPath(workspaceId, name)}`,
enabled,
headers: { Authorization: `Bearer ${gatewayToken(config, workspaceId, name)}` },
oauth: false,
};
}
async function writeManagedRuntimeEntry(config: ServerConfig, workspaceId: string, name: string, enabled: boolean): Promise<void> {
await writeRuntimeOpencodeConfig(config, workspaceId, (current) => ({
...current,
mcp: { ...runtimeMcpMap(current), [name]: runtimeConfig(config, workspaceId, name, enabled) },
}));
}
async function removeManagedRuntimeEntry(config: ServerConfig, workspaceId: string, name: string): Promise<void> {
await writeRuntimeOpencodeConfig(config, workspaceId, (current) => {
const mcp = { ...runtimeMcpMap(current) };
delete mcp[name];
return { ...current, mcp };
});
}
export async function reconcileLocalManagedMcpRuntimeEntries(config: ServerConfig): Promise<void> {
const connections = await withVaultRead(config, (vault) => Object.values(vault.connections));
for (const connection of connections) {
if (!config.workspaces.some((workspace) => workspace.id === connection.workspaceId)) continue;
await writeManagedRuntimeEntry(config, connection.workspaceId, connection.name, connection.enabled);
}
}
export async function createLocalManagedMcpConnection(config: ServerConfig, input: CreateLocalManagedMcpInput): Promise<LocalManagedMcpPublicConnection> {
let serverUrl: string;
try {
serverUrl = new URL(input.serverUrl).toString();
} catch {
throw new ApiError(400, "managed_mcp_url_invalid", `Managed MCP server URL "${input.serverUrl}" is invalid.`);
}
try {
await assertLocalManagedMcpUrl(serverUrl);
} catch (error) {
if (!(error instanceof LocalManagedMcpPrivateUrlError)) throw error;
const message = error.message.includes("managed MCP egress requires HTTPS")
? `OpenWork-managed sign-in requires an HTTPS server URL. ${error.message}`
: error.message;
throw new ApiError(400, "managed_mcp_url_not_allowed", message);
}
const now = Date.now();
const connection = await withVaultMutation(config, (vault) => {
const key = connectionKey(input.workspaceId, input.name);
if (vault.connections[key]) {
throw new ApiError(409, "managed_mcp_exists", `Managed MCP ${input.name} already exists`);
}
const stored: StoredLocalManagedMcpConnection = {
id: randomUUID(),
workspaceId: input.workspaceId,
name: input.name,
serverUrl,
enabled: true,
oauth: {
applicationType: input.oauth.applicationType ?? "native",
...(input.oauth.requestedScopes?.length ? { requestedScopes: [...new Set(input.oauth.requestedScopes)] } : {}),
...(input.oauth.authorizationServerIssuer ? { authorizationServerIssuer: input.oauth.authorizationServerIssuer } : {}),
...(input.oauth.clientId ? { clientId: input.oauth.clientId } : {}),
...(input.oauth.clientSecret ? { clientSecret: input.oauth.clientSecret } : {}),
},
status: "needs_auth",
createdAt: now,
updatedAt: now,
authorizations: {},
};
vault.connections[key] = stored;
return stored;
});
await writeManagedRuntimeEntry(config, input.workspaceId, input.name, true);
return publicConnection(connection);
}
function sortByName(connections: LocalManagedMcpPublicConnection[]): LocalManagedMcpPublicConnection[] {
return connections.sort((left, right) => left.name.localeCompare(right.name));
}
function indexPublicConnection(entry: LocalManagedMcpIndexEntry): LocalManagedMcpPublicConnection {
return {
name: entry.name,
serverUrl: entry.serverUrl,
enabled: entry.enabled,
status: entry.status,
lastError: entry.lastError ?? null,
hasCredential: entry.hasCredential,
updatedAt: entry.updatedAt,
};
}
function isSecureStorageUnavailableError(error: unknown): boolean {
return error instanceof ApiError && error.code === "managed_mcp_secure_storage_unavailable";
}
export type LocalManagedMcpSafeConnectionList = {
connections: LocalManagedMcpPublicConnection[];
available: boolean;
recovery: LocalManagedMcpVaultRecovery | null;
};
/**
* Listing that never throws for secure-storage key or decrypt causes: a stale
* key triggers quarantine-and-rebuild, and an unavailable key serves the
* plaintext non-secret index read-only with `available: false`.
*/
export async function listLocalManagedMcpConnectionsSafe(
config: ServerConfig,
workspaceId: string,
): Promise<LocalManagedMcpSafeConnectionList> {
return withVaultQueue(config, async () => {
try {
const { vault, lastRecovery } = await loadVaultLocked(config);
return {
connections: sortByName(Object.values(vault.connections)
.filter((connection) => connection.workspaceId === workspaceId)
.map(publicConnection)),
available: true,
recovery: lastRecovery,
};
} catch (error) {
if (!isSecureStorageUnavailableError(error)) throw error;
const file = await readVaultFileState(config).catch(() => null);
return {
connections: sortByName(Object.values(file?.index ?? {})
.filter((entry) => entry.workspaceId === workspaceId)
.map(indexPublicConnection)),
available: false,
recovery: file?.lastRecovery ?? null,
};
}
});
}
export type LocalManagedMcpVaultInspection = {
status: "absent" | "ok" | "recovered" | "secure-storage-unavailable" | "unreadable";
recovery: LocalManagedMcpVaultRecovery | null;
};
/**
* Passive vault visibility for diagnostics: reads only plaintext non-secret
* fields plus key availability, never decrypts, and never throws.
*/
export async function inspectLocalManagedMcpVault(config: ServerConfig): Promise<LocalManagedMcpVaultInspection> {
let file: VaultFileState | null;
try {
file = await readVaultFileState(config);
} catch {
return { status: "unreadable", recovery: null };
}
if (!file) return { status: "absent", recovery: null };
const keyAvailable = await vaultKey(config).then(() => true, () => false);
if (!keyAvailable) return { status: "secure-storage-unavailable", recovery: file.lastRecovery };
return file.lastRecovery
? { status: "recovered", recovery: file.lastRecovery }
: { status: "ok", recovery: null };
}
export async function getLocalManagedMcpConnection(config: ServerConfig, workspaceId: string, name: string): Promise<LocalManagedMcpPublicConnection> {
return withVaultRead(config, (vault) => publicConnection(requireConnection(vault, workspaceId, name)));
}
type AuthorizationStatePayload = {
version: 1;
workspaceId: string;
name: string;
connectionId: string;
redirectUri: string;
expiresAt: number;
nonce: string;
};
function encodeStatePayload(payload: AuthorizationStatePayload): string {
return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
}
async function createAuthorizationState(config: ServerConfig, workspaceId: string, name: string): Promise<string> {
const connection = await withVaultRead(config, (vault) => requireConnection(vault, workspaceId, name));
const encoded = encodeStatePayload({
version: 1,
workspaceId,
name,
connectionId: connection.id,
redirectUri: localManagedMcpCallbackUrl(config),
expiresAt: Date.now() + 10 * 60_000,
nonce: randomBytes(24).toString("base64url"),
});
const signature = createHmac("sha256", await vaultKey(config)).update(encoded).digest("base64url");
return `${encoded}.${signature}`;
}
async function verifyAuthorizationState(config: ServerConfig, state: string): Promise<AuthorizationStatePayload> {
const [encoded, signature, extra] = state.split(".");
if (!encoded || !signature || extra) throw new ApiError(400, "managed_mcp_oauth_state_invalid", "OAuth state is invalid");
const expected = createHmac("sha256", await vaultKey(config)).update(encoded).digest();
let received: Buffer;
try {
received = Buffer.from(signature, "base64url");
} catch {
throw new ApiError(400, "managed_mcp_oauth_state_invalid", "OAuth state is invalid");
}
if (received.byteLength !== expected.byteLength || !timingSafeEqual(received, expected)) {
throw new ApiError(400, "managed_mcp_oauth_state_invalid", "OAuth state is invalid");
}
let value: unknown;
try {
value = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
} catch {
throw new ApiError(400, "managed_mcp_oauth_state_invalid", "OAuth state is invalid");
}
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new ApiError(400, "managed_mcp_oauth_state_invalid", "OAuth state is invalid");
}
const payload = value as Partial<AuthorizationStatePayload>;
if (payload.version !== 1
|| typeof payload.workspaceId !== "string"
|| typeof payload.name !== "string"
|| typeof payload.connectionId !== "string"
|| typeof payload.redirectUri !== "string"
|| typeof payload.expiresAt !== "number"
|| typeof payload.nonce !== "string"
|| payload.expiresAt <= Date.now()) {
throw new ApiError(400, "managed_mcp_oauth_state_invalid", "OAuth state is invalid or expired");
}
const connection = await withVaultRead(config, (vault) => requireConnection(vault, payload.workspaceId!, payload.name!));
if (connection.id !== payload.connectionId || payload.redirectUri !== localManagedMcpCallbackUrl(config)) {
throw new ApiError(400, "managed_mcp_oauth_state_invalid", "OAuth state does not match this connection");
}
return payload as AuthorizationStatePayload;
}
async function updateConnectionStatus(
config: ServerConfig,
workspaceId: string,
name: string,
status: LocalManagedMcpStatus,
lastError?: string,
): Promise<void> {
await withVaultMutation(config, (vault) => {
const connection = requireConnection(vault, workspaceId, name);
connection.status = status;
connection.updatedAt = Date.now();
if (lastError) {
connection.lastError = sanitizeDiagnosticString(lastError)
.replace(/([?&](?:access_token|refresh_token|client_secret|code|state)=)[^&\s]+/gi, "$1[REDACTED]")
.replace(/("(?:access_token|refresh_token|client_secret|code|state)"\s*:\s*")[^"]+/gi, "$1[REDACTED]")
.slice(0, 500);
}
else delete connection.lastError;
});
}
type CompletedRequestDiagnostic = {
requestPhase: EnterpriseMcpRequestPhase;
outcome: "succeeded" | "failed";
httpStatus?: number;
};
const NETWORK_FAILURE_CODES = new Set([
"ConnectionRefused",
"ECONNREFUSED",
"ECONNRESET",
"ENETUNREACH",
"ENOTFOUND",
"EAI_AGAIN",
"ETIMEDOUT",
"UND_ERR_CONNECT_TIMEOUT",
"UND_ERR_HEADERS_TIMEOUT",
]);
function lastHandshakeRequestDiagnostic(
diagnostics: EnterpriseMcpDiagnosticEvent[],
): CompletedRequestDiagnostic | null {
for (let index = diagnostics.length - 1; index >= 0; index -= 1) {
const event = diagnostics[index];
if (!event
|| event.kind !== "request"
|| event.outcome === "started"
|| event.requestPhase === null
|| !EXTERNAL_HANDSHAKE_REQUEST_PHASES.has(event.requestPhase)) {
continue;
}
return {
requestPhase: event.requestPhase,
outcome: event.outcome,
...(event.httpStatus === undefined ? {} : { httpStatus: event.httpStatus }),
};
}
return null;
}
function errorCauseChain(error: unknown): unknown[] {
const chain: unknown[] = [];
const seen = new Set<unknown>();
let current = error;
for (let depth = 0; depth < 6 && current !== undefined && current !== null && !seen.has(current); depth += 1) {
chain.push(current);
seen.add(current);
current = isRecord(current)
? current.cause ?? (isRecord(current.data) ? current.data.cause : undefined)
: undefined;
}
return chain;
}
function hasConcreteRequestCause(error: EnterpriseMcpClientError, diagnostic: CompletedRequestDiagnostic): boolean {
const chain = errorCauseChain(error.cause);
if (diagnostic.httpStatus !== undefined) {
return diagnostic.httpStatus >= 400
&& diagnostic.httpStatus <= 599
&& chain.some((cause) => cause instanceof OAuthError
|| cause instanceof RegistrationRejectedError
|| cause instanceof SdkHttpError);
}
return chain.some((cause) => cause instanceof LocalManagedMcpPrivateUrlError
|| (cause instanceof TypeError && cause.message === "fetch failed")
|| (isRecord(cause) && typeof cause.code === "string" && NETWORK_FAILURE_CODES.has(cause.code)));
}
function externalHandshakeApiError(
error: unknown,
diagnostics: EnterpriseMcpDiagnosticEvent[],
): ApiError | null {
if (!(error instanceof EnterpriseMcpClientError)
|| error.cause instanceof AggregateError
|| error.requestPhase === null) {
return null;
}
const recognizedHandshake = (error.operationPhase === "connection-handshake"
&& error.code === "MCP_CONNECTION_HANDSHAKE_FAILED")
|| (error.operationPhase === "authorization-callback"
&& error.code === "MCP_AUTHORIZATION_CALLBACK_FAILED");
if (!recognizedHandshake) return null;
const request = lastHandshakeRequestDiagnostic(diagnostics);
if (!request || request.outcome !== "failed" || !hasConcreteRequestCause(error, request)) return null;
return new ApiError(502, "managed_mcp_connection_failed", MANAGED_MCP_CONNECTION_FAILED_MESSAGE);
}
async function rethrowConnectionFailure(
config: ServerConfig,
workspaceId: string,
name: string,
error: unknown,
diagnostics: EnterpriseMcpDiagnosticEvent[],
internalFallback: string,
): Promise<never> {
const apiError = externalHandshakeApiError(error, diagnostics);
await updateConnectionStatus(
config,
workspaceId,
name,
"reconnect_required",
apiError?.message ?? internalFallback,
);
throw apiError ?? error;
}
async function verifyTools(
config: ServerConfig,
workspaceId: string,
name: string,
redirectUri: string,
diagnostics: EnterpriseMcpDiagnosticEvent[],
): Promise<void> {
const connection = await enterpriseConnection(config, workspaceId, name);
await enterpriseClient(diagnostics).listTools({ connection, redirectUri });
await updateConnectionStatus(config, workspaceId, name, "connected");
}
async function markReconnectWhenCredentialIsGone(
config: ServerConfig,
workspaceId: string,
name: string,
error: unknown,
fallback: string,
): Promise<boolean> {
const { hasCredential, alreadyReconnectRequired } = await withVaultRead(config, (vault) => {
const connection = requireConnection(vault, workspaceId, name);
return {
hasCredential: Boolean(connection.credential),
alreadyReconnectRequired: connection.status === "reconnect_required",
};
});
// A connection that already needs a reconnect keeps its original reason
// (e.g. the secure-storage recovery copy); later tool-discovery failures
// must not overwrite it. Every other status still records this error.
if (!hasCredential && !alreadyReconnectRequired) {
await updateConnectionStatus(config, workspaceId, name, "reconnect_required", error instanceof Error ? error.message : fallback);
}
return !hasCredential;
}
export async function startLocalManagedMcpAuthorization(config: ServerConfig, workspaceId: string, name: string) {
await withVaultMutation(config, (vault) => {
const connection = requireConnection(vault, workspaceId, name);
connection.enabled = true;
connection.updatedAt = Date.now();
});
await updateConnectionStatus(config, workspaceId, name, "connecting");
await writeManagedRuntimeEntry(config, workspaceId, name, true);
const authorizationId = await createAuthorizationState(config, workspaceId, name);
const redirectUri = localManagedMcpCallbackUrl(config);
const diagnostics: EnterpriseMcpDiagnosticEvent[] = [];
try {
const connection = await enterpriseConnection(config, workspaceId, name);
const result = await enterpriseClient(diagnostics).connect({ connection, redirectUri, authorizationId });
if (result.status === "connected") {
await verifyTools(config, workspaceId, name, redirectUri, diagnostics);
return { status: "connected" as const };
}
await updateConnectionStatus(config, workspaceId, name, "needs_auth");
return { status: "needs_auth" as const, authorizeUrl: result.authorizeUrl };
} catch (error) {
return rethrowConnectionFailure(config, workspaceId, name, error, diagnostics, "Connection failed");
}
}
export async function completeLocalManagedMcpAuthorization(
config: ServerConfig,
state: string,
code: string,
): Promise<{ connection: LocalManagedMcpPublicConnection; workspaceId: string }> {
const payload = await verifyAuthorizationState(config, state);
const diagnostics: EnterpriseMcpDiagnosticEvent[] = [];
try {
const connection = await enterpriseConnection(config, payload.workspaceId, payload.name);
await enterpriseClient(diagnostics).completeAuthorization({
connection,
redirectUri: payload.redirectUri,
code,
authorizationId: state,
});
await verifyTools(config, payload.workspaceId, payload.name, payload.redirectUri, diagnostics);
await writeManagedRuntimeEntry(config, payload.workspaceId, payload.name, true);
return {
connection: await getLocalManagedMcpConnection(config, payload.workspaceId, payload.name),
workspaceId: payload.workspaceId,
};
} catch (error) {
return rethrowConnectionFailure(config, payload.workspaceId, payload.name, error, diagnostics, "Authorization failed");
}
}
export async function setLocalManagedMcpEnabled(
config: ServerConfig,
workspaceId: string,
name: string,
enabled: boolean,
): Promise<boolean> {
const updated = await withVaultMutation(config, (vault) => {
const connection = vault.connections[connectionKey(workspaceId, name)];
if (!connection) return false;
connection.enabled = enabled;
connection.updatedAt = Date.now();
return true;
}, (changed) => changed);
if (updated) await writeManagedRuntimeEntry(config, workspaceId, name, enabled);
return updated;
}
export async function disconnectLocalManagedMcp(config: ServerConfig, workspaceId: string, name: string): Promise<boolean> {
const exists = await withVaultRead(config, (vault) => Boolean(vault.connections[connectionKey(workspaceId, name)]));
if (!exists) return false;
await withVaultMutation(config, (vault) => {
const connection = requireConnection(vault, workspaceId, name);
delete connection.credential;
connection.authorizations = {};
connection.enabled = false;
connection.status = "needs_auth";
delete connection.lastError;
connection.updatedAt = Date.now();
});
await writeManagedRuntimeEntry(config, workspaceId, name, false);
return true;
}
export async function deleteLocalManagedMcp(config: ServerConfig, workspaceId: string, name: string): Promise<boolean> {
const removed = await withVaultMutation(config, (vault) => {
const key = connectionKey(workspaceId, name);
if (!vault.connections[key]) return false;
delete vault.connections[key];
return true;
}, (changed) => changed);
if (removed) await removeManagedRuntimeEntry(config, workspaceId, name);
return removed;
}
function bearerToken(request: Request): string | null {
return request.headers.get("authorization")?.match(/^Bearer\s+(.+)$/i)?.[1] ?? null;
}
export function authorizeLocalManagedMcpGateway(config: ServerConfig, request: Request, workspaceId: string, name: string): boolean {
const supplied = bearerToken(request);
if (!supplied) return false;
const expected = gatewayToken(config, workspaceId, name);
const suppliedBytes = Buffer.from(supplied);
const expectedBytes = Buffer.from(expected);
return suppliedBytes.byteLength === expectedBytes.byteLength && timingSafeEqual(suppliedBytes, expectedBytes);
}
export async function handleLocalManagedMcpGateway(
config: ServerConfig,
request: Request,
workspaceId: string,
name: string,
): Promise<Response> {
if (!authorizeLocalManagedMcpGateway(config, request, workspaceId, name)) {
return new Response(JSON.stringify({ error: "unauthorized" }), {
status: 401,
headers: { "content-type": "application/json" },
});
}
const stored = await withVaultRead(config, (vault) => requireConnection(vault, workspaceId, name));
if (!stored.enabled) {
return new Response(JSON.stringify({ error: "connection_disabled" }), {
status: 503,
headers: { "content-type": "application/json" },
});
}
const redirectUri = localManagedMcpCallbackUrl(config);
const server = new Server(
{ name: `openwork-local-${name}`, version: "1.0.0" },
{ capabilities: { tools: {} } },
);
server.setRequestHandler(ListToolsRequestSchema, async () => {
try {
const connection = await enterpriseConnection(config, workspaceId, name);
return { tools: await enterpriseClient().listTools({ connection, redirectUri }) };
} catch (error) {
const reconnect = await markReconnectWhenCredentialIsGone(config, workspaceId, name, error, "Tool discovery failed");
throw new McpError(
ErrorCode.InternalError,
reconnect
? "This MCP connection needs to be reconnected in OpenWork."
: "This MCP tool catalog could not be loaded. Retry the request.",
);
}
});
server.setRequestHandler(CallToolRequestSchema, async (call) => {
try {
const connection = await enterpriseConnection(config, workspaceId, name);
const args = call.params.arguments ?? {};
return await enterpriseClient().callTool({
connection,
redirectUri,
toolName: call.params.name,
arguments: args,
});
} catch (error) {
const reconnect = await markReconnectWhenCredentialIsGone(config, workspaceId, name, error, "Tool execution failed");
throw new McpError(
ErrorCode.InternalError,
reconnect
? "This MCP tool could not run. Reconnect it in OpenWork and retry."
: "This MCP tool could not run. Review the tool input or provider response and retry.",
);
}
});
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true,
enableDnsRebindingProtection: true,
allowedHosts: [`127.0.0.1:${config.port}`, `localhost:${config.port}`],
});
await server.connect(transport);
return transport.handleRequest(request);
}