#!/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";
const DD_API_KEY = process.env.DD_API_KEY;
const DD_APPLICATION_KEY = process.env.DD_APPLICATION_KEY;
const CLICKHOUSE_HOST = process.env.CLICKHOUSE_HOST;
const CLICKHOUSE_USER = process.env.CLICKHOUSE_USER;
const CLICKHOUSE_PASSWORD = process.env.CLICKHOUSE_PASSWORD;
const CLOUDBEDS_CLIENT_ID = process.env.CLOUDBEDS_CLIENT_ID;
const CLOUDBEDS_CLIENT_SECRET = process.env.CLOUDBEDS_CLIENT_SECRET;
if (!DD_API_KEY || !DD_APPLICATION_KEY) {
console.error("Error: DD_API_KEY and DD_APPLICATION_KEY environment variables must be set");
process.exit(1);
}
interface LogSearchArgs {
query: string;
from: string;
to: string;
limit?: number;
}
interface CloudbedsPMSSearchArgs {
hotelId: string;
endpoint: string;
queryParams?: Record<string, string>;
}
interface ClickhouseSearchArgs {
query: string;
database?: string;
format?: string;
}
// Hardcoded map of hotelId to refresh tokens
const HOTEL_REFRESH_TOKENS: Record<string, string> = {
// Add your hotel IDs and refresh tokens here
// "307542": "your_refresh_token_here",
};
// Token cache with TTL
interface TokenCacheEntry {
accessToken: string;
expiresAt: number;
}
const tokenCache = new Map<string, TokenCacheEntry>();
const server = new Server(
{
name: "datadog-logs-mcp-server",
version: "1.0.0",
},
{
capabilities: {
tools: {},
},
}
);
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "search_logs",
description: "Search Datadog logs with a query and time range",
inputSchema: {
type: "object",
properties: {
query: {
type: "string",
description: "Log search query (e.g., 'env:prd AND service:pms-connectors')",
},
from: {
type: "string",
description: "Start time for log search (e.g., 'now-10m', '2024-01-01T00:00:00Z')",
},
to: {
type: "string",
description: "End time for log search (e.g., 'now', '2024-01-01T01:00:00Z')",
},
limit: {
type: "number",
description: "Maximum number of logs to return (default: 10)",
default: 10,
},
},
required: ["query", "from", "to"],
},
},
{
name: "search_cloudbeds_pms",
description: "Search Cloudbeds PMS API for reservations with rate details",
inputSchema: {
type: "object",
properties: {
hotelId: {
type: "string",
description: "Hotel ID to query (must be configured in HOTEL_REFRESH_TOKENS map)",
},
queryParams: {
type: "object",
description: "Query parameters for the API request (e.g., reservationCheckOutFrom, reservationCheckOutTo, resultsFrom, resultsTo)",
additionalProperties: {
type: "string"
},
},
},
required: ["hotelId"],
},
},
{
name: "search_clickhouse",
description: "Query Clickhouse database (currently unimplemented)",
inputSchema: {
type: "object",
properties: {
query: {
type: "string",
description: "SQL query to execute",
},
database: {
type: "string",
description: "Database name (optional)",
},
format: {
type: "string",
description: "Output format (e.g., 'JSON', 'TabSeparated')",
default: "JSON",
},
},
required: ["query"],
},
},
],
};
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const toolName = request.params.name;
switch (toolName) {
case "search_logs":
return await handleSearchLogs(request.params.arguments);
case "search_cloudbeds_pms":
return await handleSearchCloudBedsPMS(request.params.arguments);
case "search_clickhouse":
return await handleSearchClickhouse(request.params.arguments);
default:
throw new Error(`Unknown tool: ${toolName}`);
}
});
async function handleSearchLogs(args: unknown) {
const logArgs = args as unknown as LogSearchArgs;
if (!logArgs.query || !logArgs.from || !logArgs.to) {
throw new Error("Missing required arguments: query, from, and to are required");
}
try {
const response = await fetch("https://api.datadoghq.com/api/v2/logs/events/search", {
method: "POST",
headers: {
"Content-Type": "application/json",
"DD-APPLICATION-KEY": DD_APPLICATION_KEY!,
"DD-API-KEY": DD_API_KEY!,
},
body: JSON.stringify({
filter: {
from: logArgs.from,
to: logArgs.to,
query: logArgs.query,
},
sort: "timestamp",
page: {
limit: logArgs.limit || 10,
},
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Datadog API error (${response.status}): ${errorText}`);
}
const data = await response.json();
return {
content: [
{
type: "text",
text: JSON.stringify(data, null, 2),
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error searching logs: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
};
}
}
async function getCloudbedsAccessToken(hotelId: string): Promise<string> {
// Check cache first
const cached = tokenCache.get(hotelId);
if (cached && cached.expiresAt > Date.now()) {
return cached.accessToken;
}
// Get refresh token from map
const refreshToken = HOTEL_REFRESH_TOKENS[hotelId];
if (!refreshToken) {
throw new Error(`No refresh token configured for hotel ID: ${hotelId}`);
}
if (!CLOUDBEDS_CLIENT_ID || !CLOUDBEDS_CLIENT_SECRET) {
throw new Error("Cloudbeds client credentials not configured");
}
// Request new access token
const formData = new URLSearchParams();
formData.append("grant_type", "refresh_token");
formData.append("client_id", CLOUDBEDS_CLIENT_ID);
formData.append("client_secret", CLOUDBEDS_CLIENT_SECRET);
formData.append("refresh_token", refreshToken);
const response = await fetch("https://api.cloudbeds.com/api/v1.3/access_token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: formData.toString(),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Cloudbeds auth error (${response.status}): ${errorText}`);
}
const data = await response.json() as { access_token: string; expires_in: number };
// Cache the token (TTL of 1 hour or expires_in, whichever is shorter)
const ttl = Math.min(data.expires_in * 1000, 3600000); // 1 hour max
tokenCache.set(hotelId, {
accessToken: data.access_token,
expiresAt: Date.now() + ttl,
});
return data.access_token;
}
async function handleSearchCloudBedsPMS(args: unknown) {
const pmsArgs = args as unknown as CloudbedsPMSSearchArgs;
if (!pmsArgs.hotelId) {
throw new Error("Missing required argument: hotelId is required");
}
try {
const accessToken = await getCloudbedsAccessToken(pmsArgs.hotelId);
// Build query string from queryParams
const queryString = pmsArgs.queryParams
? "?" + new URLSearchParams(pmsArgs.queryParams).toString() : "";
const url = `https://api.cloudbeds.com/api/v1.3/${pmsArgs.endpoint}${queryString}`;
const response = await fetch(url, {
method: "GET",
headers: {
"Authorization": `Bearer ${accessToken}`,
},
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Cloudbeds API error (${response.status}): ${errorText}`);
}
const data = await response.json();
return {
content: [
{
type: "text",
text: JSON.stringify(data, null, 2),
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error querying Cloudbeds PMS: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
};
}
}
async function handleSearchClickhouse(args: unknown) {
const clickhouseArgs = args as unknown as ClickhouseSearchArgs;
if (!clickhouseArgs.query) {
throw new Error("Missing required argument: query is required");
}
if (!CLICKHOUSE_HOST || !CLICKHOUSE_USER || !CLICKHOUSE_PASSWORD) {
return {
content: [
{
type: "text",
text: "Clickhouse is not configured. Please set CLICKHOUSE_HOST, CLICKHOUSE_USER, and CLICKHOUSE_PASSWORD environment variables.",
},
],
isError: true,
};
}
try {
const format = clickhouseArgs.format || "JSON";
const url = `https://${CLICKHOUSE_HOST}/?default_format=${format}`;
const credentials = Buffer.from(`${CLICKHOUSE_USER}:${CLICKHOUSE_PASSWORD}`).toString('base64');
const response = await fetch(url, {
method: "POST",
headers: {
"Authorization": `Basic ${credentials}`,
"Content-Type": "text/plain",
},
body: clickhouseArgs.query,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Clickhouse error (${response.status}): ${errorText}`);
}
const data = await response.text();
return {
content: [
{
type: "text",
text: data,
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error querying Clickhouse: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
};
}
}
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Datadog Logs MCP Server running on stdio");
}
main().catch((error) => {
console.error("Fatal error in main():", error);
process.exit(1);
});