feat(mcp-server): add notes tool integration
This commit is contained in:
parent
925e9b6b0f
commit
ec3dd4bd66
@ -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 */
|
||||
|
||||
144
services/mcp-server/src/lib/notes-client.ts
Normal file
144
services/mcp-server/src/lib/notes-client.ts
Normal file
@ -0,0 +1,144 @@
|
||||
import { config } from './config.js';
|
||||
|
||||
export interface NotesClientOptions {
|
||||
token?: string;
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
async function notesFetch<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
opts: NotesClientOptions
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
'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<string, string>) ?? {}), ...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<T>;
|
||||
}
|
||||
|
||||
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<NoteListResponse> {
|
||||
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<NoteListResponse & { query: string | null }> {
|
||||
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<NoteDoc> {
|
||||
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<NoteDoc> {
|
||||
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);
|
||||
}
|
||||
182
services/mcp-server/src/modules/notes/notes-tools.ts
Normal file
182
services/mcp-server/src/modules/notes/notes-tools.ts
Normal file
@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -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,
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user