@softeria/ms-365-mcp-server
Version:
A Model Context Protocol (MCP) server for interacting with Microsoft 365 and Office services through the Graph API
905 lines (903 loc) • 36.6 kB
JavaScript
import { randomUUID } from "crypto";
import logger from "./logger.js";
import { auditLog, getUserIdentityForAudit } from "./audit-log.js";
import {
getEndpointRequiredScopes,
getMissingAllowedScopes,
parseAllowedScopes
} from "./auth.js";
import { api } from "./generated/client.js";
import { z } from "zod";
import { readFileSync } from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { TOOL_CATEGORIES } from "./tool-categories.js";
import { getRequestTokens } from "./request-context.js";
import { parseTeamsUrl } from "./lib/teams-url-parser.js";
import { buildBM25Index, scoreQuery, tokenize } from "./lib/bm25.js";
import { describeToolSchema, describeUtilityToolSchema } from "./lib/tool-schema.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const endpointsData = JSON.parse(
readFileSync(path.join(__dirname, "endpoints.json"), "utf8")
);
function maxTopFromEnv() {
const raw = process.env.MS365_MCP_MAX_TOP;
if (raw === void 0 || raw === "") return void 0;
const n = Number.parseInt(raw, 10);
if (!Number.isFinite(n) || n < 1) {
logger.warn(
`Ignoring invalid MS365_MCP_MAX_TOP=${JSON.stringify(raw)} (use a positive integer)`
);
return void 0;
}
return n;
}
function clampTopQueryParam(queryParams) {
const cap = maxTopFromEnv();
if (cap === void 0 || queryParams["$top"] === void 0) return;
const requested = Number.parseInt(queryParams["$top"], 10);
if (!Number.isFinite(requested) || requested <= cap) return;
logger.info(`Clamping $top from ${requested} to ${cap} (MS365_MCP_MAX_TOP)`);
queryParams["$top"] = String(cap);
}
function formatDisabledToolsForLog(disabledTools) {
const shown = disabledTools.slice(0, 20).map((tool) => `${tool.toolName} (missing: ${tool.missingScopes.join(", ")})`);
const suffix = disabledTools.length > shown.length ? `, ... +${disabledTools.length - shown.length} more` : "";
return `${shown.join("; ")}${suffix}`;
}
const UTILITY_TOOLS = [
{
name: "parse-teams-url",
method: "POST",
path: "tool:parse-teams-url",
description: "Converts any Teams meeting URL format (short /meet/, full /meetup-join/, or recap ?threadId=) into a standard joinWebUrl. Use this before list-online-meetings when the user provides a recap or short URL.",
readOnlyHint: true,
openWorldHint: false,
buildSchema: () => ({
url: z.string().describe("Teams meeting URL in any format")
}),
execute: async (params) => {
const url = params.url;
if (typeof url !== "string") {
return {
content: [{ type: "text", text: JSON.stringify({ error: "url is required." }) }],
isError: true
};
}
try {
const joinWebUrl = parseTeamsUrl(url);
return { content: [{ type: "text", text: joinWebUrl }] };
} catch (error) {
return {
content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
isError: true
};
}
}
},
{
name: "download-bytes",
method: "GET",
path: "tool:download-bytes",
description: 'Download binary content from Microsoft Graph and return it as base64. Single tool for any binary read: drive file content, mail attachment, profile photo, Teams hosted content, meeting recording. Returns { contentType, encoding: "base64", contentLength, contentBytes }.',
readOnlyHint: true,
openWorldHint: true,
buildSchema: (ctx) => {
const schema = {
target: z.string().describe(
'Relative Microsoft Graph path starting with "/". Common paths: /drives/{drive-id}/items/{driveItem-id}/content (drive file content); /me/messages/{message-id}/attachments/{attachment-id}/$value (mail attachment, list-mail-attachments returns the IDs); /me/photo/$value or /users/{user-id}/photo/$value (profile photo); /chats/{chat-id}/messages/{chatMessage-id}/hostedContents/{chatMessageHostedContent-id}/$value (Teams chat hosted content, list-chat-message-hosted-contents returns the IDs); /teams/{team-id}/channels/{channel-id}/messages/{chatMessage-id}/hostedContents/{chatMessageHostedContent-id}/$value (Teams channel hosted content). For meeting recordings (often large), use get-meeting-recording-content which returns a URL for out-of-band download by the client.'
)
};
if (ctx.multiAccount) {
schema["account"] = z.string().optional().describe(
"Account to use when multiple Microsoft accounts are configured. Required when multiple accounts exist (see list-accounts)."
);
}
return schema;
},
execute: async (params, { graphClient, authManager }) => {
const target = params.target;
const accountParam = params.account;
if (typeof target !== "string" || target.length === 0) {
return {
content: [
{
type: "text",
text: JSON.stringify({ error: "target is required and must be a non-empty string." })
}
],
isError: true
};
}
if (!target.startsWith("/")) {
return {
content: [
{
type: "text",
text: JSON.stringify({
error: 'target must be a relative Microsoft Graph path starting with "/", e.g. /me/photo/$value or /drives/{drive-id}/items/{driveItem-id}/content. Absolute URLs are not accepted; if you have an @microsoft.graph.downloadUrl, use the equivalent /content or /$value path instead (Graph 302-redirects to the same bytes).'
})
}
],
isError: true
};
}
try {
let accountAccessToken;
if (authManager && !authManager.isOAuthModeEnabled() && !getRequestTokens()) {
accountAccessToken = await authManager.getTokenForAccount(accountParam);
}
return await graphClient.graphRequest(target, { accessToken: accountAccessToken });
} catch (error) {
return {
content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
isError: true
};
}
}
}
];
function registerUtilityToolWithMcp(server, utility, ctx) {
server.tool(
utility.name,
utility.description,
utility.buildSchema(ctx),
{
title: utility.name,
readOnlyHint: utility.readOnlyHint ?? true,
openWorldHint: utility.openWorldHint ?? true
},
async (params) => utility.execute(params, ctx)
);
}
async function executeGraphTool(tool, config, graphClient, params, authManager) {
logger.info(`Tool ${tool.alias} called with params: ${JSON.stringify(params)}`);
const requestId = randomUUID();
const startTime = Date.now();
const upn = getUserIdentityForAudit(getRequestTokens()?.accessToken);
const httpMethod = tool.method.toUpperCase();
try {
let accountAccessToken;
if (authManager && !authManager.isOAuthModeEnabled() && !getRequestTokens()) {
const accountParam = params.account;
try {
accountAccessToken = await authManager.getTokenForAccount(accountParam);
} catch (err) {
return {
content: [
{
type: "text",
text: JSON.stringify({ error: err.message })
}
],
isError: true
};
}
}
const parameterDefinitions = tool.parameters || [];
let path2 = tool.path;
const queryParams = {};
const headers = {};
let body = null;
for (const [paramName, paramValue] of Object.entries(params)) {
if ([
"account",
"fetchAllPages",
"includeHeaders",
"excludeResponse",
"timezone",
"expandExtendedProperties"
].includes(paramName)) {
continue;
}
const odataParams = [
"filter",
"select",
"expand",
"orderby",
"skip",
"top",
"count",
"search",
"format"
];
const normalizedParamName = paramName.startsWith("$") ? paramName.slice(1) : paramName;
const isOdataParam = odataParams.includes(normalizedParamName.toLowerCase());
const fixedParamName = isOdataParam ? `$${normalizedParamName.toLowerCase()}` : paramName;
const camelCaseParamName = paramName.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
const paramDef = parameterDefinitions.find(
(p) => p.name === paramName || p.name === camelCaseParamName || isOdataParam && p.name === normalizedParamName
);
if (paramDef) {
switch (paramDef.type) {
case "Path": {
const shouldSkipEncoding = config?.skipEncoding?.includes(paramName) ?? false;
const encodedValue = shouldSkipEncoding ? paramValue : encodeURIComponent(paramValue).replace(/%3D/g, "=");
path2 = path2.replace(`{${paramName}}`, encodedValue).replace(`:${paramName}`, encodedValue).replace(`{${camelCaseParamName}}`, encodedValue).replace(`:${camelCaseParamName}`, encodedValue);
break;
}
case "Query":
if (paramValue !== "" && paramValue != null) {
queryParams[fixedParamName] = `${paramValue}`;
}
break;
case "Body":
if (paramDef.schema) {
const parseResult = paramDef.schema.safeParse(paramValue);
if (!parseResult.success) {
const wrapped = { [paramName]: paramValue };
const wrappedResult = paramDef.schema.safeParse(wrapped);
if (wrappedResult.success) {
logger.info(
`Auto-corrected parameter '${paramName}': AI passed nested field directly, wrapped it as {${paramName}: ...}`
);
body = wrapped;
} else {
body = paramValue;
}
} else {
body = paramValue;
}
} else {
body = paramValue;
}
break;
case "Header":
headers[fixedParamName] = `${paramValue}`;
break;
}
} else if (paramName === "body") {
body = paramValue;
logger.info(`Set body param: ${JSON.stringify(body)}`);
} else if (path2.includes(`:${paramName}`) || path2.includes(`{${paramName}}`) || path2.includes(`:${camelCaseParamName}`) || path2.includes(`{${camelCaseParamName}}`)) {
const encodedValue = encodeURIComponent(paramValue).replace(/%3D/g, "=");
path2 = path2.replace(`{${paramName}}`, encodedValue).replace(`:${paramName}`, encodedValue).replace(`{${camelCaseParamName}}`, encodedValue).replace(`:${camelCaseParamName}`, encodedValue);
logger.info(`Path param fallback: replaced :${camelCaseParamName} with encoded value`);
}
}
clampTopQueryParam(queryParams);
const preferValues = [];
if (config?.supportsTimezone && params.timezone) {
preferValues.push(`outlook.timezone="${params.timezone}"`);
logger.info(`Setting timezone preference: outlook.timezone="${params.timezone}"`);
}
const bodyFormat = process.env.MS365_MCP_BODY_FORMAT || "text";
if (bodyFormat !== "html" && tool.method.toUpperCase() === "GET") {
preferValues.push(`outlook.body-content-type="${bodyFormat}"`);
}
if (preferValues.length > 0) {
headers["Prefer"] = preferValues.join(", ");
}
if (config?.supportsExpandExtendedProperties && params.expandExtendedProperties === true) {
const expandValue = "singleValueExtendedProperties";
if (queryParams["$expand"]) {
queryParams["$expand"] += `,${expandValue}`;
} else {
queryParams["$expand"] = expandValue;
}
logger.info(`Adding $expand=${expandValue} for extended properties`);
}
if (config?.contentType) {
headers["Content-Type"] = config.contentType;
logger.info(`Setting custom Content-Type: ${config.contentType}`);
}
if (config?.acceptType) {
headers["Accept"] = config.acceptType;
logger.info(`Setting custom Accept: ${config.acceptType}`);
}
if (Object.keys(queryParams).length > 0) {
const queryString = Object.entries(queryParams).map(([key, value]) => `${key}=${encodeURIComponent(value).replace(/%2C/gi, ",")}`).join("&");
path2 = `${path2}${path2.includes("?") ? "&" : "?"}${queryString}`;
}
const options = {
method: tool.method.toUpperCase(),
headers
};
if (options.method !== "GET" && body) {
if (tool.requestFormat === "binary" && typeof body === "string") {
options.body = Buffer.from(body, "base64");
if (!config?.contentType) {
headers["Content-Type"] = "application/octet-stream";
}
} else if (config?.contentType === "text/html") {
if (typeof body === "string") {
options.body = body;
} else if (typeof body === "object" && "content" in body) {
options.body = body.content;
} else {
options.body = String(body);
}
} else {
options.body = typeof body === "string" ? body : JSON.stringify(body);
}
}
const isProbablyMediaContent = tool.errors?.some((error) => error.description === "Retrieved media content") || path2.endsWith("/content");
if (config?.returnDownloadUrl && path2.endsWith("/content")) {
path2 = path2.replace(/\/content$/, "");
logger.info(
`Auto-returning download URL for ${tool.alias} (returnDownloadUrl=true in endpoints.json)`
);
} else if (isProbablyMediaContent) {
options.rawResponse = true;
}
if (params.includeHeaders === true) {
options.includeHeaders = true;
}
if (params.excludeResponse === true) {
options.excludeResponse = true;
}
if (accountAccessToken) {
options.accessToken = accountAccessToken;
}
const { accessToken: _redacted, ...safeOptions } = options;
logger.info(
`Making graph request to ${path2} with options: ${JSON.stringify(safeOptions)}${_redacted ? " [accessToken=REDACTED]" : ""}`
);
let response = await graphClient.graphRequest(path2, options);
const fetchAllPages = params.fetchAllPages === true;
if (fetchAllPages && response?.content?.[0]?.text) {
try {
let combinedResponse = JSON.parse(response.content[0].text);
let allItems = combinedResponse.value || [];
let nextLink = combinedResponse["@odata.nextLink"];
let pageCount = 1;
const maxPages = 100;
const maxItems = 1e4;
while (nextLink && pageCount < maxPages && allItems.length < maxItems) {
logger.info(`Fetching page ${pageCount + 1} from: ${nextLink}`);
const url = new URL(nextLink);
const nextPath = url.pathname.replace("/v1.0", "") + url.search;
const nextOptions = { ...options };
const nextResponse = await graphClient.graphRequest(nextPath, nextOptions);
if (nextResponse?.content?.[0]?.text) {
const nextJsonResponse = JSON.parse(nextResponse.content[0].text);
if (nextJsonResponse.value && Array.isArray(nextJsonResponse.value)) {
allItems = allItems.concat(nextJsonResponse.value);
}
nextLink = nextJsonResponse["@odata.nextLink"];
pageCount++;
} else {
break;
}
}
if (pageCount >= maxPages) {
logger.warn(`Reached maximum page limit (${maxPages}) for pagination`);
}
if (allItems.length >= maxItems) {
logger.warn(
`Reached maximum item limit (${maxItems}) for pagination \u2014 truncated at ${allItems.length} items`
);
}
combinedResponse.value = allItems;
if (combinedResponse["@odata.count"]) {
combinedResponse["@odata.count"] = allItems.length;
}
delete combinedResponse["@odata.nextLink"];
response.content[0].text = JSON.stringify(combinedResponse);
logger.info(
`Pagination complete: collected ${allItems.length} items across ${pageCount} pages`
);
} catch (e) {
logger.error(`Error during pagination: ${e}`);
}
}
if (response?.content?.[0]?.text) {
const responseText = response.content[0].text;
logger.info(`Response size: ${responseText.length} characters`);
try {
const jsonResponse = JSON.parse(responseText);
if (jsonResponse.value && Array.isArray(jsonResponse.value)) {
logger.info(`Response contains ${jsonResponse.value.length} items`);
}
if (jsonResponse["@odata.nextLink"]) {
logger.info(`Response has pagination nextLink: ${jsonResponse["@odata.nextLink"]}`);
}
} catch {
}
}
const content = response.content.map((item) => ({
type: "text",
text: item.text
}));
auditLog({
event: "tool.call",
request_id: requestId,
user_principal_name: upn,
tool: tool.alias,
http_method: httpMethod,
status: response.isError ? "error" : "success",
duration_ms: Date.now() - startTime
});
return {
content,
_meta: response._meta,
isError: response.isError
};
} catch (error) {
const err = error;
logger.error(`Error in tool ${tool.alias}: ${error.message}`);
auditLog({
event: "tool.call",
request_id: requestId,
user_principal_name: upn,
tool: tool.alias,
http_method: httpMethod,
status: "error",
duration_ms: Date.now() - startTime,
error_type: err?.name || "Error",
error_code: err?.status ?? err?.code
});
return {
content: [
{
type: "text",
text: JSON.stringify({
error: `Error in tool ${tool.alias}: ${error.message}`
})
}
],
isError: true
};
}
}
function registerGraphTools(server, graphClient, readOnly = false, enabledToolsPattern, orgMode = false, authManager, multiAccount = false, accountNames = [], allowedScopesValue) {
let enabledToolsRegex;
if (enabledToolsPattern) {
try {
enabledToolsRegex = new RegExp(enabledToolsPattern, "i");
logger.info(`Tool filtering enabled with pattern: ${enabledToolsPattern}`);
} catch {
logger.error(`Invalid tool filter regex pattern: ${enabledToolsPattern}. Ignoring filter.`);
}
}
let registeredCount = 0;
let skippedCount = 0;
let failedCount = 0;
const allowedScopes = parseAllowedScopes(allowedScopesValue);
const disabledByAllowedScopes = [];
for (const tool of api.endpoints) {
const endpointConfig = endpointsData.find((e) => e.toolName === tool.alias);
if (!orgMode && endpointConfig && !endpointConfig.scopes && endpointConfig.workScopes) {
logger.info(`Skipping work account tool ${tool.alias} - not in org mode`);
skippedCount++;
continue;
}
const method = tool.method.toUpperCase();
if (readOnly && method !== "GET") {
if (!(method === "POST" && endpointConfig?.readOnly)) {
logger.info(`Skipping write operation ${tool.alias} in read-only mode`);
skippedCount++;
continue;
}
}
if (enabledToolsRegex && !enabledToolsRegex.test(tool.alias)) {
logger.info(`Skipping tool ${tool.alias} - doesn't match filter pattern`);
skippedCount++;
continue;
}
const requiredScopes = getEndpointRequiredScopes(endpointConfig, orgMode);
const missingScopes = allowedScopes !== void 0 && !endpointConfig ? ["endpoint scope metadata"] : getMissingAllowedScopes(requiredScopes, allowedScopes);
if (missingScopes.length > 0) {
disabledByAllowedScopes.push({ toolName: tool.alias, missingScopes });
skippedCount++;
continue;
}
const paramSchema = {};
if (tool.parameters && tool.parameters.length > 0) {
for (const param of tool.parameters) {
paramSchema[param.name] = param.schema || z.any();
}
}
const pathParamMatches = tool.path.matchAll(/:([a-zA-Z]+)/g);
for (const match of pathParamMatches) {
const pathParamName = match[1];
if (!(pathParamName in paramSchema)) {
paramSchema[pathParamName] = z.string().describe(`Path parameter: ${pathParamName}`);
}
}
if (tool.method.toUpperCase() === "GET" && tool.path.includes("/")) {
paramSchema["fetchAllPages"] = z.boolean().describe(
"Follow @odata.nextLink and merge up to 100 pages into one response. Can return enormous payloads\u2014only when the user explicitly needs a full export. Prefer a small $top first, then paginate or narrow with $filter/$search."
).optional();
}
if (paramSchema["filter"] !== void 0 || paramSchema["$filter"] !== void 0) {
const key = paramSchema["$filter"] !== void 0 ? "$filter" : "filter";
paramSchema[key] = z.string().describe(
"OData filter expression. Add $count=true for advanced filters (flag/flagStatus, contains()). Cannot combine with $search."
).optional();
}
if (paramSchema["search"] !== void 0 || paramSchema["$search"] !== void 0) {
const key = paramSchema["$search"] !== void 0 ? "$search" : "search";
paramSchema[key] = z.string().describe("KQL search query \u2014 wrap value in double quotes. Cannot combine with $filter.").optional();
}
if (paramSchema["select"] !== void 0 || paramSchema["$select"] !== void 0) {
const key = paramSchema["$select"] !== void 0 ? "$select" : "select";
paramSchema[key] = z.string().describe("Comma-separated fields to return, e.g. id,subject,from,receivedDateTime").optional();
}
if (paramSchema["orderby"] !== void 0 || paramSchema["$orderby"] !== void 0) {
const key = paramSchema["$orderby"] !== void 0 ? "$orderby" : "orderby";
paramSchema[key] = z.string().describe("Sort expression, e.g. receivedDateTime desc").optional();
}
if (paramSchema["top"] !== void 0 || paramSchema["$top"] !== void 0) {
const key = paramSchema["$top"] !== void 0 ? "$top" : "top";
paramSchema[key] = z.number().describe(
"Page size (Graph $top). Start small (e.g. 5\u201315) so responses fit the model context; raise only if needed. Use $select to return fewer fields per item. For more rows, use @odata.nextLink from the response instead of a very large $top."
).optional();
}
if (paramSchema["skip"] !== void 0 || paramSchema["$skip"] !== void 0) {
const key = paramSchema["$skip"] !== void 0 ? "$skip" : "skip";
paramSchema[key] = z.number().describe("Items to skip for pagination. Not supported with $search.").optional();
}
if (paramSchema["count"] !== void 0 || paramSchema["$count"] !== void 0) {
const countKey = paramSchema["$count"] !== void 0 ? "$count" : "count";
paramSchema[countKey] = z.boolean().describe(
"Set true to enable advanced query mode (ConsistencyLevel: eventual). Required for complex $filter on flag/flagStatus or contains()."
).optional();
}
if (multiAccount) {
const accountHint = accountNames.length > 0 ? `Known accounts: ${accountNames.join(", ")}. ` : "";
paramSchema["account"] = z.string().describe(
`${accountHint}Microsoft account email to use for this request. Required when multiple accounts are configured. Use the list-accounts tool to discover all currently available accounts.`
).optional();
}
paramSchema["includeHeaders"] = z.boolean().describe("Include response headers (including ETag) in the response metadata").optional();
paramSchema["excludeResponse"] = z.boolean().describe("Exclude the full response body and only return success or failure indication").optional();
if (endpointConfig?.supportsTimezone) {
paramSchema["timezone"] = z.string().describe(
'IANA timezone name (e.g., "America/New_York", "Europe/London", "Asia/Tokyo") for calendar event times. If not specified, times are returned in UTC.'
).optional();
}
if (endpointConfig?.supportsExpandExtendedProperties) {
paramSchema["expandExtendedProperties"] = z.boolean().describe(
"When true, expands singleValueExtendedProperties on each event. Use this to retrieve custom extended properties (e.g., sync metadata) stored on calendar events."
).optional();
}
let toolDescription = tool.description || `Execute ${tool.method.toUpperCase()} request to ${tool.path}`;
if (endpointConfig?.llmTip) {
toolDescription += `
\u{1F4A1} TIP: ${endpointConfig.llmTip}`;
}
try {
server.tool(
tool.alias,
toolDescription,
paramSchema,
{
title: tool.alias,
readOnlyHint: tool.method.toUpperCase() === "GET",
destructiveHint: ["POST", "PATCH", "DELETE"].includes(tool.method.toUpperCase()),
openWorldHint: true
// All tools call Microsoft Graph API
},
async (params) => executeGraphTool(tool, endpointConfig, graphClient, params, authManager)
);
registeredCount++;
} catch (error) {
logger.error(`Failed to register tool ${tool.alias}: ${error.message}`);
failedCount++;
}
}
if (multiAccount) {
logger.info('Multi-account mode: "account" parameter injected into all tool schemas');
}
if (disabledByAllowedScopes.length > 0) {
logger.info(
`Allowed scopes disabled ${disabledByAllowedScopes.length} Graph tools: ${formatDisabledToolsForLog(disabledByAllowedScopes)}`
);
}
const utilityCtx = {
graphClient,
authManager,
multiAccount,
accountNames
};
for (const utility of UTILITY_TOOLS) {
if (readOnly && !utility.readOnlyHint) continue;
if (enabledToolsRegex && !enabledToolsRegex.test(utility.name)) continue;
try {
registerUtilityToolWithMcp(server, utility, utilityCtx);
registeredCount++;
} catch (error) {
logger.error(`Failed to register tool ${utility.name}: ${error.message}`);
failedCount++;
}
}
logger.info(
`Tool registration complete: ${registeredCount} registered, ${skippedCount} skipped, ${failedCount} failed`
);
return registeredCount;
}
function buildToolsRegistry(readOnly, orgMode, enabledToolsRegex, allowedScopesValue, disabledByAllowedScopes = []) {
const toolsMap = /* @__PURE__ */ new Map();
const allowedScopes = parseAllowedScopes(allowedScopesValue);
for (const tool of api.endpoints) {
const endpointConfig = endpointsData.find((e) => e.toolName === tool.alias);
if (!orgMode && endpointConfig && !endpointConfig.scopes && endpointConfig.workScopes) {
continue;
}
const method = tool.method.toUpperCase();
if (readOnly && method !== "GET") {
if (!(method === "POST" && endpointConfig?.readOnly)) {
continue;
}
}
if (enabledToolsRegex && !enabledToolsRegex.test(tool.alias)) {
continue;
}
const missingScopes = allowedScopes !== void 0 && !endpointConfig ? ["endpoint scope metadata"] : getMissingAllowedScopes(
getEndpointRequiredScopes(endpointConfig, orgMode),
allowedScopes
);
if (missingScopes.length > 0) {
disabledByAllowedScopes.push({ toolName: tool.alias, missingScopes });
continue;
}
toolsMap.set(tool.alias, { tool, config: endpointConfig });
}
return toolsMap;
}
function buildDiscoverySearchIndex(toolsRegistry, utilityTools = []) {
const TIP_EXCERPT_TOKENS = 12;
const DESC_CAP_TOKENS = 40;
const docs = [];
const nameTokens = /* @__PURE__ */ new Map();
for (const [name, { tool, config }] of toolsRegistry) {
const nt = tokenize(name);
nameTokens.set(name, new Set(nt));
const pathTokens = tokenize(tool.path);
const descTokens = tokenize(tool.description).slice(0, DESC_CAP_TOKENS);
const tipTokens = tokenize(config?.llmTip).slice(0, TIP_EXCERPT_TOKENS);
const tokens = [
...nt,
...nt,
...nt,
...nt,
...nt,
...pathTokens,
...pathTokens,
...tipTokens,
...descTokens
];
docs.push({ id: name, tokens });
}
for (const utility of utilityTools) {
const nt = tokenize(utility.name);
nameTokens.set(utility.name, new Set(nt));
const pathTokens = tokenize(utility.path);
const descTokens = tokenize(utility.description).slice(0, DESC_CAP_TOKENS);
const tokens = [...nt, ...nt, ...nt, ...nt, ...nt, ...pathTokens, ...pathTokens, ...descTokens];
docs.push({ id: utility.name, tokens });
}
return { bm25: buildBM25Index(docs), nameTokens };
}
function scoreDiscoveryQuery(query, index) {
const queryTokenSet = new Set(tokenize(query));
if (queryTokenSet.size === 0) return [];
const ranked = scoreQuery(query, index.bm25);
const NAME_BONUS_WEIGHT = 2;
for (const r of ranked) {
const nt = index.nameTokens.get(r.id);
if (!nt || nt.size === 0) continue;
let matchedIdf = 0;
let matchedCount = 0;
for (const qt of queryTokenSet) {
if (nt.has(qt)) {
matchedCount++;
matchedIdf += index.bm25.idf.get(qt) ?? 0;
}
}
if (matchedCount === 0) continue;
const precision = matchedCount / nt.size;
r.score += precision * matchedIdf * NAME_BONUS_WEIGHT;
}
ranked.sort((a, b) => b.score - a.score);
return ranked;
}
function registerDiscoveryTools(server, graphClient, readOnly = false, orgMode = false, authManager, multiAccount = false, accountNames = [], enabledTools, allowedScopesValue) {
let enabledToolsRegex;
if (enabledTools) {
try {
enabledToolsRegex = new RegExp(enabledTools, "i");
logger.info(`Discovery mode: filtering tools with pattern ${enabledTools}`);
} catch (error) {
logger.error(
`Invalid --enabled-tools regex ${JSON.stringify(enabledTools)} \u2014 ignoring filter: ${error.message}`
);
}
}
const disabledByAllowedScopes = [];
const toolsRegistry = buildToolsRegistry(
readOnly,
orgMode,
enabledToolsRegex,
allowedScopesValue,
disabledByAllowedScopes
);
if (disabledByAllowedScopes.length > 0) {
logger.info(
`Discovery mode: allowed scopes disabled ${disabledByAllowedScopes.length} Graph tools: ${formatDisabledToolsForLog(disabledByAllowedScopes)}`
);
}
const utilityTools = UTILITY_TOOLS.filter((u) => {
if (readOnly && !u.readOnlyHint) return false;
if (enabledToolsRegex && !enabledToolsRegex.test(u.name)) return false;
return true;
});
const searchIndex = buildDiscoverySearchIndex(toolsRegistry, utilityTools);
const totalCount = toolsRegistry.size + utilityTools.length;
logger.info(
`Discovery mode: ${totalCount} tools (${toolsRegistry.size} Graph + ${utilityTools.length} utility)`
);
const utilityCtx = {
graphClient,
authManager,
multiAccount,
accountNames
};
const utilityByName = new Map(utilityTools.map((u) => [u.name, u]));
const categoryNames = Object.keys(TOOL_CATEGORIES).join(", ");
const toResultEntry = (name) => {
const entry = toolsRegistry.get(name);
if (entry) {
const { tool, config } = entry;
return {
name,
method: tool.method.toUpperCase(),
path: tool.path,
description: tool.description || `${tool.method.toUpperCase()} ${tool.path}`,
...config?.llmTip ? { llmTip: config.llmTip } : {}
};
}
const utility = utilityByName.get(name);
if (utility) {
return {
name: utility.name,
method: utility.method,
path: utility.path,
description: utility.description
};
}
return null;
};
server.tool(
"search-tools",
`Search through ${totalCount} tools (${toolsRegistry.size} Microsoft Graph API operations + ${utilityTools.length} server utilities like download-bytes). Ranks results by BM25 over tool name, llmTip, description, and path. After picking a tool, call get-tool-schema for parameters, then execute-tool.`,
{
query: z.string().describe(
'Natural-language query. Tokenized and BM25-ranked. E.g. "send email", "download photo", "list unread messages".'
).optional(),
category: z.string().describe(`Optional pre-filter by category: ${categoryNames}`).optional(),
limit: z.number().describe("Maximum results (default: 10, max: 50)").optional()
},
{
title: "search-tools",
readOnlyHint: true,
openWorldHint: true
},
async ({ query, category, limit = 10 }) => {
const maxLimit = Math.min(Math.max(limit, 1), 50);
const categoryDef = category ? TOOL_CATEGORIES[category] : void 0;
const categoryFilter = (name) => !categoryDef || categoryDef.pattern.test(name);
let orderedNames;
if (query && query.trim().length > 0) {
const ranked = scoreDiscoveryQuery(query, searchIndex);
orderedNames = ranked.map((r) => r.id).filter(categoryFilter);
} else {
orderedNames = [...toolsRegistry.keys(), ...utilityTools.map((u) => u.name)].filter(
categoryFilter
);
}
const tools = orderedNames.slice(0, maxLimit).map(toResultEntry).filter(Boolean);
return {
content: [
{
type: "text",
text: JSON.stringify(
{
found: tools.length,
total: totalCount,
tools,
tip: "Call get-tool-schema(tool_name) to see parameters before invoking execute-tool."
},
null,
2
)
}
]
};
}
);
server.tool(
"get-tool-schema",
"Returns the full parameter schema (name, placement, required, JSON Schema) for a tool discovered via search-tools. Call this before execute-tool so you know what parameters to pass and what enum values are valid.",
{
tool_name: z.string().describe('Exact tool name from search-tools (e.g. "send-mail")')
},
{
title: "get-tool-schema",
readOnlyHint: true,
openWorldHint: false
},
async ({ tool_name }) => {
const entry = toolsRegistry.get(tool_name);
if (entry) {
const schema = describeToolSchema(entry.tool, entry.config?.llmTip);
return {
content: [{ type: "text", text: JSON.stringify(schema, null, 2) }]
};
}
const utility = utilityByName.get(tool_name);
if (utility) {
const schema = describeUtilityToolSchema(utility, utilityCtx);
return {
content: [{ type: "text", text: JSON.stringify(schema, null, 2) }]
};
}
return {
content: [
{
type: "text",
text: JSON.stringify({
error: `Tool not found: ${tool_name}`,
tip: "Use search-tools to find available tools."
})
}
],
isError: true
};
}
);
server.tool(
"execute-tool",
"Execute a Microsoft Graph API tool by name. Workflow: search-tools \u2192 get-tool-schema \u2192 execute-tool. Call get-tool-schema first for any tool you have not seen before \u2014 passing the wrong shape to parameters will fail validation or return a Graph 400. For list endpoints, prefer modest $top plus $select.",
{
tool_name: z.string().describe('Name of the tool to execute (e.g., "list-mail-messages")'),
parameters: z.record(z.any()).describe(
'Parameters shaped per get-tool-schema. Path/query/header params go at the top level; request bodies go under "body".'
).optional()
},
{
title: "execute-tool",
readOnlyHint: false,
destructiveHint: true,
openWorldHint: true
},
async ({ tool_name, parameters = {} }) => {
const toolData = toolsRegistry.get(tool_name);
if (toolData) {
return executeGraphTool(
toolData.tool,
toolData.config,
graphClient,
parameters,
authManager
);
}
const utility = utilityByName.get(tool_name);
if (utility) {
return utility.execute(parameters, utilityCtx);
}
return {
content: [
{
type: "text",
text: JSON.stringify({
error: `Tool not found: ${tool_name}`,
tip: "Use search-tools to find available tools."
})
}
],
isError: true
};
}
);
}
export {
UTILITY_TOOLS,
buildDiscoverySearchIndex,
buildToolsRegistry,
registerDiscoveryTools,
registerGraphTools,
scoreDiscoveryQuery
};