Skip to content

Commit 52232e2

Browse files
Passing tests for some tools
1 parent 16033a8 commit 52232e2

File tree

12 files changed

+221
-240
lines changed

12 files changed

+221
-240
lines changed

.cursor/rules/errors.mdc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,5 +53,5 @@ Errors are consistently through a unified `AppError` system:
5353

5454
2. **Business Logic Errors** (`ERR_2xxx` and higher)
5555
- Used for all business logic and application-specific errors
56-
- Include specific error codes (e.g., ERR_2000 for ProjectNotFoundError)
56+
- Include specific error codes
5757
- Returned as serialized CallToolResults with `isError: true`

.cursor/rules/tests.mdc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@ description: Writing unit tests with `jest`
33
globs: tests/**/*
44
alwaysApply: false
55
---
6-
6+
Make use of the helpers in tests/mcp/test-helpers.ts.

src/server/FileSystemService.ts

Lines changed: 40 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { dirname, join, resolve } from "node:path";
33
import { homedir } from "node:os";
44
import { AppError, AppErrorCode } from "../types/errors.js";
55
import { TaskManagerFile } from "../types/data.js";
6+
import * as fs from 'node:fs';
67

78
export interface InitializedTaskData {
89
data: TaskManagerFile;
@@ -12,12 +13,11 @@ export interface InitializedTaskData {
1213

1314
export class FileSystemService {
1415
private filePath: string;
15-
// Simple in-memory queue to prevent concurrent file operations
16-
private operationInProgress: boolean = false;
17-
private operationQueue: (() => void)[] = [];
16+
private lockFilePath: string;
1817

1918
constructor(filePath: string) {
2019
this.filePath = filePath;
20+
this.lockFilePath = `${filePath}.lock`;
2121
}
2222

2323
/**
@@ -41,47 +41,56 @@ export class FileSystemService {
4141
}
4242

4343
/**
44-
* Queue a file operation to prevent concurrent access
45-
* @param operation The operation to perform
46-
* @returns Promise that resolves when the operation completes
44+
* Acquires a file system lock
4745
*/
48-
private async queueOperation<T>(operation: () => Promise<T>): Promise<T> {
49-
// If another operation is in progress, wait for it to complete
50-
if (this.operationInProgress) {
51-
return new Promise<T>((resolve, reject) => {
52-
this.operationQueue.push(() => {
53-
this.executeOperation(operation).then(resolve).catch(reject);
54-
});
55-
});
46+
private async acquireLock(): Promise<void> {
47+
while (true) {
48+
try {
49+
// Try to create lock file
50+
const fd = fs.openSync(this.lockFilePath, 'wx');
51+
fs.closeSync(fd);
52+
return;
53+
} catch (error: any) {
54+
if (error.code === 'EEXIST') {
55+
// Lock file exists, wait and retry
56+
await new Promise(resolve => setTimeout(resolve, 100));
57+
continue;
58+
}
59+
throw error;
60+
}
5661
}
62+
}
5763

58-
return this.executeOperation(operation);
64+
/**
65+
* Releases the file system lock
66+
*/
67+
private async releaseLock(): Promise<void> {
68+
try {
69+
await fs.promises.unlink(this.lockFilePath);
70+
} catch (error: any) {
71+
if (error.code !== 'ENOENT') {
72+
throw error;
73+
}
74+
}
5975
}
6076

6177
/**
62-
* Execute a file operation with mutex protection
63-
* @param operation The operation to perform
64-
* @returns Promise that resolves when the operation completes
78+
* Execute a file operation with file system lock
6579
*/
6680
private async executeOperation<T>(operation: () => Promise<T>): Promise<T> {
67-
this.operationInProgress = true;
81+
await this.acquireLock();
6882
try {
6983
return await operation();
7084
} finally {
71-
this.operationInProgress = false;
72-
// Process the next operation in the queue, if any
73-
const nextOperation = this.operationQueue.shift();
74-
if (nextOperation) {
75-
nextOperation();
76-
}
85+
await this.releaseLock();
7786
}
7887
}
7988

8089
/**
8190
* Loads and initializes task data from the JSON file
8291
*/
8392
public async loadAndInitializeTasks(): Promise<InitializedTaskData> {
84-
return this.queueOperation(async () => {
93+
return this.executeOperation(async () => {
8594
const data = await this.loadTasks();
8695
const { maxProjectId, maxTaskId } = this.calculateMaxIds(data);
8796

@@ -95,11 +104,9 @@ export class FileSystemService {
95104

96105
/**
97106
* Explicitly reloads task data from the disk
98-
* This is useful when the file may have been changed by another process
99-
* @returns The latest task data from disk
100107
*/
101108
public async reloadTasks(): Promise<TaskManagerFile> {
102-
return this.queueOperation(async () => {
109+
return this.executeOperation(async () => {
103110
return this.loadTasks();
104111
});
105112
}
@@ -140,7 +147,8 @@ export class FileSystemService {
140147
} catch (error) {
141148
if (error instanceof Error) {
142149
if (error.message.includes('ENOENT')) {
143-
throw new AppError(`Tasks file not found: ${this.filePath}`, AppErrorCode.FileReadError, error);
150+
// If file doesn't exist, return empty data
151+
return { projects: [] };
144152
}
145153
throw new AppError(`Failed to read tasks file: ${error.message}`, AppErrorCode.FileReadError, error);
146154
}
@@ -149,10 +157,10 @@ export class FileSystemService {
149157
}
150158

151159
/**
152-
* Saves task data to the JSON file with an in-memory mutex to prevent concurrent writes
160+
* Saves task data to the JSON file with file system lock
153161
*/
154162
public async saveTasks(data: TaskManagerFile): Promise<void> {
155-
return this.queueOperation(async () => {
163+
return this.executeOperation(async () => {
156164
try {
157165
// Ensure directory exists before writing
158166
const dir = dirname(this.filePath);

src/server/TaskManager.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ export class TaskManager {
221221
modelProvider = deepseek(model);
222222
break;
223223
default:
224-
throw new AppError(`Invalid provider: ${provider}`, AppErrorCode.InvalidArgument);
224+
throw new AppError(`Invalid provider: ${provider}`, AppErrorCode.InvalidProvider);
225225
}
226226

227227
try {
@@ -268,6 +268,10 @@ export class TaskManager {
268268
throw new AppError('Project is already completed', AppErrorCode.ProjectAlreadyCompleted);
269269
}
270270

271+
if (!proj.tasks.length) {
272+
throw new AppError('Project has no tasks', AppErrorCode.TaskNotFound);
273+
}
274+
271275
const nextTask = proj.tasks.find((t) => !(t.status === "done" && t.approved));
272276
if (!nextTask) {
273277
// all tasks done and approved?

src/server/tools.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -474,9 +474,7 @@ export async function executeToolAndHandleErrors(
474474
if (error instanceof AppError) {
475475
if ([
476476
AppErrorCode.MissingParameter,
477-
AppErrorCode.InvalidArgument,
478-
AppErrorCode.InvalidState,
479-
AppErrorCode.ConfigurationError
477+
AppErrorCode.InvalidArgument
480478
].includes(error.code as AppErrorCode)
481479
) {
482480
throw new McpError(ErrorCode.InvalidParams, error.message);

src/types/errors.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
// Error Codes
22
export enum AppErrorCode {
3-
// Configuration / Validation (ERR_1xxx)
3+
// Protocol Errors (ERR_1xxx)
44
MissingParameter = 'ERR_1000', // General missing param (mapped to protocol -32602)
5-
InvalidState = 'ERR_1001', // e.g., invalid state filter
6-
InvalidArgument = 'ERR_1002', // General invalid arg (mapped to protocol -32602)
7-
ConfigurationError = 'ERR_1003', // e.g., Missing API Key for generate_project_plan
8-
9-
// Resource Not Found (ERR_2xxx)
10-
ProjectNotFound = 'ERR_2000',
11-
TaskNotFound = 'ERR_2001',
5+
InvalidArgument = 'ERR_1002', // Extra / invalid param (mapped to protocol -32602)
6+
7+
// Validation / Resource Not Found (ERR_2xxx)
8+
ConfigurationError = 'ERR_2000', // e.g., Missing API Key for generate_project_plan
9+
ProjectNotFound = 'ERR_2001',
10+
TaskNotFound = 'ERR_2002',
11+
InvalidState = 'ERR_2003', // e.g., invalid state filter
12+
InvalidProvider = 'ERR_2004', // e.g., invalid model provider
13+
1214
// No need for EmptyTaskFile code, handle during load
1315

1416
// Business Logic / State Rules (ERR_3xxx)
@@ -44,6 +46,9 @@ export enum AppErrorCode {
4446
this.name = this.constructor.name; // Set name to the specific error class name
4547
this.code = code;
4648
this.details = details;
49+
50+
// Fix prototype chain for instanceof to work correctly
51+
Object.setPrototypeOf(this, AppError.prototype);
4752
}
4853
}
4954

tests/mcp/test-helpers.ts

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
22
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
33
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
44
import { Task, Project, TaskManagerFile } from "../../src/types/data.js";
5+
import { FileSystemService } from "../../src/server/FileSystemService.js";
56
import * as path from 'node:path';
67
import * as os from 'node:os';
78
import * as fs from 'node:fs/promises';
@@ -17,6 +18,7 @@ export interface TestContext {
1718
tempDir: string;
1819
testFilePath: string;
1920
taskCounter: number;
21+
fileService: FileSystemService;
2022
}
2123

2224
/**
@@ -28,9 +30,12 @@ export async function setupTestContext(customFilePath?: string, skipFileInit: bo
2830
await fs.mkdir(tempDir, { recursive: true });
2931
const testFilePath = customFilePath || path.join(tempDir, 'test-tasks.json');
3032

33+
// Create FileSystemService instance
34+
const fileService = new FileSystemService(testFilePath);
35+
3136
// Initialize empty task manager file (skip for error testing)
3237
if (!skipFileInit) {
33-
await writeTaskManagerFile(testFilePath, { projects: [] });
38+
await fileService.saveTasks({ projects: [] });
3439
}
3540

3641
// Set up the transport with environment variable for test file
@@ -78,7 +83,7 @@ export async function setupTestContext(customFilePath?: string, skipFileInit: bo
7883
throw error;
7984
}
8085

81-
return { client, transport, tempDir, testFilePath, taskCounter: 0 };
86+
return { client, transport, tempDir, testFilePath, taskCounter: 0, fileService };
8287
}
8388

8489
/**
@@ -196,22 +201,16 @@ export async function getFirstTaskId(client: Client, projectId: string): Promise
196201
* Reads and parses the task manager file
197202
*/
198203
export async function readTaskManagerFile(filePath: string): Promise<TaskManagerFile> {
199-
try {
200-
const content = await fs.readFile(filePath, 'utf-8');
201-
return JSON.parse(content);
202-
} catch (error) {
203-
if ((error as any).code === 'ENOENT') {
204-
return { projects: [] };
205-
}
206-
throw error;
207-
}
204+
const fileService = new FileSystemService(filePath);
205+
return fileService.reloadTasks();
208206
}
209207

210208
/**
211209
* Writes data to the task manager file
212210
*/
213211
export async function writeTaskManagerFile(filePath: string, data: TaskManagerFile): Promise<void> {
214-
await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8');
212+
const fileService = new FileSystemService(filePath);
213+
await fileService.saveTasks(data);
215214
}
216215

217216
/**

tests/mcp/tools/approve-task.test.ts

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -89,19 +89,20 @@ describe('approve_task Tool', () => {
8989
initialPrompt: "Multi-task Project"
9090
});
9191

92-
// Create and approve multiple tasks
93-
const tasks = await Promise.all([
94-
createTestTaskInFile(context.testFilePath, project.projectId, {
95-
title: "Task 1",
96-
status: "done",
97-
completedDetails: "First task done"
98-
}),
99-
createTestTaskInFile(context.testFilePath, project.projectId, {
100-
title: "Task 2",
101-
status: "done",
102-
completedDetails: "Second task done"
103-
})
104-
]);
92+
// Create tasks sequentially
93+
const task1 = await createTestTaskInFile(context.testFilePath, project.projectId, {
94+
title: "Task 1",
95+
status: "done",
96+
completedDetails: "First task done"
97+
});
98+
99+
const task2 = await createTestTaskInFile(context.testFilePath, project.projectId, {
100+
title: "Task 2",
101+
status: "done",
102+
completedDetails: "Second task done"
103+
});
104+
105+
const tasks = [task1, task2];
105106

106107
// Approve tasks in sequence
107108
for (const task of tasks) {
@@ -135,7 +136,7 @@ describe('approve_task Tool', () => {
135136

136137
verifyCallToolResult(result);
137138
expect(result.isError).toBe(true);
138-
expect(result.content[0].text).toContain('Error: Project non_existent_project not found');
139+
expect(result.content[0].text).toContain('Tool execution failed: Project non_existent_project not found');
139140
});
140141

141142
it('should return error for non-existent task', async () => {
@@ -153,7 +154,7 @@ describe('approve_task Tool', () => {
153154

154155
verifyCallToolResult(result);
155156
expect(result.isError).toBe(true);
156-
expect(result.content[0].text).toContain('Error: Task non_existent_task not found');
157+
expect(result.content[0].text).toContain('Tool execution failed: Task non_existent_task not found');
157158
});
158159

159160
it('should return error when approving incomplete task', async () => {
@@ -175,7 +176,7 @@ describe('approve_task Tool', () => {
175176

176177
verifyCallToolResult(result);
177178
expect(result.isError).toBe(true);
178-
expect(result.content[0].text).toContain('Error: Cannot approve incomplete task');
179+
expect(result.content[0].text).toContain('Tool execution failed: Task not done yet');
179180
});
180181

181182
it('should return error when approving already approved task', async () => {
@@ -199,7 +200,7 @@ describe('approve_task Tool', () => {
199200

200201
verifyCallToolResult(result);
201202
expect(result.isError).toBe(true);
202-
expect(result.content[0].text).toContain('Error: Task is already approved');
203+
expect(result.content[0].text).toContain('Tool execution failed: Task is already approved');
203204
});
204205
});
205206
});

0 commit comments

Comments
 (0)