@softeria/ms-365-mcp-server
Version:
A Model Context Protocol (MCP) server for interacting with Microsoft 365 and Office services through the Graph API
241 lines (240 loc) • 8.44 kB
JavaScript
import logger from "./logger.js";
import { encode as toonEncode } from "@toon-format/toon";
import { getCloudEndpoints } from "./cloud-config.js";
import { getRequestTokens } from "./request-context.js";
import {
fetchWithResilience,
getSharedBreaker,
loadResilienceConfig
} from "./lib/graph-resilience.js";
function isBinaryContentType(contentType) {
if (!contentType) return false;
const lower = contentType.toLowerCase().split(";")[0].trim();
if (!lower) return false;
if (lower.startsWith("image/") || lower.startsWith("video/") || lower.startsWith("audio/") || lower.startsWith("font/")) {
return true;
}
if (lower === "application/octet-stream" || lower === "application/pdf") {
return true;
}
if (lower.startsWith("application/zip") || lower.startsWith("application/x-zip")) {
return true;
}
if (lower.startsWith("application/vnd.") || lower.startsWith("application/x-")) {
if (lower.endsWith("+json") || lower.endsWith("+xml") || lower.endsWith("+text")) {
return false;
}
return true;
}
return false;
}
class GraphClient {
constructor(authManager, secrets, outputFormat = "json") {
this.outputFormat = "json";
this.authManager = authManager;
this.secrets = secrets;
this.outputFormat = outputFormat;
}
async makeRequest(endpoint, options = {}) {
const contextTokens = getRequestTokens();
const accessToken = options.accessToken ?? contextTokens?.accessToken ?? await this.authManager.getToken();
if (!accessToken) {
throw new Error("No access token available");
}
try {
const 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 contentTypeHeader = response.headers?.get?.("content-type") || "";
const isBinaryResponse = isBinaryContentType(contentTypeHeader);
let result;
if (isBinaryResponse) {
const buffer = Buffer.from(await response.arrayBuffer());
result = {
message: "OK!",
contentType: contentTypeHeader,
encoding: "base64",
contentLength: buffer.byteLength,
contentBytes: buffer.toString("base64")
};
} else {
const text = await response.text();
if (text === "") {
result = { message: "OK!" };
} else if (options.rawResponse) {
result = { message: "OK!", rawResponse: text };
} else {
try {
result = JSON.parse(text);
} catch {
result = { message: "OK!", rawResponse: text };
}
}
}
if (options.includeHeaders) {
const etag = response.headers.get("ETag") || response.headers.get("etag");
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;
}
}
async performRequest(endpoint, accessToken, options) {
const cloudEndpoints = getCloudEndpoints(this.secrets.cloudType);
const apiVersion = options.apiVersion || "v1.0";
const url = `${cloudEndpoints.graphApi}/${apiVersion}${endpoint}`;
logger.info(`[GRAPH CLIENT] Final URL being sent to Microsoft: ${url}`);
const headers = {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
...options.headers
};
return fetchWithResilience(
url,
{
method: options.method || "GET",
headers,
// Node's fetch accepts Buffer/Uint8Array; TS BodyInit doesn't.
body: options.body
},
loadResilienceConfig(),
getSharedBreaker()
);
}
serializeData(data, outputFormat, pretty = false) {
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 : void 0);
}
}
return JSON.stringify(data, null, pretty ? 2 : void 0);
}
/**
* Encode a value in the configured format (json/toon). The fetchAllPages merge
* uses this to encode the combined result once, after parsing pages as JSON (#560).
* Compact by default like JSON.stringify, so JSON-mode output is byte-identical.
*/
serialize(data, pretty = false) {
return this.serializeData(data, this.outputFormat, pretty);
}
async graphRequest(endpoint, options = {}) {
try {
logger.info(`Calling ${endpoint} with options: ${JSON.stringify(options)}`);
const result = await this.makeRequest(endpoint, options);
const outputFormat = options.forceJsonOutput ? "json" : this.outputFormat;
return this.formatJsonResponse(
result,
options.rawResponse,
options.excludeResponse,
outputFormat
);
} catch (error) {
logger.error(`Error in Graph API request: ${error}`);
return {
content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
isError: true
};
}
}
formatJsonResponse(data, rawResponse = false, excludeResponse = false, outputFormat = this.outputFormat) {
if (excludeResponse) {
return {
content: [{ type: "text", text: this.serializeData({ success: true }, outputFormat) }]
};
}
if (data && typeof data === "object" && "_headers" in data) {
const responseData = data;
const meta = {};
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, outputFormat) }],
_meta: meta
};
}
if (responseData.data === null || responseData.data === void 0) {
return {
content: [{ type: "text", text: this.serializeData({ success: true }, outputFormat) }],
_meta: meta
};
}
const removeODataProps2 = (obj) => {
if (typeof obj === "object" && obj !== null) {
Object.keys(obj).forEach((key) => {
if (key.startsWith("@odata.") && key !== "@odata.nextLink" && key !== "@odata.deltaLink") {
delete obj[key];
} else if (typeof obj[key] === "object") {
removeODataProps2(obj[key]);
}
});
}
};
removeODataProps2(responseData.data);
return {
content: [
{ type: "text", text: this.serializeData(responseData.data, outputFormat, true) }
],
_meta: meta
};
}
if (rawResponse) {
return {
content: [{ type: "text", text: this.serializeData(data, outputFormat) }]
};
}
if (data === null || data === void 0) {
return {
content: [{ type: "text", text: this.serializeData({ success: true }, outputFormat) }]
};
}
const removeODataProps = (obj) => {
if (typeof obj === "object" && obj !== null) {
Object.keys(obj).forEach((key) => {
if (key.startsWith("@odata.") && key !== "@odata.nextLink" && key !== "@odata.deltaLink") {
delete obj[key];
} else if (typeof obj[key] === "object") {
removeODataProps(obj[key]);
}
});
}
};
removeODataProps(data);
return {
content: [{ type: "text", text: this.serializeData(data, outputFormat, true) }]
};
}
}
var graph_client_default = GraphClient;
export {
graph_client_default as default,
isBinaryContentType
};