Skip to content

Commit bc0fd77

Browse files
Merge pull request #47 from chriscarrollsmith/44-bring-error-handling-in-line-with-mcp-spec
Overhaul error handling and test patterns
2 parents 0e010d7 + 22a24bc commit bc0fd77

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

51 files changed

+5753
-5046
lines changed

.cursor/rules/errors.mdc

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
description:
3+
globs: **/errors.ts
4+
alwaysApply: false
5+
---
6+
# Error Flow
7+
8+
```mermaid
9+
graph TD
10+
subgraph Core_Logic
11+
FS[FileSystemService: e.g., FileReadError] --> TM[TaskManager: Throws App Errors, e.g., ProjectNotFound, TaskNotDone]
12+
TM -->|App Error with code ERR_xxxx| CLI_Handler["cli.ts Command Handler"]
13+
TM -->|App Error with code ERR_xxxx| ToolExec["toolExecutors.ts: execute"]
14+
end
15+
16+
subgraph CLI_Path
17+
CLI_Handler -->|App Error| CLI_Catch["cli.ts catch block"]
18+
CLI_Catch -->|Error Object| FormatCLI["client errors.ts formatCliError"]
19+
FormatCLI -->|"Error [ERR_xxxx]: message"| ConsoleOut["console.error Output"]
20+
end
21+
22+
subgraph MCP_Server_Path
23+
subgraph Validation_Layer
24+
ToolExecVal["toolExecutors.ts Validation"] -->|App Error, e.g., MissingParameter| ExecToolErrHandler
25+
end
26+
27+
subgraph App_Execution
28+
ToolExec -->|App Error with code ERR_xxxx| ExecToolErrHandler["tools.ts executeToolAndHandleErrors catch block"]
29+
ExecToolErrHandler -->|Map AppError to Protocol Error or Tool Result| ErrorMapping
30+
ErrorMapping -->|"If validation error (ERR_1xxx)"| McpError["Create McpError with appropriate ErrorCode"]
31+
ErrorMapping -->|"If business logic error (ERR_2xxx+)"| FormatResult["Format as isError true result"]
32+
33+
McpError -->|Throw| SDKHandler["server index.ts SDK Handler"]
34+
FormatResult -->|"{ content: [{ text: Error [ERR_xxxx]: message }], isError: true }"| SDKHandler
35+
end
36+
37+
SDKHandler -- Protocol Error --> SDKFormatError["SDK Formats as JSON-RPC Error Response"]
38+
SDKHandler -- Tool Result --> SDKFormatResult["SDK Formats as JSON-RPC Success Response"]
39+
40+
SDKFormatError -->|"{ error: { code: -326xx, message: ... } }"| MCPClient["MCP Client"]
41+
SDKFormatResult -->|"{ result: { content: [...], isError: true } }"| MCPClient
42+
end
43+
```
44+
45+
**Explanation of Updated Error Flow and Transformations:**
46+
47+
Errors are consistently through a unified `AppError` system:
48+
49+
1. **Validation Errors** (`ERR_1xxx` series)
50+
- Used for validation issues (e.g., MissingParameter, InvalidArgument)
51+
- Thrown by tool executors during parameter validation
52+
- Mapped to protocol-level McpErrors in `executeToolAndHandleErrors`
53+
54+
2. **Business Logic Errors** (`ERR_2xxx` and higher)
55+
- Used for all business logic and application-specific errors
56+
- Include specific error codes
57+
- Returned as serialized CallToolResults with `isError: true`

.cursor/rules/general.mdc

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
description:
3+
globs:
4+
alwaysApply: true
5+
---
6+
Work step-by-step. If presented with an implementation plan, implement the plan exactly. If the plan presents more than one implementation option, consult with the human user to decide between options. If you are tempted to embellish or imporve upon the plan, consult with the human user. Always complete the current task and wait for human review before proceeding to the next task.

.cursor/rules/tests.mdc

