feat: add mcp server (#303)

* feat: added the mcp server

* feat: added the mcp server

* fix: api for database not linked to any projects

* fix: added variable to disable MCP

* chore: refactoring
This commit is contained in:
Charles GTE
2026-06-01 23:06:21 +02:00
committed by GitHub
parent 7afcf0f76b
commit 2d7a5ce264
13 changed files with 780 additions and 12 deletions
+7
View File
@@ -108,6 +108,11 @@ export const env = createEnv({
.enum(["true", "false"])
.transform((val) => val === "true")
.default("false"),
MCP_ENABLED: z
.enum(["true", "false"])
.transform((val) => val === "true")
.default("false"),
},
client: {
NEXT_PUBLIC_PROJECT_VERSION: z.string().optional(),
@@ -183,5 +188,7 @@ export const env = createEnv({
OPENAPI_ENABLED: process.env.OPENAPI_ENABLED,
API_ENABLED: process.env.API_ENABLED,
MCP_ENABLED: process.env.MCP_ENABLED,
},
});
+36 -11
View File
@@ -66,6 +66,7 @@ export async function requireDatabaseAccess(
const access = await resolveDatabaseAccess(id, user);
if (access === "ok") {
return {
ok: true,
@@ -80,31 +81,55 @@ export async function requireDatabaseAccess(
};
}
if (access === "no_project_link") {
return {
ok: false,
response: jsonError("Database is not linked to any project", 403),
};
}
return {
ok: false,
response: jsonError("Not found", 404),
};
}
export type DatabaseAccessResult = "ok" | "forbidden" | "not_found";
export type DatabaseAccessResult =
| "ok"
| "forbidden"
| "not_found"
| "no_project_link";
export async function resolveDatabaseAccess(
id: string,
user: ApiKeyContext["user"]
): Promise<DatabaseAccessResult> {
const accessibleIds = await getAccessibleDatabaseIds(user);
const [accessibleIds, database] = await Promise.all([
getAccessibleDatabaseIds(user),
db.query.database.findFirst({
where: and(
eq(drizzleDb.schemas.database.id, id),
isNull(drizzleDb.schemas.database.deletedAt)
),
columns: {
id: true,
projectId: true,
},
}),
]);
if (!database) {
return "not_found";
}
if (database.projectId === null) {
return "no_project_link";
}
if (accessibleIds.includes(id)) {
return "ok";
}
const exists = await db.query.database.findFirst({
where: and(
eq(drizzleDb.schemas.database.id, id),
isNull(drizzleDb.schemas.database.deletedAt)
),
columns: { id: true },
});
return exists ? "forbidden" : "not_found";
return "forbidden";
}
+63
View File
@@ -0,0 +1,63 @@
import { getServerUrl } from "@/utils/get-server-url";
export type ApiV1Result =
| { ok: true; status: number; data: unknown }
| { ok: false; status: number; error: string };
/**
* Thin fetch wrapper that proxies calls to the existing /api/v1 REST layer.
* Forwards the caller's API key so the REST route runs its own auth + permission checks.
*/
export async function apiV1Fetch(
path: string,
options: RequestInit,
apiKey: string
): Promise<ApiV1Result> {
const url = `${getServerUrl()}${path}`;
let res: Response;
try {
res = await fetch(url, {
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
"x-api-key": apiKey,
},
});
} catch {
return { ok: false, status: 0, error: "Failed to reach internal API" };
}
// 204 No Content has no body
if (res.status === 204) {
return { ok: true, status: 204, data: null };
}
let body: unknown;
try {
body = await res.json();
} catch {
body = null;
}
if (!res.ok) {
const message =
typeof body === "object" &&
body !== null &&
"error" in body &&
typeof (body as Record<string, unknown>).error === "string"
? (body as { error: string }).error
: `Request failed with status ${res.status}`;
return { ok: false, status: res.status, error: message };
}
const data =
typeof body === "object" &&
body !== null &&
"data" in body
? (body as { data: unknown }).data
: body;
return { ok: true, status: res.status, data };
}
+29
View File
@@ -0,0 +1,29 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { ApiKeyContext } from "@/lib/api-v1/types";
import { registerAgentTools } from "./tools/agents";
import { registerDatabaseTools } from "./tools/databases";
import { registerBackupTools } from "./tools/backups";
/**
* Creates a configured McpServer for a single request.
* A new instance is created per request (stateless transport pattern).
*
* @param _ctx - The validated API key context (user + org memberships). Reserved for
* future tools that need server-side context beyond forwarded HTTP calls.
* @param apiKey - The raw API key forwarded to all /api/v1 REST calls.
*/
export function createPortabaseMcpServer(
_ctx: ApiKeyContext,
apiKey: string
): McpServer {
const server = new McpServer({
name: "portabase",
version: process.env.npm_package_version ?? "1.0.0",
});
registerAgentTools(server, apiKey);
registerDatabaseTools(server, apiKey);
registerBackupTools(server, apiKey);
return server;
}
+70
View File
@@ -0,0 +1,70 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import {apiV1Fetch} from "@/lib/mcp/http-client";
import {err, ok} from "@/lib/mcp/tools/response";
export function registerAgentTools(server: McpServer, apiKey: string) {
server.tool(
"list_agents",
"List all agents accessible to the authenticated user",
{},
async () => {
const result = await apiV1Fetch("/api/v1/agents", { method: "GET" }, apiKey);
return result.ok ? ok(result.data) : err(result.error);
}
);
server.tool(
"get_agent",
"Get details for a specific agent, including its associated databases",
{ id: z.string().describe("Agent ID") },
async ({ id }) => {
const result = await apiV1Fetch(`/api/v1/agents/${id}`, { method: "GET" }, apiKey);
return result.ok ? ok(result.data) : err(result.error);
}
);
server.tool(
"create_agent",
"Create a new agent, optionally scoped to an organization",
{
name: z.string().min(1).describe("Agent name"),
organizationId: z
.string()
.uuid()
.optional()
.describe("Organization ID to scope the agent to (optional)"),
},
async ({ name, organizationId }) => {
const result = await apiV1Fetch(
"/api/v1/agents",
{ method: "POST", body: JSON.stringify({ name, organizationId }) },
apiKey
);
return result.ok ? ok(result.data) : err(result.error);
}
);
server.tool(
"delete_agent",
"Delete an agent by ID",
{ id: z.string().describe("Agent ID") },
async ({ id }) => {
const result = await apiV1Fetch(`/api/v1/agents/${id}`, { method: "DELETE" }, apiKey);
return result.ok
? ok({ message: `Agent ${id} deleted successfully` })
: err(result.error);
}
);
server.tool(
"get_agent_key",
"Get the edge key for an agent (used by the agent to authenticate with Portabase)",
{ id: z.string().describe("Agent ID") },
async ({ id }) => {
const result = await apiV1Fetch(`/api/v1/agents/${id}/key`, { method: "GET" }, apiKey);
return result.ok ? ok(result.data) : err(result.error);
}
);
}
+75
View File
@@ -0,0 +1,75 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import {err, ok} from "@/lib/mcp/tools/response";
import {apiV1Fetch} from "@/lib/mcp/http-client";
export function registerBackupTools(server: McpServer, apiKey: string) {
server.tool(
"list_backups",
"List all backups for a specific database, ordered by most recent first",
{ databaseId: z.string().describe("Database ID") },
async ({ databaseId }) => {
const result = await apiV1Fetch(
`/api/v1/databases/${databaseId}/backup`,
{ method: "GET" },
apiKey
);
return result.ok ? ok(result.data) : err(result.error);
}
);
server.tool(
"get_backup",
"Get details for a specific backup, including its storage locations",
{
databaseId: z.string().describe("Database ID"),
backupId: z.string().describe("Backup ID"),
},
async ({ databaseId, backupId }) => {
const result = await apiV1Fetch(
`/api/v1/databases/${databaseId}/backup/${backupId}`,
{ method: "GET" },
apiKey
);
return result.ok ? ok(result.data) : err(result.error);
}
);
server.tool(
"trigger_backup",
"Trigger an immediate backup for a database. Returns 409 if a backup is already running.",
{ databaseId: z.string().describe("Database ID") },
async ({ databaseId }) => {
const result = await apiV1Fetch(
`/api/v1/databases/${databaseId}/backup`,
{ method: "POST", body: JSON.stringify({}) },
apiKey
);
return result.ok ? ok(result.data) : err(result.error);
}
);
server.tool(
"trigger_restore",
"Trigger a database restore from a specific backup storage. Use get_backup to find available backupStorageId values. Returns 409 if a restore is already running.",
{
databaseId: z.string().describe("Database ID"),
backupId: z.string().uuid().describe("Backup ID"),
backupStorageId: z
.string()
.uuid()
.describe("Backup storage ID (from get_backup storages list)"),
},
async ({ databaseId, backupId, backupStorageId }) => {
const result = await apiV1Fetch(
`/api/v1/databases/${databaseId}/restore`,
{
method: "POST",
body: JSON.stringify({ backupId, backupStorageId }),
},
apiKey
);
return result.ok ? ok(result.data) : err(result.error);
}
);
}
+41
View File
@@ -0,0 +1,41 @@
import {McpServer} from "@modelcontextprotocol/sdk/server/mcp.js";
import {z} from "zod";
import {err, ok} from "@/lib/mcp/tools/response";
import {apiV1Fetch} from "@/lib/mcp/http-client";
export function registerDatabaseTools(server: McpServer, apiKey: string) {
server.tool(
"list_databases",
"List all databases accessible to the authenticated user",
{},
async () => {
const result = await apiV1Fetch("/api/v1/databases", {method: "GET"}, apiKey);
return result.ok ? ok(result.data) : err(result.error);
}
);
server.tool(
"get_database",
"Get details for a specific database",
{id: z.string().describe("Database ID")},
async ({id}) => {
const result = await apiV1Fetch(`/api/v1/databases/${id}`, {method: "GET"}, apiKey);
return result.ok ? ok(result.data) : err(result.error);
}
);
server.tool(
"get_database_status",
"Get the current status of a database, including latest backup and restoration state",
{id: z.string().describe("Database ID")},
async ({id}) => {
const result = await apiV1Fetch(
`/api/v1/databases/${id}/status`,
{method: "GET"},
apiKey
);
return result.ok ? ok(result.data) : err(result.error);
}
);
}
+12
View File
@@ -0,0 +1,12 @@
export function ok(data: unknown) {
return {
content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
};
}
export function err(message: string) {
return {
content: [{ type: "text" as const, text: message }],
isError: true as const,
};
}