@x4lt7ab/tab-for-projects 0.1.0 → 0.1.1
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/package.json +6 -5
- package/src/domain/args.ts +11 -1
- package/src/domain/bootstrap.ts +12 -4
- package/src/domain/db/schema.ts +28 -7
- package/src/domain/entities.ts +8 -0
- package/src/domain/events.ts +26 -0
- package/src/domain/index.ts +3 -0
- package/src/domain/inputs.ts +2 -2
- package/src/domain/repositories/projects.ts +16 -3
- package/src/domain/repositories/tags.ts +77 -0
- package/src/domain/repositories/tasks.ts +69 -27
- package/src/domain/services/projects.ts +22 -7
- package/src/domain/services/tags.ts +85 -0
- package/src/domain/services/tasks.ts +50 -9
- package/src/domain/services.ts +31 -4
- package/src/mcp/index.ts +1 -1
- package/src/mcp/server.ts +175 -31
- package/src/mcp/standalone.ts +10 -7
- package/src/server/index.ts +44 -10
- package/src/server/routes/projects.ts +16 -5
- package/src/server/routes/tags.ts +61 -0
- package/src/server/routes/tasks.ts +86 -9
- package/src/web/dist/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa0ZL7SUc-DqGufNeO.woff2 +0 -0
- package/src/web/dist/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7-Dx4kXJAl.woff2 +0 -0
- package/src/web/dist/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1pL7SUc-CkhJZR-_.woff2 +0 -0
- package/src/web/dist/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc-DO1Apj_S.woff2 +0 -0
- package/src/web/dist/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7SUc-BOeWTOD4.woff2 +0 -0
- package/src/web/dist/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7SUc-DlzME5K_.woff2 +0 -0
- package/src/web/dist/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2pL7SUc-CBcvBZtf.woff2 +0 -0
- package/src/web/dist/assets/index-aUW2zejq.js +49 -0
- package/src/web/dist/assets/kJEPBvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzBwG-RpA6RzaxHMPdY40KH8nGzv3fzfVJO1Q-CpotRDAj.woff2 +0 -0
- package/src/web/dist/assets/xn7gYHE41ni1AdIRggOxSuXd-Dvxsihut.woff2 +0 -0
- package/src/web/dist/assets/xn7gYHE41ni1AdIRggSxSuXd-DL7QRZyv.woff2 +0 -0
- package/src/web/dist/assets/xn7gYHE41ni1AdIRggexSg-DHIcAJRg.woff2 +0 -0
- package/src/web/dist/assets/xn7gYHE41ni1AdIRggixSuXd-usUDDRr7.woff2 +0 -0
- package/src/web/dist/assets/xn7gYHE41ni1AdIRggmxSuXd-Ch3YOpNY.woff2 +0 -0
- package/src/web/dist/assets/xn7gYHE41ni1AdIRggqxSuXd-C8S-KRRz.woff2 +0 -0
- package/src/web/dist/index.html +2 -11
- package/src/web/src/App.tsx +222 -22
- package/src/web/src/components/TopBar.tsx +4 -2
- package/src/web/src/useRealtimeEvents.ts +62 -0
- package/src/web/vite.config.ts +6 -1
- package/src/web/dist/assets/index-Bonqd4_2.js +0 -49
|
@@ -1,23 +1,36 @@
|
|
|
1
1
|
import type { Task } from "../entities";
|
|
2
2
|
import type { CreateTaskInput, UpdateTaskInput } from "../inputs";
|
|
3
|
-
import type { ITaskService } from "../services";
|
|
3
|
+
import type { ITaskService, Paginated, TaskFilter } from "../services";
|
|
4
4
|
import { ServiceError } from "../errors";
|
|
5
5
|
import type { TaskRepository } from "../repositories/tasks";
|
|
6
6
|
import type { ProjectRepository } from "../repositories/projects";
|
|
7
7
|
import { TASK_STATUSES } from "../statuses";
|
|
8
|
+
import type { EventBus } from "../events";
|
|
8
9
|
|
|
9
10
|
export class TaskService implements ITaskService {
|
|
10
11
|
constructor(
|
|
11
12
|
private taskRepo: TaskRepository,
|
|
12
|
-
private projectRepo: ProjectRepository
|
|
13
|
+
private projectRepo: ProjectRepository,
|
|
14
|
+
private eventBus?: EventBus,
|
|
13
15
|
) {}
|
|
14
16
|
|
|
15
|
-
findByProjectSlug(projectSlug: string): Task
|
|
17
|
+
findByProjectSlug(projectSlug: string, limit = 100, offset = 0, filter?: TaskFilter): Paginated<Task> {
|
|
16
18
|
const project = this.projectRepo.findBySlug(projectSlug);
|
|
17
19
|
if (!project) {
|
|
18
20
|
throw new ServiceError("project not found", 404);
|
|
19
21
|
}
|
|
20
|
-
return
|
|
22
|
+
return {
|
|
23
|
+
data: this.taskRepo.findByProject(project.id, limit, offset, filter),
|
|
24
|
+
total: this.taskRepo.countByProject(project.id, filter),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
findByNumber(projectSlug: string, number: number): Task | null {
|
|
29
|
+
const project = this.projectRepo.findBySlug(projectSlug);
|
|
30
|
+
if (!project) {
|
|
31
|
+
throw new ServiceError("project not found", 404);
|
|
32
|
+
}
|
|
33
|
+
return this.taskRepo.findByNumber(project.id, number);
|
|
21
34
|
}
|
|
22
35
|
|
|
23
36
|
create(projectSlug: string, input: CreateTaskInput): Task {
|
|
@@ -37,10 +50,21 @@ export class TaskService implements ITaskService {
|
|
|
37
50
|
if (input.status !== undefined && !(TASK_STATUSES as readonly string[]).includes(input.status)) {
|
|
38
51
|
throw new ServiceError(`status must be one of: ${TASK_STATUSES.join(", ")}`, 400);
|
|
39
52
|
}
|
|
40
|
-
|
|
53
|
+
if (input.priority !== undefined && input.priority !== null) {
|
|
54
|
+
if (!Number.isInteger(input.priority) || input.priority < 1 || input.priority > 10) {
|
|
55
|
+
throw new ServiceError("priority must be an integer between 1 and 10", 400);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const task = this.taskRepo.create(project.id, input);
|
|
59
|
+
this.eventBus?.emit({ entity: "task", action: "created", payload: task });
|
|
60
|
+
return task;
|
|
41
61
|
}
|
|
42
62
|
|
|
43
|
-
update(id: string, input: UpdateTaskInput): Task | null {
|
|
63
|
+
update(projectSlug: string, id: string, input: UpdateTaskInput): Task | null {
|
|
64
|
+
const project = this.projectRepo.findBySlug(projectSlug);
|
|
65
|
+
if (!project) {
|
|
66
|
+
throw new ServiceError("project not found", 404);
|
|
67
|
+
}
|
|
44
68
|
if (input.title !== undefined && !input.title.trim()) {
|
|
45
69
|
throw new ServiceError("title cannot be empty", 400);
|
|
46
70
|
}
|
|
@@ -53,10 +77,27 @@ export class TaskService implements ITaskService {
|
|
|
53
77
|
if (input.status !== undefined && !(TASK_STATUSES as readonly string[]).includes(input.status)) {
|
|
54
78
|
throw new ServiceError(`status must be one of: ${TASK_STATUSES.join(", ")}`, 400);
|
|
55
79
|
}
|
|
56
|
-
|
|
80
|
+
if (input.priority !== undefined && input.priority !== null) {
|
|
81
|
+
if (!Number.isInteger(input.priority) || input.priority < 1 || input.priority > 10) {
|
|
82
|
+
throw new ServiceError("priority must be an integer between 1 and 10", 400);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const task = this.taskRepo.update(id, project.id, input);
|
|
86
|
+
if (task) {
|
|
87
|
+
this.eventBus?.emit({ entity: "task", action: "updated", payload: task });
|
|
88
|
+
}
|
|
89
|
+
return task;
|
|
57
90
|
}
|
|
58
91
|
|
|
59
|
-
delete(id: string): boolean {
|
|
60
|
-
|
|
92
|
+
delete(projectSlug: string, id: string): boolean {
|
|
93
|
+
const project = this.projectRepo.findBySlug(projectSlug);
|
|
94
|
+
if (!project) {
|
|
95
|
+
throw new ServiceError("project not found", 404);
|
|
96
|
+
}
|
|
97
|
+
const deleted = this.taskRepo.delete(id, project.id);
|
|
98
|
+
if (deleted) {
|
|
99
|
+
this.eventBus?.emit({ entity: "task", action: "deleted", payload: { id } });
|
|
100
|
+
}
|
|
101
|
+
return deleted;
|
|
61
102
|
}
|
|
62
103
|
}
|
package/src/domain/services.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { Project, Task } from "./entities";
|
|
1
|
+
import type { Project, Task, Tag } from "./entities";
|
|
2
|
+
import type { ProjectStatus, TaskStatus } from "./statuses";
|
|
2
3
|
import type {
|
|
3
4
|
CreateProjectInput,
|
|
4
5
|
UpdateProjectInput,
|
|
@@ -6,8 +7,22 @@ import type {
|
|
|
6
7
|
UpdateTaskInput,
|
|
7
8
|
} from "./inputs";
|
|
8
9
|
|
|
10
|
+
export interface Paginated<T> {
|
|
11
|
+
data: T[];
|
|
12
|
+
total: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ProjectFilter {
|
|
16
|
+
status?: ProjectStatus;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface TaskFilter {
|
|
20
|
+
status?: TaskStatus;
|
|
21
|
+
tag?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
9
24
|
export interface IProjectService {
|
|
10
|
-
findAll(): Project
|
|
25
|
+
findAll(limit?: number, offset?: number, filter?: ProjectFilter): Paginated<Project>;
|
|
11
26
|
findBySlug(slug: string): Project | null;
|
|
12
27
|
create(input: CreateProjectInput): Project;
|
|
13
28
|
update(slug: string, input: UpdateProjectInput): Project | null;
|
|
@@ -15,8 +30,20 @@ export interface IProjectService {
|
|
|
15
30
|
}
|
|
16
31
|
|
|
17
32
|
export interface ITaskService {
|
|
18
|
-
findByProjectSlug(projectSlug: string): Task
|
|
33
|
+
findByProjectSlug(projectSlug: string, limit?: number, offset?: number, filter?: TaskFilter): Paginated<Task>;
|
|
34
|
+
findByNumber(projectSlug: string, number: number): Task | null;
|
|
19
35
|
create(projectSlug: string, input: CreateTaskInput): Task;
|
|
20
|
-
update(id: string, input: UpdateTaskInput): Task | null;
|
|
36
|
+
update(projectSlug: string, id: string, input: UpdateTaskInput): Task | null;
|
|
37
|
+
delete(projectSlug: string, id: string): boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ITagService {
|
|
41
|
+
findAll(limit?: number, offset?: number): Paginated<Tag>;
|
|
42
|
+
findByName(name: string): Tag | null;
|
|
43
|
+
create(name: string): Tag;
|
|
21
44
|
delete(id: string): boolean;
|
|
45
|
+
addTagToTask(taskId: string, tagName: string): Tag;
|
|
46
|
+
removeTagFromTask(taskId: string, tagId: string): boolean;
|
|
47
|
+
getTagsForTask(taskId: string): Tag[];
|
|
48
|
+
findTasksByTag(tagName: string, limit?: number, offset?: number): Paginated<Task>;
|
|
22
49
|
}
|
package/src/mcp/index.ts
CHANGED
package/src/mcp/server.ts
CHANGED
|
@@ -7,11 +7,13 @@ import {
|
|
|
7
7
|
TASK_STATUSES,
|
|
8
8
|
type IProjectService,
|
|
9
9
|
type ITaskService,
|
|
10
|
+
type ITagService,
|
|
10
11
|
} from "../domain";
|
|
11
12
|
|
|
12
13
|
export interface McpServiceContext {
|
|
13
14
|
projectService: IProjectService;
|
|
14
15
|
taskService: ITaskService;
|
|
16
|
+
tagService: ITagService;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
function handle<T>(fn: () => T) {
|
|
@@ -25,13 +27,17 @@ function handle<T>(fn: () => T) {
|
|
|
25
27
|
isError: true,
|
|
26
28
|
};
|
|
27
29
|
}
|
|
28
|
-
|
|
30
|
+
console.error(err);
|
|
31
|
+
return {
|
|
32
|
+
content: [{ type: "text" as const, text: "internal server error" }],
|
|
33
|
+
isError: true,
|
|
34
|
+
};
|
|
29
35
|
}
|
|
30
36
|
}
|
|
31
37
|
|
|
32
38
|
/** Create an McpServer with all tools registered. */
|
|
33
39
|
export function createMcpServer(ctx: McpServiceContext): McpServer {
|
|
34
|
-
const { projectService, taskService } = ctx;
|
|
40
|
+
const { projectService, taskService, tagService } = ctx;
|
|
35
41
|
|
|
36
42
|
const server = new McpServer({
|
|
37
43
|
name: "tab-for-projects",
|
|
@@ -40,13 +46,23 @@ export function createMcpServer(ctx: McpServiceContext): McpServer {
|
|
|
40
46
|
|
|
41
47
|
// ── Projects ──────────────────────────────────────────────
|
|
42
48
|
|
|
43
|
-
server.registerTool(
|
|
44
|
-
|
|
49
|
+
server.registerTool(
|
|
50
|
+
"list_projects",
|
|
51
|
+
{
|
|
52
|
+
description: "List projects (paginated, filterable)",
|
|
53
|
+
inputSchema: {
|
|
54
|
+
limit: z.number().int().min(1).max(200).optional(),
|
|
55
|
+
offset: z.number().int().min(0).optional(),
|
|
56
|
+
status: z.enum(PROJECT_STATUSES).optional(),
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
({ limit, offset, status }) =>
|
|
60
|
+
handle(() => projectService.findAll(limit, offset, status ? { status } : undefined))
|
|
45
61
|
);
|
|
46
62
|
|
|
47
63
|
server.registerTool(
|
|
48
64
|
"get_project",
|
|
49
|
-
{ description: "Get a project by slug", inputSchema: { slug: z.string() } },
|
|
65
|
+
{ description: "Get a project by slug", inputSchema: { slug: z.string().max(100) } },
|
|
50
66
|
({ slug }) => handle(() => projectService.findBySlug(slug))
|
|
51
67
|
);
|
|
52
68
|
|
|
@@ -55,9 +71,9 @@ export function createMcpServer(ctx: McpServiceContext): McpServer {
|
|
|
55
71
|
{
|
|
56
72
|
description: "Create a new project",
|
|
57
73
|
inputSchema: {
|
|
58
|
-
name: z.string(),
|
|
59
|
-
slug: z.string(),
|
|
60
|
-
description: z.string().optional(),
|
|
74
|
+
name: z.string().max(255),
|
|
75
|
+
slug: z.string().max(100),
|
|
76
|
+
description: z.string().max(10000).optional(),
|
|
61
77
|
status: z.enum(PROJECT_STATUSES).optional(),
|
|
62
78
|
},
|
|
63
79
|
},
|
|
@@ -69,9 +85,9 @@ export function createMcpServer(ctx: McpServiceContext): McpServer {
|
|
|
69
85
|
{
|
|
70
86
|
description: "Update an existing project",
|
|
71
87
|
inputSchema: {
|
|
72
|
-
slug: z.string(),
|
|
73
|
-
name: z.string().optional(),
|
|
74
|
-
description: z.string().optional(),
|
|
88
|
+
slug: z.string().max(100),
|
|
89
|
+
name: z.string().max(255).optional(),
|
|
90
|
+
description: z.string().max(10000).optional(),
|
|
75
91
|
status: z.enum(PROJECT_STATUSES).optional(),
|
|
76
92
|
},
|
|
77
93
|
},
|
|
@@ -80,7 +96,7 @@ export function createMcpServer(ctx: McpServiceContext): McpServer {
|
|
|
80
96
|
|
|
81
97
|
server.registerTool(
|
|
82
98
|
"delete_project",
|
|
83
|
-
{ description: "Delete a project by slug", inputSchema: { slug: z.string() } },
|
|
99
|
+
{ description: "Delete a project by slug", inputSchema: { slug: z.string().max(100) } },
|
|
84
100
|
({ slug }) => handle(() => projectService.delete(slug))
|
|
85
101
|
);
|
|
86
102
|
|
|
@@ -88,8 +104,34 @@ export function createMcpServer(ctx: McpServiceContext): McpServer {
|
|
|
88
104
|
|
|
89
105
|
server.registerTool(
|
|
90
106
|
"list_tasks",
|
|
91
|
-
{
|
|
92
|
-
|
|
107
|
+
{
|
|
108
|
+
description: "List tasks for a project (paginated, filterable by status and tag)",
|
|
109
|
+
inputSchema: {
|
|
110
|
+
project_slug: z.string().max(100),
|
|
111
|
+
limit: z.number().int().min(1).max(500).optional(),
|
|
112
|
+
offset: z.number().int().min(0).optional(),
|
|
113
|
+
status: z.enum(TASK_STATUSES).optional(),
|
|
114
|
+
tag: z.string().max(50).optional(),
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
({ project_slug, limit, offset, status, tag }) => {
|
|
118
|
+
const filter: { status?: typeof status; tag?: string } = {};
|
|
119
|
+
if (status) filter.status = status;
|
|
120
|
+
if (tag) filter.tag = tag;
|
|
121
|
+
return handle(() => taskService.findByProjectSlug(project_slug, limit, offset, Object.keys(filter).length ? filter : undefined));
|
|
122
|
+
}
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
server.registerTool(
|
|
126
|
+
"get_task_by_number",
|
|
127
|
+
{
|
|
128
|
+
description: "Get a task by its project-scoped number",
|
|
129
|
+
inputSchema: {
|
|
130
|
+
project_slug: z.string().max(100),
|
|
131
|
+
number: z.number().int().min(1),
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
({ project_slug, number }) => handle(() => taskService.findByNumber(project_slug, number))
|
|
93
135
|
);
|
|
94
136
|
|
|
95
137
|
server.registerTool(
|
|
@@ -97,10 +139,11 @@ export function createMcpServer(ctx: McpServiceContext): McpServer {
|
|
|
97
139
|
{
|
|
98
140
|
description: "Create a task in a project",
|
|
99
141
|
inputSchema: {
|
|
100
|
-
project_slug: z.string(),
|
|
101
|
-
title: z.string(),
|
|
102
|
-
description: z.string().optional(),
|
|
142
|
+
project_slug: z.string().max(100),
|
|
143
|
+
title: z.string().max(500),
|
|
144
|
+
description: z.string().max(10000).optional(),
|
|
103
145
|
status: z.enum(TASK_STATUSES).optional(),
|
|
146
|
+
priority: z.number().int().min(1).max(10).optional(),
|
|
104
147
|
},
|
|
105
148
|
},
|
|
106
149
|
({ project_slug, ...input }) => handle(() => taskService.create(project_slug, input))
|
|
@@ -109,32 +152,133 @@ export function createMcpServer(ctx: McpServiceContext): McpServer {
|
|
|
109
152
|
server.registerTool(
|
|
110
153
|
"update_task",
|
|
111
154
|
{
|
|
112
|
-
description: "Update a task by ID",
|
|
155
|
+
description: "Update a task by ID (must specify the project it belongs to). Supports priority (1-10 or null to clear).",
|
|
113
156
|
inputSchema: {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
157
|
+
project_slug: z.string().max(100),
|
|
158
|
+
id: z.string().max(26),
|
|
159
|
+
title: z.string().max(500).optional(),
|
|
160
|
+
description: z.string().max(10000).optional(),
|
|
117
161
|
status: z.enum(TASK_STATUSES).optional(),
|
|
162
|
+
priority: z.number().int().min(1).max(10).nullable().optional(),
|
|
118
163
|
},
|
|
119
164
|
},
|
|
120
|
-
({ id, ...updates }) => handle(() => taskService.update(id, updates))
|
|
165
|
+
({ project_slug, id, ...updates }) => handle(() => taskService.update(project_slug, id, updates))
|
|
121
166
|
);
|
|
122
167
|
|
|
123
168
|
server.registerTool(
|
|
124
169
|
"delete_task",
|
|
125
|
-
{
|
|
126
|
-
|
|
170
|
+
{
|
|
171
|
+
description: "Delete a task by ID (must specify the project it belongs to)",
|
|
172
|
+
inputSchema: { project_slug: z.string().max(100), id: z.string().max(26) },
|
|
173
|
+
},
|
|
174
|
+
({ project_slug, id }) => handle(() => taskService.delete(project_slug, id))
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
// ── Tags ──────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
server.registerTool(
|
|
180
|
+
"list_tags",
|
|
181
|
+
{
|
|
182
|
+
description: "List all tags (paginated)",
|
|
183
|
+
inputSchema: {
|
|
184
|
+
limit: z.number().int().min(1).max(500).optional(),
|
|
185
|
+
offset: z.number().int().min(0).optional(),
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
({ limit, offset }) => handle(() => tagService.findAll(limit, offset))
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
server.registerTool(
|
|
192
|
+
"create_tag",
|
|
193
|
+
{
|
|
194
|
+
description: "Create a new tag (lowercase alphanumeric with hyphens)",
|
|
195
|
+
inputSchema: { name: z.string().max(50) },
|
|
196
|
+
},
|
|
197
|
+
({ name }) => handle(() => tagService.create(name))
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
server.registerTool(
|
|
201
|
+
"delete_tag",
|
|
202
|
+
{
|
|
203
|
+
description: "Delete a tag by ID",
|
|
204
|
+
inputSchema: { id: z.string().max(26) },
|
|
205
|
+
},
|
|
206
|
+
({ id }) => handle(() => tagService.delete(id))
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
server.registerTool(
|
|
210
|
+
"add_tag_to_task",
|
|
211
|
+
{
|
|
212
|
+
description: "Add a tag to a task (auto-creates tag if it doesn't exist)",
|
|
213
|
+
inputSchema: {
|
|
214
|
+
task_id: z.string().max(26),
|
|
215
|
+
tag_name: z.string().max(50),
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
({ task_id, tag_name }) => handle(() => tagService.addTagToTask(task_id, tag_name))
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
server.registerTool(
|
|
222
|
+
"remove_tag_from_task",
|
|
223
|
+
{
|
|
224
|
+
description: "Remove a tag from a task",
|
|
225
|
+
inputSchema: {
|
|
226
|
+
task_id: z.string().max(26),
|
|
227
|
+
tag_id: z.string().max(26),
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
({ task_id, tag_id }) => handle(() => tagService.removeTagFromTask(task_id, tag_id))
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
server.registerTool(
|
|
234
|
+
"get_task_tags",
|
|
235
|
+
{
|
|
236
|
+
description: "Get all tags for a task",
|
|
237
|
+
inputSchema: { task_id: z.string().max(26) },
|
|
238
|
+
},
|
|
239
|
+
({ task_id }) => handle(() => tagService.getTagsForTask(task_id))
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
server.registerTool(
|
|
243
|
+
"find_tasks_by_tag",
|
|
244
|
+
{
|
|
245
|
+
description: "Find all tasks with a given tag (cross-project)",
|
|
246
|
+
inputSchema: {
|
|
247
|
+
tag_name: z.string().max(50),
|
|
248
|
+
limit: z.number().int().min(1).max(500).optional(),
|
|
249
|
+
offset: z.number().int().min(0).optional(),
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
({ tag_name, limit, offset }) => handle(() => tagService.findTasksByTag(tag_name, limit, offset))
|
|
127
253
|
);
|
|
128
254
|
|
|
129
255
|
return server;
|
|
130
256
|
}
|
|
131
257
|
|
|
132
|
-
/**
|
|
133
|
-
|
|
258
|
+
/**
|
|
259
|
+
* Create a stateless HTTP handler that reuses a single McpServer instance.
|
|
260
|
+
*
|
|
261
|
+
* McpServer supports sequential connect → handle → close cycles (close() resets
|
|
262
|
+
* the internal transport reference), but does NOT support concurrent connections.
|
|
263
|
+
* A promise chain serializes requests so connect() is never called while a
|
|
264
|
+
* previous transport is still active.
|
|
265
|
+
*/
|
|
266
|
+
export function createMcpHttpHandler(ctx: McpServiceContext): (req: Request) => Promise<Response> {
|
|
134
267
|
const server = createMcpServer(ctx);
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
268
|
+
let pending: Promise<unknown> = Promise.resolve();
|
|
269
|
+
|
|
270
|
+
return (req: Request): Promise<Response> => {
|
|
271
|
+
const result = pending.then(async () => {
|
|
272
|
+
const transport = new WebStandardStreamableHTTPServerTransport({ enableJsonResponse: true });
|
|
273
|
+
try {
|
|
274
|
+
await server.connect(transport);
|
|
275
|
+
const response = await transport.handleRequest(req);
|
|
276
|
+
return response;
|
|
277
|
+
} finally {
|
|
278
|
+
await server.close();
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
pending = result.catch(() => {});
|
|
282
|
+
return result;
|
|
283
|
+
};
|
|
140
284
|
}
|
package/src/mcp/standalone.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Hono } from "hono";
|
|
3
|
+
import { logger } from "hono/logger";
|
|
3
4
|
import { cors } from "hono/cors";
|
|
5
|
+
import { secureHeaders } from "hono/secure-headers";
|
|
4
6
|
import { bootstrap } from "../domain";
|
|
5
|
-
import { parseArgs, logListening, type ServerOptions } from "../domain/args";
|
|
6
|
-
import {
|
|
7
|
+
import { parseArgs, parseCorsOrigins, logListening, type ServerOptions } from "../domain/args";
|
|
8
|
+
import { createMcpHttpHandler } from "./server";
|
|
7
9
|
|
|
8
10
|
export class McpStandaloneServer {
|
|
9
11
|
private options: ServerOptions;
|
|
@@ -18,19 +20,20 @@ export class McpStandaloneServer {
|
|
|
18
20
|
const ctx = bootstrap(dbPath);
|
|
19
21
|
|
|
20
22
|
const app = new Hono();
|
|
23
|
+
const isAllowedOrigin = parseCorsOrigins(process.env.PM_CORS_ORIGINS);
|
|
24
|
+
app.use("*", secureHeaders());
|
|
21
25
|
app.use(
|
|
22
26
|
"*",
|
|
23
27
|
cors({
|
|
24
|
-
origin: (origin) =>
|
|
25
|
-
origin?.startsWith("http://localhost") || origin?.startsWith("http://127.0.0.1")
|
|
26
|
-
? origin
|
|
27
|
-
: null,
|
|
28
|
+
origin: (origin) => (origin && isAllowedOrigin(origin)) ? origin : null,
|
|
28
29
|
allowMethods: ["GET", "POST", "DELETE", "OPTIONS"],
|
|
29
30
|
allowHeaders: ["Content-Type", "mcp-session-id", "Last-Event-ID", "mcp-protocol-version"],
|
|
30
31
|
exposeHeaders: ["mcp-session-id", "mcp-protocol-version"],
|
|
31
32
|
})
|
|
32
33
|
);
|
|
33
|
-
app.
|
|
34
|
+
app.use("*", logger((str) => process.stderr.write(str + "\n")));
|
|
35
|
+
const handleMcp = createMcpHttpHandler(ctx);
|
|
36
|
+
app.all("/*", (c) => handleMcp(c.req.raw));
|
|
34
37
|
|
|
35
38
|
logListening("tab-for-projects mcp (standalone)", host, port);
|
|
36
39
|
|
package/src/server/index.ts
CHANGED
|
@@ -5,15 +5,19 @@ import { cors } from "hono/cors";
|
|
|
5
5
|
import { serveStatic } from "hono/bun";
|
|
6
6
|
import { compress } from "hono/compress";
|
|
7
7
|
import { secureHeaders } from "hono/secure-headers";
|
|
8
|
+
import { bodyLimit } from "hono/body-limit";
|
|
8
9
|
import { etag } from "hono/etag";
|
|
9
10
|
import { join } from "path";
|
|
10
11
|
import { readFileSync, existsSync } from "fs";
|
|
11
12
|
import { bootstrap, ServiceError } from "../domain";
|
|
12
|
-
import {
|
|
13
|
+
import type { DomainEvent } from "../domain/events";
|
|
14
|
+
import { parseArgs, parseCorsOrigins, logListening, type ServerOptions } from "../domain/args";
|
|
13
15
|
import type { ContentfulStatusCode } from "hono/utils/http-status";
|
|
14
16
|
import { projectRoutes } from "./routes/projects";
|
|
15
17
|
import { taskRoutes } from "./routes/tasks";
|
|
16
|
-
import {
|
|
18
|
+
import { tagRoutes } from "./routes/tags";
|
|
19
|
+
import { createMcpHttpHandler } from "../mcp/server";
|
|
20
|
+
import type { ServerWebSocket } from "bun";
|
|
17
21
|
|
|
18
22
|
export class Server {
|
|
19
23
|
private options: ServerOptions;
|
|
@@ -28,17 +32,16 @@ export class Server {
|
|
|
28
32
|
const ctx = bootstrap(dbPath);
|
|
29
33
|
|
|
30
34
|
const app = new Hono();
|
|
35
|
+
const isAllowedOrigin = parseCorsOrigins(process.env.PM_CORS_ORIGINS);
|
|
31
36
|
|
|
32
37
|
// ── Global middleware ──────────────────────────────────
|
|
33
38
|
app.use("*", secureHeaders());
|
|
34
39
|
app.use("*", compress());
|
|
40
|
+
app.use("/api/*", bodyLimit({ maxSize: 1 * 1024 * 1024 }));
|
|
35
41
|
app.use(
|
|
36
42
|
"*",
|
|
37
43
|
cors({
|
|
38
|
-
origin: (origin) =>
|
|
39
|
-
origin?.startsWith("http://localhost") || origin?.startsWith("http://127.0.0.1")
|
|
40
|
-
? origin
|
|
41
|
-
: null,
|
|
44
|
+
origin: (origin) => (origin && isAllowedOrigin(origin)) ? origin : null,
|
|
42
45
|
allowMethods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
|
|
43
46
|
allowHeaders: ["Content-Type", "mcp-session-id", "Last-Event-ID", "mcp-protocol-version"],
|
|
44
47
|
exposeHeaders: ["mcp-session-id", "mcp-protocol-version"],
|
|
@@ -47,12 +50,15 @@ export class Server {
|
|
|
47
50
|
|
|
48
51
|
// ── API (with logging) ────────────────────────────────
|
|
49
52
|
app.use("/api/*", logger((str) => process.stderr.write(str + "\n")));
|
|
50
|
-
app.route("/api/projects/:projectSlug/tasks", taskRoutes(ctx.taskService));
|
|
53
|
+
app.route("/api/projects/:projectSlug/tasks", taskRoutes(ctx.taskService, ctx.tagService));
|
|
51
54
|
app.route("/api/projects", projectRoutes(ctx.projectService));
|
|
55
|
+
app.route("/api/tags", tagRoutes(ctx.tagService));
|
|
52
56
|
app.get("/api/health", (c) => c.json({ status: "ok" }));
|
|
53
57
|
|
|
54
|
-
// ── MCP
|
|
55
|
-
app.
|
|
58
|
+
// ── MCP ────────────────────────────────────────────────
|
|
59
|
+
app.use("/mcp", logger((str) => process.stderr.write(str + "\n")));
|
|
60
|
+
const handleMcp = createMcpHttpHandler(ctx);
|
|
61
|
+
app.all("/mcp", (c) => handleMcp(c.req.raw));
|
|
56
62
|
|
|
57
63
|
// ── Static web assets ─────────────────────────────────
|
|
58
64
|
const dist = join(import.meta.dir, "../web/dist");
|
|
@@ -86,12 +92,40 @@ export class Server {
|
|
|
86
92
|
return c.json({ error: "internal server error" }, 500);
|
|
87
93
|
});
|
|
88
94
|
|
|
95
|
+
// ── WebSocket client tracking ─────────────────────────
|
|
96
|
+
const wsClients = new Set<ServerWebSocket<unknown>>();
|
|
97
|
+
|
|
98
|
+
ctx.eventBus.subscribe((event: DomainEvent) => {
|
|
99
|
+
const msg = JSON.stringify(event);
|
|
100
|
+
for (const ws of wsClients) {
|
|
101
|
+
ws.send(msg);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
89
105
|
logListening("tab-for-projects", host, port);
|
|
90
106
|
|
|
91
107
|
Bun.serve({
|
|
92
108
|
port,
|
|
93
109
|
hostname: host,
|
|
94
|
-
fetch
|
|
110
|
+
fetch(req, server) {
|
|
111
|
+
// WebSocket upgrade
|
|
112
|
+
if (new URL(req.url).pathname === "/ws") {
|
|
113
|
+
if (server.upgrade(req)) return undefined;
|
|
114
|
+
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
115
|
+
}
|
|
116
|
+
return app.fetch(req, server);
|
|
117
|
+
},
|
|
118
|
+
websocket: {
|
|
119
|
+
open(ws) {
|
|
120
|
+
wsClients.add(ws);
|
|
121
|
+
},
|
|
122
|
+
close(ws) {
|
|
123
|
+
wsClients.delete(ws);
|
|
124
|
+
},
|
|
125
|
+
message() {
|
|
126
|
+
// broadcast-only — client messages are ignored
|
|
127
|
+
},
|
|
128
|
+
},
|
|
95
129
|
});
|
|
96
130
|
}
|
|
97
131
|
}
|
|
@@ -2,7 +2,9 @@ import { Hono } from "hono";
|
|
|
2
2
|
import type { ContentfulStatusCode } from "hono/utils/http-status";
|
|
3
3
|
import {
|
|
4
4
|
ServiceError,
|
|
5
|
+
PROJECT_STATUSES,
|
|
5
6
|
type IProjectService,
|
|
7
|
+
type ProjectFilter,
|
|
6
8
|
type CreateProjectInput,
|
|
7
9
|
type UpdateProjectInput,
|
|
8
10
|
} from "../../domain";
|
|
@@ -12,14 +14,23 @@ export function projectRoutes(service: IProjectService): Hono {
|
|
|
12
14
|
|
|
13
15
|
// GET /api/projects
|
|
14
16
|
app.get("/", (c) => {
|
|
15
|
-
|
|
17
|
+
const rawLimit = parseInt(c.req.query("limit") ?? "", 10);
|
|
18
|
+
const limit = Number.isFinite(rawLimit) && rawLimit >= 1 ? Math.min(rawLimit, 200) : 50;
|
|
19
|
+
const rawOffset = parseInt(c.req.query("offset") ?? "", 10);
|
|
20
|
+
const offset = Number.isFinite(rawOffset) && rawOffset >= 0 ? rawOffset : 0;
|
|
21
|
+
const filter: ProjectFilter = {};
|
|
22
|
+
const status = c.req.query("status");
|
|
23
|
+
if (status && (PROJECT_STATUSES as readonly string[]).includes(status)) {
|
|
24
|
+
filter.status = status as ProjectFilter["status"];
|
|
25
|
+
}
|
|
26
|
+
return c.json(service.findAll(limit, offset, filter));
|
|
16
27
|
});
|
|
17
28
|
|
|
18
29
|
// POST /api/projects
|
|
19
30
|
app.post("/", async (c) => {
|
|
20
|
-
const body = await c.req.json<CreateProjectInput>();
|
|
21
31
|
try {
|
|
22
|
-
const
|
|
32
|
+
const { name, slug, description, status } = await c.req.json<CreateProjectInput>();
|
|
33
|
+
const project = service.create({ name, slug, description, status });
|
|
23
34
|
return c.json(project, 201);
|
|
24
35
|
} catch (e: unknown) {
|
|
25
36
|
if (e instanceof ServiceError) return c.json({ error: e.message }, e.statusCode as ContentfulStatusCode);
|
|
@@ -36,9 +47,9 @@ export function projectRoutes(service: IProjectService): Hono {
|
|
|
36
47
|
|
|
37
48
|
// PATCH /api/projects/:slug
|
|
38
49
|
app.patch("/:slug", async (c) => {
|
|
39
|
-
const body = await c.req.json<UpdateProjectInput>();
|
|
40
50
|
try {
|
|
41
|
-
const
|
|
51
|
+
const { name, description, status } = await c.req.json<UpdateProjectInput>();
|
|
52
|
+
const project = service.update(c.req.param("slug"), { name, description, status });
|
|
42
53
|
if (!project) return c.json({ error: "project not found" }, 404);
|
|
43
54
|
return c.json(project);
|
|
44
55
|
} catch (e: unknown) {
|