Lines changed: 1 addition & 194 deletions
Original file line numberDiff line numberDiff line change
@@ -3,197 +3,4 @@ description: Writing unit tests with `jest`
33
globs: tests/**/*
44
alwaysApply: false
55
---
6-
# Testing Guidelines for TypeScript + ES Modules + Jest
7-
8-
This guide contains cumulative in-context learnings about working with this project's testing stack.
9-
10-
## Unit vs. Integration Tests
11-
12-
**Never Mix Test Types**: Separate integration tests from unit tests into different files:
13-
- Simple unit tests without mocks for validating rules (like state transitions)
14-
- Integration tests with mocks for filesystem and external dependencies
15-
16-
## File Path Handling in Tests
17-
18-
1. **Environment Variables**:
19-
- Use `process.env.TASK_MANAGER_FILE_PATH` for configuring file paths in tests
20-
- Set this in `beforeEach` and clean up in `afterEach`:
21-
```typescript
22-
beforeEach(async () => {
23-
tempDir = path.join(os.tmpdir(), `test-${Date.now()}`);
24-
await fs.mkdir(tempDir, { recursive: true });
25-
tasksFilePath = path.join(tempDir, "test-tasks.json");
26-
process.env.TASK_MANAGER_FILE_PATH = tasksFilePath;
27-
});
28-
29-
afterEach(async () => {
30-
await fs.rm(tempDir, { recursive: true, force: true });
31-
delete process.env.TASK_MANAGER_FILE_PATH;
32-
});
33-
```
34-
35-
2. **Temporary Files**:
36-
- Create unique temp directories for each test run
37-
- Use `os.tmpdir()` for platform-independent temp directories
38-
- Include timestamps in directory names to prevent conflicts
39-
- Always clean up temp files in `afterEach`
40-
41-
## Jest ESM Mocking, Step-by-Step
42-
43-
1. **Type-Only Import:**
44-
Import types for static analysis without actually executing the module code:
45-
```typescript
46-
import type { MyService as MyServiceType } from 'path/to/MyService.js';
47-
import type { readFile as ReadFileType } from 'node:fs/promises';
48-
```
49-
50-
2. **Register Mock:**
51-
Use `jest.unstable_mockModule` to replace the real module:
52-
```typescript
53-
jest.unstable_mockModule('node:fs/promises', () => ({
54-
__esModule: true,
55-
readFile: jest.fn(),
56-
}));
57-
```
58-
59-
3. **Set Default Mock Implementations, Then Dynamically Import Modules:**
60-
You must dynamically import the modules to be mocked and/or tested *after* registering mocks and setting any mock implementations. This ensures that when `MyService` attempts to import `node:fs/promises`, it gets your mocked version. Depending how you want to scope your mock implementations, you can do this in `beforeAll`, `beforeEach`, or at the top of each test.
61-
```typescript
62-
let MyService: typeof MyServiceType;
63-
let readFile: jest.MockedFunction<ReadFileType>;
64-
65-
beforeAll(async () => {
66-
const fsPromisesMock = await import('node:fs/promises');
67-
readFile = fsPromisesMock.readFile as jest.MockedFunction<ReadFileType>;
68-
69-
// Set default implementation
70-
readFile.mockResolvedValue('default mocked content');
71-
72-
const serviceModule = await import('path/to/MyService.js');
73-
MyService = serviceModule.MyService;
74-
});
75-
```
76-
77-
4. **Setup in `beforeEach`:**
78-
Reset mocks and set default behaviors before each test:
79-
```typescript
80-
beforeEach(() => {
81-
jest.clearAllMocks();
82-
readFile.mockResolvedValue('');
83-
});
84-
```
85-
86-
5. **Write a Test:**
87-
Now you can test your service with the mocked `readFile`:
88-
```typescript
89-
describe('MyService', () => {
90-
let myServiceInstance: MyServiceType;
91-
92-
beforeEach(() => {
93-
myServiceInstance = new MyService('somePath');
94-
});
95-
96-
it('should do something', async () => {
97-
readFile.mockResolvedValueOnce('some data');
98-
const result = await myServiceInstance.someMethod();
99-
expect(result).toBe('expected result');
100-
expect(readFile).toHaveBeenCalledWith('somePath', 'utf-8');
101-
});
102-
});
103-
```
104-
105-
### Mocking a Class with Methods
106-
107-
If you have a class `MyClass` that has both instance methods and static methods, you can mock it in an **ES Modules + TypeScript** setup using the same pattern. For instance:
108-
109-
```typescript
110-
// 1. Create typed jest mock functions using the original types
111-
type InitResult = { data: string };
112-
113-
const mockInit = jest.fn() as jest.MockedFunction<MyClass['init']>;
114-
const mockDoWork = jest.fn() as jest.MockedFunction<MyClass['doWork']>;
115-
const mockStaticHelper = jest.fn() as jest.MockedFunction<typeof MyClass.staticHelper>;
116-
117-
// 2. Use jest.unstable_mockModule with an ES6 class in the factory
118-
jest.unstable_mockModule('path/to/MyClass.js', () => {
119-
class MockMyClass {
120-
// Instance methods
121-
init = mockInit;
122-
doWork = mockDoWork;
123-
124-
// Static method
125-
static staticHelper = mockStaticHelper;
126-
}
127-
128-
return {
129-
__esModule: true,
130-
MyClass: MockMyClass, // same name/structure as real export
131-
};
132-
});
133-
134-
// 3. Import your class after mocking
135-
let MyClass: typeof import('path/to/MyClass.js')['MyClass'];
136-
137-
beforeAll(async () => {
138-
const myClassModule = await import('path/to/MyClass.js');
139-
MyClass = myClassModule.MyClass;
140-
});
141-
142-
// 4. Write tests and reset mocks
143-
beforeEach(() => {
144-
jest.clearAllMocks();
145-
mockInit.mockResolvedValue({ data: 'default' });
146-
mockStaticHelper.mockReturnValue(42);
147-
});
148-
149-
describe('MyClass', () => {
150-
it('should call init', async () => {
151-
const instance = new MyClass();
152-
const result = await instance.init();
153-
expect(result).toEqual({ data: 'default' });
154-
expect(mockInit).toHaveBeenCalledTimes(1);
155-
});
156-
157-
it('should call the static helper', () => {
158-
const val = MyClass.staticHelper();
159-
expect(val).toBe(42);
160-
expect(mockStaticHelper).toHaveBeenCalledTimes(1);
161-
});
162-
});
163-
```
164-
165-
### Best Practice: **Type** Your Mocked Functions
166-
167-
By default, `jest.fn()` is very generic and doesn't enforce parameter or return types. This can cause TypeScript errors like:
168-
169-
> `Argument of type 'undefined' is not assignable to parameter of type 'never'`
170-
171-
or
172-
173-
> `Type 'Promise<SomeType>' is not assignable to type 'FunctionLike'`
174-
175-
To avoid these, **use the original type with `jest.MockedFunction`**. For example, if your real function is:
176-
177-
```typescript
178-
async function loadStuff(id: string): Promise<string[]> {
179-
// ...
180-
}
181-
```
182-
183-
then you should type the mock as:
184-
185-
```typescript
186-
const mockLoadStuff = jest.fn() as jest.MockedFunction<typeof loadStuff>;
187-
```
188-
189-
For class methods, use the class type to get the method signature:
190-
191-
```typescript
192-
const mockClassMethod = jest.fn() as jest.MockedFunction<YourClass['classMethod']>;
193-
```
194-
195-
This helps TypeScript catch mistakes if you:
196-
- call the function with the wrong argument types
197-
- use `mockResolvedValue` with the wrong shape
198-
199-
Once typed properly, your `mockResolvedValue(...)`, `mockImplementation(...)`, etc. calls will be fully type-safe.
6+
Make use of the helpers in tests/mcp/test-helpers.ts.

