feat(mcpd+cli+mcplocal): wire visibility filter through routes, CLI, registrar (v7 Stage 2)

Stage 1 added the schema + service predicate. This stage threads the
filter through every surface that lists or fetches Llms/Agents:

- mcpd routes: viewerFromRequest helper builds a Viewer from the
  request's RBAC scope. List endpoints rely on the existing
  preSerialization hook (now two-phase: name-scope first, visibility
  second). get-by-id/get-by-name routes pass the viewer to the service
  which 404s on hidden rows.
- RBAC: AllowedScope gains `isAdmin` to distinguish a `*` cross-resource
  grant (admins skip visibility) from a plain `view:llms` grant
  (wildcard for RBAC, but visibility still applies). FastifyRequest
  augmentation updated.
- VirtualLlmService.register accepts ownerId and stamps it on freshly
  created virtual rows; defaults visibility to 'private' on first
  create, leaves existing rows untouched on sticky reconnect.
- AgentService.registerVirtualAgents mirrors the same defaults.
- mcplocal: LlmProviderFileEntry / AgentFileEntry / RegistrarPublishedX
  carry visibility through to the register payload (default 'private').
- CLI: VISIBILITY column on `mcpctl get llm` and `mcpctl get agent`,
  `--visibility` flag on `mcpctl create llm` / `create agent`. YAML
  round-trip works because visibility passes through stripInternalFields
  unchanged (ownerId is already stripped). Completions regenerated.

Tests: mcpd 908/908, mcplocal 731/731, cli 437/437.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michal
2026-04-29 01:03:58 +01:00
parent 21f8bede2e
commit 2c98a21323
16 changed files with 241 additions and 33 deletions

View File

@@ -264,6 +264,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
.option('--api-key-ref <ref>', 'API key reference in SECRET/KEY form (e.g. anthropic-key/token)')
.option('--extra <entry>', 'Extra config key=value (repeat)', collect, [])
.option('--pool-name <pool>', 'Stack with other Llms sharing this pool name; agents pinned to any member dispatch across the pool')
.option('--visibility <scope>', 'Visibility scope: public (everyone) or private (only owner + name-grants)', 'public')
.option('--force', 'Update if already exists')
.option('--skip-auth-check', 'Skip the upstream auth probe (for offline registration before infra exists)')
.action(async (name: string, opts) => {
@@ -276,6 +277,12 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
if (opts.url) body.url = opts.url;
if (opts.description !== undefined) body.description = opts.description;
if (opts.poolName !== undefined) body.poolName = opts.poolName;
if (opts.visibility !== undefined) {
if (opts.visibility !== 'public' && opts.visibility !== 'private') {
throw new Error(`Invalid --visibility '${opts.visibility as string}'. Expected 'public' or 'private'`);
}
body.visibility = opts.visibility;
}
if (opts.apiKeyRef) {
const slashIdx = (opts.apiKeyRef as string).indexOf('/');
if (slashIdx < 1) throw new Error(`Invalid --api-key-ref '${opts.apiKeyRef as string}'. Expected SECRET_NAME/KEY_NAME`);
@@ -333,6 +340,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
.option('--default-stop <text>', 'Default stop sequence (repeat for multiple)', collect, [])
.option('--default-extra <kv>', 'Default provider-specific knob k=v (repeat)', collect, [])
.option('--default-params-file <path>', 'Read defaultParams from a JSON file')
.option('--visibility <scope>', 'Visibility scope: public (everyone) or private (only owner + name-grants)', 'public')
.option('--force', 'Update if already exists')
.action(async (name: string, opts) => {
const body: Record<string, unknown> = {
@@ -341,6 +349,12 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
};
if (opts.project) body.project = { name: opts.project };
if (opts.description !== undefined) body.description = opts.description;
if (opts.visibility !== undefined) {
if (opts.visibility !== 'public' && opts.visibility !== 'private') {
throw new Error(`Invalid --visibility '${opts.visibility as string}'. Expected 'public' or 'private'`);
}
body.visibility = opts.visibility;
}
let systemPrompt = opts.systemPrompt as string | undefined;
if (systemPrompt === undefined && opts.systemPromptFile !== undefined) {

View File

@@ -138,6 +138,10 @@ interface LlmRow {
status?: 'active' | 'inactive' | 'hibernating';
// v4: explicit pool key. NULL = solo Llm (effective pool = its own name).
poolName?: string | null;
// v7: visibility scope. Legacy public rows omit it; mcpd defaults missing
// values to 'public' on serialization.
visibility?: 'public' | 'private';
ownerId?: string | null;
}
// v4: POOL column placed right after NAME so an operator can't miss
@@ -148,6 +152,7 @@ const llmColumns: Column<LlmRow>[] = [
{ header: 'POOL', key: (r) => (r.poolName !== null && r.poolName !== undefined && r.poolName !== '') ? r.poolName : '-', width: 18 },
{ header: 'KIND', key: (r) => r.kind ?? 'public', width: 8 },
{ header: 'STATUS', key: (r) => r.status ?? 'active', width: 12 },
{ header: 'VISIBILITY', key: (r) => r.visibility ?? 'public', width: 11 },
{ header: 'TYPE', key: 'type', width: 12 },
{ header: 'MODEL', key: 'model', width: 28 },
{ header: 'TIER', key: 'tier', width: 8 },
@@ -214,12 +219,15 @@ interface AgentRow {
// AgentService as the publishing mcplocal heartbeats and disconnects.
kind?: 'public' | 'virtual';
status?: 'active' | 'inactive';
// v7: visibility — same semantics as Llm. Public legacy agents omit it.
visibility?: 'public' | 'private';
}
const agentColumns: Column<AgentRow>[] = [
{ header: 'NAME', key: 'name' },
{ header: 'KIND', key: (r) => r.kind ?? 'public', width: 8 },
{ header: 'STATUS', key: (r) => r.status ?? 'active', width: 10 },
{ header: 'VISIBILITY', key: (r) => r.visibility ?? 'public', width: 11 },
{ header: 'LLM', key: (r) => r.llm.name, width: 24 },
{ header: 'PROJECT', key: (r) => r.project?.name ?? '-', width: 20 },
{ header: 'DESCRIPTION', key: (r) => truncate(r.description, 50) || '-', width: 50 },