feat: gated project experience & prompt intelligence
Implements the full gated session flow and prompt intelligence system: - Prisma schema: add gated, priority, summary, chapters, linkTarget fields - Session gate: state machine (gated → begin_session → ungated) with LLM-powered tool selection based on prompt index - Tag matcher: intelligent prompt-to-tool matching with project/server/action tags - LLM selector: tiered provider selection (fast for gating, heavy for complex tasks) - Link resolver: cross-project MCP resource references (project/server:uri format) - Prompt summary service: LLM-generated summaries and chapter extraction - System project bootstrap: ensures default project exists on startup - Structural link health checks: enrichWithLinkStatus on prompt GET endpoints - CLI: create prompt --priority/--link, create project --gated/--no-gated, describe project shows prompts section, get prompts shows PRI/LINK/STATUS - Apply/edit: priority, linkTarget, gated fields supported - Shell completions: fish updated with new flags - 1,253 tests passing across all packages Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
102
src/mcpd/src/bootstrap/system-project.ts
Normal file
102
src/mcpd/src/bootstrap/system-project.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Bootstrap the mcpctl-system project and its system prompts.
|
||||
*
|
||||
* This runs on every mcpd startup and uses upserts to be idempotent.
|
||||
* System prompts are editable by users but will be re-created if deleted.
|
||||
*/
|
||||
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
|
||||
/** Well-known owner ID for system-managed resources. */
|
||||
export const SYSTEM_OWNER_ID = 'system';
|
||||
|
||||
/** Well-known project name for system prompts. */
|
||||
export const SYSTEM_PROJECT_NAME = 'mcpctl-system';
|
||||
|
||||
interface SystemPromptDef {
|
||||
name: string;
|
||||
priority: number;
|
||||
content: string;
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPTS: SystemPromptDef[] = [
|
||||
{
|
||||
name: 'gate-instructions',
|
||||
priority: 10,
|
||||
content: `This project uses a gated session. Before you can access tools, you must describe your current task by calling begin_session with 3-7 keywords.
|
||||
|
||||
After calling begin_session, you will receive:
|
||||
1. Relevant project prompts matched to your keywords
|
||||
2. A list of other available prompts
|
||||
3. Full access to all project tools
|
||||
|
||||
Choose your keywords carefully — they determine which context you receive.`,
|
||||
},
|
||||
{
|
||||
name: 'gate-encouragement',
|
||||
priority: 10,
|
||||
content: `If any of the listed prompts seem relevant to your work, or if you encounter unfamiliar patterns, conventions, or constraints during implementation, use read_prompts({ tags: [...] }) to retrieve them.
|
||||
|
||||
It is better to check and not need it than to proceed without important context. The project maintainers have documented common pitfalls, architecture decisions, and required patterns — taking 10 seconds to retrieve a prompt can save hours of rework.`,
|
||||
},
|
||||
{
|
||||
name: 'gate-intercept-preamble',
|
||||
priority: 10,
|
||||
content: `The following project context was automatically retrieved based on your tool call. You bypassed the begin_session step, so this context was matched using keywords extracted from your tool invocation.
|
||||
|
||||
Review this context carefully — it may contain important guidelines, constraints, or patterns relevant to your work. If you need more context, use read_prompts({ tags: [...] }) at any time.`,
|
||||
},
|
||||
{
|
||||
name: 'session-greeting',
|
||||
priority: 10,
|
||||
content: `Welcome to this project. To get started, call begin_session with keywords describing your task.
|
||||
|
||||
Example: begin_session({ tags: ["zigbee", "pairing", "mqtt"] })
|
||||
|
||||
This will load relevant project context, policies, and guidelines tailored to your work.`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Ensure the mcpctl-system project and its system prompts exist.
|
||||
* Uses upserts so this is safe to call on every startup.
|
||||
*/
|
||||
export async function bootstrapSystemProject(prisma: PrismaClient): Promise<void> {
|
||||
// Upsert the system project
|
||||
const project = await prisma.project.upsert({
|
||||
where: { name: SYSTEM_PROJECT_NAME },
|
||||
create: {
|
||||
name: SYSTEM_PROJECT_NAME,
|
||||
description: 'System prompts for mcpctl gating and session management',
|
||||
prompt: '',
|
||||
proxyMode: 'direct',
|
||||
gated: false,
|
||||
ownerId: SYSTEM_OWNER_ID,
|
||||
},
|
||||
update: {}, // Don't overwrite user edits to the project itself
|
||||
});
|
||||
|
||||
// Upsert each system prompt (re-create if deleted, don't overwrite content if edited)
|
||||
for (const def of SYSTEM_PROMPTS) {
|
||||
const existing = await prisma.prompt.findFirst({
|
||||
where: { name: def.name, projectId: project.id },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
await prisma.prompt.create({
|
||||
data: {
|
||||
name: def.name,
|
||||
content: def.content,
|
||||
priority: def.priority,
|
||||
projectId: project.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
// If the prompt exists, don't overwrite — user may have edited it
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the names of all system prompts (for delete protection). */
|
||||
export function getSystemPromptNames(): string[] {
|
||||
return SYSTEM_PROMPTS.map((p) => p.name);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from './repositories/index.js';
|
||||
import { PromptRepository } from './repositories/prompt.repository.js';
|
||||
import { PromptRequestRepository } from './repositories/prompt-request.repository.js';
|
||||
import { bootstrapSystemProject } from './bootstrap/system-project.js';
|
||||
import {
|
||||
McpServerService,
|
||||
SecretService,
|
||||
@@ -235,6 +236,9 @@ async function main(): Promise<void> {
|
||||
});
|
||||
await seedTemplates(prisma, templates);
|
||||
|
||||
// Bootstrap system project and prompts
|
||||
await bootstrapSystemProject(prisma);
|
||||
|
||||
// Repositories
|
||||
const serverRepo = new McpServerRepository(prisma);
|
||||
const secretRepo = new SecretRepository(prisma);
|
||||
|
||||
@@ -2,10 +2,12 @@ import type { PrismaClient, PromptRequest } from '@prisma/client';
|
||||
|
||||
export interface IPromptRequestRepository {
|
||||
findAll(projectId?: string): Promise<PromptRequest[]>;
|
||||
findGlobal(): Promise<PromptRequest[]>;
|
||||
findById(id: string): Promise<PromptRequest | null>;
|
||||
findByNameAndProject(name: string, projectId: string | null): Promise<PromptRequest | null>;
|
||||
findBySession(sessionId: string, projectId?: string): Promise<PromptRequest[]>;
|
||||
create(data: { name: string; content: string; projectId?: string; createdBySession?: string; createdByUserId?: string }): Promise<PromptRequest>;
|
||||
create(data: { name: string; content: string; projectId?: string; priority?: number; createdBySession?: string; createdByUserId?: string }): Promise<PromptRequest>;
|
||||
update(id: string, data: { content?: string; priority?: number }): Promise<PromptRequest>;
|
||||
delete(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -13,13 +15,23 @@ export class PromptRequestRepository implements IPromptRequestRepository {
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
|
||||
async findAll(projectId?: string): Promise<PromptRequest[]> {
|
||||
const include = { project: { select: { name: true } } };
|
||||
if (projectId !== undefined) {
|
||||
return this.prisma.promptRequest.findMany({
|
||||
where: { OR: [{ projectId }, { projectId: null }] },
|
||||
include,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
return this.prisma.promptRequest.findMany({ orderBy: { createdAt: 'desc' } });
|
||||
return this.prisma.promptRequest.findMany({ include, orderBy: { createdAt: 'desc' } });
|
||||
}
|
||||
|
||||
async findGlobal(): Promise<PromptRequest[]> {
|
||||
return this.prisma.promptRequest.findMany({
|
||||
where: { projectId: null },
|
||||
include: { project: { select: { name: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<PromptRequest | null> {
|
||||
@@ -43,10 +55,14 @@ export class PromptRequestRepository implements IPromptRequestRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: { name: string; content: string; projectId?: string; createdBySession?: string; createdByUserId?: string }): Promise<PromptRequest> {
|
||||
async create(data: { name: string; content: string; projectId?: string; priority?: number; createdBySession?: string; createdByUserId?: string }): Promise<PromptRequest> {
|
||||
return this.prisma.promptRequest.create({ data });
|
||||
}
|
||||
|
||||
async update(id: string, data: { content?: string; priority?: number }): Promise<PromptRequest> {
|
||||
return this.prisma.promptRequest.update({ where: { id }, data });
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.prisma.promptRequest.delete({ where: { id } });
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@ import type { PrismaClient, Prompt } from '@prisma/client';
|
||||
|
||||
export interface IPromptRepository {
|
||||
findAll(projectId?: string): Promise<Prompt[]>;
|
||||
findGlobal(): Promise<Prompt[]>;
|
||||
findById(id: string): Promise<Prompt | null>;
|
||||
findByNameAndProject(name: string, projectId: string | null): Promise<Prompt | null>;
|
||||
create(data: { name: string; content: string; projectId?: string }): Promise<Prompt>;
|
||||
update(id: string, data: { content?: string }): Promise<Prompt>;
|
||||
create(data: { name: string; content: string; projectId?: string; priority?: number; linkTarget?: string }): Promise<Prompt>;
|
||||
update(id: string, data: { content?: string; priority?: number; summary?: string; chapters?: string[] }): Promise<Prompt>;
|
||||
delete(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -13,14 +14,24 @@ export class PromptRepository implements IPromptRepository {
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
|
||||
async findAll(projectId?: string): Promise<Prompt[]> {
|
||||
const include = { project: { select: { name: true } } };
|
||||
if (projectId !== undefined) {
|
||||
// Project-scoped + global prompts
|
||||
return this.prisma.prompt.findMany({
|
||||
where: { OR: [{ projectId }, { projectId: null }] },
|
||||
include,
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
}
|
||||
return this.prisma.prompt.findMany({ orderBy: { name: 'asc' } });
|
||||
return this.prisma.prompt.findMany({ include, orderBy: { name: 'asc' } });
|
||||
}
|
||||
|
||||
async findGlobal(): Promise<Prompt[]> {
|
||||
return this.prisma.prompt.findMany({
|
||||
where: { projectId: null },
|
||||
include: { project: { select: { name: true } } },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Prompt | null> {
|
||||
@@ -33,11 +44,11 @@ export class PromptRepository implements IPromptRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: { name: string; content: string; projectId?: string }): Promise<Prompt> {
|
||||
async create(data: { name: string; content: string; projectId?: string; priority?: number; linkTarget?: string }): Promise<Prompt> {
|
||||
return this.prisma.prompt.create({ data });
|
||||
}
|
||||
|
||||
async update(id: string, data: { content?: string }): Promise<Prompt> {
|
||||
async update(id: string, data: { content?: string; priority?: number; summary?: string; chapters?: string[] }): Promise<Prompt> {
|
||||
return this.prisma.prompt.update({ where: { id }, data });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,56 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Prompt } from '@prisma/client';
|
||||
import type { PromptService } from '../services/prompt.service.js';
|
||||
import type { IProjectRepository } from '../repositories/project.repository.js';
|
||||
import type { IProjectRepository, ProjectWithRelations } from '../repositories/project.repository.js';
|
||||
|
||||
type PromptWithLinkStatus = Prompt & { linkStatus: 'alive' | 'dead' | null };
|
||||
|
||||
/**
|
||||
* Enrich prompts with linkStatus by checking if the target project/server exists.
|
||||
* This is a structural check (does the target exist?) — not a runtime probe.
|
||||
*/
|
||||
async function enrichWithLinkStatus(
|
||||
prompts: Prompt[],
|
||||
projectRepo: IProjectRepository,
|
||||
): Promise<PromptWithLinkStatus[]> {
|
||||
// Cache project lookups to avoid repeated DB queries
|
||||
const projectCache = new Map<string, ProjectWithRelations | null>();
|
||||
|
||||
const results: PromptWithLinkStatus[] = [];
|
||||
|
||||
for (const p of prompts) {
|
||||
if (!p.linkTarget) {
|
||||
results.push({ ...p, linkStatus: null } as PromptWithLinkStatus);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse: project/server:uri
|
||||
const slashIdx = p.linkTarget.indexOf('/');
|
||||
if (slashIdx < 1) { results.push({ ...p, linkStatus: 'dead' as const }); continue; }
|
||||
const projectName = p.linkTarget.slice(0, slashIdx);
|
||||
const rest = p.linkTarget.slice(slashIdx + 1);
|
||||
const colonIdx = rest.indexOf(':');
|
||||
if (colonIdx < 1) { results.push({ ...p, linkStatus: 'dead' as const }); continue; }
|
||||
const serverName = rest.slice(0, colonIdx);
|
||||
|
||||
// Check if project exists (cached)
|
||||
if (!projectCache.has(projectName)) {
|
||||
projectCache.set(projectName, await projectRepo.findByName(projectName));
|
||||
}
|
||||
const project = projectCache.get(projectName);
|
||||
if (!project) { results.push({ ...p, linkStatus: 'dead' as const }); continue; }
|
||||
|
||||
// Check if server is linked to that project
|
||||
const hasServer = project.servers.some((s) => s.server.name === serverName);
|
||||
results.push({ ...p, linkStatus: hasServer ? 'alive' as const : 'dead' as const });
|
||||
} catch {
|
||||
results.push({ ...p, linkStatus: 'dead' as const });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export function registerPromptRoutes(
|
||||
app: FastifyInstance,
|
||||
@@ -9,12 +59,29 @@ export function registerPromptRoutes(
|
||||
): void {
|
||||
// ── Prompts (approved) ──
|
||||
|
||||
app.get('/api/v1/prompts', async () => {
|
||||
return service.listPrompts();
|
||||
app.get<{ Querystring: { project?: string; scope?: string; projectId?: string } }>('/api/v1/prompts', async (request) => {
|
||||
let prompts: Prompt[];
|
||||
const projectName = request.query.project;
|
||||
if (projectName) {
|
||||
const project = await projectRepo.findByName(projectName);
|
||||
if (!project) {
|
||||
throw Object.assign(new Error(`Project not found: ${projectName}`), { statusCode: 404 });
|
||||
}
|
||||
prompts = await service.listPrompts(project.id);
|
||||
} else if (request.query.projectId) {
|
||||
prompts = await service.listPrompts(request.query.projectId);
|
||||
} else if (request.query.scope === 'global') {
|
||||
prompts = await service.listGlobalPrompts();
|
||||
} else {
|
||||
prompts = await service.listPrompts();
|
||||
}
|
||||
return enrichWithLinkStatus(prompts, projectRepo);
|
||||
});
|
||||
|
||||
app.get<{ Params: { id: string } }>('/api/v1/prompts/:id', async (request) => {
|
||||
return service.getPrompt(request.params.id);
|
||||
const prompt = await service.getPrompt(request.params.id);
|
||||
const [enriched] = await enrichWithLinkStatus([prompt], projectRepo);
|
||||
return enriched;
|
||||
});
|
||||
|
||||
app.post('/api/v1/prompts', async (request, reply) => {
|
||||
@@ -34,7 +101,18 @@ export function registerPromptRoutes(
|
||||
|
||||
// ── Prompt Requests (pending proposals) ──
|
||||
|
||||
app.get('/api/v1/promptrequests', async () => {
|
||||
app.get<{ Querystring: { project?: string; scope?: string } }>('/api/v1/promptrequests', async (request) => {
|
||||
const projectName = request.query.project;
|
||||
if (projectName) {
|
||||
const project = await projectRepo.findByName(projectName);
|
||||
if (!project) {
|
||||
throw Object.assign(new Error(`Project not found: ${projectName}`), { statusCode: 404 });
|
||||
}
|
||||
return service.listPromptRequests(project.id);
|
||||
}
|
||||
if (request.query.scope === 'global') {
|
||||
return service.listGlobalPromptRequests();
|
||||
}
|
||||
return service.listPromptRequests();
|
||||
});
|
||||
|
||||
@@ -42,16 +120,59 @@ export function registerPromptRoutes(
|
||||
return service.getPromptRequest(request.params.id);
|
||||
});
|
||||
|
||||
app.put<{ Params: { id: string } }>('/api/v1/promptrequests/:id', async (request) => {
|
||||
return service.updatePromptRequest(request.params.id, request.body);
|
||||
});
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/api/v1/promptrequests/:id', async (request, reply) => {
|
||||
await service.deletePromptRequest(request.params.id);
|
||||
reply.code(204);
|
||||
});
|
||||
|
||||
app.post('/api/v1/promptrequests', async (request, reply) => {
|
||||
const body = request.body as Record<string, unknown>;
|
||||
// Resolve project name → ID if provided
|
||||
if (body.project && typeof body.project === 'string') {
|
||||
const project = await projectRepo.findByName(body.project);
|
||||
if (!project) {
|
||||
throw Object.assign(new Error(`Project not found: ${body.project}`), { statusCode: 404 });
|
||||
}
|
||||
const { project: _, ...rest } = body;
|
||||
const req = await service.propose({ ...rest, projectId: project.id });
|
||||
reply.code(201);
|
||||
return req;
|
||||
}
|
||||
const req = await service.propose(body);
|
||||
reply.code(201);
|
||||
return req;
|
||||
});
|
||||
|
||||
// Approve: atomic delete request → create prompt
|
||||
app.post<{ Params: { id: string } }>('/api/v1/promptrequests/:id/approve', async (request) => {
|
||||
return service.approve(request.params.id);
|
||||
});
|
||||
|
||||
// Regenerate summary/chapters for a prompt
|
||||
app.post<{ Params: { id: string } }>('/api/v1/prompts/:id/regenerate-summary', async (request) => {
|
||||
return service.regenerateSummary(request.params.id);
|
||||
});
|
||||
|
||||
// Compact prompt index for gating LLM (name, priority, summary, chapters)
|
||||
app.get<{ Params: { name: string } }>('/api/v1/projects/:name/prompt-index', async (request) => {
|
||||
const project = await projectRepo.findByName(request.params.name);
|
||||
if (!project) {
|
||||
throw Object.assign(new Error(`Project not found: ${request.params.name}`), { statusCode: 404 });
|
||||
}
|
||||
const prompts = await service.listPrompts(project.id);
|
||||
return prompts.map((p) => ({
|
||||
name: p.name,
|
||||
priority: p.priority,
|
||||
summary: p.summary,
|
||||
chapters: p.chapters,
|
||||
linkTarget: p.linkTarget,
|
||||
}));
|
||||
});
|
||||
|
||||
// ── Project-scoped endpoints (for mcplocal) ──
|
||||
|
||||
// Visible prompts: approved + session's pending requests
|
||||
|
||||
@@ -56,6 +56,7 @@ export class ProjectService {
|
||||
prompt: data.prompt,
|
||||
ownerId,
|
||||
proxyMode: data.proxyMode,
|
||||
gated: data.gated,
|
||||
...(data.llmProvider !== undefined ? { llmProvider: data.llmProvider } : {}),
|
||||
...(data.llmModel !== undefined ? { llmModel: data.llmModel } : {}),
|
||||
});
|
||||
@@ -80,6 +81,7 @@ export class ProjectService {
|
||||
if (data.proxyMode !== undefined) updateData['proxyMode'] = data.proxyMode;
|
||||
if (data.llmProvider !== undefined) updateData['llmProvider'] = data.llmProvider;
|
||||
if (data.llmModel !== undefined) updateData['llmModel'] = data.llmModel;
|
||||
if (data.gated !== undefined) updateData['gated'] = data.gated;
|
||||
|
||||
// Update scalar fields if any changed
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
|
||||
96
src/mcpd/src/services/prompt-summary.service.ts
Normal file
96
src/mcpd/src/services/prompt-summary.service.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Generates summary and chapters for prompt content.
|
||||
*
|
||||
* Uses regex-based extraction by default (first sentence + markdown headings).
|
||||
* An optional LLM generator can be injected for higher-quality summaries.
|
||||
*/
|
||||
|
||||
const MAX_SUMMARY_WORDS = 20;
|
||||
const HEADING_RE = /^#{1,6}\s+(.+)$/gm;
|
||||
|
||||
export interface LlmSummaryGenerator {
|
||||
generate(content: string): Promise<{ summary: string; chapters: string[] }>;
|
||||
}
|
||||
|
||||
export class PromptSummaryService {
|
||||
constructor(private readonly llmGenerator: LlmSummaryGenerator | null = null) {}
|
||||
|
||||
async generateSummary(content: string): Promise<{ summary: string; chapters: string[] }> {
|
||||
if (this.llmGenerator) {
|
||||
try {
|
||||
return await this.llmGenerator.generate(content);
|
||||
} catch {
|
||||
// Fall back to regex on LLM failure
|
||||
}
|
||||
}
|
||||
return this.generateWithRegex(content);
|
||||
}
|
||||
|
||||
generateWithRegex(content: string): { summary: string; chapters: string[] } {
|
||||
return {
|
||||
summary: extractFirstSentence(content, MAX_SUMMARY_WORDS),
|
||||
chapters: extractHeadings(content),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the first sentence, truncated to maxWords.
|
||||
* Strips markdown formatting.
|
||||
*/
|
||||
export function extractFirstSentence(content: string, maxWords: number): string {
|
||||
if (!content.trim()) return '';
|
||||
|
||||
// Skip leading headings and blank lines to find first content line
|
||||
const lines = content.split('\n');
|
||||
let firstContent = '';
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
if (trimmed.startsWith('#')) continue;
|
||||
firstContent = trimmed;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!firstContent) {
|
||||
// All lines are headings or empty — use first heading text
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('#')) {
|
||||
firstContent = trimmed.replace(/^#+\s*/, '');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!firstContent) return '';
|
||||
|
||||
// Strip basic markdown formatting
|
||||
firstContent = firstContent
|
||||
.replace(/\*\*(.+?)\*\*/g, '$1')
|
||||
.replace(/\*(.+?)\*/g, '$1')
|
||||
.replace(/`(.+?)`/g, '$1')
|
||||
.replace(/\[(.+?)\]\(.+?\)/g, '$1');
|
||||
|
||||
// Split on sentence boundaries
|
||||
const sentenceEnd = firstContent.search(/[.!?]\s|[.!?]$/);
|
||||
const sentence = sentenceEnd >= 0 ? firstContent.slice(0, sentenceEnd + 1) : firstContent;
|
||||
|
||||
// Truncate to maxWords
|
||||
const words = sentence.split(/\s+/);
|
||||
if (words.length <= maxWords) return sentence;
|
||||
return words.slice(0, maxWords).join(' ') + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract markdown headings as chapter titles.
|
||||
*/
|
||||
export function extractHeadings(content: string): string[] {
|
||||
const headings: string[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = HEADING_RE.exec(content)) !== null) {
|
||||
const heading = match[1]!.trim();
|
||||
if (heading) headings.push(heading);
|
||||
}
|
||||
return headings;
|
||||
}
|
||||
@@ -2,22 +2,34 @@ import type { Prompt, PromptRequest } from '@prisma/client';
|
||||
import type { IPromptRepository } from '../repositories/prompt.repository.js';
|
||||
import type { IPromptRequestRepository } from '../repositories/prompt-request.repository.js';
|
||||
import type { IProjectRepository } from '../repositories/project.repository.js';
|
||||
import { CreatePromptSchema, UpdatePromptSchema, CreatePromptRequestSchema } from '../validation/prompt.schema.js';
|
||||
import { CreatePromptSchema, UpdatePromptSchema, CreatePromptRequestSchema, UpdatePromptRequestSchema } from '../validation/prompt.schema.js';
|
||||
import { NotFoundError } from './mcp-server.service.js';
|
||||
import type { PromptSummaryService } from './prompt-summary.service.js';
|
||||
import { SYSTEM_PROJECT_NAME } from '../bootstrap/system-project.js';
|
||||
|
||||
export class PromptService {
|
||||
private summaryService: PromptSummaryService | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly promptRepo: IPromptRepository,
|
||||
private readonly promptRequestRepo: IPromptRequestRepository,
|
||||
private readonly projectRepo: IProjectRepository,
|
||||
) {}
|
||||
|
||||
setSummaryService(service: PromptSummaryService): void {
|
||||
this.summaryService = service;
|
||||
}
|
||||
|
||||
// ── Prompt CRUD ──
|
||||
|
||||
async listPrompts(projectId?: string): Promise<Prompt[]> {
|
||||
return this.promptRepo.findAll(projectId);
|
||||
}
|
||||
|
||||
async listGlobalPrompts(): Promise<Prompt[]> {
|
||||
return this.promptRepo.findGlobal();
|
||||
}
|
||||
|
||||
async getPrompt(id: string): Promise<Prompt> {
|
||||
const prompt = await this.promptRepo.findById(id);
|
||||
if (prompt === null) throw new NotFoundError(`Prompt not found: ${id}`);
|
||||
@@ -32,24 +44,58 @@ export class PromptService {
|
||||
if (project === null) throw new NotFoundError(`Project not found: ${data.projectId}`);
|
||||
}
|
||||
|
||||
const createData: { name: string; content: string; projectId?: string } = {
|
||||
const createData: { name: string; content: string; projectId?: string; priority?: number; linkTarget?: string } = {
|
||||
name: data.name,
|
||||
content: data.content,
|
||||
};
|
||||
if (data.projectId !== undefined) createData.projectId = data.projectId;
|
||||
return this.promptRepo.create(createData);
|
||||
if (data.priority !== undefined) createData.priority = data.priority;
|
||||
if (data.linkTarget !== undefined) createData.linkTarget = data.linkTarget;
|
||||
const prompt = await this.promptRepo.create(createData);
|
||||
// Auto-generate summary/chapters (non-blocking — don't fail create if summary fails)
|
||||
if (this.summaryService && !data.linkTarget) {
|
||||
this.generateAndStoreSummary(prompt.id, data.content).catch(() => {});
|
||||
}
|
||||
return prompt;
|
||||
}
|
||||
|
||||
async updatePrompt(id: string, input: unknown): Promise<Prompt> {
|
||||
const data = UpdatePromptSchema.parse(input);
|
||||
await this.getPrompt(id);
|
||||
const updateData: { content?: string } = {};
|
||||
const updateData: { content?: string; priority?: number } = {};
|
||||
if (data.content !== undefined) updateData.content = data.content;
|
||||
return this.promptRepo.update(id, updateData);
|
||||
if (data.priority !== undefined) updateData.priority = data.priority;
|
||||
const prompt = await this.promptRepo.update(id, updateData);
|
||||
// Regenerate summary when content changes
|
||||
if (this.summaryService && data.content !== undefined && !prompt.linkTarget) {
|
||||
this.generateAndStoreSummary(prompt.id, data.content).catch(() => {});
|
||||
}
|
||||
return prompt;
|
||||
}
|
||||
|
||||
async regenerateSummary(id: string): Promise<Prompt> {
|
||||
const prompt = await this.getPrompt(id);
|
||||
if (!this.summaryService) {
|
||||
throw new Error('Summary generation not available');
|
||||
}
|
||||
return this.generateAndStoreSummary(prompt.id, prompt.content);
|
||||
}
|
||||
|
||||
private async generateAndStoreSummary(id: string, content: string): Promise<Prompt> {
|
||||
if (!this.summaryService) throw new Error('No summary service');
|
||||
const { summary, chapters } = await this.summaryService.generateSummary(content);
|
||||
return this.promptRepo.update(id, { summary, chapters });
|
||||
}
|
||||
|
||||
async deletePrompt(id: string): Promise<void> {
|
||||
await this.getPrompt(id);
|
||||
const prompt = await this.getPrompt(id);
|
||||
// Protect system prompts from deletion
|
||||
if (prompt.projectId) {
|
||||
const project = await this.projectRepo.findById(prompt.projectId);
|
||||
if (project?.name === SYSTEM_PROJECT_NAME) {
|
||||
throw Object.assign(new Error('Cannot delete system prompts'), { statusCode: 403 });
|
||||
}
|
||||
}
|
||||
await this.promptRepo.delete(id);
|
||||
}
|
||||
|
||||
@@ -59,12 +105,25 @@ export class PromptService {
|
||||
return this.promptRequestRepo.findAll(projectId);
|
||||
}
|
||||
|
||||
async listGlobalPromptRequests(): Promise<PromptRequest[]> {
|
||||
return this.promptRequestRepo.findGlobal();
|
||||
}
|
||||
|
||||
async getPromptRequest(id: string): Promise<PromptRequest> {
|
||||
const req = await this.promptRequestRepo.findById(id);
|
||||
if (req === null) throw new NotFoundError(`PromptRequest not found: ${id}`);
|
||||
return req;
|
||||
}
|
||||
|
||||
async updatePromptRequest(id: string, input: unknown): Promise<PromptRequest> {
|
||||
await this.getPromptRequest(id);
|
||||
const data = UpdatePromptRequestSchema.parse(input);
|
||||
const updateData: { content?: string; priority?: number } = {};
|
||||
if (data.content !== undefined) updateData.content = data.content;
|
||||
if (data.priority !== undefined) updateData.priority = data.priority;
|
||||
return this.promptRequestRepo.update(id, updateData);
|
||||
}
|
||||
|
||||
async deletePromptRequest(id: string): Promise<void> {
|
||||
await this.getPromptRequest(id);
|
||||
await this.promptRequestRepo.delete(id);
|
||||
@@ -80,11 +139,12 @@ export class PromptService {
|
||||
if (project === null) throw new NotFoundError(`Project not found: ${data.projectId}`);
|
||||
}
|
||||
|
||||
const createData: { name: string; content: string; projectId?: string; createdBySession?: string; createdByUserId?: string } = {
|
||||
const createData: { name: string; content: string; projectId?: string; priority?: number; createdBySession?: string; createdByUserId?: string } = {
|
||||
name: data.name,
|
||||
content: data.content,
|
||||
};
|
||||
if (data.projectId !== undefined) createData.projectId = data.projectId;
|
||||
if (data.priority !== undefined) createData.priority = data.priority;
|
||||
if (data.createdBySession !== undefined) createData.createdBySession = data.createdBySession;
|
||||
if (data.createdByUserId !== undefined) createData.createdByUserId = data.createdByUserId;
|
||||
return this.promptRequestRepo.create(createData);
|
||||
@@ -95,12 +155,13 @@ export class PromptService {
|
||||
async approve(requestId: string): Promise<Prompt> {
|
||||
const req = await this.getPromptRequest(requestId);
|
||||
|
||||
// Create the approved prompt
|
||||
const createData: { name: string; content: string; projectId?: string } = {
|
||||
// Create the approved prompt (carry priority from request)
|
||||
const createData: { name: string; content: string; projectId?: string; priority?: number } = {
|
||||
name: req.name,
|
||||
content: req.content,
|
||||
};
|
||||
if (req.projectId !== null) createData.projectId = req.projectId;
|
||||
if (req.priority !== 5) createData.priority = req.priority;
|
||||
|
||||
const prompt = await this.promptRepo.create(createData);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ export const CreateProjectSchema = z.object({
|
||||
description: z.string().max(1000).default(''),
|
||||
prompt: z.string().max(10000).default(''),
|
||||
proxyMode: z.enum(['direct', 'filtered']).default('direct'),
|
||||
gated: z.boolean().default(true),
|
||||
llmProvider: z.string().max(100).optional(),
|
||||
llmModel: z.string().max(100).optional(),
|
||||
servers: z.array(z.string().min(1)).default([]),
|
||||
@@ -17,6 +18,7 @@ export const UpdateProjectSchema = z.object({
|
||||
description: z.string().max(1000).optional(),
|
||||
prompt: z.string().max(10000).optional(),
|
||||
proxyMode: z.enum(['direct', 'filtered']).optional(),
|
||||
gated: z.boolean().optional(),
|
||||
llmProvider: z.string().max(100).nullable().optional(),
|
||||
llmModel: z.string().max(100).nullable().optional(),
|
||||
servers: z.array(z.string().min(1)).optional(),
|
||||
|
||||
@@ -1,23 +1,36 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const LINK_TARGET_RE = /^[a-z0-9-]+\/[a-z0-9-]+:\S+$/;
|
||||
|
||||
export const CreatePromptSchema = z.object({
|
||||
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'),
|
||||
content: z.string().min(1).max(50000),
|
||||
projectId: z.string().optional(),
|
||||
priority: z.number().int().min(1).max(10).default(5).optional(),
|
||||
linkTarget: z.string().regex(LINK_TARGET_RE, 'Link target must be project/server:resource-uri').optional(),
|
||||
});
|
||||
|
||||
export const UpdatePromptSchema = z.object({
|
||||
content: z.string().min(1).max(50000).optional(),
|
||||
priority: z.number().int().min(1).max(10).optional(),
|
||||
// linkTarget intentionally excluded — links are immutable
|
||||
});
|
||||
|
||||
export const CreatePromptRequestSchema = z.object({
|
||||
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'),
|
||||
content: z.string().min(1).max(50000),
|
||||
projectId: z.string().optional(),
|
||||
priority: z.number().int().min(1).max(10).default(5).optional(),
|
||||
createdBySession: z.string().optional(),
|
||||
createdByUserId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const UpdatePromptRequestSchema = z.object({
|
||||
content: z.string().min(1).max(50000).optional(),
|
||||
priority: z.number().int().min(1).max(10).optional(),
|
||||
});
|
||||
|
||||
export type CreatePromptInput = z.infer<typeof CreatePromptSchema>;
|
||||
export type UpdatePromptInput = z.infer<typeof UpdatePromptSchema>;
|
||||
export type CreatePromptRequestInput = z.infer<typeof CreatePromptRequestSchema>;
|
||||
export type UpdatePromptRequestInput = z.infer<typeof UpdatePromptRequestSchema>;
|
||||
|
||||
124
src/mcpd/tests/bootstrap-system-project.test.ts
Normal file
124
src/mcpd/tests/bootstrap-system-project.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { bootstrapSystemProject, SYSTEM_PROJECT_NAME, SYSTEM_OWNER_ID, getSystemPromptNames } from '../src/bootstrap/system-project.js';
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
|
||||
function mockPrisma(): PrismaClient {
|
||||
const prompts = new Map<string, { id: string; name: string; projectId: string }>();
|
||||
let promptIdCounter = 1;
|
||||
|
||||
return {
|
||||
project: {
|
||||
upsert: vi.fn(async (args: { where: { name: string }; create: Record<string, unknown>; update: Record<string, unknown> }) => ({
|
||||
id: 'sys-proj-id',
|
||||
name: args.where.name,
|
||||
...args.create,
|
||||
})),
|
||||
},
|
||||
prompt: {
|
||||
findFirst: vi.fn(async (args: { where: { name: string; projectId: string } }) => {
|
||||
return prompts.get(`${args.where.projectId}:${args.where.name}`) ?? null;
|
||||
}),
|
||||
create: vi.fn(async (args: { data: { name: string; content: string; priority: number; projectId: string } }) => {
|
||||
const id = `prompt-${promptIdCounter++}`;
|
||||
const prompt = { id, ...args.data };
|
||||
prompts.set(`${args.data.projectId}:${args.data.name}`, prompt);
|
||||
return prompt;
|
||||
}),
|
||||
},
|
||||
} as unknown as PrismaClient;
|
||||
}
|
||||
|
||||
describe('bootstrapSystemProject', () => {
|
||||
let prisma: PrismaClient;
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = mockPrisma();
|
||||
});
|
||||
|
||||
it('creates the mcpctl-system project via upsert', async () => {
|
||||
await bootstrapSystemProject(prisma);
|
||||
|
||||
expect(prisma.project.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { name: SYSTEM_PROJECT_NAME },
|
||||
create: expect.objectContaining({
|
||||
name: SYSTEM_PROJECT_NAME,
|
||||
ownerId: SYSTEM_OWNER_ID,
|
||||
gated: false,
|
||||
}),
|
||||
update: {},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('creates all system prompts', async () => {
|
||||
await bootstrapSystemProject(prisma);
|
||||
|
||||
const expectedNames = getSystemPromptNames();
|
||||
expect(expectedNames.length).toBeGreaterThanOrEqual(4);
|
||||
|
||||
for (const name of expectedNames) {
|
||||
expect(prisma.prompt.findFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { name, projectId: 'sys-proj-id' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
expect(prisma.prompt.create).toHaveBeenCalledTimes(expectedNames.length);
|
||||
});
|
||||
|
||||
it('creates system prompts with priority 10', async () => {
|
||||
await bootstrapSystemProject(prisma);
|
||||
|
||||
const createCalls = vi.mocked(prisma.prompt.create).mock.calls;
|
||||
for (const call of createCalls) {
|
||||
const data = (call[0] as { data: { priority: number } }).data;
|
||||
expect(data.priority).toBe(10);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not re-create existing prompts (idempotent)', async () => {
|
||||
// First call creates everything
|
||||
await bootstrapSystemProject(prisma);
|
||||
const firstCallCount = vi.mocked(prisma.prompt.create).mock.calls.length;
|
||||
|
||||
// Second call — prompts already exist in mock, should not create again
|
||||
await bootstrapSystemProject(prisma);
|
||||
|
||||
// create should not have been called additional times
|
||||
expect(vi.mocked(prisma.prompt.create).mock.calls.length).toBe(firstCallCount);
|
||||
});
|
||||
|
||||
it('re-creates deleted prompts on subsequent startup', async () => {
|
||||
// First run creates everything
|
||||
await bootstrapSystemProject(prisma);
|
||||
|
||||
// Simulate deletion: clear the map so findFirst returns null
|
||||
vi.mocked(prisma.prompt.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.prompt.create).mockClear();
|
||||
|
||||
// Second run should recreate
|
||||
await bootstrapSystemProject(prisma);
|
||||
|
||||
const expectedNames = getSystemPromptNames();
|
||||
expect(vi.mocked(prisma.prompt.create).mock.calls.length).toBe(expectedNames.length);
|
||||
});
|
||||
|
||||
it('system project has gated=false', async () => {
|
||||
await bootstrapSystemProject(prisma);
|
||||
|
||||
const upsertCall = vi.mocked(prisma.project.upsert).mock.calls[0]![0];
|
||||
expect((upsertCall as { create: { gated: boolean } }).create.gated).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSystemPromptNames', () => {
|
||||
it('returns all system prompt names', () => {
|
||||
const names = getSystemPromptNames();
|
||||
expect(names).toContain('gate-instructions');
|
||||
expect(names).toContain('gate-encouragement');
|
||||
expect(names).toContain('gate-intercept-preamble');
|
||||
expect(names).toContain('session-greeting');
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ function makeProject(overrides: Partial<ProjectWithRelations> = {}): ProjectWith
|
||||
description: '',
|
||||
ownerId: 'user-1',
|
||||
proxyMode: 'direct',
|
||||
gated: true,
|
||||
llmProvider: null,
|
||||
llmModel: null,
|
||||
version: 1,
|
||||
|
||||
@@ -12,6 +12,7 @@ function makeProject(overrides: Partial<ProjectWithRelations> = {}): ProjectWith
|
||||
description: '',
|
||||
ownerId: 'user-1',
|
||||
proxyMode: 'direct',
|
||||
gated: true,
|
||||
llmProvider: null,
|
||||
llmModel: null,
|
||||
version: 1,
|
||||
|
||||
508
src/mcpd/tests/prompt-routes.test.ts
Normal file
508
src/mcpd/tests/prompt-routes.test.ts
Normal file
@@ -0,0 +1,508 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import Fastify from 'fastify';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { registerPromptRoutes } from '../src/routes/prompts.js';
|
||||
import { PromptService } from '../src/services/prompt.service.js';
|
||||
import { errorHandler } from '../src/middleware/error-handler.js';
|
||||
import type { IPromptRepository } from '../src/repositories/prompt.repository.js';
|
||||
import type { IPromptRequestRepository } from '../src/repositories/prompt-request.repository.js';
|
||||
import type { IProjectRepository } from '../src/repositories/project.repository.js';
|
||||
import type { Prompt, PromptRequest, Project } from '@prisma/client';
|
||||
|
||||
let app: FastifyInstance;
|
||||
|
||||
function makePrompt(overrides: Partial<Prompt> = {}): Prompt {
|
||||
return {
|
||||
id: 'prompt-1',
|
||||
name: 'test-prompt',
|
||||
content: 'Hello world',
|
||||
projectId: null,
|
||||
priority: 5,
|
||||
summary: null,
|
||||
chapters: null,
|
||||
linkTarget: null,
|
||||
version: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makePromptRequest(overrides: Partial<PromptRequest> = {}): PromptRequest {
|
||||
return {
|
||||
id: 'req-1',
|
||||
name: 'test-request',
|
||||
content: 'Proposed content',
|
||||
projectId: null,
|
||||
priority: 5,
|
||||
createdBySession: 'session-abc',
|
||||
createdByUserId: null,
|
||||
createdAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeProject(overrides: Partial<Project> = {}): Project {
|
||||
return {
|
||||
id: 'proj-1',
|
||||
name: 'homeautomation',
|
||||
description: '',
|
||||
prompt: '',
|
||||
proxyMode: 'direct',
|
||||
gated: true,
|
||||
llmProvider: null,
|
||||
llmModel: null,
|
||||
ownerId: 'user-1',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
} as Project;
|
||||
}
|
||||
|
||||
function mockPromptRepo(): IPromptRepository {
|
||||
return {
|
||||
findAll: vi.fn(async () => []),
|
||||
findGlobal: vi.fn(async () => []),
|
||||
findById: vi.fn(async () => null),
|
||||
findByNameAndProject: vi.fn(async () => null),
|
||||
create: vi.fn(async (data) => makePrompt(data)),
|
||||
update: vi.fn(async (id, data) => makePrompt({ id, ...data })),
|
||||
delete: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
function mockPromptRequestRepo(): IPromptRequestRepository {
|
||||
return {
|
||||
findAll: vi.fn(async () => []),
|
||||
findGlobal: vi.fn(async () => []),
|
||||
findById: vi.fn(async () => null),
|
||||
findByNameAndProject: vi.fn(async () => null),
|
||||
findBySession: vi.fn(async () => []),
|
||||
create: vi.fn(async (data) => makePromptRequest(data)),
|
||||
update: vi.fn(async (id, data) => makePromptRequest({ id, ...data })),
|
||||
delete: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
function makeProjectWithServers(
|
||||
overrides: Partial<Project> = {},
|
||||
serverNames: string[] = [],
|
||||
) {
|
||||
return {
|
||||
...makeProject(overrides),
|
||||
servers: serverNames.map((name, i) => ({
|
||||
id: `ps-${i}`,
|
||||
projectId: overrides.id ?? 'proj-1',
|
||||
serverId: `srv-${i}`,
|
||||
server: { id: `srv-${i}`, name },
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function mockProjectRepo(): IProjectRepository {
|
||||
return {
|
||||
findAll: vi.fn(async () => []),
|
||||
findById: vi.fn(async () => null),
|
||||
findByName: vi.fn(async () => null),
|
||||
create: vi.fn(async (data) => makeProject(data)),
|
||||
update: vi.fn(async (_id, data) => makeProject({ ...data as Partial<Project> })),
|
||||
delete: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
if (app) await app.close();
|
||||
});
|
||||
|
||||
function buildApp(opts?: {
|
||||
promptRepo?: IPromptRepository;
|
||||
promptRequestRepo?: IPromptRequestRepository;
|
||||
projectRepo?: IProjectRepository;
|
||||
}) {
|
||||
const promptRepo = opts?.promptRepo ?? mockPromptRepo();
|
||||
const promptRequestRepo = opts?.promptRequestRepo ?? mockPromptRequestRepo();
|
||||
const projectRepo = opts?.projectRepo ?? mockProjectRepo();
|
||||
const service = new PromptService(promptRepo, promptRequestRepo, projectRepo);
|
||||
|
||||
app = Fastify();
|
||||
app.setErrorHandler(errorHandler);
|
||||
registerPromptRoutes(app, service, projectRepo);
|
||||
return { app, promptRepo, promptRequestRepo, projectRepo, service };
|
||||
}
|
||||
|
||||
describe('Prompt routes', () => {
|
||||
describe('GET /api/v1/prompts', () => {
|
||||
it('returns all prompts without project filter', async () => {
|
||||
const promptRepo = mockPromptRepo();
|
||||
const globalPrompt = makePrompt({ id: 'p-1', name: 'global-rule', projectId: null });
|
||||
const scopedPrompt = makePrompt({ id: 'p-2', name: 'scoped-rule', projectId: 'proj-1' });
|
||||
vi.mocked(promptRepo.findAll).mockResolvedValue([globalPrompt, scopedPrompt]);
|
||||
|
||||
const { app: a } = buildApp({ promptRepo });
|
||||
const res = await a.inject({ method: 'GET', url: '/api/v1/prompts' });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json() as Prompt[];
|
||||
expect(body).toHaveLength(2);
|
||||
expect(promptRepo.findAll).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it('filters by project name when ?project= is given', async () => {
|
||||
const promptRepo = mockPromptRepo();
|
||||
const projectRepo = mockProjectRepo();
|
||||
vi.mocked(projectRepo.findByName).mockResolvedValue(makeProject({ id: 'proj-1', name: 'homeautomation' }));
|
||||
vi.mocked(promptRepo.findAll).mockResolvedValue([
|
||||
makePrompt({ id: 'p-1', name: 'ha-rule', projectId: 'proj-1' }),
|
||||
makePrompt({ id: 'p-2', name: 'global-rule', projectId: null }),
|
||||
]);
|
||||
|
||||
const { app: a } = buildApp({ promptRepo, projectRepo });
|
||||
const res = await a.inject({ method: 'GET', url: '/api/v1/prompts?project=homeautomation' });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(projectRepo.findByName).toHaveBeenCalledWith('homeautomation');
|
||||
expect(promptRepo.findAll).toHaveBeenCalledWith('proj-1');
|
||||
});
|
||||
|
||||
it('returns only global prompts when ?scope=global', async () => {
|
||||
const promptRepo = mockPromptRepo();
|
||||
const globalOnly = [makePrompt({ id: 'p-g', name: 'global-rule', projectId: null })];
|
||||
vi.mocked(promptRepo.findGlobal).mockResolvedValue(globalOnly);
|
||||
|
||||
const { app: a } = buildApp({ promptRepo });
|
||||
const res = await a.inject({ method: 'GET', url: '/api/v1/prompts?scope=global' });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json() as Prompt[];
|
||||
expect(body).toHaveLength(1);
|
||||
expect(promptRepo.findGlobal).toHaveBeenCalled();
|
||||
expect(promptRepo.findAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 404 when ?project= references unknown project', async () => {
|
||||
const { app: a } = buildApp();
|
||||
const res = await a.inject({ method: 'GET', url: '/api/v1/prompts?project=nonexistent' });
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
const body = res.json() as { error: string };
|
||||
expect(body.error).toContain('Project not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/promptrequests', () => {
|
||||
it('returns all prompt requests without project filter', async () => {
|
||||
const promptRequestRepo = mockPromptRequestRepo();
|
||||
vi.mocked(promptRequestRepo.findAll).mockResolvedValue([
|
||||
makePromptRequest({ id: 'r-1', name: 'req-a' }),
|
||||
]);
|
||||
|
||||
const { app: a } = buildApp({ promptRequestRepo });
|
||||
const res = await a.inject({ method: 'GET', url: '/api/v1/promptrequests' });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(promptRequestRepo.findAll).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it('returns only global prompt requests when ?scope=global', async () => {
|
||||
const promptRequestRepo = mockPromptRequestRepo();
|
||||
vi.mocked(promptRequestRepo.findGlobal).mockResolvedValue([]);
|
||||
|
||||
const { app: a } = buildApp({ promptRequestRepo });
|
||||
const res = await a.inject({ method: 'GET', url: '/api/v1/promptrequests?scope=global' });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(promptRequestRepo.findGlobal).toHaveBeenCalled();
|
||||
expect(promptRequestRepo.findAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('filters by project name when ?project= is given', async () => {
|
||||
const promptRequestRepo = mockPromptRequestRepo();
|
||||
const projectRepo = mockProjectRepo();
|
||||
vi.mocked(projectRepo.findByName).mockResolvedValue(makeProject({ id: 'proj-1' }));
|
||||
|
||||
const { app: a } = buildApp({ promptRequestRepo, projectRepo });
|
||||
const res = await a.inject({ method: 'GET', url: '/api/v1/promptrequests?project=homeautomation' });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(promptRequestRepo.findAll).toHaveBeenCalledWith('proj-1');
|
||||
});
|
||||
|
||||
it('returns 404 for unknown project on promptrequests', async () => {
|
||||
const { app: a } = buildApp();
|
||||
const res = await a.inject({ method: 'GET', url: '/api/v1/promptrequests?project=nope' });
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/promptrequests', () => {
|
||||
it('creates a global prompt request (no project)', async () => {
|
||||
const promptRequestRepo = mockPromptRequestRepo();
|
||||
const { app: a } = buildApp({ promptRequestRepo });
|
||||
const res = await a.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/promptrequests',
|
||||
payload: { name: 'global-req', content: 'some content' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(promptRequestRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'global-req', content: 'some content' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves project name to ID when project given', async () => {
|
||||
const promptRequestRepo = mockPromptRequestRepo();
|
||||
const projectRepo = mockProjectRepo();
|
||||
const proj = makeProject({ id: 'proj-1', name: 'myproj' });
|
||||
vi.mocked(projectRepo.findByName).mockResolvedValue(proj);
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(proj);
|
||||
|
||||
const { app: a } = buildApp({ promptRequestRepo, projectRepo });
|
||||
const res = await a.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/promptrequests',
|
||||
payload: { name: 'scoped-req', content: 'text', project: 'myproj' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(projectRepo.findByName).toHaveBeenCalledWith('myproj');
|
||||
expect(promptRequestRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'scoped-req', projectId: 'proj-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 404 for unknown project name', async () => {
|
||||
const { app: a } = buildApp();
|
||||
const res = await a.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/promptrequests',
|
||||
payload: { name: 'bad-req', content: 'x', project: 'nope' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/promptrequests/:id/approve', () => {
|
||||
it('atomically approves a prompt request', async () => {
|
||||
const promptRequestRepo = mockPromptRequestRepo();
|
||||
const promptRepo = mockPromptRepo();
|
||||
const req = makePromptRequest({ id: 'req-1', name: 'my-rule', projectId: 'proj-1' });
|
||||
vi.mocked(promptRequestRepo.findById).mockResolvedValue(req);
|
||||
|
||||
const { app: a } = buildApp({ promptRepo, promptRequestRepo });
|
||||
const res = await a.inject({ method: 'POST', url: '/api/v1/promptrequests/req-1/approve' });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(promptRepo.create).toHaveBeenCalledWith({
|
||||
name: 'my-rule',
|
||||
content: 'Proposed content',
|
||||
projectId: 'proj-1',
|
||||
});
|
||||
expect(promptRequestRepo.delete).toHaveBeenCalledWith('req-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Security: projectId tampering', () => {
|
||||
it('rejects projectId in prompt update payload', async () => {
|
||||
const promptRepo = mockPromptRepo();
|
||||
vi.mocked(promptRepo.findById).mockResolvedValue(makePrompt({ id: 'p-1', projectId: 'proj-a' }));
|
||||
|
||||
const { app: a } = buildApp({ promptRepo });
|
||||
const res = await a.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/v1/prompts/p-1',
|
||||
payload: { content: 'new content', projectId: 'proj-evil' },
|
||||
});
|
||||
|
||||
// Should succeed but ignore projectId — UpdatePromptSchema strips it
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(promptRepo.update).toHaveBeenCalledWith('p-1', { content: 'new content' });
|
||||
// projectId must NOT be in the update call
|
||||
const updateArg = vi.mocked(promptRepo.update).mock.calls[0]![1];
|
||||
expect(updateArg).not.toHaveProperty('projectId');
|
||||
});
|
||||
|
||||
it('rejects projectId in promptrequest update payload', async () => {
|
||||
const promptRequestRepo = mockPromptRequestRepo();
|
||||
vi.mocked(promptRequestRepo.findById).mockResolvedValue(makePromptRequest({ id: 'r-1', projectId: 'proj-a' }));
|
||||
|
||||
const { app: a } = buildApp({ promptRequestRepo });
|
||||
const res = await a.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/v1/promptrequests/r-1',
|
||||
payload: { content: 'new content', projectId: 'proj-evil' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(promptRequestRepo.update).toHaveBeenCalledWith('r-1', { content: 'new content' });
|
||||
const updateArg = vi.mocked(promptRequestRepo.update).mock.calls[0]![1];
|
||||
expect(updateArg).not.toHaveProperty('projectId');
|
||||
});
|
||||
});
|
||||
|
||||
describe('linkStatus enrichment', () => {
|
||||
it('returns linkStatus=null for non-linked prompts', async () => {
|
||||
const promptRepo = mockPromptRepo();
|
||||
vi.mocked(promptRepo.findAll).mockResolvedValue([
|
||||
makePrompt({ id: 'p-1', name: 'plain', linkTarget: null }),
|
||||
]);
|
||||
|
||||
const { app: a } = buildApp({ promptRepo });
|
||||
const res = await a.inject({ method: 'GET', url: '/api/v1/prompts' });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json() as Array<{ linkStatus: string | null }>;
|
||||
expect(body[0]!.linkStatus).toBeNull();
|
||||
});
|
||||
|
||||
it('returns linkStatus=alive when project and server exist', async () => {
|
||||
const promptRepo = mockPromptRepo();
|
||||
const projectRepo = mockProjectRepo();
|
||||
vi.mocked(promptRepo.findAll).mockResolvedValue([
|
||||
makePrompt({ id: 'p-1', name: 'linked', linkTarget: 'source-proj/docmost-mcp:docmost://pages/abc' }),
|
||||
]);
|
||||
vi.mocked(projectRepo.findByName).mockImplementation(async (name) => {
|
||||
if (name === 'source-proj') {
|
||||
return makeProjectWithServers({ id: 'sp-1', name: 'source-proj' }, ['docmost-mcp']) as never;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const { app: a } = buildApp({ promptRepo, projectRepo });
|
||||
const res = await a.inject({ method: 'GET', url: '/api/v1/prompts' });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json() as Array<{ linkStatus: string }>;
|
||||
expect(body[0]!.linkStatus).toBe('alive');
|
||||
});
|
||||
|
||||
it('returns linkStatus=dead when source project not found', async () => {
|
||||
const promptRepo = mockPromptRepo();
|
||||
vi.mocked(promptRepo.findAll).mockResolvedValue([
|
||||
makePrompt({ id: 'p-1', name: 'broken', linkTarget: 'missing-proj/srv:some://uri' }),
|
||||
]);
|
||||
|
||||
const { app: a } = buildApp({ promptRepo });
|
||||
const res = await a.inject({ method: 'GET', url: '/api/v1/prompts' });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json() as Array<{ linkStatus: string }>;
|
||||
expect(body[0]!.linkStatus).toBe('dead');
|
||||
});
|
||||
|
||||
it('returns linkStatus=dead when server not in project', async () => {
|
||||
const promptRepo = mockPromptRepo();
|
||||
const projectRepo = mockProjectRepo();
|
||||
vi.mocked(promptRepo.findAll).mockResolvedValue([
|
||||
makePrompt({ id: 'p-1', name: 'wrong-srv', linkTarget: 'proj/wrong-server:some://uri' }),
|
||||
]);
|
||||
vi.mocked(projectRepo.findByName).mockResolvedValue(
|
||||
makeProjectWithServers({ id: 'sp-1', name: 'proj' }, ['other-server']) as never,
|
||||
);
|
||||
|
||||
const { app: a } = buildApp({ promptRepo, projectRepo });
|
||||
const res = await a.inject({ method: 'GET', url: '/api/v1/prompts' });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json() as Array<{ linkStatus: string }>;
|
||||
expect(body[0]!.linkStatus).toBe('dead');
|
||||
});
|
||||
|
||||
it('enriches single prompt GET with linkStatus', async () => {
|
||||
const promptRepo = mockPromptRepo();
|
||||
const projectRepo = mockProjectRepo();
|
||||
vi.mocked(promptRepo.findById).mockResolvedValue(
|
||||
makePrompt({ id: 'p-1', name: 'linked', linkTarget: 'proj/srv:some://uri' }),
|
||||
);
|
||||
vi.mocked(projectRepo.findByName).mockResolvedValue(
|
||||
makeProjectWithServers({ id: 'sp-1', name: 'proj' }, ['srv']) as never,
|
||||
);
|
||||
|
||||
const { app: a } = buildApp({ promptRepo, projectRepo });
|
||||
const res = await a.inject({ method: 'GET', url: '/api/v1/prompts/p-1' });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json() as { linkStatus: string };
|
||||
expect(body.linkStatus).toBe('alive');
|
||||
});
|
||||
|
||||
it('caches project lookup for multiple linked prompts', async () => {
|
||||
const promptRepo = mockPromptRepo();
|
||||
const projectRepo = mockProjectRepo();
|
||||
vi.mocked(promptRepo.findAll).mockResolvedValue([
|
||||
makePrompt({ id: 'p-1', name: 'link-a', linkTarget: 'proj/srv:uri-a' }),
|
||||
makePrompt({ id: 'p-2', name: 'link-b', linkTarget: 'proj/srv:uri-b' }),
|
||||
]);
|
||||
vi.mocked(projectRepo.findByName).mockResolvedValue(
|
||||
makeProjectWithServers({ id: 'sp-1', name: 'proj' }, ['srv']) as never,
|
||||
);
|
||||
|
||||
const { app: a } = buildApp({ promptRepo, projectRepo });
|
||||
const res = await a.inject({ method: 'GET', url: '/api/v1/prompts' });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json() as Array<{ linkStatus: string }>;
|
||||
expect(body).toHaveLength(2);
|
||||
expect(body[0]!.linkStatus).toBe('alive');
|
||||
expect(body[1]!.linkStatus).toBe('alive');
|
||||
// Should only call findByName once (cached)
|
||||
expect(projectRepo.findByName).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('supports ?projectId= query parameter', async () => {
|
||||
const promptRepo = mockPromptRepo();
|
||||
vi.mocked(promptRepo.findAll).mockResolvedValue([
|
||||
makePrompt({ id: 'p-1', name: 'scoped', projectId: 'proj-1' }),
|
||||
]);
|
||||
|
||||
const { app: a } = buildApp({ promptRepo });
|
||||
const res = await a.inject({ method: 'GET', url: '/api/v1/prompts?projectId=proj-1' });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(promptRepo.findAll).toHaveBeenCalledWith('proj-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/projects/:name/prompts/visible', () => {
|
||||
it('returns approved prompts + session pending requests', async () => {
|
||||
const promptRepo = mockPromptRepo();
|
||||
const promptRequestRepo = mockPromptRequestRepo();
|
||||
const projectRepo = mockProjectRepo();
|
||||
vi.mocked(projectRepo.findByName).mockResolvedValue(makeProject({ id: 'proj-1' }));
|
||||
vi.mocked(promptRepo.findAll).mockResolvedValue([
|
||||
makePrompt({ name: 'approved-one', projectId: 'proj-1' }),
|
||||
makePrompt({ name: 'global-one', projectId: null }),
|
||||
]);
|
||||
vi.mocked(promptRequestRepo.findBySession).mockResolvedValue([
|
||||
makePromptRequest({ name: 'pending-one', projectId: 'proj-1' }),
|
||||
]);
|
||||
|
||||
const { app: a } = buildApp({ promptRepo, promptRequestRepo, projectRepo });
|
||||
const res = await a.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/projects/homeautomation/prompts/visible?session=sess-123',
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json() as Array<{ name: string; type: string }>;
|
||||
expect(body).toHaveLength(3);
|
||||
expect(body.map((b) => b.name)).toContain('approved-one');
|
||||
expect(body.map((b) => b.name)).toContain('global-one');
|
||||
expect(body.map((b) => b.name)).toContain('pending-one');
|
||||
const pending = body.find((b) => b.name === 'pending-one');
|
||||
expect(pending?.type).toBe('promptrequest');
|
||||
});
|
||||
|
||||
it('returns 404 for unknown project', async () => {
|
||||
const { app: a } = buildApp();
|
||||
const res = await a.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/projects/nonexistent/prompts/visible',
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,10 @@ function makePrompt(overrides: Partial<Prompt> = {}): Prompt {
|
||||
name: 'test-prompt',
|
||||
content: 'Hello world',
|
||||
projectId: null,
|
||||
priority: 5,
|
||||
summary: null,
|
||||
chapters: null,
|
||||
linkTarget: null,
|
||||
version: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
@@ -24,6 +28,7 @@ function makePromptRequest(overrides: Partial<PromptRequest> = {}): PromptReques
|
||||
name: 'test-request',
|
||||
content: 'Proposed content',
|
||||
projectId: null,
|
||||
priority: 5,
|
||||
createdBySession: 'session-abc',
|
||||
createdByUserId: null,
|
||||
createdAt: new Date(),
|
||||
@@ -38,6 +43,7 @@ function makeProject(overrides: Partial<Project> = {}): Project {
|
||||
description: '',
|
||||
prompt: '',
|
||||
proxyMode: 'direct',
|
||||
gated: true,
|
||||
llmProvider: null,
|
||||
llmModel: null,
|
||||
ownerId: 'user-1',
|
||||
@@ -50,6 +56,7 @@ function makeProject(overrides: Partial<Project> = {}): Project {
|
||||
function mockPromptRepo(): IPromptRepository {
|
||||
return {
|
||||
findAll: vi.fn(async () => []),
|
||||
findGlobal: vi.fn(async () => []),
|
||||
findById: vi.fn(async () => null),
|
||||
findByNameAndProject: vi.fn(async () => null),
|
||||
create: vi.fn(async (data) => makePrompt(data)),
|
||||
@@ -61,10 +68,12 @@ function mockPromptRepo(): IPromptRepository {
|
||||
function mockPromptRequestRepo(): IPromptRequestRepository {
|
||||
return {
|
||||
findAll: vi.fn(async () => []),
|
||||
findGlobal: vi.fn(async () => []),
|
||||
findById: vi.fn(async () => null),
|
||||
findByNameAndProject: vi.fn(async () => null),
|
||||
findBySession: vi.fn(async () => []),
|
||||
create: vi.fn(async (data) => makePromptRequest(data)),
|
||||
update: vi.fn(async (id, data) => makePromptRequest({ id, ...data })),
|
||||
delete: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
@@ -111,6 +120,17 @@ describe('PromptService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('listGlobalPrompts', () => {
|
||||
it('should return only global prompts', async () => {
|
||||
const globalPrompts = [makePrompt({ name: 'global-rule', projectId: null })];
|
||||
vi.mocked(promptRepo.findGlobal).mockResolvedValue(globalPrompts);
|
||||
|
||||
const result = await service.listGlobalPrompts();
|
||||
expect(result).toEqual(globalPrompts);
|
||||
expect(promptRepo.findGlobal).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPrompt', () => {
|
||||
it('should return a prompt by id', async () => {
|
||||
const prompt = makePrompt();
|
||||
@@ -173,6 +193,21 @@ describe('PromptService', () => {
|
||||
it('should throw for missing prompt', async () => {
|
||||
await expect(service.deletePrompt('nope')).rejects.toThrow('Prompt not found');
|
||||
});
|
||||
|
||||
it('should reject deletion of system prompts', async () => {
|
||||
vi.mocked(promptRepo.findById).mockResolvedValue(makePrompt({ projectId: 'sys-proj' }));
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'sys-proj', name: 'mcpctl-system' }));
|
||||
|
||||
await expect(service.deletePrompt('prompt-1')).rejects.toThrow('Cannot delete system prompts');
|
||||
});
|
||||
|
||||
it('should allow deletion of non-system project prompts', async () => {
|
||||
vi.mocked(promptRepo.findById).mockResolvedValue(makePrompt({ projectId: 'proj-1' }));
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'proj-1', name: 'my-project' }));
|
||||
|
||||
await service.deletePrompt('prompt-1');
|
||||
expect(promptRepo.delete).toHaveBeenCalledWith('prompt-1');
|
||||
});
|
||||
});
|
||||
|
||||
// ── PromptRequest CRUD ──
|
||||
@@ -267,6 +302,90 @@ describe('PromptService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Priority ──
|
||||
|
||||
describe('prompt priority', () => {
|
||||
it('creates prompt with explicit priority', async () => {
|
||||
const result = await service.createPrompt({ name: 'high-pri', content: 'x', priority: 8 });
|
||||
expect(promptRepo.create).toHaveBeenCalledWith(expect.objectContaining({ priority: 8 }));
|
||||
expect(result.priority).toBe(8);
|
||||
});
|
||||
|
||||
it('uses default priority 5 when not specified', async () => {
|
||||
const result = await service.createPrompt({ name: 'default-pri', content: 'x' });
|
||||
// Default in schema is 5 — create is called without priority
|
||||
const createArg = vi.mocked(promptRepo.create).mock.calls[0]![0];
|
||||
expect(createArg.priority).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects priority below 1', async () => {
|
||||
await expect(
|
||||
service.createPrompt({ name: 'bad-pri', content: 'x', priority: 0 }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects priority above 10', async () => {
|
||||
await expect(
|
||||
service.createPrompt({ name: 'bad-pri', content: 'x', priority: 11 }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('updates prompt priority', async () => {
|
||||
vi.mocked(promptRepo.findById).mockResolvedValue(makePrompt());
|
||||
await service.updatePrompt('prompt-1', { priority: 3 });
|
||||
expect(promptRepo.update).toHaveBeenCalledWith('prompt-1', expect.objectContaining({ priority: 3 }));
|
||||
});
|
||||
});
|
||||
|
||||
// ── Link Target ──
|
||||
|
||||
describe('prompt links', () => {
|
||||
it('creates linked prompt with valid linkTarget', async () => {
|
||||
const result = await service.createPrompt({
|
||||
name: 'linked',
|
||||
content: 'link content',
|
||||
linkTarget: 'other-project/docmost-mcp:docmost://pages/abc',
|
||||
});
|
||||
expect(promptRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ linkTarget: 'other-project/docmost-mcp:docmost://pages/abc' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid link format', async () => {
|
||||
await expect(
|
||||
service.createPrompt({ name: 'bad-link', content: 'x', linkTarget: 'invalid-format' }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects link without server part', async () => {
|
||||
await expect(
|
||||
service.createPrompt({ name: 'bad-link', content: 'x', linkTarget: 'project:uri' }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('approve carries priority from request to prompt', async () => {
|
||||
const req = makePromptRequest({ id: 'req-1', name: 'high-pri', content: 'x', projectId: 'proj-1', priority: 9 });
|
||||
vi.mocked(promptRequestRepo.findById).mockResolvedValue(req);
|
||||
|
||||
await service.approve('req-1');
|
||||
|
||||
expect(promptRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ priority: 9 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('propose passes priority through', async () => {
|
||||
const result = await service.propose({
|
||||
name: 'pri-req',
|
||||
content: 'x',
|
||||
priority: 7,
|
||||
});
|
||||
expect(promptRequestRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ priority: 7 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Visibility ──
|
||||
|
||||
describe('getVisiblePrompts', () => {
|
||||
|
||||
110
src/mcpd/tests/services/prompt-summary.test.ts
Normal file
110
src/mcpd/tests/services/prompt-summary.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
PromptSummaryService,
|
||||
extractFirstSentence,
|
||||
extractHeadings,
|
||||
type LlmSummaryGenerator,
|
||||
} from '../../src/services/prompt-summary.service.js';
|
||||
|
||||
describe('extractFirstSentence', () => {
|
||||
it('extracts first sentence from plain text', () => {
|
||||
const result = extractFirstSentence('This is the first sentence. And this is the second.', 20);
|
||||
expect(result).toBe('This is the first sentence.');
|
||||
});
|
||||
|
||||
it('truncates to maxWords', () => {
|
||||
const long = 'word '.repeat(30).trim();
|
||||
const result = extractFirstSentence(long, 5);
|
||||
expect(result).toBe('word word word word word...');
|
||||
});
|
||||
|
||||
it('skips markdown headings to find content', () => {
|
||||
const content = '# Title\n\n## Subtitle\n\nActual content here. More text.';
|
||||
expect(extractFirstSentence(content, 20)).toBe('Actual content here.');
|
||||
});
|
||||
|
||||
it('falls back to first heading if no content lines', () => {
|
||||
const content = '# Only Headings\n## Nothing Else';
|
||||
expect(extractFirstSentence(content, 20)).toBe('Only Headings');
|
||||
});
|
||||
|
||||
it('strips markdown formatting', () => {
|
||||
const content = 'This has **bold** and *italic* and `code` and [link](http://example.com).';
|
||||
expect(extractFirstSentence(content, 20)).toBe('This has bold and italic and code and link.');
|
||||
});
|
||||
|
||||
it('handles empty content', () => {
|
||||
expect(extractFirstSentence('', 20)).toBe('');
|
||||
expect(extractFirstSentence(' ', 20)).toBe('');
|
||||
});
|
||||
|
||||
it('handles content with no sentence boundary', () => {
|
||||
const content = 'No period at the end';
|
||||
expect(extractFirstSentence(content, 20)).toBe('No period at the end');
|
||||
});
|
||||
|
||||
it('handles exclamation and question marks', () => {
|
||||
expect(extractFirstSentence('Is this a question? Yes it is.', 20)).toBe('Is this a question?');
|
||||
expect(extractFirstSentence('Watch out! Be careful.', 20)).toBe('Watch out!');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractHeadings', () => {
|
||||
it('extracts all levels of markdown headings', () => {
|
||||
const content = '# H1\n## H2\n### H3\nSome text\n#### H4';
|
||||
expect(extractHeadings(content)).toEqual(['H1', 'H2', 'H3', 'H4']);
|
||||
});
|
||||
|
||||
it('returns empty array for content without headings', () => {
|
||||
expect(extractHeadings('Just plain text\nMore text')).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles empty content', () => {
|
||||
expect(extractHeadings('')).toEqual([]);
|
||||
});
|
||||
|
||||
it('trims heading text', () => {
|
||||
const content = '# Spaced Heading \n## Another ';
|
||||
expect(extractHeadings(content)).toEqual(['Spaced Heading', 'Another']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PromptSummaryService', () => {
|
||||
it('uses regex fallback when no LLM', async () => {
|
||||
const service = new PromptSummaryService(null);
|
||||
const result = await service.generateSummary('# Overview\n\nThis is a test document. It has content.\n\n## Section One\n\n## Section Two');
|
||||
expect(result.summary).toBe('This is a test document.');
|
||||
expect(result.chapters).toEqual(['Overview', 'Section One', 'Section Two']);
|
||||
});
|
||||
|
||||
it('uses LLM when available', async () => {
|
||||
const mockLlm: LlmSummaryGenerator = {
|
||||
generate: vi.fn(async () => ({
|
||||
summary: 'LLM-generated summary',
|
||||
chapters: ['LLM Chapter 1'],
|
||||
})),
|
||||
};
|
||||
const service = new PromptSummaryService(mockLlm);
|
||||
const result = await service.generateSummary('Some content');
|
||||
expect(result.summary).toBe('LLM-generated summary');
|
||||
expect(result.chapters).toEqual(['LLM Chapter 1']);
|
||||
expect(mockLlm.generate).toHaveBeenCalledWith('Some content');
|
||||
});
|
||||
|
||||
it('falls back to regex on LLM failure', async () => {
|
||||
const mockLlm: LlmSummaryGenerator = {
|
||||
generate: vi.fn(async () => { throw new Error('LLM unavailable'); }),
|
||||
};
|
||||
const service = new PromptSummaryService(mockLlm);
|
||||
const result = await service.generateSummary('Fallback content here. Second sentence.');
|
||||
expect(result.summary).toBe('Fallback content here.');
|
||||
expect(mockLlm.generate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('generateWithRegex works directly', () => {
|
||||
const service = new PromptSummaryService(null);
|
||||
const result = service.generateWithRegex('# Title\n\nContent line. More.\n\n## Chapter A\n\n## Chapter B');
|
||||
expect(result.summary).toBe('Content line.');
|
||||
expect(result.chapters).toEqual(['Title', 'Chapter A', 'Chapter B']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user