import { isIP } from "node:net"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { isLocalMcpHost } from "@rakazo/contracts"; import { Agent } from "undici"; import { combineSignals } from "./connector-safety.js"; import { createAddressCheckedLookup, isCloudMetadataAddress, isPrivateAddress, type ResolvedAddress, type ResolveHostname, } from "./network-address.js"; import { createSafeRemoteFetch, type RemoteTransportDependencies, type SafeRemoteFetch, } from "./remote-mcp.js"; import { dispatcherFetch } from "./undici-fetch.js"; import { isBlockedHostname } from "./web-ssrf.js"; const SERENITY_TIMEOUT_MS = 15_000; export const MAX_SERENITY_FACT_CHARS = 10_000; export const MAX_SERENITY_PROVENANCE_CHARS = 500; export type SerenityNetworkDependencies = RemoteTransportDependencies; export interface SerenityConnectionConfig { endpoint: string; token: string; /** Set when prepare classifies the endpoint as private LAN/DNS. */ endpointTrust?: "private" | "public"; } export type SerenityResult = { ok: true; value: T } | { ok: false; error: string }; export interface SerenityRecallFact { factId: string; fact: string; provenance: string; kind?: string; entitySlug?: string | null; } export interface SerenityRememberResult { id: string; status: string; statusText?: string; } export interface SerenityForgetResult { id: string; expired: boolean; reason: string | null; } /** Endpoints are route prefixes: no credentials, query, or fragment. */ export function parseSerenityEndpoint(endpoint: string): URL { let url: URL; try { url = new URL(endpoint); } catch { throw new Error("Serenity endpoint must be a valid HTTP(S) URL."); } if ( (url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password || url.href.includes("?") || url.href.includes("#") ) { throw new Error( "Serenity endpoint must use HTTP(S) without credentials, a query, or a fragment.", ); } return url; } export function normalizeSerenityEndpoint(endpoint: string): string { return normalizeSerenityEndpointUrl(endpoint).href.replace(/\/+$/, ""); } function normalizeSerenityEndpointUrl(endpoint: string): URL { const url = parseSerenityEndpoint(endpoint); assertAllowedSerenityEndpoint(url); const path = url.pathname.replace(/\/+$/, "") || ""; url.pathname = path.endsWith("/mcp") ? path : `${path}/mcp`; return url; } function hostnameOf(url: URL): string { return url.hostname.replace(/^\[|\]$/g, ""); } /** Loopback, RFC1918/ULA literals, or HTTP need deployment-owner authorization. */ export function serenityEndpointRequiresDeploymentOwner(endpoint: string): boolean { const url = parseSerenityEndpoint(endpoint); const host = hostnameOf(url); if (isLocalMcpHost(host) || isBlockedHostname(host)) return true; if (isIP(host) !== 0) return isPrivateAddress(host); // Unresolved public-looking hostnames may still be LAN DNS over HTTP. return url.protocol === "http:"; } /** * Classifies endpoint trust for fetch + owner gating. Public-looking HTTPS names * that resolve only to private addresses are treated as private LAN endpoints. */ export async function classifySerenityEndpointTrust( endpoint: string, resolveHostname?: ResolveHostname, ): Promise<"private" | "public"> { const url = parseSerenityEndpoint(endpoint); if (serenityEndpointRequiresDeploymentOwner(url.href)) return "private"; const host = hostnameOf(url); const resolve = resolveHostname ?? (async (hostname: string) => { const { lookup } = await import("node:dns/promises"); return lookup(hostname, { all: true, verbatim: true }); }); const addresses = await resolve(host); if (addresses.length === 0) { throw new Error("Serenity endpoint did not resolve to an address."); } if (addresses.some((entry) => isCloudMetadataAddress(entry.address))) { throw new Error("Serenity endpoint targets a blocked address."); } if (addresses.some((entry) => isPrivateAddress(entry.address))) return "private"; return "public"; } function assertAllowedSerenityEndpoint(url: URL): void { const host = hostnameOf(url); if (isCloudMetadataAddress(host)) { throw new Error("Serenity endpoint targets a blocked address."); } if (url.protocol === "http:") { // Bearer tokens stay on the wire; only loopback may use cleartext HTTP. if (!isLocalMcpHost(host)) { throw new Error( "Serenity HTTP endpoints are limited to loopback (localhost / 127.0.0.1 / ::1); use HTTPS for LAN or public hosts.", ); } return; } } function mcpTextPayload(result: unknown): unknown { if (!result || typeof result !== "object") return undefined; const row = result as { content?: unknown; structuredContent?: unknown; isError?: boolean; }; if (row.structuredContent && typeof row.structuredContent === "object") { return row.structuredContent; } const content = row.content; if (!Array.isArray(content)) return undefined; for (const part of content) { if (!part || typeof part !== "object") continue; const entry = part as { type?: unknown; text?: unknown }; if (entry.type === "text" && typeof entry.text === "string") { try { return JSON.parse(entry.text) as unknown; } catch { return entry.text; } } } return undefined; } function toolCallIsError(result: unknown): boolean { return Boolean(result && typeof result === "object" && (result as { isError?: unknown }).isError); } function verbErrorMessage(payload: unknown, fallback: string): string { if (!payload || typeof payload !== "object") return fallback; const row = payload as { error?: unknown; message?: unknown; suggestion?: unknown }; const parts = [ typeof row.message === "string" ? row.message : null, typeof row.error === "string" ? `(${row.error})` : null, typeof row.suggestion === "string" ? row.suggestion : null, ].filter(Boolean); return parts.length > 0 ? parts.join(" ") : fallback; } /** * Owner-gated private endpoints: IP literals use plain fetch; hostnames pin DNS * to private non-metadata addresses so persisted endpointTrust cannot rebind to * IMDS. Public HTTPS uses createSafeRemoteFetch (public-only pin). */ function serenityFetchPath( config: SerenityConnectionConfig, url: URL, ): "private-literal" | "private-dns" | "public" { if (config.endpointTrust === "private" || serenityEndpointRequiresDeploymentOwner(url.href)) { return isIP(hostnameOf(url)) !== 0 ? "private-literal" : "private-dns"; } return "public"; } function defaultResolveHostname(): ResolveHostname { return async (hostname: string) => { const { lookup } = await import("node:dns/promises"); return lookup(hostname, { all: true, verbatim: true }); }; } function assertPrivateLanAddresses(addresses: ResolvedAddress[]): void { if (addresses.length === 0) { throw new Error("Serenity endpoint did not resolve to an address."); } if (addresses.some((entry) => isCloudMetadataAddress(entry.address))) { throw new Error("Serenity endpoint targets a blocked address."); } if (addresses.some((entry) => !isPrivateAddress(entry.address))) { throw new Error("Serenity endpoint no longer resolves to a private address."); } } /** * Pin owner-gated hostname fetches to one private LAN answer set for the request. * Resolve once, then reuse those addresses in the dispatcher lookup so a second DNS * answer cannot redirect the bearer token to a different internal host. */ function createSerenityPrivateLanFetch( baseFetch: typeof globalThis.fetch = dispatcherFetch, resolve: ResolveHostname = defaultResolveHostname(), ): SafeRemoteFetch { const privateFetch = async (input: string | URL | Request, init?: RequestInit) => { if (typeof input !== "string" && !(input instanceof URL)) { throw new Error("Serenity fetch requires a URL, not a Request"); } const url = new URL(String(input)); const pinned = await resolve(hostnameOf(url)); assertPrivateLanAddresses(pinned); const dispatcher = new Agent({ connect: { lookup: createAddressCheckedLookup(async () => pinned, assertPrivateLanAddresses), }, }); try { const response = await baseFetch(url, { ...init, redirect: "manual", dispatcher, } as RequestInit & { dispatcher: Agent }); if (response.status >= 300 && response.status < 400) { throw new Error("Serenity MCP redirects are not permitted; configure the final URL."); } return response; } catch (error) { throw new Error( `Could not reach ${url.host}${error instanceof Error ? `: ${error.message}` : ""}`, { cause: error }, ); } finally { await dispatcher.close().catch(() => undefined); } }; const result = privateFetch as SafeRemoteFetch; result.close = async () => undefined; return result; } async function withSerenityClient( config: SerenityConnectionConfig, signal: AbortSignal | undefined, run: (client: Client, signal: AbortSignal) => Promise, network: SerenityNetworkDependencies = {}, ): Promise { const url = normalizeSerenityEndpointUrl(config.endpoint); const requestSignal = combineSignals(signal, AbortSignal.timeout(SERENITY_TIMEOUT_MS)); const path = serenityFetchPath(config, url); const resolve = network.resolveHostname ?? defaultResolveHostname(); const pinnedFetch: SafeRemoteFetch | null = path === "public" ? createSafeRemoteFetch(network.fetch, network.resolveHostname) : path === "private-dns" ? createSerenityPrivateLanFetch(network.fetch, resolve) : null; const localFetch = network.fetch ?? globalThis.fetch; const transport = new StreamableHTTPClientTransport(url, { requestInit: { headers: { Authorization: `Bearer ${config.token}`, Accept: "application/json, text/event-stream", }, redirect: "manual", signal: requestSignal, }, fetch: async (input, init) => { if (pinnedFetch) return pinnedFetch(input, init); const response = await localFetch(input, { ...init, redirect: "manual" }); if (response.status >= 300 && response.status < 400) { throw new Error("Serenity MCP redirects are not permitted; configure the final URL."); } return response; }, }); const client = new Client({ name: "rakazo", version: "0.1.0" }, { capabilities: {} }); try { await client.connect(transport, { signal: requestSignal, timeout: SERENITY_TIMEOUT_MS }); return await run(client, requestSignal); } finally { await client.close().catch(() => undefined); await pinnedFetch?.close().catch(() => undefined); } } export async function probeSerenity( config: SerenityConnectionConfig, signal?: AbortSignal, network?: SerenityNetworkDependencies, options: { requireWrites?: boolean } = {}, ): Promise> { try { await withSerenityClient( config, signal, async (client, requestSignal) => { const listed = await client.listTools( {}, { signal: requestSignal, timeout: SERENITY_TIMEOUT_MS }, ); const names = new Set(listed.tools.map((tool) => tool.name)); const required = options.requireWrites === true ? (["recall", "remember", "forget"] as const) : (["recall"] as const); for (const tool of required) { if (!names.has(tool)) { throw new Error(`Serenity MCP is missing the "${tool}" tool.`); } } }, network, ); return { ok: true, value: undefined }; } catch (error) { return { ok: false, error: `Serenity is unreachable: ${error instanceof Error ? error.message : String(error)}`, }; } } export async function recallSerenity( query: string, config: SerenityConnectionConfig, options: { limit: number; entity?: string; signal?: AbortSignal; network?: SerenityNetworkDependencies; }, ): Promise> { try { const payload = await withSerenityClient( config, options.signal, async (client, signal) => { const result = await client.callTool( { name: "recall", arguments: { query, limit: Math.min(Math.max(1, Math.floor(options.limit)), 50), ...(options.entity ? { entity: options.entity } : {}), }, }, undefined, { signal, timeout: SERENITY_TIMEOUT_MS }, ); const body = mcpTextPayload(result); if (toolCallIsError(result)) { throw new Error(verbErrorMessage(body, "Serenity recall failed")); } return body; }, options.network, ); return { ok: true, value: parseRecallFacts(payload).slice(0, options.limit) }; } catch (error) { return { ok: false, error: `Serenity recall failed: ${error instanceof Error ? error.message : String(error)}`, }; } } export async function rememberSerenity( fact: string, provenance: string, config: SerenityConnectionConfig, options: { entity?: string; signal?: AbortSignal; network?: SerenityNetworkDependencies; } = {}, ): Promise> { const trimmedFact = fact.trim().slice(0, MAX_SERENITY_FACT_CHARS); const trimmedProvenance = provenance.trim().slice(0, MAX_SERENITY_PROVENANCE_CHARS); if (!trimmedFact) return { ok: false, error: "fact is required" }; if (!trimmedProvenance) return { ok: false, error: "provenance is required" }; try { const payload = await withSerenityClient( config, options.signal, async (client, signal) => { const result = await client.callTool( { name: "remember", arguments: { fact: trimmedFact, provenance: trimmedProvenance, ...(options.entity ? { entity: options.entity } : {}), }, }, undefined, { signal, timeout: SERENITY_TIMEOUT_MS }, ); const body = mcpTextPayload(result); if (toolCallIsError(result)) { throw new Error(verbErrorMessage(body, "Serenity remember failed")); } return body; }, options.network, ); if (!payload || typeof payload !== "object") { return { ok: false, error: "Serenity remember returned an unexpected payload" }; } const row = payload as { id?: unknown; status?: unknown; status_text?: unknown }; if (typeof row.id !== "string" || typeof row.status !== "string") { return { ok: false, error: "Serenity remember returned an unexpected payload" }; } return { ok: true, value: { id: row.id, status: row.status, ...(typeof row.status_text === "string" ? { statusText: row.status_text } : {}), }, }; } catch (error) { return { ok: false, error: `Serenity remember failed: ${error instanceof Error ? error.message : String(error)}`, }; } } export async function forgetSerenity( id: string, config: SerenityConnectionConfig, options: { reason?: string; signal?: AbortSignal; network?: SerenityNetworkDependencies; } = {}, ): Promise> { const factId = id.trim(); if (!factId) return { ok: false, error: "id is required" }; try { const payload = await withSerenityClient( config, options.signal, async (client, signal) => { const result = await client.callTool( { name: "forget", // MEMORY_VERBS forget accepts only id + optional reason (additionalProperties: false). arguments: { id: factId, ...(options.reason?.trim() ? { reason: options.reason.trim() } : {}), }, }, undefined, { signal, timeout: SERENITY_TIMEOUT_MS }, ); const body = mcpTextPayload(result); if (toolCallIsError(result)) { throw new Error(verbErrorMessage(body, "Serenity forget failed")); } return body; }, options.network, ); if (!payload || typeof payload !== "object") { return { ok: false, error: "Serenity forget returned an unexpected payload" }; } const row = payload as { id?: unknown; expired?: unknown; reason?: unknown }; if (typeof row.id !== "string" || typeof row.expired !== "boolean") { return { ok: false, error: "Serenity forget returned an unexpected payload" }; } return { ok: true, value: { id: row.id, expired: row.expired, reason: typeof row.reason === "string" ? row.reason : null, }, }; } catch (error) { return { ok: false, error: `Serenity forget failed: ${error instanceof Error ? error.message : String(error)}`, }; } } function parseRecallFacts(payload: unknown): SerenityRecallFact[] { if (!payload || typeof payload !== "object") return []; const facts = (payload as { facts?: unknown }).facts; if (!Array.isArray(facts)) return []; const parsed: SerenityRecallFact[] = []; for (const item of facts) { if (!item || typeof item !== "object") continue; const row = item as { fact_id?: unknown; fact?: unknown; provenance?: unknown; kind?: unknown; entity_slug?: unknown; }; const fact = typeof row.fact === "string" ? row.fact.trim().slice(0, MAX_SERENITY_FACT_CHARS) : ""; const factId = typeof row.fact_id === "string" ? row.fact_id : ""; const provenance = typeof row.provenance === "string" ? row.provenance.trim().slice(0, MAX_SERENITY_PROVENANCE_CHARS) : ""; if (!fact || !factId) continue; parsed.push({ factId, fact, provenance, ...(typeof row.kind === "string" ? { kind: row.kind } : {}), ...(row.entity_slug === null || typeof row.entity_slug === "string" ? { entitySlug: row.entity_slug } : {}), }); } return parsed; }