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 },

View File

@@ -747,14 +747,48 @@ async function main(): Promise<void> {
registerGitBackupRoutes(app, gitBackup);
// ── RBAC list filtering hook ──
// Filters array responses to only include resources the user is allowed to see.
//
// Two filters compose here, in order:
//
// 1. RBAC name-scope (existing): when a caller has only name-scoped
// grants (no resource-wide), only items whose name is in their
// grants set pass through. wildcard=true skips this step.
//
// 2. v7 visibility filter (new): private rows are hidden from
// callers who aren't the owner and don't have a name-scoped
// grant. Skipped for `*`-resource admins (isAdmin=true) so
// org-wide audit/list operations still work. Importantly, this
// runs even when wildcard=true — that's how a regular `view:llms`
// grant stops broadcasting private virtuals across the org while
// still letting `*`-grant admins see everything.
//
// Items without a `visibility` field (today: every resource other
// than Llm and Agent) pass through this step unchanged.
app.addHook('preSerialization', async (request, _reply, payload) => {
if (!request.rbacScope || request.rbacScope.wildcard) return payload;
if (!request.rbacScope) return payload;
if (!Array.isArray(payload)) return payload;
return (payload as Array<Record<string, unknown>>).filter((item) => {
const name = item['name'];
return typeof name === 'string' && request.rbacScope!.names.has(name);
});
let items = payload as Array<Record<string, unknown>>;
// Step 1: RBAC name-scope.
if (!request.rbacScope.wildcard) {
items = items.filter((item) => {
const name = item['name'];
return typeof name === 'string' && request.rbacScope!.names.has(name);
});
}
// Step 2: visibility (only meaningful when the resource carries it).
if (!request.rbacScope.isAdmin) {
const userId = request.userId;
items = items.filter((item) => {
const visibility = item['visibility'];
if (visibility !== 'private') return true;
const ownerId = item['ownerId'];
if (typeof ownerId === 'string' && ownerId === userId) return true;
const name = item['name'];
if (typeof name === 'string' && request.rbacScope!.names.has(name)) return true;
return false;
});
}
return items;
});
// Web UI: served from /ui (static SPA bundle). Falls through to API

View File

@@ -33,7 +33,14 @@ export interface AuthDeps {
declare module 'fastify' {
interface FastifyRequest {
userId?: string;
rbacScope?: { wildcard: boolean; names: Set<string> };
/**
* v7: extended with `isAdmin` to distinguish `*` (cross-resource
* admin) grants from plain `view:llms` resource-wide grants. The
* preSerialization filter and the route-level Viewer use this to
* decide whether to skip the visibility filter (admins bypass;
* regular wildcard does not).
*/
rbacScope?: { wildcard: boolean; isAdmin: boolean; names: Set<string> };
/** Set by the auth hook when the caller authenticated via a McpToken bearer (prefix `mcpctl_pat_`). */
mcpToken?: McpTokenPrincipal;
}

View File

@@ -6,21 +6,33 @@
* — the resource is `agents`. The chat endpoints live in `agent-chat.ts` and
* map to `run:agents:<name>`.
*/
import type { FastifyInstance } from 'fastify';
import type { AgentService } from '../services/agent.service.js';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import type { AgentService, AgentViewer } from '../services/agent.service.js';
import { NotFoundError, ConflictError } from '../services/mcp-server.service.js';
/** v7: thread the request's RBAC scope into the service so foreign-private rows 404. */
function viewerFromRequest(request: FastifyRequest): AgentViewer | null {
if (request.userId === undefined || request.rbacScope === undefined) return null;
return {
userId: request.userId,
wildcard: request.rbacScope.isAdmin,
allowedNames: request.rbacScope.names,
};
}
export function registerAgentRoutes(
app: FastifyInstance,
service: AgentService,
): void {
app.get('/api/v1/agents', async () => {
// List filter is applied by the preSerialization hook (visibility +
// RBAC name-scope); service stays viewer-blind for internal callers.
return service.list();
});
app.get<{ Params: { id: string } }>('/api/v1/agents/:id', async (request, reply) => {
try {
return await getByIdOrName(service, request.params.id);
return await getByIdOrName(service, request.params.id, viewerFromRequest(request));
} catch (err) {
if (err instanceof NotFoundError) {
reply.code(404);
@@ -51,7 +63,7 @@ export function registerAgentRoutes(
app.put<{ Params: { id: string } }>('/api/v1/agents/:id', async (request, reply) => {
try {
const target = await getByIdOrName(service, request.params.id);
const target = await getByIdOrName(service, request.params.id, viewerFromRequest(request));
return await service.update(target.id, request.body);
} catch (err) {
if (err instanceof NotFoundError) {
@@ -64,7 +76,7 @@ export function registerAgentRoutes(
app.delete<{ Params: { id: string } }>('/api/v1/agents/:id', async (request, reply) => {
try {
const target = await getByIdOrName(service, request.params.id);
const target = await getByIdOrName(service, request.params.id, viewerFromRequest(request));
await service.delete(target.id);
reply.code(204);
return null;
@@ -84,7 +96,7 @@ export function registerAgentRoutes(
'/api/v1/projects/:projectName/agents',
async (request, reply) => {
try {
return await service.listByProject(request.params.projectName);
return await service.listByProject(request.params.projectName, viewerFromRequest(request));
} catch (err) {
if (err instanceof NotFoundError) {
reply.code(404);
@@ -98,9 +110,9 @@ export function registerAgentRoutes(
const CUID_RE = /^c[a-z0-9]{24}/i;
async function getByIdOrName(service: AgentService, idOrName: string) {
async function getByIdOrName(service: AgentService, idOrName: string, viewer: AgentViewer | null = null) {
if (CUID_RE.test(idOrName)) {
return service.getById(idOrName);
return service.getById(idOrName, viewer);
}
return service.getByName(idOrName);
return service.getByName(idOrName, viewer);
}

View File

@@ -1,13 +1,32 @@
import type { FastifyInstance } from 'fastify';
import type { LlmService, LlmView } from '../services/llm.service.js';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import type { LlmService, LlmView, Viewer } from '../services/llm.service.js';
import { LlmAuthVerificationError, effectivePoolName } from '../services/llm.service.js';
import { NotFoundError, ConflictError } from '../services/mcp-server.service.js';
/**
* v7: build a Viewer from the request's RBAC scope so the service
* applies the visibility filter consistently. The list endpoint relies
* on the preSerialization hook for the same logic; for get-by-name/id
* the service does the filter itself and 404s on a hidden row.
*/
function viewerFromRequest(request: FastifyRequest): Viewer | null {
if (request.userId === undefined || request.rbacScope === undefined) return null;
return {
userId: request.userId,
wildcard: request.rbacScope.isAdmin, // only admins skip the visibility filter
allowedNames: request.rbacScope.names,
};
}
export function registerLlmRoutes(
app: FastifyInstance,
service: LlmService,
): void {
app.get('/api/v1/llms', async () => {
// List goes through the preSerialization hook which applies both
// RBAC name-scope and v7 visibility filter — service stays
// viewer-blind here so internal callers (audit, sweeps) keep
// working without a request context.
return service.list();
});
@@ -16,7 +35,7 @@ export function registerLlmRoutes(
// hands over the user-facing name to avoid an extra round-trip).
app.get<{ Params: { id: string } }>('/api/v1/llms/:id', async (request, reply) => {
try {
return await getByIdOrName(service, request.params.id);
return await getByIdOrName(service, request.params.id, viewerFromRequest(request));
} catch (err) {
if (err instanceof NotFoundError) {
reply.code(404);
@@ -96,8 +115,20 @@ export function registerLlmRoutes(
// size + activeCount.
app.get<{ Params: { name: string } }>('/api/v1/llms/:name/members', async (request, reply) => {
try {
const anchor = await getByIdOrName(service, request.params.name);
const members = await service.listPoolMembers(effectivePoolName(anchor));
const viewer = viewerFromRequest(request);
const anchor = await getByIdOrName(service, request.params.name, viewer);
const allMembers = await service.listPoolMembers(effectivePoolName(anchor));
// v7: filter pool members by visibility too — without this, a
// pool with private members would leak their names through the
// /members endpoint even though the list endpoint hides them.
const members = viewer === null
? allMembers
: allMembers.filter((m) => {
if (m.visibility !== 'private') return true;
if (m.ownerId !== null && m.ownerId === viewer.userId) return true;
if (viewer.allowedNames.has(m.name)) return true;
return viewer.wildcard;
});
return {
poolName: effectivePoolName(anchor),
explicitPoolName: anchor.poolName,
@@ -131,11 +162,12 @@ const CUID_RE = /^c[a-z0-9]{24}/i;
/**
* Look up by CUID first; if the input doesn't look like one, fall back to
* findByName. Lets the same URL serve both `mcpctl describe llm <name>` and
* the FailoverRouter's name-based RBAC check.
* the FailoverRouter's name-based RBAC check. v7: the optional viewer
* threads through to the service so foreign-private rows 404 cleanly.
*/
async function getByIdOrName(service: LlmService, idOrName: string) {
async function getByIdOrName(service: LlmService, idOrName: string, viewer: Viewer | null = null) {
if (CUID_RE.test(idOrName)) {
return service.getById(idOrName);
return service.getById(idOrName, viewer);
}
return service.getByName(idOrName);
return service.getByName(idOrName, viewer);
}

View File

@@ -45,9 +45,14 @@ export function registerVirtualLlmRoutes(
const agentsInput = Array.isArray(body.agents) ? body.agents : null;
try {
// v7: ownerId from the authenticated request lands on every
// newly-created virtual Llm row (sticky reconnects update the
// existing row's ownerId too — same publisher, same session).
const ownerId = request.userId ?? 'system';
const result = await service.register({
providerSessionId: body.providerSessionId ?? null,
providers: providers.map(coerceProviderInput),
ownerId,
});
// v3: atomically publish virtual agents tied to the same session.
// If the caller didn't include an agents array, skip silently.
@@ -189,6 +194,10 @@ function coerceAgentInput(raw: unknown): VirtualAgentInput {
if (o['extras'] !== null && typeof o['extras'] === 'object') {
out.extras = o['extras'] as Record<string, unknown>;
}
// v7: optional visibility scope (defaults to 'private' on first publish).
if (o['visibility'] === 'public' || o['visibility'] === 'private') {
out.visibility = o['visibility'];
}
return out;
}
@@ -202,6 +211,7 @@ function coerceProviderInput(raw: unknown): {
extraConfig?: Record<string, unknown>;
initialStatus?: 'active' | 'hibernating';
poolName?: string;
visibility?: 'public' | 'private';
} {
if (raw === null || typeof raw !== 'object') {
throw Object.assign(new Error('provider entry must be an object'), { statusCode: 400 });
@@ -234,5 +244,10 @@ function coerceProviderInput(raw: unknown): {
if (typeof o['poolName'] === 'string' && /^[a-z0-9-]+$/.test(o['poolName']) && o['poolName'].length >= 1 && o['poolName'].length <= 100) {
out.poolName = o['poolName'];
}
// v7: visibility. Only 'public' or 'private'; unknown values fall
// through to the service default ('private' for virtuals).
if (o['visibility'] === 'public' || o['visibility'] === 'private') {
out.visibility = o['visibility'];
}
return out;
}

View File

@@ -78,6 +78,12 @@ export interface VirtualAgentInput {
project?: string;
defaultParams?: Record<string, unknown>;
extras?: Record<string, unknown>;
/**
* v7: per-user RBAC scoping. When omitted, virtual agents default to
* 'private' on register — same shape as virtual Llms. The publisher
* can override per-agent in mcplocal config.
*/
visibility?: 'public' | 'private';
}
export class AgentService {
@@ -292,6 +298,10 @@ export class AgentService {
projectId,
...(a.defaultParams !== undefined ? { defaultParams: a.defaultParams } : {}),
...(a.extras !== undefined ? { extras: a.extras } : {}),
// v7: only update visibility on sticky reconnect when the
// publisher explicitly sent it — operators may have flipped
// a virtual agent to public manually via `mcpctl edit agent`.
...(a.visibility !== undefined ? { visibility: a.visibility } : {}),
kind: 'virtual',
providerSessionId: sessionId,
status: 'active',
@@ -309,6 +319,8 @@ export class AgentService {
projectId,
...(a.defaultParams !== undefined ? { defaultParams: a.defaultParams } : {}),
...(a.extras !== undefined ? { extras: a.extras } : {}),
// v7: virtual agents default to private on first publish.
visibility: a.visibility ?? 'private',
kind: 'virtual',
providerSessionId: sessionId,
status: 'active',

View File

@@ -24,7 +24,17 @@ export interface OperationPermission {
export type Permission = ResourcePermission | OperationPermission;
export interface AllowedScope {
/** True iff the user has a resource-wide grant for this resource (no name constraint). */
wildcard: boolean;
/**
* v7: true iff the user has `*` (cross-resource admin) grant. The
* visibility filter (per-user RBAC scoping for virtual Llms/Agents)
* skips itself when this is set — admins see private rows from any
* owner, just like before v7. Plain `view:llms` resource-wide grants
* set `wildcard=true` but `isAdmin=false`, so private rows from other
* users are still hidden in the list view.
*/
isAdmin: boolean;
names: Set<string>;
}
@@ -97,6 +107,8 @@ export class RbacService {
const permissions = await this.getPermissions(userId, serviceAccountName, mcpTokenSha);
const normalized = normalizeResource(resource);
const names = new Set<string>();
let wildcard = false;
let isAdmin = false;
for (const perm of permissions) {
if (!('resource' in perm)) continue;
@@ -105,12 +117,22 @@ export class RbacService {
if (!actions.includes(action)) continue;
const permResource = normalizeResource(perm.resource);
if (permResource !== '*' && permResource !== normalized) continue;
// Unscoped binding → wildcard access to this resource
if (perm.name === undefined) return { wildcard: true, names: new Set() };
names.add(perm.name);
// Unscoped binding → wildcard access to this resource. v7: also
// record whether the binding came from a `*` (cross-resource
// admin) grant — that's the only one that bypasses the
// visibility filter for private rows from other owners.
if (perm.name === undefined) {
wildcard = true;
if (permResource === '*') isAdmin = true;
// Don't return early — keep scanning so isAdmin can flip true
// even if a more-specific binding matched first.
} else {
names.add(perm.name);
}
}
return { wildcard: false, names };
if (wildcard) return { wildcard: true, isAdmin, names: new Set() };
return { wildcard: false, isAdmin: false, names };
}
/**

View File

@@ -58,6 +58,12 @@ export interface RegisterProviderInput {
* shares the `poolName` with siblings.
*/
poolName?: string;
/**
* v7: per-user RBAC scoping. When omitted, virtuals default to
* 'private' (visible only to the publishing user). Set explicitly
* to 'public' for org-wide sharing.
*/
visibility?: 'public' | 'private';
}
export interface RegisterResult {
@@ -134,7 +140,7 @@ export interface EnqueueInferOptions {
}
export interface IVirtualLlmService {
register(input: { providerSessionId?: string | null; providers: RegisterProviderInput[] }): Promise<RegisterResult>;
register(input: { providerSessionId?: string | null; providers: RegisterProviderInput[]; ownerId?: string }): Promise<RegisterResult>;
heartbeat(providerSessionId: string): Promise<void>;
bindSession(providerSessionId: string, handle: VirtualSessionHandle): void;
unbindSession(providerSessionId: string): Promise<void>;
@@ -193,7 +199,7 @@ export class VirtualLlmService implements IVirtualLlmService {
private readonly resolveOwner: () => string = () => 'system',
) {}
async register(input: { providerSessionId?: string | null; providers: RegisterProviderInput[] }): Promise<RegisterResult> {
async register(input: { providerSessionId?: string | null; providers: RegisterProviderInput[]; ownerId?: string }): Promise<RegisterResult> {
const sessionId = input.providerSessionId ?? randomUUID();
const now = new Date();
const llms: Llm[] = [];
@@ -210,6 +216,12 @@ export class VirtualLlmService implements IVirtualLlmService {
description: p.description ?? '',
...(p.extraConfig !== undefined ? { extraConfig: p.extraConfig } : {}),
...(p.poolName !== undefined ? { poolName: p.poolName } : {}),
// v7: virtuals default to private ownership. The publisher
// (passed through from the route's authenticated userId)
// owns the row; mcplocal's defaulting + the publisher's
// explicit override land here.
ownerId: input.ownerId ?? null,
visibility: p.visibility ?? 'private',
kind: 'virtual',
providerSessionId: sessionId,
status: initialStatus,
@@ -244,6 +256,11 @@ export class VirtualLlmService implements IVirtualLlmService {
...(p.description !== undefined ? { description: p.description } : {}),
...(p.extraConfig !== undefined ? { extraConfig: p.extraConfig } : {}),
...(p.poolName !== undefined ? { poolName: p.poolName } : {}),
// v7: only update visibility on sticky reconnect when the
// publisher explicitly sent it — operators may have flipped a
// virtual to public manually via `mcpctl edit llm`, and we
// don't want a routine reconnect to clobber that.
...(p.visibility !== undefined ? { visibility: p.visibility } : {}),
kind: 'virtual',
providerSessionId: sessionId,
status: initialStatus,

View File

@@ -75,6 +75,7 @@ describe('POST /api/v1/llms/_provider-register', () => {
expect(register).toHaveBeenCalledWith({
providerSessionId: 'sess-xyz',
providers: [{ name: 'vllm-local', type: 'openai', model: 'm', tier: 'fast', extraConfig: { gpu: 1 } }],
ownerId: 'system',
});
expect(res.json()).toMatchObject({ providerSessionId: 'sess-xyz' });
});

View File

@@ -105,6 +105,14 @@ export interface LlmProviderFileEntry {
* logical pool that auto-grows as more workers come online.
*/
poolName?: string;
/**
* v7: per-user RBAC scoping. mcplocal-published virtuals default to
* 'private' on register — the publishing user owns the row and other
* users don't see it without an explicit `view:llms:<name>` grant.
* Set to 'public' here to opt into org-wide sharing for this
* provider.
*/
visibility?: 'public' | 'private';
}
export type WakeRecipe =
@@ -136,6 +144,8 @@ export interface AgentFileEntry {
project?: string;
defaultParams?: Record<string, unknown>;
extras?: Record<string, unknown>;
/** v7: see LlmProviderFileEntry.visibility — same default ('private'). */
visibility?: 'public' | 'private';
}
/**

View File

@@ -233,6 +233,10 @@ async function maybeStartVirtualLlmRegistrar(
if (entry.wake !== undefined) item.wake = entry.wake;
if (entry.poolName !== undefined) item.poolName = entry.poolName;
if (wireName !== provider.name) item.publishName = wireName;
// v7: pass visibility through; registrar already defaults to
// 'private' when omitted, and the per-provider override flows
// straight through to the register payload.
if (entry.visibility !== undefined) item.visibility = entry.visibility;
published.push(item);
}
// v3: forward locally-declared agents alongside the providers. We
@@ -255,6 +259,7 @@ async function maybeStartVirtualLlmRegistrar(
if (a.project !== undefined) item.project = a.project;
if (a.defaultParams !== undefined) item.defaultParams = a.defaultParams;
if (a.extras !== undefined) item.extras = a.extras;
if (a.visibility !== undefined) item.visibility = a.visibility;
publishedAgents.push(item);
}

View File

@@ -72,6 +72,14 @@ export interface RegistrarPublishedProvider {
* `publishName ?? provider.name` everywhere.
*/
publishName?: string;
/**
* v7: per-user RBAC scoping. mcplocal-published virtuals default to
* 'private' (visible only to the publishing user) — workstations
* shouldn't broadcast their models org-wide unless explicitly
* shared. The publisher can override per provider with
* `"visibility": "public"` in their mcplocal config.
*/
visibility?: 'public' | 'private';
}
/**
@@ -88,6 +96,8 @@ export interface RegistrarPublishedAgent {
project?: string;
defaultParams?: Record<string, unknown>;
extras?: Record<string, unknown>;
/** v7: per-user RBAC scoping, defaults to 'private' on register. */
visibility?: 'public' | 'private';
}
export interface RegistrarOptions {
@@ -207,6 +217,10 @@ export class VirtualLlmRegistrar {
...(p.tier !== undefined ? { tier: p.tier } : {}),
...(p.description !== undefined ? { description: p.description } : {}),
...(p.poolName !== undefined ? { poolName: p.poolName } : {}),
// v7: virtuals default to private. Operators who want their
// workstation model org-visible set "visibility": "public" per
// provider in mcplocal config.
visibility: p.visibility ?? 'private',
initialStatus,
};
}));
@@ -224,6 +238,9 @@ export class VirtualLlmRegistrar {
...(a.project !== undefined ? { project: a.project } : {}),
...(a.defaultParams !== undefined ? { defaultParams: a.defaultParams } : {}),
...(a.extras !== undefined ? { extras: a.extras } : {}),
// v7: forward visibility to mcpd. Defaults to 'private' for
// virtual agents on the server side when omitted.
visibility: a.visibility ?? 'private',
}));
}