evOo-local/packages/adapters/src/serenity-client.ts
cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

550 lines
18 KiB
TypeScript

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<T> = { 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<T>(
config: SerenityConnectionConfig,
signal: AbortSignal | undefined,
run: (client: Client, signal: AbortSignal) => Promise<T>,
network: SerenityNetworkDependencies = {},
): Promise<T> {
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<SerenityResult<void>> {
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<SerenityResult<SerenityRecallFact[]>> {
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<SerenityResult<SerenityRememberResult>> {
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<SerenityResult<SerenityForgetResult>> {
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;
}