index.ts•21.1 kB
#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
ListResourcesRequestSchema,
ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import axios from "axios";
import { marked } from "marked";
import NodeCache from "node-cache";
// Base URL for the Firebase hosting
const FIREBASE_BASE_URL = "http://localhost:4000";
// Cache for storing documentation data to avoid repeated requests
const cache = new NodeCache({ stdTTL: 300, checkperiod: 60 }); // Cache for 5 minutes
// Resource types mapping
const RESOURCE_TYPES: Record<string, string> = {
api: "API Reference",
documentation: "Documentation",
guide: "Guide",
reference: "Reference",
};
// Create a new MCP server
const server = new Server(
{
name: "payments-developer-portal",
version: "1.0.0",
},
{
capabilities: {
tools: {},
resources: {}, // Required for resources
},
}
);
// Utility function to fetch data from Firebase hosting
async function fetchFromFirebase(path: string): Promise<any> {
const cacheKey = `firebase_${path}`;
const cachedData = cache.get(cacheKey);
if (cachedData) {
return cachedData;
}
try {
const response = await axios.get(`${FIREBASE_BASE_URL}${path}`);
cache.set(cacheKey, response.data);
return response.data;
} catch (error: any) {
console.error(`Error fetching from Firebase: ${path}`, error.message);
throw new Error(`Failed to fetch data from ${path}: ${error.message}`);
}
}
// Fetch the docs index
async function getDocsIndex(): Promise<any> {
const cacheKey = "docs_index";
const cachedIndex = cache.get(cacheKey);
if (cachedIndex) {
return cachedIndex;
}
try {
const response = await axios.get(`${FIREBASE_BASE_URL}/docs-index.json`);
cache.set(cacheKey, response.data);
return response.data;
} catch (error: any) {
console.error("Error fetching docs index:", error.message);
throw new Error(`Failed to fetch docs index: ${error.message}`);
}
}
// Convert Markdown to plain text
async function markdownToPlainText(markdown: string): Promise<string> {
const html = await marked.parse(markdown);
return html
.replace(/<[^>]*>/g, "") // Remove HTML tags
.replace(/\n\s*\n/g, "\n\n"); // Normalize whitespace
}
// List available tools
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "search_docs",
description: "Search the developer portal documentation",
inputSchema: {
type: "object",
properties: {
query: {
type: "string",
description: "Search query",
},
type: {
type: "string",
description:
"Filter by resource type (api, documentation, guide, reference)",
enum: ["api", "documentation", "guide", "reference"],
},
category: {
type: "string",
description: "Filter by category",
},
tag: {
type: "string",
description: "Filter by tag",
},
limit: {
type: "number",
description: "Maximum number of results to return",
default: 10,
},
},
required: ["query"],
},
},
{
name: "get_resource_by_id",
description: "Get a specific resource by its ID",
inputSchema: {
type: "object",
properties: {
id: {
type: "string",
description: "Resource ID",
},
},
required: ["id"],
},
},
{
name: "list_categories",
description: "List all available documentation categories",
inputSchema: {
type: "object",
properties: {
includeResources: {
type: "boolean",
description: "Whether to include resources in each category",
default: false,
},
},
},
},
{
name: "list_resources_by_category",
description: "List resources in a specific category",
inputSchema: {
type: "object",
properties: {
category: {
type: "string",
description: "Category ID",
},
},
required: ["category"],
},
},
{
name: "list_resources_by_tag",
description: "List resources with a specific tag",
inputSchema: {
type: "object",
properties: {
tag: {
type: "string",
description: "Tag name",
},
},
required: ["tag"],
},
},
{
name: "get_related_resources",
description: "Get resources related to a specific resource",
inputSchema: {
type: "object",
properties: {
id: {
type: "string",
description: "Resource ID",
},
},
required: ["id"],
},
},
],
}));
// List available resources
server.setRequestHandler(ListResourcesRequestSchema, async () => {
try {
const docsIndex = await getDocsIndex();
// Create resource URIs for each resource type
const resources = [
{
uri: "payments-docs://api",
mimeType: "text/plain",
name: "API References",
},
{
uri: "payments-docs://documentation",
mimeType: "text/plain",
name: "Documentation",
},
{
uri: "payments-docs://guides",
mimeType: "text/plain",
name: "Guides",
},
{
uri: "payments-docs://categories",
mimeType: "text/plain",
name: "Categories",
},
{
uri: "payments-docs://tags",
mimeType: "text/plain",
name: "Tags",
},
];
return { resources };
} catch (error: any) {
console.error("Error listing resources:", error.message);
throw new Error(`Failed to list resources: ${error.message}`);
}
});
// Handle resource reading
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
try {
const docsIndex = await getDocsIndex();
const uri = request.params.uri;
// Handle different resource types
if (uri === "payments-docs://api") {
const apiResources = docsIndex.resources.filter(
(r: any) => r.type === "api"
);
return {
contents: [
{
uri,
mimeType: "text/plain",
text: `API References (${apiResources.length}):\n\n${apiResources
.map(
(r: any) =>
`- ${r.title}: ${r.description}\n ID: ${r.id}\n Path: ${
r.path
}\n Topics: ${r.topics.join(", ")}\n`
)
.join("\n")}`,
},
],
};
} else if (uri === "payments-docs://documentation") {
const docResources = docsIndex.resources.filter(
(r: any) => r.type === "documentation"
);
return {
contents: [
{
uri,
mimeType: "text/plain",
text: `Documentation (${docResources.length}):\n\n${docResources
.map(
(r: any) =>
`- ${r.title}: ${r.description}\n ID: ${r.id}\n Path: ${
r.path
}\n Topics: ${r.topics.join(", ")}\n`
)
.join("\n")}`,
},
],
};
} else if (uri === "payments-docs://guides") {
const guideResources = docsIndex.resources.filter(
(r: any) => r.type === "guide"
);
return {
contents: [
{
uri,
mimeType: "text/plain",
text: `Guides (${guideResources.length}):\n\n${guideResources
.map(
(r: any) =>
`- ${r.title}: ${r.description}\n ID: ${r.id}\n Path: ${
r.path
}\n Topics: ${r.topics.join(", ")}\n`
)
.join("\n")}`,
},
],
};
} else if (uri === "payments-docs://categories") {
return {
contents: [
{
uri,
mimeType: "text/plain",
text: `Categories (${
docsIndex.categories.length
}):\n\n${docsIndex.categories
.map(
(c: any) =>
`- ${c.name} (${c.resources.length} resources)\n ID: ${c.id}\n`
)
.join("\n")}`,
},
],
};
} else if (uri === "payments-docs://tags") {
return {
contents: [
{
uri,
mimeType: "text/plain",
text: `Tags (${docsIndex.tags.length}):\n\n${docsIndex.tags
.map(
(t: any) => `- ${t.name} (${t.resources.length} resources)\n`
)
.join("\n")}`,
},
],
};
} else if (uri.startsWith("payments-docs://resource/")) {
const resourceId = uri.replace("payments-docs://resource/", "");
const resource = docsIndex.resources.find(
(r: any) => r.id === resourceId
);
if (!resource) {
throw new Error(`Resource not found: ${resourceId}`);
}
// Fetch the actual content of the resource
let content = "";
try {
const resourceData = await fetchFromFirebase(resource.path);
if (resource.path.endsWith(".md")) {
// For markdown files, convert to plain text
content = await markdownToPlainText(resourceData);
} else if (resource.path.endsWith(".json")) {
// For JSON files, format nicely
content = JSON.stringify(resourceData, null, 2);
} else {
content = String(resourceData);
}
} catch (error) {
content = `[Content could not be fetched: ${error}]`;
}
return {
contents: [
{
uri,
mimeType: resource.path.endsWith(".md")
? "text/markdown"
: "application/json",
text: `# ${resource.title}\n\n${resource.description}\n\n${content}`,
},
],
};
}
throw new Error(`Resource not found: ${uri}`);
} catch (error: any) {
console.error("Error reading resource:", error.message);
throw new Error(`Failed to read resource: ${error.message}`);
}
});
// Handle tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
const docsIndex = await getDocsIndex();
if (request.params.name === "search_docs") {
const {
query,
type,
category,
tag,
limit = 10,
} = request.params.arguments as any;
// Filter resources based on criteria
let results = docsIndex.resources;
if (type) {
results = results.filter((r: any) => r.type === type);
}
if (category) {
const categoryObj = docsIndex.categories.find(
(c: any) =>
c.id === category || c.name.toLowerCase() === category.toLowerCase()
);
if (categoryObj) {
results = results.filter((r: any) =>
categoryObj.resources.includes(r.id)
);
} else {
return {
content: [
{ type: "text", text: `Category not found: ${category}` },
],
isError: true,
};
}
}
if (tag) {
const tagObj = docsIndex.tags.find(
(t: any) => t.name.toLowerCase() === tag.toLowerCase()
);
if (tagObj) {
results = results.filter((r: any) => tagObj.resources.includes(r.id));
} else {
return {
content: [{ type: "text", text: `Tag not found: ${tag}` }],
isError: true,
};
}
}
// Search in title, description, and topics
const searchTerms = query.toLowerCase().split(/\s+/);
results = results.filter((r: any) => {
const searchableText = `${r.title} ${r.description} ${r.topics.join(
" "
)}`.toLowerCase();
return searchTerms.every((term: string) => searchableText.includes(term));
});
// Sort by relevance (importance) and limit results
results = results
.sort((a: any, b: any) => b.importance - a.importance)
.slice(0, limit);
if (results.length === 0) {
return {
content: [
{
type: "text",
text: `No results found for query: "${query}"${
type ? ` in type: ${type}` : ""
}${category ? ` in category: ${category}` : ""}${
tag ? ` with tag: ${tag}` : ""
}`,
},
],
};
}
return {
content: [
{
type: "text",
text: `Found ${results.length} results for "${query}":\n\n${results
.map(
(r: any, i: number) =>
`${i + 1}. **${r.title}** (${
RESOURCE_TYPES[r.type] || r.type
})\n ${r.description}\n ID: \`${
r.id
}\`\n Topics: ${r.topics.join(", ")}\n`
)
.join(
"\n"
)}\n\nTo view a specific resource, use the \`get_resource_by_id\` tool with the resource ID.`,
},
],
};
} else if (request.params.name === "get_resource_by_id") {
const { id } = request.params.arguments as any;
const resource = docsIndex.resources.find((r: any) => r.id === id);
if (!resource) {
return {
content: [
{ type: "text", text: `Resource not found with ID: ${id}` },
],
isError: true,
};
}
// Fetch the actual content of the resource
let content = "";
try {
const resourceData = await fetchFromFirebase(resource.path);
if (resource.path.endsWith(".md")) {
// For markdown files, convert to plain text
content = await markdownToPlainText(resourceData);
} else if (resource.path.endsWith(".json")) {
// For JSON files, format nicely
content = JSON.stringify(resourceData, null, 2);
} else {
content = String(resourceData);
}
} catch (error) {
content = `[Content could not be fetched: ${error}]`;
}
// Find related resources
const relatedResources =
resource.related && resource.related.length > 0
? docsIndex.resources.filter((r: any) =>
resource.related.includes(r.id)
)
: [];
return {
content: [
{
type: "text",
text:
`# ${resource.title}\n\n${resource.description}\n\n` +
`**Type:** ${RESOURCE_TYPES[resource.type] || resource.type}\n` +
`**Topics:** ${resource.topics.join(", ")}\n` +
(resource.difficulty
? `**Difficulty:** ${resource.difficulty}\n`
: "") +
(resource.estimatedTime
? `**Estimated Time:** ${resource.estimatedTime}\n`
: "") +
`**Last Updated:** ${resource.lastUpdated}\n\n` +
`## Content\n\n${content}\n\n` +
(relatedResources.length > 0
? `## Related Resources\n\n${relatedResources
.map((r: any) => `- ${r.title} (ID: \`${r.id}\`)`)
.join("\n")}\n`
: ""),
},
],
};
} else if (request.params.name === "list_categories") {
const { includeResources = false } = request.params.arguments as any;
const categoriesText = docsIndex.categories
.map((c: any) => {
let text = `- **${c.name}** (ID: \`${c.id}\`): ${c.resources.length} resources`;
if (includeResources) {
const categoryResources = docsIndex.resources.filter((r: any) =>
c.resources.includes(r.id)
);
text += `\n Resources:\n${categoryResources
.map(
(r: any) =>
` - ${r.title} (ID: \`${r.id}\`, Type: ${
RESOURCE_TYPES[r.type] || r.type
})`
)
.join("\n")}`;
}
return text;
})
.join("\n\n");
return {
content: [
{
type: "text",
text: `# Documentation Categories (${docsIndex.categories.length})\n\n${categoriesText}`,
},
],
};
} else if (request.params.name === "list_resources_by_category") {
const { category } = request.params.arguments as any;
const categoryObj = docsIndex.categories.find(
(c: any) =>
c.id === category || c.name.toLowerCase() === category.toLowerCase()
);
if (!categoryObj) {
return {
content: [{ type: "text", text: `Category not found: ${category}` }],
isError: true,
};
}
const categoryResources = docsIndex.resources.filter((r: any) =>
categoryObj.resources.includes(r.id)
);
return {
content: [
{
type: "text",
text: `# ${categoryObj.name} (${
categoryResources.length
} resources)\n\n${categoryResources
.map(
(r: any) =>
`- **${r.title}** (${RESOURCE_TYPES[r.type] || r.type})\n ${
r.description
}\n ID: \`${r.id}\`\n Topics: ${r.topics.join(", ")}\n`
)
.join("\n")}`,
},
],
};
} else if (request.params.name === "list_resources_by_tag") {
const { tag } = request.params.arguments as any;
const tagObj = docsIndex.tags.find(
(t: any) => t.name.toLowerCase() === tag.toLowerCase()
);
if (!tagObj) {
return {
content: [{ type: "text", text: `Tag not found: ${tag}` }],
isError: true,
};
}
const tagResources = docsIndex.resources.filter((r: any) =>
tagObj.resources.includes(r.id)
);
return {
content: [
{
type: "text",
text: `# Resources with tag: ${tagObj.name} (${
tagResources.length
} resources)\n\n${tagResources
.map(
(r: any) =>
`- **${r.title}** (${RESOURCE_TYPES[r.type] || r.type})\n ${
r.description
}\n ID: \`${r.id}\`\n Topics: ${r.topics.join(", ")}\n`
)
.join("\n")}`,
},
],
};
} else if (request.params.name === "get_related_resources") {
const { id } = request.params.arguments as any;
const resource = docsIndex.resources.find((r: any) => r.id === id);
if (!resource) {
return {
content: [
{ type: "text", text: `Resource not found with ID: ${id}` },
],
isError: true,
};
}
if (!resource.related || resource.related.length === 0) {
return {
content: [
{
type: "text",
text: `No related resources found for: ${resource.title} (ID: ${id})`,
},
],
};
}
const relatedResources = docsIndex.resources.filter((r: any) =>
resource.related.includes(r.id)
);
return {
content: [
{
type: "text",
text: `# Resources related to: ${
resource.title
}\n\n${relatedResources
.map(
(r: any) =>
`- **${r.title}** (${RESOURCE_TYPES[r.type] || r.type})\n ${
r.description
}\n ID: \`${r.id}\`\n Topics: ${r.topics.join(", ")}\n`
)
.join("\n")}`,
},
],
};
}
throw new Error(`Unknown tool: ${request.params.name}`);
} catch (error: any) {
console.error("Error handling tool call:", error.message);
return {
content: [{ type: "text", text: `Error: ${error.message}` }],
isError: true,
};
}
});
// Run the server
async function runServer() {
try {
// Test connection to Firebase hosting
console.error("Connecting to Firebase hosting...");
await axios.get(`${FIREBASE_BASE_URL}/docs-index.json`);
console.error("Successfully connected to Firebase hosting");
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Payments Developer Portal MCP Server running on stdio");
} catch (error: any) {
console.error(`Failed to start server: ${error.message}`);
process.exit(1);
}
}
runServer().catch(console.error);