UNPKG

run402-mcp

Version:

MCP server for Run402 — full-stack backend infrastructure for AI agents: Postgres, auth, storage, serverless functions and atomic deploys. Paid with x402/MPP. Includes $0.03 image generation.

183 lines • 8.73 kB
/** * `ai` namespace — project-scoped AI add-ons (translation, moderation) and * wallet-scoped image generation. */ import { ApiError, LocalError, isRun402Error } from "../errors.js"; import { requireProjectCredentials } from "../project-credentials.js"; const IMAGE_ASPECTS = ["square", "landscape", "portrait"]; /** The gateway's answer when a multi-org principal names no paying org on the Lightning rail. */ export const ORGANIZATION_SELECTION_REQUIRED = "ORGANIZATION_SELECTION_REQUIRED"; /** `details.org_ids` off an `ORGANIZATION_SELECTION_REQUIRED` error, else `[]`. */ export function organizationCandidatesFromError(err) { if (!isRun402Error(err) || err.code !== ORGANIZATION_SELECTION_REQUIRED) return []; const details = err.details; const ids = details && typeof details === "object" && !Array.isArray(details) ? details.org_ids : undefined; return Array.isArray(ids) ? ids.filter((id) => typeof id === "string" && id.length > 0) : []; } export class Ai { client; constructor(client) { this.client = client; } /** Translate text. Requires the AI Translation add-on on the project. */ async translate(projectId, opts) { const project = await requireProjectCredentials(this.client, projectId, "translating text"); const body = { text: opts.text, to: opts.to }; if (opts.from) body.from = opts.from; if (opts.context) body.context = opts.context; return this.client.request("/ai/v1/translate", { method: "POST", headers: { Authorization: `Bearer ${project.service_key}` }, body, context: "translating text", }); } /** Run content moderation on text. Free for all projects; requires service key. */ async moderate(projectId, text) { const project = await requireProjectCredentials(this.client, projectId, "moderating content"); return this.client.request("/ai/v1/moderate", { method: "POST", headers: { Authorization: `Bearer ${project.service_key}` }, body: { text }, context: "moderating content", }); } /** Get AI translation usage for the current billing cycle. */ async usage(projectId) { const project = await requireProjectCredentials(this.client, projectId, "fetching AI usage"); return this.client.request("/ai/v1/usage", { headers: { Authorization: `Bearer ${project.service_key}` }, context: "fetching AI usage", }); } /** * Generate an image from a text prompt. Costs $0.03 via x402, MPP on * Tempo, or MPP Lightning. No project scope — payment flows through the * wallet-based fetch. See {@link GenerateImageOptions.orgId} for how a * multi-org principal's paying organization is chosen on Lightning. */ async generateImage(opts) { const aspect = opts.aspect ?? "square"; if (!IMAGE_ASPECTS.includes(aspect)) { throw new LocalError(`aspect must be one of: ${IMAGE_ASPECTS.join(", ")}`, "generating image"); } try { return await postGenerateImage(this.client, opts.prompt, aspect, opts.orgId); } catch (err) { // Only an UNNAMED org is disambiguated: a caller-named org that the // gateway still refuses is the caller's answer to keep. const candidates = opts.orgId ? [] : organizationCandidatesFromError(err); if (candidates.length === 0) throw err; const matched = await matchKnownOrganization(this.client, candidates); if (matched.chosen) return postGenerateImage(this.client, opts.prompt, aspect, matched.chosen); throw organizationSelectionError(err, candidates, matched.matches); } } } // Module-level, not class-private: `ScopedRun402`'s drift test enumerates // every runtime method on a namespace, and a TypeScript `private` is public // at runtime. async function postGenerateImage(client, prompt, aspect, orgId) { // requestWithResponse, not request: the settlement receipt rides the // RESPONSE, and `request` discards everything but the body — which is // exactly how a caller could buy an image with no way to learn which // network the money moved on. const res = await client.requestWithResponse("/generate-image/v1", { method: "POST", body: { prompt, aspect, ...(orgId ? { org_id: orgId } : {}) }, context: "generating image" }); return { ...res.body, payment: res.settlement ?? null }; } /** * Intersect the gateway's candidates with what this machine already knows, * most deliberate context first: the SELECTED pair (active org, active * project's cached owning org) decides when exactly one candidate matches * it; only when it names nothing do the STORED projects' owning orgs get a * say, again only when exactly one candidate matches. Every read is local * and best-effort — a provider without the optional methods, or a read that * throws, contributes nothing rather than failing the purchase. */ async function matchKnownOrganization(client, candidates) { const creds = client.credentials; const selected = new Set(); const activeOrg = await optionalRead(() => creds.getActiveOrg?.call(creds)); if (activeOrg) selected.add(activeOrg); const activeProject = await optionalRead(() => creds.getActiveProject?.call(creds)); if (activeProject) { const owning = orgIdOf(await optionalRead(() => client.getProjectCredentials(activeProject))); if (owning) selected.add(owning); } const stored = new Set(); const listed = await optionalRead(() => creds.listProjectCredentials?.call(creds)); for (const entry of Object.values(listed ?? {})) { const owning = orgIdOf(entry); if (owning) stored.add(owning); } const inSelected = candidates.filter((id) => selected.has(id)); if (inSelected.length === 1) return { chosen: inSelected[0], matches: inSelected }; if (inSelected.length > 1) return { chosen: null, matches: inSelected }; const inStored = candidates.filter((id) => stored.has(id)); return { chosen: inStored.length === 1 ? inStored[0] : null, matches: inStored }; } async function optionalRead(read) { try { const value = await read(); return value === undefined ? null : value; } catch { return null; } } function orgIdOf(entry) { const orgId = entry && typeof entry === "object" ? entry.org_id : undefined; return typeof orgId === "string" && orgId.length > 0 ? orgId : null; } /** * The gateway's `ORGANIZATION_SELECTION_REQUIRED` answer, re-thrown with the * candidates named in the message and a next action that says how to name * the paying organization. Status, code, and the raw envelope are kept. */ function organizationSelectionError(err, candidates, matches) { const envelope = err.body && typeof err.body === "object" && !Array.isArray(err.body) ? err.body : {}; const details = envelope.details && typeof envelope.details === "object" && !Array.isArray(envelope.details) ? envelope.details : {}; const reason = matches.length > 1 ? `${matches.length} of them (${matches.join(", ")}) match this machine's local context, so none was chosen` : "none of them matches this machine's local context (run402 orgs use, the active project, or a stored project)"; const message = `The paying organization is required: this principal belongs to ${candidates.length} organizations ` + `(${candidates.join(", ")}) and ${reason}; name one with orgId (CLI: --org <org_id>) while generating image (HTTP ${err.status ?? 400})`; const nextActions = [ { type: "edit_request", command: "run402 image generate \"<prompt>\" --org <org_id>", why: "Name the paying organization on this call (SDK: generateImage({ orgId })). Candidates are in details.org_ids.", }, { type: "edit_request", command: "run402 orgs use <org_id>", why: "Select a current organization for this profile so every later call inherits it.", }, ...(Array.isArray(envelope.next_actions) ? envelope.next_actions : []), ]; return new ApiError(message, err.status, { ...envelope, message, code: ORGANIZATION_SELECTION_REQUIRED, details: { ...details, org_ids: candidates, matched_org_ids: matches }, next_actions: nextActions, }, "generating image"); } //# sourceMappingURL=ai.js.map