From f780f0b40980b8e38de0951f866a5c34962b405e Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Thu, 5 Mar 2026 13:01:05 -0800 Subject: [PATCH] =?UTF-8?q?feat(mcp-server):=20Phase=203=20=E2=80=94=20pro?= =?UTF-8?q?duct=20namespaces=20for=20MindLyst,=20LysnrAI,=20JarvisJr=20(22?= =?UTF-8?q?=20new=20tools)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/mcp-server/.env.example | 5 + services/mcp-server/src/lib/config.ts | 4 + services/mcp-server/src/lib/jarvis-client.ts | 177 ++++++++++++++ services/mcp-server/src/lib/lysnrai-client.ts | 167 +++++++++++++ .../mcp-server/src/lib/mindlyst-client.ts | 221 ++++++++++++++++++ .../src/modules/jarvis/jarvis-tools.ts | 150 ++++++++++++ .../src/modules/lysnrai/lysnrai-tools.ts | 133 +++++++++++ .../src/modules/mindlyst/mindlyst-tools.ts | 201 ++++++++++++++++ services/mcp-server/src/server.ts | 8 +- 9 files changed, 1065 insertions(+), 1 deletion(-) create mode 100644 services/mcp-server/src/lib/jarvis-client.ts create mode 100644 services/mcp-server/src/lib/lysnrai-client.ts create mode 100644 services/mcp-server/src/lib/mindlyst-client.ts create mode 100644 services/mcp-server/src/modules/jarvis/jarvis-tools.ts create mode 100644 services/mcp-server/src/modules/lysnrai/lysnrai-tools.ts create mode 100644 services/mcp-server/src/modules/mindlyst/mindlyst-tools.ts diff --git a/services/mcp-server/.env.example b/services/mcp-server/.env.example index ca1d092f..e4431259 100644 --- a/services/mcp-server/.env.example +++ b/services/mcp-server/.env.example @@ -7,6 +7,11 @@ LOG_LEVEL=info PLATFORM_SERVICE_URL=http://localhost:4003 EXTRACTION_SERVICE_URL=http://localhost:4005 +# Product-specific backend URLs (Phase 3 product namespaces) +MINDLYST_BACKEND_URL=http://localhost:4014 +LYSNRAI_BACKEND_URL=http://localhost:4015 +JARVISJR_BACKEND_URL=http://localhost:4012 + # Auth — same JWT_SECRET as platform-service (tokens issued there are validated here) JWT_SECRET=change-me-in-production diff --git a/services/mcp-server/src/lib/config.ts b/services/mcp-server/src/lib/config.ts index 99351442..769877b8 100644 --- a/services/mcp-server/src/lib/config.ts +++ b/services/mcp-server/src/lib/config.ts @@ -9,6 +9,10 @@ const envSchema = z.object({ JWT_SECRET: z.string().min(1, 'JWT_SECRET is required'), PLATFORM_SERVICE_URL: z.string().default('http://localhost:4003'), EXTRACTION_SERVICE_URL: z.string().default('http://localhost:4005'), + /** Product-specific backend URLs */ + MINDLYST_BACKEND_URL: z.string().default('http://localhost:4014'), + LYSNRAI_BACKEND_URL: z.string().default('http://localhost:4015'), + JARVISJR_BACKEND_URL: z.string().default('http://localhost:4012'), /** 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/jarvis-client.ts b/services/mcp-server/src/lib/jarvis-client.ts new file mode 100644 index 00000000..3247c5c5 --- /dev/null +++ b/services/mcp-server/src/lib/jarvis-client.ts @@ -0,0 +1,177 @@ +/** + * JarvisJr backend client — typed HTTP wrappers for the jarvisjr-backend (port 4012). + * + * Auth: Bearer token from the caller's JWT (same JWT_SECRET as platform-service). + */ + +import { config } from './config.js'; + +export interface JarvisClientOptions { + token?: string; + requestId?: string; +} + +// ── Shared fetch helper ──────────────────────────────────────────────────── + +async function jarvisFetch( + path: string, + init: RequestInit, + opts: JarvisClientOptions +): 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.JARVISJR_BACKEND_URL}${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(`jarvisjr-backend ${init.method ?? 'GET'} ${path} → ${res.status}: ${body}`); + } + return res.json() as Promise; +} + +// ── Agents ───────────────────────────────────────────────────────────────── + +export interface JarvisAgentDoc { + id: string; + userId: string; + productId: string; + name: string; + role: string; + systemPrompt: string; + voiceId?: string; + coachingFramework?: string; + accentColor?: string; + welcomeMessage?: string; + sessionLength?: number; + difficultyLevel?: string; + language?: string; + privacyLevel?: string; + checkInSchedule?: string; + isTemplate?: boolean; + templateSource?: string; + totalSessions: number; + lastSessionAt: string | null; + createdAt: string; + updatedAt: string; +} + +export function jarvisAgentsList( + params: { limit?: number; offset?: number }, + opts: JarvisClientOptions +): Promise<{ agents: JarvisAgentDoc[]; total: number }> { + const qs = new URLSearchParams(); + if (params.limit !== undefined) qs.set('limit', String(params.limit)); + if (params.offset !== undefined) qs.set('offset', String(params.offset)); + const q = qs.toString(); + return jarvisFetch(`/jarvis/agents${q ? `?${q}` : ''}`, { method: 'GET' }, opts); +} + +export function jarvisAgentDuplicate( + agentId: string, + opts: JarvisClientOptions +): Promise { + return jarvisFetch(`/jarvis/agents/${agentId}/duplicate`, { method: 'POST' }, opts); +} + +// ── Sessions ─────────────────────────────────────────────────────────────── + +export interface JarvisSessionDoc { + id: string; + userId: string; + productId: string; + agentId: string; + mode: string; + status: 'active' | 'completed'; + summary?: string; + coachingNotes?: string[]; + skillMetrics?: Record; + duration?: number; + messageCount: number; + createdAt: string; + completedAt: string | null; +} + +export interface JarvisSessionStats { + totalSessions: number; + totalDurationMinutes: number; + currentStreak: number; + longestStreak: number; + perAgent: Record; +} + +export function jarvisSessionsList( + params: { limit?: number; offset?: number; agentId?: string }, + opts: JarvisClientOptions +): Promise<{ sessions: JarvisSessionDoc[]; total: number }> { + const qs = new URLSearchParams(); + if (params.limit !== undefined) qs.set('limit', String(params.limit)); + if (params.offset !== undefined) qs.set('offset', String(params.offset)); + if (params.agentId) qs.set('agentId', params.agentId); + const q = qs.toString(); + return jarvisFetch(`/jarvis/sessions${q ? `?${q}` : ''}`, { method: 'GET' }, opts); +} + +export function jarvisSessionsGetStats(opts: JarvisClientOptions): Promise { + return jarvisFetch('/jarvis/sessions/stats', { method: 'GET' }, opts); +} + +// ── Memory ───────────────────────────────────────────────────────────────── + +export interface JarvisMemoryDoc { + id: string; + agentId: string; + userId: string; + productId: string; + sessionId?: string; + type: 'skill_note' | 'preference' | 'goal' | 'context' | 'exercise'; + content: string; + importance: number; + tags?: string[]; + createdAt: string; + expiresAt?: string; +} + +export function jarvisMemoryList( + agentId: string, + params: { + type?: string; + minImportance?: number; + limit?: number; + offset?: number; + }, + opts: JarvisClientOptions +): Promise<{ memories: JarvisMemoryDoc[]; total: number }> { + const qs = new URLSearchParams(); + if (params.type) qs.set('type', params.type); + if (params.minImportance !== undefined) qs.set('minImportance', String(params.minImportance)); + if (params.limit !== undefined) qs.set('limit', String(params.limit)); + if (params.offset !== undefined) qs.set('offset', String(params.offset)); + const q = qs.toString(); + return jarvisFetch( + `/jarvis/agents/${agentId}/memory${q ? `?${q}` : ''}`, + { method: 'GET' }, + opts + ); +} + +export function jarvisMemoryPrune( + agentId: string, + opts: JarvisClientOptions +): Promise<{ pruned: number }> { + return jarvisFetch(`/jarvis/agents/${agentId}/memory/prune`, { method: 'POST' }, opts); +} + +export function jarvisMemoryGetContext( + agentId: string, + limit: number | undefined, + opts: JarvisClientOptions +): Promise<{ memories: JarvisMemoryDoc[]; count: number }> { + const qs = limit !== undefined ? `?limit=${limit}` : ''; + return jarvisFetch(`/jarvis/agents/${agentId}/memory/context${qs}`, { method: 'GET' }, opts); +} diff --git a/services/mcp-server/src/lib/lysnrai-client.ts b/services/mcp-server/src/lib/lysnrai-client.ts new file mode 100644 index 00000000..8c3dc19e --- /dev/null +++ b/services/mcp-server/src/lib/lysnrai-client.ts @@ -0,0 +1,167 @@ +/** + * LysnrAI backend client — typed HTTP wrappers for the lysnrai-backend (port 4015). + * + * Auth: Bearer token from the caller's JWT (same JWT_SECRET as platform-service). + */ + +import { config } from './config.js'; + +export interface LysnraiClientOptions { + token?: string; + requestId?: string; +} + +// ── Shared fetch helper ──────────────────────────────────────────────────── + +async function lysnraiFetch( + path: string, + init: RequestInit, + opts: LysnraiClientOptions +): 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.LYSNRAI_BACKEND_URL}${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(`lysnrai-backend ${init.method ?? 'GET'} ${path} → ${res.status}: ${body}`); + } + return res.json() as Promise; +} + +// ── Transcripts ──────────────────────────────────────────────────────────── + +export interface TranscriptDoc { + id: string; + userId: string; + productId: string; + rawText?: string; + cleanedText?: string; + wordCount?: number; + duration?: number; + tokensUsed?: number; + context?: string; + source?: string; + language?: string; + extractions?: unknown[]; + extractionMetadata?: Record; + extractedAt?: string; + createdAt: string; +} + +export function lysnraiTranscriptsList( + params: { limit?: number; offset?: number }, + opts: LysnraiClientOptions +): Promise<{ transcripts: TranscriptDoc[]; total: number }> { + const qs = new URLSearchParams(); + if (params.limit !== undefined) qs.set('limit', String(params.limit)); + if (params.offset !== undefined) qs.set('offset', String(params.offset)); + const q = qs.toString(); + return lysnraiFetch(`/transcripts${q ? `?${q}` : ''}`, { method: 'GET' }, opts); +} + +export function lysnraiTranscriptRunExtraction( + transcriptId: string, + opts: LysnraiClientOptions +): Promise { + return lysnraiFetch(`/transcripts/${transcriptId}/extract`, { method: 'POST' }, opts); +} + +// ── STT status ───────────────────────────────────────────────────────────── + +export interface SttStatusResult { + productId: string; + primary: 'azure' | 'whisper' | 'none'; + azureConfigured: boolean; + whisperConfigured: boolean; + azureRegion: string | null; + generatedAt: string; +} + +export function lysnraiSttGetBackendStatus(opts: LysnraiClientOptions): Promise { + return lysnraiFetch('/transcripts/stt-status', { method: 'GET' }, opts); +} + +// ── Sessions ─────────────────────────────────────────────────────────────── + +export interface SessionDoc { + id: string; + userId: string; + productId: string; + title: string; + status: 'active' | 'composed' | 'archived'; + entries: unknown[]; + composedText: string | null; + composedAt: string | null; + compositionRating: number | null; + createdAt: string; + updatedAt: string; +} + +export function lysnraiSessionsList( + params: { limit?: number; offset?: number; status?: string }, + opts: LysnraiClientOptions +): Promise<{ sessions: SessionDoc[]; total: number }> { + const qs = new URLSearchParams(); + if (params.limit !== undefined) qs.set('limit', String(params.limit)); + if (params.offset !== undefined) qs.set('offset', String(params.offset)); + if (params.status) qs.set('status', params.status); + const q = qs.toString(); + return lysnraiFetch(`/sessions${q ? `?${q}` : ''}`, { method: 'GET' }, opts); +} + +// ── Organisations ────────────────────────────────────────────────────────── + +export interface OrgDoc { + id: string; + name: string; + ownerId: string; + productId: string; + createdAt: string; +} + +export function lysnraiOrgsList( + params: { limit?: number; offset?: number }, + opts: LysnraiClientOptions +): Promise<{ organizations: OrgDoc[]; total: number }> { + const qs = new URLSearchParams(); + if (params.limit !== undefined) qs.set('limit', String(params.limit)); + if (params.offset !== undefined) qs.set('offset', String(params.offset)); + const q = qs.toString(); + return lysnraiFetch(`/organizations${q ? `?${q}` : ''}`, { method: 'GET' }, opts); +} + +// ── API tokens ───────────────────────────────────────────────────────────── + +export interface ApiTokenDoc { + id: string; + userId: string; + productId: string; + name: string; + lastUsedAt: string | null; + createdAt: string; +} + +export function lysnraiApiTokensList( + params: { limit?: number; offset?: number }, + opts: LysnraiClientOptions +): Promise<{ tokens: ApiTokenDoc[]; total: number }> { + const qs = new URLSearchParams(); + if (params.limit !== undefined) qs.set('limit', String(params.limit)); + if (params.offset !== undefined) qs.set('offset', String(params.offset)); + const q = qs.toString(); + return lysnraiFetch(`/api-tokens${q ? `?${q}` : ''}`, { method: 'GET' }, opts); +} + +export function lysnraiApiTokenRotate( + tokenId: string, + opts: LysnraiClientOptions +): Promise<{ token: string; id: string }> { + return lysnraiFetch(`/api-tokens/${tokenId}/rotate`, { method: 'POST' }, opts); +} diff --git a/services/mcp-server/src/lib/mindlyst-client.ts b/services/mcp-server/src/lib/mindlyst-client.ts new file mode 100644 index 00000000..94bba2f6 --- /dev/null +++ b/services/mcp-server/src/lib/mindlyst-client.ts @@ -0,0 +1,221 @@ +/** + * MindLyst backend client — typed HTTP wrappers for the mindlyst-backend (port 4014). + * + * Auth: Bearer token from the caller's JWT (same JWT_SECRET as platform-service). + * Admin callers may pass x-admin-user-id to operate on behalf of any user. + */ + +import { config } from './config.js'; + +export interface MindlystClientOptions { + token?: string; + requestId?: string; + /** When set (admin only) — overrides the JWT sub used as userId in backend routes */ + adminUserId?: string; +} + +// ── Shared fetch helper ──────────────────────────────────────────────────── + +async function mindlystFetch( + path: string, + init: RequestInit, + opts: MindlystClientOptions +): Promise { + const headers: Record = { + 'Content-Type': 'application/json', + ...(opts.token ? { Authorization: `Bearer ${opts.token}` } : {}), + ...(opts.requestId ? { 'x-request-id': opts.requestId } : {}), + ...(opts.adminUserId ? { 'x-admin-user-id': opts.adminUserId } : {}), + }; + const res = await fetch(`${config.MINDLYST_BACKEND_URL}${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(`mindlyst-backend ${init.method ?? 'GET'} ${path} → ${res.status}: ${body}`); + } + return res.json() as Promise; +} + +// ── Memory items ─────────────────────────────────────────────────────────── + +export interface MemoryItemDoc { + id: string; + productId: string; + userId: string; + sourceType: string; + captureSurface: string; + rawContent: string; + triageResult: { + contentType: string; + summary?: string; + urgencyScore: number; + emotionScore: number; + confidenceScore: number; + suggestedBrainId: string; + entities: string[]; + suggestedActions: string[]; + }; + brainIds: string[]; + actedOn: boolean; + actedOnAt: string | null; + nudgeCount: number; + reminderAt?: string; + isSensitive: boolean; + createdAt: string; + updatedAt: string; +} + +export function mindlystMemoryList( + params: { + brainId?: string; + filter?: 'forgotten' | 'completed_today'; + limit?: number; + offset?: number; + productId?: string; + }, + opts: MindlystClientOptions +): Promise<{ items: MemoryItemDoc[]; limit: number; offset: number }> { + const qs = new URLSearchParams(); + if (params.brainId) qs.set('brainId', params.brainId); + if (params.filter) qs.set('filter', params.filter); + if (params.limit !== undefined) qs.set('limit', String(params.limit)); + if (params.offset !== undefined) qs.set('offset', String(params.offset)); + if (params.productId) qs.set('productId', params.productId); + const q = qs.toString(); + return mindlystFetch(`/memory-items${q ? `?${q}` : ''}`, { method: 'GET' }, opts); +} + +export function mindlystMemoryRetriage( + itemId: string, + opts: MindlystClientOptions +): Promise { + return mindlystFetch(`/memory-items/${itemId}/retriage`, { method: 'POST' }, opts); +} + +export function mindlystMemoryPatch( + itemId: string, + action: 'mark_done' | 'mark_undone' | 'increment_nudge' | 'set_reminder', + reminderAt: string | undefined, + opts: MindlystClientOptions +): Promise { + return mindlystFetch( + `/memory-items/${itemId}`, + { method: 'PATCH', body: JSON.stringify({ action, ...(reminderAt ? { reminderAt } : {}) }) }, + opts + ); +} + +export function mindlystMemoryReassign( + itemId: string, + newBrainId: string, + opts: MindlystClientOptions +): Promise { + return mindlystFetch( + `/memory-items/${itemId}/reassign`, + { method: 'PUT', body: JSON.stringify({ newBrainId }) }, + opts + ); +} + +// ── Brains ───────────────────────────────────────────────────────────────── + +export interface BrainDoc { + id: string; + userId: string; + productId: string; + name: string; + rolePrompt?: string; + tone?: string; + colorFrom?: string; + colorTo?: string; + createdAt: string; + updatedAt: string | null; +} + +export function mindlystBrainsList( + params: { limit?: number; offset?: number }, + opts: MindlystClientOptions +): Promise<{ items: BrainDoc[]; total: number; limit: number; offset: number }> { + const qs = new URLSearchParams(); + if (params.limit !== undefined) qs.set('limit', String(params.limit)); + if (params.offset !== undefined) qs.set('offset', String(params.offset)); + const q = qs.toString(); + return mindlystFetch(`/brains${q ? `?${q}` : ''}`, { method: 'GET' }, opts); +} + +// ── Daily briefs ─────────────────────────────────────────────────────────── + +export interface DailyBriefDoc { + id: string; + userId: string; + productId: string; + date: string; + greeting?: string; + priorityItems: unknown[]; + brainSummaries: Record; + streakMessage?: string; + motivationalQuote?: string; + createdAt: string; +} + +export function mindlystBriefsGetToday(opts: MindlystClientOptions): Promise { + return mindlystFetch('/daily-briefs/today', { method: 'GET' }, opts); +} + +export function mindlystBriefsList( + params: { limit?: number; offset?: number }, + opts: MindlystClientOptions +): Promise<{ items: DailyBriefDoc[]; total: number }> { + const qs = new URLSearchParams(); + if (params.limit !== undefined) qs.set('limit', String(params.limit)); + if (params.offset !== undefined) qs.set('offset', String(params.offset)); + const q = qs.toString(); + return mindlystFetch(`/daily-briefs${q ? `?${q}` : ''}`, { method: 'GET' }, opts); +} + +// ── Streaks ──────────────────────────────────────────────────────────────── + +export interface StreakDoc { + id: string; + userId: string; + productId: string; + currentStreak: number; + longestStreak: number; + lastActiveDate: string; + streakFreezeAvailable: boolean; + totalActiveDays: number; + createdAt: string; + updatedAt: string; +} + +export function mindlystStreaksGet(opts: MindlystClientOptions): Promise { + return mindlystFetch('/streaks', { method: 'GET' }, opts); +} + +// ── Reflections ──────────────────────────────────────────────────────────── + +export interface ReflectionDoc { + id: string; + userId: string; + productId: string; + weekStartDate: string; + themes: string[]; + highlights: string[]; + challenges: string[]; + summary?: string; + createdAt: string; +} + +export function mindlystReflectionsList( + params: { limit?: number; offset?: number }, + opts: MindlystClientOptions +): Promise<{ items: ReflectionDoc[]; total: number }> { + const qs = new URLSearchParams(); + if (params.limit !== undefined) qs.set('limit', String(params.limit)); + if (params.offset !== undefined) qs.set('offset', String(params.offset)); + const q = qs.toString(); + return mindlystFetch(`/reflections${q ? `?${q}` : ''}`, { method: 'GET' }, opts); +} diff --git a/services/mcp-server/src/modules/jarvis/jarvis-tools.ts b/services/mcp-server/src/modules/jarvis/jarvis-tools.ts new file mode 100644 index 00000000..b48d8896 --- /dev/null +++ b/services/mcp-server/src/modules/jarvis/jarvis-tools.ts @@ -0,0 +1,150 @@ +/** + * JarvisJr MCP tools — jarvis.agents.*, jarvis.sessions.*, jarvis.memory.* + * + * Backed by: jarvisjr-backend (port 4012). + * All tools require admin role. + */ + +import { z } from 'zod'; +import { registerTool } from '../tools/registry.js'; +import { config } from '../../lib/config.js'; +import { + jarvisAgentsList, + jarvisAgentDuplicate, + jarvisSessionsList, + jarvisSessionsGetStats, + jarvisMemoryList, + jarvisMemoryPrune, + jarvisMemoryGetContext, +} from '../../lib/jarvis-client.js'; +import type { McpToolRequest } from '../tools/types.js'; + +const tokenOf = (req: McpToolRequest) => req.headers.authorization?.slice(7); + +// ── jarvis.agents.list ──────────────────────────────────────────────────── + +registerTool({ + name: 'jarvis.agents.list', + description: + 'List coaching agents for the authenticated user (name, role, coachingFramework, totalSessions). Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + 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 jarvisAgentsList(args, { token: tokenOf(req), requestId: req.id }); + }, +}); + +// ── jarvis.agents.duplicate ─────────────────────────────────────────────── + +registerTool({ + name: 'jarvis.agents.duplicate', + description: + 'Duplicate an existing agent (creates a copy with " (Copy)" suffix and zero session count). Useful for iterating on agent prompts. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + agentId: z.string().min(1).describe('Agent ID to duplicate'), + }), + async execute(args, req) { + return jarvisAgentDuplicate(args.agentId, { token: tokenOf(req), requestId: req.id }); + }, +}); + +// ── jarvis.sessions.list ────────────────────────────────────────────────── + +registerTool({ + name: 'jarvis.sessions.list', + description: + 'List coaching sessions for the authenticated user. Filter by agentId. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + agentId: z.string().optional().describe('Filter to a specific agent ID'), + 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 jarvisSessionsList(args, { token: tokenOf(req), requestId: req.id }); + }, +}); + +// ── jarvis.sessions.stats ───────────────────────────────────────────────── + +registerTool({ + name: 'jarvis.sessions.stats', + description: + 'Get coaching session statistics: total sessions, total duration, current streak, per-agent breakdown. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({}), + async execute(_args, req) { + return jarvisSessionsGetStats({ token: tokenOf(req), requestId: req.id }); + }, +}); + +// ── jarvis.memory.list ──────────────────────────────────────────────────── + +registerTool({ + name: 'jarvis.memory.list', + description: + 'List persistent memories for a coaching agent. Filter by type (skill_note, preference, goal, context, exercise) or minimum importance score. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + agentId: z.string().min(1).describe('Agent ID to query memories for'), + type: z + .enum(['skill_note', 'preference', 'goal', 'context', 'exercise']) + .optional() + .describe('Filter by memory type'), + minImportance: z.coerce + .number() + .min(0) + .max(1) + .optional() + .describe('Minimum importance score (0.0–1.0)'), + 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) { + const { agentId, ...params } = args; + return jarvisMemoryList(agentId, params, { token: tokenOf(req), requestId: req.id }); + }, +}); + +// ── jarvis.memory.prune ─────────────────────────────────────────────────── + +registerTool({ + name: 'jarvis.memory.prune', + description: + 'Prune expired memories for a coaching agent (removes entries where expiresAt < now). Returns count of pruned entries. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + agentId: z.string().min(1).describe('Agent ID to prune memories for'), + }), + async execute(args, req) { + return jarvisMemoryPrune(args.agentId, { token: tokenOf(req), requestId: req.id }); + }, +}); + +// ── jarvis.memory.getContext ────────────────────────────────────────────── + +registerTool({ + name: 'jarvis.memory.getContext', + description: + 'Retrieve the top-N memories for a coaching agent to use as session context (sorted by importance). Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + agentId: z.string().min(1).describe('Agent ID'), + limit: z.coerce + .number() + .min(1) + .max(50) + .default(20) + .describe('Number of context memories to return'), + }), + async execute(args, req) { + return jarvisMemoryGetContext(args.agentId, args.limit, { + token: tokenOf(req), + requestId: req.id, + }); + }, +}); diff --git a/services/mcp-server/src/modules/lysnrai/lysnrai-tools.ts b/services/mcp-server/src/modules/lysnrai/lysnrai-tools.ts new file mode 100644 index 00000000..4a2409f4 --- /dev/null +++ b/services/mcp-server/src/modules/lysnrai/lysnrai-tools.ts @@ -0,0 +1,133 @@ +/** + * LysnrAI MCP tools — lysnrai.transcripts.*, lysnrai.stt.*, lysnrai.sessions.*, + * lysnrai.orgs.*, lysnrai.apiTokens.* + * + * Backed by: lysnrai-backend (port 4015). + * All tools require admin role. + */ + +import { z } from 'zod'; +import { registerTool } from '../tools/registry.js'; +import { config } from '../../lib/config.js'; +import { + lysnraiTranscriptsList, + lysnraiTranscriptRunExtraction, + lysnraiSttGetBackendStatus, + lysnraiSessionsList, + lysnraiOrgsList, + lysnraiApiTokensList, + lysnraiApiTokenRotate, +} from '../../lib/lysnrai-client.js'; +import type { McpToolRequest } from '../tools/types.js'; + +const tokenOf = (req: McpToolRequest) => req.headers.authorization?.slice(7); + +// ── lysnrai.transcripts.list ────────────────────────────────────────────── + +registerTool({ + name: 'lysnrai.transcripts.list', + description: 'List dictation transcripts for the authenticated user. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + 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 lysnraiTranscriptsList(args, { token: tokenOf(req), requestId: req.id }); + }, +}); + +// ── lysnrai.transcripts.runExtraction ──────────────────────────────────── + +registerTool({ + name: 'lysnrai.transcripts.runExtraction', + description: + 'Run extraction-service enrichment on a transcript — populates extractions[] and extractedAt. Best-effort: returns partial if extraction-service is unavailable. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + transcriptId: z.string().min(1).describe('Transcript ID (trx_... prefix)'), + }), + async execute(args, req) { + return lysnraiTranscriptRunExtraction(args.transcriptId, { + token: tokenOf(req), + requestId: req.id, + }); + }, +}); + +// ── lysnrai.stt.getBackendStatus ────────────────────────────────────────── + +registerTool({ + name: 'lysnrai.stt.getBackendStatus', + description: + 'Report which speech-to-text backend is active: azure (Azure Speech SDK) or whisper (local Whisper model). Useful for ops health checks. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({}), + async execute(_args, req) { + return lysnraiSttGetBackendStatus({ token: tokenOf(req), requestId: req.id }); + }, +}); + +// ── lysnrai.sessions.list ───────────────────────────────────────────────── + +registerTool({ + name: 'lysnrai.sessions.list', + description: 'List dictation sessions for the authenticated user. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + status: z + .enum(['active', 'composed', 'archived']) + .optional() + .describe('Filter by session status'), + 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 lysnraiSessionsList(args, { token: tokenOf(req), requestId: req.id }); + }, +}); + +// ── lysnrai.orgs.list ───────────────────────────────────────────────────── + +registerTool({ + name: 'lysnrai.orgs.list', + description: 'List organisations for the authenticated user. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + 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 lysnraiOrgsList(args, { token: tokenOf(req), requestId: req.id }); + }, +}); + +// ── lysnrai.apiTokens.list ──────────────────────────────────────────────── + +registerTool({ + name: 'lysnrai.apiTokens.list', + description: 'List API tokens for the authenticated user. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + 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 lysnraiApiTokensList(args, { token: tokenOf(req), requestId: req.id }); + }, +}); + +// ── lysnrai.apiTokens.rotate ────────────────────────────────────────────── + +registerTool({ + name: 'lysnrai.apiTokens.rotate', + description: + 'Rotate an API token — invalidates the current secret and issues a new one. Returns the new plaintext token (only visible once). Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + tokenId: z.string().min(1).describe('API token ID to rotate'), + }), + async execute(args, req) { + return lysnraiApiTokenRotate(args.tokenId, { token: tokenOf(req), requestId: req.id }); + }, +}); diff --git a/services/mcp-server/src/modules/mindlyst/mindlyst-tools.ts b/services/mcp-server/src/modules/mindlyst/mindlyst-tools.ts new file mode 100644 index 00000000..9e26537a --- /dev/null +++ b/services/mcp-server/src/modules/mindlyst/mindlyst-tools.ts @@ -0,0 +1,201 @@ +/** + * MindLyst MCP tools — mindlyst.memory.*, mindlyst.brains.*, mindlyst.briefs.*, + * mindlyst.streaks.*, mindlyst.reflections.*, mindlyst.extractions.* + * + * Backed by: mindlyst-backend (port 4014) + extraction-service (port 4005). + * All tools require admin role. + */ + +import { z } from 'zod'; +import { registerTool } from '../tools/registry.js'; +import { config } from '../../lib/config.js'; +import { + mindlystMemoryList, + mindlystMemoryRetriage, + mindlystMemoryPatch, + mindlystMemoryReassign, + mindlystBrainsList, + mindlystBriefsGetToday, + mindlystBriefsList, + mindlystStreaksGet, + mindlystReflectionsList, +} from '../../lib/mindlyst-client.js'; +import { extractionRun } from '../../lib/extraction-client.js'; +import type { McpToolRequest } from '../tools/types.js'; + +const tokenOf = (req: McpToolRequest) => req.headers.authorization?.slice(7); + +// ── mindlyst.memory.list ────────────────────────────────────────────────── + +registerTool({ + name: 'mindlyst.memory.list', + description: + 'List MindLyst memory items for the authenticated user. Filter by brain, status, or use free-text. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + brainId: z.string().optional().describe('Filter to a specific Brain ID (e.g. "work", "home")'), + filter: z + .enum(['forgotten', 'completed_today']) + .optional() + .describe('forgotten = actedOn=false, completed_today = actedOn=true today'), + 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 mindlystMemoryList(args, { token: tokenOf(req), requestId: req.id }); + }, +}); + +// ── mindlyst.memory.retriage ────────────────────────────────────────────── + +registerTool({ + name: 'mindlyst.memory.retriage', + description: + 'Re-run extraction on a single memory item to refresh its triage result (urgencyScore, suggestedBrainId, entities). Best-effort: returns current item if extraction-service is unavailable. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + itemId: z.string().min(1).describe('Memory item ID'), + }), + async execute(args, req) { + return mindlystMemoryRetriage(args.itemId, { token: tokenOf(req), requestId: req.id }); + }, +}); + +// ── mindlyst.memory.patch ───────────────────────────────────────────────── + +registerTool({ + name: 'mindlyst.memory.patch', + description: + 'Update a memory item status: mark_done, mark_undone, increment_nudge, or set_reminder. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + itemId: z.string().min(1).describe('Memory item ID'), + action: z.enum(['mark_done', 'mark_undone', 'increment_nudge', 'set_reminder']), + reminderAt: z + .string() + .datetime() + .optional() + .describe('ISO 8601 datetime — required when action is set_reminder'), + }), + async execute(args, req) { + return mindlystMemoryPatch(args.itemId, args.action, args.reminderAt, { + token: tokenOf(req), + requestId: req.id, + }); + }, +}); + +// ── mindlyst.memory.reassign ────────────────────────────────────────────── + +registerTool({ + name: 'mindlyst.memory.reassign', + description: + 'Reassign a memory item to a different Brain. Records a userCorrection for training. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + itemId: z.string().min(1).describe('Memory item ID'), + newBrainId: z.string().min(1).describe('Target Brain ID'), + }), + async execute(args, req) { + return mindlystMemoryReassign(args.itemId, args.newBrainId, { + token: tokenOf(req), + requestId: req.id, + }); + }, +}); + +// ── mindlyst.brains.list ────────────────────────────────────────────────── + +registerTool({ + name: 'mindlyst.brains.list', + description: + 'List all Brains for the authenticated user (role-based life OS categories). Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + 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 mindlystBrainsList(args, { token: tokenOf(req), requestId: req.id }); + }, +}); + +// ── mindlyst.briefs.getToday ────────────────────────────────────────────── + +registerTool({ + name: 'mindlyst.briefs.getToday', + description: + "Get today's Daily Brief for the authenticated user. Returns 404 error if none generated yet. Requires admin role.", + requiredRole: 'admin', + inputSchema: z.object({}), + async execute(_args, req) { + return mindlystBriefsGetToday({ token: tokenOf(req), requestId: req.id }); + }, +}); + +// ── mindlyst.briefs.list ────────────────────────────────────────────────── + +registerTool({ + name: 'mindlyst.briefs.list', + description: 'List past Daily Briefs for the authenticated user. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + 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 mindlystBriefsList(args, { token: tokenOf(req), requestId: req.id }); + }, +}); + +// ── mindlyst.streaks.get ────────────────────────────────────────────────── + +registerTool({ + name: 'mindlyst.streaks.get', + description: + 'Get the current engagement streak for the authenticated user (currentStreak, longestStreak, lastActiveDate). Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({}), + async execute(_args, req) { + return mindlystStreaksGet({ token: tokenOf(req), requestId: req.id }); + }, +}); + +// ── mindlyst.reflections.list ───────────────────────────────────────────── + +registerTool({ + name: 'mindlyst.reflections.list', + description: 'List reflection journal entries for the authenticated user. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + 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 mindlystReflectionsList(args, { token: tokenOf(req), requestId: req.id }); + }, +}); + +// ── mindlyst.extractions.run ────────────────────────────────────────────── + +registerTool({ + name: 'mindlyst.extractions.run', + description: [ + 'Run text through the MindLyst extraction pipeline with one of the three task IDs:', + ' • triage — extract brain signals, entities, actions (used for new memory items)', + ' • memory-insight — summarise a set of memory items into brain-level insights', + ' • reflection-enrichment — extract themes and highlights from journal text', + 'Requires admin role.', + ].join('\n'), + requiredRole: 'admin', + inputSchema: z.object({ + text: z.string().min(1).describe('Text to extract from'), + taskId: z + .enum(['triage', 'memory-insight', 'reflection-enrichment']) + .default('triage') + .describe('MindLyst-specific extraction task ID'), + }), + async execute(args, req) { + return extractionRun({ text: args.text, taskId: args.taskId }, { requestId: req.id }); + }, +}); diff --git a/services/mcp-server/src/server.ts b/services/mcp-server/src/server.ts index 8a86d0b8..c37bb7e2 100644 --- a/services/mcp-server/src/server.ts +++ b/services/mcp-server/src/server.ts @@ -6,6 +6,9 @@ * platform.diagnostics.* — manage debug sessions, read logs/traces, cancel * extraction.* — run extraction, list models, cache stats, sidecar health * support.* — compound tools (createDebugPack, runIncidentPipeline) + * mindlyst.* — memory, brains, briefs, streaks, reflections, extractions + * lysnrai.* — transcripts, sessions, orgs, apiTokens, stt + * jarvis.* — agents, sessions, memory (JarvisJr coaching platform) * * Auth: JWT Bearer tokens issued by platform-service (same JWT_SECRET). * Role gating: viewer / admin / super_admin per tool. @@ -23,12 +26,15 @@ import './modules/platform/diagnostics-tools.js'; import './modules/extraction/extraction-tools.js'; import './modules/support/debug-pack.js'; import './modules/a2a/pipeline-tool.js'; +import './modules/mindlyst/mindlyst-tools.js'; +import './modules/lysnrai/lysnrai-tools.js'; +import './modules/jarvis/jarvis-tools.js'; const app = await createServiceApp({ name: 'mcp-server', version: '0.1.0', description: - 'ByteLyst MCP Server — platform.telemetry.*, platform.diagnostics.*, extraction.*, support.*', + 'ByteLyst MCP Server — platform.*, extraction.*, support.*, mindlyst.*, lysnrai.*, jarvis.*', corsOrigin: config.CORS_ORIGIN, logLevel: config.LOG_LEVEL, });