Files
MikrotikManager/shared/api/mcp.ts
T

61 lines
8.9 KiB
TypeScript

import { z } from "zod"
import { mcpCatalogResponseSchema, mcpConnectionsSchema, mcpSettingsSchema, mcpPolicyPreviewResponseSchema, mcpPollStorageCapacitySchema, type McpConnectionCreate } from "@mmapp/contracts/mcp"
import { requestJson } from "./http-client"
import { downloadHumanArtifact } from "./mcp-artifact"
export const mcpRecordsSchema = z.object({ items: z.array(z.record(z.string(), z.unknown())), total: z.number().int(), nextCursor: z.string().nullable() })
export const mcpOverviewSchema = z.object({ connections: z.number(), calls: z.number(), errors: z.number(), approvals: z.number(), running: z.number(), enabled: z.boolean(), observedAt: z.string(), pollStorageCapacity:mcpPollStorageCapacitySchema.default({status:"unavailable"}) })
const deviceCatalogSchema = z.object({ servers: z.array(z.object({ id: z.number(), name: z.string(), host: z.string(), enabled: z.boolean() })), resources: z.array(z.object({ id: z.string(), title: z.string(), read: z.boolean(), create: z.boolean(), update: z.boolean(), delete: z.boolean(), fields: z.array(z.object({ key: z.string(), type: z.enum(["string", "number", "boolean"]), options: z.array(z.string()).optional(), min: z.number().optional(), max: z.number().optional(), maxLength: z.number().optional(), requiredOnCreate: z.boolean() })) })) })
export function mcpApi(base: string) {
const get = async <T>(path: string, schema: z.ZodType<T>, signal?: AbortSignal): Promise<T> => schema.parse(await requestJson(base, "/api/mcp" + path, { signal }))
const send = async <T>(path: string, body: unknown, schema: z.ZodType<T>, method = "POST"): Promise<T> => schema.parse(await requestJson(base, "/api/mcp" + path, { method, body: JSON.stringify(body) }))
return {
downloadArtifact: (receipt: unknown, signal?: AbortSignal) => downloadHumanArtifact(base, receipt, signal),
coverage: (signal?: AbortSignal) => get("/coverage", z.object({ version: z.literal(1), routes: z.array(z.object({ method: z.string(), path: z.string(), status: z.enum(["mediated", "excluded", "pending"]), operations: z.array(z.string()), reason: z.string() })) }), signal),
uploadRestoreArchive: async (file: File) => z.object({ archiveRef: z.string(), sha256: z.string(), sizeBytes: z.number(), expiresAt: z.string() }).parse(await requestJson(base, "/api/mcp/database/archives", { method: "POST", headers: { "Content-Type": "application/octet-stream" }, body: file })),
restorePlan: (connectionId: string, archiveRef: string, expectedSha256: string) => send("/database/plan", { connectionId, archiveRef, expectedSha256 }, z.object({ planId: z.string(), state: z.string() })),
restoreReceipt: (id: string) => get(`/database/receipts/${encodeURIComponent(id)}`, z.record(z.string(), z.unknown())),
managementChannels: (signal?: AbortSignal) => get("/management-channels", z.object({ channels: z.array(z.record(z.string(), z.unknown())) }), signal),
registerManagement: (input: unknown) => send("/management-channels", input, z.object({ id: z.string(), serverId: z.number(), expiresAt: z.string(), verification: z.string(), independence: z.string() })),
revokeManagement: (id: string) => send(`/management-channels/${id}`, {}, z.object({ ok: z.literal(true) }), "DELETE"),
recoverOperation: (id: string) => send(`/operations/${id}/recover`, {}, z.object({ planId: z.string(), state: z.string() })),
checkRoutePath: (id: string, candidateId: string, proofPurpose: "community" | "control-path" = "community") => send(`/routing-profiles/${id}/check`, { candidateId, proofPurpose }, z.record(z.string(), z.unknown())),
reconcileOperation: (id: string) => send(`/operations/${id}/reconcile`, {}, z.record(z.string(), z.unknown())),
deviceCatalog: (connectionId: string, signal?: AbortSignal) => get("/devices/catalog?" + new URLSearchParams({ connectionId }), deviceCatalogSchema, signal),
deviceRows: (connectionId: string, serverId: string, resource: string, offset = 0, signal?: AbortSignal) => get(`/devices/${encodeURIComponent(serverId)}/${encodeURIComponent(resource)}?` + new URLSearchParams({ connectionId, offset: String(offset) }), z.object({ items: z.array(z.record(z.string(), z.unknown())), total: z.number(), nextOffset: z.number().nullable() }), signal),
devicePlan: (connectionId: string, serverId: string, resource: string, action: "create" | "update" | "delete", id: string | undefined, fields: Record<string, unknown>) => send(`/devices/${encodeURIComponent(serverId)}/${encodeURIComponent(resource)}/plan`, { connectionId, action, id, fields }, z.object({ planId: z.string(), state: z.string() })),
revalidate: (id: string) => send("/connections/" + id + "/revalidate", {}, mcpConnectionsSchema.shape.connections.element),
apply: (id: string) => send("/plans/" + id + "/apply", {}, z.object({ id: z.string(), state: z.string() })),
profilePlan: (input: unknown) => send("/routing-profiles/plan", input, z.object({ planId: z.string(), state: z.string() })),
catalog: (signal?: AbortSignal) => get("/catalog", mcpCatalogResponseSchema, signal),
connections: (signal?: AbortSignal) => get("/connections", mcpConnectionsSchema, signal),
overview: (signal?: AbortSignal) => get("/overview", mcpOverviewSchema, signal),
settings: (signal?: AbortSignal) => get("/settings", mcpSettingsSchema, signal),
records: (kind: "requests" | "plans" | "operations" | "audit" | "drift", params: URLSearchParams, signal?: AbortSignal) => get("/" + kind + "?" + params, mcpRecordsSchema, signal),
create: (input: McpConnectionCreate) => send("/connections", input, z.object({ token: z.string(), connection: mcpConnectionsSchema.shape.connections.element })),
update: (id: string, input: McpConnectionCreate) => send("/connections/" + id, input, mcpConnectionsSchema.shape.connections.element, "PUT"),
status: (id: string, status: "active" | "paused" | "revoked") => send(`/connections/${id}/status`, { status }, z.object({ ok: z.literal(true) })),
rotate: (id: string) => send(`/connections/${id}/rotate`, {}, z.object({ token: z.string() })),
check: (id: string) => send(`/connections/${id}/check`, {}, z.object({ ok: z.boolean(), checks: z.record(z.string(), z.boolean()), checkedAt: z.string(), protocolProbe: z.object({ status: z.enum(["passed", "failed", "skipped"]), elapsedMs: z.number(), reason: z.string().optional(), credentialType: z.literal("temporary-no-operations"), clientCredentialsVerified: z.literal(false) }).optional() })),
grants: (id: string, signal?: AbortSignal) => get(`/connections/${id}/grants`, z.object({ grants: z.array(z.object({ id: z.string(), grant: z.record(z.string(), z.unknown()), revoked_at: z.string().nullable() })) }), signal),
grant: (id: string, grant: unknown) => send(`/connections/${id}/grants`, grant, z.object({ token: z.string(), expiresAt: z.string() })),
revokeGrant: (id: string) => send(`/grants/${id}`, {}, z.object({ ok: z.literal(true) }), "DELETE"),
profiles: (signal?: AbortSignal) => get("/routing-profiles", z.object({ profiles: z.array(z.record(z.string(), z.unknown())) }), signal),
pauseProfile: (id: string) => send(`/routing-profiles/${id}/pause`, {}, z.object({ ok: z.literal(true) })),
decision: (id: string, approve: boolean) => send(`/plans/${id}/decision`, { approve }, z.object({ ok: z.literal(true) })),
saveSettings: (settings: unknown) => send("/settings", settings, mcpSettingsSchema, "PUT"),
pause: () => send("/pause", {}, z.object({ ok: z.literal(true) })),
stopAutomation: () => send("/stop-automation", {}, z.object({ ok: z.literal(true) })),
preview: (input: unknown) => send("/policy/preview", input, mcpPolicyPreviewResponseSchema),
clients: (signal?: AbortSignal) => get("/oauth/clients", z.object({ clients: z.array(z.object({ id: z.string(), name: z.string(), redirect_uris: z.array(z.string()), enabled: z.boolean() })) }), signal),
createClient: (name: string, redirectUris: string[]) => send("/oauth/clients", { name, redirectUris }, z.object({ clientId: z.string() })),
disableClient: (id: string) => send(`/oauth/clients/${id}`, {}, z.object({ ok: z.literal(true) }), "DELETE"),
consentRequest: (id: string, signal?: AbortSignal) => get("/oauth/requests/" + id, z.object({ id: z.string(), name: z.string(), redirect_uri: z.string(), scope: z.string() }), signal),
consent: (requestId: string, connectionId: string, approve: boolean) => send("/oauth/consent", { requestId, connectionId, approve }, z.object({ redirectUrl: z.string().url() })),
protections: (signal?: AbortSignal) => get("/protections", z.object({ protections: z.array(z.object({ id: z.string(), object_ref: z.object({ kind: z.string(), id: z.string(), serverId: z.number().optional(), protection: z.enum(["deny", "human-approval-required"]) }), reason: z.string() })) }), signal),
protect: (object: unknown, reason: string) => send("/protections", { object, reason }, z.object({ id: z.string() })),
removeProtection: async (id: string) => requestJson(base, "/api/mcp/protections/" + id, { method: "DELETE" }),
}
}
export type McpApi = ReturnType<typeof mcpApi>