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'),
|
CHRONOMIND_BACKEND_URL: z.string().default('http://localhost:4011'),
|
||||||
NOMGAP_BACKEND_URL: z.string().default('http://localhost:4013'),
|
NOMGAP_BACKEND_URL: z.string().default('http://localhost:4013'),
|
||||||
PEAKPULSE_BACKEND_URL: z.string().default('http://localhost:4010'),
|
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) */
|
/** Max items returned per tool call query (hard cap) */
|
||||||
QUERY_MAX_LIMIT: z.coerce.number().default(100),
|
QUERY_MAX_LIMIT: z.coerce.number().default(100),
|
||||||
/** Default items per tool call query */
|
/** 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
|
* chronomind.* — timers, routines, syncStatus
|
||||||
* nomgap.* — fasting sessions, push triggers
|
* nomgap.* — fasting sessions, push triggers
|
||||||
* peakpulse.* — adventure sessions, GPS routes, stats
|
* peakpulse.* — adventure sessions, GPS routes, stats
|
||||||
|
* notes.* — notes, search, draft creation
|
||||||
* tracker.* — items, votes, comments, public roadmap
|
* tracker.* — items, votes, comments, public roadmap
|
||||||
* flags.* — feature flag CRUD + kill switch
|
* flags.* — feature flag CRUD + kill switch
|
||||||
* jobs.* — background job list, trigger, run history
|
* 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/chronomind/chronomind-tools.js';
|
||||||
import './modules/nomgap/nomgap-tools.js';
|
import './modules/nomgap/nomgap-tools.js';
|
||||||
import './modules/peakpulse/peakpulse-tools.js';
|
import './modules/peakpulse/peakpulse-tools.js';
|
||||||
|
import './modules/notes/notes-tools.js';
|
||||||
import './modules/tracker/tracker-tools.js';
|
import './modules/tracker/tracker-tools.js';
|
||||||
import './modules/platform/ops-tools.js';
|
import './modules/platform/ops-tools.js';
|
||||||
import './modules/platform/webhooks-tools.js';
|
import './modules/platform/webhooks-tools.js';
|
||||||
@ -80,7 +82,7 @@ const app = await createServiceApp({
|
|||||||
name: 'mcp-server',
|
name: 'mcp-server',
|
||||||
version: '0.1.0',
|
version: '0.1.0',
|
||||||
description:
|
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,
|
corsOrigin: config.CORS_ORIGIN,
|
||||||
logLevel: config.LOG_LEVEL,
|
logLevel: config.LOG_LEVEL,
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user