feat: add prompt resources, fix MCP proxy transport, enrich tool descriptions
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled

- Fix MCP proxy to support SSE and STDIO transports (not just HTTP POST)
- Enrich tool descriptions with server context for LLM clarity
- Add Prompt and PromptRequest resources with two-resource RBAC model
- Add propose_prompt MCP tool for LLM to create pending prompt requests
- Add prompt resources visible in MCP resources/list (approved + session's pending)
- Add project-level prompt/instructions in MCP initialize response
- Add ServiceAccount subject type for RBAC (SA identity from X-Service-Account header)
- Add CLI commands: create prompt, get prompts/promptrequests, approve promptrequest
- Add prompts to apply config schema
- 956 tests passing across all packages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-02-24 14:53:00 +00:00
parent 7829f4fb92
commit 079c7b3dfa
32 changed files with 1713 additions and 94 deletions

View File

@@ -76,13 +76,14 @@ const GroupSpecSchema = z.object({
});
const RbacSubjectSchema = z.object({
kind: z.enum(['User', 'Group']),
kind: z.enum(['User', 'Group', 'ServiceAccount']),
name: z.string().min(1),
});
const RESOURCE_ALIASES: Record<string, string> = {
server: 'servers', instance: 'instances', secret: 'secrets',
project: 'projects', template: 'templates', user: 'users', group: 'groups',
prompt: 'prompts', promptrequest: 'promptrequests',
};
const RbacRoleBindingSchema = z.union([
@@ -103,9 +104,16 @@ const RbacBindingSpecSchema = z.object({
roleBindings: z.array(RbacRoleBindingSchema).default([]),
});
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(),
});
const ProjectSpecSchema = z.object({
name: z.string().min(1),
description: z.string().default(''),
prompt: z.string().max(10000).default(''),
proxyMode: z.enum(['direct', 'filtered']).default('direct'),
llmProvider: z.string().optional(),
llmModel: z.string().optional(),
@@ -121,6 +129,7 @@ const ApplyConfigSchema = z.object({
templates: z.array(TemplateSpecSchema).default([]),
rbacBindings: z.array(RbacBindingSpecSchema).default([]),
rbac: z.array(RbacBindingSpecSchema).default([]),
prompts: z.array(PromptSpecSchema).default([]),
}).transform((data) => ({
...data,
// Merge rbac into rbacBindings so both keys work
@@ -158,6 +167,7 @@ export function createApplyCommand(deps: ApplyCommandDeps): Command {
if (config.projects.length > 0) log(` ${config.projects.length} project(s)`);
if (config.templates.length > 0) log(` ${config.templates.length} template(s)`);
if (config.rbacBindings.length > 0) log(` ${config.rbacBindings.length} rbacBinding(s)`);
if (config.prompts.length > 0) log(` ${config.prompts.length} prompt(s)`);
return;
}
@@ -292,6 +302,22 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args
log(`Error applying rbacBinding '${rbacBinding.name}': ${err instanceof Error ? err.message : err}`);
}
}
// Apply prompts
for (const prompt of config.prompts) {
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 });
log(`Updated prompt: ${prompt.name}`);
} else {
await client.post('/api/v1/prompts', prompt);
log(`Created prompt: ${prompt.name}`);
}
} catch (err) {
log(`Error applying prompt '${prompt.name}': ${err instanceof Error ? err.message : err}`);
}
}
}
async function findByName(client: ApiClient, resource: string, name: string): Promise<unknown | null> {

View File

@@ -198,6 +198,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
.option('--proxy-mode <mode>', 'Proxy mode (direct, filtered)')
.option('--proxy-mode-llm-provider <name>', 'LLM provider name (for filtered proxy mode)')
.option('--proxy-mode-llm-model <name>', 'LLM model name (for filtered proxy mode)')
.option('--prompt <text>', 'Project-level prompt / instructions for the LLM')
.option('--server <name>', 'Server name (repeat for multiple)', collect, [])
.option('--force', 'Update if already exists')
.action(async (name: string, opts) => {
@@ -206,6 +207,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
description: opts.description,
proxyMode: opts.proxyMode ?? 'direct',
};
if (opts.prompt) body.prompt = opts.prompt;
if (opts.proxyModeLlmProvider) body.llmProvider = opts.proxyModeLlmProvider;
if (opts.proxyModeLlmModel) body.llmModel = opts.proxyModeLlmModel;
if (opts.server.length > 0) body.servers = opts.server;
@@ -347,5 +349,35 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
}
});
// --- create prompt ---
cmd.command('prompt')
.description('Create an approved prompt')
.argument('<name>', 'Prompt name (lowercase alphanumeric with hyphens)')
.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')
.action(async (name: string, opts) => {
let content = opts.content as string | undefined;
if (opts.contentFile) {
const fs = await import('node:fs/promises');
content = await fs.readFile(opts.contentFile as string, 'utf-8');
}
if (!content) {
throw new Error('--content or --content-file is required');
}
const body: Record<string, unknown> = { name, content };
if (opts.project) {
// Resolve project name to ID
const projects = await client.get<Array<{ id: string; name: string }>>('/api/v1/projects');
const project = projects.find((p) => p.name === opts.project);
if (!project) throw new Error(`Project '${opts.project as string}' not found`);
body.projectId = project.id;
}
const prompt = await client.post<{ id: string; name: string }>('/api/v1/prompts', body);
log(`prompt '${prompt.name}' created (id: ${prompt.id})`);
});
return cmd;
}

