feat(wip): set up mcp server and tools, but mcp on cursor not working despite working in inspector

This commit is contained in:
Ralph Khreish
2025-03-25 00:39:20 +00:00
parent 90580581ba
commit 21e74ab8f5
15 changed files with 529 additions and 2435 deletions

8
.cursor/mcp.json Normal file
View File

@@ -0,0 +1,8 @@
{
"mcpServers": {
"taskMaster": {
"command": "node",
"args": ["mcp-server/server.js"]
}
}
}

View File

@@ -2,15 +2,11 @@
import TaskMasterMCPServer from "./src/index.js";
import dotenv from "dotenv";
import { logger } from "../scripts/modules/utils.js";
import logger from "./src/logger.js";
// Load environment variables
dotenv.config();
// Constants
const PORT = process.env.MCP_SERVER_PORT || 3000;
const HOST = process.env.MCP_SERVER_HOST || "localhost";
/**
* Start the MCP server
*/
@@ -19,21 +15,17 @@ async function startServer() {
// Handle graceful shutdown
process.on("SIGINT", async () => {
logger.info("Received SIGINT, shutting down gracefully...");
await server.stop();
process.exit(0);
});
process.on("SIGTERM", async () => {
logger.info("Received SIGTERM, shutting down gracefully...");
await server.stop();
process.exit(0);
});
try {
await server.start({ port: PORT, host: HOST });
logger.info(`MCP server running at http://${HOST}:${PORT}`);
logger.info("Press Ctrl+C to stop");
await server.start();
} catch (error) {
logger.error(`Failed to start MCP server: ${error.message}`);
process.exit(1);

View File

@@ -1,970 +0,0 @@
import { z } from "zod";
import { logger } from "../../scripts/modules/utils.js";
import ContextManager from "./context-manager.js";
/**
* MCP API Handlers class
* Implements handlers for the MCP API endpoints
*/
class MCPApiHandlers {
constructor(server) {
this.server = server;
this.contextManager = new ContextManager();
this.logger = logger;
// Bind methods
this.registerEndpoints = this.registerEndpoints.bind(this);
this.setupContextHandlers = this.setupContextHandlers.bind(this);
this.setupModelHandlers = this.setupModelHandlers.bind(this);
this.setupExecuteHandlers = this.setupExecuteHandlers.bind(this);
// Register all handlers
this.registerEndpoints();
}
/**
* Register all MCP API endpoints
*/
registerEndpoints() {
this.setupContextHandlers();
this.setupModelHandlers();
this.setupExecuteHandlers();
this.logger.info("Registered all MCP API endpoint handlers");
}
/**
* Set up handlers for the /context endpoint
*/
setupContextHandlers() {
// Add a tool to create context
this.server.addTool({
name: "createContext",
description:
"Create a new context with the given data and optional metadata",
parameters: z.object({
contextId: z.string().describe("Unique identifier for the context"),
data: z.any().describe("The context data to store"),
metadata: z
.object({})
.optional()
.describe("Optional metadata for the context"),
}),
execute: async (args) => {
try {
const context = await this.contextManager.createContext(
args.contextId,
args.data,
args.metadata || {}
);
return { success: true, context };
} catch (error) {
this.logger.error(`Error creating context: ${error.message}`);
return { success: false, error: error.message };
}
},
});
// Add a tool to get context
this.server.addTool({
name: "getContext",
description:
"Retrieve a context by its ID, optionally a specific version",
parameters: z.object({
contextId: z.string().describe("The ID of the context to retrieve"),
versionId: z
.string()
.optional()
.describe("Optional specific version ID to retrieve"),
}),
execute: async (args) => {
try {
const context = await this.contextManager.getContext(
args.contextId,
args.versionId
);
return { success: true, context };
} catch (error) {
this.logger.error(`Error retrieving context: ${error.message}`);
return { success: false, error: error.message };
}
},
});
// Add a tool to update context
this.server.addTool({
name: "updateContext",
description: "Update an existing context with new data and/or metadata",
parameters: z.object({
contextId: z.string().describe("The ID of the context to update"),
data: z
.any()
.optional()
.describe("New data to update the context with"),
metadata: z
.object({})
.optional()
.describe("New metadata to update the context with"),
createNewVersion: z
.boolean()
.optional()
.default(true)
.describe(
"Whether to create a new version (true) or update in place (false)"
),
}),
execute: async (args) => {
try {
const context = await this.contextManager.updateContext(
args.contextId,
args.data || {},
args.metadata || {},
args.createNewVersion
);
return { success: true, context };
} catch (error) {
this.logger.error(`Error updating context: ${error.message}`);
return { success: false, error: error.message };
}
},
});
// Add a tool to delete context
this.server.addTool({
name: "deleteContext",
description: "Delete a context by its ID",
parameters: z.object({
contextId: z.string().describe("The ID of the context to delete"),
}),
execute: async (args) => {
try {
const result = await this.contextManager.deleteContext(
args.contextId
);
return { success: result };
} catch (error) {
this.logger.error(`Error deleting context: ${error.message}`);
return { success: false, error: error.message };
}
},
});
// Add a tool to list contexts with pagination and advanced filtering
this.server.addTool({
name: "listContexts",
description:
"List available contexts with filtering, pagination and sorting",
parameters: z.object({
// Filtering parameters
filters: z
.object({
tag: z.string().optional().describe("Filter contexts by tag"),
metadataKey: z
.string()
.optional()
.describe("Filter contexts by metadata key"),
metadataValue: z
.string()
.optional()
.describe("Filter contexts by metadata value"),
createdAfter: z
.string()
.optional()
.describe("Filter contexts created after date (ISO format)"),
updatedAfter: z
.string()
.optional()
.describe("Filter contexts updated after date (ISO format)"),
})
.optional()
.describe("Filters to apply to the context list"),
// Pagination parameters
limit: z
.number()
.optional()
.default(100)
.describe("Maximum number of contexts to return"),
offset: z
.number()
.optional()
.default(0)
.describe("Number of contexts to skip"),
// Sorting parameters
sortBy: z
.string()
.optional()
.default("updated")
.describe("Field to sort by (id, created, updated, size)"),
sortDirection: z
.enum(["asc", "desc"])
.optional()
.default("desc")
.describe("Sort direction"),
// Search query
query: z.string().optional().describe("Free text search query"),
}),
execute: async (args) => {
try {
const result = await this.contextManager.listContexts(args);
return {
success: true,
...result,
};
} catch (error) {
this.logger.error(`Error listing contexts: ${error.message}`);
return { success: false, error: error.message };
}
},
});
// Add a tool to get context history
this.server.addTool({
name: "getContextHistory",
description: "Get the version history of a context",
parameters: z.object({
contextId: z
.string()
.describe("The ID of the context to get history for"),
}),
execute: async (args) => {
try {
const history = await this.contextManager.getContextHistory(
args.contextId
);
return {
success: true,
history,
contextId: args.contextId,
};
} catch (error) {
this.logger.error(`Error getting context history: ${error.message}`);
return { success: false, error: error.message };
}
},
});
// Add a tool to merge contexts
this.server.addTool({
name: "mergeContexts",
description: "Merge multiple contexts into a new context",
parameters: z.object({
contextIds: z
.array(z.string())
.describe("Array of context IDs to merge"),
newContextId: z.string().describe("ID for the new merged context"),
metadata: z
.object({})
.optional()
.describe("Optional metadata for the new context"),
}),
execute: async (args) => {
try {
const mergedContext = await this.contextManager.mergeContexts(
args.contextIds,
args.newContextId,
args.metadata || {}
);
return {
success: true,
context: mergedContext,
};
} catch (error) {
this.logger.error(`Error merging contexts: ${error.message}`);
return { success: false, error: error.message };
}
},
});
// Add a tool to add tags to a context
this.server.addTool({
name: "addTags",
description: "Add tags to a context",
parameters: z.object({
contextId: z.string().describe("The ID of the context to tag"),
tags: z
.array(z.string())
.describe("Array of tags to add to the context"),
}),
execute: async (args) => {
try {
const context = await this.contextManager.addTags(
args.contextId,
args.tags
);
return { success: true, context };
} catch (error) {
this.logger.error(`Error adding tags to context: ${error.message}`);
return { success: false, error: error.message };
}
},
});
// Add a tool to remove tags from a context
this.server.addTool({
name: "removeTags",
description: "Remove tags from a context",
parameters: z.object({
contextId: z
.string()
.describe("The ID of the context to remove tags from"),
tags: z
.array(z.string())
.describe("Array of tags to remove from the context"),
}),
execute: async (args) => {
try {
const context = await this.contextManager.removeTags(
args.contextId,
args.tags
);
return { success: true, context };
} catch (error) {
this.logger.error(
`Error removing tags from context: ${error.message}`
);
return { success: false, error: error.message };
}
},
});
// Add a tool to truncate context
this.server.addTool({
name: "truncateContext",
description: "Truncate a context to a maximum size",
parameters: z.object({
contextId: z.string().describe("The ID of the context to truncate"),
maxSize: z
.number()
.describe("Maximum size (in characters) for the context"),
strategy: z
.enum(["start", "end", "middle"])
.default("end")
.describe("Truncation strategy: start, end, or middle"),
}),
execute: async (args) => {
try {
const context = await this.contextManager.truncateContext(
args.contextId,
args.maxSize,
args.strategy
);
return { success: true, context };
} catch (error) {
this.logger.error(`Error truncating context: ${error.message}`);
return { success: false, error: error.message };
}
},
});
this.logger.info("Registered context endpoint handlers");
}
/**
* Set up handlers for the /models endpoint
*/
setupModelHandlers() {
// Add a tool to list available models
this.server.addTool({
name: "listModels",
description: "List all available models with their capabilities",
parameters: z.object({}),
execute: async () => {
// Here we could get models from a more dynamic source
// For now, returning static list of models supported by Task Master
const models = [
{
id: "claude-3-opus-20240229",
provider: "anthropic",
capabilities: [
"text-generation",
"embeddings",
"context-window-100k",
],
},
{
id: "claude-3-7-sonnet-20250219",
provider: "anthropic",
capabilities: [
"text-generation",
"embeddings",
"context-window-200k",
],
},
{
id: "sonar-medium-online",
provider: "perplexity",
capabilities: ["text-generation", "web-search", "research"],
},
];
return { success: true, models };
},
});
// Add a tool to get model details
this.server.addTool({
name: "getModelDetails",
description: "Get detailed information about a specific model",
parameters: z.object({
modelId: z.string().describe("The ID of the model to get details for"),
}),
execute: async (args) => {
// Here we could get model details from a more dynamic source
// For now, returning static information
const modelsMap = {
"claude-3-opus-20240229": {
id: "claude-3-opus-20240229",
provider: "anthropic",
capabilities: [
"text-generation",
"embeddings",
"context-window-100k",
],
maxTokens: 100000,
temperature: { min: 0, max: 1, default: 0.7 },
pricing: { input: 0.000015, output: 0.000075 },
},
"claude-3-7-sonnet-20250219": {
id: "claude-3-7-sonnet-20250219",
provider: "anthropic",
capabilities: [
"text-generation",
"embeddings",
"context-window-200k",
],
maxTokens: 200000,
temperature: { min: 0, max: 1, default: 0.7 },
pricing: { input: 0.000003, output: 0.000015 },
},
"sonar-medium-online": {
id: "sonar-medium-online",
provider: "perplexity",
capabilities: ["text-generation", "web-search", "research"],
maxTokens: 4096,
temperature: { min: 0, max: 1, default: 0.7 },
},
};
const model = modelsMap[args.modelId];
if (!model) {
return {
success: false,
error: `Model with ID ${args.modelId} not found`,
};
}
return { success: true, model };
},
});
this.logger.info("Registered models endpoint handlers");
}
/**
* Set up handlers for the /execute endpoint
*/
setupExecuteHandlers() {
// Add a tool to execute operations with context
this.server.addTool({
name: "executeWithContext",
description: "Execute an operation with the provided context",
parameters: z.object({
operation: z.string().describe("The operation to execute"),
contextId: z.string().describe("The ID of the context to use"),
parameters: z
.record(z.any())
.optional()
.describe("Additional parameters for the operation"),
versionId: z
.string()
.optional()
.describe("Optional specific context version to use"),
}),
execute: async (args) => {
try {
// Get the context first, with version if specified
const context = await this.contextManager.getContext(
args.contextId,
args.versionId
);
// Execute different operations based on the operation name
switch (args.operation) {
case "generateTask":
return await this.executeGenerateTask(context, args.parameters);
case "expandTask":
return await this.executeExpandTask(context, args.parameters);
case "analyzeComplexity":
return await this.executeAnalyzeComplexity(
context,
args.parameters
);
case "mergeContexts":
return await this.executeMergeContexts(context, args.parameters);
case "searchContexts":
return await this.executeSearchContexts(args.parameters);
case "extractInsights":
return await this.executeExtractInsights(
context,
args.parameters
);
case "syncWithRepository":
return await this.executeSyncWithRepository(
context,
args.parameters
);
default:
return {
success: false,
error: `Unknown operation: ${args.operation}`,
};
}
} catch (error) {
this.logger.error(`Error executing operation: ${error.message}`);
return {
success: false,
error: error.message,
operation: args.operation,
contextId: args.contextId,
};
}
},
});
// Add tool for batch operations
this.server.addTool({
name: "executeBatchOperations",
description: "Execute multiple operations in a single request",
parameters: z.object({
operations: z
.array(
z.object({
operation: z.string().describe("The operation to execute"),
contextId: z.string().describe("The ID of the context to use"),
parameters: z
.record(z.any())
.optional()
.describe("Additional parameters"),
versionId: z
.string()
.optional()
.describe("Optional context version"),
})
)
.describe("Array of operations to execute in sequence"),
}),
execute: async (args) => {
const results = [];
let hasErrors = false;
for (const op of args.operations) {
try {
const context = await this.contextManager.getContext(
op.contextId,
op.versionId
);
let result;
switch (op.operation) {
case "generateTask":
result = await this.executeGenerateTask(context, op.parameters);
break;
case "expandTask":
result = await this.executeExpandTask(context, op.parameters);
break;
case "analyzeComplexity":
result = await this.executeAnalyzeComplexity(
context,
op.parameters
);
break;
case "mergeContexts":
result = await this.executeMergeContexts(
context,
op.parameters
);
break;
case "searchContexts":
result = await this.executeSearchContexts(op.parameters);
break;
case "extractInsights":
result = await this.executeExtractInsights(
context,
op.parameters
);
break;
case "syncWithRepository":
result = await this.executeSyncWithRepository(
context,
op.parameters
);
break;
default:
result = {
success: false,
error: `Unknown operation: ${op.operation}`,
};
hasErrors = true;
}
results.push({
operation: op.operation,
contextId: op.contextId,
result: result,
});
if (!result.success) {
hasErrors = true;
}
} catch (error) {
this.logger.error(
`Error in batch operation ${op.operation}: ${error.message}`
);
results.push({
operation: op.operation,
contextId: op.contextId,
result: {
success: false,
error: error.message,
},
});
hasErrors = true;
}
}
return {
success: !hasErrors,
results: results,
};
},
});
this.logger.info("Registered execute endpoint handlers");
}
/**
* Execute the generateTask operation
* @param {object} context - The context to use
* @param {object} parameters - Additional parameters
* @returns {Promise<object>} The result of the operation
*/
async executeGenerateTask(context, parameters = {}) {
// This is a placeholder for actual task generation logic
// In a real implementation, this would use Task Master's task generation
this.logger.info(`Generating task with context ${context.id}`);
// Improved task generation with more detailed result
const task = {
id: Math.floor(Math.random() * 1000),
title: parameters.title || "New Task",
description: parameters.description || "Task generated from context",
status: "pending",
dependencies: parameters.dependencies || [],
priority: parameters.priority || "medium",
details: `This task was generated using context ${
context.id
}.\n\n${JSON.stringify(context.data, null, 2)}`,
metadata: {
generatedAt: new Date().toISOString(),
generatedFrom: context.id,
contextVersion: context.metadata.version,
generatedBy: parameters.user || "system",
},
};
return {
success: true,
task,
contextUsed: {
id: context.id,
version: context.metadata.version,
},
};
}
/**
* Execute the expandTask operation
* @param {object} context - The context to use
* @param {object} parameters - Additional parameters
* @returns {Promise<object>} The result of the operation
*/
async executeExpandTask(context, parameters = {}) {
// This is a placeholder for actual task expansion logic
// In a real implementation, this would use Task Master's task expansion
this.logger.info(`Expanding task with context ${context.id}`);
// Enhanced task expansion with more configurable options
const numSubtasks = parameters.numSubtasks || 3;
const subtaskPrefix = parameters.subtaskPrefix || "";
const subtasks = [];
for (let i = 1; i <= numSubtasks; i++) {
subtasks.push({
id: `${subtaskPrefix}${i}`,
title: parameters.titleTemplate
? parameters.titleTemplate.replace("{i}", i)
: `Subtask ${i}`,
description: parameters.descriptionTemplate
? parameters.descriptionTemplate
.replace("{i}", i)
.replace("{taskId}", parameters.taskId || "unknown")
: `Subtask ${i} for ${parameters.taskId || "unknown task"}`,
dependencies: i > 1 ? [i - 1] : [],
status: "pending",
metadata: {
expandedAt: new Date().toISOString(),
expandedFrom: context.id,
contextVersion: context.metadata.version,
expandedBy: parameters.user || "system",
},
});
}
return {
success: true,
taskId: parameters.taskId,
subtasks,
contextUsed: {
id: context.id,
version: context.metadata.version,
},
};
}
/**
* Execute the analyzeComplexity operation
* @param {object} context - The context to use
* @param {object} parameters - Additional parameters
* @returns {Promise<object>} The result of the operation
*/
async executeAnalyzeComplexity(context, parameters = {}) {
// This is a placeholder for actual complexity analysis logic
// In a real implementation, this would use Task Master's complexity analysis
this.logger.info(`Analyzing complexity with context ${context.id}`);
// Enhanced complexity analysis with more detailed factors
const complexityScore = Math.floor(Math.random() * 10) + 1;
const recommendedSubtasks = Math.floor(complexityScore / 2) + 1;
// More detailed analysis with weighted factors
const factors = [
{
name: "Task scope breadth",
score: Math.floor(Math.random() * 10) + 1,
weight: 0.3,
description: "How broad is the scope of this task",
},
{
name: "Technical complexity",
score: Math.floor(Math.random() * 10) + 1,
weight: 0.4,
description: "How technically complex is the implementation",
},
{
name: "External dependencies",
score: Math.floor(Math.random() * 10) + 1,
weight: 0.2,
description: "How many external dependencies does this task have",
},
{
name: "Risk assessment",
score: Math.floor(Math.random() * 10) + 1,
weight: 0.1,
description: "What is the risk level of this task",
},
];
return {
success: true,
analysis: {
taskId: parameters.taskId || "unknown",
complexityScore,
recommendedSubtasks,
factors,
recommendedTimeEstimate: `${complexityScore * 2}-${
complexityScore * 4
} hours`,
metadata: {
analyzedAt: new Date().toISOString(),
analyzedUsing: context.id,
contextVersion: context.metadata.version,
analyzedBy: parameters.user || "system",
},
},
contextUsed: {
id: context.id,
version: context.metadata.version,
},
};
}
/**
* Execute the mergeContexts operation
* @param {object} primaryContext - The primary context to use
* @param {object} parameters - Additional parameters
* @returns {Promise<object>} The result of the operation
*/
async executeMergeContexts(primaryContext, parameters = {}) {
this.logger.info(
`Merging contexts with primary context ${primaryContext.id}`
);
if (
!parameters.contextIds ||
!Array.isArray(parameters.contextIds) ||
parameters.contextIds.length === 0
) {
return {
success: false,
error: "No context IDs provided for merging",
};
}
if (!parameters.newContextId) {
return {
success: false,
error: "New context ID is required for the merged context",
};
}
try {
// Add the primary context to the list if not already included
if (!parameters.contextIds.includes(primaryContext.id)) {
parameters.contextIds.unshift(primaryContext.id);
}
const mergedContext = await this.contextManager.mergeContexts(
parameters.contextIds,
parameters.newContextId,
{
mergedAt: new Date().toISOString(),
mergedBy: parameters.user || "system",
mergeStrategy: parameters.strategy || "concatenate",
...parameters.metadata,
}
);
return {
success: true,
mergedContext,
sourceContexts: parameters.contextIds,
};
} catch (error) {
this.logger.error(`Error merging contexts: ${error.message}`);
return {
success: false,
error: error.message,
};
}
}
/**
* Execute the searchContexts operation
* @param {object} parameters - Search parameters
* @returns {Promise<object>} The result of the operation
*/
async executeSearchContexts(parameters = {}) {
this.logger.info(
`Searching contexts with query: ${parameters.query || ""}`
);
try {
const searchResults = await this.contextManager.listContexts({
query: parameters.query || "",
filters: parameters.filters || {},
limit: parameters.limit || 100,
offset: parameters.offset || 0,
sortBy: parameters.sortBy || "updated",
sortDirection: parameters.sortDirection || "desc",
});
return {
success: true,
...searchResults,
};
} catch (error) {
this.logger.error(`Error searching contexts: ${error.message}`);
return {
success: false,
error: error.message,
};
}
}
/**
* Execute the extractInsights operation
* @param {object} context - The context to analyze
* @param {object} parameters - Additional parameters
* @returns {Promise<object>} The result of the operation
*/
async executeExtractInsights(context, parameters = {}) {
this.logger.info(`Extracting insights from context ${context.id}`);
// Placeholder for actual insight extraction
// In a real implementation, this would perform analysis on the context data
const insights = [
{
type: "summary",
content: `Summary of context ${context.id}`,
confidence: 0.85,
},
{
type: "key_points",
content: ["First key point", "Second key point", "Third key point"],
confidence: 0.78,
},
{
type: "recommendations",
content: ["First recommendation", "Second recommendation"],
confidence: 0.72,
},
];
return {
success: true,
insights,
contextUsed: {
id: context.id,
version: context.metadata.version,
},
metadata: {
extractedAt: new Date().toISOString(),
model: parameters.model || "default",
extractedBy: parameters.user || "system",
},
};
}
/**
* Execute the syncWithRepository operation
* @param {object} context - The context to sync
* @param {object} parameters - Additional parameters
* @returns {Promise<object>} The result of the operation
*/
async executeSyncWithRepository(context, parameters = {}) {
this.logger.info(`Syncing context ${context.id} with repository`);
// Placeholder for actual repository sync
// In a real implementation, this would sync the context with an external repository
return {
success: true,
syncStatus: "complete",
syncedTo: parameters.repository || "default",
syncTimestamp: new Date().toISOString(),
contextUsed: {
id: context.id,
version: context.metadata.version,
},
};
}
}
export default MCPApiHandlers;

View File

@@ -1,285 +0,0 @@
import jwt from "jsonwebtoken";
import { logger } from "../../scripts/modules/utils.js";
import crypto from "crypto";
import fs from "fs/promises";
import path from "path";
import { fileURLToPath } from "url";
// Constants
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const API_KEYS_FILE =
process.env.MCP_API_KEYS_FILE || path.join(__dirname, "../api-keys.json");
const JWT_SECRET =
process.env.MCP_JWT_SECRET || "task-master-mcp-server-secret";
const JWT_EXPIRATION = process.env.MCP_JWT_EXPIRATION || "24h";
/**
* Authentication middleware and utilities for MCP server
*/
class MCPAuth {
constructor() {
this.apiKeys = new Map();
this.logger = logger;
this.loadApiKeys();
}
/**
* Load API keys from disk
*/
async loadApiKeys() {
try {
// Create API keys file if it doesn't exist
try {
await fs.access(API_KEYS_FILE);
} catch (error) {
// File doesn't exist, create it with a default admin key
const defaultApiKey = this.generateApiKey();
const defaultApiKeys = {
keys: [
{
id: "admin",
key: defaultApiKey,
role: "admin",
created: new Date().toISOString(),
},
],
};
await fs.mkdir(path.dirname(API_KEYS_FILE), { recursive: true });
await fs.writeFile(
API_KEYS_FILE,
JSON.stringify(defaultApiKeys, null, 2),
"utf8"
);
this.logger.info(
`Created default API keys file with admin key: ${defaultApiKey}`
);
}
// Load API keys
const data = await fs.readFile(API_KEYS_FILE, "utf8");
const apiKeys = JSON.parse(data);
apiKeys.keys.forEach((key) => {
this.apiKeys.set(key.key, {
id: key.id,
role: key.role,
created: key.created,
});
});
this.logger.info(`Loaded ${this.apiKeys.size} API keys`);
} catch (error) {
this.logger.error(`Failed to load API keys: ${error.message}`);
throw error;
}
}
/**
* Save API keys to disk
*/
async saveApiKeys() {
try {
const keys = [];
this.apiKeys.forEach((value, key) => {
keys.push({
id: value.id,
key,
role: value.role,
created: value.created,
});
});
await fs.writeFile(
API_KEYS_FILE,
JSON.stringify({ keys }, null, 2),
"utf8"
);
this.logger.info(`Saved ${keys.length} API keys`);
} catch (error) {
this.logger.error(`Failed to save API keys: ${error.message}`);
throw error;
}
}
/**
* Generate a new API key
* @returns {string} The generated API key
*/
generateApiKey() {
return crypto.randomBytes(32).toString("hex");
}
/**
* Create a new API key
* @param {string} id - Client identifier
* @param {string} role - Client role (admin, user)
* @returns {string} The generated API key
*/
async createApiKey(id, role = "user") {
const apiKey = this.generateApiKey();
this.apiKeys.set(apiKey, {
id,
role,
created: new Date().toISOString(),
});
await this.saveApiKeys();
this.logger.info(`Created new API key for ${id} with role ${role}`);
return apiKey;
}
/**
* Revoke an API key
* @param {string} apiKey - The API key to revoke
* @returns {boolean} True if the key was revoked
*/
async revokeApiKey(apiKey) {
if (!this.apiKeys.has(apiKey)) {
return false;
}
this.apiKeys.delete(apiKey);
await this.saveApiKeys();
this.logger.info(`Revoked API key`);
return true;
}
/**
* Validate an API key
* @param {string} apiKey - The API key to validate
* @returns {object|null} The API key details if valid, null otherwise
*/
validateApiKey(apiKey) {
return this.apiKeys.get(apiKey) || null;
}
/**
* Generate a JWT token for a client
* @param {string} clientId - Client identifier
* @param {string} role - Client role
* @returns {string} The JWT token
*/
generateToken(clientId, role) {
return jwt.sign({ clientId, role }, JWT_SECRET, {
expiresIn: JWT_EXPIRATION,
});
}
/**
* Verify a JWT token
* @param {string} token - The JWT token to verify
* @returns {object|null} The token payload if valid, null otherwise
*/
verifyToken(token) {
try {
return jwt.verify(token, JWT_SECRET);
} catch (error) {
this.logger.error(`Failed to verify token: ${error.message}`);
return null;
}
}
/**
* Express middleware for API key authentication
* @param {object} req - Express request object
* @param {object} res - Express response object
* @param {function} next - Express next function
*/
authenticateApiKey(req, res, next) {
const apiKey = req.headers["x-api-key"];
if (!apiKey) {
return res.status(401).json({
success: false,
error: "API key is required",
});
}
const keyDetails = this.validateApiKey(apiKey);
if (!keyDetails) {
return res.status(401).json({
success: false,
error: "Invalid API key",
});
}
// Attach client info to request
req.client = {
id: keyDetails.id,
role: keyDetails.role,
};
next();
}
/**
* Express middleware for JWT authentication
* @param {object} req - Express request object
* @param {object} res - Express response object
* @param {function} next - Express next function
*/
authenticateToken(req, res, next) {
const authHeader = req.headers["authorization"];
const token = authHeader && authHeader.split(" ")[1];
if (!token) {
return res.status(401).json({
success: false,
error: "Authentication token is required",
});
}
const payload = this.verifyToken(token);
if (!payload) {
return res.status(401).json({
success: false,
error: "Invalid or expired token",
});
}
// Attach client info to request
req.client = {
id: payload.clientId,
role: payload.role,
};
next();
}
/**
* Express middleware for role-based authorization
* @param {Array} roles - Array of allowed roles
* @returns {function} Express middleware
*/
authorizeRoles(roles) {
return (req, res, next) => {
if (!req.client || !req.client.role) {
return res.status(401).json({
success: false,
error: "Unauthorized: Authentication required",
});
}
if (!roles.includes(req.client.role)) {
return res.status(403).json({
success: false,
error: "Forbidden: Insufficient permissions",
});
}
next();
};
}
}
export default MCPAuth;

View File

@@ -1,873 +0,0 @@
import { logger } from "../../scripts/modules/utils.js";
import fs from "fs/promises";
import path from "path";
import { fileURLToPath } from "url";
import crypto from "crypto";
import Fuse from "fuse.js";
// Constants
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const CONTEXT_DIR =
process.env.MCP_CONTEXT_DIR || path.join(__dirname, "../contexts");
const MAX_CONTEXT_HISTORY = parseInt(
process.env.MCP_MAX_CONTEXT_HISTORY || "10",
10
);
/**
* Context Manager for MCP server
* Handles storage, retrieval, and manipulation of context data
* Implements efficient indexing, versioning, and advanced context operations
*/
class ContextManager {
constructor() {
this.contexts = new Map();
this.contextHistory = new Map(); // For version history
this.contextIndex = null; // For fuzzy search
this.logger = logger;
this.ensureContextDir();
this.rebuildSearchIndex();
}
/**
* Ensure the contexts directory exists
*/
async ensureContextDir() {
try {
await fs.mkdir(CONTEXT_DIR, { recursive: true });
this.logger.info(`Context directory ensured at ${CONTEXT_DIR}`);
// Also create a versions subdirectory for history
await fs.mkdir(path.join(CONTEXT_DIR, "versions"), { recursive: true });
} catch (error) {
this.logger.error(`Failed to create context directory: ${error.message}`);
throw error;
}
}
/**
* Rebuild the search index for efficient context lookup
*/
async rebuildSearchIndex() {
await this.loadAllContextsFromDisk();
const contextsForIndex = Array.from(this.contexts.values()).map((ctx) => ({
id: ctx.id,
content:
typeof ctx.data === "string" ? ctx.data : JSON.stringify(ctx.data),
tags: ctx.tags.join(" "),
metadata: Object.entries(ctx.metadata)
.map(([k, v]) => `${k}:${v}`)
.join(" "),
}));
this.contextIndex = new Fuse(contextsForIndex, {
keys: ["id", "content", "tags", "metadata"],
includeScore: true,
threshold: 0.6,
});
this.logger.info(
`Rebuilt search index with ${contextsForIndex.length} contexts`
);
}
/**
* Create a new context
* @param {string} contextId - Unique identifier for the context
* @param {object|string} contextData - Initial context data
* @param {object} metadata - Optional metadata for the context
* @returns {object} The created context
*/
async createContext(contextId, contextData, metadata = {}) {
if (this.contexts.has(contextId)) {
throw new Error(`Context with ID ${contextId} already exists`);
}
const timestamp = new Date().toISOString();
const versionId = this.generateVersionId();
const context = {
id: contextId,
data: contextData,
metadata: {
created: timestamp,
updated: timestamp,
version: versionId,
...metadata,
},
tags: metadata.tags || [],
size: this.estimateSize(contextData),
};
this.contexts.set(contextId, context);
// Initialize version history
this.contextHistory.set(contextId, [
{
versionId,
timestamp,
data: JSON.parse(JSON.stringify(contextData)), // Deep clone
metadata: { ...context.metadata },
},
]);
await this.persistContext(contextId);
await this.persistContextVersion(contextId, versionId);
// Update the search index
this.rebuildSearchIndex();
this.logger.info(`Created context: ${contextId} (version: ${versionId})`);
return context;
}
/**
* Retrieve a context by ID
* @param {string} contextId - The context ID to retrieve
* @param {string} versionId - Optional specific version to retrieve
* @returns {object} The context object
*/
async getContext(contextId, versionId = null) {
// If specific version requested, try to get it from history
if (versionId) {
return this.getContextVersion(contextId, versionId);
}
// Try to get from memory first
if (this.contexts.has(contextId)) {
return this.contexts.get(contextId);
}
// Try to load from disk
try {
const context = await this.loadContextFromDisk(contextId);
if (context) {
this.contexts.set(contextId, context);
return context;
}
} catch (error) {
this.logger.error(
`Failed to load context ${contextId}: ${error.message}`
);
}
throw new Error(`Context with ID ${contextId} not found`);
}
/**
* Get a specific version of a context
* @param {string} contextId - The context ID
* @param {string} versionId - The version ID
* @returns {object} The versioned context
*/
async getContextVersion(contextId, versionId) {
// Check if version history is in memory
if (this.contextHistory.has(contextId)) {
const history = this.contextHistory.get(contextId);
const version = history.find((v) => v.versionId === versionId);
if (version) {
return {
id: contextId,
data: version.data,
metadata: version.metadata,
tags: version.metadata.tags || [],
size: this.estimateSize(version.data),
versionId: version.versionId,
};
}
}
// Try to load from disk
try {
const versionPath = path.join(
CONTEXT_DIR,
"versions",
`${contextId}_${versionId}.json`
);
const data = await fs.readFile(versionPath, "utf8");
const version = JSON.parse(data);
// Add to memory cache
if (!this.contextHistory.has(contextId)) {
this.contextHistory.set(contextId, []);
}
const history = this.contextHistory.get(contextId);
history.push(version);
return {
id: contextId,
data: version.data,
metadata: version.metadata,
tags: version.metadata.tags || [],
size: this.estimateSize(version.data),
versionId: version.versionId,
};
} catch (error) {
this.logger.error(
`Failed to load context version ${contextId}@${versionId}: ${error.message}`
);
throw new Error(
`Context version ${versionId} for ${contextId} not found`
);
}
}
/**
* Update an existing context
* @param {string} contextId - The context ID to update
* @param {object|string} contextData - New context data
* @param {object} metadata - Optional metadata updates
* @param {boolean} createNewVersion - Whether to create a new version
* @returns {object} The updated context
*/
async updateContext(
contextId,
contextData,
metadata = {},
createNewVersion = true
) {
const context = await this.getContext(contextId);
const timestamp = new Date().toISOString();
// Generate a new version ID if requested
const versionId = createNewVersion
? this.generateVersionId()
: context.metadata.version;
// Create a backup of the current state for versioning
if (createNewVersion) {
// Store the current version in history
if (!this.contextHistory.has(contextId)) {
this.contextHistory.set(contextId, []);
}
const history = this.contextHistory.get(contextId);
// Add current state to history
history.push({
versionId: context.metadata.version,
timestamp: context.metadata.updated,
data: JSON.parse(JSON.stringify(context.data)), // Deep clone
metadata: { ...context.metadata },
});
// Trim history if it exceeds the maximum size
if (history.length > MAX_CONTEXT_HISTORY) {
const excessVersions = history.splice(
0,
history.length - MAX_CONTEXT_HISTORY
);
// Clean up excess versions from disk
for (const version of excessVersions) {
this.removeContextVersionFile(contextId, version.versionId).catch(
(err) =>
this.logger.error(
`Failed to remove old version file: ${err.message}`
)
);
}
}
// Persist version
await this.persistContextVersion(contextId, context.metadata.version);
}
// Update the context
context.data = contextData;
context.metadata = {
...context.metadata,
...metadata,
updated: timestamp,
};
if (createNewVersion) {
context.metadata.version = versionId;
context.metadata.previousVersion = context.metadata.version;
}
if (metadata.tags) {
context.tags = metadata.tags;
}
// Update size estimate
context.size = this.estimateSize(contextData);
this.contexts.set(contextId, context);
await this.persistContext(contextId);
// Update the search index
this.rebuildSearchIndex();
this.logger.info(`Updated context: ${contextId} (version: ${versionId})`);
return context;
}
/**
* Delete a context and all its versions
* @param {string} contextId - The context ID to delete
* @returns {boolean} True if deletion was successful
*/
async deleteContext(contextId) {
if (!this.contexts.has(contextId)) {
const contextPath = path.join(CONTEXT_DIR, `${contextId}.json`);
try {
await fs.access(contextPath);
} catch (error) {
throw new Error(`Context with ID ${contextId} not found`);
}
}
this.contexts.delete(contextId);
// Remove from history
const history = this.contextHistory.get(contextId) || [];
this.contextHistory.delete(contextId);
try {
// Delete main context file
const contextPath = path.join(CONTEXT_DIR, `${contextId}.json`);
await fs.unlink(contextPath);
// Delete all version files
for (const version of history) {
await this.removeContextVersionFile(contextId, version.versionId);
}
// Update the search index
this.rebuildSearchIndex();
this.logger.info(`Deleted context: ${contextId}`);
return true;
} catch (error) {
this.logger.error(
`Failed to delete context files for ${contextId}: ${error.message}`
);
throw error;
}
}
/**
* List all available contexts with pagination and advanced filtering
* @param {object} options - Options for listing contexts
* @param {object} options.filters - Filters to apply
* @param {number} options.limit - Maximum number of contexts to return
* @param {number} options.offset - Number of contexts to skip
* @param {string} options.sortBy - Field to sort by
* @param {string} options.sortDirection - Sort direction ('asc' or 'desc')
* @param {string} options.query - Free text search query
* @returns {Array} Array of context objects
*/
async listContexts(options = {}) {
// Load all contexts from disk first
await this.loadAllContextsFromDisk();
const {
filters = {},
limit = 100,
offset = 0,
sortBy = "updated",
sortDirection = "desc",
query = "",
} = options;
let contexts;
// If there's a search query, use the search index
if (query && this.contextIndex) {
const searchResults = this.contextIndex.search(query);
contexts = searchResults.map((result) =>
this.contexts.get(result.item.id)
);
} else {
contexts = Array.from(this.contexts.values());
}
// Apply filters
if (filters.tag) {
contexts = contexts.filter(
(ctx) => ctx.tags && ctx.tags.includes(filters.tag)
);
}
if (filters.metadataKey && filters.metadataValue) {
contexts = contexts.filter(
(ctx) =>
ctx.metadata &&
ctx.metadata[filters.metadataKey] === filters.metadataValue
);
}
if (filters.createdAfter) {
const timestamp = new Date(filters.createdAfter);
contexts = contexts.filter(
(ctx) => new Date(ctx.metadata.created) >= timestamp
);
}
if (filters.updatedAfter) {
const timestamp = new Date(filters.updatedAfter);
contexts = contexts.filter(
(ctx) => new Date(ctx.metadata.updated) >= timestamp
);
}
// Apply sorting
contexts.sort((a, b) => {
let valueA, valueB;
if (sortBy === "created" || sortBy === "updated") {
valueA = new Date(a.metadata[sortBy]).getTime();
valueB = new Date(b.metadata[sortBy]).getTime();
} else if (sortBy === "size") {
valueA = a.size || 0;
valueB = b.size || 0;
} else if (sortBy === "id") {
valueA = a.id;
valueB = b.id;
} else {
valueA = a.metadata[sortBy];
valueB = b.metadata[sortBy];
}
if (valueA === valueB) return 0;
const sortFactor = sortDirection === "asc" ? 1 : -1;
return valueA < valueB ? -1 * sortFactor : 1 * sortFactor;
});
// Apply pagination
const paginatedContexts = contexts.slice(offset, offset + limit);
return {
contexts: paginatedContexts,
total: contexts.length,
offset,
limit,
hasMore: offset + limit < contexts.length,
};
}
/**
* Get the version history of a context
* @param {string} contextId - The context ID
* @returns {Array} Array of version objects
*/
async getContextHistory(contextId) {
// Ensure context exists
await this.getContext(contextId);
// Load history if not in memory
if (!this.contextHistory.has(contextId)) {
await this.loadContextHistoryFromDisk(contextId);
}
const history = this.contextHistory.get(contextId) || [];
// Return versions in reverse chronological order (newest first)
return history.sort((a, b) => {
const timeA = new Date(a.timestamp).getTime();
const timeB = new Date(b.timestamp).getTime();
return timeB - timeA;
});
}
/**
* Add tags to a context
* @param {string} contextId - The context ID
* @param {Array} tags - Array of tags to add
* @returns {object} The updated context
*/
async addTags(contextId, tags) {
const context = await this.getContext(contextId);
const currentTags = context.tags || [];
const uniqueTags = [...new Set([...currentTags, ...tags])];
// Update context with new tags
return this.updateContext(
contextId,
context.data,
{
tags: uniqueTags,
},
false
); // Don't create a new version for tag updates
}
/**
* Remove tags from a context
* @param {string} contextId - The context ID
* @param {Array} tags - Array of tags to remove
* @returns {object} The updated context
*/
async removeTags(contextId, tags) {
const context = await this.getContext(contextId);
const currentTags = context.tags || [];
const newTags = currentTags.filter((tag) => !tags.includes(tag));
// Update context with new tags
return this.updateContext(
contextId,
context.data,
{
tags: newTags,
},
false
); // Don't create a new version for tag updates
}
/**
* Handle context windowing and truncation
* @param {string} contextId - The context ID
* @param {number} maxSize - Maximum size in tokens/chars
* @param {string} strategy - Truncation strategy ('start', 'end', 'middle')
* @returns {object} The truncated context
*/
async truncateContext(contextId, maxSize, strategy = "end") {
const context = await this.getContext(contextId);
const contextText =
typeof context.data === "string"
? context.data
: JSON.stringify(context.data);
if (contextText.length <= maxSize) {
return context; // No truncation needed
}
let truncatedData;
switch (strategy) {
case "start":
truncatedData = contextText.slice(contextText.length - maxSize);
break;
case "middle":
const halfSize = Math.floor(maxSize / 2);
truncatedData =
contextText.slice(0, halfSize) +
"...[truncated]..." +
contextText.slice(contextText.length - halfSize);
break;
case "end":
default:
truncatedData = contextText.slice(0, maxSize);
break;
}
// If original data was an object, try to parse the truncated data
// Otherwise use it as a string
let updatedData;
if (typeof context.data === "object") {
try {
// This may fail if truncation broke JSON structure
updatedData = {
...context.data,
truncated: true,
truncation_strategy: strategy,
original_size: contextText.length,
truncated_size: truncatedData.length,
};
} catch (error) {
updatedData = truncatedData;
}
} else {
updatedData = truncatedData;
}
// Update with truncated data
return this.updateContext(
contextId,
updatedData,
{
truncated: true,
truncation_strategy: strategy,
original_size: contextText.length,
truncated_size: truncatedData.length,
},
true
); // Create a new version for the truncated data
}
/**
* Merge multiple contexts into a new context
* @param {Array} contextIds - Array of context IDs to merge
* @param {string} newContextId - ID for the new merged context
* @param {object} metadata - Optional metadata for the new context
* @returns {object} The new merged context
*/
async mergeContexts(contextIds, newContextId, metadata = {}) {
if (contextIds.length === 0) {
throw new Error("At least one context ID must be provided for merging");
}
if (this.contexts.has(newContextId)) {
throw new Error(`Context with ID ${newContextId} already exists`);
}
// Load all contexts to be merged
const contextsToMerge = [];
for (const id of contextIds) {
try {
const context = await this.getContext(id);
contextsToMerge.push(context);
} catch (error) {
this.logger.error(
`Could not load context ${id} for merging: ${error.message}`
);
throw new Error(`Failed to merge contexts: ${error.message}`);
}
}
// Check data types and decide how to merge
const allStrings = contextsToMerge.every((c) => typeof c.data === "string");
const allObjects = contextsToMerge.every(
(c) => typeof c.data === "object" && c.data !== null
);
let mergedData;
if (allStrings) {
// Merge strings with newlines between them
mergedData = contextsToMerge.map((c) => c.data).join("\n\n");
} else if (allObjects) {
// Merge objects by combining their properties
mergedData = {};
for (const context of contextsToMerge) {
mergedData = { ...mergedData, ...context.data };
}
} else {
// Convert everything to strings and concatenate
mergedData = contextsToMerge
.map((c) =>
typeof c.data === "string" ? c.data : JSON.stringify(c.data)
)
.join("\n\n");
}
// Collect all tags from merged contexts
const allTags = new Set();
for (const context of contextsToMerge) {
for (const tag of context.tags || []) {
allTags.add(tag);
}
}
// Create merged metadata
const mergedMetadata = {
...metadata,
tags: [...allTags],
merged_from: contextIds,
merged_at: new Date().toISOString(),
};
// Create the new merged context
return this.createContext(newContextId, mergedData, mergedMetadata);
}
/**
* Persist a context to disk
* @param {string} contextId - The context ID to persist
* @returns {Promise<void>}
*/
async persistContext(contextId) {
const context = this.contexts.get(contextId);
if (!context) {
throw new Error(`Context with ID ${contextId} not found`);
}
const contextPath = path.join(CONTEXT_DIR, `${contextId}.json`);
try {
await fs.writeFile(contextPath, JSON.stringify(context, null, 2), "utf8");
this.logger.debug(`Persisted context ${contextId} to disk`);
} catch (error) {
this.logger.error(
`Failed to persist context ${contextId}: ${error.message}`
);
throw error;
}
}
/**
* Persist a context version to disk
* @param {string} contextId - The context ID
* @param {string} versionId - The version ID
* @returns {Promise<void>}
*/
async persistContextVersion(contextId, versionId) {
if (!this.contextHistory.has(contextId)) {
throw new Error(`Context history for ${contextId} not found`);
}
const history = this.contextHistory.get(contextId);
const version = history.find((v) => v.versionId === versionId);
if (!version) {
throw new Error(`Version ${versionId} of context ${contextId} not found`);
}
const versionPath = path.join(
CONTEXT_DIR,
"versions",
`${contextId}_${versionId}.json`
);
try {
await fs.writeFile(versionPath, JSON.stringify(version, null, 2), "utf8");
this.logger.debug(
`Persisted context version ${contextId}@${versionId} to disk`
);
} catch (error) {
this.logger.error(
`Failed to persist context version ${contextId}@${versionId}: ${error.message}`
);
throw error;
}
}
/**
* Remove a context version file from disk
* @param {string} contextId - The context ID
* @param {string} versionId - The version ID
* @returns {Promise<void>}
*/
async removeContextVersionFile(contextId, versionId) {
const versionPath = path.join(
CONTEXT_DIR,
"versions",
`${contextId}_${versionId}.json`
);
try {
await fs.unlink(versionPath);
this.logger.debug(
`Removed context version file ${contextId}@${versionId}`
);
} catch (error) {
if (error.code !== "ENOENT") {
this.logger.error(
`Failed to remove context version file ${contextId}@${versionId}: ${error.message}`
);
throw error;
}
}
}
/**
* Load a context from disk
* @param {string} contextId - The context ID to load
* @returns {Promise<object>} The loaded context
*/
async loadContextFromDisk(contextId) {
const contextPath = path.join(CONTEXT_DIR, `${contextId}.json`);
try {
const data = await fs.readFile(contextPath, "utf8");
const context = JSON.parse(data);
this.logger.debug(`Loaded context ${contextId} from disk`);
return context;
} catch (error) {
this.logger.error(
`Failed to load context ${contextId} from disk: ${error.message}`
);
throw error;
}
}
/**
* Load context history from disk
* @param {string} contextId - The context ID
* @returns {Promise<Array>} The loaded history
*/
async loadContextHistoryFromDisk(contextId) {
try {
const files = await fs.readdir(path.join(CONTEXT_DIR, "versions"));
const versionFiles = files.filter(
(file) => file.startsWith(`${contextId}_`) && file.endsWith(".json")
);
const history = [];
for (const file of versionFiles) {
try {
const data = await fs.readFile(
path.join(CONTEXT_DIR, "versions", file),
"utf8"
);
const version = JSON.parse(data);
history.push(version);
} catch (error) {
this.logger.error(
`Failed to load context version file ${file}: ${error.message}`
);
}
}
this.contextHistory.set(contextId, history);
this.logger.debug(
`Loaded ${history.length} versions for context ${contextId}`
);
return history;
} catch (error) {
this.logger.error(
`Failed to load context history for ${contextId}: ${error.message}`
);
this.contextHistory.set(contextId, []);
return [];
}
}
/**
* Load all contexts from disk
* @returns {Promise<void>}
*/
async loadAllContextsFromDisk() {
try {
const files = await fs.readdir(CONTEXT_DIR);
const contextFiles = files.filter((file) => file.endsWith(".json"));
for (const file of contextFiles) {
const contextId = path.basename(file, ".json");
if (!this.contexts.has(contextId)) {
try {
const context = await this.loadContextFromDisk(contextId);
this.contexts.set(contextId, context);
} catch (error) {
// Already logged in loadContextFromDisk
}
}
}
this.logger.info(`Loaded ${this.contexts.size} contexts from disk`);
} catch (error) {
this.logger.error(`Failed to load contexts from disk: ${error.message}`);
throw error;
}
}
/**
* Generate a unique version ID
* @returns {string} A unique version ID
*/
generateVersionId() {
return crypto.randomBytes(8).toString("hex");
}
/**
* Estimate the size of context data
* @param {object|string} data - The context data
* @returns {number} Estimated size in bytes
*/
estimateSize(data) {
if (typeof data === "string") {
return Buffer.byteLength(data, "utf8");
}
if (typeof data === "object" && data !== null) {
return Buffer.byteLength(JSON.stringify(data), "utf8");
}
return 0;
}
}
export default ContextManager;

View File

@@ -1,16 +1,10 @@
import { FastMCP } from "fastmcp";
import { z } from "zod";
import path from "path";
import fs from "fs/promises";
import dotenv from "dotenv";
import { fileURLToPath } from "url";
import express from "express";
import cors from "cors";
import helmet from "helmet";
import { logger } from "../../scripts/modules/utils.js";
import MCPAuth from "./auth.js";
import MCPApiHandlers from "./api-handlers.js";
import ContextManager from "./context-manager.js";
import fs from "fs";
import logger from "./logger.js";
import { registerTaskMasterTools } from "./tools/index.js";
// Load environment variables
dotenv.config();
@@ -18,25 +12,27 @@ dotenv.config();
// Constants
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const DEFAULT_PORT = process.env.MCP_SERVER_PORT || 3000;
const DEFAULT_HOST = process.env.MCP_SERVER_HOST || "localhost";
/**
* Main MCP server class that integrates with Task Master
*/
class TaskMasterMCPServer {
constructor(options = {}) {
constructor() {
// Get version from package.json using synchronous fs
const packagePath = path.join(__dirname, "../../package.json");
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
this.options = {
name: "Task Master MCP Server",
version: process.env.PROJECT_VERSION || "1.0.0",
...options,
version: packageJson.version,
};
this.server = new FastMCP(this.options);
this.expressApp = null;
this.initialized = false;
this.auth = new MCPAuth();
this.contextManager = new ContextManager();
// this.server.addResource({});
// this.server.addResourceTemplate({});
// Bind methods
this.init = this.init.bind(this);
@@ -53,301 +49,27 @@ class TaskMasterMCPServer {
async init() {
if (this.initialized) return;
this.logger.info("Initializing Task Master MCP server...");
// Set up express for additional customization if needed
this.expressApp = express();
this.expressApp.use(cors());
this.expressApp.use(helmet());
this.expressApp.use(express.json());
// Set up authentication middleware
this.setupAuthentication();
// Register API handlers
this.apiHandlers = new MCPApiHandlers(this.server);
// Register additional task master specific tools
this.registerTaskMasterTools();
// Register Task Master tools
registerTaskMasterTools(this.server);
this.initialized = true;
this.logger.info("Task Master MCP server initialized successfully");
return this;
}
/**
* Set up authentication for the MCP server
*/
setupAuthentication() {
// Add a health check endpoint that doesn't require authentication
this.expressApp.get("/health", (req, res) => {
res.status(200).json({
status: "ok",
service: this.options.name,
version: this.options.version,
});
});
// Add an authenticate endpoint to get a JWT token using an API key
this.expressApp.post("/auth/token", async (req, res) => {
const apiKey = req.headers["x-api-key"];
if (!apiKey) {
return res.status(401).json({
success: false,
error: "API key is required",
});
}
const keyDetails = this.auth.validateApiKey(apiKey);
if (!keyDetails) {
return res.status(401).json({
success: false,
error: "Invalid API key",
});
}
const token = this.auth.generateToken(keyDetails.id, keyDetails.role);
res.status(200).json({
success: true,
token,
expiresIn: process.env.MCP_JWT_EXPIRATION || "24h",
clientId: keyDetails.id,
role: keyDetails.role,
});
});
// Create authenticator middleware for FastMCP
this.server.setAuthenticator((request) => {
// Get token from Authorization header
const authHeader = request.headers?.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return null;
}
const token = authHeader.split(" ")[1];
const payload = this.auth.verifyToken(token);
if (!payload) {
return null;
}
return {
clientId: payload.clientId,
role: payload.role,
};
});
// Set up a protected route for API key management (admin only)
this.expressApp.post(
"/auth/api-keys",
(req, res, next) => {
this.auth.authenticateToken(req, res, next);
},
(req, res, next) => {
this.auth.authorizeRoles(["admin"])(req, res, next);
},
async (req, res) => {
const { clientId, role } = req.body;
if (!clientId) {
return res.status(400).json({
success: false,
error: "Client ID is required",
});
}
try {
const apiKey = await this.auth.createApiKey(clientId, role || "user");
res.status(201).json({
success: true,
apiKey,
clientId,
role: role || "user",
});
} catch (error) {
this.logger.error(`Error creating API key: ${error.message}`);
res.status(500).json({
success: false,
error: "Failed to create API key",
});
}
}
);
this.logger.info("Set up MCP authentication");
}
/**
* Register Task Master specific tools with the MCP server
*/
registerTaskMasterTools() {
// Add a tool to get tasks from Task Master
this.server.addTool({
name: "listTasks",
description: "List all tasks from Task Master",
parameters: z.object({
status: z.string().optional().describe("Filter tasks by status"),
withSubtasks: z
.boolean()
.optional()
.describe("Include subtasks in the response"),
}),
execute: async (args) => {
try {
// In a real implementation, this would use the Task Master API
// to fetch tasks. For now, returning mock data.
this.logger.info(
`Listing tasks with filters: ${JSON.stringify(args)}`
);
// Mock task data
const tasks = [
{
id: 1,
title: "Implement Task Data Structure",
status: "done",
dependencies: [],
priority: "high",
},
{
id: 2,
title: "Develop Command Line Interface Foundation",
status: "done",
dependencies: [1],
priority: "high",
},
{
id: 23,
title: "Implement MCP Server Functionality",
status: "in-progress",
dependencies: [22],
priority: "medium",
subtasks: [
{
id: "23.1",
title: "Create Core MCP Server Module",
status: "in-progress",
dependencies: [],
},
{
id: "23.2",
title: "Implement Context Management System",
status: "pending",
dependencies: ["23.1"],
},
],
},
];
// Apply status filter if provided
let filteredTasks = tasks;
if (args.status) {
filteredTasks = tasks.filter((task) => task.status === args.status);
}
// Remove subtasks if not requested
if (!args.withSubtasks) {
filteredTasks = filteredTasks.map((task) => {
const { subtasks, ...taskWithoutSubtasks } = task;
return taskWithoutSubtasks;
});
}
return { success: true, tasks: filteredTasks };
} catch (error) {
this.logger.error(`Error listing tasks: ${error.message}`);
return { success: false, error: error.message };
}
},
});
// Add a tool to get task details
this.server.addTool({
name: "getTaskDetails",
description: "Get detailed information about a specific task",
parameters: z.object({
taskId: z
.union([z.number(), z.string()])
.describe("The ID of the task to get details for"),
}),
execute: async (args) => {
try {
// In a real implementation, this would use the Task Master API
// to fetch task details. For now, returning mock data.
this.logger.info(`Getting details for task ${args.taskId}`);
// Mock task details
const taskDetails = {
id: 23,
title: "Implement MCP Server Functionality",
description:
"Extend Task Master to function as an MCP server, allowing it to provide context management services to other applications.",
status: "in-progress",
dependencies: [22],
priority: "medium",
details:
"This task involves implementing the Model Context Protocol server capabilities within Task Master.",
testStrategy:
"Testing should include unit tests, integration tests, and compatibility tests.",
subtasks: [
{
id: "23.1",
title: "Create Core MCP Server Module",
status: "in-progress",
dependencies: [],
},
{
id: "23.2",
title: "Implement Context Management System",
status: "pending",
dependencies: ["23.1"],
},
],
};
return { success: true, task: taskDetails };
} catch (error) {
this.logger.error(`Error getting task details: ${error.message}`);
return { success: false, error: error.message };
}
},
});
this.logger.info("Registered Task Master specific tools");
}
/**
* Start the MCP server
*/
async start({ port = DEFAULT_PORT, host = DEFAULT_HOST } = {}) {
async start() {
if (!this.initialized) {
await this.init();
}
this.logger.info(
`Starting Task Master MCP server on http://${host}:${port}`
);
// Start the FastMCP server
await this.server.start({
port,
host,
transportType: "sse",
expressApp: this.expressApp,
transportType: "stdio",
});
this.logger.info(
`Task Master MCP server running at http://${host}:${port}`
);
return this;
}
@@ -356,9 +78,7 @@ class TaskMasterMCPServer {
*/
async stop() {
if (this.server) {
this.logger.info("Stopping Task Master MCP server...");
await this.server.stop();
this.logger.info("Task Master MCP server stopped");
}
}
}

68
mcp-server/src/logger.js Normal file
View File

@@ -0,0 +1,68 @@
import chalk from "chalk";
// Define log levels
const LOG_LEVELS = {
debug: 0,
info: 1,
warn: 2,
error: 3,
success: 4,
};
// Get log level from environment or default to info
const LOG_LEVEL = process.env.LOG_LEVEL
? LOG_LEVELS[process.env.LOG_LEVEL.toLowerCase()]
: LOG_LEVELS.info;
/**
* Logs a message with the specified level
* @param {string} level - The log level (debug, info, warn, error, success)
* @param {...any} args - Arguments to log
*/
function log(level, ...args) {
const icons = {
debug: chalk.gray("🔍"),
info: chalk.blue(""),
warn: chalk.yellow("⚠️"),
error: chalk.red("❌"),
success: chalk.green("✅"),
};
if (LOG_LEVELS[level] >= LOG_LEVEL) {
const icon = icons[level] || "";
if (level === "error") {
console.error(icon, chalk.red(...args));
} else if (level === "warn") {
console.warn(icon, chalk.yellow(...args));
} else if (level === "success") {
console.log(icon, chalk.green(...args));
} else if (level === "info") {
console.log(icon, chalk.blue(...args));
} else {
console.log(icon, ...args);
}
}
}
/**
* Create a logger object with methods for different log levels
* Can be used as a drop-in replacement for existing logger initialization
* @returns {Object} Logger object with info, error, debug, warn, and success methods
*/
export function createLogger() {
return {
debug: (message) => log("debug", message),
info: (message) => log("info", message),
warn: (message) => log("warn", message),
error: (message) => log("error", message),
success: (message) => log("success", message),
log: log, // Also expose the raw log function
};
}
// Export a default logger instance
const logger = createLogger();
export default logger;
export { log, LOG_LEVELS };

View File

@@ -0,0 +1,56 @@
/**
* tools/addTask.js
* Tool to add a new task using AI
*/
import { z } from "zod";
import {
executeTaskMasterCommand,
createContentResponse,
createErrorResponse,
} from "./utils.js";
/**
* Register the addTask tool with the MCP server
* @param {FastMCP} server - FastMCP server instance
*/
export function registerAddTaskTool(server) {
server.addTool({
name: "addTask",
description: "Add a new task using AI",
parameters: z.object({
prompt: z.string().describe("Description of the task to add"),
dependencies: z
.string()
.optional()
.describe("Comma-separated list of task IDs this task depends on"),
priority: z
.string()
.optional()
.describe("Task priority (high, medium, low)"),
file: z.string().optional().describe("Path to the tasks file"),
}),
execute: async (args, { log }) => {
try {
log.info(`Adding new task: ${args.prompt}`);
const cmdArgs = [`--prompt="${args.prompt}"`];
if (args.dependencies)
cmdArgs.push(`--dependencies=${args.dependencies}`);
if (args.priority) cmdArgs.push(`--priority=${args.priority}`);
if (args.file) cmdArgs.push(`--file=${args.file}`);
const result = executeTaskMasterCommand("add-task", log, cmdArgs);
if (!result.success) {
throw new Error(result.error);
}
return createContentResponse(result.stdout);
} catch (error) {
log.error(`Error adding task: ${error.message}`);
return createErrorResponse(`Error adding task: ${error.message}`);
}
},
});
}

View File

@@ -0,0 +1,66 @@
/**
* tools/expandTask.js
* Tool to break down a task into detailed subtasks
*/
import { z } from "zod";
import {
executeTaskMasterCommand,
createContentResponse,
createErrorResponse,
} from "./utils.js";
/**
* Register the expandTask tool with the MCP server
* @param {Object} server - FastMCP server instance
*/
export function registerExpandTaskTool(server) {
server.addTool({
name: "expandTask",
description: "Break down a task into detailed subtasks",
parameters: z.object({
id: z.union([z.string(), z.number()]).describe("Task ID to expand"),
num: z.number().optional().describe("Number of subtasks to generate"),
research: z
.boolean()
.optional()
.describe(
"Enable Perplexity AI for research-backed subtask generation"
),
prompt: z
.string()
.optional()
.describe("Additional context to guide subtask generation"),
force: z
.boolean()
.optional()
.describe(
"Force regeneration of subtasks for tasks that already have them"
),
file: z.string().optional().describe("Path to the tasks file"),
}),
execute: async (args, { log }) => {
try {
log.info(`Expanding task ${args.id}`);
const cmdArgs = [`--id=${args.id}`];
if (args.num) cmdArgs.push(`--num=${args.num}`);
if (args.research) cmdArgs.push("--research");
if (args.prompt) cmdArgs.push(`--prompt="${args.prompt}"`);
if (args.force) cmdArgs.push("--force");
if (args.file) cmdArgs.push(`--file=${args.file}`);
const result = executeTaskMasterCommand("expand", log, cmdArgs);
if (!result.success) {
throw new Error(result.error);
}
return createContentResponse(result.stdout);
} catch (error) {
log.error(`Error expanding task: ${error.message}`);
return createErrorResponse(`Error expanding task: ${error.message}`);
}
},
});
}

View File

@@ -0,0 +1,29 @@
/**
* tools/index.js
* Export all Task Master CLI tools for MCP server
*/
import logger from "../logger.js";
import { registerListTasksTool } from "./listTasks.js";
import { registerShowTaskTool } from "./showTask.js";
import { registerSetTaskStatusTool } from "./setTaskStatus.js";
import { registerExpandTaskTool } from "./expandTask.js";
import { registerNextTaskTool } from "./nextTask.js";
import { registerAddTaskTool } from "./addTask.js";
/**
* Register all Task Master tools with the MCP server
* @param {Object} server - FastMCP server instance
*/
export function registerTaskMasterTools(server) {
registerListTasksTool(server);
registerShowTaskTool(server);
registerSetTaskStatusTool(server);
registerExpandTaskTool(server);
registerNextTaskTool(server);
registerAddTaskTool(server);
}
export default {
registerTaskMasterTools,
};

View File

@@ -0,0 +1,51 @@
/**
* tools/listTasks.js
* Tool to list all tasks from Task Master
*/
import { z } from "zod";
import {
executeTaskMasterCommand,
createContentResponse,
createErrorResponse,
} from "./utils.js";
/**
* Register the listTasks tool with the MCP server
* @param {Object} server - FastMCP server instance
*/
export function registerListTasksTool(server) {
server.addTool({
name: "listTasks",
description: "List all tasks from Task Master",
parameters: z.object({
status: z.string().optional().describe("Filter tasks by status"),
withSubtasks: z
.boolean()
.optional()
.describe("Include subtasks in the response"),
file: z.string().optional().describe("Path to the tasks file"),
}),
execute: async (args, { log }) => {
try {
log.info(`Listing tasks with filters: ${JSON.stringify(args)}`);
const cmdArgs = [];
if (args.status) cmdArgs.push(`--status=${args.status}`);
if (args.withSubtasks) cmdArgs.push("--with-subtasks");
if (args.file) cmdArgs.push(`--file=${args.file}`);
const result = executeTaskMasterCommand("list", log, cmdArgs);
if (!result.success) {
throw new Error(result.error);
}
return createContentResponse(result.stdout);
} catch (error) {
log.error(`Error listing tasks: ${error.message}`);
return createErrorResponse(`Error listing tasks: ${error.message}`);
}
},
});
}

View File

@@ -0,0 +1,45 @@
/**
* tools/nextTask.js
* Tool to show the next task to work on based on dependencies and status
*/
import { z } from "zod";
import {
executeTaskMasterCommand,
createContentResponse,
createErrorResponse,
} from "./utils.js";
/**
* Register the nextTask tool with the MCP server
* @param {Object} server - FastMCP server instance
*/
export function registerNextTaskTool(server) {
server.addTool({
name: "nextTask",
description:
"Show the next task to work on based on dependencies and status",
parameters: z.object({
file: z.string().optional().describe("Path to the tasks file"),
}),
execute: async (args, { log }) => {
try {
log.info(`Finding next task to work on`);
const cmdArgs = [];
if (args.file) cmdArgs.push(`--file=${args.file}`);
const result = executeTaskMasterCommand("next", log, cmdArgs);
if (!result.success) {
throw new Error(result.error);
}
return createContentResponse(result.stdout);
} catch (error) {
log.error(`Error finding next task: ${error.message}`);
return createErrorResponse(`Error finding next task: ${error.message}`);
}
},
});
}

View File

@@ -0,0 +1,52 @@
/**
* tools/setTaskStatus.js
* Tool to set the status of a task
*/
import { z } from "zod";
import {
executeTaskMasterCommand,
createContentResponse,
createErrorResponse,
} from "./utils.js";
/**
* Register the setTaskStatus tool with the MCP server
* @param {Object} server - FastMCP server instance
*/
export function registerSetTaskStatusTool(server) {
server.addTool({
name: "setTaskStatus",
description: "Set the status of a task",
parameters: z.object({
id: z
.union([z.string(), z.number()])
.describe("Task ID (can be comma-separated for multiple tasks)"),
status: z
.string()
.describe("New status (todo, in-progress, review, done)"),
file: z.string().optional().describe("Path to the tasks file"),
}),
execute: async (args, { log }) => {
try {
log.info(`Setting status of task(s) ${args.id} to: ${args.status}`);
const cmdArgs = [`--id=${args.id}`, `--status=${args.status}`];
if (args.file) cmdArgs.push(`--file=${args.file}`);
const result = executeTaskMasterCommand("set-status", log, cmdArgs);
if (!result.success) {
throw new Error(result.error);
}
return createContentResponse(result.stdout);
} catch (error) {
log.error(`Error setting task status: ${error.message}`);
return createErrorResponse(
`Error setting task status: ${error.message}`
);
}
},
});
}

View File

@@ -0,0 +1,45 @@
/**
* tools/showTask.js
* Tool to show detailed information about a specific task
*/
import { z } from "zod";
import {
executeTaskMasterCommand,
createContentResponse,
createErrorResponse,
} from "./utils.js";
/**
* Register the showTask tool with the MCP server
* @param {Object} server - FastMCP server instance
*/
export function registerShowTaskTool(server) {
server.addTool({
name: "showTask",
description: "Show detailed information about a specific task",
parameters: z.object({
id: z.union([z.string(), z.number()]).describe("Task ID to show"),
file: z.string().optional().describe("Path to the tasks file"),
}),
execute: async (args, { log }) => {
try {
log.info(`Showing task details for ID: ${args.id}`);
const cmdArgs = [args.id];
if (args.file) cmdArgs.push(`--file=${args.file}`);
const result = executeTaskMasterCommand("show", log, cmdArgs);
if (!result.success) {
throw new Error(result.error);
}
return createContentResponse(result.stdout);
} catch (error) {
log.error(`Error showing task: ${error.message}`);
return createErrorResponse(`Error showing task: ${error.message}`);
}
},
});
}

View File

@@ -0,0 +1,90 @@
/**
* tools/utils.js
* Utility functions for Task Master CLI integration
*/
import { spawnSync } from "child_process";
/**
* Execute a Task Master CLI command using child_process
* @param {string} command - The command to execute
* @param {Object} log - The logger object from FastMCP
* @param {Array} args - Arguments for the command
* @returns {Object} - The result of the command execution
*/
export function executeTaskMasterCommand(command, log, args = []) {
try {
log.info(
`Executing task-master ${command} with args: ${JSON.stringify(args)}`
);
// Prepare full arguments array
const fullArgs = [command, ...args];
// Execute the command using the global task-master CLI or local script
// Try the global CLI first
let result = spawnSync("task-master", fullArgs, { encoding: "utf8" });
// If global CLI is not available, try fallback to the local script
if (result.error && result.error.code === "ENOENT") {
log.info("Global task-master not found, falling back to local script");
result = spawnSync("node", ["scripts/dev.js", ...fullArgs], {
encoding: "utf8",
});
}
if (result.error) {
throw new Error(`Command execution error: ${result.error.message}`);
}
if (result.status !== 0) {
throw new Error(
`Command failed with exit code ${result.status}: ${result.stderr}`
);
}
return {
success: true,
stdout: result.stdout,
stderr: result.stderr,
};
} catch (error) {
log.error(`Error executing task-master command: ${error.message}`);
return {
success: false,
error: error.message,
};
}
}
/**
* Creates standard content response for tools
* @param {string} text - Text content to include in response
* @returns {Object} - Content response object
*/
export function createContentResponse(text) {
return {
content: [
{
text,
type: "text",
},
],
};
}
/**
* Creates error response for tools
* @param {string} errorMessage - Error message to include in response
* @returns {Object} - Error content response object
*/
export function createErrorResponse(errorMessage) {
return {
content: [
{
text: errorMessage,
type: "text",
},
],
};
}