mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 07:41:23 +00:00
feat: ship phase 1 surface area (v0.2.0)
- primora CLI + @primora/mcp server over generated client - auth users admin panel, mobile bottom nav - release workflow: GHCR images, compiled binaries, npm tarballs - fix gzip Content-Length truncation, nginx upstream re-resolution Authored By: TDvorak <[email protected]> Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
f77823ad90
commit
420502eec1
90 files changed
+9172
-9718
No files matched your search
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@primora/mcp",
|
||||
"version": "0.2.0",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"description": "Primora MCP server — operate projects, storage, keys and audit from any MCP client",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"primora-mcp": "dist/server.js"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "esbuild src/server.ts --bundle --platform=node --format=esm --target=node20 --outfile=dist/server.js --banner:js=\"#!/usr/bin/env node\"",
|
||||
"dev": "npm run build && node dist/server.js",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@primora/api-client": "0.2.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"esbuild": "^0.27.4",
|
||||
"typescript": "^5.9.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { OpenAPI } from "@primora/api-client";
|
||||
|
||||
export interface McpConfig {
|
||||
baseUrl: string;
|
||||
auth?:
|
||||
| { type: "session"; cookieName: string; sessionToken: string }
|
||||
| { type: "apiKey"; key: string };
|
||||
organizationId?: string;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
/** Shares the CLI's config file — `primora login` once, both tools work. */
|
||||
export function loadConfig(): McpConfig {
|
||||
const dir =
|
||||
process.env.PRIMORA_CONFIG_DIR ??
|
||||
join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "primora");
|
||||
const cfg: McpConfig = { baseUrl: "http://localhost" };
|
||||
try {
|
||||
const path = join(dir, "config.json");
|
||||
if (existsSync(path)) {
|
||||
Object.assign(cfg, JSON.parse(readFileSync(path, "utf8")) as Partial<McpConfig>);
|
||||
}
|
||||
} catch {
|
||||
// unreadable config — env vars may still provide credentials
|
||||
}
|
||||
if (process.env.PRIMORA_BASE_URL) cfg.baseUrl = process.env.PRIMORA_BASE_URL;
|
||||
if (process.env.PRIMORA_API_KEY) cfg.auth = { type: "apiKey", key: process.env.PRIMORA_API_KEY };
|
||||
if (process.env.PRIMORA_ORG) cfg.organizationId = process.env.PRIMORA_ORG;
|
||||
if (process.env.PRIMORA_PROJECT) cfg.projectId = process.env.PRIMORA_PROJECT;
|
||||
cfg.baseUrl = cfg.baseUrl.replace(/\/+$/, "");
|
||||
return cfg;
|
||||
}
|
||||
|
||||
async function mintSessionToken(cfg: McpConfig): Promise<string> {
|
||||
if (cfg.auth?.type !== "session") throw new Error("no session stored");
|
||||
const res = await fetch(`${cfg.baseUrl}/auth/token`, {
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
cookie: `${cfg.auth.cookieName}=${cfg.auth.sessionToken}`,
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error(`token mint failed (${res.status}) — run \`primora login\` again`);
|
||||
return ((await res.json()) as { token: string }).token;
|
||||
}
|
||||
|
||||
export function configureClient(cfg: McpConfig): void {
|
||||
OpenAPI.BASE = `${cfg.baseUrl}/api/v1`;
|
||||
if (cfg.auth?.type === "apiKey") {
|
||||
OpenAPI.HEADERS = { "X-API-Key": cfg.auth.key };
|
||||
return;
|
||||
}
|
||||
if (cfg.auth?.type === "session") {
|
||||
let cached: string | undefined;
|
||||
OpenAPI.TOKEN = async () => {
|
||||
cached ??= await mintSessionToken(cfg);
|
||||
return cached;
|
||||
};
|
||||
return;
|
||||
}
|
||||
throw new Error("no credentials — run `primora login` or set PRIMORA_API_KEY");
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
CreateBucketRequest,
|
||||
OpenAPI,
|
||||
OrganizationsService,
|
||||
PlatformService,
|
||||
ProjectsService,
|
||||
StorageService,
|
||||
} from "@primora/api-client";
|
||||
|
||||
import { configureClient, loadConfig, type McpConfig } from "./config.js";
|
||||
|
||||
const cfg = loadConfig();
|
||||
configureClient(cfg);
|
||||
|
||||
const TEXT_TYPES = /^(text\/|application\/(json|xml|yaml|javascript|x-ndjson))/;
|
||||
|
||||
function slugify(name: string): string {
|
||||
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
function ok(data: unknown) {
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
|
||||
}
|
||||
|
||||
function fail(error: unknown) {
|
||||
const err = error as { message?: string; body?: unknown };
|
||||
const body = err?.body as { error?: { message?: string } } | undefined;
|
||||
const message = body?.error?.message ?? err?.message ?? "unknown error";
|
||||
return { content: [{ type: "text" as const, text: `error: ${message}` }], isError: true };
|
||||
}
|
||||
|
||||
function projectId(override?: string): string {
|
||||
const id = override ?? cfg.projectId;
|
||||
if (!id) {
|
||||
throw new Error("no project selected — pass projectId, run `primora use`, or set PRIMORA_PROJECT");
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
async function bucketIdFor(project: string, ref: string): Promise<string> {
|
||||
const { items } = await StorageService.listBuckets({ projectId: project });
|
||||
const match = items.find((b) => b.id === ref || b.slug === ref || b.name === ref);
|
||||
if (!match) throw new Error(`bucket "${ref}" not found in project`);
|
||||
return match.id;
|
||||
}
|
||||
|
||||
/** Raw binary-safe fetch — the generated client decodes non-JSON as text. */
|
||||
async function apiFetch(path: string): Promise<Response> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (cfg.auth?.type === "apiKey") {
|
||||
headers["X-API-Key"] = cfg.auth.key;
|
||||
} else if (cfg.auth?.type === "session") {
|
||||
// reuse the client's cached JWT resolver
|
||||
const token = typeof OpenAPI.TOKEN === "function" ? await OpenAPI.TOKEN({} as never) : OpenAPI.TOKEN;
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
const res = await fetch(`${cfg.baseUrl}/api/v1${path}`, { headers });
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => undefined)) as
|
||||
| { error?: { message?: string } }
|
||||
| undefined;
|
||||
throw new Error(body?.error?.message ?? `request failed (${res.status})`);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
const server = new McpServer({ name: "primora", version: "0.2.0" });
|
||||
|
||||
server.registerTool(
|
||||
"primora_whoami",
|
||||
{ description: "Current identity, organizations and projects visible to the credentials" },
|
||||
async () => {
|
||||
try {
|
||||
return ok(await PlatformService.getMe());
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"primora_list_organizations",
|
||||
{ description: "List organizations the current identity belongs to" },
|
||||
async () => {
|
||||
try {
|
||||
return ok(await OrganizationsService.listOrganizations());
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"primora_list_projects",
|
||||
{
|
||||
description: "List projects in an organization",
|
||||
inputSchema: { organizationId: z.string().optional().describe("Defaults to configured org") },
|
||||
},
|
||||
async ({ organizationId }) => {
|
||||
try {
|
||||
const org = organizationId ?? cfg.organizationId;
|
||||
if (!org) throw new Error("no organization selected — pass organizationId or run `primora use`");
|
||||
return ok(await ProjectsService.listProjects({ organizationId: org }));
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"primora_create_project",
|
||||
{
|
||||
description: "Create a project in an organization",
|
||||
inputSchema: {
|
||||
name: z.string(),
|
||||
slug: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
organizationId: z.string().optional(),
|
||||
},
|
||||
},
|
||||
async ({ name, slug, description, organizationId }) => {
|
||||
try {
|
||||
const org = organizationId ?? cfg.organizationId;
|
||||
if (!org) throw new Error("no organization selected — pass organizationId or run `primora use`");
|
||||
return ok(
|
||||
await ProjectsService.createProject({
|
||||
organizationId: org,
|
||||
requestBody: { name, slug: slug ?? slugify(name), description },
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"primora_list_buckets",
|
||||
{
|
||||
description: "List storage buckets in a project",
|
||||
inputSchema: { projectId: z.string().optional() },
|
||||
},
|
||||
async ({ projectId: p }) => {
|
||||
try {
|
||||
return ok(await StorageService.listBuckets({ projectId: projectId(p) }));
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"primora_create_bucket",
|
||||
{
|
||||
description: "Create a storage bucket",
|
||||
inputSchema: {
|
||||
name: z.string(),
|
||||
slug: z.string().optional(),
|
||||
public: z.boolean().optional().describe("Public visibility (default private)"),
|
||||
projectId: z.string().optional(),
|
||||
},
|
||||
},
|
||||
async ({ name, slug, public: isPublic, projectId: p }) => {
|
||||
try {
|
||||
return ok(
|
||||
await StorageService.createBucket({
|
||||
projectId: projectId(p),
|
||||
requestBody: {
|
||||
name,
|
||||
slug: slug ?? slugify(name),
|
||||
visibility: isPublic
|
||||
? CreateBucketRequest.visibility.PUBLIC
|
||||
: CreateBucketRequest.visibility.PRIVATE,
|
||||
},
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const bucketRef = z.string().describe("Bucket id, slug or name");
|
||||
|
||||
server.registerTool(
|
||||
"primora_list_objects",
|
||||
{
|
||||
description: "List objects in a bucket",
|
||||
inputSchema: {
|
||||
bucket: bucketRef,
|
||||
projectId: z.string().optional(),
|
||||
q: z.string().optional().describe("Search filter"),
|
||||
limit: z.number().int().optional(),
|
||||
offset: z.number().int().optional(),
|
||||
},
|
||||
},
|
||||
async ({ bucket, projectId: p, q, limit, offset }) => {
|
||||
try {
|
||||
const bucketId = await bucketIdFor(projectId(p), bucket);
|
||||
return ok(await StorageService.listBucketObjects({ bucketId, q, limit, offset }));
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"primora_upload_object",
|
||||
{
|
||||
description: "Upload an object. Pass UTF-8 content or base64 for binary.",
|
||||
inputSchema: {
|
||||
bucket: bucketRef,
|
||||
key: z.string().describe("Object key, slashes allowed"),
|
||||
content: z.string().optional().describe("UTF-8 content"),
|
||||
contentBase64: z.string().optional().describe("Base64 content (binary-safe)"),
|
||||
contentType: z.string().optional().describe("MIME type"),
|
||||
projectId: z.string().optional(),
|
||||
},
|
||||
},
|
||||
async ({ bucket, key, content, contentBase64, contentType, projectId: p }) => {
|
||||
try {
|
||||
const bucketId = await bucketIdFor(projectId(p), bucket);
|
||||
let blob: Blob;
|
||||
if (contentBase64 !== undefined) {
|
||||
blob = new Blob([Buffer.from(contentBase64, "base64")], {
|
||||
type: contentType ?? "application/octet-stream",
|
||||
});
|
||||
} else if (content !== undefined) {
|
||||
blob = new Blob([content], { type: contentType ?? "text/plain" });
|
||||
} else {
|
||||
throw new Error("pass content or contentBase64");
|
||||
}
|
||||
return ok(
|
||||
await StorageService.uploadBucketObject({
|
||||
bucketId,
|
||||
formData: { objectKey: key, file: blob },
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"primora_download_object",
|
||||
{
|
||||
description: "Download an object — returns text for text types, base64 otherwise",
|
||||
inputSchema: {
|
||||
bucket: bucketRef,
|
||||
key: z.string(),
|
||||
projectId: z.string().optional(),
|
||||
},
|
||||
},
|
||||
async ({ bucket, key, projectId: p }) => {
|
||||
try {
|
||||
const bucketId = await bucketIdFor(projectId(p), bucket);
|
||||
const res = await apiFetch(`/buckets/${bucketId}/objects/${encodeURIComponent(key)}`);
|
||||
const type = res.headers.get("content-type") ?? "application/octet-stream";
|
||||
if (TEXT_TYPES.test(type)) {
|
||||
return ok({ key, contentType: type, content: await res.text() });
|
||||
}
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
return ok({ key, contentType: type, base64: buf.toString("base64"), bytes: buf.length });
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"primora_delete_object",
|
||||
{
|
||||
description: "Delete an object from a bucket",
|
||||
inputSchema: {
|
||||
bucket: bucketRef,
|
||||
key: z.string(),
|
||||
projectId: z.string().optional(),
|
||||
},
|
||||
},
|
||||
async ({ bucket, key, projectId: p }) => {
|
||||
try {
|
||||
const bucketId = await bucketIdFor(projectId(p), bucket);
|
||||
await StorageService.deleteBucketObject({ bucketId, objectKey: key });
|
||||
return ok({ deleted: key });
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"primora_list_api_keys",
|
||||
{
|
||||
description: "List API keys for a project",
|
||||
inputSchema: { projectId: z.string().optional() },
|
||||
},
|
||||
async ({ projectId: p }) => {
|
||||
try {
|
||||
return ok(await ProjectsService.listApiKeys({ projectId: projectId(p) }));
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"primora_create_api_key",
|
||||
{
|
||||
description: "Create an API key — the secret is returned once, store it",
|
||||
inputSchema: { name: z.string(), projectId: z.string().optional() },
|
||||
},
|
||||
async ({ name, projectId: p }) => {
|
||||
try {
|
||||
return ok(await ProjectsService.createApiKey({ projectId: projectId(p), requestBody: { name } }));
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"primora_revoke_api_key",
|
||||
{
|
||||
description: "Revoke an API key",
|
||||
inputSchema: { apiKeyId: z.string(), projectId: z.string().optional() },
|
||||
},
|
||||
async ({ apiKeyId, projectId: p }) => {
|
||||
try {
|
||||
await ProjectsService.revokeApiKey({ projectId: projectId(p), apiKeyId });
|
||||
return ok({ revoked: apiKeyId });
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"primora_query_audit",
|
||||
{
|
||||
description: "Query the project audit log",
|
||||
inputSchema: {
|
||||
projectId: z.string().optional(),
|
||||
q: z.string().optional().describe("Search resource/action/request id"),
|
||||
action: z.string().optional().describe("Filter by action, e.g. object.uploaded"),
|
||||
limit: z.number().int().optional(),
|
||||
offset: z.number().int().optional(),
|
||||
},
|
||||
},
|
||||
async ({ projectId: p, q, action, limit, offset }) => {
|
||||
try {
|
||||
return ok(
|
||||
await ProjectsService.listAuditLogs({ projectId: projectId(p), q, action, limit, offset }),
|
||||
);
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await server.connect(new StdioServerTransport());
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in new issue
Block a user