diff --git a/services/mcp-server/src/lib/platform-client.ts b/services/mcp-server/src/lib/platform-client.ts index 8fed76d1..f3cb252c 100644 --- a/services/mcp-server/src/lib/platform-client.ts +++ b/services/mcp-server/src/lib/platform-client.ts @@ -289,3 +289,242 @@ export async function diagnosticsGetTraces( opts ); } + +// ── Tracker — items ─────────────────────────────────────────────────────────── + +export interface TrackerItemDoc { + id: string; + productId: string; + type: string; + status: string; + priority: string; + title: string; + description?: string; + labels?: string[]; + assignee?: string | null; + reportedBy?: string; + source?: string; + visibility?: string; + voteCount: number; + commentCount: number; + targetRelease?: string | null; + createdAt: string; + updatedAt: string; +} + +export function trackerItemsList( + params: { + productId?: string; + type?: string; + status?: string; + priority?: string; + q?: string; + labels?: string; + visibility?: string; + sortBy?: string; + sortOrder?: string; + limit?: number; + offset?: number; + }, + opts: PlatformClientOptions +): Promise<{ items: TrackerItemDoc[]; total: number; limit: number; offset: number }> { + const qs = new URLSearchParams(); + if (params.productId) qs.set('productId', params.productId); + if (params.type) qs.set('type', params.type); + if (params.status) qs.set('status', params.status); + if (params.priority) qs.set('priority', params.priority); + if (params.q) qs.set('q', params.q); + if (params.labels) qs.set('labels', params.labels); + if (params.visibility) qs.set('visibility', params.visibility); + if (params.sortBy) qs.set('sortBy', params.sortBy); + if (params.sortOrder) qs.set('sortOrder', params.sortOrder); + qs.set( + 'limit', + String(Math.min(params.limit ?? config.QUERY_DEFAULT_LIMIT, config.QUERY_MAX_LIMIT)) + ); + if (params.offset !== undefined) qs.set('offset', String(params.offset)); + return platformFetch<{ items: TrackerItemDoc[]; total: number; limit: number; offset: number }>( + `/api/items?${qs}`, + { method: 'GET' }, + opts + ); +} + +export function trackerItemsStats(opts: PlatformClientOptions): Promise<{ + total: number; + byType: Record; + byStatus: Record; + byPriority: Record; +}> { + return platformFetch<{ + total: number; + byType: Record; + byStatus: Record; + byPriority: Record; + }>('/api/items/stats', { method: 'GET' }, opts); +} + +export function trackerItemsGet( + itemId: string, + opts: PlatformClientOptions +): Promise { + return platformFetch( + `/api/items/${encodeURIComponent(itemId)}`, + { method: 'GET' }, + opts + ); +} + +export function trackerItemsCreate( + input: { + productId?: string; + type: string; + priority: string; + title: string; + description?: string; + labels?: string[]; + assignee?: string; + source?: string; + visibility?: string; + targetRelease?: string; + }, + opts: PlatformClientOptions +): Promise { + return platformFetch( + '/api/items', + { method: 'POST', body: JSON.stringify(input) }, + opts + ); +} + +export function trackerItemsUpdateStatus( + itemId: string, + status: string, + opts: PlatformClientOptions +): Promise { + return platformFetch( + `/api/items/${encodeURIComponent(itemId)}/status`, + { method: 'PATCH', body: JSON.stringify({ status }) }, + opts + ); +} + +export function trackerItemsDelete( + itemId: string, + opts: PlatformClientOptions +): Promise<{ success: boolean }> { + return platformFetch<{ success: boolean }>( + `/api/items/${encodeURIComponent(itemId)}`, + { method: 'DELETE' }, + opts + ); +} + +// ── Tracker — votes ─────────────────────────────────────────────────────────── + +export function trackerVotesToggle( + itemId: string, + opts: PlatformClientOptions +): Promise<{ voted: boolean; voteCount: number }> { + return platformFetch<{ voted: boolean; voteCount: number }>( + `/api/items/${encodeURIComponent(itemId)}/vote`, + { method: 'POST', body: '{}' }, + opts + ); +} + +export function trackerVotesList( + itemId: string, + opts: PlatformClientOptions +): Promise<{ votes: unknown[]; count: number }> { + return platformFetch<{ votes: unknown[]; count: number }>( + `/api/items/${encodeURIComponent(itemId)}/votes`, + { method: 'GET' }, + opts + ); +} + +// ── Tracker — comments ──────────────────────────────────────────────────────── + +export function trackerCommentsList( + itemId: string, + opts: PlatformClientOptions +): Promise<{ comments: unknown[]; count: number }> { + return platformFetch<{ comments: unknown[]; count: number }>( + `/api/items/${encodeURIComponent(itemId)}/comments`, + { method: 'GET' }, + opts + ); +} + +export function trackerCommentsAdd( + itemId: string, + body: string, + opts: PlatformClientOptions +): Promise { + return platformFetch( + `/api/items/${encodeURIComponent(itemId)}/comments`, + { method: 'POST', body: JSON.stringify({ body }) }, + opts + ); +} + +export function trackerCommentsDelete( + itemId: string, + commentId: string, + opts: PlatformClientOptions +): Promise<{ success: boolean }> { + return platformFetch<{ success: boolean }>( + `/api/items/${encodeURIComponent(itemId)}/comments/${encodeURIComponent(commentId)}`, + { method: 'DELETE' }, + opts + ); +} + +// ── Tracker — public roadmap ────────────────────────────────────────────────── + +export function trackerPublicRoadmap( + params: { + productId?: string; + type?: string; + status?: string; + q?: string; + sortBy?: string; + sortOrder?: string; + limit?: number; + offset?: number; + }, + opts: PlatformClientOptions +): Promise<{ items: TrackerItemDoc[]; total: number }> { + const qs = new URLSearchParams(); + if (params.productId) qs.set('productId', params.productId); + if (params.type) qs.set('type', params.type); + if (params.status) qs.set('status', params.status); + if (params.q) qs.set('q', params.q); + if (params.sortBy) qs.set('sortBy', params.sortBy); + if (params.sortOrder) qs.set('sortOrder', params.sortOrder); + qs.set( + 'limit', + String(Math.min(params.limit ?? config.QUERY_DEFAULT_LIMIT, config.QUERY_MAX_LIMIT)) + ); + if (params.offset !== undefined) qs.set('offset', String(params.offset)); + return platformFetch<{ items: TrackerItemDoc[]; total: number }>( + `/api/public/roadmap?${qs}`, + { method: 'GET' }, + opts + ); +} + +export function trackerPublicStats(opts: PlatformClientOptions): Promise<{ + total: number; + byStatus: Record; + byType: Record; + totalVotes: number; +}> { + return platformFetch<{ + total: number; + byStatus: Record; + byType: Record; + totalVotes: number; + }>('/api/public/roadmap/stats', { method: 'GET' }, opts); +} diff --git a/services/mcp-server/src/modules/tracker/tracker-tools.ts b/services/mcp-server/src/modules/tracker/tracker-tools.ts new file mode 100644 index 00000000..716483db --- /dev/null +++ b/services/mcp-server/src/modules/tracker/tracker-tools.ts @@ -0,0 +1,284 @@ +/** + * Tracker MCP tools — tracker.items.*, tracker.votes.*, tracker.comments.*, tracker.public.* + * + * Backed by: platform-service (port 4003) — items, votes, comments, public modules. + * All tools require admin role minimum. + */ + +import { z } from 'zod'; +import { registerTool } from '../tools/registry.js'; +import { config } from '../../lib/config.js'; +import { + trackerItemsList, + trackerItemsStats, + trackerItemsGet, + trackerItemsCreate, + trackerItemsUpdateStatus, + trackerItemsDelete, + trackerVotesToggle, + trackerVotesList, + trackerCommentsList, + trackerCommentsAdd, + trackerCommentsDelete, + trackerPublicRoadmap, + trackerPublicStats, +} from '../../lib/platform-client.js'; +import type { McpToolRequest } from '../tools/types.js'; + +const tokenOf = (req: McpToolRequest) => req.headers.authorization?.slice(7); + +// ── tracker.items.stats ─────────────────────────────────────────────────────── + +registerTool({ + name: 'tracker.items.stats', + description: + 'Aggregate tracker item counts by type, status, and priority for the product scoped to the request. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + productId: z.string().min(1).describe('Product ID to scope the stats'), + }), + async execute(args, req) { + return trackerItemsStats({ token: tokenOf(req), requestId: req.id, productId: args.productId }); + }, +}); + +// ── tracker.items.list ──────────────────────────────────────────────────────── + +registerTool({ + name: 'tracker.items.list', + description: + 'List tracker items (bugs, feature requests, tasks, improvements). Filter by type, status, priority, visibility, or free text. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + productId: z.string().min(1).describe('Product ID'), + type: z + .enum(['bug', 'feature', 'task', 'improvement']) + .optional() + .describe('Filter by item type'), + status: z + .enum(['open', 'in_progress', 'in_review', 'closed', 'wont_fix']) + .optional() + .describe('Filter by status'), + priority: z + .enum(['critical', 'high', 'medium', 'low']) + .optional() + .describe('Filter by priority'), + visibility: z + .enum(['public', 'internal']) + .optional() + .describe('Filter by visibility (public = on roadmap)'), + q: z.string().optional().describe('Full-text search across title and description'), + sortBy: z + .enum(['createdAt', 'updatedAt', 'voteCount', 'priority']) + .optional() + .default('createdAt'), + sortOrder: z.enum(['asc', 'desc']).optional().default('desc'), + limit: z.coerce.number().min(1).max(config.QUERY_MAX_LIMIT).default(config.QUERY_DEFAULT_LIMIT), + offset: z.coerce.number().min(0).default(0), + }), + async execute(args, req) { + return trackerItemsList(args, { + token: tokenOf(req), + requestId: req.id, + productId: args.productId, + }); + }, +}); + +// ── tracker.items.get ───────────────────────────────────────────────────────── + +registerTool({ + name: 'tracker.items.get', + description: + 'Get a single tracker item by ID including voteCount, commentCount, labels, assignee, and targetRelease. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + itemId: z.string().min(1).describe('Tracker item ID (trk_…)'), + }), + async execute(args, req) { + return trackerItemsGet(args.itemId, { token: tokenOf(req), requestId: req.id }); + }, +}); + +// ── tracker.items.create ────────────────────────────────────────────────────── + +registerTool({ + name: 'tracker.items.create', + description: + 'Create a new tracker item. Sets status=open automatically. Visibility defaults to internal — set to public to surface on the roadmap. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + productId: z.string().min(1).describe('Product ID'), + type: z.enum(['bug', 'feature', 'task', 'improvement']), + priority: z.enum(['critical', 'high', 'medium', 'low']).default('medium'), + title: z.string().min(1).max(250).describe('Item title'), + description: z.string().optional().describe('Detailed description (markdown supported)'), + labels: z.array(z.string()).optional().describe('Label tags'), + assignee: z.string().optional().describe('Assignee user ID or email'), + visibility: z.enum(['public', 'internal']).optional().default('internal'), + targetRelease: z.string().optional().describe('Target release version string'), + }), + async execute(args, req) { + return trackerItemsCreate(args, { + token: tokenOf(req), + requestId: req.id, + productId: args.productId, + }); + }, +}); + +// ── tracker.items.updateStatus ──────────────────────────────────────────────── + +registerTool({ + name: 'tracker.items.updateStatus', + description: + 'Quick status transition for a tracker item (open → in_progress → in_review → closed / wont_fix). Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + itemId: z.string().min(1).describe('Tracker item ID'), + status: z.enum(['open', 'in_progress', 'in_review', 'closed', 'wont_fix']), + }), + async execute(args, req) { + return trackerItemsUpdateStatus(args.itemId, args.status, { + token: tokenOf(req), + requestId: req.id, + }); + }, +}); + +// ── tracker.items.delete ────────────────────────────────────────────────────── + +registerTool({ + name: 'tracker.items.delete', + description: + 'Permanently delete a tracker item. This also removes associated vote counts. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + itemId: z.string().min(1).describe('Tracker item ID to delete'), + }), + async execute(args, req) { + return trackerItemsDelete(args.itemId, { token: tokenOf(req), requestId: req.id }); + }, +}); + +// ── tracker.votes.toggle ────────────────────────────────────────────────────── + +registerTool({ + name: 'tracker.votes.toggle', + description: + 'Toggle an upvote on a tracker item on behalf of the authenticated admin user. Returns new voteCount and voted state. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + itemId: z.string().min(1).describe('Tracker item ID to vote on'), + }), + async execute(args, req) { + return trackerVotesToggle(args.itemId, { token: tokenOf(req), requestId: req.id }); + }, +}); + +// ── tracker.votes.list ──────────────────────────────────────────────────────── + +registerTool({ + name: 'tracker.votes.list', + description: 'List voters for a tracker item (userId list + count). Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + itemId: z.string().min(1).describe('Tracker item ID'), + }), + async execute(args, req) { + return trackerVotesList(args.itemId, { token: tokenOf(req), requestId: req.id }); + }, +}); + +// ── tracker.comments.list ───────────────────────────────────────────────────── + +registerTool({ + name: 'tracker.comments.list', + description: 'List all comments for a tracker item in chronological order. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + itemId: z.string().min(1).describe('Tracker item ID'), + }), + async execute(args, req) { + return trackerCommentsList(args.itemId, { token: tokenOf(req), requestId: req.id }); + }, +}); + +// ── tracker.comments.add ────────────────────────────────────────────────────── + +registerTool({ + name: 'tracker.comments.add', + description: + 'Add a comment to a tracker item. The comment body supports Markdown. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + itemId: z.string().min(1).describe('Tracker item ID'), + body: z.string().min(1).describe('Comment body (Markdown supported)'), + }), + async execute(args, req) { + return trackerCommentsAdd(args.itemId, args.body, { token: tokenOf(req), requestId: req.id }); + }, +}); + +// ── tracker.comments.delete ─────────────────────────────────────────────────── + +registerTool({ + name: 'tracker.comments.delete', + description: 'Delete a comment (admin can delete any comment). Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + itemId: z.string().min(1).describe('Tracker item ID'), + commentId: z.string().min(1).describe('Comment ID (cmt_…)'), + }), + async execute(args, req) { + return trackerCommentsDelete(args.itemId, args.commentId, { + token: tokenOf(req), + requestId: req.id, + }); + }, +}); + +// ── tracker.public.roadmap ──────────────────────────────────────────────────── + +registerTool({ + name: 'tracker.public.roadmap', + description: + 'List public roadmap items (visibility=public, excludes closed/wont_fix) as seen by end-users. No auth required on the backend — useful for admin inspection of what is publicly visible. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + productId: z.string().min(1).describe('Product ID'), + type: z.enum(['bug', 'feature', 'task', 'improvement']).optional().describe('Filter by type'), + status: z.enum(['open', 'in_progress', 'in_review']).optional().describe('Filter by status'), + q: z.string().optional().describe('Free-text search'), + sortBy: z.enum(['createdAt', 'updatedAt', 'voteCount']).optional().default('voteCount'), + sortOrder: z.enum(['asc', 'desc']).optional().default('desc'), + limit: z.coerce.number().min(1).max(config.QUERY_MAX_LIMIT).default(config.QUERY_DEFAULT_LIMIT), + offset: z.coerce.number().min(0).default(0), + }), + async execute(args, req) { + return trackerPublicRoadmap(args, { + token: tokenOf(req), + requestId: req.id, + productId: args.productId, + }); + }, +}); + +// ── tracker.public.stats ────────────────────────────────────────────────────── + +registerTool({ + name: 'tracker.public.stats', + description: + 'Aggregate public roadmap stats: total public items, counts by status and type, total votes cast. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + productId: z.string().min(1).describe('Product ID'), + }), + async execute(args, req) { + return trackerPublicStats({ + token: tokenOf(req), + requestId: req.id, + productId: args.productId, + }); + }, +}); diff --git a/services/mcp-server/src/server.ts b/services/mcp-server/src/server.ts index 34e1aacb..e5933118 100644 --- a/services/mcp-server/src/server.ts +++ b/services/mcp-server/src/server.ts @@ -12,6 +12,7 @@ * chronomind.* — timers, routines, syncStatus * nomgap.* — fasting sessions, push triggers * peakpulse.* — adventure sessions, GPS routes, stats + * tracker.* — items, votes, comments, public roadmap * * Auth: JWT Bearer tokens issued by platform-service (same JWT_SECRET). * Role gating: viewer / admin / super_admin per tool. @@ -35,12 +36,13 @@ import './modules/jarvis/jarvis-tools.js'; import './modules/chronomind/chronomind-tools.js'; import './modules/nomgap/nomgap-tools.js'; import './modules/peakpulse/peakpulse-tools.js'; +import './modules/tracker/tracker-tools.js'; const app = await createServiceApp({ name: 'mcp-server', version: '0.1.0', description: - 'ByteLyst MCP Server — platform.*, extraction.*, support.*, mindlyst.*, lysnrai.*, jarvis.*, chronomind.*, nomgap.*, peakpulse.*', + 'ByteLyst MCP Server — platform.*, extraction.*, support.*, mindlyst.*, lysnrai.*, jarvis.*, chronomind.*, nomgap.*, peakpulse.*, tracker.*', corsOrigin: config.CORS_ORIGIN, logLevel: config.LOG_LEVEL, });