Mandatory (test-coverage): - internal-file-urls.test: pin the SSRF/traversal ACCEPT path of resolveInternalFilePath (the sole guard for content-controlled `src`): an absolute/protocol-relative URL has its foreign host dropped and only an /api/files/ pathname survives (http://evil.com/api/files/x/y.png -> /files/x/y.png), while a host-dropped path that escapes /api/files/ (https://evil.com/api/auth/whoami) or a backslash-traversal (/api/files\..\auth\whoami) is rejected. Locks the behavior so a future prefix-only refactor cannot silently open a bypass. Suggestions: - index.ts: the stash_page MCP tool now returns structuredContent { uri, sha256, size, images } alongside the resource_link, so the MCP output matches the documented shape (clients get the blob's sha256/ETag and the mirror counts, not just the link). No outputSchema registered. Rebuilt build/. - new stash-page-mcp-result.test: server round-trip via InMemoryTransport asserts both the resource_link and the structuredContent mirror. - internal-file-urls.test: cover the new URL parse-failure catch branch (http://[ -> "Invalid internal file src"). - environment.service.spec: assert getPositiveIntEnv warns once per key and independently across keys (the invalidPositiveIntWarned dedup). Tests: packages/mcp 383 pass; apps/server sandbox/environment/mcp 235 pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
156 lines
4.7 KiB
JavaScript
156 lines
4.7 KiB
JavaScript
// Server round-trip test for the stash_page MCP tool result shape. The in-app
|
|
// path returns the full documented `{ uri, size, sha256, images }` object, but
|
|
// the MCP transport must deliver the SAME shape: a resource_link (primary
|
|
// payload) PLUS a `structuredContent` mirror carrying sha256 + image counts.
|
|
// This connects a real MCP Client to the server over a linked in-memory
|
|
// transport pair and asserts both halves of the result, end to end.
|
|
import { test, after } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import http from "node:http";
|
|
import { createHash } from "node:crypto";
|
|
import { createDocmostMcpServer } from "../../build/index.js";
|
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
|
|
function readBody(req) {
|
|
return new Promise((resolve) => {
|
|
let raw = "";
|
|
req.on("data", (c) => (raw += c));
|
|
req.on("end", () => resolve(raw));
|
|
});
|
|
}
|
|
|
|
function startServer(handler) {
|
|
return new Promise((resolve) => {
|
|
const server = http.createServer(handler);
|
|
server.listen(0, "127.0.0.1", () => {
|
|
const { port } = server.address();
|
|
resolve({ server, baseURL: `http://127.0.0.1:${port}/api` });
|
|
});
|
|
});
|
|
}
|
|
|
|
const openServers = [];
|
|
async function spawn(handler) {
|
|
const { server, baseURL } = await startServer(handler);
|
|
openServers.push(server);
|
|
return baseURL;
|
|
}
|
|
after(async () => {
|
|
await Promise.all(openServers.map((s) => new Promise((r) => s.close(r))));
|
|
});
|
|
|
|
// Minimal in-memory sandbox sink: store the blob and return a uri + sha256 +
|
|
// size, with has/evict probes the client's reconciliation may call.
|
|
function makeSandbox() {
|
|
const live = new Map();
|
|
const idOf = (uri) => uri.substring(uri.lastIndexOf("/") + 1);
|
|
let n = 0;
|
|
return {
|
|
put(buf) {
|
|
const sha256 = createHash("sha256").update(buf).digest("hex");
|
|
const id = `id-${n++}`;
|
|
live.set(id, buf.length);
|
|
return { uri: `https://sb.test/api/sb/${id}`, sha256, size: buf.length };
|
|
},
|
|
has(uri) {
|
|
return live.has(idOf(uri));
|
|
},
|
|
evict(uri) {
|
|
live.delete(idOf(uri));
|
|
},
|
|
};
|
|
}
|
|
|
|
const IMAGE_BYTES = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]);
|
|
|
|
// One internal image (so images.mirrored === 1) inside a normal page doc.
|
|
function pageDoc() {
|
|
return {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "image",
|
|
attrs: { src: "/api/files/att-1/pic.png", attachmentId: "att-1" },
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
// Mock Docmost: login, page info, internal file bytes — same pattern as
|
|
// stash-page.test.mjs.
|
|
async function buildBaseURL() {
|
|
return spawn(async (req, res) => {
|
|
await readBody(req);
|
|
if (req.url === "/api/auth/login") {
|
|
res.writeHead(200, {
|
|
"Content-Type": "application/json",
|
|
"Set-Cookie": "authToken=tok; HttpOnly",
|
|
});
|
|
res.end(JSON.stringify({ token: "tok" }));
|
|
return;
|
|
}
|
|
if (req.url === "/api/pages/info") {
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(
|
|
JSON.stringify({ data: { id: "page-1", title: "T", content: pageDoc() } }),
|
|
);
|
|
return;
|
|
}
|
|
if (req.url.startsWith("/api/files/")) {
|
|
res.writeHead(200, { "Content-Type": "image/png" });
|
|
res.end(IMAGE_BYTES);
|
|
return;
|
|
}
|
|
res.writeHead(404);
|
|
res.end();
|
|
});
|
|
}
|
|
|
|
test("stash_page MCP tool returns a resource_link AND a structuredContent mirror", async () => {
|
|
const baseURL = await buildBaseURL();
|
|
const sandbox = makeSandbox();
|
|
const server = createDocmostMcpServer({
|
|
apiUrl: baseURL,
|
|
email: "u@example.com",
|
|
password: "pw",
|
|
sandbox,
|
|
});
|
|
|
|
const client = new Client({ name: "test-client", version: "0.0.0" });
|
|
const [a, b] = InMemoryTransport.createLinkedPair();
|
|
await server.connect(b);
|
|
await client.connect(a);
|
|
|
|
try {
|
|
const res = await client.callTool({
|
|
name: "stash_page",
|
|
arguments: { pageId: "page-1" },
|
|
});
|
|
|
|
// Primary payload: a resource_link pointing at the sandbox doc blob.
|
|
const link = res.content[0];
|
|
assert.equal(link.type, "resource_link");
|
|
assert.match(link.uri, /^https:\/\/sb\.test\/api\/sb\//);
|
|
|
|
// structuredContent mirrors the full documented shape.
|
|
const sc = res.structuredContent;
|
|
assert.equal(typeof sc, "object");
|
|
assert.equal(sc.uri, link.uri); // same blob as the link
|
|
assert.match(sc.sha256, /^[0-9a-f]{64}$/); // 64-hex ETag
|
|
assert.equal(typeof sc.size, "number");
|
|
assert.deepEqual(sc.images, { mirrored: 1, failed: 0 });
|
|
|
|
// Deep-equal the whole structured payload against what the mock implies.
|
|
assert.deepEqual(sc, {
|
|
uri: link.uri,
|
|
sha256: sc.sha256,
|
|
size: sc.size,
|
|
images: { mirrored: 1, failed: 0 },
|
|
});
|
|
} finally {
|
|
await client.close();
|
|
await server.close();
|
|
}
|
|
});
|