.github/workflows/npm-publish.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ jobs:
1313
node-version: 'latest'
1414
- run: npm ci
1515
- run: npm install -g tsx
16-
- run: npm run build
1716
- run: npm test
1817

1918
publish:

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,14 @@ This will show the available commands and options.
4040
The task manager supports multiple LLM providers for generating project plans. You can configure one or more of the following environment variables depending on which providers you want to use:
4141

4242
- `OPENAI_API_KEY`: Required for using OpenAI models (e.g., GPT-4)
43-
- `GEMINI_API_KEY`: Required for using Google's Gemini models
43+
- `GOOGLE_GENERATIVE_AI_API_KEY`: Required for using Google's Gemini models
4444
- `DEEPSEEK_API_KEY`: Required for using Deepseek models
4545

4646
To generate project plans using the CLI, set these environment variables in your shell:
4747

4848
```bash
4949
export OPENAI_API_KEY="your-api-key"
50-
export GEMINI_API_KEY="your-api-key"
50+
export GOOGLE_GENERATIVE_AI_API_KEY="your-api-key"
5151
export DEEPSEEK_API_KEY="your-api-key"
5252
```
5353

@@ -61,7 +61,7 @@ Or you can include them in your MCP client configuration to generate project pla
6161
"args": ["-y", "taskqueue-mcp"],
6262
"env": {
6363
"OPENAI_API_KEY": "your-api-key",
64-
"GEMINI_API_KEY": "your-api-key",
64+
"GOOGLE_GENERATIVE_AI_API_KEY": "your-api-key",
6565
"DEEPSEEK_API_KEY": "your-api-key"
6666
}
6767
}

babel.config.cjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
module.exports = {
2+
presets: [
3+
['@babel/preset-env', { targets: { node: 'current' } }],
4+
'@babel/preset-typescript',
5+
],
6+
};

jest.config.cjs

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,7 @@
1-
const { createDefaultEsmPreset } = require('ts-jest');
2-
3-
const presetConfig = createDefaultEsmPreset({
4-
useESM: true,
5-
});
6-
71
module.exports = {
8-
...presetConfig,
92
testEnvironment: 'node',
103
moduleNameMapper: {
11-
'^(\\.{1,2}/.*)\\.js$': '$1',
4+
'^(\\.{1,2}/.*)\\.js$': '$1'
125
},
136
modulePathIgnorePatterns: ['<rootDir>/dist/'],
147
// Force Jest to exit after all tests have completed

0 commit comments

Comments
 (0)