Compare commits

...

2 Commits

Author SHA1 Message Date
Michal
ee18c5107e feat(mcpd): auto-create project on virtual-agent register (v6 Stage 2)
Some checks failed
CI/CD / typecheck (pull_request) Successful in 55s
CI/CD / test (pull_request) Successful in 1m12s
CI/CD / lint (pull_request) Successful in 3m0s
CI/CD / smoke (pull_request) Failing after 1m44s
CI/CD / build (pull_request) Successful in 6m41s
CI/CD / publish (pull_request) Has been skipped
Closes the v3-deferred "project must already exist" gap. When a
virtual agent declares `project: "my-team"` and no such project
exists, mcpd creates it idempotently with the publishing user as
owner (instead of throwing 404 from registerVirtualAgents).

ProjectService gains `ensureByName(name, ownerId, opts)` — find
the project or create it with sensible defaults (description carries
an audit note pointing at the registrar; proxyModel/gated take
their schema defaults). First publisher to land on a name owns the
row; subsequent publishers reuse the existing one.

AgentService.registerVirtualAgents calls ensureByName instead of
resolveAndGet, so the same agent register payload works regardless
of whether the project pre-existed or not.

Tests: 2 new tests (auto-creates a missing project on first publish;
reuses an existing project without re-creating). Mock projects
factory rebuilt to track _created names + maintain id→name reverse
lookup so the agent's toView returns the correct project name
(prior mock hardcoded 'mcpctl-dev').

Existing 13 virtual-agent tests + 870 mcpd suite green.
2026-04-28 15:54:27 +01:00
Michal
c346b93789 feat(mcplocal): per-publisher namespacing for virtual Llms/Agents (v6 Stage 1)
Two mcplocals sharing the same config template (`vllm-local-qwen3`)
no longer collide on mcpd's cluster-wide unique-name constraint.
Each publisher can append a suffix derived from hostname (or any
other stable per-host identifier) so the wire-side names become
distinct (`vllm-local-qwen3-alice`, `vllm-local-qwen3-bob`).
Pair with an explicit `poolName` (v4) and the rows still appear as
one logical pool — agents pinned to any member load-balance across
both.

Config (`~/.mcpctl/config.json`):

  {
    "publisher": { "suffix": "auto" }   // → os.hostname() sanitized
                  // or { "suffix": "alice" } for explicit override
  }

Or via env: `MCPCTL_PUBLISHER_SUFFIX=alice` (operations override).

Resolution order: env var → config.publisher.suffix → empty
(legacy behavior, no mangling). Sanitization lowercases, replaces
non-`[a-z0-9-]` runs with `-`, strips leading/trailing dashes —
the result must satisfy mcpd's name validation, otherwise the
register POST would 422.

Wire shape: RegistrarPublishedProvider gets an optional
`publishName` field. When set, the wire payload's `name` is
`publishName` (suffixed); when not, today's `provider.name`.
Inbound infer/wake task lookups match `publishName ?? provider.name`
so the local registry stays addressable by its original name —
SSE frames carrying the suffixed wire name still find their
provider.

Agents are forwarded with their own suffixed name AND a
`llmName` rewritten through the same per-local→wire map so the
agent rows pin to the suffixed Llm wire name (otherwise
registerVirtualAgents would 404).

Tests: 8 new tests covering applyPublisherSuffix (empty, normal,
length limit, exact-100) and loadPublisherSuffix (env override,
absent, sanitization, dash stripping). Existing registrar tests
untouched — no suffix means no behavior change.
2026-04-28 15:54:06 +01:00
7 changed files with 304 additions and 21 deletions

View File

