Files
claude-task-master/apps/mcp/src/shared/utils.ts
Ralph Khreish c798639d1a feat: add user-defined metadata field to tasks (#1555) (#1611)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Cedric Hurst <cedric@spantree.net>
Closes #1555
2026-01-26 17:41:33 +01:00

505 lines
13 KiB
TypeScript

/**
* Shared utilities for MCP tools
*/
import dotenv from 'dotenv';
import fs from 'node:fs';
import path from 'node:path';
import {
LOCAL_ONLY_COMMANDS,
type LocalOnlyCommand,
createTmCore
} from '@tm/core';
import type { ContentResult, Context } from 'fastmcp';
import packageJson from '../../../../package.json' with { type: 'json' };
import type { ToolContext } from './types.js';
/**
* Get version information
*/
export function getVersionInfo() {
return {
version: packageJson.version || 'unknown',
name: packageJson.name || 'task-master-ai'
};
}
/**
* Creates a content response for MCP tools
* FastMCP requires text type, so we format objects as JSON strings
*/
export function createContentResponse(content: any): ContentResult {
return {
content: [
{
type: 'text',
text:
typeof content === 'object'
? // Format JSON nicely with indentation
JSON.stringify(content, null, 2)
: // Keep other content types as-is
String(content)
}
]
};
}
/**
* Creates an error response for MCP tools
*/
export function createErrorResponse(
errorMessage: string,
versionInfo?: { version: string; name: string },
tagInfo?: { currentTag: string }
): ContentResult {
// Provide fallback version info if not provided
if (!versionInfo) {
versionInfo = getVersionInfo();
}
let responseText = `Error: ${errorMessage}
Version: ${versionInfo.version}
Name: ${versionInfo.name}`;
// Add tag information if available
if (tagInfo) {
responseText += `
Current Tag: ${tagInfo.currentTag}`;
}
return {
content: [
{
type: 'text',
text: responseText
}
],
isError: true
};
}
/**
* Function signature for progress reporting
*/
export type ReportProgressFn = (progress: number, total?: number) => void;
/**
* Validate that reportProgress is available for long-running operations
*/
export function checkProgressCapability(
reportProgress: any,
log: any
): ReportProgressFn | undefined {
if (typeof reportProgress !== 'function') {
log?.debug?.(
'reportProgress not available - operation will run without progress updates'
);
return undefined;
}
return reportProgress;
}
/**
* Get current tag for a project root
*/
export function getCurrentTag(projectRoot: string): string | null {
try {
// Try to read current tag from state.json
const stateJsonPath = path.join(projectRoot, '.taskmaster', 'state.json');
if (fs.existsSync(stateJsonPath)) {
const stateData = JSON.parse(fs.readFileSync(stateJsonPath, 'utf-8'));
return stateData.currentTag || 'master';
}
return 'master';
} catch {
return null;
}
}
/**
* Handle API result with standardized error handling and response formatting
* This provides a consistent response structure for all MCP tools
*/
export async function handleApiResult<T>(options: {
result: { success: boolean; data?: T; error?: { message: string } };
log?: any;
errorPrefix?: string;
projectRoot?: string;
tag?: string; // Optional tag/brief to use instead of reading from state.json
}): Promise<ContentResult> {
const {
result,
log,
errorPrefix = 'API error',
projectRoot,
tag: providedTag
} = options;
// Get version info for every response
const versionInfo = getVersionInfo();
// Use provided tag if available, otherwise get from state.json
// Note: For API storage, tm-core returns the brief name as the tag
const currentTag =
providedTag !== undefined
? providedTag
: projectRoot
? getCurrentTag(projectRoot)
: null;
if (!result.success) {
const errorMsg = result.error?.message || `Unknown ${errorPrefix}`;
log?.error?.(`${errorPrefix}: ${errorMsg}`);
let errorText = `Error: ${errorMsg}\nVersion: ${versionInfo.version}\nName: ${versionInfo.name}`;
if (currentTag) {
errorText += `\nCurrent Tag: ${currentTag}`;
}
return {
content: [
{
type: 'text',
text: errorText
}
],
isError: true
};
}
log?.info?.('Successfully completed operation');
// Create the response payload including version info and tag
const responsePayload: any = {
data: result.data,
version: versionInfo
};
// Add current tag if available
if (currentTag) {
responsePayload.tag = currentTag;
}
return {
content: [
{
type: 'text',
text: JSON.stringify(responsePayload, null, 2)
}
]
};
}
/**
* Normalize project root path (handles URI encoding, file:// protocol, Windows paths)
*/
export function normalizeProjectRoot(rawPath: string): string {
if (!rawPath) return process.cwd();
try {
let pathString = rawPath;
// Decode URI encoding
try {
pathString = decodeURIComponent(pathString);
} catch {
// If decoding fails, use as-is
}
// Strip file:// prefix
if (pathString.startsWith('file:///')) {
pathString = pathString.slice(7);
} else if (pathString.startsWith('file://')) {
pathString = pathString.slice(7);
}
// Handle Windows drive letter after stripping prefix (e.g., /C:/...)
if (
pathString.startsWith('/') &&
/[A-Za-z]:/.test(pathString.substring(1, 3))
) {
pathString = pathString.substring(1);
}
// Normalize backslashes to forward slashes
pathString = pathString.replace(/\\/g, '/');
// Resolve to absolute path
return path.resolve(pathString);
} catch {
return path.resolve(rawPath);
}
}
/**
* Get project root from session object
*/
function getProjectRootFromSession(session: any): string | null {
try {
// Check primary location
if (session?.roots?.[0]?.uri) {
return normalizeProjectRoot(session.roots[0].uri);
}
// Check alternate location
else if (session?.roots?.roots?.[0]?.uri) {
return normalizeProjectRoot(session.roots.roots[0].uri);
}
return null;
} catch {
return null;
}
}
/**
* Wrapper to normalize project root in args with proper precedence order
*
* PRECEDENCE ORDER:
* 1. TASK_MASTER_PROJECT_ROOT environment variable (from process.env or session)
* 2. args.projectRoot (explicitly provided)
* 3. Session-based project root resolution
* 4. Current directory fallback
*/
export function withNormalizedProjectRoot<T extends { projectRoot?: string }>(
fn: (
args: T & { projectRoot: string },
context: Context<undefined>
) => Promise<ContentResult>
): (args: T, context: any) => Promise<ContentResult> {
return async (args: T, context: any): Promise<ContentResult> => {
const { log, session } = context;
let normalizedRoot: string | null = null;
let rootSource = 'unknown';
try {
// 1. Check for TASK_MASTER_PROJECT_ROOT environment variable first
if (process.env.TASK_MASTER_PROJECT_ROOT) {
const envRoot = process.env.TASK_MASTER_PROJECT_ROOT;
normalizedRoot = path.isAbsolute(envRoot)
? envRoot
: path.resolve(process.cwd(), envRoot);
rootSource = 'TASK_MASTER_PROJECT_ROOT environment variable';
log?.info?.(`Using project root from ${rootSource}: ${normalizedRoot}`);
}
// Also check session environment variables for TASK_MASTER_PROJECT_ROOT
else if (session?.env?.TASK_MASTER_PROJECT_ROOT) {
const envRoot = session.env.TASK_MASTER_PROJECT_ROOT;
normalizedRoot = path.isAbsolute(envRoot)
? envRoot
: path.resolve(process.cwd(), envRoot);
rootSource = 'TASK_MASTER_PROJECT_ROOT session environment variable';
log?.info?.(`Using project root from ${rootSource}: ${normalizedRoot}`);
}
// 2. If no environment variable, try args.projectRoot
else if (args.projectRoot) {
normalizedRoot = normalizeProjectRoot(args.projectRoot);
rootSource = 'args.projectRoot';
log?.info?.(`Using project root from ${rootSource}: ${normalizedRoot}`);
}
// 3. If no args.projectRoot, try session-based resolution
else {
const sessionRoot = getProjectRootFromSession(session);
if (sessionRoot) {
normalizedRoot = sessionRoot;
rootSource = 'session';
log?.info?.(
`Using project root from ${rootSource}: ${normalizedRoot}`
);
}
}
if (!normalizedRoot) {
log?.error?.(
'Could not determine project root from environment, args, or session.'
);
return handleApiResult({
result: {
success: false,
error: {
message:
'Could not determine project root. Please provide projectRoot argument or ensure TASK_MASTER_PROJECT_ROOT environment variable is set.'
}
}
});
}
// Inject the normalized root back into args
const updatedArgs = { ...args, projectRoot: normalizedRoot } as T & {
projectRoot: string;
};
// Execute the original function with normalized root in args
return await fn(updatedArgs, context);
} catch (error: any) {
log?.error?.(
`Error within withNormalizedProjectRoot HOF (Normalized Root: ${normalizedRoot}): ${error.message}`
);
if (error.stack && log?.debug) {
log.debug(error.stack);
}
return handleApiResult({
result: {
success: false,
error: {
message: `Operation failed: ${error.message}`
}
}
});
}
};
}
/**
* Tool execution function signature with tmCore provided
*/
export type ToolExecuteFn<TArgs = any, TResult = any> = (
args: TArgs,
context: ToolContext
) => Promise<TResult>;
/**
* Higher-order function that wraps MCP tool execution with:
* - Normalized project root (via withNormalizedProjectRoot)
* - TmCore instance creation
* - Command guard check (for local-only commands)
*
* Use this for ALL MCP tools to provide consistent context and auth checking.
*
* @param commandName - Name of the command (used for guard check)
* @param executeFn - Tool execution function that receives args and enhanced context
* @returns Wrapped execute function
*
* @example
* ```ts
* export function registerAddDependencyTool(server: FastMCP) {
* server.addTool({
* name: 'add_dependency',
* parameters: AddDependencySchema,
* execute: withToolContext('add-dependency', async (args, context) => {
* // context.tmCore is already available
* // Auth guard already checked
* // Just implement the tool logic!
* })
* });
* }
* ```
*/
export function withToolContext<TArgs extends { projectRoot?: string }>(
commandName: string,
executeFn: ToolExecuteFn<TArgs & { projectRoot: string }, ContentResult>
) {
return withNormalizedProjectRoot(
async (
args: TArgs & { projectRoot: string },
context: Context<undefined>
) => {
// Load project .env if it exists (won't overwrite MCP-provided env vars)
// This ensures project-specific env vars are available to tool execution
const envPath = path.join(args.projectRoot, '.env');
if (fs.existsSync(envPath)) {
dotenv.config({ path: envPath });
}
// Create tmCore instance
const tmCore = await createTmCore({
projectPath: args.projectRoot,
loggerConfig: { mcpMode: true, logCallback: context.log }
});
// Check if this is a local-only command that needs auth guard
if (LOCAL_ONLY_COMMANDS.includes(commandName as LocalOnlyCommand)) {
const authResult = await tmCore.auth.guardCommand(
commandName,
tmCore.tasks.getStorageType()
);
if (authResult.isBlocked) {
const errorMsg = `You're working on the ${authResult.briefName} Brief in Hamster so this command is managed for you. This command is only available for local file storage. Log out with 'tm auth logout' to use local commands.`;
context.log.info(errorMsg);
return handleApiResult({
result: {
success: false,
error: { message: errorMsg }
},
log: context.log,
projectRoot: args.projectRoot
});
}
}
// Create enhanced context with tmCore
const enhancedContext: ToolContext = {
log: context.log,
session: context.session,
tmCore
};
// Execute the actual tool logic with enhanced context
return executeFn(args, enhancedContext);
}
);
}
/**
* Validates and parses metadata string for MCP tools.
* Checks environment flag, validates JSON format, and ensures metadata is a plain object.
*
* @param metadataString - JSON string to parse and validate
* @param errorResponseFn - Function to create error response
* @returns Object with parsed metadata or error
*/
export function validateMcpMetadata(
metadataString: string | null | undefined,
errorResponseFn: (message: string) => ContentResult
): { parsedMetadata: Record<string, unknown> | null; error?: ContentResult } {
// Return null if no metadata provided
if (!metadataString) {
return { parsedMetadata: null };
}
// Check if metadata updates are allowed via environment variable
const allowMetadataUpdates =
process.env.TASK_MASTER_ALLOW_METADATA_UPDATES === 'true';
if (!allowMetadataUpdates) {
return {
parsedMetadata: null,
error: errorResponseFn(
'Metadata updates are disabled. Set TASK_MASTER_ALLOW_METADATA_UPDATES=true in your MCP server environment to enable metadata modifications.'
)
};
}
// Parse and validate JSON
try {
const parsedMetadata = JSON.parse(metadataString);
// Ensure it's a plain object (not null, not array)
if (
typeof parsedMetadata !== 'object' ||
parsedMetadata === null ||
Array.isArray(parsedMetadata)
) {
return {
parsedMetadata: null,
error: errorResponseFn(
'Invalid metadata: must be a JSON object (not null or array)'
)
};
}
return { parsedMetadata };
} catch (parseError: unknown) {
const message =
parseError instanceof Error ? parseError.message : 'Unknown parse error';
return {
parsedMetadata: null,
error: errorResponseFn(
`Invalid metadata JSON: ${message}. Provide a valid JSON object string.`
)
};
}
}