263 lines
9.7 KiB
JavaScript
263 lines
9.7 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";
|
|
|
|
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],
|
|
);
|
|
|
|
const token = "edgeever_mcp_test_token";
|
|
const tokenHash = new Bun.CryptoHasher("sha256").update(token).digest("hex");
|
|
sqlite.run(
|
|
"INSERT INTO api_tokens (id, workspace_id, name, token_hash, token_value, scopes_json) VALUES (?, ?, ?, ?, ?, ?)",
|
|
[
|
|
"tok_test",
|
|
"ws_default",
|
|
"MCP regression",
|
|
tokenHash,
|
|
token,
|
|
JSON.stringify(["read:notebooks", "read:memos", "write:memos", "read:resources", "read:tags"]),
|
|
],
|
|
);
|
|
|
|
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 ${token}` }),
|
|
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 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 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);
|
|
|
|
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(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 protocol, identity, notebook lookup, and idempotent import regression passed");
|