Skip to main content
Glama
graph-tools.ts17.9 kB
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import logger from './logger.js'; import GraphClient from './graph-client.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'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); interface EndpointConfig { pathPattern: string; method: string; toolName: string; scopes?: string[]; workScopes?: string[]; returnDownloadUrl?: boolean; supportsTimezone?: boolean; llmTip?: string; } const endpointsData = JSON.parse( readFileSync(path.join(__dirname, 'endpoints.json'), 'utf8') ) as EndpointConfig[]; type TextContent = { type: 'text'; text: string; [key: string]: unknown; }; type ImageContent = { type: 'image'; data: string; mimeType: string; [key: string]: unknown; }; type AudioContent = { type: 'audio'; data: string; mimeType: string; [key: string]: unknown; }; type ResourceTextContent = { type: 'resource'; resource: { text: string; uri: string; mimeType?: string; [key: string]: unknown; }; [key: string]: unknown; }; type ResourceBlobContent = { type: 'resource'; resource: { blob: string; uri: string; mimeType?: string; [key: string]: unknown; }; [key: string]: unknown; }; type ResourceContent = ResourceTextContent | ResourceBlobContent; type ContentItem = TextContent | ImageContent | AudioContent | ResourceContent; interface CallToolResult { content: ContentItem[]; _meta?: Record<string, unknown>; isError?: boolean; [key: string]: unknown; } async function executeGraphTool( tool: (typeof api.endpoints)[0], config: EndpointConfig | undefined, graphClient: GraphClient, params: Record<string, unknown> ): Promise<CallToolResult> { logger.info(`Tool ${tool.alias} called with params: ${JSON.stringify(params)}`); try { const parameterDefinitions = tool.parameters || []; let path = tool.path; const queryParams: Record<string, string> = {}; const headers: Record<string, string> = {}; let body: unknown = null; for (const [paramName, paramValue] of Object.entries(params)) { // Skip control parameters - not part of the Microsoft Graph API if (['fetchAllPages', 'includeHeaders', 'excludeResponse', 'timezone'].includes(paramName)) { continue; } // Ok, so, MCP clients (such as claude code) doesn't support $ in parameter names, // and others might not support __, so we strip them in hack.ts and restore them here const odataParams = [ 'filter', 'select', 'expand', 'orderby', 'skip', 'top', 'count', 'search', 'format', ]; // Handle both "top" and "$top" formats - strip $ if present, then re-add it const normalizedParamName = paramName.startsWith('$') ? paramName.slice(1) : paramName; const isOdataParam = odataParams.includes(normalizedParamName.toLowerCase()); const fixedParamName = isOdataParam ? `$${normalizedParamName.toLowerCase()}` : paramName; // Look up param definition using normalized name (without $) for OData params const paramDef = parameterDefinitions.find( (p) => p.name === paramName || (isOdataParam && p.name === normalizedParamName) ); if (paramDef) { switch (paramDef.type) { case 'Path': path = path .replace(`{${paramName}}`, encodeURIComponent(paramValue as string)) .replace(`:${paramName}`, encodeURIComponent(paramValue as string)); break; case 'Query': 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)}`); } } // Handle timezone parameter for calendar endpoints if (config?.supportsTimezone && params.timezone) { headers['Prefer'] = `outlook.timezone="${params.timezone}"`; logger.info(`Setting timezone header: Prefer: outlook.timezone="${params.timezone}"`); } if (Object.keys(queryParams).length > 0) { const queryString = Object.entries(queryParams) .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) .join('&'); path = `${path}${path.includes('?') ? '&' : '?'}${queryString}`; } const options: { method: string; headers: Record<string, string>; body?: string; rawResponse?: boolean; includeHeaders?: boolean; excludeResponse?: boolean; queryParams?: Record<string, string>; } = { method: tool.method.toUpperCase(), headers, }; if (options.method !== 'GET' && body) { options.body = typeof body === 'string' ? body : JSON.stringify(body); } const isProbablyMediaContent = tool.errors?.some((error) => error.description === 'Retrieved media content') || path.endsWith('/content'); if (config?.returnDownloadUrl && path.endsWith('/content')) { path = path.replace(/\/content$/, ''); logger.info( `Auto-returning download URL for ${tool.alias} (returnDownloadUrl=true in endpoints.json)` ); } else if (isProbablyMediaContent) { options.rawResponse = true; } // Set includeHeaders if requested if (params.includeHeaders === true) { options.includeHeaders = true; } // Set excludeResponse if requested if (params.excludeResponse === true) { options.excludeResponse = true; } logger.info(`Making graph request to ${path} with options: ${JSON.stringify(options)}`); let response = await graphClient.graphRequest(path, 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; while (nextLink && pageCount < 100) { logger.info(`Fetching page ${pageCount + 1} from: ${nextLink}`); const url = new URL(nextLink); const nextPath = url.pathname.replace('/v1.0', ''); const nextOptions = { ...options }; const nextQueryParams: Record<string, string> = {}; for (const [key, value] of url.searchParams.entries()) { nextQueryParams[key] = value; } nextOptions.queryParams = nextQueryParams; 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 >= 100) { logger.warn(`Reached maximum page limit (100) for pagination`); } 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 { // Non-JSON response } } // Convert McpResponse to CallToolResult with the correct structure const content: ContentItem[] = response.content.map((item) => ({ type: 'text' as const, text: item.text, })); return { content, _meta: response._meta, isError: response.isError, }; } catch (error) { logger.error(`Error in tool ${tool.alias}: ${(error as Error).message}`); return { content: [ { type: 'text', text: JSON.stringify({ error: `Error in tool ${tool.alias}: ${(error as Error).message}`, }), }, ], isError: true, }; } } export function registerGraphTools( server: McpServer, graphClient: GraphClient, readOnly: boolean = false, enabledToolsPattern?: string, orgMode: boolean = false ): number { let enabledToolsRegex: RegExp | undefined; 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; 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; } if (readOnly && tool.method.toUpperCase() !== 'GET') { 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 paramSchema: Record<string, z.ZodTypeAny> = {}; if (tool.parameters && tool.parameters.length > 0) { for (const param of tool.parameters) { paramSchema[param.name] = param.schema || z.any(); } } if (tool.method.toUpperCase() === 'GET' && tool.path.includes('/')) { paramSchema['fetchAllPages'] = z .boolean() .describe('Automatically fetch all pages of results') .optional(); } // Add includeHeaders parameter for all tools to capture ETags and other headers paramSchema['includeHeaders'] = z .boolean() .describe('Include response headers (including ETag) in the response metadata') .optional(); // Add excludeResponse parameter to only return success/failure indication paramSchema['excludeResponse'] = z .boolean() .describe('Exclude the full response body and only return success or failure indication') .optional(); // Add timezone parameter for calendar endpoints that support it 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(); } // Build the tool description, optionally appending LLM tips let toolDescription = tool.description || `Execute ${tool.method.toUpperCase()} request to ${tool.path}`; if (endpointConfig?.llmTip) { toolDescription += `\n\n💡 TIP: ${endpointConfig.llmTip}`; } try { server.tool( tool.alias, toolDescription, paramSchema, { title: tool.alias, readOnlyHint: tool.method.toUpperCase() === 'GET', }, async (params) => executeGraphTool(tool, endpointConfig, graphClient, params) ); registeredCount++; } catch (error) { logger.error(`Failed to register tool ${tool.alias}: ${(error as Error).message}`); failedCount++; } } logger.info( `Tool registration complete: ${registeredCount} registered, ${skippedCount} skipped, ${failedCount} failed` ); return registeredCount; } function buildToolsRegistry( readOnly: boolean, orgMode: boolean ): Map<string, { tool: (typeof api.endpoints)[0]; config: EndpointConfig | undefined }> { const toolsMap = new Map< string, { tool: (typeof api.endpoints)[0]; config: EndpointConfig | undefined } >(); for (const tool of api.endpoints) { const endpointConfig = endpointsData.find((e) => e.toolName === tool.alias); if (!orgMode && endpointConfig && !endpointConfig.scopes && endpointConfig.workScopes) { continue; } if (readOnly && tool.method.toUpperCase() !== 'GET') { continue; } toolsMap.set(tool.alias, { tool, config: endpointConfig }); } return toolsMap; } export function registerDiscoveryTools( server: McpServer, graphClient: GraphClient, readOnly: boolean = false, orgMode: boolean = false ): void { const toolsRegistry = buildToolsRegistry(readOnly, orgMode); logger.info(`Discovery mode: ${toolsRegistry.size} tools available in registry`); server.tool( 'search-tools', `Search through ${toolsRegistry.size} available Microsoft Graph API tools. Use this to find tools by name, path, or description before executing them.`, { query: z .string() .describe('Search query to filter tools (searches name, path, and description)') .optional(), category: z .string() .describe( 'Filter by category: mail, calendar, files, contacts, tasks, onenote, search, users, excel' ) .optional(), limit: z.number().describe('Maximum results to return (default: 20, max: 50)').optional(), }, { title: 'search-tools', readOnlyHint: true, }, async ({ query, category, limit = 20 }) => { const maxLimit = Math.min(limit, 50); const results: Array<{ name: string; method: string; path: string; description: string; }> = []; const queryLower = query?.toLowerCase(); const categoryDef = category ? TOOL_CATEGORIES[category] : undefined; for (const [name, { tool, config }] of toolsRegistry) { if (categoryDef && !categoryDef.pattern.test(name)) { continue; } if (queryLower) { const searchText = `${name} ${tool.path} ${tool.description || ''} ${config?.llmTip || ''}`.toLowerCase(); if (!searchText.includes(queryLower)) { continue; } } results.push({ name, method: tool.method.toUpperCase(), path: tool.path, description: tool.description || `${tool.method.toUpperCase()} ${tool.path}`, }); if (results.length >= maxLimit) break; } return { content: [ { type: 'text', text: JSON.stringify( { found: results.length, total: toolsRegistry.size, tools: results, tip: 'Use execute-tool with the tool name and required parameters to call any of these tools.', }, null, 2 ), }, ], }; } ); server.tool( 'execute-tool', 'Execute a Microsoft Graph API tool by name. Use search-tools first to find available tools and their parameters.', { tool_name: z.string().describe('Name of the tool to execute (e.g., "list-mail-messages")'), parameters: z .record(z.any()) .describe('Parameters to pass to the tool as key-value pairs') .optional(), }, { title: 'execute-tool', readOnlyHint: false, }, async ({ tool_name, parameters = {} }) => { const toolData = toolsRegistry.get(tool_name); if (!toolData) { return { content: [ { type: 'text', text: JSON.stringify({ error: `Tool not found: ${tool_name}`, tip: 'Use search-tools to find available tools.', }), }, ], isError: true, }; } return executeGraphTool(toolData.tool, toolData.config, graphClient, parameters); } ); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Softeria/ms-365-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server