Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ WEB_ORIGIN=http://localhost:3000
# Shared parent domain for the session cookie when the API and dashboard are on
# different subdomains, for example `medialit.cloud`. Leave unset otherwise.
# AUTH_COOKIE_DOMAIN=
# Cloudflare Turnstile on the hosted login page. Set both keys or neither.
# The widget's hostname in Cloudflare must be PUBLIC_API_URL's host.
# TURNSTILE_SITE_KEY=
# TURNSTILE_SECRET_KEY=
BETTER_AUTH_SECRET=medialit-local-better-auth-secret-32b-min
EMAIL=admin@example.com
# Optional. An API key the API creates for EMAIL's default app at startup, so
Expand Down
26 changes: 26 additions & 0 deletions apps/api/__tests__/mcp/call-tool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { medialitMcpTools } from "../../src/mcp/server";
import type { MedialitMcpContext } from "../../src/mcp/context";

/** Runs a tool the way the kit does after authentication, with the args
* already parsed. */
export async function callTool(
name: string,
context: Partial<MedialitMcpContext> & { user: any },
args: Record<string, unknown> = {},
): Promise<any> {
const tool = medialitMcpTools().find(
(candidate) => candidate.name === name,
);
if (!tool) throw new Error(`No MCP tool named ${name}`);
return tool.handler({
context: {
authKind: "apikey",
userId: context.user._id,
appId: "test-app",
scopes: ["data:read", "data:write"],
...context,
},
args: tool.inputSchema.parse(args),
signal: new AbortController().signal,
});
}
27 changes: 8 additions & 19 deletions apps/api/__tests__/mcp/media-tool.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import test, { afterEach, describe, mock } from "node:test";
import assert from "node:assert";
import { handleGetTotalStorageTool } from "../../src/mcp/tools/media";
import mediaQueries from "../../src/media/queries";
import { callTool } from "./call-tool";
import { maxStorageAllowedSubscribed } from "../../src/config/constants";
import { billingStateSource } from "../../src/billing/entitlements";

