329 lines
13 KiB
JavaScript
329 lines
13 KiB
JavaScript
import { strict as assert } from "node:assert";
|
|
import { globSync, readFileSync } from "node:fs";
|
|
import { Database } from "bun:sqlite";
|
|
import worker from "../apps/api/src/index.ts";
|
|
import { createMcpHttpHeaders } from "./mcp-http-headers.mjs";
|
|
|
|
class SqliteD1PreparedStatement {
|
|
constructor(db, sql, bindings = []) {
|
|
this.db = db;
|
|
this.sql = sql;
|
|
this.bindings = bindings;
|
|
}
|
|
|
|
bind(...bindings) {
|
|
return new SqliteD1PreparedStatement(this.db, this.sql, bindings);
|
|
}
|
|
|
|
async all() {
|
|
return { results: this.db.query(this.sql).all(...this.bindings), success: true, meta: {} };
|
|
}
|
|
|
|
async first() {
|
|
return this.db.query(this.sql).get(...this.bindings) ?? null;
|
|
}
|
|
|
|
async run() {
|
|
this.db.query(this.sql).run(...this.bindings);
|
|
return { success: true, meta: {} };
|
|
}
|
|
}
|
|
|
|
class SqliteD1Database {
|
|
constructor(db) {
|
|
this.db = db;
|
|
}
|
|
|
|
prepare(sql) {
|
|
return new SqliteD1PreparedStatement(this.db, sql);
|
|
}
|
|
|
|
async batch(statements) {
|
|
return this.db.transaction(() =>
|
|
statements.map((statement) => this.db.query(statement.sql).run(...statement.bindings)))();
|
|
}
|
|
}
|
|
|
|
const sqlite = new Database(":memory:");
|
|
sqlite.exec("PRAGMA foreign_keys = ON");
|
|
for (const file of globSync("migrations/*.sql").sort()) {
|
|
sqlite.exec(readFileSync(file, "utf8"));
|
|
}
|
|
|
|
sqlite.run(
|
|
"INSERT INTO users (id, username, password_hash, display_name) VALUES (?, ?, ?, ?)",
|
|
["usr_zack", "zack42", "test", "Zack"],
|
|
);
|
|
sqlite.run(
|
|
"INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)",
|
|
["ws_default", "usr_zack", "owner"],
|
|
);
|
|
sqlite.run(
|
|
"INSERT INTO notebooks (id, workspace_id, parent_id, name, sort_order) VALUES (?, ?, NULL, ?, ?)",
|
|
["nb_imports", "ws_default", "Imports", 100],
|
|
);
|
|
sqlite.run(
|
|
"INSERT INTO notebooks (id, workspace_id, parent_id, name, sort_order) VALUES (?, ?, ?, ?, ?)",
|
|
["nb_flomo", "ws_default", "nb_imports", "Flomo", 10],
|
|
);
|
|
|
|
sqlite.run(
|
|
"INSERT INTO users (id, username, password_hash, display_name) VALUES (?, ?, ?, ?)",
|
|
["usr_other", "other", "test", "Other"],
|
|
);
|
|
sqlite.run("INSERT INTO workspaces (id, name, is_personal) VALUES (?, ?, 1)", ["ws_other", "Other workspace"]);
|
|
sqlite.run(
|
|
"INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)",
|
|
["ws_other", "usr_other", "member"],
|
|
);
|
|
sqlite.run(
|
|
"INSERT INTO notebooks (id, workspace_id, parent_id, name, sort_order) VALUES (?, ?, NULL, ?, ?)",
|
|
["nb_other", "ws_other", "Private", 10],
|
|
);
|
|
|
|
// Simulate a token created before migration 0007 began retaining token plaintext.
|
|
// Upgraded rows have a valid hash and their original granular scopes, while
|
|
// token_value remains NULL and therefore cannot be copied again from the UI.
|
|
const legacyToken = "edgeever_mcp_test_token";
|
|
const legacyTokenHash = new Bun.CryptoHasher("sha256").update(legacyToken).digest("hex");
|
|
const legacyScopes = [
|
|
"read:notebooks",
|
|
"write:notebooks",
|
|
"read:memos",
|
|
"write:memos",
|
|
"read:resources",
|
|
"read:tags",
|
|
];
|
|
sqlite.run(
|
|
"INSERT INTO api_tokens (id, workspace_id, name, token_hash, scopes_json) VALUES (?, ?, ?, ?, ?)",
|
|
[
|
|
"tok_test",
|
|
"ws_default",
|
|
"MCP Token 1",
|
|
legacyTokenHash,
|
|
JSON.stringify(legacyScopes),
|
|
],
|
|
);
|
|
const storedLegacyToken = sqlite
|
|
.query("SELECT token_value, scopes_json FROM api_tokens WHERE id = ?")
|
|
.get("tok_test");
|
|
assert.equal(storedLegacyToken.token_value, null);
|
|
assert.deepEqual(JSON.parse(storedLegacyToken.scopes_json), legacyScopes);
|
|
|
|
const env = {
|
|
DB: new SqliteD1Database(sqlite),
|
|
RESOURCES: { delete: async () => undefined, get: async () => null, put: async () => undefined },
|
|
};
|
|
const executionContext = { waitUntil: () => undefined, passThroughOnException: () => undefined };
|
|
|
|
const fetchMcp = (payload, options = {}) =>
|
|
worker.fetch(
|
|
new Request("https://edgeever.test/mcp", {
|
|
method: options.method ?? "POST",
|
|
headers: {
|
|
...(options.auth === false ? {} : { Authorization: `Bearer ${legacyToken}` }),
|
|
Accept: "application/json, text/event-stream",
|
|
"Content-Type": "application/json",
|
|
...(options.headers ?? {}),
|
|
},
|
|
...(options.method === "GET" ? {} : { body: JSON.stringify(payload) }),
|
|
}),
|
|
env,
|
|
executionContext,
|
|
);
|
|
|
|
const rpc = (id, method, params) => ({ jsonrpc: "2.0", id, method, ...(params ? { params } : {}) });
|
|
const modernRpc = (id, method, params = {}) => ({
|
|
jsonrpc: "2.0",
|
|
id,
|
|
method,
|
|
params: {
|
|
...params,
|
|
_meta: {
|
|
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
|
|
"io.modelcontextprotocol/clientInfo": { name: "edgeever-integration-test", version: "1.0.0" },
|
|
"io.modelcontextprotocol/clientCapabilities": {},
|
|
},
|
|
},
|
|
});
|
|
const fetchModernMcp = (payload, options = {}) =>
|
|
fetchMcp(payload, {
|
|
...options,
|
|
headers: { ...createMcpHttpHeaders(payload), ...(options.headers ?? {}) },
|
|
});
|
|
const callTool = async (id, name, args = {}) => {
|
|
const response = await fetchMcp(rpc(id, "tools/call", { name, arguments: args }));
|
|
assert.equal(response.status, 200);
|
|
return response.json();
|
|
};
|
|
|
|
let response = await fetchMcp(rpc(1, "initialize", {
|
|
protocolVersion: "2025-11-25",
|
|
capabilities: {},
|
|
clientInfo: { name: "test", version: "1.0.0" },
|
|
}), { auth: false });
|
|
assert.equal(response.status, 401);
|
|
assert.match(response.headers.get("WWW-Authenticate") ?? "", /^Bearer /);
|
|
|
|
response = await fetchMcp(rpc(2, "initialize", {
|
|
protocolVersion: "2025-11-25",
|
|
capabilities: {},
|
|
clientInfo: { name: "test", version: "1.0.0" },
|
|
}));
|
|
assert.equal(response.status, 200);
|
|
let body = await response.json();
|
|
assert.equal(body.result.protocolVersion, "2025-11-25");
|
|
assert.equal(body.result.serverInfo.name, "edgeever");
|
|
assert.notEqual(body.result.serverInfo.version, "0.1.0");
|
|
|
|
response = await fetchModernMcp(modernRpc(201, "server/discover"));
|
|
assert.equal(response.status, 200);
|
|
body = await response.json();
|
|
assert.equal(body.result.resultType, "complete");
|
|
assert.deepEqual(body.result.supportedVersions, ["2026-07-28"]);
|
|
assert.equal(body.result.capabilities.tools.listChanged, false);
|
|
assert.equal(body.result.cacheScope, "public");
|
|
assert.equal(body.result._meta["io.modelcontextprotocol/serverInfo"].name, "edgeever");
|
|
assert.equal(body.result.serverInfo, undefined);
|
|
|
|
response = await fetchModernMcp(modernRpc(202, "tools/call", {
|
|
name: "get_current_user",
|
|
arguments: {},
|
|
}));
|
|
assert.equal(response.status, 200);
|
|
body = await response.json();
|
|
assert.equal(body.result.resultType, "complete");
|
|
assert.equal(body.result.structuredContent.user.username, "zack42");
|
|
assert.equal(body.result._meta["io.modelcontextprotocol/serverInfo"].name, "edgeever");
|
|
|
|
response = await fetchMcp(rpc(3, "tools/list"));
|
|
body = await response.json();
|
|
const tools = new Map(body.result.tools.map((tool) => [tool.name, tool]));
|
|
for (const name of ["get_current_user", "get_notebook", "find_notebooks", "resolve_notebook_path"]) {
|
|
assert.ok(tools.has(name), `Missing MCP tool ${name}`);
|
|
assert.equal(tools.get(name).annotations.readOnlyHint, true);
|
|
assert.equal(tools.get(name).annotations.openWorldHint, false);
|
|
assert.equal(tools.get(name).outputSchema.type, "object");
|
|
}
|
|
assert.equal(tools.get("trash_memos").annotations.destructiveHint, true);
|
|
assert.equal(tools.get("create_memo").annotations.destructiveHint, false);
|
|
assert.equal(tools.get("import_memos").annotations.idempotentHint, true);
|
|
assert.equal(tools.get("import_memos").annotations.destructiveHint, false);
|
|
assert.equal(tools.get("rename_notebook").annotations.readOnlyHint, false);
|
|
assert.equal(tools.get("rename_notebook").annotations.destructiveHint, false);
|
|
assert.equal(tools.get("rename_notebook").annotations.idempotentHint, true);
|
|
|
|
body = await callTool(4, "get_current_user");
|
|
assert.equal(body.result.structuredContent.user.username, "zack42");
|
|
assert.deepEqual(JSON.parse(body.result.content[0].text), body.result.structuredContent);
|
|
|
|
body = await callTool(5, "get_notebook", { notebookId: "nb_flomo" });
|
|
assert.equal(body.result.structuredContent.notebook.name, "Flomo");
|
|
|
|
body = await callTool(6, "find_notebooks", { name: "flo", parentId: "nb_imports" });
|
|
assert.deepEqual(body.result.structuredContent.notebooks.map((notebook) => notebook.id), ["nb_flomo"]);
|
|
|
|
body = await callTool(7, "resolve_notebook_path", { path: "Imports/Flomo" });
|
|
assert.equal(body.result.structuredContent.resolved, true);
|
|
assert.equal(body.result.structuredContent.notebook.id, "nb_flomo");
|
|
|
|
body = await callTool(8, "resolve_notebook_path", { path: "Imports/Missing" });
|
|
assert.equal(body.result.structuredContent.resolved, false);
|
|
assert.equal(body.result.structuredContent.failedSegment, "Missing");
|
|
|
|
body = await callTool(9, "get_notebook", { notebookId: "nb_other" });
|
|
assert.equal(body.result.isError, true);
|
|
assert.equal(body.result.structuredContent.error.code, "not_found");
|
|
|
|
body = await callTool(10, "list_notebooks");
|
|
assert.ok(!body.result.structuredContent.notebooks.some((notebook) => notebook.id === "nb_other"));
|
|
|
|
body = await callTool(101, "rename_notebook", { notebookId: "nb_flomo", name: "Flomo Archive" });
|
|
assert.equal(body.result.isError, false);
|
|
assert.equal(body.result.structuredContent.notebook.name, "Flomo Archive");
|
|
assert.equal(sqlite.query("SELECT name FROM notebooks WHERE id = ?").get("nb_flomo").name, "Flomo Archive");
|
|
|
|
body = await callTool(102, "rename_notebook", { notebookId: "nb_other", name: "Leaked" });
|
|
assert.equal(body.result.isError, true);
|
|
assert.equal(body.result.structuredContent.error.code, "not_found");
|
|
|
|
body = await callTool(11, "import_memos", {
|
|
source: "Flomo",
|
|
notebookId: "nb_flomo",
|
|
items: [
|
|
{
|
|
externalId: "flomo-1",
|
|
title: "Imported once",
|
|
contentMarkdown: "First import",
|
|
tags: ["migration"],
|
|
createdAt: "2026-07-01T00:00:00.000Z",
|
|
updatedAt: "2026-07-02T00:00:00.000Z",
|
|
},
|
|
{ externalId: "flomo-invalid", title: "Invalid", createdAt: "not-a-date" },
|
|
],
|
|
});
|
|
assert.equal(body.result.structuredContent.source, "flomo");
|
|
assert.equal(body.result.structuredContent.created, 1);
|
|
assert.equal(body.result.structuredContent.failed, 1);
|
|
assert.equal(body.result.structuredContent.results[0].status, "created");
|
|
const importedMemoId = body.result.structuredContent.results[0].memo.id;
|
|
|
|
body = await callTool(12, "import_memos", {
|
|
source: "flomo",
|
|
notebookId: "nb_flomo",
|
|
items: [{ externalId: "flomo-1", title: "A retry must not duplicate this memo" }],
|
|
});
|
|
assert.equal(body.result.structuredContent.created, 0);
|
|
assert.equal(body.result.structuredContent.skipped, 1);
|
|
assert.equal(body.result.structuredContent.results[0].memo.id, importedMemoId);
|
|
|
|
body = await callTool(13, "import_memos", {
|
|
source: "flomo",
|
|
notebookId: "nb_flomo",
|
|
dryRun: true,
|
|
items: [{ externalId: "flomo-2", title: "Preview only" }],
|
|
});
|
|
assert.equal(body.result.structuredContent.wouldCreate, 1);
|
|
assert.equal(body.result.structuredContent.results[0].status, "would_create");
|
|
assert.equal(sqlite.query("SELECT COUNT(*) AS count FROM memo_import_sources").get().count, 1);
|
|
assert.equal(sqlite.query("SELECT COUNT(*) AS count FROM memos WHERE id = ?").get(importedMemoId).count, 1);
|
|
|
|
sqlite.run(
|
|
`INSERT INTO resources (id, memo_id, bucket_name, object_key, kind, mime_type, filename, byte_size)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
["res_imported", importedMemoId, "test-resources", "mcp/imported.txt", "attachment", "text/plain", "imported.txt", 12],
|
|
);
|
|
body = await callTool(14, "list_memo_resources", { memoId: importedMemoId });
|
|
assert.equal(body.result.isError, false);
|
|
assert.deepEqual(body.result.structuredContent.resources.map((resource) => resource.id), ["res_imported"]);
|
|
|
|
response = await fetchMcp(rpc(15, "tools/call", { name: "unknown_tool", arguments: {} }));
|
|
assert.equal(response.status, 400);
|
|
body = await response.json();
|
|
assert.equal(body.error.code, -32602);
|
|
|
|
response = await fetchMcp([rpc(16, "tools/list")]);
|
|
assert.equal(response.status, 400);
|
|
|
|
response = await fetchMcp(rpc(17, "tools/list"), {
|
|
headers: { "MCP-Protocol-Version": "2099-01-01" },
|
|
});
|
|
assert.equal(response.status, 400);
|
|
|
|
response = await fetchMcp(rpc(18, "tools/list"), { headers: { Origin: "https://attacker.example" } });
|
|
assert.equal(response.status, 403);
|
|
|
|
response = await fetchMcp(rpc(19, "tools/list"), { headers: { Accept: "application/json" } });
|
|
assert.equal(response.status, 406);
|
|
|
|
response = await fetchMcp(rpc(20, "tools/list"), { headers: { "Content-Type": "text/plain" } });
|
|
assert.equal(response.status, 415);
|
|
|
|
response = await fetchMcp({ jsonrpc: "2.0", method: "notifications/initialized" });
|
|
assert.equal(response.status, 202);
|
|
|
|
response = await fetchMcp(null, { method: "GET" });
|
|
assert.equal(response.status, 405);
|
|
|
|
console.log("MCP 2026/2025 protocol, identity, notebook lookup and rename, and idempotent import regression passed");
|