@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.
Files changed (43) hide show
  1. package/package.json +6 -5
  2. package/src/domain/args.ts +11 -1
  3. package/src/domain/bootstrap.ts +12 -4
  4. package/src/domain/db/schema.ts +28 -7
  5. package/src/domain/entities.ts +8 -0
  6. package/src/domain/events.ts +26 -0
  7. package/src/domain/index.ts +3 -0
  8. package/src/domain/inputs.ts +2 -2
  9. package/src/domain/repositories/projects.ts +16 -3
  10. package/src/domain/repositories/tags.ts +77 -0
  11. package/src/domain/repositories/tasks.ts +69 -27
  12. package/src/domain/services/projects.ts +22 -7
  13. package/src/domain/services/tags.ts +85 -0
  14. package/src/domain/services/tasks.ts +50 -9
  15. package/src/domain/services.ts +31 -4
  16. package/src/mcp/index.ts +1 -1
  17. package/src/mcp/server.ts +175 -31
  18. package/src/mcp/standalone.ts +10 -7
  19. package/src/server/index.ts +44 -10
  20. package/src/server/routes/projects.ts +16 -5
  21. package/src/server/routes/tags.ts +61 -0
  22. package/src/server/routes/tasks.ts +86 -9
  23. package/src/web/dist/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa0ZL7SUc-DqGufNeO.woff2 +0 -0
  24. package/src/web/dist/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7-Dx4kXJAl.woff2 +0 -0
  25. package/src/web/dist/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1pL7SUc-CkhJZR-_.woff2 +0 -0
  26. package/src/web/dist/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc-DO1Apj_S.woff2 +0 -0
  27. package/src/web/dist/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7SUc-BOeWTOD4.woff2 +0 -0
  28. package/src/web/dist/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7SUc-DlzME5K_.woff2 +0 -0
  29. package/src/web/dist/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2pL7SUc-CBcvBZtf.woff2 +0 -0
  30. package/src/web/dist/assets/index-aUW2zejq.js +49 -0
  31. package/src/web/dist/assets/kJEPBvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzBwG-RpA6RzaxHMPdY40KH8nGzv3fzfVJO1Q-CpotRDAj.woff2 +0 -0
  32. package/src/web/dist/assets/xn7gYHE41ni1AdIRggOxSuXd-Dvxsihut.woff2 +0 -0
  33. package/src/web/dist/assets/xn7gYHE41ni1AdIRggSxSuXd-DL7QRZyv.woff2 +0 -0
  34. package/src/web/dist/assets/xn7gYHE41ni1AdIRggexSg-DHIcAJRg.woff2 +0 -0
  35. package/src/web/dist/assets/xn7gYHE41ni1AdIRggixSuXd-usUDDRr7.woff2 +0 -0
  36. package/src/web/dist/assets/xn7gYHE41ni1AdIRggmxSuXd-Ch3YOpNY.woff2 +0 -0
  37. package/src/web/dist/assets/xn7gYHE41ni1AdIRggqxSuXd-C8S-KRRz.woff2 +0 -0
  38. package/src/web/dist/index.html +2 -11
  39. package/src/web/src/App.tsx +222 -22
  40. package/src/web/src/components/TopBar.tsx +4 -2
  41. package/src/web/src/useRealtimeEvents.ts +62 -0
  42. package/src/web/vite.config.ts +6 -1
  43. 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 this.taskRepo.findByProject(project.id);
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
- return this.taskRepo.create(project.id, input);
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
- return this.taskRepo.update(id, input);
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
- return this.taskRepo.delete(id);
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
  }
@@ -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
@@ -1,3 +1,3 @@
1
- export { createMcpServer, handleMcpHttp } from "./server";
1
+ export { createMcpServer, createMcpHttpHandler } from "./server";
2
2
  export type { McpServiceContext } from "./server";
3
3
  export { McpStandaloneServer } from "./standalone";
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
- throw err;
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("list_projects", { description: "List all projects" }, () =>
44
- handle(() => projectService.findAll())
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
- { description: "List tasks for a project", inputSchema: { project_slug: z.string() } },
92
- ({ project_slug }) => handle(() => taskService.findByProjectSlug(project_slug))
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
- id: z.string(),
115
- title: z.string().optional(),
116
- description: z.string().optional(),
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
- { description: "Delete a task by ID", inputSchema: { id: z.string() } },
126
- ({ id }) => handle(() => taskService.delete(id))
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
- /** Handle a single MCP-over-HTTP request (stateless, one server per request). */
133
- export async function handleMcpHttp(ctx: McpServiceContext, req: Request): Promise<Response> {
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
- const transport = new WebStandardStreamableHTTPServerTransport();
136
- await server.connect(transport);
137
- const response = await transport.handleRequest(req);
138
- await server.close();
139
- return response;
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
  }
@@ -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 { handleMcpHttp } from "./server";
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.all("/*", (c) => handleMcpHttp(ctx, c.req.raw));
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
 
@@ -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 { parseArgs, logListening, type ServerOptions } from "../domain/args";
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 { handleMcpHttp } from "../mcp/server";
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 (no logging) ──────────────────────────────────
55
- app.all("/mcp", (c) => handleMcpHttp(ctx, c.req.raw));
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: app.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
- return c.json(service.findAll());
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 project = service.create(body);
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 project = service.update(c.req.param("slug"), body);
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) {