diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index eb27698..acc6c38 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -185,10 +185,10 @@ _mcpctl() { COMPREPLY=($(compgen -W "--data --force -h --help" -- "$cur")) ;; llm) - COMPREPLY=($(compgen -W "--type --model --url --tier --description --api-key-ref --extra --pool-name --force --skip-auth-check -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "--type --model --url --tier --description --api-key-ref --extra --pool-name --visibility --force --skip-auth-check -h --help" -- "$cur")) ;; agent) - COMPREPLY=($(compgen -W "--llm --project --description --system-prompt --system-prompt-file --proxy-model --default-temperature --default-top-p --default-top-k --default-max-tokens --default-seed --default-stop --default-extra --default-params-file --force -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "--llm --project --description --system-prompt --system-prompt-file --proxy-model --default-temperature --default-top-p --default-top-k --default-max-tokens --default-seed --default-stop --default-extra --default-params-file --visibility --force -h --help" -- "$cur")) ;; secretbackend) COMPREPLY=($(compgen -W "--type --description --default --url --namespace --mount --path-prefix --auth --token-secret --role --auth-mount --sa-token-path --config --wizard --setup-token --policy-name --token-role --no-promote-default --force -h --help" -- "$cur")) diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index e4162a2..80c131f 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -335,6 +335,7 @@ complete -c mcpctl -n "__mcpctl_subcmd_active create llm" -l description -d 'Des complete -c mcpctl -n "__mcpctl_subcmd_active create llm" -l api-key-ref -d 'API key reference in SECRET/KEY form (e.g. anthropic-key/token)' -x complete -c mcpctl -n "__mcpctl_subcmd_active create llm" -l extra -d 'Extra config key=value (repeat)' -x complete -c mcpctl -n "__mcpctl_subcmd_active create llm" -l pool-name -d 'Stack with other Llms sharing this pool name; agents pinned to any member dispatch across the pool' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create llm" -l visibility -d 'Visibility scope: public (everyone) or private (only owner + name-grants)' -x complete -c mcpctl -n "__mcpctl_subcmd_active create llm" -l force -d 'Update if already exists' complete -c mcpctl -n "__mcpctl_subcmd_active create llm" -l skip-auth-check -d 'Skip the upstream auth probe (for offline registration before infra exists)' @@ -353,6 +354,7 @@ complete -c mcpctl -n "__mcpctl_subcmd_active create agent" -l default-seed -d ' complete -c mcpctl -n "__mcpctl_subcmd_active create agent" -l default-stop -d 'Default stop sequence (repeat for multiple)' -x complete -c mcpctl -n "__mcpctl_subcmd_active create agent" -l default-extra -d 'Default provider-specific knob k=v (repeat)' -x complete -c mcpctl -n "__mcpctl_subcmd_active create agent" -l default-params-file -d 'Read defaultParams from a JSON file' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create agent" -l visibility -d 'Visibility scope: public (everyone) or private (only owner + name-grants)' -x complete -c mcpctl -n "__mcpctl_subcmd_active create agent" -l force -d 'Update if already exists' # create secretbackend options diff --git a/src/cli/src/commands/create.ts b/src/cli/src/commands/create.ts index d0f4d69..95a35d3 100644 --- a/src/cli/src/commands/create.ts +++ b/src/cli/src/commands/create.ts @@ -264,6 +264,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { .option('--api-key-ref ', 'API key reference in SECRET/KEY form (e.g. anthropic-key/token)') .option('--extra ', 'Extra config key=value (repeat)', collect, []) .option('--pool-name ', 'Stack with other Llms sharing this pool name; agents pinned to any member dispatch across the pool') + .option('--visibility ', '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 ', 'Default stop sequence (repeat for multiple)', collect, []) .option('--default-extra ', 'Default provider-specific knob k=v (repeat)', collect, []) .option('--default-params-file ', 'Read defaultParams from a JSON file') + .option('--visibility ', '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 = { @@ -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) { diff --git a/src/cli/src/commands/get.ts b/src/cli/src/commands/get.ts index 445bc6d..5c1ca16 100644 --- a/src/cli/src/commands/get.ts +++ b/src/cli/src/commands/get.ts @@ -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[] = [ { 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[] = [ { 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 }, diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index d53828f..8269e7c 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -747,14 +747,48 @@ async function main(): Promise { 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>).filter((item) => { - const name = item['name']; - return typeof name === 'string' && request.rbacScope!.names.has(name); - }); + let items = payload as Array>; + // 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 diff --git a/src/mcpd/src/middleware/auth.ts b/src/mcpd/src/middleware/auth.ts index 0bc3a3c..741ad66 100644 --- a/src/mcpd/src/middleware/auth.ts +++ b/src/mcpd/src/middleware/auth.ts @@ -33,7 +33,14 @@ export interface AuthDeps { declare module 'fastify' { interface FastifyRequest { userId?: string; - rbacScope?: { wildcard: boolean; names: Set }; + /** + * 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 }; /** Set by the auth hook when the caller authenticated via a McpToken bearer (prefix `mcpctl_pat_`). */ mcpToken?: McpTokenPrincipal; } diff --git a/src/mcpd/src/routes/agents.ts b/src/mcpd/src/routes/agents.ts index 5e3e633..d2fbf09 100644 --- a/src/mcpd/src/routes/agents.ts +++ b/src/mcpd/src/routes/agents.ts @@ -6,21 +6,33 @@ * — the resource is `agents`. The chat endpoints live in `agent-chat.ts` and * map to `run:agents:`. */ -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); } diff --git a/src/mcpd/src/routes/llms.ts b/src/mcpd/src/routes/llms.ts index b153a9a..740e8d1 100644 --- a/src/mcpd/src/routes/llms.ts +++ b/src/mcpd/src/routes/llms.ts @@ -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 ` 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); } diff --git a/src/mcpd/src/routes/virtual-llms.ts b/src/mcpd/src/routes/virtual-llms.ts index fbde2ba..be86d1f 100644 --- a/src/mcpd/src/routes/virtual-llms.ts +++ b/src/mcpd/src/routes/virtual-llms.ts @@ -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; } + // 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; 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; } diff --git a/src/mcpd/src/services/agent.service.ts b/src/mcpd/src/services/agent.service.ts index 4875766..440d4d2 100644 --- a/src/mcpd/src/services/agent.service.ts +++ b/src/mcpd/src/services/agent.service.ts @@ -78,6 +78,12 @@ export interface VirtualAgentInput { project?: string; defaultParams?: Record; extras?: Record; + /** + * 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', diff --git a/src/mcpd/src/services/rbac.service.ts b/src/mcpd/src/services/rbac.service.ts index ddc7fe2..db76359 100644 --- a/src/mcpd/src/services/rbac.service.ts +++ b/src/mcpd/src/services/rbac.service.ts @@ -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; } @@ -97,6 +107,8 @@ export class RbacService { const permissions = await this.getPermissions(userId, serviceAccountName, mcpTokenSha); const normalized = normalizeResource(resource); const names = new Set(); + 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 }; } /** diff --git a/src/mcpd/src/services/virtual-llm.service.ts b/src/mcpd/src/services/virtual-llm.service.ts index 7a6a8b2..594435f 100644 --- a/src/mcpd/src/services/virtual-llm.service.ts +++ b/src/mcpd/src/services/virtual-llm.service.ts @@ -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; + register(input: { providerSessionId?: string | null; providers: RegisterProviderInput[]; ownerId?: string }): Promise; heartbeat(providerSessionId: string): Promise; bindSession(providerSessionId: string, handle: VirtualSessionHandle): void; unbindSession(providerSessionId: string): Promise; @@ -193,7 +199,7 @@ export class VirtualLlmService implements IVirtualLlmService { private readonly resolveOwner: () => string = () => 'system', ) {} - async register(input: { providerSessionId?: string | null; providers: RegisterProviderInput[] }): Promise { + async register(input: { providerSessionId?: string | null; providers: RegisterProviderInput[]; ownerId?: string }): Promise { 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, diff --git a/src/mcpd/tests/virtual-llm-routes.test.ts b/src/mcpd/tests/virtual-llm-routes.test.ts index a4477bc..1e4c47f 100644 --- a/src/mcpd/tests/virtual-llm-routes.test.ts +++ b/src/mcpd/tests/virtual-llm-routes.test.ts @@ -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' }); }); diff --git a/src/mcplocal/src/http/config.ts b/src/mcplocal/src/http/config.ts index b99ebb9..3290280 100644 --- a/src/mcplocal/src/http/config.ts +++ b/src/mcplocal/src/http/config.ts @@ -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:` 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; extras?: Record; + /** v7: see LlmProviderFileEntry.visibility — same default ('private'). */ + visibility?: 'public' | 'private'; } /** diff --git a/src/mcplocal/src/main.ts b/src/mcplocal/src/main.ts index 3dc842a..892b95f 100644 --- a/src/mcplocal/src/main.ts +++ b/src/mcplocal/src/main.ts @@ -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); } diff --git a/src/mcplocal/src/providers/registrar.ts b/src/mcplocal/src/providers/registrar.ts index 5e2b17e..4069040 100644 --- a/src/mcplocal/src/providers/registrar.ts +++ b/src/mcplocal/src/providers/registrar.ts @@ -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; extras?: Record; + /** 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', })); }