Menu
popagent
publicLatest change 2fb6f198c4c71ef37dffc8ac5dca8482068a7bfc - Add governed AkurAI Build maintenance by AkurAI Build
import { createTool } from "@mastra/core/tools";
import { z } from "zod";
import { agentSettings, type AgentSettingsStore } from "../agent-settings";
import {
BUILD_AGENT_TOOL_ALLOWLISTS,
type AgentToolName,
type BuildAgentId,
} from "../api-types";
const DEFAULT_URL = "https://akurai-build.olibuijr.com";
const PROTOCOL_VERSION = "2025-06-18";
const DEFAULT_TIMEOUT_MS = 30_000;
const MAX_REQUEST_BYTES = 256 * 1024;
const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
export const AKURAI_BUILD_TOOL_NAMES = [
"akuraiRepoList",
"akuraiRepoAdd",
"akuraiRepoHost",
"akuraiRepoSync",
"akuraiRelease",
"akuraiRepoRename",
"akuraiRepoRemove",
"akuraiRepoVisibility",
"akuraiRepoBranches",
"akuraiRepoTree",
"akuraiRepoBlob",
"akuraiRunQueue",
"akuraiRuns",
"akuraiRunShow",
"akuraiRunCancel",
"akuraiRunWait",
"akuraiRunLogs",
"akuraiRunRetry",
"akuraiRunPromote",
"akuraiDeliveryMetrics",
"akuraiWorkers",
"akuraiPipelineValidate",
"akuraiIssueList",
"akuraiIssueCreate",
"akuraiIssueUpdate",
"akuraiIssueComment",
"akuraiCacheStats",
"akuraiCachePrune",
"akuraiWorkerDrain",
"akuraiAuditEvents",
] as const;
export type AkuraiBuildToolName = (typeof AKURAI_BUILD_TOOL_NAMES)[number];
export type AkuraiBuildStatus = {
enabled: boolean;
configured: boolean;
url: string;
state: "disabled" | "unknown" | "healthy" | "unhealthy";
lastError?: string;
};
type JsonObject = Record<string, unknown>;
type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
type RpcResponse = {
jsonrpc: "2.0";
id: number;
result?: unknown;
error?: unknown;
};
class AkuraiBuildError extends Error {
constructor(message: string) {
super(message);
this.name = "AkuraiBuildError";
}
}
function objectOf(value: unknown, label: string): JsonObject {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new AkuraiBuildError(`AkurAI Build MCP ${label} must be an object`);
}
return value as JsonObject;
}
function safeErrorMessage(error: unknown, credential: string | undefined): string {
const message = error instanceof Error ? error.message : "request failed";
return message
.replaceAll(credential ?? "\u0000", "[redacted]")
.replaceAll(/Bearer\s+[^\s)]+/gi, "Bearer [redacted]")
.slice(0, 240);
}
function parseRpcResponse(value: unknown, id: number): RpcResponse {
const response = objectOf(value, "response");
if (response.jsonrpc !== "2.0" || response.id !== id) {
throw new AkuraiBuildError("AkurAI Build MCP response ID or protocol mismatch");
}
if (Object.hasOwn(response, "result") && Object.hasOwn(response, "error")) {
throw new AkuraiBuildError("AkurAI Build MCP response contains both result and error");
}
if (!Object.hasOwn(response, "result") && !Object.hasOwn(response, "error")) {
throw new AkuraiBuildError("AkurAI Build MCP response has no result or error");
}
return response as unknown as RpcResponse;
}
function parseRpcError(value: unknown): never {
const error = objectOf(value, "error");
const code = error.code;
const message = error.message;
if (typeof code !== "number" || typeof message !== "string" || !message.trim()) {
throw new AkuraiBuildError("AkurAI Build MCP returned a malformed error");
}
throw new AkuraiBuildError(`AkurAI Build MCP error ${code}: ${message.trim()}`);
}
async function readBoundedBody(response: Response): Promise<string> {
if (!response.body) return "";
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let size = 0;
try {
for (;;) {
const next = await reader.read();
if (next.done) break;
size += next.value.byteLength;
if (size > MAX_RESPONSE_BYTES) {
await reader.cancel();
throw new AkuraiBuildError("AkurAI Build MCP response exceeded 8 MiB");
}
chunks.push(next.value);
}
} finally {
reader.releaseLock();
}
const output = new Uint8Array(size);
let offset = 0;
for (const chunk of chunks) {
output.set(chunk, offset);
offset += chunk.byteLength;
}
return new TextDecoder().decode(output);
}
function parseToolResult(value: unknown): unknown {
const result = objectOf(value, "tool result");
if (result.isError === true) {
const content = Array.isArray(result.content) ? result.content : [];
const text = content
.filter((item) => item && typeof item === "object" && !Array.isArray(item))
.map((item) => (item as JsonObject).text)
.filter((item): item is string => typeof item === "string")
.join("\n");
throw new AkuraiBuildError(text || "AkurAI Build MCP tool call failed");
}
if (result.isError !== undefined && result.isError !== false) {
throw new AkuraiBuildError("AkurAI Build MCP tool result is malformed");
}
if (!Array.isArray(result.content) || result.content.length === 0) {
throw new AkuraiBuildError("AkurAI Build MCP tool result is malformed");
}
const text = result.content.map((item) => {
const content = objectOf(item, "content item");
if (content.type !== "text" || typeof content.text !== "string") {
throw new AkuraiBuildError("AkurAI Build MCP returned unsupported content");
}
return content.text;
}).join("\n");
try {
return JSON.parse(text);
} catch {
throw new AkuraiBuildError("AkurAI Build MCP returned invalid tool JSON");
}
}
export class AkuraiBuildClient {
private readonly baseUrl: string;
private readonly apiKey: string | undefined;
private readonly fetchImpl: FetchLike;
private readonly timeoutMs: number;
private nextId = 1;
private initialized?: Promise<void>;
private currentStatus: AkuraiBuildStatus;
constructor(options: {
url?: string;
apiKey?: string;
timeoutMs?: number;
fetchImpl?: FetchLike;
} = {}) {
this.baseUrl = (options.url ?? process.env.AKURAI_BUILD_URL ?? DEFAULT_URL).replace(/\/$/, "");
const configuredKey = options.apiKey ?? process.env.AKURAI_BUILD_API_KEY;
this.apiKey = configuredKey?.trim() || undefined;
this.fetchImpl = options.fetchImpl ?? fetch;
this.timeoutMs = Math.min(Math.max(options.timeoutMs ?? DEFAULT_TIMEOUT_MS, 1_000), 60_000);
this.currentStatus = {
enabled: Boolean(this.apiKey),
configured: Boolean(this.apiKey),
url: this.baseUrl,
state: this.apiKey ? "unknown" : "disabled",
};
}
getStatus(): AkuraiBuildStatus {
return { ...this.currentStatus };
}
private disabledError(): AkuraiBuildError {
return new AkuraiBuildError("AkurAI Build MCP is disabled: AKURAI_BUILD_API_KEY is not configured");
}
private async request(method: string, params: JsonObject, signal?: AbortSignal): Promise<unknown> {
if (!this.apiKey) throw this.disabledError();
const id = this.nextId++;
const body = JSON.stringify({ jsonrpc: "2.0", id, method, params });
if (new TextEncoder().encode(body).byteLength > MAX_REQUEST_BYTES) {
throw new AkuraiBuildError("AkurAI Build MCP request exceeded 256 KiB");
}
const controller = new AbortController();
const abort = () => controller.abort(signal?.reason ?? new Error("request aborted"));
const timer = setTimeout(() => controller.abort(new Error("request timed out")), this.timeoutMs);
signal?.addEventListener("abort", abort, { once: true });
try {
const response = await this.fetchImpl(`${this.baseUrl}/mcp`, {
method: "POST",
headers: {
accept: "application/json",
"content-type": "application/json",
authorization: `Bearer ${this.apiKey}`,
},
body,
signal: controller.signal,
});
const text = await readBoundedBody(response);
if (!response.ok) throw new AkuraiBuildError(`AkurAI Build MCP HTTP ${response.status}`);
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
throw new AkuraiBuildError("AkurAI Build MCP returned invalid JSON");
}
const rpc = parseRpcResponse(parsed, id);
if (rpc.error !== undefined) parseRpcError(rpc.error);
this.currentStatus = { ...this.currentStatus, state: "healthy", lastError: undefined };
return rpc.result;
} catch (error) {
if (signal?.aborted) throw new AkuraiBuildError("AkurAI Build MCP request aborted");
const message = safeErrorMessage(error, this.apiKey);
this.currentStatus = { ...this.currentStatus, state: "unhealthy", lastError: message };
if (error instanceof AkuraiBuildError && error.message === message) throw error;
throw new AkuraiBuildError(`AkurAI Build MCP request failed: ${message}`);
} finally {
clearTimeout(timer);
signal?.removeEventListener("abort", abort);
}
}
private async initialize(signal?: AbortSignal): Promise<void> {
const result = objectOf(await this.request("initialize", {
protocolVersion: PROTOCOL_VERSION,
capabilities: {},
clientInfo: { name: "popagent", version: "1" },
}, signal), "initialize result");
if (typeof result.protocolVersion !== "string") {
throw new AkuraiBuildError("AkurAI Build MCP initialize response is missing protocolVersion");
}
}
private async ensureInitialized(signal?: AbortSignal): Promise<void> {
if (!this.initialized) {
this.initialized = this.initialize(signal).catch((error) => {
this.initialized = undefined;
const message = safeErrorMessage(error, this.apiKey);
this.currentStatus = { ...this.currentStatus, state: "unhealthy", lastError: message };
throw error;
});
}
await this.initialized;
}
async call<T = unknown>(name: string, arguments_: JsonObject = {}, signal?: AbortSignal): Promise<T> {
if (!this.apiKey) throw this.disabledError();
signal?.throwIfAborted();
await this.ensureInitialized(signal);
const result = await this.request("tools/call", { name, arguments: arguments_ }, signal);
try {
return parseToolResult(result) as T;
} catch (error) {
const message = safeErrorMessage(error, this.apiKey);
if (error instanceof AkuraiBuildError && error.message === message) throw error;
throw new AkuraiBuildError(`AkurAI Build MCP tool failed: ${message}`);
}
}
}
export const akuraiBuildClient = new AkuraiBuildClient();
const emptySchema = z.object({}).strict();
const repositoryName = z.string().trim().min(1).max(200);
const repositoryListSchema = z.object({
search: z.string().trim().max(200).optional(),
visibility: z.enum(["public", "private"]).optional(),
limit: z.number().int().min(1).max(100).optional(),
offset: z.number().int().min(0).max(100_000).optional(),
}).strict();
const repoAddSchema = z.object({
name: repositoryName,
url: z.string().trim().min(1).max(2_000),
branch: z.string().trim().min(1).max(256).optional(),
}).strict();
const issueCreateSchema = z.object({
repository: repositoryName,
title: z.string().trim().min(1).max(500),
body: z.string().trim().min(1).max(50_000),
}).strict();
const issueUpdateSchema = z.object({
repository: repositoryName,
number: z.number().int().positive(),
title: z.string().trim().min(1).max(500).optional(),
body: z.string().trim().min(1).max(50_000).optional(),
state: z.enum(["open", "closed"]).optional(),
}).strict().refine(
({ title, body, state }) => title !== undefined || body !== undefined || state !== undefined,
{ message: "At least one issue field must be updated" },
);
const repositoryNameSchema = z.object({ name: repositoryName }).strict();
const refSchema = z.string().trim().min(1).max(256).optional();
const repoTreeSchema = z.object({ repository: repositoryName, ref: refSchema, path: z.string().max(2_000).optional() }).strict();
const repoBlobSchema = z.object({ repository: repositoryName, ref: refSchema, path: z.string().trim().min(1).max(2_000) }).strict();
const repoHostSchema = z.object({ name: repositoryName, source: z.string().trim().min(1).max(2_000), branch: z.string().trim().min(1).max(256).optional() }).strict();
const repoSyncSchema = z.object({ name: repositoryName, source: z.string().trim().min(1).max(2_000) }).strict();
const repoRenameSchema = z.object({ old: repositoryName, new: repositoryName }).strict();
const repoVisibilitySchema = z.object({ repository: repositoryName, visibility: z.enum(["public", "private"]) }).strict();
const releaseSchema = z.object({ repository: repositoryName, source: z.string().trim().min(1).max(2_000), bump: z.enum(["patch", "minor", "major"]).optional(), notes: z.string().max(20_000).optional(), wait: z.boolean().optional() }).strict();
const runIdSchema = z.object({ id: z.number().int().positive() }).strict();
const runWaitSchema = runIdSchema.extend({ timeout: z.number().int().min(1).max(14_400).optional(), interval: z.number().int().min(1).max(60).optional() }).strict();
const runLogsSchema = runIdSchema.extend({ failed: z.boolean().optional() }).strict();
const runRetrySchema = runIdSchema.extend({ wait: z.boolean().optional() }).strict();
const runPromoteSchema = runIdSchema.extend({ environment: z.string().trim().min(1).max(200), wait: z.boolean().optional() }).strict();
const pipelineValidateSchema = z.object({ repository: repositoryName, ref: refSchema }).strict();
const issueListSchema = z.object({ repository: repositoryName, state: z.enum(["open", "closed"]).optional(), limit: z.number().int().min(1).max(100).optional(), offset: z.number().int().min(0).max(100_000).optional() }).strict();
const runQueueSchema = z.object({ repository: repositoryName, git_ref: z.string().trim().min(1).max(256).optional(), commit: z.string().trim().min(1).max(256).optional(), wait: z.boolean().optional() }).strict();
const runsSchema = z.object({ repo: z.union([repositoryName, z.array(repositoryName).max(32)]).optional(), repository: z.union([repositoryName, z.array(repositoryName).max(32)]).optional(), status: z.union([z.string().trim().min(1).max(64), z.array(z.string().trim().min(1).max(64)).max(32)]).optional(), git_ref: z.string().trim().max(256).optional(), trigger: z.union([z.string().trim().min(1).max(64), z.array(z.string().trim().min(1).max(64)).max(32)]).optional(), search: z.string().trim().max(200).optional(), limit: z.number().int().min(1).max(100).optional(), offset: z.number().int().min(0).max(100_000).optional() }).strict();
const issueCommentSchema = z.object({ repository: repositoryName, number: z.number().int().positive(), body: z.string().trim().min(1).max(50_000) }).strict();
const cacheStatsSchema = z.object({ repository: repositoryName.optional() }).strict();
const cachePruneSchema = z.object({ repository: repositoryName.optional(), max_age_days: z.number().int().min(1).max(36_500), dry_run: z.boolean() }).strict();
const workerDrainSchema = z.object({ worker: z.string().trim().min(1).max(200), draining: z.boolean() }).strict();
const auditEventsSchema = z.object({ repository: repositoryName.optional(), limit: z.number().int().min(1).max(100).optional(), offset: z.number().int().min(0).max(100_000).optional() }).strict();
const communityRepositoryTools = new Set<AkuraiBuildToolName>([
"akuraiRepoBranches",
"akuraiRepoTree",
"akuraiRepoBlob",
"akuraiIssueList",
"akuraiIssueCreate",
"akuraiIssueUpdate",
"akuraiIssueComment",
]);
async function requireCommunityPublicRepository(
client: AkuraiBuildClient,
repository: string,
signal: AbortSignal | undefined,
) {
const result = await client.call<unknown>("akurai_repo_list", {
search: repository,
visibility: "public",
limit: 100,
offset: 0,
}, signal);
const rows = Array.isArray(result)
? result
: objectOf(result, "repository list").repositories;
if (!Array.isArray(rows) || !rows.some((row) => {
if (!row || typeof row !== "object" || Array.isArray(row)) return false;
const item = row as JsonObject;
return item.name === repository && item.visibility === "public";
})) {
throw new Error("Community Steward may access public repositories only");
}
}
function repositoryArgument(input: JsonObject): string | undefined {
return typeof input.repository === "string" ? input.repository : undefined;
}
function createBuildTool<T extends z.ZodTypeAny>(
toolName: AkuraiBuildToolName,
mcpName: string,
description: string,
inputSchema: T,
roles: Pick<AgentSettingsStore, "get"> = agentSettings,
client: AkuraiBuildClient = akuraiBuildClient,
) {
return createTool({
id: toolName,
description,
inputSchema,
outputSchema: z.unknown(),
execute: async (input, context) => {
const agentId = context.agent?.agentId as BuildAgentId | undefined;
if (!agentId || !Object.hasOwn(BUILD_AGENT_TOOL_ALLOWLISTS, agentId)) {
throw new Error("AkurAI Build tools require a governed Build agent role");
}
const allowed = BUILD_AGENT_TOOL_ALLOWLISTS[agentId];
if (!(allowed as readonly AgentToolName[]).includes(toolName as AgentToolName)) {
throw new Error(`Agent role is not allowed to use ${toolName}`);
}
const role = await roles.get(agentId);
if (!role || !role.tools.includes(toolName as AgentToolName)) {
throw new Error(`Agent role has disabled ${toolName}`);
}
const arguments_ = input as JsonObject;
if (agentId === "community-steward") {
if (toolName === "akuraiRepoList") {
arguments_.visibility = "public";
} else if (communityRepositoryTools.has(toolName)) {
const repository = repositoryArgument(arguments_);
if (!repository) throw new Error("A public repository is required");
await requireCommunityPublicRepository(client, repository, context.abortSignal);
}
}
return client.call(mcpName, arguments_, context.abortSignal);
},
});
}
export const akuraiRepoList = createBuildTool("akuraiRepoList", "akurai_repo_list", "List repositories visible to AkurAI Build. Read-only.", repositoryListSchema);
export const akuraiRepoAdd = createBuildTool("akuraiRepoAdd", "akurai_repo_add", "Register a repository for CI. This changes Build state and may authorize future pipeline activity.", repoAddSchema);
export const akuraiRepoHost = createBuildTool("akuraiRepoHost", "akurai_repo_host", "Create and register a Build-owned mirror from a trusted checkout. This writes a mirror and changes CI state.", repoHostSchema);
export const akuraiRepoSync = createBuildTool("akuraiRepoSync", "akurai_repo_sync", "Synchronize a Build mirror from a trusted checkout. This overwrites the hosted mirror contents.", repoSyncSchema);
export const akuraiRelease = createBuildTool("akuraiRelease", "akurai_release", "Run the maintained release path: bump version, update changelog, commit and tag, sync the mirror, and queue a pipeline. This is a consequential repository and CI mutation.", releaseSchema);
export const akuraiRepoRename = createBuildTool("akuraiRepoRename", "akurai_repo_rename", "Rename a registered repository. This changes the Build registry and its hosted mirror identity.", repoRenameSchema);
export const akuraiRepoRemove = createBuildTool("akuraiRepoRemove", "akurai_repo_remove", "Unregister a repository and delete its hosted mirror, cascading runs and artifacts. Destructive and irreversible.", repositoryNameSchema);
export const akuraiRepoVisibility = createBuildTool("akuraiRepoVisibility", "akurai_repo_visibility", "Change repository visibility. This changes who may read the repository publicly.", repoVisibilitySchema);
export const akuraiRepoBranches = createBuildTool("akuraiRepoBranches", "akurai_repo_branches", "List remote branches for a registered repository. Read-only.", repositoryNameSchema);
export const akuraiRepoTree = createBuildTool("akuraiRepoTree", "akurai_repo_tree", "Browse a bounded repository tree at a ref and path. Read-only.", repoTreeSchema);
export const akuraiRepoBlob = createBuildTool("akuraiRepoBlob", "akurai_repo_blob", "Read a bounded UTF-8 repository blob at a ref and path. Read-only.", repoBlobSchema);
export const akuraiRunQueue = createBuildTool("akuraiRunQueue", "akurai_run_queue", "Queue a pipeline run, optionally waiting for its state. This starts CI work and consumes worker capacity; it does not approve production.", runQueueSchema);
export const akuraiRuns = createBuildTool("akuraiRuns", "akurai_runs", "Query pipeline runs. Read-only.", runsSchema);
export const akuraiRunShow = createBuildTool("akuraiRunShow", "akurai_run_show", "Read one pipeline run with bounded jobs, logs, artifacts, and deployments. Read-only.", runIdSchema);
export const akuraiRunCancel = createBuildTool("akuraiRunCancel", "akurai_run_cancel", "Cancel a queued or waiting pipeline run. This changes durable CI state and cannot stop work already owned by a worker.", runIdSchema);
export const akuraiRunWait = createBuildTool("akuraiRunWait", "akurai_run_wait", "Poll a pipeline run until terminal or approval-required state. Read-only apart from waiting for current state.", runWaitSchema);
export const akuraiRunLogs = createBuildTool("akuraiRunLogs", "akurai_run_logs", "Read bounded job logs for a pipeline run. Read-only.", runLogsSchema);
export const akuraiRunRetry = createBuildTool("akuraiRunRetry", "akurai_run_retry", "Queue the same immutable revision as a prior run. This starts new CI work and consumes worker capacity.", runRetrySchema);
export const akuraiRunPromote = createBuildTool("akuraiRunPromote", "akurai_run_promote", "Approve and resume a protected environment. This is production-impacting and may deploy the release; use only with explicit approval.", runPromoteSchema);
export const akuraiDeliveryMetrics = createBuildTool("akuraiDeliveryMetrics", "akurai_delivery_metrics", "Read source-derived delivery metrics and immutable run/deployment drill-down IDs. Read-only.", z.object({ repository: repositoryName.optional(), environment: z.string().trim().max(200).optional(), window_seconds: z.number().int().min(1).max(315_360_000).optional() }).strict());
export const akuraiWorkers = createBuildTool("akuraiWorkers", "akurai_workers", "Read Titan worker status, capacity, current run, heartbeat, and completed-run count.", emptySchema);
export const akuraiPipelineValidate = createBuildTool("akuraiPipelineValidate", "akurai_pipeline_validate", "Validate a repository pipeline configuration without queueing or deploying work. Read-only.", pipelineValidateSchema);
export const akuraiIssueList = createBuildTool("akuraiIssueList", "akurai_issue_list", "List repository community issues filtered by open or closed state. Read-only.", issueListSchema);
export const akuraiIssueCreate = createBuildTool("akuraiIssueCreate", "akurai_issue_create", "Create a repository community issue. This publishes user-visible content.", issueCreateSchema);
export const akuraiIssueUpdate = createBuildTool("akuraiIssueUpdate", "akurai_issue_update", "Update or close a repository community issue. This changes user-visible content and state.", issueUpdateSchema);
export const akuraiIssueComment = createBuildTool("akuraiIssueComment", "akurai_issue_comment", "Publish a comment on a repository community issue. This creates user-visible content.", issueCommentSchema);
export const akuraiCacheStats = createBuildTool("akuraiCacheStats", "akurai_cache_stats", "Read Build cache statistics. Read-only.", cacheStatsSchema);
export const akuraiCachePrune = createBuildTool("akuraiCachePrune", "akurai_cache_prune", "Prune old Build cache entries. This deletes cache data; dry_run must be used to preview the effect.", cachePruneSchema);
export const akuraiWorkerDrain = createBuildTool("akuraiWorkerDrain", "akurai_worker_drain", "Set a worker's drain state. This changes scheduling capacity and may defer queued work.", workerDrainSchema);
export const akuraiAuditEvents = createBuildTool("akuraiAuditEvents", "akurai_audit_events", "Read bounded Build operation and approval audit events. Read-only.", auditEventsSchema);
export const akuraiBuildTools = {
akuraiRepoList,
akuraiRepoAdd,
akuraiRepoHost,
akuraiRepoSync,
akuraiRelease,
akuraiRepoRename,
akuraiRepoRemove,
akuraiRepoVisibility,
akuraiRepoBranches,
akuraiRepoTree,
akuraiRepoBlob,
akuraiRunQueue,
akuraiRuns,
akuraiRunShow,
akuraiRunCancel,
akuraiRunWait,
akuraiRunLogs,
akuraiRunRetry,
akuraiRunPromote,
akuraiDeliveryMetrics,
akuraiWorkers,
akuraiPipelineValidate,
akuraiIssueList,
akuraiIssueCreate,
akuraiIssueUpdate,
akuraiIssueComment,
akuraiCacheStats,
akuraiCachePrune,
akuraiWorkerDrain,
akuraiAuditEvents,
};