mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
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:
@@ -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,
|
||||
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user