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:
Tomas DvorakandDevin committed 2026-09-16 14:04:43 +02:00
1 parent f77823ad90
commit 420502eec1
90 files changed
+9172 -9718

No files matched your search

+33
View File
@@ -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"
}
}
+65
View File
@@ -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");
}
+365
View File
@@ -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());
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true,
"types": ["node"]
},
"include": ["src"]
}