View File

@@ -130,6 +130,36 @@ const templateColumns: Column<TemplateRow>[] = [
{ header: 'DESCRIPTION', key: 'description', width: 50 },
];
interface PromptRow {
id: string;
name: string;
projectId: string | null;
createdAt: string;
}
interface PromptRequestRow {
id: string;
name: string;
projectId: string | null;
createdBySession: string | null;
createdAt: string;
}
const promptColumns: Column<PromptRow>[] = [
{ header: 'NAME', key: 'name' },
{ header: 'PROJECT', key: (r) => r.projectId ?? '-', width: 20 },
{ 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: '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' },
];
const instanceColumns: Column<InstanceRow>[] = [
{ header: 'NAME', key: (r) => r.server?.name ?? '-', width: 20 },
{ header: 'STATUS', key: 'status', width: 10 },
@@ -157,6 +187,10 @@ function getColumnsForResource(resource: string): Column<Record<string, unknown>
return groupColumns as unknown as Column<Record<string, unknown>>[];
case 'rbac':
return rbacColumns as unknown as Column<Record<string, unknown>>[];
case 'prompts':
return promptColumns as unknown as Column<Record<string, unknown>>[];
case 'promptrequests':
return promptRequestColumns as unknown as Column<Record<string, unknown>>[];
default:
return [
{ header: 'ID', key: 'id' as keyof Record<string, unknown> },

View File

@@ -1,6 +1,6 @@
import { Command } from 'commander';
import type { ApiClient } from '../api-client.js';
import { resolveNameOrId } from './shared.js';
import { resolveNameOrId, resolveResource } from './shared.js';
export interface ProjectOpsDeps {
client: ApiClient;
@@ -45,3 +45,22 @@ export function createDetachServerCommand(deps: ProjectOpsDeps): Command {
log(`server '${serverName}' detached from project '${projectName}'`);
});
}
export function createApproveCommand(deps: ProjectOpsDeps): Command {
const { client, log } = deps;
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')
.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})`);
});
}

View File

@@ -16,6 +16,11 @@ export const RESOURCE_ALIASES: Record<string, string> = {
rbac: 'rbac',
'rbac-definition': 'rbac',
'rbac-binding': 'rbac',
prompt: 'prompts',
prompts: 'prompts',
promptrequest: 'promptrequests',
promptrequests: 'promptrequests',
pr: 'promptrequests',
};
export function resolveResource(name: string): string {

View File

@@ -12,7 +12,7 @@ import { createCreateCommand } from './commands/create.js';
import { createEditCommand } from './commands/edit.js';
import { createBackupCommand, createRestoreCommand } from './commands/backup.js';
import { createLoginCommand, createLogoutCommand } from './commands/auth.js';
import { createAttachServerCommand, createDetachServerCommand } from './commands/project-ops.js';
import { createAttachServerCommand, createDetachServerCommand, createApproveCommand } from './commands/project-ops.js';
import { createMcpCommand } from './commands/mcp.js';
import { ApiClient, ApiError } from './api-client.js';
import { loadConfig } from './config/index.js';
@@ -151,6 +151,7 @@ export function createProgram(): Command {
};
program.addCommand(createAttachServerCommand(projectOpsDeps), { hidden: true });
program.addCommand(createDetachServerCommand(projectOpsDeps), { hidden: true });
program.addCommand(createApproveCommand(projectOpsDeps));
program.addCommand(createMcpCommand({
getProject: () => program.opts().project as string | undefined,
}), { hidden: true });