diff --git a/.cursor/rules/mcp.mdc b/.cursor/rules/mcp.mdc index a06fc102..d1ea7953 100644 --- a/.cursor/rules/mcp.mdc +++ b/.cursor/rules/mcp.mdc @@ -15,6 +15,270 @@ The MCP server acts as a bridge between external tools (like Cursor) and the cor - **Flow**: `External Tool (Cursor)` <-> `FastMCP Server` <-> `MCP Tools` (`mcp-server/src/tools/*.js`) <-> `Core Logic Wrappers` (`mcp-server/src/core/direct-functions/*.js`, exported via `task-master-core.js`) <-> `Core Modules` (`scripts/modules/*.js`) - **Goal**: Provide a performant and reliable way for external tools to interact with Task Master functionality without directly invoking the CLI for every operation. +## Tool Definition and Execution + +### Tool Structure + +MCP tools must follow a specific structure to properly interact with the FastMCP framework: + +```javascript +server.addTool({ + name: "tool_name", // Use snake_case for tool names + description: "Description of what the tool does", + parameters: z.object({ + // Define parameters using Zod + param1: z.string().describe("Parameter description"), + param2: z.number().optional().describe("Optional parameter description"), + // IMPORTANT: For file operations, always include these optional parameters + file: z.string().optional().describe("Path to the tasks file"), + projectRoot: z.string().optional().describe("Root directory of the project") + }), + + // The execute function is the core of the tool implementation + execute: async (args, context) => { + // Implementation goes here + // Return response in the appropriate format + } +}); +``` + +### Execute Function Signature + +The `execute` function should follow this exact pattern: + +```javascript +execute: async (args, context) => { + // Tool implementation +} +``` + +- **args**: The first parameter contains all the validated parameters defined in the tool's schema + - You can destructure specific parameters: `const { param1, param2, file, projectRoot } = args;` + - Always pass the full `args` object to direct functions: `await yourDirectFunction(args, context.log);` + +- **context**: The second parameter is an object with specific properties provided by FastMCP + - Contains `{ log, reportProgress, session }` - **always destructure these from the context object** + - ✅ **DO**: `execute: async (args, { log, reportProgress, session }) => {}` + - ❌ **DON'T**: `execute: async (args, log) => {}` + +### Logging Convention + +The `log` object provides standardized logging methods: + +```javascript +// Proper logging usage within a tool's execute method +execute: async (args, { log, reportProgress, session }) => { + try { + // Log the start of execution with key parameters (but sanitize sensitive data) + log.info(`Starting ${toolName} with parameters: ${JSON.stringify(args, null, 2)}`); + + // For debugging information + log.debug("Detailed operation information", { additionalContext: "value" }); + + // For warnings that don't prevent execution + log.warn("Warning: potential issue detected", { details: "..." }); + + // Call the direct function and handle the result + const result = await someDirectFunction(args, log); + + // Log successful completion + log.info(`Successfully completed ${toolName}`, { + resultSummary: "brief summary without sensitive data" + }); + + return handleApiResult(result, log); + } catch (error) { + // Log errors with full context + log.error(`Error in ${toolName}: ${error.message}`, { + errorDetails: error.stack + }); + + return createErrorResponse(error.message, "ERROR_CODE"); + } +} +``` + +### Progress Reporting Convention + +Use `reportProgress` for long-running operations to provide feedback to the client: + +```javascript +execute: async (args, { log, reportProgress, session }) => { + // Initialize progress at the start + await reportProgress({ progress: 0, total: 100 }); + + // For known progress stages, update with specific percentages + for (let i = 0; i < stages.length; i++) { + // Do some work... + + // Report intermediate progress + await reportProgress({ + progress: Math.floor((i + 1) / stages.length * 100), + total: 100 + }); + } + + // For indeterminate progress (no known total) + await reportProgress({ progress: 1 }); // Just increment without total + + // When complete + await reportProgress({ progress: 100, total: 100 }); + + // Return the result + return result; +} +``` + +FYI reportProgress object is not arbitrary. It must be { progress: number, total: number }. + +### Session Usage Convention + +The `session` object contains authenticated session data and can be used for: + +1. **Authentication information**: Access user-specific data that was set during authentication +2. **Environment variables**: Access environment variables without direct process.env references (if implemented) +3. **User context**: Check user permissions or preferences + +```javascript +execute: async (args, { log, reportProgress, session }) => { + // Check if session exists (user is authenticated) + if (!session) { + log.warn("No session data available, operating in anonymous mode"); + } else { + // Access authenticated user information + log.info(`Operation requested by user: ${session.userId}`); + + // Access environment variables or configuration via session + const apiKey = session.env?.API_KEY; + + // Check user-specific permissions + if (session.permissions?.canUpdateTasks) { + // Perform privileged operation + } + } + + // Continue with implementation... +} +``` + +### Accessing Project Roots through Session + +The `session` object in FastMCP provides access to filesystem roots via the `session.roots` array, which can be used to get the client's project root directory. This can help bypass some of the path resolution logic in `path-utils.js` when the client explicitly provides its project root. + +#### What are Session Roots? + +- The `roots` array contains filesystem root objects provided by the FastMCP client (e.g., Cursor). +- Each root object typically represents a mounted filesystem or workspace that the client (IDE) has access to. +- For tools like Cursor, the first root is usually the current project or workspace root. + +#### Roots Structure + +Based on the FastMCP core implementation, the roots structure looks like: + +```javascript +// Root type from FastMCP +type Root = { + uri: string; // URI of the root, e.g., "file:///Users/username/project" + name: string; // Display name of the root + capabilities?: { // Optional capabilities this root supports + // Root-specific capabilities + } +}; +``` + +#### Accessing Project Root from Session + +To properly access and use the project root from the session: + +```javascript +execute: async (args, { log, reportProgress, session }) => { + try { + // Try to get the project root from session + let rootFolder = null; + + if (session && session.roots && session.roots.length > 0) { + // The first root is typically the main project workspace in clients like Cursor + const firstRoot = session.roots[0]; + + if (firstRoot && firstRoot.uri) { + // Convert the URI to a file path (strip the file:// prefix if present) + const rootUri = firstRoot.uri; + rootFolder = rootUri.startsWith('file://') + ? decodeURIComponent(rootUri.slice(7)) // Remove 'file://' and decode URI components + : rootUri; + + log.info(`Using project root from session: ${rootFolder}`); + } + } + + // Use rootFolder if available, otherwise let path-utils.js handle the detection + const result = await yourDirectFunction({ + projectRoot: rootFolder, + ...args + }, log); + + return handleApiResult(result, log); + } catch (error) { + log.error(`Error in tool: ${error.message}`); + return createErrorResponse(error.message); + } +} +``` + +#### Integration with path-utils.js + +The `rootFolder` extracted from the session should be passed to the direct function as the `projectRoot` parameter. This integrates with `findTasksJsonPath` in `path-utils.js`, which uses a precedence order for finding the project root: + +1. **TASK_MASTER_PROJECT_ROOT** environment variable +2. **Explicitly provided `projectRoot`** (from session.roots or args) +3. Previously found/cached project root +4. Search from current directory upwards +5. Package directory fallback + +By providing the rootFolder from session.roots as the projectRoot parameter, we're using the second option in this hierarchy, allowing the client to explicitly tell us where the project is located rather than having to search for it. + +#### Example Implementation + +Here's a complete example for a tool that properly uses session roots: + +```javascript +execute: async (args, { log, reportProgress, session }) => { + try { + log.info(`Starting tool with args: ${JSON.stringify(args)}`); + + // Extract project root from session if available + let rootFolder = null; + if (session && session.roots && session.roots.length > 0) { + const firstRoot = session.roots[0]; + if (firstRoot && firstRoot.uri) { + rootFolder = firstRoot.uri.startsWith('file://') + ? decodeURIComponent(firstRoot.uri.slice(7)) + : firstRoot.uri; + log.info(`Using project root from session: ${rootFolder}`); + } + } + + // If we couldn't get a root from session but args has projectRoot, use that + if (!rootFolder && args.projectRoot) { + rootFolder = args.projectRoot; + log.info(`Using project root from args: ${rootFolder}`); + } + + // Call the direct function with the rootFolder + const result = await someDirectFunction({ + projectRoot: rootFolder, + ...args + }, log); + + log.info(`Completed tool successfully`); + return handleApiResult(result, log); + } catch (error) { + log.error(`Error in tool: ${error.message}`); + return createErrorResponse(error.message); + } +} +``` + ## Key Principles - **Prefer Direct Function Calls**: For optimal performance and error handling, MCP tools should utilize direct function wrappers (exported via [`task-master-core.js`](mdc:mcp-server/src/core/task-master-core.js), implemented in [`mcp-server/src/core/direct-functions/`](mdc:mcp-server/src/core/direct-functions/)). These wrappers call the underlying logic from the core modules (e.g., [`task-manager.js`](mdc:scripts/modules/task-manager.js)). @@ -208,7 +472,7 @@ Follow these steps to add MCP support for an existing Task Master command (see [ 1. **Ensure Core Logic Exists**: Verify the core functionality is implemented and exported from the relevant module in `scripts/modules/`. -2. **Create Direct Function File in `mcp-server/src/core/direct-functions/`**: +2. **Create Direct Function File in `mcp-server/src/core/direct-functions/`: - Create a new file (e.g., `your-command.js`) in the `direct-functions` directory using **kebab-case** for file naming. - Import necessary core functions from Task Master modules. - Import utilities: **`findTasksJsonPath` from `../utils/path-utils.js`** and `getCachedOrExecute` from `../../tools/utils.js` if needed. @@ -228,7 +492,7 @@ Follow these steps to add MCP support for an existing Task Master command (see [ - Implement `registerYourCommandTool(server)`. - Define the tool `name` using **snake_case** (e.g., `your_command`). - Define the `parameters` using `zod`. **Crucially, if the tool needs project context, include `projectRoot: z.string().optional().describe(...)` and potentially `file: z.string().optional().describe(...)`**. Make `projectRoot` optional. - - Implement the standard `async execute(args, log)` method: call `yourCommandDirect(args, log)` and pass the result to `handleApiResult(result, log, 'Error Message')`. + - Implement the standard `async execute(args, { log, reportProgress, session })` method: call `yourCommandDirect(args, log)` and pass the result to `handleApiResult(result, log, 'Error Message')`. 5. **Register Tool**: Import and call `registerYourCommandTool` in `mcp-server/src/tools/index.js`. @@ -318,9 +582,9 @@ Follow these steps to add MCP support for an existing Task Master command (see [ - ❌ DON'T: Log entire large data structures or sensitive information - The MCP server integrates with Task Master core functions through three layers: - 1. Tool Definitions (`mcp-server/src/tools/*.js`) - Define parameters and execute methods - 2. Direct Function Wrappers (`task-master-core.js`) - Handle validation, path resolution, and caching - 3. Core Logic Functions (various modules) - Implement actual functionality + 1. Tool Definitions (`mcp-server/src/tools/*.js`) - Define parameters and validation + 2. Direct Functions (`mcp-server/src/core/direct-functions/*.js`) - Handle core logic integration + 3. Core Functions (`scripts/modules/*.js`) - Implement the actual functionality - This layered approach provides: - Clear separation of concerns diff --git a/docs/fastmcp-core.txt b/docs/fastmcp-core.txt new file mode 100644 index 00000000..553a6056 --- /dev/null +++ b/docs/fastmcp-core.txt @@ -0,0 +1,1179 @@ +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { + CallToolRequestSchema, + ClientCapabilities, + CompleteRequestSchema, + CreateMessageRequestSchema, + ErrorCode, + GetPromptRequestSchema, + ListPromptsRequestSchema, + ListResourcesRequestSchema, + ListResourceTemplatesRequestSchema, + ListToolsRequestSchema, + McpError, + ReadResourceRequestSchema, + Root, + RootsListChangedNotificationSchema, + ServerCapabilities, + SetLevelRequestSchema, +} from "@modelcontextprotocol/sdk/types.js"; +import { zodToJsonSchema } from "zod-to-json-schema"; +import { z } from "zod"; +import { setTimeout as delay } from "timers/promises"; +import { readFile } from "fs/promises"; +import { fileTypeFromBuffer } from "file-type"; +import { StrictEventEmitter } from "strict-event-emitter-types"; +import { EventEmitter } from "events"; +import Fuse from "fuse.js"; +import { startSSEServer } from "mcp-proxy"; +import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +import parseURITemplate from "uri-templates"; +import http from "http"; +import { + fetch +} from "undici"; + +export type SSEServer = { + close: () => Promise; +}; + +type FastMCPEvents = { + connect: (event: { session: FastMCPSession }) => void; + disconnect: (event: { session: FastMCPSession }) => void; +}; + +type FastMCPSessionEvents = { + rootsChanged: (event: { roots: Root[] }) => void; + error: (event: { error: Error }) => void; +}; + +/** + * Generates an image content object from a URL, file path, or buffer. + */ +export const imageContent = async ( + input: { url: string } | { path: string } | { buffer: Buffer }, +): Promise => { + let rawData: Buffer; + + if ("url" in input) { + const response = await fetch(input.url); + + if (!response.ok) { + throw new Error(`Failed to fetch image from URL: ${response.statusText}`); + } + + rawData = Buffer.from(await response.arrayBuffer()); + } else if ("path" in input) { + rawData = await readFile(input.path); + } else if ("buffer" in input) { + rawData = input.buffer; + } else { + throw new Error( + "Invalid input: Provide a valid 'url', 'path', or 'buffer'", + ); + } + + const mimeType = await fileTypeFromBuffer(rawData); + + const base64Data = rawData.toString("base64"); + + return { + type: "image", + data: base64Data, + mimeType: mimeType?.mime ?? "image/png", + } as const; +}; + +abstract class FastMCPError extends Error { + public constructor(message?: string) { + super(message); + this.name = new.target.name; + } +} + +type Extra = unknown; + +type Extras = Record; + +export class UnexpectedStateError extends FastMCPError { + public extras?: Extras; + + public constructor(message: string, extras?: Extras) { + super(message); + this.name = new.target.name; + this.extras = extras; + } +} + +/** + * An error that is meant to be surfaced to the user. + */ +export class UserError extends UnexpectedStateError {} + +type ToolParameters = z.ZodTypeAny; + +type Literal = boolean | null | number | string | undefined; + +type SerializableValue = + | Literal + | SerializableValue[] + | { [key: string]: SerializableValue }; + +type Progress = { + /** + * The progress thus far. This should increase every time progress is made, even if the total is unknown. + */ + progress: number; + /** + * Total number of items to process (or total progress required), if known. + */ + total?: number; +}; + +type Context = { + session: T | undefined; + reportProgress: (progress: Progress) => Promise; + log: { + debug: (message: string, data?: SerializableValue) => void; + error: (message: string, data?: SerializableValue) => void; + info: (message: string, data?: SerializableValue) => void; + warn: (message: string, data?: SerializableValue) => void; + }; +}; + +type TextContent = { + type: "text"; + text: string; +}; + +const TextContentZodSchema = z + .object({ + type: z.literal("text"), + /** + * The text content of the message. + */ + text: z.string(), + }) + .strict() satisfies z.ZodType; + +type ImageContent = { + type: "image"; + data: string; + mimeType: string; +}; + +const ImageContentZodSchema = z + .object({ + type: z.literal("image"), + /** + * The base64-encoded image data. + */ + data: z.string().base64(), + /** + * The MIME type of the image. Different providers may support different image types. + */ + mimeType: z.string(), + }) + .strict() satisfies z.ZodType; + +type Content = TextContent | ImageContent; + +const ContentZodSchema = z.discriminatedUnion("type", [ + TextContentZodSchema, + ImageContentZodSchema, +]) satisfies z.ZodType; + +type ContentResult = { + content: Content[]; + isError?: boolean; +}; + +const ContentResultZodSchema = z + .object({ + content: ContentZodSchema.array(), + isError: z.boolean().optional(), + }) + .strict() satisfies z.ZodType; + +type Completion = { + values: string[]; + total?: number; + hasMore?: boolean; +}; + +/** + * https://github.com/modelcontextprotocol/typescript-sdk/blob/3164da64d085ec4e022ae881329eee7b72f208d4/src/types.ts#L983-L1003 + */ +const CompletionZodSchema = z.object({ + /** + * An array of completion values. Must not exceed 100 items. + */ + values: z.array(z.string()).max(100), + /** + * The total number of completion options available. This can exceed the number of values actually sent in the response. + */ + total: z.optional(z.number().int()), + /** + * Indicates whether there are additional completion options beyond those provided in the current response, even if the exact total is unknown. + */ + hasMore: z.optional(z.boolean()), +}) satisfies z.ZodType; + +type Tool = { + name: string; + description?: string; + parameters?: Params; + execute: ( + args: z.infer, + context: Context, + ) => Promise; +}; + +type ResourceResult = + | { + text: string; + } + | { + blob: string; + }; + +type InputResourceTemplateArgument = Readonly<{ + name: string; + description?: string; + complete?: ArgumentValueCompleter; +}>; + +type ResourceTemplateArgument = Readonly<{ + name: string; + description?: string; + complete?: ArgumentValueCompleter; +}>; + +type ResourceTemplate< + Arguments extends ResourceTemplateArgument[] = ResourceTemplateArgument[], +> = { + uriTemplate: string; + name: string; + description?: string; + mimeType?: string; + arguments: Arguments; + complete?: (name: string, value: string) => Promise; + load: ( + args: ResourceTemplateArgumentsToObject, + ) => Promise; +}; + +type ResourceTemplateArgumentsToObject = { + [K in T[number]["name"]]: string; +}; + +type InputResourceTemplate< + Arguments extends ResourceTemplateArgument[] = ResourceTemplateArgument[], +> = { + uriTemplate: string; + name: string; + description?: string; + mimeType?: string; + arguments: Arguments; + load: ( + args: ResourceTemplateArgumentsToObject, + ) => Promise; +}; + +type Resource = { + uri: string; + name: string; + description?: string; + mimeType?: string; + load: () => Promise; + complete?: (name: string, value: string) => Promise; +}; + +type ArgumentValueCompleter = (value: string) => Promise; + +type InputPromptArgument = Readonly<{ + name: string; + description?: string; + required?: boolean; + complete?: ArgumentValueCompleter; + enum?: string[]; +}>; + +type PromptArgumentsToObject = + { + [K in T[number]["name"]]: Extract< + T[number], + { name: K } + >["required"] extends true + ? string + : string | undefined; + }; + +type InputPrompt< + Arguments extends InputPromptArgument[] = InputPromptArgument[], + Args = PromptArgumentsToObject, +> = { + name: string; + description?: string; + arguments?: InputPromptArgument[]; + load: (args: Args) => Promise; +}; + +type PromptArgument = Readonly<{ + name: string; + description?: string; + required?: boolean; + complete?: ArgumentValueCompleter; + enum?: string[]; +}>; + +type Prompt< + Arguments extends PromptArgument[] = PromptArgument[], + Args = PromptArgumentsToObject, +> = { + arguments?: PromptArgument[]; + complete?: (name: string, value: string) => Promise; + description?: string; + load: (args: Args) => Promise; + name: string; +}; + +type ServerOptions = { + name: string; + version: `${number}.${number}.${number}`; + authenticate?: Authenticate; +}; + +type LoggingLevel = + | "debug" + | "info" + | "notice" + | "warning" + | "error" + | "critical" + | "alert" + | "emergency"; + +const FastMCPSessionEventEmitterBase: { + new (): StrictEventEmitter; +} = EventEmitter; + +class FastMCPSessionEventEmitter extends FastMCPSessionEventEmitterBase {} + +type SamplingResponse = { + model: string; + stopReason?: "endTurn" | "stopSequence" | "maxTokens" | string; + role: "user" | "assistant"; + content: TextContent | ImageContent; +}; + +type FastMCPSessionAuth = Record | undefined; + +export class FastMCPSession extends FastMCPSessionEventEmitter { + #capabilities: ServerCapabilities = {}; + #clientCapabilities?: ClientCapabilities; + #loggingLevel: LoggingLevel = "info"; + #prompts: Prompt[] = []; + #resources: Resource[] = []; + #resourceTemplates: ResourceTemplate[] = []; + #roots: Root[] = []; + #server: Server; + #auth: T | undefined; + + constructor({ + auth, + name, + version, + tools, + resources, + resourcesTemplates, + prompts, + }: { + auth?: T; + name: string; + version: string; + tools: Tool[]; + resources: Resource[]; + resourcesTemplates: InputResourceTemplate[]; + prompts: Prompt[]; + }) { + super(); + + this.#auth = auth; + + if (tools.length) { + this.#capabilities.tools = {}; + } + + if (resources.length || resourcesTemplates.length) { + this.#capabilities.resources = {}; + } + + if (prompts.length) { + for (const prompt of prompts) { + this.addPrompt(prompt); + } + + this.#capabilities.prompts = {}; + } + + this.#capabilities.logging = {}; + + this.#server = new Server( + { name: name, version: version }, + { capabilities: this.#capabilities }, + ); + + this.setupErrorHandling(); + this.setupLoggingHandlers(); + this.setupRootsHandlers(); + this.setupCompleteHandlers(); + + if (tools.length) { + this.setupToolHandlers(tools); + } + + if (resources.length || resourcesTemplates.length) { + for (const resource of resources) { + this.addResource(resource); + } + + this.setupResourceHandlers(resources); + + if (resourcesTemplates.length) { + for (const resourceTemplate of resourcesTemplates) { + this.addResourceTemplate(resourceTemplate); + } + + this.setupResourceTemplateHandlers(resourcesTemplates); + } + } + + if (prompts.length) { + this.setupPromptHandlers(prompts); + } + } + + private addResource(inputResource: Resource) { + this.#resources.push(inputResource); + } + + private addResourceTemplate(inputResourceTemplate: InputResourceTemplate) { + const completers: Record = {}; + + for (const argument of inputResourceTemplate.arguments ?? []) { + if (argument.complete) { + completers[argument.name] = argument.complete; + } + } + + const resourceTemplate = { + ...inputResourceTemplate, + complete: async (name: string, value: string) => { + if (completers[name]) { + return await completers[name](value); + } + + return { + values: [], + }; + }, + }; + + this.#resourceTemplates.push(resourceTemplate); + } + + private addPrompt(inputPrompt: InputPrompt) { + const completers: Record = {}; + const enums: Record = {}; + + for (const argument of inputPrompt.arguments ?? []) { + if (argument.complete) { + completers[argument.name] = argument.complete; + } + + if (argument.enum) { + enums[argument.name] = argument.enum; + } + } + + const prompt = { + ...inputPrompt, + complete: async (name: string, value: string) => { + if (completers[name]) { + return await completers[name](value); + } + + if (enums[name]) { + const fuse = new Fuse(enums[name], { + keys: ["value"], + }); + + const result = fuse.search(value); + + return { + values: result.map((item) => item.item), + total: result.length, + }; + } + + return { + values: [], + }; + }, + }; + + this.#prompts.push(prompt); + } + + public get clientCapabilities(): ClientCapabilities | null { + return this.#clientCapabilities ?? null; + } + + public get server(): Server { + return this.#server; + } + + #pingInterval: ReturnType | null = null; + + public async requestSampling( + message: z.infer["params"], + ): Promise { + return this.#server.createMessage(message); + } + + public async connect(transport: Transport) { + if (this.#server.transport) { + throw new UnexpectedStateError("Server is already connected"); + } + + await this.#server.connect(transport); + + let attempt = 0; + + while (attempt++ < 10) { + const capabilities = await this.#server.getClientCapabilities(); + + if (capabilities) { + this.#clientCapabilities = capabilities; + + break; + } + + await delay(100); + } + + if (!this.#clientCapabilities) { + console.warn('[warning] FastMCP could not infer client capabilities') + } + + if (this.#clientCapabilities?.roots?.listChanged) { + try { + const roots = await this.#server.listRoots(); + this.#roots = roots.roots; + } catch(e) { + console.error(`[error] FastMCP received error listing roots.\n\n${e instanceof Error ? e.stack : JSON.stringify(e)}`) + } + } + + this.#pingInterval = setInterval(async () => { + try { + await this.#server.ping(); + } catch (error) { + this.emit("error", { + error: error as Error, + }); + } + }, 1000); + } + + public get roots(): Root[] { + return this.#roots; + } + + public async close() { + if (this.#pingInterval) { + clearInterval(this.#pingInterval); + } + + try { + await this.#server.close(); + } catch (error) { + console.error("[MCP Error]", "could not close server", error); + } + } + + private setupErrorHandling() { + this.#server.onerror = (error) => { + console.error("[MCP Error]", error); + }; + } + + public get loggingLevel(): LoggingLevel { + return this.#loggingLevel; + } + + private setupCompleteHandlers() { + this.#server.setRequestHandler(CompleteRequestSchema, async (request) => { + if (request.params.ref.type === "ref/prompt") { + const prompt = this.#prompts.find( + (prompt) => prompt.name === request.params.ref.name, + ); + + if (!prompt) { + throw new UnexpectedStateError("Unknown prompt", { + request, + }); + } + + if (!prompt.complete) { + throw new UnexpectedStateError("Prompt does not support completion", { + request, + }); + } + + const completion = CompletionZodSchema.parse( + await prompt.complete( + request.params.argument.name, + request.params.argument.value, + ), + ); + + return { + completion, + }; + } + + if (request.params.ref.type === "ref/resource") { + const resource = this.#resourceTemplates.find( + (resource) => resource.uriTemplate === request.params.ref.uri, + ); + + if (!resource) { + throw new UnexpectedStateError("Unknown resource", { + request, + }); + } + + if (!("uriTemplate" in resource)) { + throw new UnexpectedStateError("Unexpected resource"); + } + + if (!resource.complete) { + throw new UnexpectedStateError( + "Resource does not support completion", + { + request, + }, + ); + } + + const completion = CompletionZodSchema.parse( + await resource.complete( + request.params.argument.name, + request.params.argument.value, + ), + ); + + return { + completion, + }; + } + + throw new UnexpectedStateError("Unexpected completion request", { + request, + }); + }); + } + + private setupRootsHandlers() { + this.#server.setNotificationHandler( + RootsListChangedNotificationSchema, + () => { + this.#server.listRoots().then((roots) => { + this.#roots = roots.roots; + + this.emit("rootsChanged", { + roots: roots.roots, + }); + }); + }, + ); + } + + private setupLoggingHandlers() { + this.#server.setRequestHandler(SetLevelRequestSchema, (request) => { + this.#loggingLevel = request.params.level; + + return {}; + }); + } + + private setupToolHandlers(tools: Tool[]) { + this.#server.setRequestHandler(ListToolsRequestSchema, async () => { + return { + tools: tools.map((tool) => { + return { + name: tool.name, + description: tool.description, + inputSchema: tool.parameters + ? zodToJsonSchema(tool.parameters) + : undefined, + }; + }), + }; + }); + + this.#server.setRequestHandler(CallToolRequestSchema, async (request) => { + const tool = tools.find((tool) => tool.name === request.params.name); + + if (!tool) { + throw new McpError( + ErrorCode.MethodNotFound, + `Unknown tool: ${request.params.name}`, + ); + } + + let args: any = undefined; + + if (tool.parameters) { + const parsed = tool.parameters.safeParse(request.params.arguments); + + if (!parsed.success) { + throw new McpError( + ErrorCode.InvalidParams, + `Invalid ${request.params.name} parameters`, + ); + } + + args = parsed.data; + } + + const progressToken = request.params?._meta?.progressToken; + + let result: ContentResult; + + try { + const reportProgress = async (progress: Progress) => { + await this.#server.notification({ + method: "notifications/progress", + params: { + ...progress, + progressToken, + }, + }); + }; + + const log = { + debug: (message: string, context?: SerializableValue) => { + this.#server.sendLoggingMessage({ + level: "debug", + data: { + message, + context, + }, + }); + }, + error: (message: string, context?: SerializableValue) => { + this.#server.sendLoggingMessage({ + level: "error", + data: { + message, + context, + }, + }); + }, + info: (message: string, context?: SerializableValue) => { + this.#server.sendLoggingMessage({ + level: "info", + data: { + message, + context, + }, + }); + }, + warn: (message: string, context?: SerializableValue) => { + this.#server.sendLoggingMessage({ + level: "warning", + data: { + message, + context, + }, + }); + }, + }; + + const maybeStringResult = await tool.execute(args, { + reportProgress, + log, + session: this.#auth, + }); + + if (typeof maybeStringResult === "string") { + result = ContentResultZodSchema.parse({ + content: [{ type: "text", text: maybeStringResult }], + }); + } else if ("type" in maybeStringResult) { + result = ContentResultZodSchema.parse({ + content: [maybeStringResult], + }); + } else { + result = ContentResultZodSchema.parse(maybeStringResult); + } + } catch (error) { + if (error instanceof UserError) { + return { + content: [{ type: "text", text: error.message }], + isError: true, + }; + } + + return { + content: [{ type: "text", text: `Error: ${error}` }], + isError: true, + }; + } + + return result; + }); + } + + private setupResourceHandlers(resources: Resource[]) { + this.#server.setRequestHandler(ListResourcesRequestSchema, async () => { + return { + resources: resources.map((resource) => { + return { + uri: resource.uri, + name: resource.name, + mimeType: resource.mimeType, + }; + }), + }; + }); + + this.#server.setRequestHandler( + ReadResourceRequestSchema, + async (request) => { + if ("uri" in request.params) { + const resource = resources.find( + (resource) => + "uri" in resource && resource.uri === request.params.uri, + ); + + if (!resource) { + for (const resourceTemplate of this.#resourceTemplates) { + const uriTemplate = parseURITemplate( + resourceTemplate.uriTemplate, + ); + + const match = uriTemplate.fromUri(request.params.uri); + + if (!match) { + continue; + } + + const uri = uriTemplate.fill(match); + + const result = await resourceTemplate.load(match); + + return { + contents: [ + { + uri: uri, + mimeType: resourceTemplate.mimeType, + name: resourceTemplate.name, + ...result, + }, + ], + }; + } + + throw new McpError( + ErrorCode.MethodNotFound, + `Unknown resource: ${request.params.uri}`, + ); + } + + if (!("uri" in resource)) { + throw new UnexpectedStateError("Resource does not support reading"); + } + + let maybeArrayResult: Awaited>; + + try { + maybeArrayResult = await resource.load(); + } catch (error) { + throw new McpError( + ErrorCode.InternalError, + `Error reading resource: ${error}`, + { + uri: resource.uri, + }, + ); + } + + if (Array.isArray(maybeArrayResult)) { + return { + contents: maybeArrayResult.map((result) => ({ + uri: resource.uri, + mimeType: resource.mimeType, + name: resource.name, + ...result, + })), + }; + } else { + return { + contents: [ + { + uri: resource.uri, + mimeType: resource.mimeType, + name: resource.name, + ...maybeArrayResult, + }, + ], + }; + } + } + + throw new UnexpectedStateError("Unknown resource request", { + request, + }); + }, + ); + } + + private setupResourceTemplateHandlers(resourceTemplates: ResourceTemplate[]) { + this.#server.setRequestHandler( + ListResourceTemplatesRequestSchema, + async () => { + return { + resourceTemplates: resourceTemplates.map((resourceTemplate) => { + return { + name: resourceTemplate.name, + uriTemplate: resourceTemplate.uriTemplate, + }; + }), + }; + }, + ); + } + + private setupPromptHandlers(prompts: Prompt[]) { + this.#server.setRequestHandler(ListPromptsRequestSchema, async () => { + return { + prompts: prompts.map((prompt) => { + return { + name: prompt.name, + description: prompt.description, + arguments: prompt.arguments, + complete: prompt.complete, + }; + }), + }; + }); + + this.#server.setRequestHandler(GetPromptRequestSchema, async (request) => { + const prompt = prompts.find( + (prompt) => prompt.name === request.params.name, + ); + + if (!prompt) { + throw new McpError( + ErrorCode.MethodNotFound, + `Unknown prompt: ${request.params.name}`, + ); + } + + const args = request.params.arguments; + + for (const arg of prompt.arguments ?? []) { + if (arg.required && !(args && arg.name in args)) { + throw new McpError( + ErrorCode.InvalidRequest, + `Missing required argument: ${arg.name}`, + ); + } + } + + let result: Awaited>; + + try { + result = await prompt.load(args as Record); + } catch (error) { + throw new McpError( + ErrorCode.InternalError, + `Error loading prompt: ${error}`, + ); + } + + return { + description: prompt.description, + messages: [ + { + role: "user", + content: { type: "text", text: result }, + }, + ], + }; + }); + } +} + +const FastMCPEventEmitterBase: { + new (): StrictEventEmitter>; +} = EventEmitter; + +class FastMCPEventEmitter extends FastMCPEventEmitterBase {} + +type Authenticate = (request: http.IncomingMessage) => Promise; + +export class FastMCP | undefined = undefined> extends FastMCPEventEmitter { + #options: ServerOptions; + #prompts: InputPrompt[] = []; + #resources: Resource[] = []; + #resourcesTemplates: InputResourceTemplate[] = []; + #sessions: FastMCPSession[] = []; + #sseServer: SSEServer | null = null; + #tools: Tool[] = []; + #authenticate: Authenticate | undefined; + + constructor(public options: ServerOptions) { + super(); + + this.#options = options; + this.#authenticate = options.authenticate; + } + + public get sessions(): FastMCPSession[] { + return this.#sessions; + } + + /** + * Adds a tool to the server. + */ + public addTool(tool: Tool) { + this.#tools.push(tool as unknown as Tool); + } + + /** + * Adds a resource to the server. + */ + public addResource(resource: Resource) { + this.#resources.push(resource); + } + + /** + * Adds a resource template to the server. + */ + public addResourceTemplate< + const Args extends InputResourceTemplateArgument[], + >(resource: InputResourceTemplate) { + this.#resourcesTemplates.push(resource); + } + + /** + * Adds a prompt to the server. + */ + public addPrompt( + prompt: InputPrompt, + ) { + this.#prompts.push(prompt); + } + + /** + * Starts the server. + */ + public async start( + options: + | { transportType: "stdio" } + | { + transportType: "sse"; + sse: { endpoint: `/${string}`; port: number }; + } = { + transportType: "stdio", + }, + ) { + if (options.transportType === "stdio") { + const transport = new StdioServerTransport(); + + const session = new FastMCPSession({ + name: this.#options.name, + version: this.#options.version, + tools: this.#tools, + resources: this.#resources, + resourcesTemplates: this.#resourcesTemplates, + prompts: this.#prompts, + }); + + await session.connect(transport); + + this.#sessions.push(session); + + this.emit("connect", { + session, + }); + + } else if (options.transportType === "sse") { + this.#sseServer = await startSSEServer>({ + endpoint: options.sse.endpoint as `/${string}`, + port: options.sse.port, + createServer: async (request) => { + let auth: T | undefined; + + if (this.#authenticate) { + auth = await this.#authenticate(request); + } + + return new FastMCPSession({ + auth, + name: this.#options.name, + version: this.#options.version, + tools: this.#tools, + resources: this.#resources, + resourcesTemplates: this.#resourcesTemplates, + prompts: this.#prompts, + }); + }, + onClose: (session) => { + this.emit("disconnect", { + session, + }); + }, + onConnect: async (session) => { + this.#sessions.push(session); + + this.emit("connect", { + session, + }); + }, + }); + + console.info( + `server is running on SSE at http://localhost:${options.sse.port}${options.sse.endpoint}`, + ); + } else { + throw new Error("Invalid transport type"); + } + } + + /** + * Stops the server. + */ + public async stop() { + if (this.#sseServer) { + this.#sseServer.close(); + } + } +} + +export type { Context }; +export type { Tool, ToolParameters }; +export type { Content, TextContent, ImageContent, ContentResult }; +export type { Progress, SerializableValue }; +export type { Resource, ResourceResult }; +export type { ResourceTemplate, ResourceTemplateArgument }; +export type { Prompt, PromptArgument }; +export type { InputPrompt, InputPromptArgument }; +export type { ServerOptions, LoggingLevel }; +export type { FastMCPEvents, FastMCPSessionEvents }; \ No newline at end of file diff --git a/mcp-server/src/core/utils/path-utils.js b/mcp-server/src/core/utils/path-utils.js index 70e344c3..4114a3aa 100644 --- a/mcp-server/src/core/utils/path-utils.js +++ b/mcp-server/src/core/utils/path-utils.js @@ -13,7 +13,7 @@ import fs from 'fs'; import { fileURLToPath } from 'url'; import os from 'os'; -// Store last found project root to improve performance on subsequent calls +// Store last found project root to improve performance on subsequent calls (primarily for CLI) export let lastFoundProjectRoot = null; // Project marker files that indicate a potential project root @@ -59,6 +59,7 @@ export const PROJECT_MARKERS = [ /** * Gets the path to the task-master package installation directory + * NOTE: This might become unnecessary if CLI fallback in MCP utils is removed. * @returns {string} - Absolute path to the package installation directory */ export function getPackagePath() { @@ -81,67 +82,45 @@ export function getPackagePath() { * @throws {Error} - If tasks.json cannot be found. */ export function findTasksJsonPath(args, log) { - // PRECEDENCE ORDER: - // 1. Environment variable override - // 2. Explicitly provided projectRoot in args - // 3. Previously found/cached project root - // 4. Current directory and parent traversal - // 5. Package directory (for development scenarios) + // PRECEDENCE ORDER for finding tasks.json: + // 1. Explicitly provided `projectRoot` in args (Highest priority, expected in MCP context) + // 2. Previously found/cached `lastFoundProjectRoot` (primarily for CLI performance) + // 3. Search upwards from current working directory (`process.cwd()`) - CLI usage - // 1. Check for environment variable override - if (process.env.TASK_MASTER_PROJECT_ROOT) { - const envProjectRoot = process.env.TASK_MASTER_PROJECT_ROOT; - log.info(`Using project root from TASK_MASTER_PROJECT_ROOT environment variable: ${envProjectRoot}`); - return findTasksJsonInDirectory(envProjectRoot, args.file, log); - } - - // 2. If project root is explicitly provided, use it directly + // 1. If project root is explicitly provided (e.g., from MCP session), use it directly if (args.projectRoot) { const projectRoot = args.projectRoot; log.info(`Using explicitly provided project root: ${projectRoot}`); - return findTasksJsonInDirectory(projectRoot, args.file, log); + // This will throw if tasks.json isn't found within this root + return findTasksJsonInDirectory(projectRoot, args.file, log); } - // 3. If we have a last known project root that worked, try it first + // --- Fallback logic primarily for CLI or when projectRoot isn't passed --- + + // 2. If we have a last known project root that worked, try it first if (lastFoundProjectRoot) { log.info(`Trying last known project root: ${lastFoundProjectRoot}`); try { - const tasksPath = findTasksJsonInDirectory(lastFoundProjectRoot, args.file, log); - return tasksPath; + // Use the cached root + const tasksPath = findTasksJsonInDirectory(lastFoundProjectRoot, args.file, log); + return tasksPath; // Return if found in cached root } catch (error) { log.info(`Task file not found in last known project root, continuing search.`); - // Continue with search if not found + // Continue with search if not found in cache } } - // 4. Start with current directory - this is likely the user's project directory + // 3. Start search from current directory (most common CLI scenario) const startDir = process.cwd(); log.info(`Searching for tasks.json starting from current directory: ${startDir}`); // Try to find tasks.json by walking up the directory tree from cwd try { - return findTasksJsonWithParentSearch(startDir, args.file, log); + // This will throw if not found in the CWD tree + return findTasksJsonWithParentSearch(startDir, args.file, log); } catch (error) { - // 5. If not found in cwd or parents, package might be installed via npm - // and the user could be in an unrelated directory - - // As a last resort, check if there's a tasks.json in the package directory itself - // (for development scenarios) - const packagePath = getPackagePath(); - if (packagePath !== startDir) { - log.info(`Tasks file not found in current directory tree. Checking package directory: ${packagePath}`); - try { - return findTasksJsonInDirectory(packagePath, args.file, log); - } catch (packageError) { - // Fall through to throw the original error - } - } - - // If all attempts fail, throw the original error with guidance - error.message = `${error.message}\n\nPossible solutions: -1. Run the command from your project directory containing tasks.json -2. Use --project-root=/path/to/project to specify the project location -3. Set TASK_MASTER_PROJECT_ROOT environment variable to your project path`; + // If all attempts fail, augment and throw the original error from CWD search + error.message = `${error.message}\n\nPossible solutions:\n1. Run the command from your project directory containing tasks.json\n2. Use --project-root=/path/to/project to specify the project location (if using CLI)\n3. Ensure the project root is correctly passed from the client (if using MCP)`; throw error; } } @@ -243,6 +222,8 @@ function findTasksJsonWithParentSearch(startDir, explicitFilePath, log) { throw error; } +// Note: findTasksWithNpmConsideration is not used by findTasksJsonPath and might be legacy or used elsewhere. +// If confirmed unused, it could potentially be removed in a separate cleanup. function findTasksWithNpmConsideration(startDir, log) { // First try our recursive parent search from cwd try { diff --git a/mcp-server/src/tools/add-dependency.js b/mcp-server/src/tools/add-dependency.js index 22d78812..05b9bdba 100644 --- a/mcp-server/src/tools/add-dependency.js +++ b/mcp-server/src/tools/add-dependency.js @@ -6,7 +6,8 @@ import { z } from "zod"; import { handleApiResult, - createErrorResponse + createErrorResponse, + getProjectRootFromSession } from "./utils.js"; import { addDependencyDirect } from "../core/task-master-core.js"; @@ -24,12 +25,27 @@ export function registerAddDependencyTool(server) { file: z.string().optional().describe("Path to the tasks file (default: tasks/tasks.json)"), projectRoot: z.string().optional().describe("Root directory of the project (default: current working directory)") }), - execute: async (args, { log }) => { + execute: async (args, { log, session, reportProgress }) => { try { - log.info(`Adding dependency for task ${args.id} to depend on ${args.dependsOn} with args: ${JSON.stringify(args)}`); + log.info(`Adding dependency for task ${args.id} to depend on ${args.dependsOn}`); + reportProgress({ progress: 0 }); - // Call the direct function wrapper - const result = await addDependencyDirect(args, log); + // Get project root using the utility function + let rootFolder = getProjectRootFromSession(session, log); + + // Fallback to args.projectRoot if session didn't provide one + if (!rootFolder && args.projectRoot) { + rootFolder = args.projectRoot; + log.info(`Using project root from args as fallback: ${rootFolder}`); + } + + // Call the direct function with the resolved rootFolder + const result = await addDependencyDirect({ + projectRoot: rootFolder, + ...args + }, log); + + reportProgress({ progress: 100 }); // Log result if (result.success) { diff --git a/mcp-server/src/tools/add-task.js b/mcp-server/src/tools/add-task.js index 4ea8c9cd..43b55c06 100644 --- a/mcp-server/src/tools/add-task.js +++ b/mcp-server/src/tools/add-task.js @@ -6,7 +6,8 @@ import { z } from "zod"; import { handleApiResult, - createErrorResponse + createErrorResponse, + getProjectRootFromSession } from "./utils.js"; import { addTaskDirect } from "../core/task-master-core.js"; @@ -25,19 +26,26 @@ export function registerAddTaskTool(server) { file: z.string().optional().describe("Path to the tasks file"), projectRoot: z.string().optional().describe("Root directory of the project (default: current working directory)") }), - execute: async ({ prompt, dependencies, priority, file, projectRoot }, log) => { + execute: async (args, { log, reportProgress, session }) => { try { - log.info(`MCP add_task called with prompt: "${prompt}"`); + log.info(`MCP add_task called with prompt: "${args.prompt}"`); + // Get project root using the utility function + let rootFolder = getProjectRootFromSession(session, log); + + // Fallback to args.projectRoot if session didn't provide one + if (!rootFolder && args.projectRoot) { + rootFolder = args.projectRoot; + log.info(`Using project root from args as fallback: ${rootFolder}`); + } + + // Call the direct function with the resolved rootFolder const result = await addTaskDirect({ - prompt, - dependencies, - priority, - file, - projectRoot + projectRoot: rootFolder, // Pass the resolved root + ...args }, log); - return handleApiResult(result); + return handleApiResult(result, log); } catch (error) { log.error(`Error in add_task MCP tool: ${error.message}`); return createErrorResponse(error.message, "ADD_TASK_ERROR"); diff --git a/mcp-server/src/tools/utils.js b/mcp-server/src/tools/utils.js index c9c79cc0..fbfd4940 100644 --- a/mcp-server/src/tools/utils.js +++ b/mcp-server/src/tools/utils.js @@ -7,6 +7,7 @@ import { spawnSync } from "child_process"; import path from "path"; import { contextManager } from '../core/context-manager.js'; // Import the singleton import fs from 'fs'; +import { decodeURIComponent } from 'querystring'; // Added for URI decoding // Import path utilities to ensure consistent path resolution import { lastFoundProjectRoot, getPackagePath, PROJECT_MARKERS } from '../core/utils/path-utils.js'; @@ -67,6 +68,36 @@ export function getProjectRoot(projectRootRaw, log) { return currentDir; } +/** + * Extracts the project root path from the FastMCP session object. + * @param {Object} session - The FastMCP session object. + * @param {Object} log - Logger object. + * @returns {string|null} - The absolute path to the project root, or null if not found. + */ +export function getProjectRootFromSession(session, log) { + if (session && session.roots && session.roots.length > 0) { + const firstRoot = session.roots[0]; + if (firstRoot && firstRoot.uri) { + try { + const rootUri = firstRoot.uri; + const rootPath = rootUri.startsWith('file://') + ? decodeURIComponent(rootUri.slice(7)) // Remove 'file://' and decode + : rootUri; // Assume it's a path if no scheme + log.info(`Extracted project root from session: ${rootPath}`); + return rootPath; + } catch (e) { + log.error(`Error decoding project root URI from session: ${firstRoot.uri}`, e); + return null; + } + } else { + log.info('Session exists, but first root or its URI is missing.'); + } + } else { + log.info('No session or session roots found to extract project root.'); + } + return null; +} + /** * Handle API result with standardized error handling and response formatting * @param {Object} result - Result object from API call with success, data, and error properties @@ -317,3 +348,13 @@ export function createErrorResponse(errorMessage) { isError: true }; } + +// Ensure all functions are exported +export { + handleApiResult, + executeTaskMasterCommand, + getCachedOrExecute, + processMCPResponseData, + createContentResponse, + createErrorResponse +}; diff --git a/mcp-test.js b/mcp-test.js new file mode 100644 index 00000000..f873c673 --- /dev/null +++ b/mcp-test.js @@ -0,0 +1,71 @@ +#!/usr/bin/env node + +import { Config } from 'fastmcp'; +import path from 'path'; +import fs from 'fs'; + +// Log the current directory +console.error(`Current working directory: ${process.cwd()}`); + +try { + console.error('Attempting to load FastMCP Config...'); + + // Check if .cursor/mcp.json exists + const mcpPath = path.join(process.cwd(), '.cursor', 'mcp.json'); + console.error(`Checking if mcp.json exists at: ${mcpPath}`); + + if (fs.existsSync(mcpPath)) { + console.error('mcp.json file found'); + console.error(`File content: ${JSON.stringify(JSON.parse(fs.readFileSync(mcpPath, 'utf8')), null, 2)}`); + } else { + console.error('mcp.json file not found'); + } + + // Try to create Config + const config = new Config(); + console.error('Config created successfully'); + + // Check if env property exists + if (config.env) { + console.error(`Config.env exists with keys: ${Object.keys(config.env).join(', ')}`); + + // Print each env var value (careful with sensitive values) + for (const [key, value] of Object.entries(config.env)) { + if (key.includes('KEY')) { + console.error(`${key}: [value hidden]`); + } else { + console.error(`${key}: ${value}`); + } + } + } else { + console.error('Config.env does not exist'); + } +} catch (error) { + console.error(`Error loading Config: ${error.message}`); + console.error(`Stack trace: ${error.stack}`); +} + +// Log process.env to see if values from mcp.json were loaded automatically +console.error('\nChecking if process.env already has values from mcp.json:'); +const envVars = [ + 'ANTHROPIC_API_KEY', + 'PERPLEXITY_API_KEY', + 'MODEL', + 'PERPLEXITY_MODEL', + 'MAX_TOKENS', + 'TEMPERATURE', + 'DEFAULT_SUBTASKS', + 'DEFAULT_PRIORITY' +]; + +for (const varName of envVars) { + if (process.env[varName]) { + if (varName.includes('KEY')) { + console.error(`${varName}: [value hidden]`); + } else { + console.error(`${varName}: ${process.env[varName]}`); + } + } else { + console.error(`${varName}: not set`); + } +} \ No newline at end of file