UNPKG

@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
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 () => {