@@ -228,8 +228,18 @@ export class AgentService {
const out: AgentView[] = []; const out: AgentView[] = [];
for (const a of inputs) { for (const a of inputs) {
const llm = await this.llms.getByName(a.llmName); const llm = await this.llms.getByName(a.llmName);
// v6: auto-create the project if it doesn't exist. Publishers
// commonly want their virtual agents pinned to a team-scoped
// project; pre-v6 they had to ask an admin to create the
// project first. ensureByName is idempotent — the second
// publisher landing on the same name reuses the existing row.
// Owner is the publishing user (first writer wins).
const projectId = a.project !== undefined const projectId = a.project !== undefined
? (await this.projects.resolveAndGet(a.project)).id ? (await this.projects.ensureByName(
a.project,
ownerId,
{ auditNote: `Auto-created by virtual-agent registrar for '${a.name}'` },
)).id
: null; : null;
const existing = await this.repo.findByName(a.name); const existing = await this.repo.findByName(a.name);
if (existing !== null) { if (existing !== null) {

View File

@@ -35,6 +35,41 @@ export class ProjectService {
throw new NotFoundError(`Project not found: ${idOrName}`); throw new NotFoundError(`Project not found: ${idOrName}`);
} }
/**
* v6: idempotent get-or-create by name. Used by virtual-agent
* registration so a publisher can declare `project: "my-team"` in
* their mcplocal config and have mcpd auto-create the project on
* first publish, with sensible defaults (description carries an
* audit trail of who created it; proxyModel + gated take their
* schema defaults). Subsequent registers from the same or other
* publishers reuse the existing project — only the publishing user
* who wins the create race owns the row.
*
* Caller passes `ownerId` so the project is owned by whoever first
* triggered the auto-create. Returns the resolved row whether it
* was found or just created.
*/
async ensureByName(name: string, ownerId: string, opts: { auditNote?: string } = {}): Promise<ProjectWithRelations> {
const byName = await this.projectRepo.findByName(name);
if (byName !== null) return byName;
// Validate the name client-side to surface a clean error before
// we touch the DB. Same regex CreateProjectSchema enforces.
if (!/^[a-z0-9-]+$/.test(name) || name.length > 100) {
throw new Error(
`Cannot auto-create project '${name}' — name must be lowercase alphanumeric with hyphens (max 100 chars)`,
);
}
const description = opts.auditNote ?? 'Auto-created by mcplocal virtual-agent registrar';
return this.projectRepo.create({
name,
description,
prompt: '',
ownerId,
proxyModel: '',
gated: true,
});
}
async create(input: unknown, ownerId: string): Promise<ProjectWithRelations> { async create(input: unknown, ownerId: string): Promise<ProjectWithRelations> {
const data = CreateProjectSchema.parse(input); const data = CreateProjectSchema.parse(input);

View File

@@ -140,14 +140,40 @@ function mockLlms(): LlmService {
} as unknown as LlmService; } as unknown as LlmService;
} }
function mockProjects(): ProjectService { function mockProjects(opts: { existingNames?: string[] } = {}): ProjectService & { _created: string[] } {
return { const existing = new Set(opts.existingNames ?? ['mcpctl-dev']);
getById: vi.fn(async (id: string) => ({ id, name: 'mcpctl-dev' })), const created: string[] = [];
resolveAndGet: vi.fn(async (idOrName: string) => ({ // Reverse-lookup id → name so toView's getById finds the project we
id: idOrName === 'mcpctl-dev' ? 'proj-1' : 'proj-other', // ensured/created (instead of always returning a hardcoded name).
name: idOrName, const idToName = new Map<string, string>();
})), const remember = (name: string): { id: string; name: string } => {
} as unknown as ProjectService; const id = `proj-${name}`;
idToName.set(id, name);
return { id, name };
};
// Pre-seed the lookup so existing projects round-trip correctly.
for (const n of existing) remember(n);
const svc = {
_created: created,
getById: vi.fn(async (id: string) => ({ id, name: idToName.get(id) ?? 'mcpctl-dev' })),
resolveAndGet: vi.fn(async (idOrName: string) => {
if (!existing.has(idOrName)) {
const err = new Error(`Project not found: ${idOrName}`);
err.name = 'NotFoundError';
throw err;
}
return remember(idOrName);
}),
// v6: ensureByName auto-creates when missing.
ensureByName: vi.fn(async (name: string, ownerId: string, _opts?: { auditNote?: string }) => {
if (existing.has(name)) return remember(name);
created.push(name);
existing.add(name);
const row = remember(name);
return { ...row, ownerId };
}),
};
return svc as unknown as ProjectService & { _created: string[] };
} }
describe('AgentService — virtual-agent lifecycle (v3 Stage 2)', () => { describe('AgentService — virtual-agent lifecycle (v3 Stage 2)', () => {
@@ -192,6 +218,37 @@ describe('AgentService — virtual-agent lifecycle (v3 Stage 2)', () => {
expect(out[0]!.status).toBe('active'); expect(out[0]!.status).toBe('active');
}); });
it('registerVirtualAgents auto-creates a missing project (v6 Stage 2)', async () => {
// Pre-v6 the publisher had to ask an admin to create the project
// first; otherwise registerVirtualAgents 404'd. v6: ensureByName
// creates with sensible defaults and the publishing user as owner.
const repo = mockAgentRepo();
const projects = mockProjects({ existingNames: [] });
const svc = new AgentService(repo, mockLlms(), projects);
const out = await svc.registerVirtualAgents(
'sess-1',
[{ name: 'team-coder', llmName: 'vllm-local', project: 'platform' }],
'owner-platform',
);
expect(out).toHaveLength(1);
expect(out[0]!.project?.name).toBe('platform');
expect(projects._created).toEqual(['platform']);
});
it('registerVirtualAgents reuses an existing project without re-creating it (v6 Stage 2)', async () => {
// ensureByName must be idempotent — second publisher landing on
// the same project name doesn't try to re-create (would 409).
const repo = mockAgentRepo();
const projects = mockProjects({ existingNames: ['platform'] });
const svc = new AgentService(repo, mockLlms(), projects);
await svc.registerVirtualAgents(
'sess-2',
[{ name: 'team-reviewer', llmName: 'vllm-local', project: 'platform' }],
'owner-other',
);
expect(projects._created).toEqual([]);
});
it('registerVirtualAgents refuses to overwrite a public agent (409)', async () => { it('registerVirtualAgents refuses to overwrite a public agent (409)', async () => {
const repo = mockAgentRepo([makeAgent({ name: 'reviewer', kind: 'public', providerSessionId: null })]); const repo = mockAgentRepo([makeAgent({ name: 'reviewer', kind: 'public', providerSessionId: null })]);
const svc = new AgentService(repo, mockLlms(), mockProjects()); const svc = new AgentService(repo, mockLlms(), mockProjects());

View File

@@ -138,10 +138,30 @@ export interface AgentFileEntry {
extras?: Record<string, unknown>; extras?: Record<string, unknown>;
} }
/**
* v6: per-publisher namespacing config. When a `publisher.suffix` is set,
* mcplocal appends `-<suffix>` to every published Llm/Agent name before
* sending the register payload. Two mcplocals with distinct suffixes can
* publish the same logical name (`vllm-local-qwen3` from a shared
* config template) without colliding on the cluster-wide unique-name
* constraint — they end up as `vllm-local-qwen3-alice` and
* `vllm-local-qwen3-bob`. Pair with an explicit `poolName` (v4) and they
* still appear as one logical pool.
*
* Set to `"auto"` to derive from the system hostname (sanitized to
* `[a-z0-9-]`); explicit string values override. When the field is
* absent or empty, no suffix is applied — this is fully backwards-
* compatible with pre-v6 configs.
*/
export interface PublisherConfig {
suffix?: string;
}
interface McpctlConfig { interface McpctlConfig {
llm?: LlmFileConfig | LlmMultiFileConfig; llm?: LlmFileConfig | LlmMultiFileConfig;
agents?: AgentFileEntry[]; agents?: AgentFileEntry[];
projects?: Record<string, { llm?: ProjectLlmOverride }>; projects?: Record<string, { llm?: ProjectLlmOverride }>;
publisher?: PublisherConfig;
} }
/** Cached config for the process lifetime (reloaded on SIGHUP if needed). */ /** Cached config for the process lifetime (reloaded on SIGHUP if needed). */
@@ -225,6 +245,70 @@ export function loadProjectLlmOverride(projectName: string): ProjectLlmOverride
* Load locally-declared agents from ~/.mcpctl/config.json (v3 virtual * Load locally-declared agents from ~/.mcpctl/config.json (v3 virtual
* agents). Returns empty array if no agents block is configured. * agents). Returns empty array if no agents block is configured.
*/ */
/**
* v6: resolve the per-publisher suffix from config (or env override).
* Returns the empty string when no suffix is configured (today's
* behavior — names pass through unchanged). Sanitizes both literal
* strings and the `"auto"` form to the same `[a-z0-9-]+` charset that
* mcpd's name validation accepts; non-conforming characters get
* replaced with `-` so a hostname like `Alice's-laptop.local` becomes
* `alice-s-laptop-local`.
*
* Resolution order:
* 1. Env `MCPCTL_PUBLISHER_SUFFIX` (operations override)
* 2. config.publisher.suffix === "auto" → os.hostname()
* 3. config.publisher.suffix === literal string
* 4. otherwise empty
*/
export function loadPublisherSuffix(env: Record<string, string | undefined> = process.env): string {
const fromEnv = env['MCPCTL_PUBLISHER_SUFFIX'];
if (fromEnv !== undefined && fromEnv !== '') return sanitizeSuffix(fromEnv);
const config = loadFullConfig();
const suffix = config.publisher?.suffix;
if (suffix === undefined || suffix === '') return '';
if (suffix === 'auto') {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const os = require('node:os') as typeof import('node:os');
return sanitizeSuffix(os.hostname());
}
return sanitizeSuffix(suffix);
}
/**
* v6: apply the suffix to a name. The result must still be a valid
* mcpd resource name (`[a-z0-9-]{1,100}`); we leave the original name
* untouched when no suffix is set so legacy callers see no change.
*
* Convention: append, don't prefix. Operators reading
* `mcpctl get llm` typically scan by logical name first
* (`vllm-local-qwen3-…`) and the suffix is the disambiguator at the
* tail. The combined length is bounded — registrar refuses names
* longer than 100 chars (server-side validation matches), erroring
* loud rather than silently truncating.
*/
export function applyPublisherSuffix(name: string, suffix: string): string {
if (suffix === '') return name;
const combined = `${name}-${suffix}`;
if (combined.length > 100) {
throw new Error(
`Publisher-suffixed name '${combined}' exceeds the 100-char limit; shorten the base name or the suffix.`,
);
}
return combined;
}
function sanitizeSuffix(raw: string): string {
// Lowercase, replace anything outside [a-z0-9-] with '-', collapse
// runs of '-', strip leading/trailing '-'. End result is a stable
// identifier per host that round-trips unchanged on subsequent
// restarts of the same mcplocal.
return raw
.toLowerCase()
.replace(/[^a-z0-9-]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '');
}
export function loadLocalAgents(): AgentFileEntry[] { export function loadLocalAgents(): AgentFileEntry[] {
const config = loadFullConfig(); const config = loadFullConfig();
return Array.isArray(config.agents) ? config.agents : []; return Array.isArray(config.agents) ? config.agents : [];

View File

@@ -7,7 +7,7 @@ import { StdioProxyServer } from './server.js';
import { StdioUpstream } from './upstream/stdio.js'; import { StdioUpstream } from './upstream/stdio.js';
import { HttpUpstream } from './upstream/http.js'; import { HttpUpstream } from './upstream/http.js';
import { createHttpServer } from './http/server.js'; import { createHttpServer } from './http/server.js';
import { loadHttpConfig, loadLlmProviders, loadLocalAgents } from './http/config.js'; import { loadHttpConfig, loadLlmProviders, loadLocalAgents, loadPublisherSuffix, applyPublisherSuffix } from './http/config.js';
import type { HttpConfig, LlmProviderFileEntry, AgentFileEntry } from './http/config.js'; import type { HttpConfig, LlmProviderFileEntry, AgentFileEntry } from './http/config.js';
import { createProvidersFromConfig } from './llm-config.js'; import { createProvidersFromConfig } from './llm-config.js';
import { createSecretStore } from '@mcpctl/shared'; import { createSecretStore } from '@mcpctl/shared';
@@ -204,6 +204,17 @@ async function maybeStartVirtualLlmRegistrar(
const opted = llmEntries.filter((e) => e.publish === true); const opted = llmEntries.filter((e) => e.publish === true);
if (opted.length === 0 && localAgents.length === 0) return null; if (opted.length === 0 && localAgents.length === 0) return null;
// v6: per-publisher namespacing. Each user's mcplocal can append a
// suffix to all published names so two publishers sharing a config
// template (`vllm-local-qwen3`) don't collide on mcpd's cluster-wide
// unique-name constraint. Empty suffix = today's behavior.
const publisherSuffix = loadPublisherSuffix();
// Map of local-provider-name → wire-side publish name. Used twice:
// once when building RegistrarPublishedProvider entries, again when
// rewriting agent.llm references so the agent rows get pinned to
// the suffixed wire name (otherwise they'd 404 on register).
const publishNameByLocal = new Map<string, string>();
const published: RegistrarPublishedProvider[] = []; const published: RegistrarPublishedProvider[] = [];
for (const entry of opted) { for (const entry of opted) {
const provider = providerRegistry.get(entry.name); const provider = providerRegistry.get(entry.name);
@@ -211,6 +222,8 @@ async function maybeStartVirtualLlmRegistrar(
process.stderr.write(`virtual-llm registrar: provider '${entry.name}' opted-in but not registered locally; skipping\n`); process.stderr.write(`virtual-llm registrar: provider '${entry.name}' opted-in but not registered locally; skipping\n`);
continue; continue;
} }
const wireName = applyPublisherSuffix(entry.name, publisherSuffix);
publishNameByLocal.set(entry.name, wireName);
const item: RegistrarPublishedProvider = { const item: RegistrarPublishedProvider = {
provider, provider,
type: entry.type, type: entry.type,
@@ -219,22 +232,24 @@ async function maybeStartVirtualLlmRegistrar(
if (entry.tier !== undefined) item.tier = entry.tier; if (entry.tier !== undefined) item.tier = entry.tier;
if (entry.wake !== undefined) item.wake = entry.wake; if (entry.wake !== undefined) item.wake = entry.wake;
if (entry.poolName !== undefined) item.poolName = entry.poolName; if (entry.poolName !== undefined) item.poolName = entry.poolName;
if (wireName !== provider.name) item.publishName = wireName;
published.push(item); published.push(item);
} }
// v3: forward locally-declared agents alongside the providers. We // v3: forward locally-declared agents alongside the providers. We
// only forward agents whose `llm` field points at a name we're // only forward agents whose `llm` field points at a name we're
// actually publishing (or pre-declared). Stale entries are dropped // actually publishing (or pre-declared). Stale entries are dropped
// with a warning rather than failing the whole registration. // with a warning rather than failing the whole registration.
// v6: agent's `llm` field also gets the publisher suffix applied
// (only when it matches a locally-published provider). When it
// doesn't match, the agent is presumed to be pinning a public Llm
// by name and the suffix is NOT applied.
const publishedAgents: RegistrarPublishedAgent[] = []; const publishedAgents: RegistrarPublishedAgent[] = [];
const publishedNames = new Set(published.map((p) => p.provider.name));
for (const a of localAgents) { for (const a of localAgents) {
if (!publishedNames.has(a.llm)) { // Pin to suffixed wire name when the agent's llm is one of ours;
// Allow agents pinned to public LLMs the user expects to exist // pass through otherwise (publisher means it for a public Llm).
// server-side — mcpd validates llmName at registerVirtualAgents const llmWireName = publishNameByLocal.get(a.llm) ?? a.llm;
// time and 404s with a clear message if it's missing. const agentWireName = applyPublisherSuffix(a.name, publisherSuffix);
// We don't drop these client-side; just note it. const item: RegistrarPublishedAgent = { name: agentWireName, llmName: llmWireName };
}
const item: RegistrarPublishedAgent = { name: a.name, llmName: a.llm };
if (a.description !== undefined) item.description = a.description; if (a.description !== undefined) item.description = a.description;
if (a.systemPrompt !== undefined) item.systemPrompt = a.systemPrompt; if (a.systemPrompt !== undefined) item.systemPrompt = a.systemPrompt;
if (a.project !== undefined) item.project = a.project; if (a.project !== undefined) item.project = a.project;

View File

@@ -60,6 +60,18 @@ export interface RegistrarPublishedProvider {
* Agents pinned to any pool member dispatch across all healthy members. * Agents pinned to any pool member dispatch across all healthy members.
*/ */
poolName?: string; poolName?: string;
/**
* v6: optional override for the wire-side name. When set, the row
* mcpd creates uses this name instead of `provider.name`. Used by
* the per-publisher namespacing path: each user's mcplocal can take
* a shared local config (`provider.name = "vllm-local-qwen3"`) and
* publish under a hostname-suffixed wire name
* (`vllm-local-qwen3-alice`) so two publishers don't collide on
* mcpd's cluster-wide name uniqueness. Inbound infer/wake tasks
* carry the wire name, so the registrar matches by
* `publishName ?? provider.name` everywhere.
*/
publishName?: string;
} }
/** /**
@@ -186,7 +198,10 @@ export class VirtualLlmRegistrar {
if (!alive) initialStatus = 'hibernating'; if (!alive) initialStatus = 'hibernating';
} }
return { return {
name: p.provider.name, // v6: when `publishName` is set, that's the cluster-wide unique
// name the row goes under. Defaults to the provider's local
// name (today's behavior — no mangling).
name: p.publishName ?? p.provider.name,
type: p.type, type: p.type,
model: p.model, model: p.model,
...(p.tier !== undefined ? { tier: p.tier } : {}), ...(p.tier !== undefined ? { tier: p.tier } : {}),
@@ -350,7 +365,10 @@ export class VirtualLlmRegistrar {
* the heartbeat so mcpd's GC sweep doesn't decide we're stale mid-wake. * the heartbeat so mcpd's GC sweep doesn't decide we're stale mid-wake.
*/ */
private async handleWakeTask(task: { kind: 'wake'; taskId: string; llmName: string }): Promise<void> { private async handleWakeTask(task: { kind: 'wake'; taskId: string; llmName: string }): Promise<void> {
const published = this.opts.publishedProviders.find((p) => p.provider.name === task.llmName); // v6: match against the publish name (wire-side) when set, fall
// back to the local provider name. Inbound task frames carry the
// wire name mcpd knows the row by.
const published = this.opts.publishedProviders.find((p) => (p.publishName ?? p.provider.name) === task.llmName);
if (published === undefined) { if (published === undefined) {
await this.postResult(task.taskId, { error: `provider '${task.llmName}' not registered locally` }); await this.postResult(task.taskId, { error: `provider '${task.llmName}' not registered locally` });
return; return;
@@ -385,7 +403,10 @@ export class VirtualLlmRegistrar {
} }
private async handleInferTask(task: InferTask): Promise<void> { private async handleInferTask(task: InferTask): Promise<void> {
const published = this.opts.publishedProviders.find((p) => p.provider.name === task.llmName); // v6: match against the publish name (wire-side) when set, fall
// back to the local provider name. Inbound task frames carry the
// wire name mcpd knows the row by.
const published = this.opts.publishedProviders.find((p) => (p.publishName ?? p.provider.name) === task.llmName);
if (published === undefined) { if (published === undefined) {
await this.postResult(task.taskId, { error: `provider '${task.llmName}' not registered locally` }); await this.postResult(task.taskId, { error: `provider '${task.llmName}' not registered locally` });
return; return;

View File

@@ -0,0 +1,61 @@
/**
* v6 unit tests for the per-publisher namespacing helpers
* (loadPublisherSuffix + applyPublisherSuffix). The wiring through
* mcplocal/main.ts is exercised at the smoke level; these tests
* cover the pure logic so name-mangling regressions are caught fast.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { resetConfigCache, applyPublisherSuffix, loadPublisherSuffix } from '../src/http/config.js';
describe('applyPublisherSuffix', () => {
it('passes the name through unchanged when the suffix is empty', () => {
// Empty suffix is the legacy code path — pre-v6 behavior.
expect(applyPublisherSuffix('vllm-local-qwen3', '')).toBe('vllm-local-qwen3');
});
it('appends -<suffix> to a normal name', () => {
expect(applyPublisherSuffix('vllm-local-qwen3', 'alice')).toBe('vllm-local-qwen3-alice');
});
it('throws when the combined length exceeds the 100-char limit', () => {
// Name validation on mcpd's side caps at 100; we'd rather error
// loud at enqueue time than have the register POST 422 later.
const longName = 'a'.repeat(95);
expect(() => applyPublisherSuffix(longName, 'alicebob')).toThrow(/100-char limit/);
});
it('lets a name + short suffix exactly hit 100', () => {
const name = 'a'.repeat(95);
expect(applyPublisherSuffix(name, 'four')).toHaveLength(100);
});
});
describe('loadPublisherSuffix', () => {
beforeEach(() => {
resetConfigCache();
vi.unstubAllEnvs();
});
afterEach(() => {
vi.unstubAllEnvs();
resetConfigCache();
});
it('honors MCPCTL_PUBLISHER_SUFFIX env var (operations override)', () => {
// Env override takes precedence so an operator can flip the suffix
// for a one-off run without touching ~/.mcpctl/config.json.
expect(loadPublisherSuffix({ MCPCTL_PUBLISHER_SUFFIX: 'BoB.Lap-top' })).toBe('bob-lap-top');
});
it('returns empty string when neither env nor config sets a suffix', () => {
expect(loadPublisherSuffix({})).toBe('');
});
it('sanitizes uppercase + special chars + collapses runs', () => {
expect(loadPublisherSuffix({ MCPCTL_PUBLISHER_SUFFIX: "Alice's-MacBook.Pro!!" })).toBe('alice-s-macbook-pro');
});
it('strips leading/trailing dashes after sanitization', () => {
expect(loadPublisherSuffix({ MCPCTL_PUBLISHER_SUFFIX: '___bob___' })).toBe('bob');
});
});