@softeria/ms-365-mcp-server
Version:
A Model Context Protocol (MCP) server for interacting with Microsoft 365 and Office services through the Graph API
1,232 lines (1,231 loc) • 65.1 kB
JavaScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { z } from "zod";
vi.mock("../logger.js", () => ({
default: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn()
}
}));
const mockEndpoints = [];
vi.mock("../generated/client-beta.js", () => ({ api: { endpoints: [] } }));
vi.mock("../generated/client.js", () => ({
api: {
get endpoints() {
return mockEndpoints;
}
}
}));
let mockEndpointsJson = [];
vi.mock("fs", async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
readFileSync: (filePath, encoding) => {
if (typeof filePath === "string" && filePath.includes("endpoints.json")) {
return JSON.stringify(mockEndpointsJson);
}
return actual.readFileSync(filePath, encoding);
}
};
});
vi.mock("../tool-categories.js", () => ({
TOOL_CATEGORIES: {}
}));
function makeEndpoint(overrides = {}) {
return {
method: "get",
path: "/me/messages",
alias: "test-tool",
description: "Test tool",
requestFormat: "json",
parameters: [
{ name: "filter", type: "Query", schema: z.string().optional() },
{ name: "search", type: "Query", schema: z.string().optional() },
{ name: "select", type: "Query", schema: z.string().optional() },
{ name: "orderby", type: "Query", schema: z.string().optional() },
{ name: "count", type: "Query", schema: z.boolean().optional() },
{ name: "top", type: "Query", schema: z.number().optional() },
{ name: "skip", type: "Query", schema: z.number().optional() }
],
response: z.any(),
...overrides
};
}
function makeConfig(overrides = {}) {
return {
pathPattern: "/me/messages",
method: "get",
toolName: "test-tool",
scopes: ["Mail.Read"],
...overrides
};
}
function createMockGraphClient(responses, outputFormat = "json") {
const responseQueue = [...responses || []];
return {
graphRequest: vi.fn().mockImplementation(async () => {
if (responseQueue.length > 0) {
return responseQueue.shift();
}
return {
content: [{ type: "text", text: JSON.stringify({ value: [] }) }]
};
}),
// Fake serialize: prefix the JSON in toon mode so a test can tell the merged
// body went through serialize() and not a plain JSON.stringify.
serialize: vi.fn().mockImplementation(
(data) => outputFormat === "toon" ? `TOON:${JSON.stringify(data)}` : JSON.stringify(data)
)
};
}
async function loadModule() {
vi.resetModules();
const mod = await import("../graph-tools.js");
return mod;
}
function createMockServer() {
const tools = /* @__PURE__ */ new Map();
return {
tool: vi.fn(
(name, description, schema, annotations, handler) => {
tools.set(name, { description, schema, handler });
}
),
tools
};
}
function makeJwt(payload) {
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
return `header.${body}.signature`;
}
describe("graph-tools", () => {
beforeEach(() => {
mockEndpoints.length = 0;
mockEndpointsJson = [];
vi.clearAllMocks();
});
describe("$count advanced query mode", () => {
it("should set ConsistencyLevel: eventual header when $count=true", async () => {
const endpoint = makeEndpoint();
const config = makeConfig();
mockEndpoints.push(endpoint);
mockEndpointsJson = [config];
const graphClient = createMockGraphClient([
{ content: [{ type: "text", text: JSON.stringify({ value: [] }) }] }
]);
const server = createMockServer();
const { registerGraphTools } = await loadModule();
registerGraphTools(server, graphClient);
const tool = server.tools.get("test-tool");
expect(tool).toBeDefined();
await tool.handler({ count: true });
expect(graphClient.graphRequest).toHaveBeenCalledTimes(1);
const [url] = graphClient.graphRequest.mock.calls[0];
expect(url).toContain("$count=true");
});
});
describe("fetchAllPages pagination", () => {
it("should follow @odata.nextLink and combine results", async () => {
const endpoint = makeEndpoint();
const config = makeConfig();
mockEndpoints.push(endpoint);
mockEndpointsJson = [config];
const graphClient = createMockGraphClient([
{
content: [
{
type: "text",
text: JSON.stringify({
value: [{ id: "1" }, { id: "2" }],
"@odata.nextLink": "https://graph.microsoft.com/v1.0/me/messages?$skip=2"
})
}
]
},
{
content: [
{
type: "text",
text: JSON.stringify({
value: [{ id: "3" }]
})
}
]
}
]);
const server = createMockServer();
const { registerGraphTools } = await loadModule();
registerGraphTools(server, graphClient);
const tool = server.tools.get("test-tool");
const result = await tool.handler({ fetchAllPages: true });
expect(graphClient.graphRequest).toHaveBeenCalledTimes(2);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.value).toHaveLength(3);
expect(parsed.value.map((v) => v.id)).toEqual(["1", "2", "3"]);
expect(parsed["@odata.nextLink"]).toBeUndefined();
});
it("merges all pages under --toon and encodes the combined result once (#560)", async () => {
const endpoint = makeEndpoint();
const config = makeConfig();
mockEndpoints.push(endpoint);
mockEndpointsJson = [config];
const graphClient = createMockGraphClient(
[
{
content: [
{
type: "text",
text: JSON.stringify({
value: [{ id: "1" }, { id: "2" }],
"@odata.nextLink": "https://graph.microsoft.com/v1.0/me/messages?$skip=2"
})
}
]
},
{
content: [{ type: "text", text: JSON.stringify({ value: [{ id: "3" }] }) }]
}
],
"toon"
);
const server = createMockServer();
const { registerGraphTools } = await loadModule();
registerGraphTools(server, graphClient);
const tool = server.tools.get("test-tool");
const result = await tool.handler({ fetchAllPages: true });
for (const call of graphClient.graphRequest.mock.calls) {
expect(call[1]?.forceJsonOutput).toBe(true);
}
expect(graphClient.serialize).toHaveBeenCalledTimes(1);
expect(result.content[0].text.startsWith("TOON:")).toBe(true);
const parsed = JSON.parse(result.content[0].text.slice("TOON:".length));
expect(parsed.value.map((v) => v.id)).toEqual(["1", "2", "3"]);
expect(parsed["@odata.nextLink"]).toBeUndefined();
});
it("does not inject value:[] when fetchAllPages hits a single-object (non-collection) GET", async () => {
const endpoint = makeEndpoint();
const config = makeConfig();
mockEndpoints.push(endpoint);
mockEndpointsJson = [config];
const graphClient = createMockGraphClient([
{ content: [{ type: "text", text: JSON.stringify({ id: "abc", displayName: "Solo" }) }] }
]);
const server = createMockServer();
const { registerGraphTools } = await loadModule();
registerGraphTools(server, graphClient);
const result = await server.tools.get("test-tool").handler({ fetchAllPages: true });
const parsed = JSON.parse(result.content[0].text);
expect(parsed).toEqual({ id: "abc", displayName: "Solo" });
expect(parsed.value).toBeUndefined();
expect(graphClient.graphRequest).toHaveBeenCalledTimes(1);
});
it("should stop at 100 page limit", async () => {
const endpoint = makeEndpoint();
const config = makeConfig();
mockEndpoints.push(endpoint);
mockEndpointsJson = [config];
const responses = [];
for (let i = 0; i < 101; i++) {
responses.push({
content: [
{
type: "text",
text: JSON.stringify({
value: [{ id: `item-${i}` }],
"@odata.nextLink": "https://graph.microsoft.com/v1.0/me/messages?$skip=" + (i + 1)
})
}
]
});
}
const graphClient = createMockGraphClient(responses);
const server = createMockServer();
const { registerGraphTools } = await loadModule();
registerGraphTools(server, graphClient);
const tool = server.tools.get("test-tool");
await tool.handler({ fetchAllPages: true });
expect(graphClient.graphRequest).toHaveBeenCalledTimes(100);
});
describe("pagination env caps", () => {
const prev = {
pages: process.env.MS365_MCP_MAX_PAGES,
items: process.env.MS365_MCP_MAX_ITEMS,
allow: process.env.MS365_MCP_ALLOW_PAGINATION
};
afterEach(() => {
const restore = (name, value) => value === void 0 ? delete process.env[name] : process.env[name] = value;
restore("MS365_MCP_MAX_PAGES", prev.pages);
restore("MS365_MCP_MAX_ITEMS", prev.items);
restore("MS365_MCP_ALLOW_PAGINATION", prev.allow);
});
const paginatingResponses = (count) => Array.from({ length: count }, (_, i) => ({
content: [
{
type: "text",
text: JSON.stringify({
value: [{ id: `item-${i}` }],
"@odata.nextLink": "https://graph.microsoft.com/v1.0/me/messages?$skip=" + (i + 1)
})
}
]
}));
it("should honor MS365_MCP_MAX_PAGES below the default", async () => {
process.env.MS365_MCP_MAX_PAGES = "2";
mockEndpoints.push(makeEndpoint());
mockEndpointsJson = [makeConfig()];
const graphClient = createMockGraphClient(paginatingResponses(5));
const server = createMockServer();
const { registerGraphTools } = await loadModule();
registerGraphTools(server, graphClient);
await server.tools.get("test-tool").handler({ fetchAllPages: true });
expect(graphClient.graphRequest).toHaveBeenCalledTimes(2);
});
it("should honor MS365_MCP_MAX_ITEMS below the default", async () => {
process.env.MS365_MCP_MAX_ITEMS = "2";
mockEndpoints.push(makeEndpoint());
mockEndpointsJson = [makeConfig()];
const graphClient = createMockGraphClient([
{
content: [
{
type: "text",
text: JSON.stringify({
value: [{ id: "1" }, { id: "2" }],
"@odata.nextLink": "https://graph.microsoft.com/v1.0/me/messages?$skip=2"
})
}
]
},
...paginatingResponses(3)
]);
const server = createMockServer();
const { registerGraphTools } = await loadModule();
registerGraphTools(server, graphClient);
const result = await server.tools.get("test-tool").handler({ fetchAllPages: true });
expect(graphClient.graphRequest).toHaveBeenCalledTimes(1);
expect(JSON.parse(result.content[0].text).value).toHaveLength(2);
});
it("should not follow nextLink when MS365_MCP_ALLOW_PAGINATION is disabled", async () => {
process.env.MS365_MCP_ALLOW_PAGINATION = "0";
mockEndpoints.push(makeEndpoint());
mockEndpointsJson = [makeConfig()];
const graphClient = createMockGraphClient(paginatingResponses(5));
const server = createMockServer();
const { registerGraphTools } = await loadModule();
registerGraphTools(server, graphClient);
await server.tools.get("test-tool").handler({ fetchAllPages: true });
expect(graphClient.graphRequest).toHaveBeenCalledTimes(1);
expect(server.tools.get("test-tool").schema.fetchAllPages).toBeUndefined();
});
it("should advertise fetchAllPages when pagination is enabled", async () => {
delete process.env.MS365_MCP_ALLOW_PAGINATION;
mockEndpoints.push(makeEndpoint());
mockEndpointsJson = [makeConfig()];
const server = createMockServer();
const { registerGraphTools } = await loadModule();
registerGraphTools(server, createMockGraphClient());
expect(server.tools.get("test-tool").schema.fetchAllPages).toBeDefined();
});
it("should reflect MS365_MCP_MAX_PAGES in the fetchAllPages description", async () => {
process.env.MS365_MCP_MAX_PAGES = "7";
mockEndpoints.push(makeEndpoint());
mockEndpointsJson = [makeConfig()];
const server = createMockServer();
const { registerGraphTools } = await loadModule();
registerGraphTools(server, createMockGraphClient());
const schema = server.tools.get("test-tool").schema.fetchAllPages;
expect(schema.description).toContain("up to 7 pages");
});
});
});
describe("parameter describe() overrides", () => {
it("should apply custom descriptions to OData parameters", async () => {
const endpoint = makeEndpoint();
const config = makeConfig();
mockEndpoints.push(endpoint);
mockEndpointsJson = [config];
const server = createMockServer();
const { registerGraphTools } = await loadModule();
registerGraphTools(server, createMockGraphClient());
const tool = server.tools.get("test-tool");
expect(tool).toBeDefined();
const schema = tool.schema;
expect(schema["filter"]).toBeDefined();
expect(schema["filter"].description).toContain("OData filter expression");
expect(schema["filter"].description).toContain("$count=true");
expect(schema["search"]).toBeDefined();
expect(schema["search"].description).toContain("KQL search query");
expect(schema["select"]).toBeDefined();
expect(schema["select"].description).toContain("Comma-separated fields");
expect(schema["orderby"]).toBeDefined();
expect(schema["orderby"].description).toContain("Sort expression");
expect(schema["count"]).toBeDefined();
expect(schema["count"].description).toContain("advanced query mode");
expect(schema["top"].description).toContain("Start small");
expect(schema["top"].description).toContain("$select");
});
});
describe("MS365_MCP_MAX_TOP", () => {
const prevMaxTop = process.env.MS365_MCP_MAX_TOP;
afterEach(() => {
if (prevMaxTop === void 0) delete process.env.MS365_MCP_MAX_TOP;
else process.env.MS365_MCP_MAX_TOP = prevMaxTop;
});
it("should clamp $top when MS365_MCP_MAX_TOP is set", async () => {
process.env.MS365_MCP_MAX_TOP = "10";
const endpoint = makeEndpoint();
const config = makeConfig();
mockEndpoints.push(endpoint);
mockEndpointsJson = [config];
const graphClient = createMockGraphClient([
{ content: [{ type: "text", text: JSON.stringify({ value: [] }) }] }
]);
const server = createMockServer();
const { registerGraphTools } = await loadModule();
registerGraphTools(server, graphClient);
const tool = server.tools.get("test-tool");
await tool.handler({ top: 50 });
const [url] = graphClient.graphRequest.mock.calls[0];
expect(url).toContain("$top=10");
});
it("should pass through $top when MS365_MCP_MAX_TOP is unset", async () => {
delete process.env.MS365_MCP_MAX_TOP;
const endpoint = makeEndpoint();
const config = makeConfig();
mockEndpoints.push(endpoint);
mockEndpointsJson = [config];
const graphClient = createMockGraphClient([
{ content: [{ type: "text", text: JSON.stringify({ value: [] }) }] }
]);
const server = createMockServer();
const { registerGraphTools } = await loadModule();
registerGraphTools(server, graphClient);
const tool = server.tools.get("test-tool");
await tool.handler({ top: 50 });
const [url] = graphClient.graphRequest.mock.calls[0];
expect(url).toContain("$top=50");
});
});
describe("returnDownloadUrl", () => {
it("should strip /content from path and return downloadUrl when returnDownloadUrl=true", async () => {
const endpoint = makeEndpoint({
alias: "download-file",
path: "/me/drive/items/:driveItem-id/content",
parameters: [{ name: "driveItem-id", type: "Path", schema: z.string() }]
});
const config = makeConfig({
toolName: "download-file",
pathPattern: "/me/drive/items/{driveItem-id}/content",
returnDownloadUrl: true
});
mockEndpoints.push(endpoint);
mockEndpointsJson = [config];
const downloadUrl = "https://download.example.com/file.pdf";
const graphClient = createMockGraphClient([
{
content: [
{
type: "text",
text: JSON.stringify({
"@microsoft.graph.downloadUrl": downloadUrl,
name: "file.pdf"
})
}
]
}
]);
const server = createMockServer();
const { registerGraphTools } = await loadModule();
registerGraphTools(server, graphClient);
const tool = server.tools.get("download-file");
expect(tool).toBeDefined();
await tool.handler({ "driveItem-id": "abc123" });
const [requestedPath] = graphClient.graphRequest.mock.calls[0];
expect(requestedPath).not.toContain("/content");
expect(requestedPath).toContain("/me/drive/items/abc123");
});
});
describe("kebab-case path param normalization", () => {
it("should substitute path when LLM passes message-id (kebab) but schema has messageId (camelCase)", async () => {
const endpoint = makeEndpoint({
alias: "get-mail-message",
method: "get",
path: "/me/messages/:messageId",
parameters: [
{ name: "messageId", type: "Path", schema: z.string() },
{ name: "select", type: "Query", schema: z.string().optional() }
]
});
const config = makeConfig({
toolName: "get-mail-message",
pathPattern: "/me/messages/{message-id}"
});
mockEndpoints.push(endpoint);
mockEndpointsJson = [config];
const graphClient = createMockGraphClient([
{ content: [{ type: "text", text: JSON.stringify({ id: "AAMk123", subject: "Test" }) }] }
]);
const server = createMockServer();
const { registerGraphTools } = await loadModule();
registerGraphTools(server, graphClient);
const tool = server.tools.get("get-mail-message");
expect(tool).toBeDefined();
await tool.handler({ "message-id": "AAMk123abc=" });
const [requestedPath] = graphClient.graphRequest.mock.calls[0];
expect(requestedPath).toContain("AAMk123abc=");
expect(requestedPath).not.toContain(":messageId");
});
it("should also work when LLM passes messageId (camelCase) directly", async () => {
const endpoint = makeEndpoint({
alias: "get-mail-message2",
method: "get",
path: "/me/messages/:messageId",
parameters: [{ name: "messageId", type: "Path", schema: z.string() }]
});
const config = makeConfig({
toolName: "get-mail-message2",
pathPattern: "/me/messages/{message-id}"
});
mockEndpoints.push(endpoint);
mockEndpointsJson = [config];
const graphClient = createMockGraphClient([
{ content: [{ type: "text", text: JSON.stringify({ id: "AAMk456" }) }] }
]);
const server = createMockServer();
const { registerGraphTools } = await loadModule();
registerGraphTools(server, graphClient);
const tool = server.tools.get("get-mail-message2");
await tool.handler({ messageId: "AAMk456xyz=" });
const [requestedPath] = graphClient.graphRequest.mock.calls[0];
expect(requestedPath).toContain("AAMk456xyz=");
expect(requestedPath).not.toContain(":messageId");
});
});
describe("supportsTimezone", () => {
it("should set Prefer: outlook.timezone header when timezone param provided", async () => {
const endpoint = makeEndpoint({
alias: "list-calendar-events",
path: "/me/events",
parameters: []
});
const config = makeConfig({
toolName: "list-calendar-events",
pathPattern: "/me/events",
supportsTimezone: true
});
mockEndpoints.push(endpoint);
mockEndpointsJson = [config];
const graphClient = createMockGraphClient([
{ content: [{ type: "text", text: JSON.stringify({ value: [] }) }] }
]);
const server = createMockServer();
const { registerGraphTools } = await loadModule();
registerGraphTools(server, graphClient);
const tool = server.tools.get("list-calendar-events");
expect(tool).toBeDefined();
expect(tool.schema["timezone"]).toBeDefined();
expect(tool.schema["timezone"].description).toContain("IANA timezone");
await tool.handler({ timezone: "Europe/Brussels" });
const [, options] = graphClient.graphRequest.mock.calls[0];
expect(options.headers["Prefer"]).toContain('outlook.timezone="Europe/Brussels"');
});
it("should NOT add timezone parameter when supportsTimezone is false/absent", async () => {
const endpoint = makeEndpoint({
alias: "list-mail",
path: "/me/messages",
parameters: []
});
const config = makeConfig({
toolName: "list-mail",
pathPattern: "/me/messages"
// no supportsTimezone
});
mockEndpoints.push(endpoint);
mockEndpointsJson = [config];
const server = createMockServer();
const { registerGraphTools } = await loadModule();
registerGraphTools(server, createMockGraphClient());
const tool = server.tools.get("list-mail");
expect(tool.schema["timezone"]).toBeUndefined();
});
});
describe("outlook.body-content-type Prefer header", () => {
it('should set Prefer: outlook.body-content-type="text" on GET requests', async () => {
const endpoint = makeEndpoint({ method: "get" });
const config = makeConfig({ method: "get" });
mockEndpoints.push(endpoint);
mockEndpointsJson = [config];
const graphClient = createMockGraphClient([
{ content: [{ type: "text", text: JSON.stringify({ value: [] }) }] }
]);
const server = createMockServer();
const { registerGraphTools } = await loadModule();
registerGraphTools(server, graphClient);
await server.tools.get("test-tool").handler({});
const [, options] = graphClient.graphRequest.mock.calls[0];
expect(options.headers["Prefer"]).toContain('outlook.body-content-type="text"');
});
it("should NOT set Prefer: outlook.body-content-type on POST requests", async () => {
const endpoint = makeEndpoint({
alias: "create-reply-draft",
method: "post",
path: "/me/messages/:messageId/createReply",
parameters: [
{ name: "messageId", type: "Path", schema: z.string() },
{ name: "body", type: "Body", schema: z.any() }
]
});
const config = makeConfig({
toolName: "create-reply-draft",
method: "post",
pathPattern: "/me/messages/{message-id}/createReply"
});
mockEndpoints.push(endpoint);
mockEndpointsJson = [config];
const graphClient = createMockGraphClient([{ content: [{ type: "text", text: "{}" }] }]);
const server = createMockServer();
const { registerGraphTools } = await loadModule();
registerGraphTools(server, graphClient);
await server.tools.get("create-reply-draft").handler({
messageId: "AAMk123",
body: { Message: { body: { contentType: "html", content: "<p>hi</p>" } } },
confirm: true
// destructive POST — required by isDestructiveOperation gate
});
const [, options] = graphClient.graphRequest.mock.calls[0];
const prefer = options.headers["Prefer"];
expect(prefer === void 0 || !prefer.includes("outlook.body-content-type")).toBe(true);
});
});
describe("binary upload bodies", () => {
it("decodes base64 body to bytes and sets octet-stream Content-Type", async () => {
const endpoint = makeEndpoint({
alias: "upload-file-content",
method: "put",
path: "/drives/:driveId/items/:driveItemId/content",
requestFormat: "binary",
parameters: [
{ name: "driveId", type: "Path", schema: z.string() },
{ name: "driveItemId", type: "Path", schema: z.string() },
{
name: "body",
type: "Body",
schema: z.string().describe("Base64-encoded file content")
}
]
});
const config = makeConfig({
toolName: "upload-file-content",
method: "put",
pathPattern: "/drives/{drive-id}/items/{driveItem-id}/content",
scopes: ["Files.ReadWrite"]
});
mockEndpoints.push(endpoint);
mockEndpointsJson = [config];
const graphClient = createMockGraphClient([{ content: [{ type: "text", text: "{}" }] }]);
const server = createMockServer();
const { registerGraphTools } = await loadModule();
registerGraphTools(server, graphClient);
const original = "Hello, world!";
const base64 = Buffer.from(original, "utf-8").toString("base64");
await server.tools.get("upload-file-content").handler({
driveId: "drive123",
driveItemId: "item456",
body: base64,
confirm: true
// destructive PUT — required by isDestructiveOperation gate
});
const [path, options] = graphClient.graphRequest.mock.calls[0];
expect(path).toBe("/drives/drive123/items/item456/content");
expect(options.headers["Content-Type"]).toBe("application/octet-stream");
expect(Buffer.isBuffer(options.body) || options.body instanceof Uint8Array).toBe(true);
expect(Buffer.from(options.body).toString("utf-8")).toBe(original);
});
it("honors endpoints.json contentType override on binary uploads", async () => {
const endpoint = makeEndpoint({
alias: "upload-file-content",
method: "put",
path: "/drives/:driveId/items/:driveItemId/content",
requestFormat: "binary",
parameters: [
{ name: "driveId", type: "Path", schema: z.string() },
{ name: "driveItemId", type: "Path", schema: z.string() },
{ name: "body", type: "Body", schema: z.string() }
]
});
const config = makeConfig({
toolName: "upload-file-content",
method: "put",
pathPattern: "/drives/{drive-id}/items/{driveItem-id}/content",
scopes: ["Files.ReadWrite"],
contentType: "application/pdf"
});
mockEndpoints.push(endpoint);
mockEndpointsJson = [config];
const graphClient = createMockGraphClient([{ content: [{ type: "text", text: "{}" }] }]);
const server = createMockServer();
const { registerGraphTools } = await loadModule();
registerGraphTools(server, graphClient);
await server.tools.get("upload-file-content").handler({
driveId: "d",
driveItemId: "i",
body: Buffer.from("%PDF-1.4").toString("base64"),
confirm: true
// destructive PUT — required by isDestructiveOperation gate
});
const [, options] = graphClient.graphRequest.mock.calls[0];
expect(options.headers["Content-Type"]).toBe("application/pdf");
});
});
describe("download-bytes", () => {
it("routes a relative Graph path through graphRequest", async () => {
mockEndpoints.length = 0;
mockEndpointsJson = [];
const graphClient = {
graphRequest: vi.fn().mockResolvedValue({
content: [
{
type: "text",
text: JSON.stringify({
contentType: "image/jpeg",
encoding: "base64",
contentBytes: "aGk="
})
}
]
})
};
const server = createMockServer();
const { registerGraphTools } = await loadModule();
registerGraphTools(server, graphClient);
const tool = server.tools.get("download-bytes");
expect(tool).toBeDefined();
await tool.handler({ target: "/me/photo/$value" });
expect(graphClient.graphRequest).toHaveBeenCalledTimes(1);
const [path, options] = graphClient.graphRequest.mock.calls[0];
expect(path).toBe("/me/photo/$value");
expect(options.accessToken).toBeUndefined();
});
it("rejects absolute URLs (Graph paths only)", async () => {
mockEndpoints.length = 0;
mockEndpointsJson = [];
const server = createMockServer();
const { registerGraphTools } = await loadModule();
registerGraphTools(server, {});
const tool = server.tools.get("download-bytes");
const result = await tool.handler({
target: "https://example.sharepoint.com/d/abc?temp=signed"
});
expect(result.isError).toBe(true);
const payload = JSON.parse(result.content[0].text);
expect(payload.error).toMatch(/relative Microsoft Graph path/);
});
it("rejects targets that do not start with /", async () => {
mockEndpoints.length = 0;
mockEndpointsJson = [];
const server = createMockServer();
const { registerGraphTools } = await loadModule();
registerGraphTools(server, {});
const tool = server.tools.get("download-bytes");
const result = await tool.handler({ target: "ftp://example.com/x" });
expect(result.isError).toBe(true);
const payload = JSON.parse(result.content[0].text);
expect(payload.error).toMatch(/relative Microsoft Graph path/);
});
});
describe("get-download-url", () => {
it("strips /content, fetches item metadata, and returns the pre-authed downloadUrl", async () => {
mockEndpoints.length = 0;
mockEndpointsJson = [];
const downloadUrl = "https://contoso.sharepoint.com/download.aspx?tempauth=abc";
const graphClient = {
graphRequest: vi.fn().mockResolvedValue({
content: [
{
type: "text",
text: JSON.stringify({
id: "item1",
name: "report.pdf",
size: 12727,
file: { mimeType: "application/pdf" },
"@microsoft.graph.downloadUrl": downloadUrl
})
}
]
})
};
const server = createMockServer();
const { registerGraphTools } = await loadModule();
registerGraphTools(server, graphClient);
const tool = server.tools.get("get-download-url");
expect(tool).toBeDefined();
const result = await tool.handler({
target: "/drives/d1/items/item1/content"
});
const [requestedPath] = graphClient.graphRequest.mock.calls[0];
expect(requestedPath).toBe("/drives/d1/items/item1");
const payload = JSON.parse(result.content[0].text);
expect(payload.downloadUrl).toBe(downloadUrl);
expect(payload.name).toBe("report.pdf");
expect(payload.size).toBe(12727);
expect(payload.contentType).toBe("application/pdf");
});
it("forces a JSON body on the metadata request so it works under --toon (#560)", async () => {
mockEndpoints.length = 0;
mockEndpointsJson = [];
const graphClient = {
graphRequest: vi.fn().mockResolvedValue({
content: [
{
type: "text",
text: JSON.stringify({ "@microsoft.graph.downloadUrl": "https://dl.example/x" })
}
]
})
};
const server = createMockServer();
const { registerGraphTools } = await loadModule();
registerGraphTools(server, graphClient);
const result = await server.tools.get("get-download-url").handler({ target: "/drives/d1/items/item1/content" });
const [, opts] = graphClient.graphRequest.mock.calls[0];
expect(opts?.forceJsonOutput).toBe(true);
expect(JSON.parse(result.content[0].text).downloadUrl).toBe("https://dl.example/x");
});
it("rejects query-shaped targets instead of silently changing request semantics", async () => {
mockEndpoints.length = 0;
mockEndpointsJson = [];
const graphClient = { graphRequest: vi.fn() };
const server = createMockServer();
const { registerGraphTools } = await loadModule();
registerGraphTools(server, graphClient);
const tool = server.tools.get("get-download-url");
const result = await tool.handler({
target: "/drives/d1/items/item1/content?$select=id,name"
});
expect(result.isError).toBe(true);
expect(graphClient.graphRequest).not.toHaveBeenCalled();
const payload = JSON.parse(result.content[0].text);
expect(payload.error).toContain("must not include query parameters");
});
it("rejects non-drive Graph targets before making an authenticated request", async () => {
mockEndpoints.length = 0;
mockEndpointsJson = [];
const graphClient = { graphRequest: vi.fn() };
const server = createMockServer();
const { registerGraphTools } = await loadModule();
registerGraphTools(server, graphClient);
const tool = server.tools.get("get-download-url");
const result = await tool.handler({
target: "/me/messages/m1"
});
expect(result.isError).toBe(true);
expect(graphClient.graphRequest).not.toHaveBeenCalled();
const payload = JSON.parse(result.content[0].text);
expect(payload.error).toContain("target must identify a driveItem");
});
it("rejects mail attachment $value paths (no pre-authed URL exists)", async () => {
mockEndpoints.length = 0;
mockEndpointsJson = [];
const graphClient = { graphRequest: vi.fn() };
const server = createMockServer();
const { registerGraphTools } = await loadModule();
registerGraphTools(server, graphClient);
const tool = server.tools.get("get-download-url");
const result = await tool.handler({
target: "/me/messages/m1/attachments/a1/$value"
});
expect(result.isError).toBe(true);
expect(graphClient.graphRequest).not.toHaveBeenCalled();
const payload = JSON.parse(result.content[0].text);
expect(payload.error).toMatch(/do not expose a pre-authenticated download URL/);
});
it("rejects calendar event attachment $value paths (no pre-authed URL exists)", async () => {
mockEndpoints.length = 0;
mockEndpointsJson = [];
const graphClient = { graphRequest: vi.fn() };
const server = createMockServer();
const { registerGraphTools } = await loadModule();
registerGraphTools(server, graphClient);
const tool = server.tools.get("get-download-url");
const result = await tool.handler({
target: "/me/events/e1/attachments/a1/$value"
});
expect(result.isError).toBe(true);
expect(graphClient.graphRequest).not.toHaveBeenCalled();
const payload = JSON.parse(result.content[0].text);
expect(payload.error).toMatch(/Mail and calendar event attachments/);
});
it("rejects group mailbox attachment paths (no pre-authed URL exists)", async () => {
mockEndpoints.length = 0;
mockEndpointsJson = [];
const graphClient = { graphRequest: vi.fn() };
const server = createMockServer();
const { registerGraphTools } = await loadModule();
registerGraphTools(server, graphClient);
const tool = server.tools.get("get-download-url");
const result = await tool.handler({
target: "/groups/g1/messages/m1/attachments/a1/$value"
});
expect(result.isError).toBe(true);
expect(graphClient.graphRequest).not.toHaveBeenCalled();
const payload = JSON.parse(result.content[0].text);
expect(payload.error).toMatch(/Mail and calendar event attachments/);
});
it("rejects list-item driveItem relationships until callers provide a drive item path", async () => {
mockEndpoints.length = 0;
mockEndpointsJson = [];
const graphClient = { graphRequest: vi.fn() };
const server = createMockServer();
const { registerGraphTools } = await loadModule();
registerGraphTools(server, graphClient);
const tool = server.tools.get("get-download-url");
const result = await tool.handler({
target: "/sites/site1/lists/list1/items/item1/driveItem"
});
expect(result.isError).toBe(true);
expect(graphClient.graphRequest).not.toHaveBeenCalled();
const payload = JSON.parse(result.content[0].text);
expect(payload.error).toContain("target must identify a driveItem");
});
it("errors when the resource exposes no downloadUrl", async () => {
mockEndpoints.length = 0;
mockEndpointsJson = [];
const graphClient = {
graphRequest: vi.fn().mockResolvedValue({
content: [{ type: "text", text: JSON.stringify({ id: "item1", name: "x" }) }]
})
};
const server = createMockServer();
const { registerGraphTools } = await loadModule();
registerGraphTools(server, graphClient);
const tool = server.tools.get("get-download-url");
const result = await tool.handler({ target: "/drives/d1/items/item1" });
expect(result.isError).toBe(true);
const payload = JSON.parse(result.content[0].text);
expect(payload.error).toMatch(/No pre-authenticated download URL/);
});
it("surfaces the underlying Graph error instead of masking it as no-downloadUrl", async () => {
mockEndpoints.length = 0;
mockEndpointsJson = [];
const graphClient = {
graphRequest: vi.fn().mockResolvedValue({
isError: true,
content: [
{
type: "text",
text: JSON.stringify({ error: "Microsoft Graph API error: 403 Forbidden" })
}
]
})
};
const server = createMockServer();
const { registerGraphTools } = await loadModule();
registerGraphTools(server, graphClient);
const tool = server.tools.get("get-download-url");
const result = await tool.handler({ target: "/drives/d1/items/item1/content" });
expect(result.isError).toBe(true);
const payload = JSON.parse(result.content[0].text);
expect(payload.error).toMatch(/403 Forbidden/);
expect(payload.error).not.toMatch(/No pre-authenticated download URL/);
});
it('does not falsely reject drive folders literally named "attachments"', async () => {
mockEndpoints.length = 0;
mockEndpointsJson = [];
const downloadUrl = "https://contoso.sharepoint.com/download.aspx?tempauth=xyz";
const graphClient = {
graphRequest: vi.fn().mockResolvedValue({
content: [
{
type: "text",
text: JSON.stringify({
name: "report.pdf",
"@microsoft.graph.downloadUrl": downloadUrl
})
}
]
})
};
const server = createMockServer();
const { registerGraphTools } = await loadModule();
registerGraphTools(server, graphClient);
const tool = server.tools.get("get-download-url");
const result = await tool.handler({
target: "/me/drive/root:/Project/attachments/report.pdf:/content"
});
expect(result.isError).toBeFalsy();
const payload = JSON.parse(result.content[0].text);
expect(payload.downloadUrl).toBe(downloadUrl);
});
it("does not falsely reject drive item paths containing messages and attachments folders", async () => {
mockEndpoints.length = 0;
mockEndpointsJson = [];
const downloadUrl = "https://contoso.sharepoint.com/download.aspx?tempauth=folders";
const graphClient = {
graphRequest: vi.fn().mockResolvedValue({
content: [
{
type: "text",
text: JSON.stringify({
name: "report.pdf",
"@microsoft.graph.downloadUrl": downloadUrl
})
}
]
})
};
const server = createMockServer();
const { registerGraphTools } = await loadModule();
registerGraphTools(server, graphClient);
const tool = server.tools.get("get-download-url");
const result = await tool.handler({
target: "/me/drive/root:/messages/m1/attachments/a1/report.pdf:/content"
});
expect(result.isError).toBeFalsy();
const [requestedPath] = graphClient.graphRequest.mock.calls[0];
expect(requestedPath).toBe("/me/drive/root:/messages/m1/attachments/a1/report.pdf:");
const payload = JSON.parse(result.content[0].text);
expect(payload.downloadUrl).toBe(downloadUrl);
});
it("allows SharePoint site drive item paths", async () => {
mockEndpoints.length = 0;
mockEndpointsJson = [];
const downloadUrl = "https://contoso.sharepoint.com/download.aspx?tempauth=site-drive";
const graphClient = {
graphRequest: vi.fn().mockResolvedValue({
content: [
{
type: "text",
text: JSON.stringify({
name: "site-report.pdf",
"@microsoft.graph.downloadUrl": downloadUrl
})
}
]
})
};
const server = createMockServer();
const { registerGraphTools } = await loadModule();
registerGraphTools(server, graphClient);
const tool = server.tools.get("get-download-url");
const result = await tool.handler({
target: "/sites/site1/drive/items/item1/content"
});
expect(result.isError).toBeFalsy();
const [requestedPath] = graphClient.graphRequest.mock.calls[0];
expect(requestedPath).toBe("/sites/site1/drive/items/item1");
const payload = JSON.parse(result.content[0].text);
expect(payload.downloadUrl).toBe(downloadUrl);
});
it("does not strip a drive item path whose item name is content", async () => {
mockEndpoints.length = 0;
mockEndpointsJson = [];
const downloadUrl = "https://contoso.sharepoint.com/download.aspx?tempauth=content-file";
const graphClient = {
graphRequest: vi.fn().mockResolvedValue({
content: [
{
type: "text",
text: JSON.stringify({
name: "content",
"@microsoft.graph.downloadUrl": downloadUrl
})
}
]
})
};
const server = createMockServer();
const { registerGraphTools } = await loadModule();
registerGraphTools(server, graphClient);
const tool = server.tools.get("get-download-url");
const result = await tool.handler({
target: "/me/drive/root:/Project/content:"
});
expect(result.isError).toBeFalsy();
const [requestedPath] = graphClient.graphRequest.mock.calls[0];
expect(requestedPath).toBe("/me/drive/root:/Project/content:");
const payload = JSON.parse(result.content[0].text);
expect(payload.downloadUrl).toBe(downloadUrl);
});
it("rejects meeting recording content paths because Graph returns authenticated bytes", async () => {
mockEndpoints.length = 0;
mockEndpointsJson = [];
const graphClient = { graphRequest: vi.fn() };
const server = createMockServer();
const { registerGraphTools } = await loadModule();
registerGraphTools(server, graphClient);
const tool = server.tools.get("get-download-url");
const result = await tool.handler({
target: "/me/onlineMeetings/meeting1/recordings/recording1/content"
});
expect(result.isError).toBe(true);
expect(graphClient.graphRequest).not.toHaveBeenCalled();
const payload = JSON.parse(result.content[0].text);
expect(payload.error).toMatch(/Meeting recordings do not expose/);
});
it("refuses mismatched account param in bearer mode before resolving download URL", async () => {
mockEndpoints.length = 0;
mockEndpointsJson = [];
const graphClient = { graphRequest: vi.fn() };
const authManager = {
isOAuthModeEnabled: vi.fn().mockReturnValue(false),
getToken: vi.fn().mockResolvedValue(null),
getTokenForAccount: vi.fn()
};
const server = createMockServer();
const { registerGraphTools } = await loadModule();
registerGraphTools(
server,
graphClient,
false,
void 0,
false,
authManager,
true,
["user1@domain.com", "user2@domain.com"]
);
const { requestContext } = await import("../request-context.js");
const tool = server.tools.get("get-download-url");
const bearer = makeJwt({ upn: "user1@domain.com" });
const result = await requestContext.run(
{ accessToken: bearer },
() => tool.handler({
target: "/drives/d1/items/item1/content",
account: "user2@domain.com"
})
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("'account' parameter is not supported");
expect(graphClient.graphRequest).not.toHaveBeenCalled();
expect(authManager.getTokenForAccount).not.toHaveBeenCalled();
});
});
describe("allowed scopes filtering", () => {
it("registerGraphTools hides Graph tools outside the allowed scopes", async () => {
mockEndpoints.push(
{
alias: "list-mail-messages",
method: "get",
path: "/me/messages",
description: "List mail",
parameters: []
},
{
alias: "list-calendar-events",
method: "get",
path: "/me/events",
description: "List events",
parameters: []
}
);
mockEndpointsJson = [
{
toolName: "list-mail-messages",
method: "get",
pathPattern: "/me/messages",
scopes: ["Mail.Read"]
},
{
toolName: "list-calendar-events",
method: "get",
pathPattern: "/me/events",
scopes: ["Calendars.Read"]
}
];
const server = createMockServer();
const { registerGraphTools } = await loadModule();
registerGraphTools(
server,
createMockGraphClient(),
false,
void 0,
false,
void 0,
false,
[],
"Mail.Read"
);
expect(server.tools.has("list-mail-messages")).toBe(true);
expect(server.tools.has("list-calendar-events")).toBe(false);
});
it("discovery hides Graph tools outside the allowed scopes", async () => {
mockEndpoints.push(
{
alias: "list-mail-messages",
method: "get",
path: "/me/messages",
description: "List mail",
parameters: []
},
{
alias: "list-calendar-events",
method: "get",
path: "/me/events",
description: "List events",
parameters: []
}
);
mockEndpointsJson = [
{
toolName: "list-mail-messages",
method: "get",
pathPattern: "/me/messages",
scopes: ["Mail.Read"]
},
{
toolName: "list-calendar-events",
method: "get",
pathPattern: "/me/events",
scopes: ["Calendars.Read"]
}
];
const server = createMockServer();
const { registerDiscoveryTools } = await loadModule();
registerDiscoveryTools(
server,
{},
false,
false,
void 0,
false,
[],
void 0,
"Mail.Read"
);
const result = await server.tools.get("search-tools").handler({ limit: 50 });
const found = JSON.parse(result.content[0].text).tools.map((t) => t.name);
expect(found).toContain("list-mail-messages");
expect(found).not.toContain("list-calendar-events");
});
});
describe("discovery mode: utility tools", () => {
it('search-tools surfaces download-bytes for "download" queries', async () => {
mockEndpoints.length = 0;
mockEndpointsJson = [];
const server = createMockServer();
const { registerDiscoveryTools } = await loadModule();
registerDiscoveryTools(server, {});
const result = await server.tools.get("search-tools").handler({ query: "download" });
const payload = JSON.parse(result.content[0].text);
const names = payload.tools.map((t) => t.name);
expect(names).toContain("download-bytes");
});
it("get-tool-schema returns the download-bytes parameter schema", async () => {
mockEndpoints.length = 0;
mockEndpointsJson = [];
const server = createMockServer();
const { registerDiscoveryTools } = await loadModule();
registerDiscoveryTools(server, {});
const result = await server.tools.get("get-tool-schema").handler({ tool_name: "download-bytes" });
const schema = JSON.parse(result.content[0].text);
expect(schema.name).toBe("download-bytes");
expect(schema.path).toBe("tool:download-bytes");
const targetParam = schema.parameters.find((p) => p.name === "target");
expect(targetParam).toBeDefined();
expect(targetParam.required).toBe(true);
expect(targetParam.description).toContain("authenticated recording bytes");
expect(targetParam.description).not.toContain("returns a URL");
expect(schema.description).toContain("For large drive/SharePoint file content");
});
it("execute-tool dispatches to download-bytes for a Graph path", async () => {