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:
@@ -1,5 +1,5 @@
|
||||
import { Command } from 'commander';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { readFileSync, readSync } from 'node:fs';
|
||||
import yaml from 'js-yaml';
|
||||
import { z } from 'zod';
|
||||
import type { ApiClient } from '../api-client.js';
|
||||
@@ -108,6 +108,8 @@ const PromptSpecSchema = z.object({
|
||||
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
|
||||
content: z.string().min(1).max(50000),
|
||||
projectId: z.string().optional(),
|
||||
priority: z.number().int().min(1).max(10).optional(),
|
||||
linkTarget: z.string().optional(),
|
||||
});
|
||||
|
||||
const ProjectSpecSchema = z.object({
|
||||
@@ -115,6 +117,7 @@ const ProjectSpecSchema = z.object({
|
||||
description: z.string().default(''),
|
||||
prompt: z.string().max(10000).default(''),
|
||||
proxyMode: z.enum(['direct', 'filtered']).default('direct'),
|
||||
gated: z.boolean().default(true),
|
||||
llmProvider: z.string().optional(),
|
||||
llmModel: z.string().optional(),
|
||||
servers: z.array(z.string()).default([]),
|
||||
@@ -175,11 +178,27 @@ export function createApplyCommand(deps: ApplyCommandDeps): Command {
|
||||
});
|
||||
}
|
||||
|
||||
function readStdin(): string {
|
||||
const chunks: Buffer[] = [];
|
||||
const buf = Buffer.alloc(4096);
|
||||
try {
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const bytesRead = readSync(0, buf, 0, buf.length, null);
|
||||
if (bytesRead === 0) break;
|
||||
chunks.push(buf.subarray(0, bytesRead));
|
||||
}
|
||||
} catch {
|
||||
// EOF or closed pipe
|
||||
}
|
||||
return Buffer.concat(chunks).toString('utf-8');
|
||||
}
|
||||
|
||||
function loadConfigFile(path: string): ApplyConfig {
|
||||
const raw = readFileSync(path, 'utf-8');
|
||||
const raw = path === '-' ? readStdin() : readFileSync(path, 'utf-8');
|
||||
let parsed: unknown;
|
||||
|
||||
if (path.endsWith('.json')) {
|
||||
if (path === '-' ? raw.trimStart().startsWith('{') : path.endsWith('.json')) {
|
||||
parsed = JSON.parse(raw);
|
||||
} else {
|
||||
parsed = yaml.load(raw);
|
||||
@@ -308,7 +327,9 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args
|
||||
try {
|
||||
const existing = await findByName(client, 'prompts', prompt.name);
|
||||
if (existing) {
|
||||
await client.put(`/api/v1/prompts/${(existing as { id: string }).id}`, { content: prompt.content });
|
||||
const updateData: Record<string, unknown> = { content: prompt.content };
|
||||
if (prompt.priority !== undefined) updateData.priority = prompt.priority;
|
||||
await client.put(`/api/v1/prompts/${(existing as { id: string }).id}`, updateData);
|
||||
log(`Updated prompt: ${prompt.name}`);
|
||||
} else {
|
||||
await client.post('/api/v1/prompts', prompt);
|
||||
|
||||
@@ -197,6 +197,8 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
||||
.option('-d, --description <text>', 'Project description', '')
|
||||
.option('--proxy-mode <mode>', 'Proxy mode (direct, filtered)')
|
||||
.option('--prompt <text>', 'Project-level prompt / instructions for the LLM')
|
||||
.option('--gated', 'Enable gated sessions (default: true)')
|
||||
.option('--no-gated', 'Disable gated sessions')
|
||||
.option('--server <name>', 'Server name (repeat for multiple)', collect, [])
|
||||
.option('--force', 'Update if already exists')
|
||||
.action(async (name: string, opts) => {
|
||||
@@ -206,6 +208,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
||||
proxyMode: opts.proxyMode ?? 'direct',
|
||||
};
|
||||
if (opts.prompt) body.prompt = opts.prompt;
|
||||
if (opts.gated !== undefined) body.gated = opts.gated as boolean;
|
||||
if (opts.server.length > 0) body.servers = opts.server;
|
||||
|
||||
try {
|
||||
@@ -352,6 +355,8 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
||||
.option('--project <name>', 'Project name to scope the prompt to')
|
||||
.option('--content <text>', 'Prompt content text')
|
||||
.option('--content-file <path>', 'Read prompt content from file')
|
||||
.option('--priority <number>', 'Priority 1-10 (default: 5, higher = more important)')
|
||||
.option('--link <target>', 'Link to MCP resource (format: project/server:uri)')
|
||||
.action(async (name: string, opts) => {
|
||||
let content = opts.content as string | undefined;
|
||||
if (opts.contentFile) {
|
||||
@@ -370,6 +375,16 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
||||
if (!project) throw new Error(`Project '${opts.project as string}' not found`);
|
||||
body.projectId = project.id;
|
||||
}
|
||||
if (opts.priority) {
|
||||
const priority = Number(opts.priority);
|
||||
if (isNaN(priority) || priority < 1 || priority > 10) {
|
||||
throw new Error('--priority must be a number between 1 and 10');
|
||||
}
|
||||
body.priority = priority;
|
||||
}
|
||||
if (opts.link) {
|
||||
body.linkTarget = opts.link;
|
||||
}
|
||||
|
||||
const prompt = await client.post<{ id: string; name: string }>('/api/v1/prompts', body);
|
||||
log(`prompt '${prompt.name}' created (id: ${prompt.id})`);
|
||||
@@ -379,9 +394,10 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
||||
cmd.command('promptrequest')
|
||||
.description('Create a prompt request (pending proposal that needs approval)')
|
||||
.argument('<name>', 'Prompt request name (lowercase alphanumeric with hyphens)')
|
||||
.requiredOption('--project <name>', 'Project name (required)')
|
||||
.option('--project <name>', 'Project name to scope the prompt request to')
|
||||
.option('--content <text>', 'Prompt content text')
|
||||
.option('--content-file <path>', 'Read prompt content from file')
|
||||
.option('--priority <number>', 'Priority 1-10 (default: 5, higher = more important)')
|
||||
.action(async (name: string, opts) => {
|
||||
let content = opts.content as string | undefined;
|
||||
if (opts.contentFile) {
|
||||
@@ -392,10 +408,21 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
||||
throw new Error('--content or --content-file is required');
|
||||
}
|
||||
|
||||
const projectName = opts.project as string;
|
||||
const body: Record<string, unknown> = { name, content };
|
||||
if (opts.project) {
|
||||
body.project = opts.project;
|
||||
}
|
||||
if (opts.priority) {
|
||||
const priority = Number(opts.priority);
|
||||
if (isNaN(priority) || priority < 1 || priority > 10) {
|
||||
throw new Error('--priority must be a number between 1 and 10');
|
||||
}
|
||||
body.priority = priority;
|
||||
}
|
||||
|
||||
const pr = await client.post<{ id: string; name: string }>(
|
||||
`/api/v1/projects/${encodeURIComponent(projectName)}/promptrequests`,
|
||||
{ name, content },
|
||||
'/api/v1/promptrequests',
|
||||
body,
|
||||
);
|
||||
log(`prompt request '${pr.name}' created (id: ${pr.id})`);
|
||||
log(` approve with: mcpctl approve promptrequest ${pr.name}`);
|
||||
|
||||
@@ -133,11 +133,15 @@ function formatInstanceDetail(instance: Record<string, unknown>, inspect?: Recor
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function formatProjectDetail(project: Record<string, unknown>): string {
|
||||
function formatProjectDetail(
|
||||
project: Record<string, unknown>,
|
||||
prompts: Array<{ name: string; priority: number; linkTarget: string | null }> = [],
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`=== Project: ${project.name} ===`);
|
||||
lines.push(`${pad('Name:')}${project.name}`);
|
||||
if (project.description) lines.push(`${pad('Description:')}${project.description}`);
|
||||
lines.push(`${pad('Gated:')}${project.gated ? 'yes' : 'no'}`);
|
||||
|
||||
// Proxy config section
|
||||
const proxyMode = project.proxyMode as string | undefined;
|
||||
@@ -162,6 +166,18 @@ function formatProjectDetail(project: Record<string, unknown>): string {
|
||||
}
|
||||
}
|
||||
|
||||
// Prompts section
|
||||
if (prompts.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('Prompts:');
|
||||
const nameW = Math.max(4, ...prompts.map((p) => p.name.length)) + 2;
|
||||
lines.push(` ${'NAME'.padEnd(nameW)}${'PRI'.padEnd(6)}TYPE`);
|
||||
for (const p of prompts) {
|
||||
const type = p.linkTarget ? 'link' : 'local';
|
||||
lines.push(` ${p.name.padEnd(nameW)}${String(p.priority).padEnd(6)}${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('Metadata:');
|
||||
lines.push(` ${pad('ID:', 12)}${project.id}`);
|
||||
@@ -586,9 +602,13 @@ export function createDescribeCommand(deps: DescribeCommandDeps): Command {
|
||||
case 'templates':
|
||||
deps.log(formatTemplateDetail(item));
|
||||
break;
|
||||
case 'projects':
|
||||
deps.log(formatProjectDetail(item));
|
||||
case 'projects': {
|
||||
const projectPrompts = await deps.client
|
||||
.get<Array<{ name: string; priority: number; linkTarget: string | null }>>(`/api/v1/prompts?projectId=${item.id as string}`)
|
||||
.catch(() => []);
|
||||
deps.log(formatProjectDetail(item, projectPrompts));
|
||||
break;
|
||||
}
|
||||
case 'users': {
|
||||
// Fetch RBAC definitions and groups to show permissions
|
||||
const [rbacDefsForUser, allGroupsForUser] = await Promise.all([
|
||||
|
||||
@@ -6,6 +6,7 @@ import { execSync } from 'node:child_process';
|
||||
import yaml from 'js-yaml';
|
||||
import type { ApiClient } from '../api-client.js';
|
||||
import { resolveResource, resolveNameOrId, stripInternalFields } from './shared.js';
|
||||
import { reorderKeys } from '../formatters/output.js';
|
||||
|
||||
export interface EditCommandDeps {
|
||||
client: ApiClient;
|
||||
@@ -47,7 +48,7 @@ export function createEditCommand(deps: EditCommandDeps): Command {
|
||||
return;
|
||||
}
|
||||
|
||||
const validResources = ['servers', 'secrets', 'projects', 'groups', 'rbac'];
|
||||
const validResources = ['servers', 'secrets', 'projects', 'groups', 'rbac', 'prompts', 'promptrequests'];
|
||||
if (!validResources.includes(resource)) {
|
||||
log(`Error: unknown resource type '${resourceArg}'`);
|
||||
process.exitCode = 1;
|
||||
@@ -61,7 +62,7 @@ export function createEditCommand(deps: EditCommandDeps): Command {
|
||||
const current = await client.get<Record<string, unknown>>(`/api/v1/${resource}/${id}`);
|
||||
|
||||
// Strip read-only fields for editor
|
||||
const editable = stripInternalFields(current);
|
||||
const editable = reorderKeys(stripInternalFields(current)) as Record<string, unknown>;
|
||||
|
||||
// Serialize to YAML
|
||||
const singular = resource.replace(/s$/, '');
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { Column } from '../formatters/table.js';
|
||||
import { resolveResource, stripInternalFields } from './shared.js';
|
||||
|
||||
export interface GetCommandDeps {
|
||||
fetchResource: (resource: string, id?: string) => Promise<unknown[]>;
|
||||
fetchResource: (resource: string, id?: string, opts?: { project?: string; all?: boolean }) => Promise<unknown[]>;
|
||||
log: (...args: string[]) => void;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ interface ProjectRow {
|
||||
name: string;
|
||||
description: string;
|
||||
proxyMode: string;
|
||||
gated: boolean;
|
||||
ownerId: string;
|
||||
servers?: Array<{ server: { name: string } }>;
|
||||
}
|
||||
@@ -83,6 +84,7 @@ interface RbacRow {
|
||||
const projectColumns: Column<ProjectRow>[] = [
|
||||
{ header: 'NAME', key: 'name' },
|
||||
{ header: 'MODE', key: (r) => r.proxyMode ?? 'direct', width: 10 },
|
||||
{ header: 'GATED', key: (r) => r.gated ? 'yes' : 'no', width: 6 },
|
||||
{ header: 'SERVERS', key: (r) => r.servers ? String(r.servers.length) : '0', width: 8 },
|
||||
{ header: 'DESCRIPTION', key: 'description', width: 30 },
|
||||
{ header: 'ID', key: 'id' },
|
||||
@@ -134,6 +136,10 @@ interface PromptRow {
|
||||
id: string;
|
||||
name: string;
|
||||
projectId: string | null;
|
||||
project?: { name: string } | null;
|
||||
priority: number;
|
||||
linkTarget: string | null;
|
||||
linkStatus: 'alive' | 'dead' | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -141,20 +147,24 @@ interface PromptRequestRow {
|
||||
id: string;
|
||||
name: string;
|
||||
projectId: string | null;
|
||||
project?: { name: string } | null;
|
||||
createdBySession: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const promptColumns: Column<PromptRow>[] = [
|
||||
{ header: 'NAME', key: 'name' },
|
||||
{ header: 'PROJECT', key: (r) => r.projectId ?? '-', width: 20 },
|
||||
{ header: 'PROJECT', key: (r) => r.project?.name ?? (r.projectId ? r.projectId : '(global)'), width: 20 },
|
||||
{ header: 'PRI', key: (r) => String(r.priority), width: 4 },
|
||||
{ header: 'LINK', key: (r) => r.linkTarget ? r.linkTarget.split(':')[0]! : '-', width: 20 },
|
||||
{ header: 'STATUS', key: (r) => r.linkStatus ?? '-', width: 6 },
|
||||
{ header: 'CREATED', key: (r) => new Date(r.createdAt).toLocaleString(), width: 20 },
|
||||
{ header: 'ID', key: 'id' },
|
||||
];
|
||||
|
||||
const promptRequestColumns: Column<PromptRequestRow>[] = [
|
||||
{ header: 'NAME', key: 'name' },
|
||||
{ header: 'PROJECT', key: (r) => r.projectId ?? '-', width: 20 },
|
||||
{ header: 'PROJECT', key: (r) => r.project?.name ?? (r.projectId ? r.projectId : '(global)'), width: 20 },
|
||||
{ header: 'SESSION', key: (r) => r.createdBySession ? r.createdBySession.slice(0, 12) : '-', width: 14 },
|
||||
{ header: 'CREATED', key: (r) => new Date(r.createdAt).toLocaleString(), width: 20 },
|
||||
{ header: 'ID', key: 'id' },
|
||||
@@ -216,9 +226,14 @@ export function createGetCommand(deps: GetCommandDeps): Command {
|
||||
.argument('<resource>', 'resource type (servers, projects, instances)')
|
||||
.argument('[id]', 'specific resource ID or name')
|
||||
.option('-o, --output <format>', 'output format (table, json, yaml)', 'table')
|
||||
.action(async (resourceArg: string, id: string | undefined, opts: { output: string }) => {
|
||||
.option('--project <name>', 'Filter by project')
|
||||
.option('-A, --all', 'Show all (including project-scoped) resources')
|
||||
.action(async (resourceArg: string, id: string | undefined, opts: { output: string; project?: string; all?: true }) => {
|
||||
const resource = resolveResource(resourceArg);
|
||||
const items = await deps.fetchResource(resource, id);
|
||||
const fetchOpts: { project?: string; all?: boolean } = {};
|
||||
if (opts.project) fetchOpts.project = opts.project;
|
||||
if (opts.all) fetchOpts.all = true;
|
||||
const items = await deps.fetchResource(resource, id, Object.keys(fetchOpts).length > 0 ? fetchOpts : undefined);
|
||||
|
||||
if (opts.output === 'json') {
|
||||
// Apply-compatible JSON wrapped in resource key
|
||||
|
||||
@@ -52,13 +52,12 @@ export function createApproveCommand(deps: ProjectOpsDeps): Command {
|
||||
return new Command('approve')
|
||||
.description('Approve a pending prompt request (atomic: delete request, create prompt)')
|
||||
.argument('<resource>', 'Resource type (promptrequest)')
|
||||
.argument('<name>', 'Prompt request name or ID')
|
||||
.argument('<name>', 'Resource name or ID')
|
||||
.action(async (resourceArg: string, nameOrId: string) => {
|
||||
const resource = resolveResource(resourceArg);
|
||||
if (resource !== 'promptrequests') {
|
||||
throw new Error(`approve is only supported for 'promptrequest', got '${resourceArg}'`);
|
||||
}
|
||||
|
||||
const id = await resolveNameOrId(client, 'promptrequests', nameOrId);
|
||||
const prompt = await client.post<{ id: string; name: string }>(`/api/v1/promptrequests/${id}/approve`, {});
|
||||
log(`prompt request approved → prompt '${prompt.name}' created (id: ${prompt.id})`);
|
||||
|
||||
@@ -61,8 +61,21 @@ export async function resolveNameOrId(
|
||||
/** Strip internal/read-only fields from an API response to make it apply-compatible. */
|
||||
export function stripInternalFields(obj: Record<string, unknown>): Record<string, unknown> {
|
||||
const result = { ...obj };
|
||||
for (const key of ['id', 'createdAt', 'updatedAt', 'version', 'ownerId']) {
|
||||
for (const key of ['id', 'createdAt', 'updatedAt', 'version', 'ownerId', 'summary', 'chapters']) {
|
||||
delete result[key];
|
||||
}
|
||||
// Strip relationship joins that aren't part of the resource spec (like k8s namespaces don't list deployments)
|
||||
if ('servers' in result && Array.isArray(result.servers)) {
|
||||
delete result.servers;
|
||||
}
|
||||
if ('owner' in result && typeof result.owner === 'object') {
|
||||
delete result.owner;
|
||||
}
|
||||
if ('members' in result && Array.isArray(result.members)) {
|
||||
delete result.members;
|
||||
}
|
||||
if ('project' in result && typeof result.project === 'object' && result.project !== null) {
|
||||
delete result.project;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,29 @@ export function formatJson(data: unknown): string {
|
||||
return JSON.stringify(data, null, 2);
|
||||
}
|
||||
|
||||
export function formatYaml(data: unknown): string {
|
||||
return yaml.dump(data, { lineWidth: 120, noRefs: true }).trimEnd();
|
||||
/**
|
||||
* Reorder object keys so that long text fields (like `content`, `prompt`)
|
||||
* come last. This makes YAML output more readable when content spans
|
||||
* multiple lines.
|
||||
*/
|
||||
export function reorderKeys(obj: unknown): unknown {
|
||||
if (Array.isArray(obj)) return obj.map(reorderKeys);
|
||||
if (obj !== null && typeof obj === 'object') {
|
||||
const rec = obj as Record<string, unknown>;
|
||||
const lastKeys = ['content', 'prompt'];
|
||||
const ordered: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(rec)) {
|
||||
if (!lastKeys.includes(key)) ordered[key] = reorderKeys(rec[key]);
|
||||
}
|
||||
for (const key of lastKeys) {
|
||||
if (key in rec) ordered[key] = rec[key];
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
export function formatYaml(data: unknown): string {
|
||||
const reordered = reorderKeys(data);
|
||||
return yaml.dump(reordered, { lineWidth: 120, noRefs: true }).trimEnd();
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { createBackupCommand, createRestoreCommand } from './commands/backup.js'
|
||||
import { createLoginCommand, createLogoutCommand } from './commands/auth.js';
|
||||
import { createAttachServerCommand, createDetachServerCommand, createApproveCommand } from './commands/project-ops.js';
|
||||
import { createMcpCommand } from './commands/mcp.js';
|
||||
import { createPatchCommand } from './commands/patch.js';
|
||||
import { ApiClient, ApiError } from './api-client.js';
|
||||
import { loadConfig } from './config/index.js';
|
||||
import { loadCredentials } from './auth/index.js';
|
||||
@@ -54,8 +55,8 @@ export function createProgram(): Command {
|
||||
log: (...args) => console.log(...args),
|
||||
}));
|
||||
|
||||
const fetchResource = async (resource: string, nameOrId?: string): Promise<unknown[]> => {
|
||||
const projectName = program.opts().project as string | undefined;
|
||||
const fetchResource = async (resource: string, nameOrId?: string, opts?: { project?: string; all?: boolean }): Promise<unknown[]> => {
|
||||
const projectName = opts?.project ?? program.opts().project as string | undefined;
|
||||
|
||||
// --project scoping for servers and instances
|
||||
if (projectName && !nameOrId && (resource === 'servers' || resource === 'instances')) {
|
||||
@@ -70,6 +71,17 @@ export function createProgram(): Command {
|
||||
return allInstances.filter((inst) => serverIds.has(inst.serverId));
|
||||
}
|
||||
|
||||
// --project scoping for prompts and promptrequests
|
||||
if (!nameOrId && (resource === 'prompts' || resource === 'promptrequests')) {
|
||||
if (projectName) {
|
||||
return client.get<unknown[]>(`/api/v1/${resource}?project=${encodeURIComponent(projectName)}`);
|
||||
}
|
||||
// Default: global-only. --all (-A) shows everything.
|
||||
if (!opts?.all) {
|
||||
return client.get<unknown[]>(`/api/v1/${resource}?scope=global`);
|
||||
}
|
||||
}
|
||||
|
||||
if (nameOrId) {
|
||||
// Glob pattern — use query param filtering
|
||||
if (nameOrId.includes('*')) {
|
||||
@@ -134,6 +146,11 @@ export function createProgram(): Command {
|
||||
log: (...args) => console.log(...args),
|
||||
}));
|
||||
|
||||
program.addCommand(createPatchCommand({
|
||||
client,
|
||||
log: (...args) => console.log(...args),
|
||||
}));
|
||||
|
||||
program.addCommand(createBackupCommand({
|
||||
client,
|
||||
log: (...args) => console.log(...args),
|
||||
|
||||
@@ -447,4 +447,114 @@ describe('create command', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('create prompt', () => {
|
||||
it('creates a prompt with content', async () => {
|
||||
vi.mocked(client.post).mockResolvedValueOnce({ id: 'p-1', name: 'test-prompt' });
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await cmd.parseAsync(['prompt', 'test-prompt', '--content', 'Hello world'], { from: 'user' });
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/prompts', {
|
||||
name: 'test-prompt',
|
||||
content: 'Hello world',
|
||||
});
|
||||
expect(output.join('\n')).toContain("prompt 'test-prompt' created");
|
||||
});
|
||||
|
||||
it('requires content or content-file', async () => {
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await expect(
|
||||
cmd.parseAsync(['prompt', 'no-content'], { from: 'user' }),
|
||||
).rejects.toThrow('--content or --content-file is required');
|
||||
});
|
||||
|
||||
it('--priority sets prompt priority', async () => {
|
||||
vi.mocked(client.post).mockResolvedValueOnce({ id: 'p-1', name: 'pri-prompt' });
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await cmd.parseAsync(['prompt', 'pri-prompt', '--content', 'x', '--priority', '8'], { from: 'user' });
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/prompts', expect.objectContaining({
|
||||
priority: 8,
|
||||
}));
|
||||
});
|
||||
|
||||
it('--priority validates range 1-10', async () => {
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await expect(
|
||||
cmd.parseAsync(['prompt', 'bad', '--content', 'x', '--priority', '15'], { from: 'user' }),
|
||||
).rejects.toThrow('--priority must be a number between 1 and 10');
|
||||
});
|
||||
|
||||
it('--priority rejects zero', async () => {
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await expect(
|
||||
cmd.parseAsync(['prompt', 'bad', '--content', 'x', '--priority', '0'], { from: 'user' }),
|
||||
).rejects.toThrow('--priority must be a number between 1 and 10');
|
||||
});
|
||||
|
||||
it('--link sets linkTarget', async () => {
|
||||
vi.mocked(client.post).mockResolvedValueOnce({ id: 'p-1', name: 'linked' });
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await cmd.parseAsync(['prompt', 'linked', '--content', 'x', '--link', 'proj/srv:docmost://pages/abc'], { from: 'user' });
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/prompts', expect.objectContaining({
|
||||
linkTarget: 'proj/srv:docmost://pages/abc',
|
||||
}));
|
||||
});
|
||||
|
||||
it('--project resolves project name to ID', async () => {
|
||||
vi.mocked(client.get).mockResolvedValueOnce([{ id: 'proj-1', name: 'my-project' }] as never);
|
||||
vi.mocked(client.post).mockResolvedValueOnce({ id: 'p-1', name: 'scoped' });
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await cmd.parseAsync(['prompt', 'scoped', '--content', 'x', '--project', 'my-project'], { from: 'user' });
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/prompts', expect.objectContaining({
|
||||
projectId: 'proj-1',
|
||||
}));
|
||||
});
|
||||
|
||||
it('--project throws when project not found', async () => {
|
||||
vi.mocked(client.get).mockResolvedValueOnce([] as never);
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await expect(
|
||||
cmd.parseAsync(['prompt', 'bad', '--content', 'x', '--project', 'nope'], { from: 'user' }),
|
||||
).rejects.toThrow("Project 'nope' not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe('create promptrequest', () => {
|
||||
it('creates a prompt request with priority', async () => {
|
||||
vi.mocked(client.post).mockResolvedValueOnce({ id: 'r-1', name: 'req' });
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await cmd.parseAsync(['promptrequest', 'req', '--content', 'proposal', '--priority', '7'], { from: 'user' });
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/promptrequests', expect.objectContaining({
|
||||
name: 'req',
|
||||
content: 'proposal',
|
||||
priority: 7,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('create project', () => {
|
||||
it('creates a project with --gated', async () => {
|
||||
vi.mocked(client.post).mockResolvedValueOnce({ id: 'proj-1', name: 'gated-proj' });
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await cmd.parseAsync(['project', 'gated-proj', '--gated'], { from: 'user' });
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', expect.objectContaining({
|
||||
gated: true,
|
||||
}));
|
||||
});
|
||||
|
||||
it('creates a project with --no-gated', async () => {
|
||||
vi.mocked(client.post).mockResolvedValueOnce({ id: 'proj-1', name: 'open-proj' });
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await cmd.parseAsync(['project', 'open-proj', '--no-gated'], { from: 'user' });
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', expect.objectContaining({
|
||||
gated: false,
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ describe('get command', () => {
|
||||
const cmd = createGetCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'servers']);
|
||||
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('servers', undefined);
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('servers', undefined, undefined);
|
||||
expect(deps.output[0]).toContain('NAME');
|
||||
expect(deps.output[0]).toContain('TRANSPORT');
|
||||
expect(deps.output.join('\n')).toContain('slack');
|
||||
@@ -31,14 +31,14 @@ describe('get command', () => {
|
||||
const deps = makeDeps([]);
|
||||
const cmd = createGetCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'srv']);
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('servers', undefined);
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('servers', undefined, undefined);
|
||||
});
|
||||
|
||||
it('passes ID when provided', async () => {
|
||||
const deps = makeDeps([{ id: 'srv-1', name: 'slack' }]);
|
||||
const cmd = createGetCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'servers', 'srv-1']);
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('servers', 'srv-1');
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('servers', 'srv-1', undefined);
|
||||
});
|
||||
|
||||
it('outputs apply-compatible JSON format', async () => {
|
||||
@@ -94,7 +94,7 @@ describe('get command', () => {
|
||||
const cmd = createGetCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'users']);
|
||||
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('users', undefined);
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('users', undefined, undefined);
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('EMAIL');
|
||||
expect(text).toContain('NAME');
|
||||
@@ -110,7 +110,7 @@ describe('get command', () => {
|
||||
const deps = makeDeps([]);
|
||||
const cmd = createGetCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'user']);
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('users', undefined);
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('users', undefined, undefined);
|
||||
});
|
||||
|
||||
it('lists groups with correct columns', async () => {
|
||||
@@ -126,7 +126,7 @@ describe('get command', () => {
|
||||
const cmd = createGetCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'groups']);
|
||||
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('groups', undefined);
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('groups', undefined, undefined);
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('NAME');
|
||||
expect(text).toContain('MEMBERS');
|
||||
@@ -141,7 +141,7 @@ describe('get command', () => {
|
||||
const deps = makeDeps([]);
|
||||
const cmd = createGetCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'group']);
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('groups', undefined);
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('groups', undefined, undefined);
|
||||
});
|
||||
|
||||
it('lists rbac definitions with correct columns', async () => {
|
||||
@@ -156,7 +156,7 @@ describe('get command', () => {
|
||||
const cmd = createGetCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'rbac']);
|
||||
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('rbac', undefined);
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('rbac', undefined, undefined);
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('NAME');
|
||||
expect(text).toContain('SUBJECTS');
|
||||
@@ -170,7 +170,7 @@ describe('get command', () => {
|
||||
const deps = makeDeps([]);
|
||||
const cmd = createGetCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'rbac-definition']);
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('rbac', undefined);
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('rbac', undefined, undefined);
|
||||
});
|
||||
|
||||
it('lists projects with new columns', async () => {
|
||||
@@ -251,4 +251,87 @@ describe('get command', () => {
|
||||
await cmd.parseAsync(['node', 'test', 'rbac']);
|
||||
expect(deps.output[0]).toContain('No rbac found');
|
||||
});
|
||||
|
||||
it('lists prompts with project name column', async () => {
|
||||
const deps = makeDeps([
|
||||
{ id: 'p-1', name: 'debug-guide', projectId: 'proj-1', project: { name: 'smart-home' }, createdAt: '2025-01-01T00:00:00Z' },
|
||||
{ id: 'p-2', name: 'global-rules', projectId: null, project: null, createdAt: '2025-01-01T00:00:00Z' },
|
||||
]);
|
||||
const cmd = createGetCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'prompts']);
|
||||
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('NAME');
|
||||
expect(text).toContain('PROJECT');
|
||||
expect(text).toContain('debug-guide');
|
||||
expect(text).toContain('smart-home');
|
||||
expect(text).toContain('global-rules');
|
||||
expect(text).toContain('(global)');
|
||||
});
|
||||
|
||||
it('lists promptrequests with project name column', async () => {
|
||||
const deps = makeDeps([
|
||||
{ id: 'pr-1', name: 'new-rule', projectId: 'proj-1', project: { name: 'my-project' }, createdBySession: 'sess-abc123def456', createdAt: '2025-01-01T00:00:00Z' },
|
||||
]);
|
||||
const cmd = createGetCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'promptrequests']);
|
||||
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('new-rule');
|
||||
expect(text).toContain('my-project');
|
||||
expect(text).toContain('sess-abc123d');
|
||||
});
|
||||
|
||||
it('passes --project option to fetchResource', async () => {
|
||||
const deps = makeDeps([]);
|
||||
const cmd = createGetCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'prompts', '--project', 'smart-home']);
|
||||
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('prompts', undefined, { project: 'smart-home' });
|
||||
});
|
||||
|
||||
it('does not pass project when --project is not specified', async () => {
|
||||
const deps = makeDeps([]);
|
||||
const cmd = createGetCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'prompts']);
|
||||
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('prompts', undefined, undefined);
|
||||
});
|
||||
|
||||
it('passes --all flag to fetchResource', async () => {
|
||||
const deps = makeDeps([]);
|
||||
const cmd = createGetCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'prompts', '-A']);
|
||||
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('prompts', undefined, { all: true });
|
||||
});
|
||||
|
||||
it('passes both --project and --all when both given', async () => {
|
||||
const deps = makeDeps([]);
|
||||
const cmd = createGetCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'prompts', '--project', 'my-proj', '-A']);
|
||||
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('prompts', undefined, { project: 'my-proj', all: true });
|
||||
});
|
||||
|
||||
it('resolves prompt alias', async () => {
|
||||
const deps = makeDeps([]);
|
||||
const cmd = createGetCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'prompt']);
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('prompts', undefined, undefined);
|
||||
});
|
||||
|
||||
it('resolves pr alias to promptrequests', async () => {
|
||||
const deps = makeDeps([]);
|
||||
const cmd = createGetCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'pr']);
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('promptrequests', undefined, undefined);
|
||||
});
|
||||
|
||||
it('shows no results message for empty prompts list', async () => {
|
||||
const deps = makeDeps([]);
|
||||
const cmd = createGetCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'prompts']);
|
||||
expect(deps.output[0]).toContain('No prompts found');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('CLI command registration (e2e)', () => {
|
||||
expect(subcommands).toContain('reset');
|
||||
});
|
||||
|
||||
it('create command has user, group, rbac subcommands', () => {
|
||||
it('create command has user, group, rbac, prompt, promptrequest subcommands', () => {
|
||||
const program = createProgram();
|
||||
const create = program.commands.find((c) => c.name() === 'create');
|
||||
expect(create).toBeDefined();
|
||||
@@ -59,6 +59,24 @@ describe('CLI command registration (e2e)', () => {
|
||||
expect(subcommands).toContain('user');
|
||||
expect(subcommands).toContain('group');
|
||||
expect(subcommands).toContain('rbac');
|
||||
expect(subcommands).toContain('prompt');
|
||||
expect(subcommands).toContain('promptrequest');
|
||||
});
|
||||
|
||||
it('get command accepts --project option', () => {
|
||||
const program = createProgram();
|
||||
const get = program.commands.find((c) => c.name() === 'get');
|
||||
expect(get).toBeDefined();
|
||||
|
||||
const projectOpt = get!.options.find((o) => o.long === '--project');
|
||||
expect(projectOpt).toBeDefined();
|
||||
expect(projectOpt!.description).toContain('project');
|
||||
});
|
||||
|
||||
it('program-level --project option is defined', () => {
|
||||
const program = createProgram();
|
||||
const projectOpt = program.options.find((o) => o.long === '--project');
|
||||
expect(projectOpt).toBeDefined();
|
||||
});
|
||||
|
||||
it('displays version', () => {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- AlterTable: Add gated flag to Project
|
||||
ALTER TABLE "Project" ADD COLUMN "gated" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- AlterTable: Add priority, summary, chapters, linkTarget to Prompt
|
||||
ALTER TABLE "Prompt" ADD COLUMN "priority" INTEGER NOT NULL DEFAULT 5;
|
||||
ALTER TABLE "Prompt" ADD COLUMN "summary" TEXT;
|
||||
ALTER TABLE "Prompt" ADD COLUMN "chapters" JSONB;
|
||||
ALTER TABLE "Prompt" ADD COLUMN "linkTarget" TEXT;
|
||||
|
||||
-- AlterTable: Add priority to PromptRequest
|
||||
ALTER TABLE "PromptRequest" ADD COLUMN "priority" INTEGER NOT NULL DEFAULT 5;
|
||||
@@ -172,6 +172,7 @@ model Project {
|
||||
description String @default("")
|
||||
prompt String @default("")
|
||||
proxyMode String @default("direct")
|
||||
gated Boolean @default(true)
|
||||
llmProvider String?
|
||||
llmModel String?
|
||||
ownerId String
|
||||
@@ -233,13 +234,17 @@ enum InstanceStatus {
|
||||
// ── Prompts (approved content resources) ──
|
||||
|
||||
model Prompt {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
content String @db.Text
|
||||
projectId String?
|
||||
version Int @default(1)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
content String @db.Text
|
||||
projectId String?
|
||||
priority Int @default(5)
|
||||
summary String? @db.Text
|
||||
chapters Json?
|
||||
linkTarget String?
|
||||
version Int @default(1)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -254,6 +259,7 @@ model PromptRequest {
|
||||
name String
|
||||
content String @db.Text
|
||||
projectId String?
|
||||
priority Int @default(5)
|
||||
createdBySession String?
|
||||
createdByUserId String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
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']);
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,7 @@ export async function refreshProjectUpstreams(
|
||||
export interface ProjectLlmConfig {
|
||||
llmProvider?: string;
|
||||
llmModel?: string;
|
||||
gated?: boolean;
|
||||
}
|
||||
|
||||
export async function fetchProjectLlmConfig(
|
||||
@@ -65,10 +66,12 @@ export async function fetchProjectLlmConfig(
|
||||
const project = await mcpdClient.get<{
|
||||
llmProvider?: string;
|
||||
llmModel?: string;
|
||||
gated?: boolean;
|
||||
}>(`/api/v1/projects/${encodeURIComponent(projectName)}`);
|
||||
const config: ProjectLlmConfig = {};
|
||||
if (project.llmProvider) config.llmProvider = project.llmProvider;
|
||||
if (project.llmModel) config.llmModel = project.llmModel;
|
||||
if (project.gated !== undefined) config.gated = project.gated;
|
||||
return config;
|
||||
} catch {
|
||||
return {};
|
||||
|
||||
81
src/mcplocal/src/gate/llm-selector.ts
Normal file
81
src/mcplocal/src/gate/llm-selector.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* LLM-based prompt selection for the gating flow.
|
||||
*
|
||||
* Sends tags + prompt index to the heavy LLM, which returns
|
||||
* a ranked list of relevant prompt names.
|
||||
*/
|
||||
|
||||
import type { ProviderRegistry } from '../providers/registry.js';
|
||||
|
||||
export interface PromptIndexForLlm {
|
||||
name: string;
|
||||
priority: number;
|
||||
summary: string | null;
|
||||
chapters: string[] | null;
|
||||
}
|
||||
|
||||
export interface LlmSelectionResult {
|
||||
selectedNames: string[];
|
||||
reasoning: string;
|
||||
}
|
||||
|
||||
export class LlmPromptSelector {
|
||||
constructor(
|
||||
private readonly providerRegistry: ProviderRegistry,
|
||||
private readonly modelOverride?: string,
|
||||
) {}
|
||||
|
||||
async selectPrompts(
|
||||
tags: string[],
|
||||
promptIndex: PromptIndexForLlm[],
|
||||
): Promise<LlmSelectionResult> {
|
||||
const systemPrompt = `You are a context selection assistant. Given a developer's task keywords and a list of available project prompts, select which prompts are relevant to their work. Return a JSON object with "selectedNames" (array of prompt names) and "reasoning" (brief explanation). Priority 10 prompts must always be included.`;
|
||||
|
||||
const userPrompt = `Task keywords: ${tags.join(', ')}
|
||||
|
||||
Available prompts:
|
||||
${promptIndex.map((p) => `- ${p.name} (priority: ${p.priority}): ${p.summary ?? 'No summary'}${p.chapters?.length ? `\n Chapters: ${p.chapters.join(', ')}` : ''}`).join('\n')}
|
||||
|
||||
Select the relevant prompts. Return JSON: { "selectedNames": [...], "reasoning": "..." }`;
|
||||
|
||||
const provider = this.providerRegistry.getProvider('heavy');
|
||||
if (!provider) {
|
||||
throw new Error('No heavy LLM provider available');
|
||||
}
|
||||
|
||||
const completionOptions: import('../providers/types.js').CompletionOptions = {
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
temperature: 0,
|
||||
maxTokens: 1024,
|
||||
};
|
||||
if (this.modelOverride) {
|
||||
completionOptions.model = this.modelOverride;
|
||||
}
|
||||
|
||||
const result = await provider.complete(completionOptions);
|
||||
|
||||
const response = result.content;
|
||||
|
||||
// Parse JSON from response (may be wrapped in markdown code blocks)
|
||||
const jsonMatch = response.match(/\{[\s\S]*"selectedNames"[\s\S]*\}/);
|
||||
if (!jsonMatch) {
|
||||
throw new Error('LLM response did not contain valid selection JSON');
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(jsonMatch[0]) as { selectedNames?: string[]; reasoning?: string };
|
||||
const selectedNames = parsed.selectedNames ?? [];
|
||||
const reasoning = parsed.reasoning ?? '';
|
||||
|
||||
// Always include priority 10 prompts
|
||||
for (const p of promptIndex) {
|
||||
if (p.priority === 10 && !selectedNames.includes(p.name)) {
|
||||
selectedNames.push(p.name);
|
||||
}
|
||||
}
|
||||
|
||||
return { selectedNames, reasoning };
|
||||
}
|
||||
}
|
||||
76
src/mcplocal/src/gate/session-gate.ts
Normal file
76
src/mcplocal/src/gate/session-gate.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Per-session gating state machine.
|
||||
*
|
||||
* Tracks whether a session has gone through the prompt selection flow.
|
||||
* When gated, only begin_session is accessible. After ungating, all tools work.
|
||||
*/
|
||||
|
||||
import type { PromptIndexEntry, TagMatchResult } from './tag-matcher.js';
|
||||
|
||||
export interface SessionState {
|
||||
gated: boolean;
|
||||
tags: string[];
|
||||
retrievedPrompts: Set<string>;
|
||||
briefing: string | null;
|
||||
}
|
||||
|
||||
export class SessionGate {
|
||||
private sessions = new Map<string, SessionState>();
|
||||
|
||||
/** Create a new session. Starts gated if the project is gated. */
|
||||
createSession(sessionId: string, projectGated: boolean): void {
|
||||
this.sessions.set(sessionId, {
|
||||
gated: projectGated,
|
||||
tags: [],
|
||||
retrievedPrompts: new Set(),
|
||||
briefing: null,
|
||||
});
|
||||
}
|
||||
|
||||
/** Get session state. Returns null if session doesn't exist. */
|
||||
getSession(sessionId: string): SessionState | null {
|
||||
return this.sessions.get(sessionId) ?? null;
|
||||
}
|
||||
|
||||
/** Check if a session is currently gated. Unknown sessions are treated as ungated. */
|
||||
isGated(sessionId: string): boolean {
|
||||
return this.sessions.get(sessionId)?.gated ?? false;
|
||||
}
|
||||
|
||||
/** Ungate a session after prompt selection is complete. */
|
||||
ungate(sessionId: string, tags: string[], matchResult: TagMatchResult): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
session.gated = false;
|
||||
session.tags = [...session.tags, ...tags];
|
||||
|
||||
// Track which prompts have been sent
|
||||
for (const p of matchResult.fullContent) {
|
||||
session.retrievedPrompts.add(p.name);
|
||||
}
|
||||
}
|
||||
|
||||
/** Record additional prompts retrieved via read_prompts. */
|
||||
addRetrievedPrompts(sessionId: string, tags: string[], promptNames: string[]): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
session.tags = [...session.tags, ...tags];
|
||||
for (const name of promptNames) {
|
||||
session.retrievedPrompts.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
/** Filter out prompts already sent to avoid duplicates. */
|
||||
filterAlreadySent(sessionId: string, prompts: PromptIndexEntry[]): PromptIndexEntry[] {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return prompts;
|
||||
return prompts.filter((p) => !session.retrievedPrompts.has(p.name));
|
||||
}
|
||||
|
||||
/** Remove a session (cleanup on disconnect). */
|
||||
removeSession(sessionId: string): void {
|
||||
this.sessions.delete(sessionId);
|
||||
}
|
||||
}
|
||||
109
src/mcplocal/src/gate/tag-matcher.ts
Normal file
109
src/mcplocal/src/gate/tag-matcher.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Deterministic keyword-based tag matching for prompt selection.
|
||||
*
|
||||
* Used as the no-LLM fallback (and for read_prompts in hybrid mode).
|
||||
* Scores prompts by tag overlap * priority, then fills a byte budget.
|
||||
*/
|
||||
|
||||
export interface PromptIndexEntry {
|
||||
name: string;
|
||||
priority: number;
|
||||
summary: string | null;
|
||||
chapters: string[] | null;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface TagMatchResult {
|
||||
/** Prompts with full content included (within byte budget) */
|
||||
fullContent: PromptIndexEntry[];
|
||||
/** Matched prompts beyond byte budget — name + summary only */
|
||||
indexOnly: PromptIndexEntry[];
|
||||
/** Non-matched prompts — listed for awareness */
|
||||
remaining: PromptIndexEntry[];
|
||||
}
|
||||
|
||||
const DEFAULT_BYTE_BUDGET = 8192;
|
||||
|
||||
export class TagMatcher {
|
||||
constructor(private readonly byteBudget: number = DEFAULT_BYTE_BUDGET) {}
|
||||
|
||||
match(tags: string[], prompts: PromptIndexEntry[]): TagMatchResult {
|
||||
const lowerTags = tags.map((t) => t.toLowerCase());
|
||||
|
||||
// Score each prompt
|
||||
const scored = prompts.map((p) => ({
|
||||
prompt: p,
|
||||
score: this.computeScore(lowerTags, p),
|
||||
matched: this.computeScore(lowerTags, p) > 0,
|
||||
}));
|
||||
|
||||
// Partition: matched (score > 0) vs non-matched
|
||||
const matched = scored.filter((s) => s.matched).sort((a, b) => b.score - a.score);
|
||||
const nonMatched = scored.filter((s) => !s.matched).map((s) => s.prompt);
|
||||
|
||||
// Fill byte budget from matched prompts
|
||||
let budgetRemaining = this.byteBudget;
|
||||
const fullContent: PromptIndexEntry[] = [];
|
||||
const indexOnly: PromptIndexEntry[] = [];
|
||||
|
||||
for (const { prompt } of matched) {
|
||||
const contentBytes = Buffer.byteLength(prompt.content, 'utf-8');
|
||||
if (budgetRemaining >= contentBytes) {
|
||||
fullContent.push(prompt);
|
||||
budgetRemaining -= contentBytes;
|
||||
} else {
|
||||
indexOnly.push(prompt);
|
||||
}
|
||||
}
|
||||
|
||||
return { fullContent, indexOnly, remaining: nonMatched };
|
||||
}
|
||||
|
||||
private computeScore(lowerTags: string[], prompt: PromptIndexEntry): number {
|
||||
// Priority 10 always included
|
||||
if (prompt.priority === 10) return Infinity;
|
||||
|
||||
if (lowerTags.length === 0) return 0;
|
||||
|
||||
const searchText = [
|
||||
prompt.name,
|
||||
prompt.summary ?? '',
|
||||
...(prompt.chapters ?? []),
|
||||
].join(' ').toLowerCase();
|
||||
|
||||
let matchCount = 0;
|
||||
for (const tag of lowerTags) {
|
||||
if (searchText.includes(tag)) matchCount++;
|
||||
}
|
||||
|
||||
return matchCount * prompt.priority;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract keywords from a tool call for the intercept fallback path.
|
||||
* Pulls words from the tool name and string argument values.
|
||||
*/
|
||||
export function extractKeywordsFromToolCall(
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
): string[] {
|
||||
const keywords = new Set<string>();
|
||||
|
||||
// Tool name parts (split on / and -)
|
||||
for (const part of toolName.split(/[/-]/)) {
|
||||
if (part.length > 2) keywords.add(part.toLowerCase());
|
||||
}
|
||||
|
||||
// String argument values — extract words
|
||||
for (const value of Object.values(args)) {
|
||||
if (typeof value === 'string' && value.length < 200) {
|
||||
for (const word of value.split(/\s+/)) {
|
||||
const clean = word.replace(/[^a-zA-Z0-9-]/g, '').toLowerCase();
|
||||
if (clean.length > 2) keywords.add(clean);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...keywords].slice(0, 10); // Cap at 10 keywords
|
||||
}
|
||||
@@ -52,13 +52,28 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
|
||||
const mcpdConfig = await fetchProjectLlmConfig(mcpdClient, projectName);
|
||||
const resolvedModel = localOverride?.model ?? mcpdConfig.llmModel ?? undefined;
|
||||
|
||||
// If project llmProvider is "none", disable LLM for this project
|
||||
const llmDisabled = mcpdConfig.llmProvider === 'none' || localOverride?.provider === 'none';
|
||||
const effectiveRegistry = llmDisabled ? null : (providerRegistry ?? null);
|
||||
|
||||
// Wire pagination support with LLM provider and project model override
|
||||
router.setPaginator(new ResponsePaginator(providerRegistry ?? null, {}, resolvedModel));
|
||||
router.setPaginator(new ResponsePaginator(effectiveRegistry, {}, resolvedModel));
|
||||
|
||||
// Configure prompt resources with SA-scoped client for RBAC
|
||||
const saClient = mcpdClient.withHeaders({ 'X-Service-Account': `project:${projectName}` });
|
||||
router.setPromptConfig(saClient, projectName);
|
||||
|
||||
// Configure gating if project has it enabled (default: true)
|
||||
const isGated = mcpdConfig.gated !== false;
|
||||
const gateConfig: import('../router.js').GateConfig = {
|
||||
gated: isGated,
|
||||
providerRegistry: effectiveRegistry,
|
||||
};
|
||||
if (resolvedModel) {
|
||||
gateConfig.modelOverride = resolvedModel;
|
||||
}
|
||||
router.setGateConfig(gateConfig);
|
||||
|
||||
// Fetch project instructions and set on router
|
||||
try {
|
||||
const instructions = await mcpdClient.get<{ prompt: string; servers: Array<{ name: string; description: string }> }>(
|
||||
@@ -131,6 +146,7 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
|
||||
const id = transport.sessionId;
|
||||
if (id) {
|
||||
sessions.delete(id);
|
||||
router.cleanupSession(id);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -2,11 +2,23 @@ import type { UpstreamConnection, JsonRpcRequest, JsonRpcResponse, JsonRpcNotifi
|
||||
import type { LlmProcessor } from './llm/processor.js';
|
||||
import { ResponsePaginator } from './llm/pagination.js';
|
||||
import type { McpdClient } from './http/mcpd-client.js';
|
||||
import { SessionGate } from './gate/session-gate.js';
|
||||
import { TagMatcher, extractKeywordsFromToolCall } from './gate/tag-matcher.js';
|
||||
import type { PromptIndexEntry, TagMatchResult } from './gate/tag-matcher.js';
|
||||
import { LlmPromptSelector } from './gate/llm-selector.js';
|
||||
import type { ProviderRegistry } from './providers/registry.js';
|
||||
|
||||
export interface RouteContext {
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
export interface GateConfig {
|
||||
gated: boolean;
|
||||
providerRegistry: ProviderRegistry | null;
|
||||
modelOverride?: string;
|
||||
byteBudget?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes MCP requests to the appropriate upstream server.
|
||||
*
|
||||
@@ -28,11 +40,28 @@ export class McpRouter {
|
||||
private projectName: string | null = null;
|
||||
private mcpctlResourceContents = new Map<string, string>();
|
||||
private paginator: ResponsePaginator | null = null;
|
||||
private sessionGate = new SessionGate();
|
||||
private gateConfig: GateConfig | null = null;
|
||||
private tagMatcher: TagMatcher | null = null;
|
||||
private llmSelector: LlmPromptSelector | null = null;
|
||||
private cachedPromptIndex: PromptIndexEntry[] | null = null;
|
||||
private promptIndexFetchedAt = 0;
|
||||
private readonly PROMPT_INDEX_TTL_MS = 60_000;
|
||||
private systemPromptCache = new Map<string, { content: string; fetchedAt: number }>();
|
||||
private readonly SYSTEM_PROMPT_TTL_MS = 300_000; // 5 minutes
|
||||
|
||||
setPaginator(paginator: ResponsePaginator): void {
|
||||
this.paginator = paginator;
|
||||
}
|
||||
|
||||
setGateConfig(config: GateConfig): void {
|
||||
this.gateConfig = config;
|
||||
this.tagMatcher = new TagMatcher(config.byteBudget);
|
||||
if (config.providerRegistry) {
|
||||
this.llmSelector = new LlmPromptSelector(config.providerRegistry, config.modelOverride);
|
||||
}
|
||||
}
|
||||
|
||||
setLlmProcessor(processor: LlmProcessor): void {
|
||||
this.llmProcessor = processor;
|
||||
}
|
||||
@@ -257,28 +286,50 @@ export class McpRouter {
|
||||
*/
|
||||
async route(request: JsonRpcRequest, context?: RouteContext): Promise<JsonRpcResponse> {
|
||||
switch (request.method) {
|
||||
case 'initialize':
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id: request.id,
|
||||
result: {
|
||||
protocolVersion: '2024-11-05',
|
||||
serverInfo: {
|
||||
name: 'mcpctl-proxy',
|
||||
version: '0.1.0',
|
||||
},
|
||||
capabilities: {
|
||||
tools: {},
|
||||
resources: {},
|
||||
prompts: {},
|
||||
},
|
||||
...(this.instructions ? { instructions: this.instructions } : {}),
|
||||
case 'initialize': {
|
||||
// Create gated session if project is gated
|
||||
const isGated = this.gateConfig?.gated ?? false;
|
||||
if (context?.sessionId && this.gateConfig) {
|
||||
this.sessionGate.createSession(context.sessionId, isGated);
|
||||
}
|
||||
|
||||
// Build instructions: base project instructions + gate message with prompt index
|
||||
let instructions = this.instructions ?? '';
|
||||
if (isGated) {
|
||||
instructions = await this.buildGatedInstructions(instructions);
|
||||
}
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
protocolVersion: '2024-11-05',
|
||||
serverInfo: {
|
||||
name: 'mcpctl-proxy',
|
||||
version: '0.1.0',
|
||||
},
|
||||
capabilities: {
|
||||
tools: {},
|
||||
resources: {},
|
||||
prompts: {},
|
||||
},
|
||||
};
|
||||
if (instructions) {
|
||||
result['instructions'] = instructions;
|
||||
}
|
||||
|
||||
return { jsonrpc: '2.0', id: request.id, result };
|
||||
}
|
||||
|
||||
case 'tools/list': {
|
||||
// When gated, only show begin_session
|
||||
if (context?.sessionId && this.sessionGate.isGated(context.sessionId)) {
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id: request.id,
|
||||
result: { tools: [this.getBeginSessionTool()] },
|
||||
};
|
||||
}
|
||||
|
||||
const tools = await this.discoverTools();
|
||||
// Append propose_prompt tool if prompt config is set
|
||||
// Append built-in tools if prompt config is set
|
||||
if (this.mcpdClient && this.projectName) {
|
||||
tools.push({
|
||||
name: 'propose_prompt',
|
||||
@@ -293,6 +344,10 @@ export class McpRouter {
|
||||
},
|
||||
});
|
||||
}
|
||||
// Always offer read_prompts when gating is configured (even for ungated sessions)
|
||||
if (this.gateConfig && this.mcpdClient && this.projectName) {
|
||||
tools.push(this.getReadPromptsTool());
|
||||
}
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id: request.id,
|
||||
@@ -337,6 +392,44 @@ export class McpRouter {
|
||||
case 'resources/read': {
|
||||
const params = request.params as Record<string, unknown> | undefined;
|
||||
const uri = params?.['uri'] as string | undefined;
|
||||
if (uri?.startsWith('mcpctl://prompts/') && this.mcpdClient && this.projectName) {
|
||||
const promptName = uri.slice('mcpctl://prompts/'.length);
|
||||
try {
|
||||
const sessionParam = context?.sessionId ? `?session=${encodeURIComponent(context.sessionId)}` : '';
|
||||
const visible = await this.mcpdClient.get<Array<{ name: string; content: string; type: string }>>(
|
||||
`/api/v1/projects/${encodeURIComponent(this.projectName)}/prompts/visible${sessionParam}`,
|
||||
);
|
||||
const found = visible.find((p) => p.name === promptName);
|
||||
if (found) {
|
||||
this.mcpctlResourceContents.set(uri, found.content);
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id: request.id,
|
||||
result: {
|
||||
contents: [{ uri, mimeType: 'text/plain', text: found.content }],
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Fall through to cache
|
||||
}
|
||||
// Fallback to cache if mcpd is unreachable
|
||||
const cached = this.mcpctlResourceContents.get(uri);
|
||||
if (cached !== undefined) {
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id: request.id,
|
||||
result: {
|
||||
contents: [{ uri, mimeType: 'text/plain', text: cached }],
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id: request.id,
|
||||
error: { code: -32602, message: `Resource not found: ${uri}` },
|
||||
};
|
||||
}
|
||||
if (uri?.startsWith('mcpctl://')) {
|
||||
const content = this.mcpctlResourceContents.get(uri);
|
||||
if (content !== undefined) {
|
||||
@@ -400,13 +493,26 @@ export class McpRouter {
|
||||
const params = request.params as Record<string, unknown> | undefined;
|
||||
const toolName = params?.['name'] as string | undefined;
|
||||
|
||||
// Handle built-in propose_prompt tool
|
||||
// Handle built-in tools
|
||||
if (toolName === 'propose_prompt') {
|
||||
return this.handleProposePrompt(request, context);
|
||||
}
|
||||
if (toolName === 'begin_session') {
|
||||
return this.handleBeginSession(request, context);
|
||||
}
|
||||
if (toolName === 'read_prompts') {
|
||||
return this.handleReadPrompts(request, context);
|
||||
}
|
||||
|
||||
// Extract tool arguments early (needed for both gated intercept and pagination)
|
||||
const toolArgs = (params?.['arguments'] ?? {}) as Record<string, unknown>;
|
||||
|
||||
// Intercept: if session is gated and trying to call a real tool, auto-ungate with keyword extraction
|
||||
if (context?.sessionId && this.sessionGate.isGated(context.sessionId)) {
|
||||
return this.handleGatedIntercept(request, context, toolName ?? '', toolArgs);
|
||||
}
|
||||
|
||||
// Intercept pagination page requests before routing to upstream
|
||||
const toolArgs = (params?.['arguments'] ?? {}) as Record<string, unknown>;
|
||||
if (this.paginator) {
|
||||
const paginationReq = ResponsePaginator.extractPaginationParams(toolArgs);
|
||||
if (paginationReq) {
|
||||
@@ -525,6 +631,417 @@ export class McpRouter {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Gate tool definitions ──
|
||||
|
||||
private getBeginSessionTool(): { name: string; description: string; inputSchema: unknown } {
|
||||
return {
|
||||
name: 'begin_session',
|
||||
description: 'Start your session by providing keywords that describe your current task. You will receive relevant project context, policies, and guidelines. This is required before using other tools.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tags: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
maxItems: 10,
|
||||
description: '3-7 keywords describing your current task (e.g. ["zigbee", "pairing", "mqtt"])',
|
||||
},
|
||||
},
|
||||
required: ['tags'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private getReadPromptsTool(): { name: string; description: string; inputSchema: unknown } {
|
||||
return {
|
||||
name: 'read_prompts',
|
||||
description: 'Retrieve additional project prompts by keywords. Use this if you need more context about specific topics. Returns matched prompts and a list of other available prompts.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tags: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
maxItems: 10,
|
||||
description: 'Keywords to match against available prompts',
|
||||
},
|
||||
},
|
||||
required: ['tags'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Gate handlers ──
|
||||
|
||||
private async handleBeginSession(request: JsonRpcRequest, context?: RouteContext): Promise<JsonRpcResponse> {
|
||||
if (!this.gateConfig || !this.mcpdClient || !this.projectName) {
|
||||
return { jsonrpc: '2.0', id: request.id, error: { code: -32603, message: 'Gating not configured' } };
|
||||
}
|
||||
|
||||
const params = request.params as Record<string, unknown> | undefined;
|
||||
const args = (params?.['arguments'] ?? {}) as Record<string, unknown>;
|
||||
const tags = args['tags'] as string[] | undefined;
|
||||
|
||||
if (!tags || !Array.isArray(tags) || tags.length === 0) {
|
||||
return { jsonrpc: '2.0', id: request.id, error: { code: -32602, message: 'Missing or empty tags array' } };
|
||||
}
|
||||
|
||||
const sessionId = context?.sessionId;
|
||||
if (sessionId && !this.sessionGate.isGated(sessionId)) {
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id: request.id,
|
||||
result: {
|
||||
content: [{ type: 'text', text: 'Session already started. Use read_prompts to retrieve additional context.' }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const promptIndex = await this.fetchPromptIndex();
|
||||
|
||||
// Primary: LLM selection. Fallback: deterministic tag matching.
|
||||
let matchResult: TagMatchResult;
|
||||
let reasoning = '';
|
||||
|
||||
if (this.llmSelector) {
|
||||
try {
|
||||
const llmIndex = promptIndex.map((p) => ({
|
||||
name: p.name,
|
||||
priority: p.priority,
|
||||
summary: p.summary,
|
||||
chapters: p.chapters,
|
||||
}));
|
||||
const llmResult = await this.llmSelector.selectPrompts(tags, llmIndex);
|
||||
reasoning = llmResult.reasoning;
|
||||
|
||||
// Convert LLM names back to full PromptIndexEntry results via TagMatcher for byte-budget
|
||||
const selectedSet = new Set(llmResult.selectedNames);
|
||||
const selected = promptIndex.filter((p) => selectedSet.has(p.name));
|
||||
const remaining = promptIndex.filter((p) => !selectedSet.has(p.name));
|
||||
|
||||
// Apply byte budget to the LLM-selected prompts
|
||||
matchResult = this.tagMatcher!.match(
|
||||
// Use all tags + selected names as keywords so everything scores > 0
|
||||
[...tags, ...llmResult.selectedNames],
|
||||
selected,
|
||||
);
|
||||
// Put LLM-unselected in remaining
|
||||
matchResult.remaining = [...matchResult.remaining, ...remaining];
|
||||
} catch {
|
||||
// LLM failed — fall back to keyword matching
|
||||
matchResult = this.tagMatcher!.match(tags, promptIndex);
|
||||
}
|
||||
} else {
|
||||
matchResult = this.tagMatcher!.match(tags, promptIndex);
|
||||
}
|
||||
|
||||
// Ungate the session
|
||||
if (sessionId) {
|
||||
this.sessionGate.ungate(sessionId, tags, matchResult);
|
||||
}
|
||||
|
||||
// Build response
|
||||
const responseParts: string[] = [];
|
||||
|
||||
if (reasoning) {
|
||||
responseParts.push(`Selection reasoning: ${reasoning}\n`);
|
||||
}
|
||||
|
||||
// Full content prompts
|
||||
for (const p of matchResult.fullContent) {
|
||||
responseParts.push(`--- ${p.name} (priority: ${p.priority}) ---\n${p.content}\n`);
|
||||
}
|
||||
|
||||
// Index-only (over budget)
|
||||
if (matchResult.indexOnly.length > 0) {
|
||||
responseParts.push('Additional matched prompts (use read_prompts to retrieve full content):');
|
||||
for (const p of matchResult.indexOnly) {
|
||||
responseParts.push(` - ${p.name}: ${p.summary ?? 'No description'}`);
|
||||
}
|
||||
responseParts.push('');
|
||||
}
|
||||
|
||||
// Remaining prompts for awareness
|
||||
if (matchResult.remaining.length > 0) {
|
||||
responseParts.push('Other available prompts:');
|
||||
for (const p of matchResult.remaining) {
|
||||
responseParts.push(` - ${p.name}: ${p.summary ?? 'No description'}`);
|
||||
}
|
||||
responseParts.push('');
|
||||
}
|
||||
|
||||
// Encouragement (from system prompt or fallback)
|
||||
const encouragement = await this.getSystemPrompt(
|
||||
'gate-encouragement',
|
||||
'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.',
|
||||
);
|
||||
responseParts.push(encouragement);
|
||||
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id: request.id,
|
||||
result: {
|
||||
content: [{ type: 'text', text: responseParts.join('\n') }],
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id: request.id,
|
||||
error: { code: -32603, message: `begin_session failed: ${err instanceof Error ? err.message : String(err)}` },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async handleReadPrompts(request: JsonRpcRequest, context?: RouteContext): Promise<JsonRpcResponse> {
|
||||
if (!this.tagMatcher || !this.mcpdClient || !this.projectName) {
|
||||
return { jsonrpc: '2.0', id: request.id, error: { code: -32603, message: 'Prompt retrieval not configured' } };
|
||||
}
|
||||
|
||||
const params = request.params as Record<string, unknown> | undefined;
|
||||
const args = (params?.['arguments'] ?? {}) as Record<string, unknown>;
|
||||
const tags = args['tags'] as string[] | undefined;
|
||||
|
||||
if (!tags || !Array.isArray(tags) || tags.length === 0) {
|
||||
return { jsonrpc: '2.0', id: request.id, error: { code: -32602, message: 'Missing or empty tags array' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const promptIndex = await this.fetchPromptIndex();
|
||||
const sessionId = context?.sessionId;
|
||||
|
||||
// Filter out already-sent prompts
|
||||
const available = sessionId ? this.sessionGate.filterAlreadySent(sessionId, promptIndex) : promptIndex;
|
||||
|
||||
// Always use deterministic tag matching for read_prompts (hybrid mode)
|
||||
const matchResult = this.tagMatcher.match(tags, available);
|
||||
|
||||
// Record retrieved prompts
|
||||
if (sessionId) {
|
||||
this.sessionGate.addRetrievedPrompts(
|
||||
sessionId,
|
||||
tags,
|
||||
matchResult.fullContent.map((p) => p.name),
|
||||
);
|
||||
}
|
||||
|
||||
if (matchResult.fullContent.length === 0 && matchResult.indexOnly.length === 0) {
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id: request.id,
|
||||
result: {
|
||||
content: [{ type: 'text', text: 'No new matching prompts found for the given keywords.' }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const responseParts: string[] = [];
|
||||
|
||||
for (const p of matchResult.fullContent) {
|
||||
responseParts.push(`--- ${p.name} (priority: ${p.priority}) ---\n${p.content}\n`);
|
||||
}
|
||||
|
||||
if (matchResult.indexOnly.length > 0) {
|
||||
responseParts.push('Additional matched prompts (too large to include, try more specific keywords):');
|
||||
for (const p of matchResult.indexOnly) {
|
||||
responseParts.push(` - ${p.name}: ${p.summary ?? 'No description'}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id: request.id,
|
||||
result: {
|
||||
content: [{ type: 'text', text: responseParts.join('\n') }],
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id: request.id,
|
||||
error: { code: -32603, message: `read_prompts failed: ${err instanceof Error ? err.message : String(err)}` },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercept handler: when a gated session tries to call a real tool,
|
||||
* extract keywords from the tool call, auto-ungate, and prepend a briefing.
|
||||
*/
|
||||
private async handleGatedIntercept(
|
||||
request: JsonRpcRequest,
|
||||
context: RouteContext,
|
||||
toolName: string,
|
||||
toolArgs: Record<string, unknown>,
|
||||
): Promise<JsonRpcResponse> {
|
||||
const sessionId = context.sessionId!;
|
||||
|
||||
// Extract keywords from the tool call as a fallback
|
||||
const tags = extractKeywordsFromToolCall(toolName, toolArgs);
|
||||
|
||||
try {
|
||||
const promptIndex = await this.fetchPromptIndex();
|
||||
const matchResult = this.tagMatcher!.match(tags, promptIndex);
|
||||
|
||||
// Ungate the session
|
||||
this.sessionGate.ungate(sessionId, tags, matchResult);
|
||||
|
||||
// Build briefing from matched content
|
||||
const briefingParts: string[] = [];
|
||||
if (matchResult.fullContent.length > 0) {
|
||||
const preamble = await this.getSystemPrompt(
|
||||
'gate-intercept-preamble',
|
||||
'The following project context was automatically retrieved based on your tool call.',
|
||||
);
|
||||
briefingParts.push(`--- ${preamble} ---\n`);
|
||||
for (const p of matchResult.fullContent) {
|
||||
briefingParts.push(`--- ${p.name} (priority: ${p.priority}) ---\n${p.content}\n`);
|
||||
}
|
||||
briefingParts.push('--- End of project context ---\n');
|
||||
}
|
||||
|
||||
if (matchResult.remaining.length > 0 || matchResult.indexOnly.length > 0) {
|
||||
briefingParts.push('Other prompts available (use read_prompts to retrieve):');
|
||||
for (const p of [...matchResult.indexOnly, ...matchResult.remaining]) {
|
||||
briefingParts.push(` - ${p.name}: ${p.summary ?? 'No description'}`);
|
||||
}
|
||||
briefingParts.push('');
|
||||
}
|
||||
|
||||
// Now route the actual tool call
|
||||
const response = await this.routeNamespacedCall(request, 'name', this.toolToServer);
|
||||
const paginatedResponse = await this.maybePaginate(toolName, response);
|
||||
|
||||
// Prepend briefing to the response
|
||||
if (briefingParts.length > 0 && paginatedResponse.result && !paginatedResponse.error) {
|
||||
const result = paginatedResponse.result as { content?: Array<{ type: string; text: string }> };
|
||||
const briefing = briefingParts.join('\n');
|
||||
if (result.content && Array.isArray(result.content)) {
|
||||
result.content.unshift({ type: 'text', text: briefing });
|
||||
} else {
|
||||
(paginatedResponse.result as Record<string, unknown>)['_briefing'] = briefing;
|
||||
}
|
||||
}
|
||||
|
||||
return paginatedResponse;
|
||||
} catch {
|
||||
// If prompt retrieval fails, just ungate and route normally
|
||||
this.sessionGate.ungate(sessionId, tags, { fullContent: [], indexOnly: [], remaining: [] });
|
||||
return this.routeNamespacedCall(request, 'name', this.toolToServer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch prompt index from mcpd with caching.
|
||||
*/
|
||||
private async fetchPromptIndex(): Promise<PromptIndexEntry[]> {
|
||||
const now = Date.now();
|
||||
if (this.cachedPromptIndex && (now - this.promptIndexFetchedAt) < this.PROMPT_INDEX_TTL_MS) {
|
||||
return this.cachedPromptIndex;
|
||||
}
|
||||
|
||||
if (!this.mcpdClient || !this.projectName) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const index = await this.mcpdClient.get<Array<{
|
||||
name: string;
|
||||
priority: number;
|
||||
summary: string | null;
|
||||
chapters: string[] | null;
|
||||
content?: string;
|
||||
}>>(
|
||||
`/api/v1/projects/${encodeURIComponent(this.projectName)}/prompts/visible`,
|
||||
);
|
||||
|
||||
this.cachedPromptIndex = index.map((p) => ({
|
||||
name: p.name,
|
||||
priority: p.priority,
|
||||
summary: p.summary,
|
||||
chapters: p.chapters,
|
||||
content: p.content ?? '',
|
||||
}));
|
||||
this.promptIndexFetchedAt = now;
|
||||
return this.cachedPromptIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build instructions for gated projects: base instructions + gate message + prompt index.
|
||||
*/
|
||||
private async buildGatedInstructions(baseInstructions: string): Promise<string> {
|
||||
const parts: string[] = [];
|
||||
if (baseInstructions) {
|
||||
parts.push(baseInstructions);
|
||||
}
|
||||
|
||||
const gateInstructions = await this.getSystemPrompt(
|
||||
'gate-instructions',
|
||||
'IMPORTANT: This project uses a gated session. You must call begin_session with keywords describing your task before using any other tools. This will provide you with relevant project context, policies, and guidelines.',
|
||||
);
|
||||
parts.push(`\n${gateInstructions}`);
|
||||
|
||||
// Append compact prompt index so the LLM knows what's available
|
||||
try {
|
||||
const promptIndex = await this.fetchPromptIndex();
|
||||
if (promptIndex.length > 0) {
|
||||
// Cap at 50 entries; if over 50, show priority 7+ only
|
||||
let displayIndex = promptIndex;
|
||||
if (displayIndex.length > 50) {
|
||||
displayIndex = displayIndex.filter((p) => p.priority >= 7);
|
||||
}
|
||||
// Sort by priority descending
|
||||
displayIndex.sort((a, b) => b.priority - a.priority);
|
||||
|
||||
parts.push('\nAvailable project prompts:');
|
||||
for (const p of displayIndex) {
|
||||
const summary = p.summary ? `: ${p.summary}` : '';
|
||||
parts.push(`- ${p.name} (priority ${p.priority})${summary}`);
|
||||
}
|
||||
parts.push(
|
||||
'\nChoose your begin_session keywords based on which of these prompts seem relevant to your task.',
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Prompt index is optional — don't fail initialization
|
||||
}
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a system prompt from mcpctl-system project, with caching and fallback.
|
||||
*/
|
||||
private async getSystemPrompt(name: string, fallback: string): Promise<string> {
|
||||
const now = Date.now();
|
||||
const cached = this.systemPromptCache.get(name);
|
||||
if (cached && (now - cached.fetchedAt) < this.SYSTEM_PROMPT_TTL_MS) {
|
||||
return cached.content;
|
||||
}
|
||||
|
||||
if (!this.mcpdClient) return fallback;
|
||||
|
||||
try {
|
||||
const visible = await this.mcpdClient.get<Array<{ name: string; content: string }>>(
|
||||
'/api/v1/projects/mcpctl-system/prompts/visible',
|
||||
);
|
||||
// Cache all system prompts from the response
|
||||
for (const p of visible) {
|
||||
this.systemPromptCache.set(p.name, { content: p.content, fetchedAt: now });
|
||||
}
|
||||
const found = visible.find((p) => p.name === name);
|
||||
return found?.content ?? fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Session cleanup ──
|
||||
|
||||
cleanupSession(sessionId: string): void {
|
||||
this.sessionGate.removeSession(sessionId);
|
||||
}
|
||||
|
||||
getUpstreamNames(): string[] {
|
||||
return [...this.upstreams.keys()];
|
||||
}
|
||||
|
||||
133
src/mcplocal/src/services/link-resolver.ts
Normal file
133
src/mcplocal/src/services/link-resolver.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import type { McpdClient } from '../http/mcpd-client.js';
|
||||
|
||||
export interface LinkResolution {
|
||||
content: string | null;
|
||||
status: 'alive' | 'dead' | 'unknown';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
resolution: LinkResolution;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface ParsedLink {
|
||||
project: string;
|
||||
server: string;
|
||||
uri: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves prompt links by fetching MCP resources from source projects via mcpd.
|
||||
* Link format: project/server:resource-uri
|
||||
*/
|
||||
export class LinkResolver {
|
||||
private cache = new Map<string, CacheEntry>();
|
||||
|
||||
constructor(
|
||||
private readonly mcpdClient: McpdClient,
|
||||
private readonly cacheTtlMs = 5 * 60 * 1000, // 5 minutes
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Parse a link target string into its components.
|
||||
* Format: project/server:resource-uri
|
||||
*/
|
||||
parseLink(linkTarget: string): ParsedLink {
|
||||
const slashIdx = linkTarget.indexOf('/');
|
||||
if (slashIdx < 1) throw new Error(`Invalid link format (missing project): ${linkTarget}`);
|
||||
|
||||
const project = linkTarget.slice(0, slashIdx);
|
||||
const rest = linkTarget.slice(slashIdx + 1);
|
||||
|
||||
const colonIdx = rest.indexOf(':');
|
||||
if (colonIdx < 1) throw new Error(`Invalid link format (missing server:uri): ${linkTarget}`);
|
||||
|
||||
const server = rest.slice(0, colonIdx);
|
||||
const uri = rest.slice(colonIdx + 1);
|
||||
if (!uri) throw new Error(`Invalid link format (empty uri): ${linkTarget}`);
|
||||
|
||||
return { project, server, uri };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a link target and return the fetched content + status.
|
||||
* Results are cached with a configurable TTL.
|
||||
*/
|
||||
async resolve(linkTarget: string): Promise<LinkResolution> {
|
||||
// Check cache first
|
||||
const cached = this.cache.get(linkTarget);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.resolution;
|
||||
}
|
||||
|
||||
let resolution: LinkResolution;
|
||||
try {
|
||||
const { project, server, uri } = this.parseLink(linkTarget);
|
||||
const content = await this.fetchResource(project, server, uri);
|
||||
resolution = { content, status: 'alive' };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[link-resolver] Dead link: ${linkTarget} — ${message}`);
|
||||
resolution = { content: null, status: 'dead', error: message };
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
this.cache.set(linkTarget, {
|
||||
resolution,
|
||||
expiresAt: Date.now() + this.cacheTtlMs,
|
||||
});
|
||||
|
||||
return resolution;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check link health without returning full content (uses cache if available).
|
||||
*/
|
||||
async checkHealth(linkTarget: string): Promise<'alive' | 'dead' | 'unknown'> {
|
||||
const cached = this.cache.get(linkTarget);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.resolution.status;
|
||||
}
|
||||
// Don't do a full resolve just for health — return unknown
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/** Clear all cached resolutions. */
|
||||
clearCache(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
private async fetchResource(project: string, server: string, uri: string): Promise<string> {
|
||||
// Step 1: Resolve server name → server ID from the project's servers
|
||||
const servers = await this.mcpdClient.get<Array<{ id: string; name: string }>>(
|
||||
`/api/v1/projects/${encodeURIComponent(project)}/servers`,
|
||||
);
|
||||
const target = servers.find((s) => s.name === server);
|
||||
if (!target) {
|
||||
throw new Error(`Server '${server}' not found in project '${project}'`);
|
||||
}
|
||||
|
||||
// Step 2: Call resources/read via the MCP proxy
|
||||
const proxyResponse = await this.mcpdClient.post<{
|
||||
result?: { contents?: Array<{ text?: string; uri?: string }> };
|
||||
error?: { code: number; message: string };
|
||||
}>('/api/v1/mcp/proxy', {
|
||||
serverId: target.id,
|
||||
method: 'resources/read',
|
||||
params: { uri },
|
||||
});
|
||||
|
||||
if (proxyResponse.error) {
|
||||
throw new Error(`MCP error: ${proxyResponse.error.message}`);
|
||||
}
|
||||
|
||||
const contents = proxyResponse.result?.contents;
|
||||
if (!contents || contents.length === 0) {
|
||||
throw new Error(`No content returned for resource: ${uri}`);
|
||||
}
|
||||
|
||||
// Concatenate all text contents
|
||||
return contents.map((c) => c.text ?? '').join('\n');
|
||||
}
|
||||
}
|
||||
241
src/mcplocal/tests/link-resolver.test.ts
Normal file
241
src/mcplocal/tests/link-resolver.test.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { LinkResolver } from '../src/services/link-resolver.js';
|
||||
import type { McpdClient } from '../src/http/mcpd-client.js';
|
||||
|
||||
function mockClient(): McpdClient {
|
||||
return {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
forward: vi.fn(),
|
||||
withHeaders: vi.fn(),
|
||||
} as unknown as McpdClient;
|
||||
}
|
||||
|
||||
describe('LinkResolver', () => {
|
||||
let client: McpdClient;
|
||||
let resolver: LinkResolver;
|
||||
|
||||
beforeEach(() => {
|
||||
client = mockClient();
|
||||
resolver = new LinkResolver(client, 1000); // 1s TTL for tests
|
||||
});
|
||||
|
||||
// ── parseLink ──
|
||||
|
||||
describe('parseLink', () => {
|
||||
it('parses valid link target', () => {
|
||||
const result = resolver.parseLink('my-project/docmost-mcp:docmost://pages/abc');
|
||||
expect(result).toEqual({
|
||||
project: 'my-project',
|
||||
server: 'docmost-mcp',
|
||||
uri: 'docmost://pages/abc',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses link with complex URI', () => {
|
||||
const result = resolver.parseLink('proj/srv:file:///path/to/resource');
|
||||
expect(result).toEqual({
|
||||
project: 'proj',
|
||||
server: 'srv',
|
||||
uri: 'file:///path/to/resource',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws on missing project separator', () => {
|
||||
expect(() => resolver.parseLink('noslash')).toThrow('missing project');
|
||||
});
|
||||
|
||||
it('throws on missing server:uri separator', () => {
|
||||
expect(() => resolver.parseLink('proj/nocolon')).toThrow('missing server:uri');
|
||||
});
|
||||
|
||||
it('throws on empty uri', () => {
|
||||
expect(() => resolver.parseLink('proj/srv:')).toThrow('empty uri');
|
||||
});
|
||||
|
||||
it('throws when project is empty', () => {
|
||||
expect(() => resolver.parseLink('/srv:uri')).toThrow('missing project');
|
||||
});
|
||||
|
||||
it('throws when server is empty', () => {
|
||||
expect(() => resolver.parseLink('proj/:uri')).toThrow('missing server:uri');
|
||||
});
|
||||
});
|
||||
|
||||
// ── resolve ──
|
||||
|
||||
describe('resolve', () => {
|
||||
it('fetches resource content successfully', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue([
|
||||
{ id: 'srv-id-1', name: 'docmost-mcp' },
|
||||
]);
|
||||
vi.mocked(client.post).mockResolvedValue({
|
||||
result: { contents: [{ text: 'Hello from docmost', uri: 'docmost://pages/abc' }] },
|
||||
});
|
||||
|
||||
const result = await resolver.resolve('my-project/docmost-mcp:docmost://pages/abc');
|
||||
|
||||
expect(result).toEqual({ content: 'Hello from docmost', status: 'alive' });
|
||||
expect(client.get).toHaveBeenCalledWith('/api/v1/projects/my-project/servers');
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/mcp/proxy', {
|
||||
serverId: 'srv-id-1',
|
||||
method: 'resources/read',
|
||||
params: { uri: 'docmost://pages/abc' },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns dead status when server not found in project', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue([
|
||||
{ id: 'srv-other', name: 'other-server' },
|
||||
]);
|
||||
|
||||
const result = await resolver.resolve('proj/missing-srv:some://uri');
|
||||
|
||||
expect(result.status).toBe('dead');
|
||||
expect(result.content).toBeNull();
|
||||
expect(result.error).toContain("Server 'missing-srv' not found");
|
||||
});
|
||||
|
||||
it('returns dead status when MCP proxy returns error', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue([{ id: 'srv-1', name: 'srv' }]);
|
||||
vi.mocked(client.post).mockResolvedValue({
|
||||
error: { code: -32601, message: 'Method not found' },
|
||||
});
|
||||
|
||||
const result = await resolver.resolve('proj/srv:some://uri');
|
||||
|
||||
expect(result.status).toBe('dead');
|
||||
expect(result.error).toContain('Method not found');
|
||||
});
|
||||
|
||||
it('returns dead status when no content returned', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue([{ id: 'srv-1', name: 'srv' }]);
|
||||
vi.mocked(client.post).mockResolvedValue({
|
||||
result: { contents: [] },
|
||||
});
|
||||
|
||||
const result = await resolver.resolve('proj/srv:some://uri');
|
||||
|
||||
expect(result.status).toBe('dead');
|
||||
expect(result.error).toContain('No content returned');
|
||||
});
|
||||
|
||||
it('returns dead status on network error', async () => {
|
||||
vi.mocked(client.get).mockRejectedValue(new Error('Connection refused'));
|
||||
|
||||
const result = await resolver.resolve('proj/srv:some://uri');
|
||||
|
||||
expect(result.status).toBe('dead');
|
||||
expect(result.error).toContain('Connection refused');
|
||||
});
|
||||
|
||||
it('concatenates multiple content entries', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue([{ id: 'srv-1', name: 'srv' }]);
|
||||
vi.mocked(client.post).mockResolvedValue({
|
||||
result: {
|
||||
contents: [
|
||||
{ text: 'Part 1', uri: 'uri1' },
|
||||
{ text: 'Part 2', uri: 'uri2' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await resolver.resolve('proj/srv:some://uri');
|
||||
|
||||
expect(result.content).toBe('Part 1\nPart 2');
|
||||
expect(result.status).toBe('alive');
|
||||
});
|
||||
|
||||
it('logs dead link to console.error', async () => {
|
||||
vi.mocked(client.get).mockRejectedValue(new Error('fail'));
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await resolver.resolve('proj/srv:some://uri');
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(expect.stringContaining('[link-resolver] Dead link'));
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// ── caching ──
|
||||
|
||||
describe('caching', () => {
|
||||
it('returns cached result on second call', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue([{ id: 'srv-1', name: 'srv' }]);
|
||||
vi.mocked(client.post).mockResolvedValue({
|
||||
result: { contents: [{ text: 'cached content' }] },
|
||||
});
|
||||
|
||||
const first = await resolver.resolve('proj/srv:some://uri');
|
||||
const second = await resolver.resolve('proj/srv:some://uri');
|
||||
|
||||
expect(first).toEqual(second);
|
||||
// Only one HTTP call — second was cached
|
||||
expect(client.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('refetches after cache expires', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue([{ id: 'srv-1', name: 'srv' }]);
|
||||
vi.mocked(client.post).mockResolvedValue({
|
||||
result: { contents: [{ text: 'content' }] },
|
||||
});
|
||||
|
||||
await resolver.resolve('proj/srv:some://uri');
|
||||
|
||||
// Advance time past TTL
|
||||
vi.useFakeTimers();
|
||||
vi.advanceTimersByTime(1500);
|
||||
|
||||
await resolver.resolve('proj/srv:some://uri');
|
||||
|
||||
expect(client.get).toHaveBeenCalledTimes(2);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('clearCache removes all entries', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue([{ id: 'srv-1', name: 'srv' }]);
|
||||
vi.mocked(client.post).mockResolvedValue({
|
||||
result: { contents: [{ text: 'content' }] },
|
||||
});
|
||||
|
||||
await resolver.resolve('proj/srv:some://uri');
|
||||
resolver.clearCache();
|
||||
await resolver.resolve('proj/srv:some://uri');
|
||||
|
||||
expect(client.get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── checkHealth ──
|
||||
|
||||
describe('checkHealth', () => {
|
||||
it('returns cached status if available', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue([{ id: 'srv-1', name: 'srv' }]);
|
||||
vi.mocked(client.post).mockResolvedValue({
|
||||
result: { contents: [{ text: 'content' }] },
|
||||
});
|
||||
|
||||
await resolver.resolve('proj/srv:some://uri');
|
||||
const health = await resolver.checkHealth('proj/srv:some://uri');
|
||||
|
||||
expect(health).toBe('alive');
|
||||
});
|
||||
|
||||
it('returns unknown if not cached', async () => {
|
||||
const health = await resolver.checkHealth('proj/srv:some://uri');
|
||||
expect(health).toBe('unknown');
|
||||
});
|
||||
|
||||
it('returns dead from cached dead link', async () => {
|
||||
vi.mocked(client.get).mockRejectedValue(new Error('fail'));
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await resolver.resolve('proj/srv:some://uri');
|
||||
const health = await resolver.checkHealth('proj/srv:some://uri');
|
||||
|
||||
expect(health).toBe('dead');
|
||||
});
|
||||
});
|
||||
});
|
||||
166
src/mcplocal/tests/llm-selector.test.ts
Normal file
166
src/mcplocal/tests/llm-selector.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { LlmPromptSelector, type PromptIndexForLlm } from '../src/gate/llm-selector.js';
|
||||
import { ProviderRegistry } from '../src/providers/registry.js';
|
||||
import type { LlmProvider, CompletionOptions, CompletionResult } from '../src/providers/types.js';
|
||||
|
||||
function makeMockProvider(responseContent: string): LlmProvider {
|
||||
return {
|
||||
name: 'mock-heavy',
|
||||
complete: vi.fn().mockResolvedValue({
|
||||
content: responseContent,
|
||||
toolCalls: [],
|
||||
usage: { promptTokens: 100, completionTokens: 50, totalTokens: 150 },
|
||||
finishReason: 'stop',
|
||||
} satisfies CompletionResult),
|
||||
listModels: vi.fn().mockResolvedValue(['mock-model']),
|
||||
isAvailable: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
}
|
||||
|
||||
function makeRegistry(provider: LlmProvider): ProviderRegistry {
|
||||
const registry = new ProviderRegistry();
|
||||
registry.register(provider);
|
||||
registry.assignTier(provider.name, 'heavy');
|
||||
return registry;
|
||||
}
|
||||
|
||||
const sampleIndex: PromptIndexForLlm[] = [
|
||||
{ name: 'zigbee-pairing', priority: 7, summary: 'How to pair Zigbee devices', chapters: ['Setup', 'Troubleshooting'] },
|
||||
{ name: 'mqtt-config', priority: 5, summary: 'MQTT broker configuration', chapters: null },
|
||||
{ name: 'common-mistakes', priority: 10, summary: 'Critical safety rules', chapters: null },
|
||||
];
|
||||
|
||||
describe('LlmPromptSelector', () => {
|
||||
it('sends tags and index to heavy LLM and parses response', async () => {
|
||||
const provider = makeMockProvider(
|
||||
'```json\n{ "selectedNames": ["zigbee-pairing"], "reasoning": "User is working with zigbee" }\n```',
|
||||
);
|
||||
const registry = makeRegistry(provider);
|
||||
const selector = new LlmPromptSelector(registry);
|
||||
|
||||
const result = await selector.selectPrompts(['zigbee', 'pairing'], sampleIndex);
|
||||
|
||||
expect(result.selectedNames).toContain('zigbee-pairing');
|
||||
expect(result.selectedNames).toContain('common-mistakes'); // Priority 10 always included
|
||||
expect(result.reasoning).toBe('User is working with zigbee');
|
||||
});
|
||||
|
||||
it('always includes priority 10 prompts even if LLM omits them', async () => {
|
||||
const provider = makeMockProvider(
|
||||
'{ "selectedNames": ["mqtt-config"], "reasoning": "MQTT related" }',
|
||||
);
|
||||
const registry = makeRegistry(provider);
|
||||
const selector = new LlmPromptSelector(registry);
|
||||
|
||||
const result = await selector.selectPrompts(['mqtt'], sampleIndex);
|
||||
|
||||
expect(result.selectedNames).toContain('mqtt-config');
|
||||
expect(result.selectedNames).toContain('common-mistakes');
|
||||
});
|
||||
|
||||
it('does not duplicate priority 10 if LLM already selected them', async () => {
|
||||
const provider = makeMockProvider(
|
||||
'{ "selectedNames": ["common-mistakes", "mqtt-config"], "reasoning": "Both needed" }',
|
||||
);
|
||||
const registry = makeRegistry(provider);
|
||||
const selector = new LlmPromptSelector(registry);
|
||||
|
||||
const result = await selector.selectPrompts(['mqtt'], sampleIndex);
|
||||
|
||||
const count = result.selectedNames.filter((n) => n === 'common-mistakes').length;
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
it('passes system and user messages to provider.complete', async () => {
|
||||
const provider = makeMockProvider(
|
||||
'{ "selectedNames": [], "reasoning": "none" }',
|
||||
);
|
||||
const registry = makeRegistry(provider);
|
||||
const selector = new LlmPromptSelector(registry);
|
||||
|
||||
await selector.selectPrompts(['test'], sampleIndex);
|
||||
|
||||
expect(provider.complete).toHaveBeenCalledOnce();
|
||||
const call = (provider.complete as ReturnType<typeof vi.fn>).mock.calls[0]![0] as CompletionOptions;
|
||||
expect(call.messages).toHaveLength(2);
|
||||
expect(call.messages[0]!.role).toBe('system');
|
||||
expect(call.messages[1]!.role).toBe('user');
|
||||
expect(call.messages[1]!.content).toContain('test');
|
||||
expect(call.temperature).toBe(0);
|
||||
});
|
||||
|
||||
it('passes model override to complete options', async () => {
|
||||
const provider = makeMockProvider(
|
||||
'{ "selectedNames": [], "reasoning": "" }',
|
||||
);
|
||||
const registry = makeRegistry(provider);
|
||||
const selector = new LlmPromptSelector(registry, 'gemini-pro');
|
||||
|
||||
await selector.selectPrompts(['test'], sampleIndex);
|
||||
|
||||
const call = (provider.complete as ReturnType<typeof vi.fn>).mock.calls[0]![0] as CompletionOptions;
|
||||
expect(call.model).toBe('gemini-pro');
|
||||
});
|
||||
|
||||
it('throws when no heavy provider is available', async () => {
|
||||
const registry = new ProviderRegistry(); // Empty registry
|
||||
const selector = new LlmPromptSelector(registry);
|
||||
|
||||
await expect(selector.selectPrompts(['test'], sampleIndex)).rejects.toThrow(
|
||||
'No heavy LLM provider available',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when LLM response has no valid JSON', async () => {
|
||||
const provider = makeMockProvider('I cannot help with that request.');
|
||||
const registry = makeRegistry(provider);
|
||||
const selector = new LlmPromptSelector(registry);
|
||||
|
||||
await expect(selector.selectPrompts(['test'], sampleIndex)).rejects.toThrow(
|
||||
'LLM response did not contain valid selection JSON',
|
||||
);
|
||||
});
|
||||
|
||||
it('handles response with empty selectedNames', async () => {
|
||||
const provider = makeMockProvider('{ "selectedNames": [], "reasoning": "nothing matched" }');
|
||||
const registry = makeRegistry(provider);
|
||||
const selector = new LlmPromptSelector(registry);
|
||||
|
||||
// Empty selectedNames, but priority 10 should still be included
|
||||
const result = await selector.selectPrompts(['test'], sampleIndex);
|
||||
expect(result.selectedNames).toEqual(['common-mistakes']);
|
||||
expect(result.reasoning).toBe('nothing matched');
|
||||
});
|
||||
|
||||
it('handles response with reasoning missing', async () => {
|
||||
const provider = makeMockProvider('{ "selectedNames": ["mqtt-config"] }');
|
||||
const registry = makeRegistry(provider);
|
||||
const selector = new LlmPromptSelector(registry);
|
||||
|
||||
const result = await selector.selectPrompts(['test'], sampleIndex);
|
||||
expect(result.reasoning).toBe('');
|
||||
expect(result.selectedNames).toContain('mqtt-config');
|
||||
});
|
||||
|
||||
it('includes prompt details in the user prompt', async () => {
|
||||
const indexWithNull: PromptIndexForLlm[] = [
|
||||
...sampleIndex,
|
||||
{ name: 'no-desc', priority: 3, summary: null, chapters: null },
|
||||
];
|
||||
const provider = makeMockProvider(
|
||||
'{ "selectedNames": [], "reasoning": "" }',
|
||||
);
|
||||
const registry = makeRegistry(provider);
|
||||
const selector = new LlmPromptSelector(registry);
|
||||
|
||||
await selector.selectPrompts(['zigbee'], indexWithNull);
|
||||
|
||||
const call = (provider.complete as ReturnType<typeof vi.fn>).mock.calls[0]![0] as CompletionOptions;
|
||||
const userMsg = call.messages[1]!.content;
|
||||
expect(userMsg).toContain('zigbee-pairing');
|
||||
expect(userMsg).toContain('priority: 7');
|
||||
expect(userMsg).toContain('How to pair Zigbee devices');
|
||||
expect(userMsg).toContain('Setup, Troubleshooting');
|
||||
expect(userMsg).toContain('No summary'); // For prompts with null summary
|
||||
});
|
||||
});
|
||||
520
src/mcplocal/tests/router-gate.test.ts
Normal file
520
src/mcplocal/tests/router-gate.test.ts
Normal file
@@ -0,0 +1,520 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { McpRouter } from '../src/router.js';
|
||||
import type { UpstreamConnection, JsonRpcRequest, JsonRpcResponse, JsonRpcNotification } from '../src/types.js';
|
||||
import type { McpdClient } from '../src/http/mcpd-client.js';
|
||||
import { ProviderRegistry } from '../src/providers/registry.js';
|
||||
import type { LlmProvider, CompletionResult } from '../src/providers/types.js';
|
||||
|
||||
function mockUpstream(
|
||||
name: string,
|
||||
opts: { tools?: Array<{ name: string; description?: string }> } = {},
|
||||
): UpstreamConnection {
|
||||
return {
|
||||
name,
|
||||
isAlive: vi.fn(() => true),
|
||||
close: vi.fn(async () => {}),
|
||||
onNotification: vi.fn(),
|
||||
send: vi.fn(async (req: JsonRpcRequest): Promise<JsonRpcResponse> => {
|
||||
if (req.method === 'tools/list') {
|
||||
return { jsonrpc: '2.0', id: req.id, result: { tools: opts.tools ?? [] } };
|
||||
}
|
||||
if (req.method === 'tools/call') {
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id: req.id,
|
||||
result: { content: [{ type: 'text', text: `Called ${(req.params as Record<string, unknown>)?.name}` }] },
|
||||
};
|
||||
}
|
||||
if (req.method === 'resources/list') {
|
||||
return { jsonrpc: '2.0', id: req.id, result: { resources: [] } };
|
||||
}
|
||||
if (req.method === 'prompts/list') {
|
||||
return { jsonrpc: '2.0', id: req.id, result: { prompts: [] } };
|
||||
}
|
||||
return { jsonrpc: '2.0', id: req.id, error: { code: -32601, message: 'Not found' } };
|
||||
}),
|
||||
} as UpstreamConnection;
|
||||
}
|
||||
|
||||
function mockMcpdClient(prompts: Array<{ name: string; priority: number; summary: string | null; chapters: string[] | null; content: string; type?: string }> = []): McpdClient {
|
||||
return {
|
||||
get: vi.fn(async (path: string) => {
|
||||
if (path.includes('/prompts/visible')) {
|
||||
return prompts.map((p) => ({ ...p, type: p.type ?? 'prompt' }));
|
||||
}
|
||||
if (path.includes('/prompt-index')) {
|
||||
return prompts.map((p) => ({
|
||||
name: p.name,
|
||||
priority: p.priority,
|
||||
summary: p.summary,
|
||||
chapters: p.chapters,
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
post: vi.fn(async () => ({})),
|
||||
put: vi.fn(async () => ({})),
|
||||
delete: vi.fn(async () => {}),
|
||||
forward: vi.fn(async () => ({ status: 200, body: {} })),
|
||||
withHeaders: vi.fn(function (this: McpdClient) { return this; }),
|
||||
} as unknown as McpdClient;
|
||||
}
|
||||
|
||||
const samplePrompts = [
|
||||
{ name: 'common-mistakes', priority: 10, summary: 'Critical safety rules everyone must follow', chapters: null, content: 'NEVER do X. ALWAYS do Y.' },
|
||||
{ name: 'zigbee-pairing', priority: 7, summary: 'How to pair Zigbee devices with the hub', chapters: ['Setup', 'Troubleshooting'], content: 'Step 1: Put device in pairing mode...' },
|
||||
{ name: 'mqtt-config', priority: 5, summary: 'MQTT broker configuration guide', chapters: ['Broker Setup', 'Authentication'], content: 'Configure the MQTT broker at...' },
|
||||
{ name: 'security-policy', priority: 8, summary: 'Security policies for production deployments', chapters: ['Network', 'Auth'], content: 'All connections must use TLS...' },
|
||||
];
|
||||
|
||||
function setupGatedRouter(
|
||||
opts: {
|
||||
gated?: boolean;
|
||||
prompts?: typeof samplePrompts;
|
||||
withLlm?: boolean;
|
||||
llmResponse?: string;
|
||||
} = {},
|
||||
): { router: McpRouter; mcpdClient: McpdClient } {
|
||||
const router = new McpRouter();
|
||||
const prompts = opts.prompts ?? samplePrompts;
|
||||
const mcpdClient = mockMcpdClient(prompts);
|
||||
router.setPromptConfig(mcpdClient, 'test-project');
|
||||
|
||||
let providerRegistry: ProviderRegistry | null = null;
|
||||
if (opts.withLlm) {
|
||||
providerRegistry = new ProviderRegistry();
|
||||
const mockProvider: LlmProvider = {
|
||||
name: 'mock-heavy',
|
||||
complete: vi.fn().mockResolvedValue({
|
||||
content: opts.llmResponse ?? '{ "selectedNames": ["zigbee-pairing"], "reasoning": "User is working with zigbee" }',
|
||||
toolCalls: [],
|
||||
usage: { promptTokens: 100, completionTokens: 50, totalTokens: 150 },
|
||||
finishReason: 'stop',
|
||||
} satisfies CompletionResult),
|
||||
listModels: vi.fn().mockResolvedValue([]),
|
||||
isAvailable: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
providerRegistry.register(mockProvider);
|
||||
providerRegistry.assignTier(mockProvider.name, 'heavy');
|
||||
}
|
||||
|
||||
router.setGateConfig({
|
||||
gated: opts.gated !== false,
|
||||
providerRegistry,
|
||||
});
|
||||
|
||||
return { router, mcpdClient };
|
||||
}
|
||||
|
||||
describe('McpRouter gating', () => {
|
||||
describe('initialize with gating', () => {
|
||||
it('creates gated session on initialize', async () => {
|
||||
const { router } = setupGatedRouter();
|
||||
|
||||
const res = await router.route(
|
||||
{ jsonrpc: '2.0', id: 1, method: 'initialize' },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
expect(res.result).toBeDefined();
|
||||
// The session should be gated now
|
||||
const toolsRes = await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/list' },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
const tools = (toolsRes.result as { tools: Array<{ name: string }> }).tools;
|
||||
expect(tools).toHaveLength(1);
|
||||
expect(tools[0]!.name).toBe('begin_session');
|
||||
});
|
||||
|
||||
it('creates ungated session when project is not gated', async () => {
|
||||
const { router } = setupGatedRouter({ gated: false });
|
||||
router.addUpstream(mockUpstream('ha', { tools: [{ name: 'get_entities' }] }));
|
||||
|
||||
await router.route(
|
||||
{ jsonrpc: '2.0', id: 1, method: 'initialize' },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
const toolsRes = await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/list' },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
const tools = (toolsRes.result as { tools: Array<{ name: string }> }).tools;
|
||||
const names = tools.map((t) => t.name);
|
||||
expect(names).toContain('ha/get_entities');
|
||||
expect(names).toContain('read_prompts');
|
||||
expect(names).not.toContain('begin_session');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tools/list gating', () => {
|
||||
it('shows only begin_session when session is gated', async () => {
|
||||
const { router } = setupGatedRouter();
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
const res = await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/list' },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
const tools = (res.result as { tools: Array<{ name: string }> }).tools;
|
||||
expect(tools).toHaveLength(1);
|
||||
expect(tools[0]!.name).toBe('begin_session');
|
||||
});
|
||||
|
||||
it('shows all tools plus read_prompts after ungating', async () => {
|
||||
const { router } = setupGatedRouter();
|
||||
router.addUpstream(mockUpstream('ha', { tools: [{ name: 'get_entities' }] }));
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
// Ungate via begin_session
|
||||
await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee'] } } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
const toolsRes = await router.route(
|
||||
{ jsonrpc: '2.0', id: 3, method: 'tools/list' },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
const tools = (toolsRes.result as { tools: Array<{ name: string }> }).tools;
|
||||
const names = tools.map((t) => t.name);
|
||||
expect(names).toContain('ha/get_entities');
|
||||
expect(names).toContain('propose_prompt');
|
||||
expect(names).toContain('read_prompts');
|
||||
expect(names).not.toContain('begin_session');
|
||||
});
|
||||
});
|
||||
|
||||
describe('begin_session', () => {
|
||||
it('returns matched prompts with keyword matching', async () => {
|
||||
const { router } = setupGatedRouter();
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
const res = await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee', 'pairing'] } } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
expect(res.error).toBeUndefined();
|
||||
const text = ((res.result as { content: Array<{ text: string }> }).content[0]!.text);
|
||||
// Should include priority 10 prompt
|
||||
expect(text).toContain('common-mistakes');
|
||||
expect(text).toContain('NEVER do X');
|
||||
// Should include zigbee-pairing (matches both tags)
|
||||
expect(text).toContain('zigbee-pairing');
|
||||
expect(text).toContain('pairing mode');
|
||||
// Should include encouragement
|
||||
expect(text).toContain('read_prompts');
|
||||
});
|
||||
|
||||
it('includes priority 10 prompts even without matching tags', async () => {
|
||||
const { router } = setupGatedRouter();
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
const res = await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['unrelated-keyword'] } } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
const text = ((res.result as { content: Array<{ text: string }> }).content[0]!.text);
|
||||
expect(text).toContain('common-mistakes');
|
||||
expect(text).toContain('NEVER do X');
|
||||
});
|
||||
|
||||
it('uses LLM selection when provider is available', async () => {
|
||||
const { router } = setupGatedRouter({
|
||||
withLlm: true,
|
||||
llmResponse: '{ "selectedNames": ["zigbee-pairing", "security-policy"], "reasoning": "Zigbee pairing needs security awareness" }',
|
||||
});
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
const res = await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee'] } } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
const text = ((res.result as { content: Array<{ text: string }> }).content[0]!.text);
|
||||
expect(text).toContain('Zigbee pairing needs security awareness');
|
||||
expect(text).toContain('zigbee-pairing');
|
||||
expect(text).toContain('security-policy');
|
||||
expect(text).toContain('common-mistakes'); // priority 10 always included
|
||||
});
|
||||
|
||||
it('rejects empty tags', async () => {
|
||||
const { router } = setupGatedRouter();
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
const res = await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: [] } } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
expect(res.error).toBeDefined();
|
||||
expect(res.error!.code).toBe(-32602);
|
||||
});
|
||||
|
||||
it('returns message when session is already ungated', async () => {
|
||||
const { router } = setupGatedRouter();
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
// First call ungates
|
||||
await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee'] } } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
// Second call tells user to use read_prompts
|
||||
const res = await router.route(
|
||||
{ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['mqtt'] } } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
const text = ((res.result as { content: Array<{ text: string }> }).content[0]!.text);
|
||||
expect(text).toContain('already started');
|
||||
expect(text).toContain('read_prompts');
|
||||
});
|
||||
|
||||
it('lists remaining prompts for awareness', async () => {
|
||||
const { router } = setupGatedRouter();
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
const res = await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee'] } } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
const text = ((res.result as { content: Array<{ text: string }> }).content[0]!.text);
|
||||
// Non-matching prompts should be listed as "other available prompts"
|
||||
// security-policy doesn't match 'zigbee' in keyword mode
|
||||
expect(text).toContain('security-policy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('read_prompts', () => {
|
||||
it('returns prompts matching keywords', async () => {
|
||||
const { router } = setupGatedRouter({ gated: false });
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
const res = await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'read_prompts', arguments: { tags: ['mqtt', 'broker'] } } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
expect(res.error).toBeUndefined();
|
||||
const text = ((res.result as { content: Array<{ text: string }> }).content[0]!.text);
|
||||
expect(text).toContain('mqtt-config');
|
||||
expect(text).toContain('Configure the MQTT broker');
|
||||
});
|
||||
|
||||
it('filters out already-sent prompts', async () => {
|
||||
const { router } = setupGatedRouter();
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
// begin_session sends common-mistakes (priority 10) and zigbee-pairing
|
||||
await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee'] } } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
// read_prompts for mqtt should not re-send common-mistakes
|
||||
const res = await router.route(
|
||||
{ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'read_prompts', arguments: { tags: ['mqtt'] } } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
const text = ((res.result as { content: Array<{ text: string }> }).content[0]!.text);
|
||||
expect(text).toContain('mqtt-config');
|
||||
// common-mistakes was already sent, should not appear again
|
||||
expect(text).not.toContain('NEVER do X');
|
||||
});
|
||||
|
||||
it('returns message when no new prompts match', async () => {
|
||||
const { router } = setupGatedRouter({ prompts: [] });
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
const res = await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'read_prompts', arguments: { tags: ['nonexistent'] } } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
const text = ((res.result as { content: Array<{ text: string }> }).content[0]!.text);
|
||||
expect(text).toContain('No new matching prompts');
|
||||
});
|
||||
|
||||
it('rejects empty tags', async () => {
|
||||
const { router } = setupGatedRouter({ gated: false });
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
const res = await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'read_prompts', arguments: { tags: [] } } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
expect(res.error).toBeDefined();
|
||||
expect(res.error!.code).toBe(-32602);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gated intercept', () => {
|
||||
it('auto-ungates when gated session calls a real tool', async () => {
|
||||
const { router } = setupGatedRouter();
|
||||
const ha = mockUpstream('ha', { tools: [{ name: 'get_entities' }] });
|
||||
router.addUpstream(ha);
|
||||
await router.discoverTools();
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
// Call a real tool while gated — should intercept, extract keywords, and route
|
||||
const res = await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'ha/get_entities', arguments: { domain: 'light' } } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
// Response should include the tool result
|
||||
expect(res.error).toBeUndefined();
|
||||
const result = res.result as { content: Array<{ type: string; text: string }> };
|
||||
// Should have briefing prepended
|
||||
expect(result.content.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Session should now be ungated
|
||||
const toolsRes = await router.route(
|
||||
{ jsonrpc: '2.0', id: 3, method: 'tools/list' },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
const tools = (toolsRes.result as { tools: Array<{ name: string }> }).tools;
|
||||
expect(tools.map((t) => t.name)).toContain('ha/get_entities');
|
||||
});
|
||||
|
||||
it('includes project context in intercepted response', async () => {
|
||||
const { router } = setupGatedRouter();
|
||||
const ha = mockUpstream('ha', { tools: [{ name: 'get_entities' }] });
|
||||
router.addUpstream(ha);
|
||||
await router.discoverTools();
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
const res = await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'ha/get_entities', arguments: { domain: 'light' } } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
const result = res.result as { content: Array<{ type: string; text: string }> };
|
||||
// First content block should be the briefing (priority 10 at minimum)
|
||||
const briefing = result.content[0]!.text;
|
||||
expect(briefing).toContain('common-mistakes');
|
||||
expect(briefing).toContain('NEVER do X');
|
||||
});
|
||||
});
|
||||
|
||||
describe('initialize instructions for gated projects', () => {
|
||||
it('includes gate message and prompt index in instructions', async () => {
|
||||
const { router } = setupGatedRouter();
|
||||
|
||||
const res = await router.route(
|
||||
{ jsonrpc: '2.0', id: 1, method: 'initialize' },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
const result = res.result as { instructions?: string };
|
||||
expect(result.instructions).toBeDefined();
|
||||
expect(result.instructions).toContain('begin_session');
|
||||
expect(result.instructions).toContain('gated session');
|
||||
// Should list available prompts
|
||||
expect(result.instructions).toContain('common-mistakes');
|
||||
expect(result.instructions).toContain('zigbee-pairing');
|
||||
});
|
||||
|
||||
it('does not include gate message for non-gated projects', async () => {
|
||||
const { router } = setupGatedRouter({ gated: false });
|
||||
router.setInstructions('Base project instructions');
|
||||
|
||||
const res = await router.route(
|
||||
{ jsonrpc: '2.0', id: 1, method: 'initialize' },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
const result = res.result as { instructions?: string };
|
||||
expect(result.instructions).toBe('Base project instructions');
|
||||
expect(result.instructions).not.toContain('gated session');
|
||||
});
|
||||
|
||||
it('preserves base instructions and appends gate message', async () => {
|
||||
const { router } = setupGatedRouter();
|
||||
router.setInstructions('You are a helpful assistant.');
|
||||
|
||||
const res = await router.route(
|
||||
{ jsonrpc: '2.0', id: 1, method: 'initialize' },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
const result = res.result as { instructions?: string };
|
||||
expect(result.instructions).toContain('You are a helpful assistant.');
|
||||
expect(result.instructions).toContain('begin_session');
|
||||
});
|
||||
|
||||
it('sorts prompt index by priority descending', async () => {
|
||||
const { router } = setupGatedRouter();
|
||||
|
||||
const res = await router.route(
|
||||
{ jsonrpc: '2.0', id: 1, method: 'initialize' },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
const result = res.result as { instructions: string };
|
||||
const lines = result.instructions.split('\n');
|
||||
// Find the prompt index lines
|
||||
const promptLines = lines.filter((l) => l.startsWith('- ') && l.includes('priority'));
|
||||
// priority 10 should come first
|
||||
expect(promptLines[0]).toContain('common-mistakes');
|
||||
expect(promptLines[0]).toContain('priority 10');
|
||||
});
|
||||
});
|
||||
|
||||
describe('session cleanup', () => {
|
||||
it('cleanupSession removes gate state', async () => {
|
||||
const { router } = setupGatedRouter();
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
// Session is gated
|
||||
let toolsRes = await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/list' },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
expect((toolsRes.result as { tools: Array<{ name: string }> }).tools[0]!.name).toBe('begin_session');
|
||||
|
||||
// Cleanup
|
||||
router.cleanupSession('s1');
|
||||
|
||||
// After cleanup, session is treated as unknown (ungated)
|
||||
toolsRes = await router.route(
|
||||
{ jsonrpc: '2.0', id: 3, method: 'tools/list' },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
const tools = (toolsRes.result as { tools: Array<{ name: string }> }).tools;
|
||||
expect(tools.map((t) => t.name)).not.toContain('begin_session');
|
||||
});
|
||||
});
|
||||
|
||||
describe('prompt index caching', () => {
|
||||
it('caches prompt index for 60 seconds', async () => {
|
||||
const { router, mcpdClient } = setupGatedRouter({ gated: false });
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
// First read_prompts call fetches from mcpd
|
||||
await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'read_prompts', arguments: { tags: ['mqtt'] } } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
// Second call should use cache
|
||||
await router.route(
|
||||
{ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'read_prompts', arguments: { tags: ['zigbee'] } } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
// mcpdClient.get should have been called only once for prompts/visible
|
||||
const getCalls = vi.mocked(mcpdClient.get).mock.calls.filter((c) => (c[0] as string).includes('/prompts/visible'));
|
||||
expect(getCalls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -165,16 +165,13 @@ describe('McpRouter - Prompt Integration', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should read mcpctl resource content', async () => {
|
||||
it('should read mcpctl resource content live from mcpd', async () => {
|
||||
router.setPromptConfig(mcpdClient, 'proj');
|
||||
vi.mocked(mcpdClient.get).mockResolvedValue([
|
||||
{ name: 'my-prompt', content: 'The content here', type: 'prompt' },
|
||||
]);
|
||||
|
||||
// First list to populate cache
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'resources/list' });
|
||||
|
||||
// Then read
|
||||
// Read directly — no need to list first
|
||||
const response = await router.route({
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
@@ -187,8 +184,55 @@ describe('McpRouter - Prompt Integration', () => {
|
||||
expect(contents[0]!.text).toBe('The content here');
|
||||
});
|
||||
|
||||
it('should return fresh content after prompt update', async () => {
|
||||
router.setPromptConfig(mcpdClient, 'proj');
|
||||
|
||||
// First call returns old content
|
||||
vi.mocked(mcpdClient.get).mockResolvedValueOnce([
|
||||
{ name: 'my-prompt', content: 'Old content', type: 'prompt' },
|
||||
]);
|
||||
await router.route({
|
||||
jsonrpc: '2.0', id: 1, method: 'resources/read',
|
||||
params: { uri: 'mcpctl://prompts/my-prompt' },
|
||||
});
|
||||
|
||||
// Second call returns updated content
|
||||
vi.mocked(mcpdClient.get).mockResolvedValueOnce([
|
||||
{ name: 'my-prompt', content: 'Updated content', type: 'prompt' },
|
||||
]);
|
||||
const response = await router.route({
|
||||
jsonrpc: '2.0', id: 2, method: 'resources/read',
|
||||
params: { uri: 'mcpctl://prompts/my-prompt' },
|
||||
});
|
||||
|
||||
const contents = (response.result as { contents: Array<{ text: string }> }).contents;
|
||||
expect(contents[0]!.text).toBe('Updated content');
|
||||
});
|
||||
|
||||
it('should fall back to cache when mcpd is unreachable on read', async () => {
|
||||
router.setPromptConfig(mcpdClient, 'proj');
|
||||
|
||||
// Populate cache via list
|
||||
vi.mocked(mcpdClient.get).mockResolvedValueOnce([
|
||||
{ name: 'cached-prompt', content: 'Cached content', type: 'prompt' },
|
||||
]);
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'resources/list' });
|
||||
|
||||
// mcpd goes down for read
|
||||
vi.mocked(mcpdClient.get).mockRejectedValueOnce(new Error('Connection refused'));
|
||||
const response = await router.route({
|
||||
jsonrpc: '2.0', id: 2, method: 'resources/read',
|
||||
params: { uri: 'mcpctl://prompts/cached-prompt' },
|
||||
});
|
||||
|
||||
expect(response.error).toBeUndefined();
|
||||
const contents = (response.result as { contents: Array<{ text: string }> }).contents;
|
||||
expect(contents[0]!.text).toBe('Cached content');
|
||||
});
|
||||
|
||||
it('should return error for unknown mcpctl resource', async () => {
|
||||
router.setPromptConfig(mcpdClient, 'proj');
|
||||
vi.mocked(mcpdClient.get).mockResolvedValue([]);
|
||||
|
||||
const response = await router.route({
|
||||
jsonrpc: '2.0',
|
||||
|
||||
155
src/mcplocal/tests/session-gate.test.ts
Normal file
155
src/mcplocal/tests/session-gate.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SessionGate } from '../src/gate/session-gate.js';
|
||||
import type { TagMatchResult, PromptIndexEntry } from '../src/gate/tag-matcher.js';
|
||||
|
||||
function makeMatchResult(names: string[]): TagMatchResult {
|
||||
return {
|
||||
fullContent: names.map((name) => ({
|
||||
name,
|
||||
priority: 5,
|
||||
summary: null,
|
||||
chapters: null,
|
||||
content: `Content of ${name}`,
|
||||
})),
|
||||
indexOnly: [],
|
||||
remaining: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe('SessionGate', () => {
|
||||
it('creates a gated session when project is gated', () => {
|
||||
const gate = new SessionGate();
|
||||
gate.createSession('s1', true);
|
||||
expect(gate.isGated('s1')).toBe(true);
|
||||
});
|
||||
|
||||
it('creates an ungated session when project is not gated', () => {
|
||||
const gate = new SessionGate();
|
||||
gate.createSession('s1', false);
|
||||
expect(gate.isGated('s1')).toBe(false);
|
||||
});
|
||||
|
||||
it('unknown sessions are treated as ungated', () => {
|
||||
const gate = new SessionGate();
|
||||
expect(gate.isGated('nonexistent')).toBe(false);
|
||||
});
|
||||
|
||||
it('getSession returns null for unknown sessions', () => {
|
||||
const gate = new SessionGate();
|
||||
expect(gate.getSession('nonexistent')).toBeNull();
|
||||
});
|
||||
|
||||
it('getSession returns session state', () => {
|
||||
const gate = new SessionGate();
|
||||
gate.createSession('s1', true);
|
||||
const state = gate.getSession('s1');
|
||||
expect(state).not.toBeNull();
|
||||
expect(state!.gated).toBe(true);
|
||||
expect(state!.tags).toEqual([]);
|
||||
expect(state!.retrievedPrompts.size).toBe(0);
|
||||
expect(state!.briefing).toBeNull();
|
||||
});
|
||||
|
||||
it('ungate marks session as ungated and records tags', () => {
|
||||
const gate = new SessionGate();
|
||||
gate.createSession('s1', true);
|
||||
|
||||
gate.ungate('s1', ['zigbee', 'mqtt'], makeMatchResult(['prompt-a', 'prompt-b']));
|
||||
|
||||
expect(gate.isGated('s1')).toBe(false);
|
||||
const state = gate.getSession('s1');
|
||||
expect(state!.tags).toEqual(['zigbee', 'mqtt']);
|
||||
expect(state!.retrievedPrompts.has('prompt-a')).toBe(true);
|
||||
expect(state!.retrievedPrompts.has('prompt-b')).toBe(true);
|
||||
});
|
||||
|
||||
it('ungate appends tags on repeated calls', () => {
|
||||
const gate = new SessionGate();
|
||||
gate.createSession('s1', true);
|
||||
|
||||
gate.ungate('s1', ['zigbee'], makeMatchResult(['p1']));
|
||||
gate.ungate('s1', ['mqtt'], makeMatchResult(['p2']));
|
||||
|
||||
const state = gate.getSession('s1');
|
||||
expect(state!.tags).toEqual(['zigbee', 'mqtt']);
|
||||
expect(state!.retrievedPrompts.has('p1')).toBe(true);
|
||||
expect(state!.retrievedPrompts.has('p2')).toBe(true);
|
||||
});
|
||||
|
||||
it('ungate is no-op for unknown sessions', () => {
|
||||
const gate = new SessionGate();
|
||||
// Should not throw
|
||||
gate.ungate('nonexistent', ['tag'], makeMatchResult(['p']));
|
||||
});
|
||||
|
||||
it('addRetrievedPrompts records additional prompts', () => {
|
||||
const gate = new SessionGate();
|
||||
gate.createSession('s1', true);
|
||||
gate.ungate('s1', ['zigbee'], makeMatchResult(['p1']));
|
||||
|
||||
gate.addRetrievedPrompts('s1', ['mqtt', 'lights'], ['p2', 'p3']);
|
||||
|
||||
const state = gate.getSession('s1');
|
||||
expect(state!.tags).toEqual(['zigbee', 'mqtt', 'lights']);
|
||||
expect(state!.retrievedPrompts.has('p2')).toBe(true);
|
||||
expect(state!.retrievedPrompts.has('p3')).toBe(true);
|
||||
});
|
||||
|
||||
it('addRetrievedPrompts is no-op for unknown sessions', () => {
|
||||
const gate = new SessionGate();
|
||||
gate.addRetrievedPrompts('nonexistent', ['tag'], ['p']);
|
||||
});
|
||||
|
||||
it('filterAlreadySent removes already-sent prompts', () => {
|
||||
const gate = new SessionGate();
|
||||
gate.createSession('s1', true);
|
||||
gate.ungate('s1', ['zigbee'], makeMatchResult(['p1']));
|
||||
|
||||
const prompts: PromptIndexEntry[] = [
|
||||
{ name: 'p1', priority: 5, summary: 'already sent', chapters: null, content: 'x' },
|
||||
{ name: 'p2', priority: 5, summary: 'new', chapters: null, content: 'y' },
|
||||
];
|
||||
|
||||
const filtered = gate.filterAlreadySent('s1', prompts);
|
||||
expect(filtered).toHaveLength(1);
|
||||
expect(filtered[0]!.name).toBe('p2');
|
||||
});
|
||||
|
||||
it('filterAlreadySent returns all prompts for unknown sessions', () => {
|
||||
const gate = new SessionGate();
|
||||
const prompts: PromptIndexEntry[] = [
|
||||
{ name: 'p1', priority: 5, summary: null, chapters: null, content: 'x' },
|
||||
];
|
||||
|
||||
const filtered = gate.filterAlreadySent('nonexistent', prompts);
|
||||
expect(filtered).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('removeSession cleans up state', () => {
|
||||
const gate = new SessionGate();
|
||||
gate.createSession('s1', true);
|
||||
expect(gate.getSession('s1')).not.toBeNull();
|
||||
|
||||
gate.removeSession('s1');
|
||||
expect(gate.getSession('s1')).toBeNull();
|
||||
expect(gate.isGated('s1')).toBe(false);
|
||||
});
|
||||
|
||||
it('removeSession is safe for unknown sessions', () => {
|
||||
const gate = new SessionGate();
|
||||
gate.removeSession('nonexistent'); // Should not throw
|
||||
});
|
||||
|
||||
it('manages multiple sessions independently', () => {
|
||||
const gate = new SessionGate();
|
||||
gate.createSession('s1', true);
|
||||
gate.createSession('s2', false);
|
||||
|
||||
expect(gate.isGated('s1')).toBe(true);
|
||||
expect(gate.isGated('s2')).toBe(false);
|
||||
|
||||
gate.ungate('s1', ['zigbee'], makeMatchResult(['p1']));
|
||||
expect(gate.isGated('s1')).toBe(false);
|
||||
expect(gate.getSession('s2')!.tags).toEqual([]); // s2 untouched
|
||||
});
|
||||
});
|
||||
165
src/mcplocal/tests/tag-matcher.test.ts
Normal file
165
src/mcplocal/tests/tag-matcher.test.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { TagMatcher, extractKeywordsFromToolCall, type PromptIndexEntry } from '../src/gate/tag-matcher.js';
|
||||
|
||||
function makePrompt(overrides: Partial<PromptIndexEntry> = {}): PromptIndexEntry {
|
||||
return {
|
||||
name: 'test-prompt',
|
||||
priority: 5,
|
||||
summary: 'A test prompt for testing',
|
||||
chapters: ['Chapter One', 'Chapter Two'],
|
||||
content: 'Full content of the test prompt.',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('TagMatcher', () => {
|
||||
it('returns priority 10 prompts regardless of tags', () => {
|
||||
const matcher = new TagMatcher();
|
||||
const critical = makePrompt({ name: 'common-mistakes', priority: 10, summary: 'Unrelated stuff' });
|
||||
const normal = makePrompt({ name: 'normal', priority: 5, summary: 'Something else' });
|
||||
|
||||
const result = matcher.match([], [critical, normal]);
|
||||
expect(result.fullContent.map((p) => p.name)).toEqual(['common-mistakes']);
|
||||
expect(result.remaining.map((p) => p.name)).toEqual(['normal']);
|
||||
});
|
||||
|
||||
it('scores by matching_tags * priority', () => {
|
||||
const matcher = new TagMatcher();
|
||||
const high = makePrompt({ name: 'important', priority: 8, summary: 'zigbee mqtt pairing' });
|
||||
const low = makePrompt({ name: 'basic', priority: 3, summary: 'zigbee basics' });
|
||||
|
||||
// Both match "zigbee": high scores 1*8=8, low scores 1*3=3
|
||||
const result = matcher.match(['zigbee'], [low, high]);
|
||||
expect(result.fullContent[0]!.name).toBe('important');
|
||||
expect(result.fullContent[1]!.name).toBe('basic');
|
||||
});
|
||||
|
||||
it('matches more tags = higher score', () => {
|
||||
const matcher = new TagMatcher();
|
||||
const twoMatch = makePrompt({ name: 'two-match', priority: 5, summary: 'zigbee mqtt' });
|
||||
const oneMatch = makePrompt({ name: 'one-match', priority: 5, summary: 'zigbee only' });
|
||||
|
||||
// two-match: 2*5=10, one-match: 1*5=5
|
||||
const result = matcher.match(['zigbee', 'mqtt'], [oneMatch, twoMatch]);
|
||||
expect(result.fullContent[0]!.name).toBe('two-match');
|
||||
});
|
||||
|
||||
it('performs case-insensitive matching', () => {
|
||||
const matcher = new TagMatcher();
|
||||
const prompt = makePrompt({ name: 'test', summary: 'ZIGBEE Protocol Setup' });
|
||||
|
||||
const result = matcher.match(['zigbee'], [prompt]);
|
||||
expect(result.fullContent).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('matches against name, summary, and chapters', () => {
|
||||
const matcher = new TagMatcher();
|
||||
const byName = makePrompt({ name: 'zigbee-config', summary: 'unrelated', chapters: [] });
|
||||
const bySummary = makePrompt({ name: 'setup', summary: 'zigbee setup guide', chapters: [] });
|
||||
const byChapter = makePrompt({ name: 'guide', summary: 'unrelated', chapters: ['Zigbee Pairing'] });
|
||||
|
||||
const result = matcher.match(['zigbee'], [byName, bySummary, byChapter]);
|
||||
expect(result.fullContent).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('respects byte budget', () => {
|
||||
const matcher = new TagMatcher(100); // Very small budget
|
||||
const small = makePrompt({ name: 'small', summary: 'zigbee', content: 'Short.' }); // ~6 bytes
|
||||
const big = makePrompt({ name: 'big', summary: 'zigbee', content: 'x'.repeat(200) }); // 200 bytes
|
||||
|
||||
const result = matcher.match(['zigbee'], [small, big]);
|
||||
expect(result.fullContent.map((p) => p.name)).toEqual(['small']);
|
||||
expect(result.indexOnly.map((p) => p.name)).toEqual(['big']);
|
||||
});
|
||||
|
||||
it('puts non-matched prompts in remaining', () => {
|
||||
const matcher = new TagMatcher();
|
||||
const matched = makePrompt({ name: 'matched', summary: 'zigbee stuff' });
|
||||
const unmatched = makePrompt({ name: 'unmatched', summary: 'completely different topic' });
|
||||
|
||||
const result = matcher.match(['zigbee'], [matched, unmatched]);
|
||||
expect(result.fullContent.map((p) => p.name)).toEqual(['matched']);
|
||||
expect(result.remaining.map((p) => p.name)).toEqual(['unmatched']);
|
||||
});
|
||||
|
||||
it('handles empty tags — only priority 10 matched', () => {
|
||||
const matcher = new TagMatcher();
|
||||
const critical = makePrompt({ name: 'critical', priority: 10 });
|
||||
const normal = makePrompt({ name: 'normal', priority: 5 });
|
||||
|
||||
const result = matcher.match([], [critical, normal]);
|
||||
expect(result.fullContent.map((p) => p.name)).toEqual(['critical']);
|
||||
expect(result.remaining.map((p) => p.name)).toEqual(['normal']);
|
||||
});
|
||||
|
||||
it('handles empty prompts array', () => {
|
||||
const matcher = new TagMatcher();
|
||||
const result = matcher.match(['zigbee'], []);
|
||||
expect(result.fullContent).toEqual([]);
|
||||
expect(result.indexOnly).toEqual([]);
|
||||
expect(result.remaining).toEqual([]);
|
||||
});
|
||||
|
||||
it('all priority 10 prompts are included even beyond budget', () => {
|
||||
const matcher = new TagMatcher(50); // Tiny budget
|
||||
const c1 = makePrompt({ name: 'c1', priority: 10, content: 'x'.repeat(40) });
|
||||
const c2 = makePrompt({ name: 'c2', priority: 10, content: 'y'.repeat(40) });
|
||||
|
||||
const result = matcher.match([], [c1, c2]);
|
||||
// Both should be in fullContent — priority 10 has Infinity score
|
||||
// First one fits budget, second overflows but still priority 10
|
||||
expect(result.fullContent.length + result.indexOnly.length).toBe(2);
|
||||
// At minimum the first one is in fullContent
|
||||
expect(result.fullContent[0]!.name).toBe('c1');
|
||||
});
|
||||
|
||||
it('sorts matched by score descending', () => {
|
||||
const matcher = new TagMatcher();
|
||||
const p1 = makePrompt({ name: 'p1', priority: 3, summary: 'mqtt zigbee lights' }); // 3 matches * 3 = 9
|
||||
const p2 = makePrompt({ name: 'p2', priority: 8, summary: 'mqtt' }); // 1 match * 8 = 8
|
||||
const p3 = makePrompt({ name: 'p3', priority: 2, summary: 'mqtt zigbee lights pairing automation' }); // 5 * 2 = 10
|
||||
|
||||
const result = matcher.match(['mqtt', 'zigbee', 'lights', 'pairing', 'automation'], [p1, p2, p3]);
|
||||
expect(result.fullContent.map((p) => p.name)).toEqual(['p3', 'p1', 'p2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractKeywordsFromToolCall', () => {
|
||||
it('extracts from tool name', () => {
|
||||
const keywords = extractKeywordsFromToolCall('home-assistant/get_entities', {});
|
||||
expect(keywords).toContain('home');
|
||||
expect(keywords).toContain('assistant');
|
||||
expect(keywords).toContain('get_entities');
|
||||
});
|
||||
|
||||
it('extracts from string arguments', () => {
|
||||
const keywords = extractKeywordsFromToolCall('tool', { domain: 'light', area: 'kitchen' });
|
||||
expect(keywords).toContain('light');
|
||||
expect(keywords).toContain('kitchen');
|
||||
});
|
||||
|
||||
it('ignores short words (<=2 chars)', () => {
|
||||
const keywords = extractKeywordsFromToolCall('ab', { x: 'hi' });
|
||||
expect(keywords).not.toContain('ab');
|
||||
expect(keywords).not.toContain('hi');
|
||||
});
|
||||
|
||||
it('ignores long string values (>200 chars)', () => {
|
||||
const keywords = extractKeywordsFromToolCall('tool', { data: 'x'.repeat(201) });
|
||||
// Only 'tool' from the name
|
||||
expect(keywords).toEqual(['tool']);
|
||||
});
|
||||
|
||||
it('caps at 10 keywords', () => {
|
||||
const args: Record<string, string> = {};
|
||||
for (let i = 0; i < 20; i++) args[`key${i}`] = `keyword${i}value`;
|
||||
const keywords = extractKeywordsFromToolCall('tool', args);
|
||||
expect(keywords.length).toBeLessThanOrEqual(10);
|
||||
});
|
||||
|
||||
it('lowercases all keywords', () => {
|
||||
const keywords = extractKeywordsFromToolCall('MyTool', { name: 'MQTT' });
|
||||
expect(keywords).toContain('mytool');
|
||||
expect(keywords).toContain('mqtt');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user