From ec3dd4bd66132d43c90b2a23ca0309bb2b35f5ac Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Tue, 10 Mar 2026 09:39:07 -0700 Subject: [PATCH] feat(mcp-server): add notes tool integration --- services/mcp-server/src/lib/config.ts | 1 + services/mcp-server/src/lib/notes-client.ts | 144 ++++++++++++++ .../src/modules/notes/notes-tools.ts | 182 ++++++++++++++++++ services/mcp-server/src/server.ts | 4 +- 4 files changed, 330 insertions(+), 1 deletion(-) create mode 100644 services/mcp-server/src/lib/notes-client.ts create mode 100644 services/mcp-server/src/modules/notes/notes-tools.ts diff --git a/services/mcp-server/src/lib/config.ts b/services/mcp-server/src/lib/config.ts index 401c256f..a00db066 100644 --- a/services/mcp-server/src/lib/config.ts +++ b/services/mcp-server/src/lib/config.ts @@ -16,6 +16,7 @@ const envSchema = z.object({ CHRONOMIND_BACKEND_URL: z.string().default('http://localhost:4011'), NOMGAP_BACKEND_URL: z.string().default('http://localhost:4013'), PEAKPULSE_BACKEND_URL: z.string().default('http://localhost:4010'), + BYTELYST_NOTES_BACKEND_URL: z.string().default('http://localhost:4016'), /** Max items returned per tool call query (hard cap) */ QUERY_MAX_LIMIT: z.coerce.number().default(100), /** Default items per tool call query */ diff --git a/services/mcp-server/src/lib/notes-client.ts b/services/mcp-server/src/lib/notes-client.ts new file mode 100644 index 00000000..e2a5fe59 --- /dev/null +++ b/services/mcp-server/src/lib/notes-client.ts @@ -0,0 +1,144 @@ +import { config } from './config.js'; + +export interface NotesClientOptions { + token?: string; + requestId?: string; +} + +async function notesFetch( + path: string, + init: RequestInit, + opts: NotesClientOptions +): Promise { + const headers: Record = { + 'Content-Type': 'application/json', + ...(opts.token ? { Authorization: `Bearer ${opts.token}` } : {}), + ...(opts.requestId ? { 'x-request-id': opts.requestId } : {}), + }; + const res = await fetch(`${config.BYTELYST_NOTES_BACKEND_URL}/api${path}`, { + ...init, + headers: { ...((init.headers as Record) ?? {}), ...headers }, + signal: AbortSignal.timeout(15_000), + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error( + `bytelyst-notes-backend ${init.method ?? 'GET'} ${path} → ${res.status}: ${body}` + ); + } + return res.json() as Promise; +} + +export interface NoteDoc { + id: string; + productId: string; + workspaceId: string; + userId: string; + title: string; + body: string; + status: 'draft' | 'active' | 'archived'; + tags: string[]; + links: string[]; + sourceType?: string; + sourceUri?: string; + createdAt: string; + updatedAt: string; + createdBy: string; + updatedBy: string; + agentId?: string; +} + +export interface NoteListResponse { + items: NoteDoc[]; + total: number; + limit: number; + offset: number; +} + +export function notesList( + params: { + workspaceId: string; + status?: 'draft' | 'active' | 'archived'; + tag?: string; + limit?: number; + offset?: number; + }, + opts: NotesClientOptions +): Promise { + const qs = new URLSearchParams(); + qs.set('workspaceId', params.workspaceId); + if (params.status) qs.set('status', params.status); + if (params.tag) qs.set('tag', params.tag); + if (params.limit !== undefined) qs.set('limit', String(params.limit)); + if (params.offset !== undefined) qs.set('offset', String(params.offset)); + return notesFetch(`/notes?${qs.toString()}`, { method: 'GET' }, opts); +} + +export function notesSearch( + params: { + workspaceId: string; + query: string; + status?: 'draft' | 'active' | 'archived'; + tag?: string; + limit?: number; + offset?: number; + }, + opts: NotesClientOptions +): Promise { + const qs = new URLSearchParams(); + qs.set('workspaceId', params.workspaceId); + qs.set('search', params.query); + if (params.status) qs.set('status', params.status); + if (params.tag) qs.set('tag', params.tag); + if (params.limit !== undefined) qs.set('limit', String(params.limit)); + if (params.offset !== undefined) qs.set('offset', String(params.offset)); + return notesFetch(`/notes/search?${qs.toString()}`, { method: 'GET' }, opts); +} + +export function notesGet( + noteId: string, + workspaceId: string, + opts: NotesClientOptions +): Promise { + const qs = new URLSearchParams({ workspaceId }); + return notesFetch(`/notes/${noteId}?${qs.toString()}`, { method: 'GET' }, opts); +} + +export function notesCreateDraft( + input: { + id: string; + workspaceId: string; + title: string; + body: string; + tags?: string[]; + links?: string[]; + sourceType?: string; + sourceUri?: string; + agentId?: string; + }, + opts: NotesClientOptions +): Promise { + return notesFetch('/notes', { method: 'POST', body: JSON.stringify(input) }, opts); +} + +export function notesCreateAgentAction( + input: { + id: string; + workspaceId: string; + noteId: string; + actorId: string; + actorType: 'agent' | 'human'; + toolName: string; + actionType: 'create' | 'update' | 'summarize' | 'extract_tasks' | 'attach_citation'; + state?: 'draft' | 'proposed' | 'approved' | 'rejected' | 'applied'; + reason?: string; + beforeSummary?: string; + afterSummary?: string; + idempotencyKey?: string; + correlationId?: string; + workflowId?: string; + }, + opts: NotesClientOptions +): Promise<{ id: string; noteId: string; state: string; toolName: string }> { + return notesFetch('/note-agent-actions', { method: 'POST', body: JSON.stringify(input) }, opts); +} diff --git a/services/mcp-server/src/modules/notes/notes-tools.ts b/services/mcp-server/src/modules/notes/notes-tools.ts new file mode 100644 index 00000000..80e3c04c --- /dev/null +++ b/services/mcp-server/src/modules/notes/notes-tools.ts @@ -0,0 +1,182 @@ +import { randomUUID } from 'node:crypto'; +import { z } from 'zod'; +import { registerTool } from '../tools/registry.js'; +import { config } from '../../lib/config.js'; +import { + notesCreateAgentAction, + notesCreateDraft, + notesGet, + notesList, + notesSearch, +} from '../../lib/notes-client.js'; +import type { McpToolRequest } from '../tools/types.js'; + +const tokenOf = (req: McpToolRequest) => req.headers.authorization?.slice(7); + +function requireProductScope(req: McpToolRequest): void { + const requestProductId = req.jwtPayload?.productId; + if (requestProductId && requestProductId !== 'bytelyst-notes') { + throw new Error("Product scope mismatch: expected 'bytelyst-notes'"); + } +} + +function requireUserId(req: McpToolRequest): string { + requireProductScope(req); + if (!req.jwtPayload?.sub) { + throw new Error('Authentication required'); + } + return req.jwtPayload.sub; +} + +registerTool({ + name: 'notes.notes.list', + description: 'List notes in a workspace with optional status and tag filters.', + requiredRole: 'viewer', + inputSchema: z.object({ + workspaceId: z.string().min(1).max(128), + status: z.enum(['draft', 'active', 'archived']).optional(), + tag: z.string().min(1).max(64).optional(), + 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) { + requireUserId(req); + return notesList(args, { token: tokenOf(req), requestId: req.id }); + }, +}); + +registerTool({ + name: 'notes.notes.get', + description: 'Get a single note by note ID and workspace scope.', + requiredRole: 'viewer', + inputSchema: z.object({ + noteId: z.string().min(1).max(128), + workspaceId: z.string().min(1).max(128), + }), + async execute(args, req) { + requireUserId(req); + return notesGet(args.noteId, args.workspaceId, { token: tokenOf(req), requestId: req.id }); + }, +}); + +registerTool({ + name: 'notes.notes.search', + description: 'Search notes in a workspace using lexical query plus optional filters.', + requiredRole: 'viewer', + inputSchema: z.object({ + workspaceId: z.string().min(1).max(128), + query: z.string().min(1).max(200), + status: z.enum(['draft', 'active', 'archived']).optional(), + tag: z.string().min(1).max(64).optional(), + 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) { + requireUserId(req); + return notesSearch(args, { token: tokenOf(req), requestId: req.id }); + }, +}); + +registerTool({ + name: 'notes.notes.create_draft', + description: + 'Create a note draft in a workspace. Supports dry-run, idempotency, and correlation metadata.', + requiredRole: 'admin', + inputSchema: z.object({ + workspaceId: z.string().min(1).max(128), + title: z.string().min(1).max(500), + body: z.string().min(1).max(50000), + tags: z.array(z.string().min(1).max(64)).default([]), + links: z.array(z.string().min(1).max(512)).default([]), + sourceType: z.string().max(128).optional(), + sourceUri: z.string().max(2000).optional(), + agentId: z.string().max(128).optional(), + dryRun: z.boolean().default(false), + idempotencyKey: z.string().max(255).optional(), + correlationId: z.string().max(255).optional(), + }), + async execute(args, req) { + const userId = requireUserId(req); + const noteId = randomUUID(); + const correlationId = args.correlationId ?? req.id; + + if (args.dryRun) { + const now = new Date().toISOString(); + return { + dryRun: true, + state: 'proposed', + note: { + id: noteId, + workspaceId: args.workspaceId, + title: args.title, + body: args.body, + status: 'draft', + tags: args.tags, + links: args.links, + sourceType: args.sourceType, + sourceUri: args.sourceUri, + agentId: args.agentId, + createdAt: now, + updatedAt: now, + }, + idempotencyKey: args.idempotencyKey, + correlationId, + }; + } + + const note = await notesCreateDraft( + { + id: noteId, + workspaceId: args.workspaceId, + title: args.title, + body: args.body, + tags: args.tags, + links: args.links, + sourceType: args.sourceType, + sourceUri: args.sourceUri, + agentId: args.agentId, + }, + { token: tokenOf(req), requestId: req.id } + ); + + await notesCreateAgentAction( + { + id: randomUUID(), + workspaceId: args.workspaceId, + noteId: note.id, + actorId: args.agentId ?? userId, + actorType: args.agentId ? 'agent' : 'human', + toolName: 'notes.notes.create_draft', + actionType: 'create', + state: 'proposed', + reason: 'Created via shared MCP create_draft tool', + afterSummary: `${note.title}\n\n${note.body}`.slice(0, 4000), + idempotencyKey: args.idempotencyKey, + correlationId, + workflowId: req.id, + }, + { token: tokenOf(req), requestId: req.id } + ); + + return { + dryRun: false, + state: 'draft', + note: { + id: note.id, + workspaceId: note.workspaceId, + title: note.title, + body: note.body, + status: note.status, + tags: note.tags, + links: note.links, + sourceType: note.sourceType, + sourceUri: note.sourceUri, + agentId: note.agentId, + createdAt: note.createdAt, + updatedAt: note.updatedAt, + }, + idempotencyKey: args.idempotencyKey, + correlationId, + }; + }, +}); diff --git a/services/mcp-server/src/server.ts b/services/mcp-server/src/server.ts index 99a9fdab..55b01561 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 + * notes.* — notes, search, draft creation * tracker.* — items, votes, comments, public roadmap * flags.* — feature flag CRUD + kill switch * jobs.* — background job list, trigger, run history @@ -67,6 +68,7 @@ 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/notes/notes-tools.js'; import './modules/tracker/tracker-tools.js'; import './modules/platform/ops-tools.js'; import './modules/platform/webhooks-tools.js'; @@ -80,7 +82,7 @@ const app = await createServiceApp({ name: 'mcp-server', version: '0.1.0', description: - 'ByteLyst MCP Server — platform.*, extraction.*, support.*, mindlyst.*, lysnrai.*, jarvis.*, chronomind.*, nomgap.*, peakpulse.*, tracker.*, flags.*, jobs.*, maintenance.*, settings.*, webhooks.*', + 'ByteLyst MCP Server — platform.*, extraction.*, support.*, mindlyst.*, lysnrai.*, jarvis.*, chronomind.*, nomgap.*, peakpulse.*, notes.*, tracker.*, flags.*, jobs.*, maintenance.*, settings.*, webhooks.*', corsOrigin: config.CORS_ORIGIN, logLevel: config.LOG_LEVEL, });