Expand Down Expand Up @@ -35,28 +36,16 @@ describe("MCP get_total_storage", () => {
};

let queriedUserId: unknown;
const response = await handleGetTotalStorageTool(
{},
{
authInfo: {
extra: {
authKind: "apikey",
userId: user._id,
user,
appId: "test-app",
},
},
},
{
getTotalSpace: async ({ userId }: any) => {
queriedUserId = userId;
return 2103931;
},
mock.method(mediaQueries, "getTotalSpace").mock.mockImplementation(
async ({ userId }: any) => {
queriedUserId = userId;
return 2103931;
},
);
const response = await callTool("get_total_storage", { user });

assert.equal(queriedUserId, user._id);
assert.deepEqual((response as any).structuredContent, {
assert.deepEqual(response, {
storage: 2103931,
maxStorage: maxStorageAllowedSubscribed,
});
Expand Down
42 changes: 17 additions & 25 deletions apps/api/__tests__/mcp/upload-tool.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import test, { afterEach, describe, mock } from "node:test";
import assert from "node:assert";
import { handleUploadMediaTool } from "../../src/mcp/tools/upload";
import { callTool } from "./call-tool";
import mediaQueries from "../../src/media/queries";
import mediaService from "../../src/media/service";
import {
Expand All @@ -12,19 +12,6 @@ import {
NOT_ENOUGH_STORAGE,
} from "../../src/config/strings";

function authInfo(user: any) {
return {
authInfo: {
extra: {
authKind: "apikey",
userId: user.id,
user,
appId: "test-app",
},
},
};
}

function uploadArgs(size: number) {
return {
fileBase64: Buffer.alloc(size).toString("base64"),
Expand All @@ -47,16 +34,17 @@ describe("MCP upload_media", () => {
throw new Error("upload should not be called");
});

const response = await handleUploadMediaTool(
const response = await callTool(
"upload_media",
{ user, userId: user.id },
uploadArgs(maxFileUploadSizeNotSubscribed + 1),
authInfo(user),
);

assert.equal((response as any).isError, true);
assert.equal(
response.content[0].text,
`${FILE_SIZE_EXCEEDED}. Allowed: ${maxFileUploadSizeNotSubscribed} bytes`,
);
assert.equal(response.error.code, "validation_failed");
assert.deepEqual(response.error.safeDetails, {
reason: "file_size_exceeded",
message: `${FILE_SIZE_EXCEEDED}. Allowed: ${maxFileUploadSizeNotSubscribed} bytes`,
});
});

test("rejects uploads that exceed remaining account storage", async () => {
Expand All @@ -71,12 +59,16 @@ describe("MCP upload_media", () => {
throw new Error("upload should not be called");
});

const response = await handleUploadMediaTool(
const response = await callTool(
"upload_media",
{ user, userId: user.id },
uploadArgs(1024),
authInfo(user),
);

assert.equal((response as any).isError, true);
assert.equal(response.content[0].text, NOT_ENOUGH_STORAGE);
assert.equal(response.error.code, "forbidden");
assert.deepEqual(response.error.safeDetails, {
reason: "not_enough_storage",
message: NOT_ENOUGH_STORAGE,
});
});
});
82 changes: 47 additions & 35 deletions apps/api/__tests__/mcp/whoami-tool.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import test, { afterEach, describe, mock } from "node:test";
import test, { afterEach, before, describe, mock } from "node:test";
import assert from "node:assert";
import { handleWhoamiTool } from "../../src/mcp/tools/whoami";
import path from "node:path";
import { PGlite } from "@electric-sql/pglite";
import { drizzle } from "drizzle-orm/pglite";
import { migrate } from "drizzle-orm/pglite/migrator";
import { setDb, type AppDb } from "../../src/db/client";
import * as schema from "../../src/db/schema/index";
import { createAccount, createApp, listApps } from "../../src/db";
import mediaQueries from "../../src/media/queries";
import { maxStorageAllowedSubscribed } from "../../src/config/constants";
import { billingStateSource } from "../../src/billing/entitlements";
import { callTool } from "./call-tool";

function mockPaidPlan() {
mock.method(billingStateSource, "read").mock.mockImplementation(
Expand All @@ -22,60 +30,64 @@ function mockPaidPlan() {
);
}

const user = {
id: "user-1",
_id: "user-1",
email: "hi@example.com",
};
let user: any;
let app: { id: string; appId: string; name: string };

function extra(authKind: "oauth" | "apikey") {
return {
authInfo: {
extra: { authKind, userId: user._id, user, appId: "internal-app" },
},
};
}
before(async () => {
const db = drizzle(new PGlite(), { schema });
await migrate(db, {
migrationsFolder: path.resolve(__dirname, "../../drizzle"),
});
setDb(db as unknown as AppDb);
user = await createAccount({ email: "hi@example.com" });
[app] = await listApps(user._id);
});

describe("MCP whoami", () => {
afterEach(() => mock.restoreAll());

test("reports the account, the connected app and its usage", async () => {
mockPaidPlan();
const queried: unknown[] = [];
const response = await handleWhoamiTool({}, extra("oauth"), {
getApp: async (id: string) => {
queried.push(id);
return {
id,
appId: "app-1",
name: "My Store",
default: true,
deleted: false,
userId: user._id,
} as any;
},
getMediaCount: async ({ userId, appId }: any) => {
mock.method(mediaQueries, "getMediaCount").mock.mockImplementation(
async ({ userId, appId }: any) => {
queried.push([userId, appId]);
return 3;
},
getTotalSpace: async () => 2048,
);
mock.method(mediaQueries, "getTotalSpace").mock.mockImplementation(
async () => 2048,
);

const response = await callTool("whoami", {
authKind: "oauth",
user,
appId: app.id,
});

assert.deepEqual(queried, ["internal-app", ["user-1", "internal-app"]]);
assert.deepEqual((response as any).structuredContent, {
assert.deepEqual(queried, [[user._id, app.id]]);
assert.deepEqual(response, {
auth: "oauth",
email: "hi@example.com",
app: { id: "app-1", name: "My Store", default: true },
app: { id: app.appId, name: app.name, default: true },
files: 3,
storage: 2048,
maxStorage: maxStorageAllowedSubscribed,
});
// The app is named by its public ID, never the internal one.
assert.doesNotMatch(response.content[0].text, /internal-app/);
assert.doesNotMatch(JSON.stringify(response), new RegExp(app.id));
});

test("rejects calls without auth", async () => {
const response = await handleWhoamiTool({}, {});
assert.equal((response as any).isError, true);
test("refuses an app that was deleted", async () => {
const deleted = await createApp({
userId: user._id,
name: "Gone",
deleted: true,
});
const response = await callTool("whoami", {
user,
appId: deleted.id,
});
assert.equal(response.error.code, "tenant_forbidden");
});
});
14 changes: 7 additions & 7 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,16 +40,15 @@
"@aws-sdk/s3-request-presigner": "^3.1146.0",
"@better-auth/cimd": "1.7.7",
"@better-auth/oauth-provider": "1.7.7",
"@codelitdev/billing": "0.4.1",
"@codelitdev/mcp-server-kit": "0.4.1",
"@codelitdev/oauth-server-kit": "0.4.1",
"@codelitdev/observability": "0.4.1",
"@codelitdev/platform": "0.4.1",
"@codelitdev/billing": "0.8.0",
"@codelitdev/mcp-server-kit": "0.8.0",
"@codelitdev/oauth-server-kit": "0.8.0",
"@codelitdev/observability": "0.8.0",
"@codelitdev/platform": "0.8.0",
"@medialit/images": "workspace:*",
"@medialit/models": "workspace:*",
"@medialit/thumbnail": "workspace:*",
"@medialit/utils": "workspace:*",
"@modelcontextprotocol/sdk": "1.32.0",
"@tus/file-store": "^2.1.1",
"@tus/server": "^2.4.5",
"better-auth": "1.7.7",
Expand All @@ -69,7 +68,8 @@
"zod": "3.25.76"
},
"devDependencies": {
"@codelitdev/platform-conformance": "0.4.1",
"@codelitdev/platform-conformance": "0.8.0",
"@modelcontextprotocol/client": "2.4.0",
"@electric-sql/pglite": "0.5.8",
"@types/cors": "^2.8.12",
"@types/express": "^4.17.20",
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/__tests__/lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ function recordingSteps(calls: string[]) {
stopBackgroundJobs: () => {
calls.push("jobs");
},
closeMcpSessions: async () => {
closeMcp: async () => {
calls.push("mcp");
},
closeServer: async () => {
Expand Down
Loading
Loading