index.js•16.5 kB
#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import fetch from "node-fetch";
const API_BASE_URL = "https://api.federated.directory/v2";
class FederatedDirectoryServer {
constructor() {
this.apiToken = process.env.FEDERATED_DIRECTORY_API_TOKEN;
this.enableLogging = process.env.FEDERATED_DIRECTORY_DEBUG === "true";
if (!this.apiToken) {
console.error(
"ERROR: FEDERATED_DIRECTORY_API_TOKEN environment variable is required",
);
process.exit(1);
}
if (this.enableLogging) {
console.error("DEBUG: Logging enabled");
console.error(
"DEBUG: API Token (masked):",
this.maskToken(this.apiToken),
);
}
this.server = new Server(
{
name: "federated-directory-server",
version: "1.0.0",
},
{
capabilities: {
tools: {},
},
},
);
this.setupToolHandlers();
this.server.onerror = (error) => console.error("[MCP Error]", error);
process.on("SIGINT", async () => {
await this.server.close();
process.exit(0);
});
}
maskToken(token) {
if (!token || token.length < 8) return "***";
return token.substring(0, 4) + "..." + token.substring(token.length - 4);
}
logRequest(url, headers) {
if (!this.enableLogging) return;
console.error("\n========== API REQUEST ==========");
console.error("URL:", url);
console.error("Method: GET");
console.error("Headers:", {
...headers,
Authorization: `Bearer ${this.maskToken(this.apiToken)}`,
});
console.error("=================================\n");
}
logResponse(status, statusText, data) {
if (!this.enableLogging) return;
console.error("\n========== API RESPONSE ==========");
console.error("Status:", status, statusText);
console.error("Response Data:", JSON.stringify(data, null, 2));
console.error("==================================\n");
}
logError(error) {
if (!this.enableLogging) return;
console.error("\n========== ERROR ==========");
console.error("Error:", error.message);
console.error("Stack:", error.stack);
console.error("===========================\n");
}
setupToolHandlers() {
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "search_contacts_by_name",
description:
"Search for contacts in the Federated Directory by name. Searches across displayName, givenName, and familyName fields.",
inputSchema: {
type: "object",
properties: {
name: {
type: "string",
description:
"The name to search for (partial matches supported)",
},
limit: {
type: "number",
description:
"Maximum number of results to return (default: 10, max: 100)",
default: 10,
},
attributes: {
type: "string",
description:
"Comma-separated list of attributes to return (e.g., 'userName,displayName,emails'). If not specified, returns all attributes.",
},
},
required: ["name"],
},
},
{
name: "search_contacts_by_email",
description:
"Search for contacts in the Federated Directory by email address. Supports partial email matching.",
inputSchema: {
type: "object",
properties: {
email: {
type: "string",
description:
"The email address to search for (partial matches supported)",
},
limit: {
type: "number",
description:
"Maximum number of results to return (default: 10, max: 100)",
default: 10,
},
attributes: {
type: "string",
description:
"Comma-separated list of attributes to return (e.g., 'userName,displayName,emails'). If not specified, returns all attributes.",
},
},
required: ["email"],
},
},
{
name: "get_contact_by_id",
description:
"Get detailed information for a specific contact by their ID.",
inputSchema: {
type: "object",
properties: {
id: {
type: "string",
description: "The unique ID of the contact",
},
attributes: {
type: "string",
description:
"Comma-separated list of attributes to return (e.g., 'userName,displayName,emails,photos'). If not specified, returns all attributes.",
},
},
required: ["id"],
},
},
],
}));
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === "search_contacts_by_name") {
return await this.searchContactsByName(request.params.arguments);
} else if (request.params.name === "search_contacts_by_email") {
return await this.searchContactsByEmail(request.params.arguments);
} else if (request.params.name === "get_contact_by_id") {
return await this.getContactById(request.params.arguments);
}
throw new Error(`Unknown tool: ${request.params.name}`);
});
}
async searchContactsByName(args) {
try {
const { name, limit = 10, attributes } = args;
if (!name || typeof name !== "string" || name.trim().length === 0) {
throw new Error(
"Name parameter is required and must be a non-empty string",
);
}
const searchLimit = Math.min(Math.max(1, limit), 100);
// SCIM filter for name search (searches displayName, givenName, and familyName)
const filter = `displayName co "${name.trim()}" or name.givenName co "${name.trim()}" or name.familyName co "${name.trim()}"`;
let url = `${API_BASE_URL}/Users?filter=${encodeURIComponent(filter)}&count=${searchLimit}`;
if (
attributes &&
typeof attributes === "string" &&
attributes.trim().length > 0
) {
url += `&attributes=${encodeURIComponent(attributes.trim())}`;
}
const headers = {
Accept: "application/scim+json",
Authorization: `Bearer ${this.apiToken}`,
"User-Agent": "MCP-FederatedDirectory/1.0",
};
this.logRequest(url, headers);
const response = await fetch(url, {
method: "GET",
headers: headers,
});
const responseText = await response.text();
let data;
try {
data = JSON.parse(responseText);
} catch (parseError) {
console.error(
"Failed to parse JSON response. Raw response:",
responseText.substring(0, 500),
);
throw new Error(
`Invalid JSON response from API. Status: ${response.status}. Response preview: ${responseText.substring(0, 200)}`,
);
}
this.logResponse(response.status, response.statusText, data);
if (!response.ok) {
if (response.status === 401) {
throw new Error(
"Authentication failed. Please check your API token.",
);
}
throw new Error(
`API request failed: ${response.status} ${response.statusText}`,
);
}
// SCIM response format
const resources = data.Resources || [];
return {
content: [
{
type: "text",
text: JSON.stringify(
{
success: true,
count: resources.length,
totalResults: data.totalResults,
query: { name, limit: searchLimit },
results: resources.map((user) => ({
id: user.id,
username: user.userName,
displayName: user.displayName,
firstName: user.name?.givenName,
lastName: user.name?.familyName,
email: user.emails?.[0]?.value,
emails: user.emails,
profileUrl: user.profileUrl,
avatarUrl: user.photos?.[0]?.value,
active: user.active,
})),
},
null,
2,
),
},
],
};
} catch (error) {
this.logError(error);
return {
content: [
{
type: "text",
text: JSON.stringify(
{
success: false,
error: error.message,
},
null,
2,
),
},
],
isError: true,
};
}
}
async getContactById(args) {
try {
const { id, attributes } = args;
if (!id || typeof id !== "string" || id.trim().length === 0) {
throw new Error(
"ID parameter is required and must be a non-empty string",
);
}
let url = `${API_BASE_URL}/Users/${encodeURIComponent(id.trim())}`;
if (
attributes &&
typeof attributes === "string" &&
attributes.trim().length > 0
) {
url += `?attributes=${encodeURIComponent(attributes.trim())}`;
}
const headers = {
Accept: "application/scim+json",
Authorization: `Bearer ${this.apiToken}`,
"User-Agent": "MCP-FederatedDirectory/1.0",
};
this.logRequest(url, headers);
const response = await fetch(url, {
method: "GET",
headers: headers,
});
const responseText = await response.text();
let data;
try {
data = JSON.parse(responseText);
} catch (parseError) {
console.error(
"Failed to parse JSON response. Raw response:",
responseText.substring(0, 500),
);
throw new Error(
`Invalid JSON response from API. Status: ${response.status}. Response preview: ${responseText.substring(0, 200)}`,
);
}
this.logResponse(response.status, response.statusText, data);
if (!response.ok) {
if (response.status === 401) {
throw new Error(
"Authentication failed. Please check your API token.",
);
}
if (response.status === 404) {
throw new Error(`Contact with ID '${id}' not found.`);
}
throw new Error(
`API request failed: ${response.status} ${response.statusText}`,
);
}
// Return full SCIM user object
return {
content: [
{
type: "text",
text: JSON.stringify(
{
success: true,
query: { id, attributes },
result: {
id: data.id,
username: data.userName,
displayName: data.displayName,
name: data.name,
emails: data.emails,
phoneNumbers: data.phoneNumbers,
photos: data.photos,
profileUrl: data.profileUrl,
active: data.active,
addresses: data.addresses,
locale: data.locale,
timezone: data.timezone,
// Include any other fields that were returned
...Object.keys(data).reduce((acc, key) => {
if (
![
"id",
"userName",
"displayName",
"name",
"emails",
"phoneNumbers",
"photos",
"profileUrl",
"active",
"addresses",
"locale",
"timezone",
].includes(key)
) {
acc[key] = data[key];
}
return acc;
}, {}),
},
},
null,
2,
),
},
],
};
} catch (error) {
this.logError(error);
return {
content: [
{
type: "text",
text: JSON.stringify(
{
success: false,
error: error.message,
},
null,
2,
),
},
],
isError: true,
};
}
}
async searchContactsByEmail(args) {
try {
const { email, limit = 10, attributes } = args;
if (!email || typeof email !== "string" || email.trim().length === 0) {
throw new Error(
"Email parameter is required and must be a non-empty string",
);
}
const searchLimit = Math.min(Math.max(1, limit), 100);
// SCIM filter for email search
const filter = `emails.value co "${email.trim()}"`;
let url = `${API_BASE_URL}/Users?filter=${encodeURIComponent(filter)}&count=${searchLimit}`;
if (
attributes &&
typeof attributes === "string" &&
attributes.trim().length > 0
) {
url += `&attributes=${encodeURIComponent(attributes.trim())}`;
}
const headers = {
Accept: "application/scim+json",
Authorization: `Bearer ${this.apiToken}`,
"User-Agent": "MCP-FederatedDirectory/1.0",
};
this.logRequest(url, headers);
const response = await fetch(url, {
method: "GET",
headers: headers,
});
const responseText = await response.text();
let data;
try {
data = JSON.parse(responseText);
} catch (parseError) {
console.error(
"Failed to parse JSON response. Raw response:",
responseText.substring(0, 500),
);
throw new Error(
`Invalid JSON response from API. Status: ${response.status}. Response preview: ${responseText.substring(0, 200)}`,
);
}
this.logResponse(response.status, response.statusText, data);
if (!response.ok) {
if (response.status === 401) {
throw new Error(
"Authentication failed. Please check your API token.",
);
}
throw new Error(
`API request failed: ${response.status} ${response.statusText}`,
);
}
// SCIM response format
const resources = data.Resources || [];
return {
content: [
{
type: "text",
text: JSON.stringify(
{
success: true,
count: resources.length,
totalResults: data.totalResults,
query: { email, limit: searchLimit },
results: resources.map((user) => ({
id: user.id,
username: user.userName,
displayName: user.displayName,
firstName: user.name?.givenName,
lastName: user.name?.familyName,
email: user.emails?.[0]?.value,
emails: user.emails,
profileUrl: user.profileUrl,
avatarUrl: user.photos?.[0]?.value,
active: user.active,
})),
},
null,
2,
),
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: JSON.stringify(
{
success: false,
error: error.message,
},
null,
2,
),
},
],
isError: true,
};
}
}
async run() {
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error("Federated Directory MCP server running on stdio");
}
}
const server = new FederatedDirectoryServer();
server.run();