@prmichaelsen/task-mcp 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +19 -0
- package/AGENT.md +1165 -0
- package/CHANGELOG.md +72 -0
- package/agent/commands/acp.commit.md +511 -0
- package/agent/commands/acp.init.md +376 -0
- package/agent/commands/acp.package-install.md +347 -0
- package/agent/commands/acp.proceed.md +311 -0
- package/agent/commands/acp.report.md +392 -0
- package/agent/commands/acp.status.md +280 -0
- package/agent/commands/acp.sync.md +323 -0
- package/agent/commands/acp.update.md +301 -0
- package/agent/commands/acp.validate.md +385 -0
- package/agent/commands/acp.version-check-for-updates.md +275 -0
- package/agent/commands/acp.version-check.md +190 -0
- package/agent/commands/acp.version-update.md +288 -0
- package/agent/commands/command.template.md +273 -0
- package/agent/commands/git.commit.md +511 -0
- package/agent/commands/git.init.md +513 -0
- package/agent/design/.gitkeep +0 -0
- package/agent/design/acp-task-execution-requirements.md +555 -0
- package/agent/design/api-dto-design.md +394 -0
- package/agent/design/code-extraction-guide.md +827 -0
- package/agent/design/design.template.md +136 -0
- package/agent/design/requirements.template.md +387 -0
- package/agent/design/rest-api-integration.md +489 -0
- package/agent/design/sdk-export-requirements.md +549 -0
- package/agent/milestones/.gitkeep +0 -0
- package/agent/milestones/milestone-1-{title}.template.md +206 -0
- package/agent/milestones/milestone-2-task-infrastructure.md +232 -0
- package/agent/milestones/milestone-4-autonomous-execution.md +235 -0
- package/agent/patterns/.gitkeep +0 -0
- package/agent/patterns/bootstrap.md +1271 -0
- package/agent/patterns/bootstrap.template.md +1237 -0
- package/agent/patterns/pattern.template.md +364 -0
- package/agent/progress.template.yaml +158 -0
- package/agent/progress.yaml +375 -0
- package/agent/scripts/check-for-updates.sh +88 -0
- package/agent/scripts/install.sh +157 -0
- package/agent/scripts/uninstall.sh +75 -0
- package/agent/scripts/update.sh +139 -0
- package/agent/scripts/version.sh +35 -0
- package/agent/tasks/.gitkeep +0 -0
- package/agent/tasks/task-1-{title}.template.md +225 -0
- package/agent/tasks/task-86-task-data-model-schemas.md +143 -0
- package/agent/tasks/task-87-task-database-service.md +220 -0
- package/agent/tasks/task-88-firebase-client-wrapper.md +139 -0
- package/agent/tasks/task-88-task-execution-engine.md +277 -0
- package/agent/tasks/task-89-mcp-server-implementation.md +197 -0
- package/agent/tasks/task-90-build-configuration.md +146 -0
- package/agent/tasks/task-91-deployment-configuration.md +128 -0
- package/coverage/base.css +224 -0
- package/coverage/block-navigation.js +87 -0
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +191 -0
- package/coverage/lcov-report/base.css +224 -0
- package/coverage/lcov-report/block-navigation.js +87 -0
- package/coverage/lcov-report/favicon.png +0 -0
- package/coverage/lcov-report/index.html +191 -0
- package/coverage/lcov-report/prettify.css +1 -0
- package/coverage/lcov-report/prettify.js +2 -0
- package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
- package/coverage/lcov-report/sorter.js +210 -0
- package/coverage/lcov-report/src/client.ts.html +1030 -0
- package/coverage/lcov-report/src/constant/collections.ts.html +469 -0
- package/coverage/lcov-report/src/constant/index.html +116 -0
- package/coverage/lcov-report/src/dto/index.html +116 -0
- package/coverage/lcov-report/src/dto/transformers.ts.html +568 -0
- package/coverage/lcov-report/src/index.html +146 -0
- package/coverage/lcov-report/src/schemas/index.html +116 -0
- package/coverage/lcov-report/src/schemas/task.ts.html +547 -0
- package/coverage/lcov-report/src/server-factory.ts.html +418 -0
- package/coverage/lcov-report/src/server.ts.html +289 -0
- package/coverage/lcov-report/src/services/index.html +116 -0
- package/coverage/lcov-report/src/services/task-database.service.ts.html +1495 -0
- package/coverage/lcov-report/src/tools/index.html +236 -0
- package/coverage/lcov-report/src/tools/index.ts.html +292 -0
- package/coverage/lcov-report/src/tools/task-add-message.ts.html +277 -0
- package/coverage/lcov-report/src/tools/task-complete-task-item.ts.html +343 -0
- package/coverage/lcov-report/src/tools/task-create-milestone.ts.html +286 -0
- package/coverage/lcov-report/src/tools/task-create-task-item.ts.html +358 -0
- package/coverage/lcov-report/src/tools/task-get-next-step.ts.html +460 -0
- package/coverage/lcov-report/src/tools/task-get-status.ts.html +316 -0
- package/coverage/lcov-report/src/tools/task-report-completion.ts.html +343 -0
- package/coverage/lcov-report/src/tools/task-update-progress.ts.html +232 -0
- package/coverage/lcov.info +974 -0
- package/coverage/prettify.css +1 -0
- package/coverage/prettify.js +2 -0
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +210 -0
- package/coverage/src/client.ts.html +1030 -0
- package/coverage/src/constant/collections.ts.html +469 -0
- package/coverage/src/constant/index.html +116 -0
- package/coverage/src/dto/index.html +116 -0
- package/coverage/src/dto/transformers.ts.html +568 -0
- package/coverage/src/index.html +146 -0
- package/coverage/src/schemas/index.html +116 -0
- package/coverage/src/schemas/task.ts.html +547 -0
- package/coverage/src/server-factory.ts.html +418 -0
- package/coverage/src/server.ts.html +289 -0
- package/coverage/src/services/index.html +116 -0
- package/coverage/src/services/task-database.service.ts.html +1495 -0
- package/coverage/src/tools/index.html +236 -0
- package/coverage/src/tools/index.ts.html +292 -0
- package/coverage/src/tools/task-add-message.ts.html +277 -0
- package/coverage/src/tools/task-complete-task-item.ts.html +343 -0
- package/coverage/src/tools/task-create-milestone.ts.html +286 -0
- package/coverage/src/tools/task-create-task-item.ts.html +358 -0
- package/coverage/src/tools/task-get-next-step.ts.html +460 -0
- package/coverage/src/tools/task-get-status.ts.html +316 -0
- package/coverage/src/tools/task-report-completion.ts.html +343 -0
- package/coverage/src/tools/task-update-progress.ts.html +232 -0
- package/firestore.rules +95 -0
- package/jest.config.js +31 -0
- package/package.json +67 -0
- package/src/client.spec.ts +199 -0
- package/src/client.ts +315 -0
- package/src/constant/collections.ts +128 -0
- package/src/dto/index.ts +47 -0
- package/src/dto/task-api.dto.ts +219 -0
- package/src/dto/transformers.spec.ts +462 -0
- package/src/dto/transformers.ts +161 -0
- package/src/schemas/task.ts +154 -0
- package/src/server-factory.spec.ts +70 -0
- package/src/server-factory.ts +111 -0
- package/src/server.ts +68 -0
- package/src/services/task-database.service.e2e.ts +116 -0
- package/src/services/task-database.service.spec.ts +479 -0
- package/src/services/task-database.service.ts +470 -0
- package/src/test-schemas.ts +161 -0
- package/src/tools/index.ts +69 -0
- package/src/tools/task-add-message.ts +64 -0
- package/src/tools/task-complete-task-item.ts +86 -0
- package/src/tools/task-create-milestone.ts +67 -0
- package/src/tools/task-create-task-item.ts +91 -0
- package/src/tools/task-get-next-step.spec.ts +136 -0
- package/src/tools/task-get-next-step.ts +125 -0
- package/src/tools/task-get-status.spec.ts +213 -0
- package/src/tools/task-get-status.ts +77 -0
- package/src/tools/task-report-completion.ts +86 -0
- package/src/tools/task-update-progress.ts +49 -0
- package/src/tools/tools.spec.ts +194 -0
- package/tsconfig.json +31 -0
package/firestore.rules
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
rules_version = '2';
|
|
2
|
+
|
|
3
|
+
service cloud.firestore {
|
|
4
|
+
match /databases/{database}/documents {
|
|
5
|
+
|
|
6
|
+
// Helper function to check if user is authenticated
|
|
7
|
+
function isAuthenticated() {
|
|
8
|
+
return request.auth != null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Helper function to check if user owns the resource
|
|
12
|
+
function isOwner(userId) {
|
|
13
|
+
return isAuthenticated() && request.auth.uid == userId;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Helper function to validate task structure
|
|
17
|
+
function isValidTask() {
|
|
18
|
+
let task = request.resource.data;
|
|
19
|
+
return task.keys().hasAll(['id', 'user_id', 'title', 'description', 'status',
|
|
20
|
+
'created_at', 'updated_at', 'progress', 'execution', 'config'])
|
|
21
|
+
&& task.status in ['not_started', 'in_progress', 'paused', 'completed', 'failed']
|
|
22
|
+
&& task.user_id is string
|
|
23
|
+
&& task.title is string
|
|
24
|
+
&& task.description is string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Helper function to validate task message structure
|
|
28
|
+
function isValidTaskMessage() {
|
|
29
|
+
let message = request.resource.data;
|
|
30
|
+
return message.keys().hasAll(['task_id', 'role', 'content', 'timestamp'])
|
|
31
|
+
&& message.role in ['user', 'assistant', 'system']
|
|
32
|
+
&& message.task_id is string
|
|
33
|
+
&& message.content is string
|
|
34
|
+
&& message.timestamp is string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// User tasks collection
|
|
38
|
+
// Path: users/{userId}/tasks/{taskId}
|
|
39
|
+
match /users/{userId}/tasks/{taskId} {
|
|
40
|
+
// Allow read if user owns the task
|
|
41
|
+
allow read: if isOwner(userId);
|
|
42
|
+
|
|
43
|
+
// Allow create if user is authenticated and owns the task
|
|
44
|
+
allow create: if isOwner(userId)
|
|
45
|
+
&& isValidTask()
|
|
46
|
+
&& request.resource.data.user_id == userId;
|
|
47
|
+
|
|
48
|
+
// Allow update if user owns the task and user_id doesn't change
|
|
49
|
+
allow update: if isOwner(userId)
|
|
50
|
+
&& isValidTask()
|
|
51
|
+
&& request.resource.data.user_id == resource.data.user_id;
|
|
52
|
+
|
|
53
|
+
// Allow delete if user owns the task
|
|
54
|
+
allow delete: if isOwner(userId);
|
|
55
|
+
|
|
56
|
+
// Task messages subcollection
|
|
57
|
+
// Path: users/{userId}/tasks/{taskId}/messages/{messageId}
|
|
58
|
+
match /messages/{messageId} {
|
|
59
|
+
// Allow read if user owns the parent task
|
|
60
|
+
allow read: if isOwner(userId);
|
|
61
|
+
|
|
62
|
+
// Allow create if user owns the parent task and message is valid
|
|
63
|
+
allow create: if isOwner(userId)
|
|
64
|
+
&& isValidTaskMessage()
|
|
65
|
+
&& request.resource.data.task_id == taskId;
|
|
66
|
+
|
|
67
|
+
// Allow update if user owns the parent task
|
|
68
|
+
allow update: if isOwner(userId)
|
|
69
|
+
&& isValidTaskMessage()
|
|
70
|
+
&& request.resource.data.task_id == resource.data.task_id;
|
|
71
|
+
|
|
72
|
+
// Allow delete if user owns the parent task
|
|
73
|
+
allow delete: if isOwner(userId);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Task events collection (for WebSocket updates)
|
|
78
|
+
// Path: users/{userId}/task_events/{eventId}
|
|
79
|
+
match /users/{userId}/task_events/{eventId} {
|
|
80
|
+
// Allow read if user owns the events
|
|
81
|
+
allow read: if isOwner(userId);
|
|
82
|
+
|
|
83
|
+
// Allow create if user is authenticated (system can create events)
|
|
84
|
+
allow create: if isAuthenticated();
|
|
85
|
+
|
|
86
|
+
// Deny updates and deletes (events are immutable)
|
|
87
|
+
allow update, delete: if false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Deny all other access
|
|
91
|
+
match /{document=**} {
|
|
92
|
+
allow read, write: if false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
package/jest.config.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
preset: 'ts-jest/presets/default-esm',
|
|
3
|
+
testEnvironment: 'node',
|
|
4
|
+
extensionsToTreatAsEsm: ['.ts'],
|
|
5
|
+
roots: ['<rootDir>/src'],
|
|
6
|
+
testMatch: ['**/*.spec.ts'],
|
|
7
|
+
moduleFileExtensions: ['ts', 'js'],
|
|
8
|
+
collectCoverage: true,
|
|
9
|
+
coverageDirectory: 'coverage',
|
|
10
|
+
coverageReporters: ['text', 'lcov', 'html'],
|
|
11
|
+
collectCoverageFrom: [
|
|
12
|
+
'src/**/*.ts',
|
|
13
|
+
'!src/**/*.d.ts',
|
|
14
|
+
'!src/**/*.spec.ts',
|
|
15
|
+
'!src/**/*.e2e.ts',
|
|
16
|
+
'!src/test-schemas.ts',
|
|
17
|
+
],
|
|
18
|
+
moduleNameMapper: {
|
|
19
|
+
'^@/(.*)\\.(js|ts)$': '<rootDir>/src/$1',
|
|
20
|
+
'^@/(.*)$': '<rootDir>/src/$1',
|
|
21
|
+
'^(\\.{1,2}/.*)\\.js$': '$1',
|
|
22
|
+
},
|
|
23
|
+
transform: {
|
|
24
|
+
'^.+\\.ts$': [
|
|
25
|
+
'ts-jest',
|
|
26
|
+
{
|
|
27
|
+
useESM: true,
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@prmichaelsen/task-mcp",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "MCP server for autonomous task execution with ACP-style task management",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/server.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"task-mcp": "./dist/server.js"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/server.d.ts",
|
|
13
|
+
"import": "./dist/server.js"
|
|
14
|
+
},
|
|
15
|
+
"./factory": {
|
|
16
|
+
"types": "./dist/server-factory.d.ts",
|
|
17
|
+
"import": "./dist/server-factory.js"
|
|
18
|
+
},
|
|
19
|
+
"./client": {
|
|
20
|
+
"types": "./dist/client.d.ts",
|
|
21
|
+
"import": "./dist/client.js"
|
|
22
|
+
},
|
|
23
|
+
"./services": {
|
|
24
|
+
"types": "./dist/services/task-database.service.d.ts",
|
|
25
|
+
"import": "./dist/services/task-database.service.js"
|
|
26
|
+
},
|
|
27
|
+
"./schemas": {
|
|
28
|
+
"types": "./dist/schemas/task.d.ts",
|
|
29
|
+
"import": "./dist/schemas/task.js"
|
|
30
|
+
},
|
|
31
|
+
"./dto": {
|
|
32
|
+
"types": "./dist/dto/index.d.ts",
|
|
33
|
+
"import": "./dist/dto/index.js"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsc",
|
|
38
|
+
"test": "jest",
|
|
39
|
+
"test:unit": "jest --testMatch='**/*.spec.ts'",
|
|
40
|
+
"test:e2e": "jest --testMatch='**/*.e2e.ts'",
|
|
41
|
+
"test:schemas": "tsx src/test-schemas.ts",
|
|
42
|
+
"test:all": "npm run test:unit && npm run test:e2e",
|
|
43
|
+
"typecheck": "tsc --noEmit"
|
|
44
|
+
},
|
|
45
|
+
"keywords": [
|
|
46
|
+
"mcp",
|
|
47
|
+
"task-execution",
|
|
48
|
+
"autonomous",
|
|
49
|
+
"acp",
|
|
50
|
+
"firestore"
|
|
51
|
+
],
|
|
52
|
+
"author": "",
|
|
53
|
+
"license": "MIT",
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
56
|
+
"firebase-admin": "^13.6.1",
|
|
57
|
+
"zod": "^4.3.6"
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"@types/jest": "^29.5.14",
|
|
61
|
+
"@types/node": "^25.2.3",
|
|
62
|
+
"jest": "^29.7.0",
|
|
63
|
+
"ts-jest": "^29.4.6",
|
|
64
|
+
"tsx": "^4.21.0",
|
|
65
|
+
"typescript": "^5.9.3"
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit Tests for FirebaseClient
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals'
|
|
6
|
+
import { FirebaseClient } from '@/client.js'
|
|
7
|
+
import * as firebaseApp from 'firebase-admin/app'
|
|
8
|
+
import * as firebaseFirestore from 'firebase-admin/firestore'
|
|
9
|
+
import { TaskDatabaseService } from '@/services/task-database.service.js'
|
|
10
|
+
|
|
11
|
+
// Mock Firebase Admin
|
|
12
|
+
jest.mock('firebase-admin/app')
|
|
13
|
+
jest.mock('firebase-admin/firestore')
|
|
14
|
+
jest.mock('@/services/task-database.service.js')
|
|
15
|
+
|
|
16
|
+
describe('FirebaseClient', () => {
|
|
17
|
+
const mockUserId = 'test-user-123'
|
|
18
|
+
const mockServiceAccountPath = './test-service-account.json'
|
|
19
|
+
|
|
20
|
+
let client: FirebaseClient
|
|
21
|
+
let mockApp: any
|
|
22
|
+
let mockDb: any
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
// Setup mocks
|
|
26
|
+
mockApp = { name: `task-mcp-${mockUserId}` }
|
|
27
|
+
mockDb = {}
|
|
28
|
+
|
|
29
|
+
;(firebaseApp.getApps as jest.Mock<any>).mockReturnValue([])
|
|
30
|
+
;(firebaseApp.initializeApp as jest.Mock<any>).mockReturnValue(mockApp)
|
|
31
|
+
;(firebaseApp.cert as jest.Mock<any>).mockReturnValue({})
|
|
32
|
+
;(firebaseFirestore.getFirestore as jest.Mock<any>).mockReturnValue(mockDb)
|
|
33
|
+
;(TaskDatabaseService.initialize as jest.Mock<any>).mockReturnValue(undefined)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
jest.clearAllMocks()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('Constructor', () => {
|
|
41
|
+
it('should create client with userId', () => {
|
|
42
|
+
client = new FirebaseClient({
|
|
43
|
+
userId: mockUserId,
|
|
44
|
+
serviceAccountPath: mockServiceAccountPath
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
expect(client).toBeDefined()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('should throw error if userId is missing', () => {
|
|
51
|
+
expect(() => {
|
|
52
|
+
new FirebaseClient({
|
|
53
|
+
userId: '',
|
|
54
|
+
serviceAccountPath: mockServiceAccountPath
|
|
55
|
+
})
|
|
56
|
+
}).toThrow('userId is required')
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('connect', () => {
|
|
61
|
+
it('should initialize Firebase with service account path', async () => {
|
|
62
|
+
client = new FirebaseClient({
|
|
63
|
+
userId: mockUserId,
|
|
64
|
+
serviceAccountPath: mockServiceAccountPath
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
await client.connect()
|
|
68
|
+
|
|
69
|
+
expect(firebaseApp.cert).toHaveBeenCalledWith(mockServiceAccountPath)
|
|
70
|
+
expect(firebaseApp.initializeApp).toHaveBeenCalled()
|
|
71
|
+
expect(firebaseFirestore.getFirestore).toHaveBeenCalledWith(mockApp)
|
|
72
|
+
expect(TaskDatabaseService.initialize).toHaveBeenCalledWith(mockDb)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('should initialize Firebase with service account JSON', async () => {
|
|
76
|
+
const mockServiceAccount = { type: 'service_account', project_id: 'test' }
|
|
77
|
+
|
|
78
|
+
client = new FirebaseClient({
|
|
79
|
+
userId: mockUserId,
|
|
80
|
+
serviceAccountJson: JSON.stringify(mockServiceAccount)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
await client.connect()
|
|
84
|
+
|
|
85
|
+
expect(firebaseApp.cert).toHaveBeenCalledWith(mockServiceAccount)
|
|
86
|
+
expect(firebaseApp.initializeApp).toHaveBeenCalled()
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('should reuse existing app if already initialized', async () => {
|
|
90
|
+
const existingApp = { name: `task-mcp-${mockUserId}` }
|
|
91
|
+
;(firebaseApp.getApps as jest.Mock<any>).mockReturnValue([existingApp])
|
|
92
|
+
|
|
93
|
+
client = new FirebaseClient({
|
|
94
|
+
userId: mockUserId,
|
|
95
|
+
serviceAccountPath: mockServiceAccountPath
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
await client.connect()
|
|
99
|
+
|
|
100
|
+
expect(firebaseApp.initializeApp).not.toHaveBeenCalled()
|
|
101
|
+
expect(firebaseFirestore.getFirestore).toHaveBeenCalledWith(existingApp)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('should not reconnect if already connected', async () => {
|
|
105
|
+
client = new FirebaseClient({
|
|
106
|
+
userId: mockUserId,
|
|
107
|
+
serviceAccountPath: mockServiceAccountPath
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
await client.connect()
|
|
111
|
+
await client.connect() // Second call
|
|
112
|
+
|
|
113
|
+
expect(firebaseApp.initializeApp).toHaveBeenCalledTimes(1)
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
describe('disconnect', () => {
|
|
118
|
+
it('should delete app and cleanup', async () => {
|
|
119
|
+
;(firebaseApp.deleteApp as jest.Mock<any>).mockResolvedValue(undefined)
|
|
120
|
+
|
|
121
|
+
client = new FirebaseClient({
|
|
122
|
+
userId: mockUserId,
|
|
123
|
+
serviceAccountPath: mockServiceAccountPath
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
await client.connect()
|
|
127
|
+
await client.disconnect()
|
|
128
|
+
|
|
129
|
+
expect(firebaseApp.deleteApp).toHaveBeenCalledWith(mockApp)
|
|
130
|
+
expect(client.isConnected()).toBe(false)
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
describe('Task Operations', () => {
|
|
135
|
+
beforeEach(async () => {
|
|
136
|
+
client = new FirebaseClient({
|
|
137
|
+
userId: mockUserId,
|
|
138
|
+
serviceAccountPath: mockServiceAccountPath
|
|
139
|
+
})
|
|
140
|
+
await client.connect()
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('should get task', async () => {
|
|
144
|
+
const mockTask = { id: 'task-123', title: 'Test Task' } as any
|
|
145
|
+
;(TaskDatabaseService.getTask as jest.Mock<any>).mockResolvedValue(mockTask)
|
|
146
|
+
|
|
147
|
+
const task = await client.getTask('task-123')
|
|
148
|
+
|
|
149
|
+
expect(TaskDatabaseService.getTask).toHaveBeenCalledWith(mockUserId, 'task-123')
|
|
150
|
+
expect(task).toEqual(mockTask)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('should create task', async () => {
|
|
154
|
+
const mockTask = { id: 'task-456', title: 'New Task' } as any
|
|
155
|
+
;(TaskDatabaseService.createTask as jest.Mock<any>).mockResolvedValue(mockTask)
|
|
156
|
+
|
|
157
|
+
const task = await client.createTask('New Task', 'Description')
|
|
158
|
+
|
|
159
|
+
expect(TaskDatabaseService.createTask).toHaveBeenCalledWith(
|
|
160
|
+
mockUserId,
|
|
161
|
+
'New Task',
|
|
162
|
+
'Description',
|
|
163
|
+
undefined,
|
|
164
|
+
undefined
|
|
165
|
+
)
|
|
166
|
+
expect(task).toEqual(mockTask)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('should update task status', async () => {
|
|
170
|
+
;(TaskDatabaseService.updateTaskStatus as jest.Mock<any>).mockResolvedValue(undefined)
|
|
171
|
+
|
|
172
|
+
await client.updateTaskStatus('task-123', 'in_progress')
|
|
173
|
+
|
|
174
|
+
expect(TaskDatabaseService.updateTaskStatus).toHaveBeenCalledWith(
|
|
175
|
+
mockUserId,
|
|
176
|
+
'task-123',
|
|
177
|
+
'in_progress'
|
|
178
|
+
)
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
describe('Auto-connect', () => {
|
|
183
|
+
it('should auto-connect when calling methods if not connected', async () => {
|
|
184
|
+
client = new FirebaseClient({
|
|
185
|
+
userId: mockUserId,
|
|
186
|
+
serviceAccountPath: mockServiceAccountPath
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
;(TaskDatabaseService.getTask as jest.Mock<any>).mockResolvedValue(null)
|
|
190
|
+
|
|
191
|
+
// Don't call connect() manually
|
|
192
|
+
await client.getTask('task-123')
|
|
193
|
+
|
|
194
|
+
// Should have auto-connected
|
|
195
|
+
expect(firebaseApp.initializeApp).toHaveBeenCalled()
|
|
196
|
+
expect(client.isConnected()).toBe(true)
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
})
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Firebase Client Wrapper
|
|
3
|
+
*
|
|
4
|
+
* Provides a clean interface for MCP tools to interact with Firestore.
|
|
5
|
+
* Handles service account authentication and user-scoped operations.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { initializeApp, cert, App, getApps, deleteApp } from 'firebase-admin/app'
|
|
9
|
+
import { getFirestore, Firestore } from 'firebase-admin/firestore'
|
|
10
|
+
import { TaskDatabaseService } from '@/services/task-database.service.js'
|
|
11
|
+
import type { Task, Milestone, TaskItem } from '@/schemas/task.js'
|
|
12
|
+
|
|
13
|
+
export interface FirebaseClientConfig {
|
|
14
|
+
userId: string
|
|
15
|
+
serviceAccountPath?: string
|
|
16
|
+
serviceAccountJson?: string
|
|
17
|
+
projectId?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class FirebaseClient {
|
|
21
|
+
private app: App | null = null
|
|
22
|
+
private db: Firestore | null = null
|
|
23
|
+
private userId: string
|
|
24
|
+
private config: FirebaseClientConfig
|
|
25
|
+
|
|
26
|
+
constructor(config: FirebaseClientConfig) {
|
|
27
|
+
if (!config.userId) {
|
|
28
|
+
throw new Error('userId is required')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
this.userId = config.userId
|
|
32
|
+
this.config = config
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Initialize Firebase connection
|
|
37
|
+
*/
|
|
38
|
+
async connect(): Promise<void> {
|
|
39
|
+
if (this.app) {
|
|
40
|
+
return // Already connected
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
// Check if already initialized
|
|
45
|
+
const existingApps = getApps()
|
|
46
|
+
const appName = `task-mcp-${this.userId}`
|
|
47
|
+
const existingApp = existingApps.find(app => app.name === appName)
|
|
48
|
+
|
|
49
|
+
if (existingApp) {
|
|
50
|
+
this.app = existingApp
|
|
51
|
+
this.db = getFirestore(this.app)
|
|
52
|
+
TaskDatabaseService.initialize(this.db)
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Get service account credentials
|
|
57
|
+
let credential
|
|
58
|
+
|
|
59
|
+
if (this.config.serviceAccountJson) {
|
|
60
|
+
// Use JSON string
|
|
61
|
+
const serviceAccount = JSON.parse(this.config.serviceAccountJson)
|
|
62
|
+
credential = cert(serviceAccount)
|
|
63
|
+
} else if (this.config.serviceAccountPath) {
|
|
64
|
+
// Use file path
|
|
65
|
+
credential = cert(this.config.serviceAccountPath)
|
|
66
|
+
} else if (process.env.FIREBASE_SERVICE_ACCOUNT_JSON) {
|
|
67
|
+
// From environment variable (JSON)
|
|
68
|
+
const serviceAccount = JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT_JSON)
|
|
69
|
+
credential = cert(serviceAccount)
|
|
70
|
+
} else if (process.env.FIREBASE_SERVICE_ACCOUNT_PATH) {
|
|
71
|
+
// From environment variable (path)
|
|
72
|
+
credential = cert(process.env.FIREBASE_SERVICE_ACCOUNT_PATH)
|
|
73
|
+
} else {
|
|
74
|
+
throw new Error('Firebase service account credentials required')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Initialize app
|
|
78
|
+
this.app = initializeApp(
|
|
79
|
+
{
|
|
80
|
+
credential,
|
|
81
|
+
projectId: this.config.projectId || process.env.FIREBASE_PROJECT_ID
|
|
82
|
+
},
|
|
83
|
+
appName
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
this.db = getFirestore(this.app)
|
|
87
|
+
TaskDatabaseService.initialize(this.db)
|
|
88
|
+
} catch (error) {
|
|
89
|
+
throw new Error(`Failed to initialize Firebase: ${error instanceof Error ? error.message : String(error)}`)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Disconnect and cleanup
|
|
95
|
+
*/
|
|
96
|
+
async disconnect(): Promise<void> {
|
|
97
|
+
if (this.app) {
|
|
98
|
+
await deleteApp(this.app)
|
|
99
|
+
this.app = null
|
|
100
|
+
this.db = null
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Check if connected
|
|
106
|
+
*/
|
|
107
|
+
isConnected(): boolean {
|
|
108
|
+
return this.app !== null && this.db !== null
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ==================== Task Operations ====================
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Get a task (user-scoped)
|
|
115
|
+
*/
|
|
116
|
+
async getTask(taskId: string): Promise<Task | null> {
|
|
117
|
+
if (!this.isConnected()) {
|
|
118
|
+
await this.connect()
|
|
119
|
+
}
|
|
120
|
+
return TaskDatabaseService.getTask(this.userId, taskId)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Create a task (user-scoped)
|
|
125
|
+
*/
|
|
126
|
+
async createTask(
|
|
127
|
+
title: string,
|
|
128
|
+
description: string,
|
|
129
|
+
config?: Partial<Task['config']>,
|
|
130
|
+
metadata?: Task['metadata']
|
|
131
|
+
): Promise<Task> {
|
|
132
|
+
if (!this.isConnected()) {
|
|
133
|
+
await this.connect()
|
|
134
|
+
}
|
|
135
|
+
return TaskDatabaseService.createTask(this.userId, title, description, config, metadata)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Update task status (user-scoped)
|
|
140
|
+
*/
|
|
141
|
+
async updateTaskStatus(taskId: string, status: Task['status']): Promise<void> {
|
|
142
|
+
if (!this.isConnected()) {
|
|
143
|
+
await this.connect()
|
|
144
|
+
}
|
|
145
|
+
return TaskDatabaseService.updateTaskStatus(this.userId, taskId, status)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Delete a task (user-scoped)
|
|
150
|
+
*/
|
|
151
|
+
async deleteTask(taskId: string): Promise<void> {
|
|
152
|
+
if (!this.isConnected()) {
|
|
153
|
+
await this.connect()
|
|
154
|
+
}
|
|
155
|
+
return TaskDatabaseService.deleteTask(this.userId, taskId)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* List tasks (user-scoped)
|
|
160
|
+
*/
|
|
161
|
+
async listTasks(limit = 50): Promise<Task[]> {
|
|
162
|
+
if (!this.isConnected()) {
|
|
163
|
+
await this.connect()
|
|
164
|
+
}
|
|
165
|
+
return TaskDatabaseService.listTasks(this.userId, limit)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ==================== Progress Operations ====================
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Update overall progress (user-scoped)
|
|
172
|
+
*/
|
|
173
|
+
async updateOverallProgress(taskId: string, percentage: number): Promise<void> {
|
|
174
|
+
if (!this.isConnected()) {
|
|
175
|
+
await this.connect()
|
|
176
|
+
}
|
|
177
|
+
return TaskDatabaseService.updateOverallProgress(this.userId, taskId, percentage)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Create a milestone (user-scoped)
|
|
182
|
+
*/
|
|
183
|
+
async createMilestone(taskId: string, milestone: Milestone): Promise<void> {
|
|
184
|
+
if (!this.isConnected()) {
|
|
185
|
+
await this.connect()
|
|
186
|
+
}
|
|
187
|
+
return TaskDatabaseService.createMilestone(this.userId, taskId, milestone)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Update a milestone (user-scoped)
|
|
192
|
+
*/
|
|
193
|
+
async updateMilestone(
|
|
194
|
+
taskId: string,
|
|
195
|
+
milestoneId: string,
|
|
196
|
+
updates: Partial<Milestone>
|
|
197
|
+
): Promise<void> {
|
|
198
|
+
if (!this.isConnected()) {
|
|
199
|
+
await this.connect()
|
|
200
|
+
}
|
|
201
|
+
return TaskDatabaseService.updateMilestone(this.userId, taskId, milestoneId, updates)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Complete a milestone (user-scoped)
|
|
206
|
+
*/
|
|
207
|
+
async completeMilestone(taskId: string, milestoneId: string): Promise<void> {
|
|
208
|
+
if (!this.isConnected()) {
|
|
209
|
+
await this.connect()
|
|
210
|
+
}
|
|
211
|
+
return TaskDatabaseService.completeMilestone(this.userId, taskId, milestoneId)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Create a task item (user-scoped)
|
|
216
|
+
*/
|
|
217
|
+
async createTaskItem(
|
|
218
|
+
taskId: string,
|
|
219
|
+
milestoneId: string,
|
|
220
|
+
taskItem: TaskItem
|
|
221
|
+
): Promise<void> {
|
|
222
|
+
if (!this.isConnected()) {
|
|
223
|
+
await this.connect()
|
|
224
|
+
}
|
|
225
|
+
return TaskDatabaseService.createTaskItem(this.userId, taskId, milestoneId, taskItem)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Update a task item (user-scoped)
|
|
230
|
+
*/
|
|
231
|
+
async updateTaskItem(
|
|
232
|
+
taskId: string,
|
|
233
|
+
milestoneId: string,
|
|
234
|
+
taskItemId: string,
|
|
235
|
+
updates: Partial<TaskItem>
|
|
236
|
+
): Promise<void> {
|
|
237
|
+
if (!this.isConnected()) {
|
|
238
|
+
await this.connect()
|
|
239
|
+
}
|
|
240
|
+
return TaskDatabaseService.updateTaskItem(this.userId, taskId, milestoneId, taskItemId, updates)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Complete a task item (user-scoped)
|
|
245
|
+
*/
|
|
246
|
+
async completeTaskItem(
|
|
247
|
+
taskId: string,
|
|
248
|
+
milestoneId: string,
|
|
249
|
+
taskItemId: string
|
|
250
|
+
): Promise<void> {
|
|
251
|
+
if (!this.isConnected()) {
|
|
252
|
+
await this.connect()
|
|
253
|
+
}
|
|
254
|
+
return TaskDatabaseService.completeTaskItem(this.userId, taskId, milestoneId, taskItemId)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ==================== Message Operations ====================
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Add a message (user-scoped)
|
|
261
|
+
*/
|
|
262
|
+
async addMessage(
|
|
263
|
+
taskId: string,
|
|
264
|
+
role: 'user' | 'assistant' | 'system',
|
|
265
|
+
content: string,
|
|
266
|
+
metadata?: any
|
|
267
|
+
): Promise<string> {
|
|
268
|
+
if (!this.isConnected()) {
|
|
269
|
+
await this.connect()
|
|
270
|
+
}
|
|
271
|
+
return TaskDatabaseService.addMessage(this.userId, taskId, role, content, metadata)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Get messages (user-scoped)
|
|
276
|
+
*/
|
|
277
|
+
async getMessages(taskId: string, limit = 100): Promise<any[]> {
|
|
278
|
+
if (!this.isConnected()) {
|
|
279
|
+
await this.connect()
|
|
280
|
+
}
|
|
281
|
+
return TaskDatabaseService.getMessages(this.userId, taskId, limit)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ==================== Query Operations ====================
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Get tasks by status (user-scoped)
|
|
288
|
+
*/
|
|
289
|
+
async getTasksByStatus(status: Task['status'], limit = 50): Promise<Task[]> {
|
|
290
|
+
if (!this.isConnected()) {
|
|
291
|
+
await this.connect()
|
|
292
|
+
}
|
|
293
|
+
return TaskDatabaseService.getTasksByStatus(this.userId, status, limit)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Get active tasks (user-scoped)
|
|
298
|
+
*/
|
|
299
|
+
async getActiveTasks(limit = 50): Promise<Task[]> {
|
|
300
|
+
if (!this.isConnected()) {
|
|
301
|
+
await this.connect()
|
|
302
|
+
}
|
|
303
|
+
return TaskDatabaseService.getActiveTasks(this.userId, limit)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Search tasks by title (user-scoped)
|
|
308
|
+
*/
|
|
309
|
+
async searchTasksByTitle(searchTerm: string, limit = 50): Promise<Task[]> {
|
|
310
|
+
if (!this.isConnected()) {
|
|
311
|
+
await this.connect()
|
|
312
|
+
}
|
|
313
|
+
return TaskDatabaseService.searchTasksByTitle(this.userId, searchTerm, limit)
|
|
314
|
+
}
|
|
315
|
+
}
|