Skip to main content
Glama
graph-client.ts8.96 kB
import logger from './logger.js'; import AuthManager from './auth.js'; import { refreshAccessToken } from './lib/microsoft-auth.js'; import { encode as toonEncode } from '@toon-format/toon'; interface GraphRequestOptions { headers?: Record<string, string>; method?: string; body?: string; rawResponse?: boolean; includeHeaders?: boolean; excludeResponse?: boolean; accessToken?: string; refreshToken?: string; [key: string]: unknown; } interface ContentItem { type: 'text'; text: string; [key: string]: unknown; } interface McpResponse { content: ContentItem[]; _meta?: Record<string, unknown>; isError?: boolean; [key: string]: unknown; } class GraphClient { private authManager: AuthManager; private accessToken: string | null = null; private refreshToken: string | null = null; private readonly outputFormat: 'json' | 'toon' = 'json'; constructor(authManager: AuthManager, outputFormat: 'json' | 'toon' = 'json') { this.authManager = authManager; this.outputFormat = outputFormat; } setOAuthTokens(accessToken: string, refreshToken?: string): void { this.accessToken = accessToken; this.refreshToken = refreshToken || null; } async makeRequest(endpoint: string, options: GraphRequestOptions = {}): Promise<unknown> { // Use OAuth tokens if available, otherwise fall back to authManager let accessToken = options.accessToken || this.accessToken || (await this.authManager.getToken()); let refreshToken = options.refreshToken || this.refreshToken; if (!accessToken) { throw new Error('No access token available'); } try { let response = await this.performRequest(endpoint, accessToken, options); if (response.status === 401 && refreshToken) { // Token expired, try to refresh await this.refreshAccessToken(refreshToken); // Update token for retry accessToken = this.accessToken || accessToken; if (!accessToken) { throw new Error('Failed to refresh access token'); } // Retry the request with new token response = await this.performRequest(endpoint, accessToken, options); } if (response.status === 403) { const errorText = await response.text(); if (errorText.includes('scope') || errorText.includes('permission')) { throw new Error( `Microsoft Graph API scope error: ${response.status} ${response.statusText} - ${errorText}. This tool requires organization mode. Please restart with --org-mode flag.` ); } throw new Error( `Microsoft Graph API error: ${response.status} ${response.statusText} - ${errorText}` ); } if (!response.ok) { throw new Error( `Microsoft Graph API error: ${response.status} ${response.statusText} - ${await response.text()}` ); } const text = await response.text(); let result: any; if (text === '') { result = { message: 'OK!' }; } else { try { result = JSON.parse(text); } catch { result = { message: 'OK!', rawResponse: text }; } } // If includeHeaders is requested, add response headers to the result if (options.includeHeaders) { const etag = response.headers.get('ETag') || response.headers.get('etag'); // Simple approach: just add ETag to the result if it's an object if (result && typeof result === 'object' && !Array.isArray(result)) { return { ...result, _etag: etag || 'no-etag-found', }; } } return result; } catch (error) { logger.error('Microsoft Graph API request failed:', error); throw error; } } private async refreshAccessToken(refreshToken: string): Promise<void> { const tenantId = process.env.MS365_MCP_TENANT_ID || 'common'; const clientId = process.env.MS365_MCP_CLIENT_ID || '084a3e9f-a9f4-43f7-89f9-d229cf97853e'; const clientSecret = process.env.MS365_MCP_CLIENT_SECRET; if (!clientSecret) { throw new Error('MS365_MCP_CLIENT_SECRET not configured'); } const response = await refreshAccessToken(refreshToken, clientId, clientSecret, tenantId); this.accessToken = response.access_token; if (response.refresh_token) { this.refreshToken = response.refresh_token; } } private async performRequest( endpoint: string, accessToken: string, options: GraphRequestOptions ): Promise<Response> { const url = `https://graph.microsoft.com/v1.0${endpoint}`; const headers: Record<string, string> = { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', ...options.headers, }; return fetch(url, { method: options.method || 'GET', headers, body: options.body, }); } private serializeData(data: unknown, outputFormat: 'json' | 'toon', pretty = false): string { if (outputFormat === 'toon') { try { return toonEncode(data); } catch (error) { logger.warn(`Failed to encode as TOON, falling back to JSON: ${error}`); return JSON.stringify(data, null, pretty ? 2 : undefined); } } return JSON.stringify(data, null, pretty ? 2 : undefined); } async graphRequest(endpoint: string, options: GraphRequestOptions = {}): Promise<McpResponse> { try { logger.info(`Calling ${endpoint} with options: ${JSON.stringify(options)}`); // Use new OAuth-aware request method const result = await this.makeRequest(endpoint, options); return this.formatJsonResponse(result, options.rawResponse, options.excludeResponse); } catch (error) { logger.error(`Error in Graph API request: ${error}`); return { content: [{ type: 'text', text: JSON.stringify({ error: (error as Error).message }) }], isError: true, }; } } formatJsonResponse(data: unknown, rawResponse = false, excludeResponse = false): McpResponse { // If excludeResponse is true, only return success indication if (excludeResponse) { return { content: [{ type: 'text', text: this.serializeData({ success: true }, this.outputFormat) }], }; } // Handle the case where data includes headers metadata if (data && typeof data === 'object' && '_headers' in data) { const responseData = data as { data: unknown; _headers: Record<string, string>; _etag?: string; }; const meta: Record<string, unknown> = {}; if (responseData._etag) { meta.etag = responseData._etag; } if (responseData._headers) { meta.headers = responseData._headers; } if (rawResponse) { return { content: [ { type: 'text', text: this.serializeData(responseData.data, this.outputFormat) }, ], _meta: meta, }; } if (responseData.data === null || responseData.data === undefined) { return { content: [ { type: 'text', text: this.serializeData({ success: true }, this.outputFormat) }, ], _meta: meta, }; } // Remove OData properties const removeODataProps = (obj: Record<string, unknown>): void => { if (typeof obj === 'object' && obj !== null) { Object.keys(obj).forEach((key) => { if (key.startsWith('@odata.')) { delete obj[key]; } else if (typeof obj[key] === 'object') { removeODataProps(obj[key] as Record<string, unknown>); } }); } }; removeODataProps(responseData.data as Record<string, unknown>); return { content: [ { type: 'text', text: this.serializeData(responseData.data, this.outputFormat, true) }, ], _meta: meta, }; } // Original handling for backward compatibility if (rawResponse) { return { content: [{ type: 'text', text: this.serializeData(data, this.outputFormat) }], }; } if (data === null || data === undefined) { return { content: [{ type: 'text', text: this.serializeData({ success: true }, this.outputFormat) }], }; } // Remove OData properties const removeODataProps = (obj: Record<string, unknown>): void => { if (typeof obj === 'object' && obj !== null) { Object.keys(obj).forEach((key) => { if (key.startsWith('@odata.')) { delete obj[key]; } else if (typeof obj[key] === 'object') { removeODataProps(obj[key] as Record<string, unknown>); } }); } }; removeODataProps(data as Record<string, unknown>); return { content: [{ type: 'text', text: this.serializeData(data, this.outputFormat, true) }], }; } } export default GraphClient;

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