SysDeck/klanker-gate/packages/mcp/src/codemode/codegen.ts

427 lines
14 KiB
TypeScript
Executable File

import { type MCPToolCatalogEntry, qualifyToolName } from "../registry.ts";
import {
clamp,
makeUniqueNamer,
MAX_DESCRIPTION,
MAX_PARAMS,
MAX_TOOL_NAME,
MAX_TYPE_LABEL,
sanitizeIdentifier,
shortHash,
} from "./sanitize.ts";
export { sanitizeIdentifier, shortHash };
/** Binding level for the generated SDK. */
export type BindingLevel = "server-level" | "tool-level";
/** One generated, inert stub module in the virtual file system. */
export interface GeneratedFile {
/** Sanitized VFS path (no `.`/`/`/`\` traversal in any segment). */
path: string;
/** Owning MCP client id (raw — a data field, never emitted into code). */
server: string;
/** Raw tool names bound in this module. */
tools: string[];
/** Python stub text. Inert: never executed while the executor is off. */
source: string;
}
interface DerivedParam {
rawName: string;
required: boolean;
typeLabel: string;
}
type Derived =
| { mode: "named"; params: DerivedParam[] }
| { mode: "opaque" };
/**
* Reads ONLY `properties`/`required`/`type` from a JSON Schema, treating it as
* opaque data (no `$ref` resolution, no network, no execution). Unknown,
* non-object, or oversized schemas degrade to a single opaque `args` parameter
* (design §2.3).
*/
function deriveParams(schema: Record<string, unknown> | undefined): Derived {
if (!schema || typeof schema !== "object") {
return { mode: "opaque" };
}
const type = schema["type"];
if (typeof type === "string" && type !== "object") {
return { mode: "opaque" };
}
const props = schema["properties"];
if (!props || typeof props !== "object") {
return { mode: "opaque" };
}
const propsObj = props as Record<string, unknown>;
const names = Object.keys(propsObj);
if (names.length > MAX_PARAMS) {
return { mode: "opaque" };
}
const requiredRaw = schema["required"];
const requiredSet = new Set(
Array.isArray(requiredRaw)
? requiredRaw.filter((x): x is string => typeof x === "string")
: [],
);
const params: DerivedParam[] = names.map((name) => {
const prop = propsObj[name];
const propType = prop && typeof prop === "object"
? (prop as Record<string, unknown>)["type"]
: undefined;
return {
rawName: name,
required: requiredSet.has(name),
typeLabel: typeof propType === "string"
? clamp(propType, MAX_TYPE_LABEL)
: "any",
};
});
// Deterministic order: required first (alpha), then optional (alpha). Required
// must precede optional so the generated Python signature stays valid.
params.sort((a, b) => {
if (a.required !== b.required) {
return a.required ? -1 : 1;
}
return a.rawName < b.rawName ? -1 : a.rawName > b.rawName ? 1 : 0;
});
return { mode: "named", params };
}
/**
* Renders one inert function stub. Untrusted `description`/param metadata are
* emitted only inside a JSON-encoded docstring literal; the dispatch target is
* the server-constructed `qualifiedName`, JSON-encoded.
*/
function renderFunction(entry: MCPToolCatalogEntry, funcName: string): string {
// Untrusted `name`/`description` are length-capped BEFORE entering output so a
// hostile MCP server cannot memory-amplify `GET /vfs`; sanitization and
// JSON-encoding still run on top.
const rawName = clamp(entry.name, MAX_TOOL_NAME);
const qualified = qualifyToolName(entry.clientId, rawName);
const derived = deriveParams(entry.inputSchema);
const docLines: string[] = [];
if (entry.description) {
docLines.push(clamp(entry.description, MAX_DESCRIPTION));
}
let signature: string;
let argsExpr: string;
if (derived.mode === "opaque") {
signature = "args=None";
argsExpr = "args";
docLines.push("", "Args: forwarded to the tool as a single object.");
} else {
const namer = makeUniqueNamer();
const rendered = derived.params.map((p) => {
const raw = clamp(p.rawName, MAX_TOOL_NAME);
return {
raw,
safe: namer(raw, `${qualified}#${raw}`),
required: p.required,
typeLabel: p.typeLabel,
};
});
const required = rendered.filter((p) => p.required);
const optional = rendered.filter((p) => !p.required);
signature = [
...required.map((p) => p.safe),
...optional.map((p) => `${p.safe}=None`),
].join(", ");
const dictEntries = rendered.map((p) =>
`${JSON.stringify(p.raw)}: ${p.safe}`
);
argsExpr = dictEntries.length ? `{${dictEntries.join(", ")}}` : "{}";
if (rendered.length) {
docLines.push("", "Args:");
for (const p of rendered) {
docLines.push(
` ${p.raw} (${p.typeLabel})${p.required ? "" : ", optional"}`,
);
}
}
}
const docstring = JSON.stringify(docLines.join("\n"));
return [
`def ${funcName}(${signature}):`,
` ${docstring}`,
` return __frosty_call(${JSON.stringify(qualified)}, ${argsExpr})`,
].join("\n");
}
/** Fixed, untrusted-free module header. `safeServer` is already sanitized. */
function moduleHeader(path: string, safeServer: string): string {
return [
`# Code Mode SDK — ${path}`,
`# Server: ${safeServer}`,
"# Generated from the gateway MCP catalog. This stub is inert metadata: it",
"# is never executed while the Code Mode executor is disabled",
"# (FROSTY_CODE_MODE=off). `__frosty_call(qualified_name, args)` is provided",
"# by the sandbox broker at runtime; it is not defined here.",
"",
].join("\n");
}
function groupByClient(
catalog: MCPToolCatalogEntry[],
): Map<string, MCPToolCatalogEntry[]> {
const groups = new Map<string, MCPToolCatalogEntry[]>();
for (const entry of catalog) {
const list = groups.get(entry.clientId) ?? [];
list.push(entry);
groups.set(entry.clientId, list);
}
return groups;
}
/** server-level: one module per client, one function per exposed tool. */
function generateServerLevel(catalog: MCPToolCatalogEntry[]): GeneratedFile[] {
const groups = groupByClient(catalog);
const clientIds = [...groups.keys()].sort();
const fileNamer = makeUniqueNamer();
const files: GeneratedFile[] = [];
for (const clientId of clientIds) {
const entries = [...groups.get(clientId)!].sort((a, b) =>
a.name < b.name ? -1 : a.name > b.name ? 1 : 0
);
const safeServer = fileNamer(clientId, clientId);
const path = `servers/${safeServer}.py`;
const funcNamer = makeUniqueNamer();
const body = entries
.map((entry) => {
const name = clamp(entry.name, MAX_TOOL_NAME);
return renderFunction(
entry,
funcNamer(name, qualifyToolName(entry.clientId, name)),
);
})
.join("\n\n\n");
files.push({
path,
server: clientId,
tools: entries.map((e) => clamp(e.name, MAX_TOOL_NAME)),
source: moduleHeader(path, safeServer) + "\n" + body + "\n",
});
}
return files;
}
/** tool-level: one module per qualified tool, keyed collision-free on it. */
function generateToolLevel(catalog: MCPToolCatalogEntry[]): GeneratedFile[] {
const entries = [...catalog].sort((a, b) => {
const qa = qualifyToolName(a.clientId, a.name);
const qb = qualifyToolName(b.clientId, b.name);
return qa < qb ? -1 : qa > qb ? 1 : 0;
});
const fileNamer = makeUniqueNamer();
return entries.map((entry) => {
const name = clamp(entry.name, MAX_TOOL_NAME);
const qualified = qualifyToolName(entry.clientId, name);
const safeBase = fileNamer(qualified, qualified);
const path = `tools/${safeBase}.py`;
const funcNamer = makeUniqueNamer();
const body = renderFunction(entry, funcNamer(name, qualified));
return {
path,
server: entry.clientId,
tools: [name],
source: moduleHeader(path, safeBase) + "\n" + body + "\n",
};
});
}
/**
* Deterministically generates the inert Python stub modules for a catalog. Same
* catalog ⇒ byte-identical output (stable ordering, no timestamps, no random
* ids). Python stays the DISPLAY/preview artifact (design §5.2); the executable
* path uses the TypeScript emission below.
*/
export function generateFiles(
catalog: MCPToolCatalogEntry[],
binding: BindingLevel,
): GeneratedFile[] {
return binding === "tool-level"
? generateToolLevel(catalog)
: generateServerLevel(catalog);
}
interface TsRenderedTool {
/** Sanitized method identifier (code position). */
safeMethod: string;
/** Gateway-constructed dispatch target (JSON-encoded into a literal). */
qualified: string;
/** Fixed param type — untrusted param NAMES appear only as quoted keys. */
paramsType: string;
/** JSON-encoded doc string; all untrusted text is confined to this literal. */
doc: string;
}
/** Renders one TS tool binding, mirroring the Python renderer's derivations. */
function renderToolTs(
entry: MCPToolCatalogEntry,
methodName: string,
): TsRenderedTool {
const rawName = clamp(entry.name, MAX_TOOL_NAME);
const qualified = qualifyToolName(entry.clientId, rawName);
const derived = deriveParams(entry.inputSchema);
const docLines: string[] = [];
if (entry.description) {
docLines.push(clamp(entry.description, MAX_DESCRIPTION));
}
let paramsType: string;
if (derived.mode === "opaque") {
paramsType = "Record<string, unknown>";
docLines.push("", "Args: forwarded to the tool as a single object.");
} else {
const namer = makeUniqueNamer();
const rendered = derived.params.map((p) => {
const raw = clamp(p.rawName, MAX_TOOL_NAME);
return {
raw,
safe: namer(raw, `${qualified}#${raw}`),
required: p.required,
typeLabel: p.typeLabel,
};
});
// Untrusted param names appear ONLY as JSON-encoded (quoted) object keys; the
// value type is a fixed `unknown` so no untrusted `type` label enters code.
const fields = rendered.map((p) =>
`${JSON.stringify(p.raw)}${p.required ? "" : "?"}: unknown`
);
paramsType = fields.length
? `{ ${fields.join("; ")} }`
: "Record<string, unknown>";
if (rendered.length) {
docLines.push("", "Args:");
for (const p of rendered) {
docLines.push(
` ${p.raw} (${p.typeLabel})${p.required ? "" : ", optional"}`,
);
}
}
}
return {
safeMethod: methodName,
qualified,
paramsType,
doc: JSON.stringify(docLines.join("\n")),
};
}
/** Fixed, untrusted-free TS module header. Line comments only (no block comment). */
function moduleHeaderTs(path: string, safeName: string): string {
return [
`// Code Mode SDK (TypeScript) — ${path}`,
`// Server: ${safeName}`,
"// The SDK the model authors against. At runtime the sandbox worker injects",
"// the executable equivalents as `sdk.<server>.<tool>` (server-grouped, same",
"// sanitized names) and `__frostyCall(qualifiedName, args)` is provided by the",
"// broker on the main isolate. This text is inert: never eval'd or imported.",
"",
].join("\n");
}
/** server-level TS: one module per client exporting an `sdk.<server>` object. */
function generateServerLevelTs(
catalog: MCPToolCatalogEntry[],
): GeneratedFile[] {
const groups = groupByClient(catalog);
const clientIds = [...groups.keys()].sort();
const fileNamer = makeUniqueNamer();
const files: GeneratedFile[] = [];
for (const clientId of clientIds) {
const entries = [...groups.get(clientId)!].sort((a, b) =>
a.name < b.name ? -1 : a.name > b.name ? 1 : 0
);
const safeServer = fileNamer(clientId, clientId);
const path = `servers/${safeServer}.ts`;
const methodNamer = makeUniqueNamer();
const rendered = entries.map((entry) => {
const name = clamp(entry.name, MAX_TOOL_NAME);
return renderToolTs(
entry,
methodNamer(name, qualifyToolName(entry.clientId, name)),
);
});
const methods = rendered
.map((r) =>
` ${r.safeMethod}(args: ${r.paramsType}): Promise<string> {\n` +
` return __frostyCall(${JSON.stringify(r.qualified)}, args);\n` +
` },`
)
.join("\n");
const docs = rendered
.map((r) => ` ${JSON.stringify(r.safeMethod)}: ${r.doc},`)
.join("\n");
const source = [
moduleHeaderTs(path, safeServer),
`export const ${safeServer} = {`,
methods,
`};`,
"",
"/** Tool docs (inert data — untrusted text lives only in these literals). */",
"export const descriptions: Record<string, string> = {",
docs,
"};",
"",
].join("\n");
files.push({
path,
server: clientId,
tools: entries.map((e) => clamp(e.name, MAX_TOOL_NAME)),
source,
});
}
return files;
}
/** tool-level TS: one module per qualified tool exporting a single function. */
function generateToolLevelTs(catalog: MCPToolCatalogEntry[]): GeneratedFile[] {
const entries = [...catalog].sort((a, b) => {
const qa = qualifyToolName(a.clientId, a.name);
const qb = qualifyToolName(b.clientId, b.name);
return qa < qb ? -1 : qa > qb ? 1 : 0;
});
const fileNamer = makeUniqueNamer();
return entries.map((entry) => {
const name = clamp(entry.name, MAX_TOOL_NAME);
const qualified = qualifyToolName(entry.clientId, name);
const safeBase = fileNamer(qualified, qualified);
const path = `tools/${safeBase}.ts`;
const methodNamer = makeUniqueNamer();
const r = renderToolTs(entry, methodNamer(name, qualified));
const source = [
moduleHeaderTs(path, safeBase),
`export function ${r.safeMethod}(args: ${r.paramsType}): Promise<string> {`,
` return __frostyCall(${JSON.stringify(r.qualified)}, args);`,
`}`,
"",
"/** Tool doc (inert data — untrusted text lives only in this literal). */",
`export const description: string = ${r.doc};`,
"",
].join("\n");
return { path, server: entry.clientId, tools: [name], source };
});
}
/**
* Deterministically generates the inert TypeScript SDK modules — the executable
* authoring surface (design §5.2). Same catalog ⇒ byte-identical output. Reuses
* the exact clamp/sanitizeIdentifier/MAX_* of the Python path (M2), so a hostile
* field cannot be neutralized for display yet amplify or inject on this path.
*/
export function generateFilesTs(
catalog: MCPToolCatalogEntry[],
binding: BindingLevel,
): GeneratedFile[] {
return binding === "tool-level"
? generateToolLevelTs(catalog)
: generateServerLevelTs(catalog);
}