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
JavaScript
/**
* `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