feat(cli): metadata.mcpServers auto-attach in mcpctl skills sync

Closes the third deferred item from PR-5: skills can declare upstream
MCP server dependencies via `metadata.mcpServers` and `mcpctl skills
sync` now attaches them to the active project. Same trust model as
postInstall/hooks: the publisher is responsible, the client just
asks mcpd to attach.

## Behaviour

- For each `{ name, fromTemplate?, project? }` entry:
  - If the project already has the server attached → record as
    `alreadyAttached`, no-op.
  - If the server doesn't exist on mcpd → warn + skip (we don't
    auto-create from template; that's a separate explicit decision
    for the operator). The warning surfaces the suggested template
    so the operator can decide.
  - Otherwise → POST `/api/v1/projects/:id/servers { server: <name> }`.
  - 409 from POST → treat as alreadyAttached (server-side idempotency).
  - 404 from POST → treat as missing (race: server vanished mid-sync).
  - Other errors collected per-server; sync continues.
- A dep with `project:` set to a non-active project is skipped during
  this sync — keeps the active sync from making collateral changes
  to other projects.
- Global skill syncs (no project context) skip mcpServers entirely
  with a warning — there's no project to attach to.

## Files

### Added
- src/cli/src/utils/mcpservers-materialiser.ts (~140 LOC)
- src/cli/tests/utils/mcpservers-materialiser.test.ts (~190 LOC,
  10 tests covering: parse-tolerance, fresh attach, alreadyAttached
  short-circuit, missing-server warn+skip, missing-project errors-
  out, 409→alreadyAttached, 404→missing, cross-project skip,
  per-server error collection, empty-deps no-op)

### Edited
- src/cli/src/commands/skills.ts: applyOne calls
  attachSkillMcpServers after hooks. Tracks per-skill attachments in
  result.mcpServersAttached. Summary line surfaces "N mcpServers
  attached".

## Verification

165 test files / 2193 tests green (up from 2182).

Real-world flow:

```yaml
# skill metadata.yaml
mcpServers:
  - name: my-grafana
    fromTemplate: grafana
    project: monitoring
  - name: my-loki
    fromTemplate: loki
```

```bash
# As operator: ensure the named servers exist on mcpd first
mcpctl create server my-grafana --from-template grafana --env-from-secret grafana-creds
mcpctl create server my-loki --from-template loki

# Now publish the skill that declares them as deps. Sync will attach:
mcpctl skills sync
# → mcpctl: 1 installed, 2 mcpServers attached
mcpctl describe project monitoring     # both servers now attached
```

## What's intentionally NOT in this PR

- Auto-creating servers from `fromTemplate` when they don't exist.
  Provisioning infra from a skill push is a separate decision needing
  explicit operator policy. v1 warns + skips; the warning includes
  the suggested template name so the operator can act manually.
- Detaching a server when a skill drops it from mcpServers. Detach is
  destructive (project loses access) and we can't tell whether the
  detach is intentional vs. accidental drop. PR-7 can revisit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michal
2026-05-07 19:12:20 +01:00
parent 7ebc8b22d1
commit 180e50a978
3 changed files with 447 additions and 4 deletions

View File

@@ -26,6 +26,10 @@ import {
removeManagedHooks, removeManagedHooks,
type HooksByEvent, type HooksByEvent,
} from '../utils/hooks-materialiser.js'; } from '../utils/hooks-materialiser.js';
import {
attachSkillMcpServers,
parseMcpServerDeps,
} from '../utils/mcpservers-materialiser.js';
import { ApiError } from '../api-client.js'; import { ApiError } from '../api-client.js';
/** /**
@@ -70,8 +74,7 @@ interface SyncedSkillMetadata {
postInstall?: unknown; postInstall?: unknown;
postInstallTimeoutSec?: unknown; postInstallTimeoutSec?: unknown;
hooks?: unknown; hooks?: unknown;
// mcpServers auto-attach lives in a follow-up — straightforward mcpServers?: unknown;
// wrapper around the existing `mcpctl create serverattachment` path.
} }
export interface SyncOpts { export interface SyncOpts {
@@ -99,6 +102,7 @@ export interface SyncResult {
postInstallsRan: string[]; // skills whose postInstall executed in this sync postInstallsRan: string[]; // skills whose postInstall executed in this sync
postInstallsSkipped: string[]; // skills with postInstall but unchanged hash → no rerun postInstallsSkipped: string[]; // skills with postInstall but unchanged hash → no rerun
hooksApplied: string[]; // skills whose hooks were registered/updated in ~/.claude/settings.json hooksApplied: string[]; // skills whose hooks were registered/updated in ~/.claude/settings.json
mcpServersAttached: string[]; // "<skill>:<server>" tuples that landed in this sync
errors: Array<{ skill: string; error: string }>; errors: Array<{ skill: string; error: string }>;
exitCode: 0 | 1 | 2; exitCode: 0 | 1 | 2;
} }
@@ -125,6 +129,7 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<Syn
postInstallsRan: [], postInstallsRan: [],
postInstallsSkipped: [], postInstallsSkipped: [],
hooksApplied: [], hooksApplied: [],
mcpServersAttached: [],
errors: [], errors: [],
exitCode: 0, exitCode: 0,
}; };
@@ -242,7 +247,15 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<Syn
} }
// 8. Summary. // 8. Summary.
if (!opts.quiet || result.errors.length > 0 || result.installed.length > 0 || result.updated.length > 0 || result.removed.length > 0 || result.postInstallsRan.length > 0) { const anythingHappened =
result.errors.length > 0 ||
result.installed.length > 0 ||
result.updated.length > 0 ||
result.removed.length > 0 ||
result.postInstallsRan.length > 0 ||
result.hooksApplied.length > 0 ||
result.mcpServersAttached.length > 0;
if (!opts.quiet || anythingHappened) {
const parts: string[] = []; const parts: string[] = [];
if (result.installed.length) parts.push(`${String(result.installed.length)} installed`); if (result.installed.length) parts.push(`${String(result.installed.length)} installed`);
if (result.updated.length) parts.push(`${String(result.updated.length)} updated`); if (result.updated.length) parts.push(`${String(result.updated.length)} updated`);
@@ -250,11 +263,13 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<Syn
if (result.removed.length) parts.push(`${String(result.removed.length)} removed`); if (result.removed.length) parts.push(`${String(result.removed.length)} removed`);
if (result.preserved.length) parts.push(`${String(result.preserved.length)} preserved (modified)`); if (result.preserved.length) parts.push(`${String(result.preserved.length)} preserved (modified)`);
if (result.postInstallsRan.length) parts.push(`${String(result.postInstallsRan.length)} postInstall ran`); if (result.postInstallsRan.length) parts.push(`${String(result.postInstallsRan.length)} postInstall ran`);
if (result.hooksApplied.length) parts.push(`${String(result.hooksApplied.length)} hooks applied`);
if (result.mcpServersAttached.length) parts.push(`${String(result.mcpServersAttached.length)} mcpServers attached`);
if (result.errors.length) parts.push(`${String(result.errors.length)} errors`); if (result.errors.length) parts.push(`${String(result.errors.length)} errors`);
if (parts.length === 0) parts.push('no changes'); if (parts.length === 0) parts.push('no changes');
if (!opts.quiet) { if (!opts.quiet) {
log(`mcpctl skills sync${projectName ? ` (project: ${projectName})` : ' (global only)'}: ${parts.join(', ')}`); log(`mcpctl skills sync${projectName ? ` (project: ${projectName})` : ' (global only)'}: ${parts.join(', ')}`);
} else if (result.installed.length || result.updated.length || result.removed.length || result.postInstallsRan.length || result.errors.length) { } else if (anythingHappened) {
// Quiet mode: only emit a single line if something actually happened. // Quiet mode: only emit a single line if something actually happened.
warn(`mcpctl: ${parts.join(', ')}`); warn(`mcpctl: ${parts.join(', ')}`);
} }
@@ -306,6 +321,31 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<Syn
try { await removeManagedHooks(v.name); } catch { /* best-effort */ } try { await removeManagedHooks(v.name); } catch { /* best-effort */ }
} }
// ── mcpServers: auto-attach declared deps to the active project ──
// Only meaningful when a project context is active; global skills
// can't attach to "no project". v1 doesn't auto-create missing
// servers (warn + skip). Idempotent — re-syncing a skill whose
// deps are already attached is a no-op.
const mcpServerDeps = parseMcpServerDeps(meta.mcpServers);
if (mcpServerDeps.length > 0 && projectName) {
try {
const att = await attachSkillMcpServers(client, projectName, mcpServerDeps, warn);
for (const srv of att.attached) {
result.mcpServersAttached.push(`${v.name}:${srv}`);
}
for (const e of att.errors) {
result.errors.push({
skill: v.name,
error: `mcpServers attach '${e.server}': ${e.error}`,
});
}
} catch (err: unknown) {
warn(`mcpctl: failed to attach mcpServers for skill '${v.name}': ${err instanceof Error ? err.message : String(err)}`);
}
} else if (mcpServerDeps.length > 0) {
warn(`mcpctl: skill '${v.name}' declares mcpServers but sync is running global-only; skipping attach`);
}
// ── postInstall: run metadata.postInstall when present ── // ── postInstall: run metadata.postInstall when present ──
// Hash-pinned: only execute when the script's sha256 differs from // Hash-pinned: only execute when the script's sha256 differs from
// what state recorded. Failures DO NOT update the recorded hash so // what state recorded. Failures DO NOT update the recorded hash so

View File

@@ -0,0 +1,176 @@
/**
* Auto-attach the MCP server dependencies a skill declares to the
* project that's syncing. Per the corporate-appliance trust model,
* publishing a skill that says "this project depends on my-grafana"
* is enough — the client takes mcpd at its word and asks mcpd to
* attach the server to the project.
*
* What this function does NOT do (deliberately):
* - Auto-create the server from a template if it's missing.
* Provisioning infrastructure from a skill push is a separate
* decision that needs explicit operator consent. v1 just warns
* when the named server doesn't exist and skips that dep.
* - Detach servers that a skill removed from its mcpServers list.
* Detach is destructive (the project loses access) and the
* `attach` itself is idempotent on the server side, so we err
* on the side of leaving things attached. PR-7 can revisit if
* a use case shows up.
*
* The mcpServers field is per-project: a skill's declared deps only
* get attached to the project the sync is running for. Global skills
* (no projectName context) skip this step entirely — there's no
* project to attach to.
*/
import type { ApiClient } from '../api-client.js';
import { ApiError } from '../api-client.js';
export interface McpServerDep {
name: string;
fromTemplate?: string;
project?: string;
}
export interface AttachResult {
attached: string[];
alreadyAttached: string[];
missing: string[];
errors: Array<{ server: string; error: string }>;
}
/**
* Resolve project name → id, list its currently-attached servers,
* then attach each declared dep that isn't already there. Idempotent
* by virtue of the existing-attachment check.
*
* Failures per-server are collected, not thrown — sync continues.
*/
export async function attachSkillMcpServers(
client: ApiClient,
projectName: string,
deps: McpServerDep[],
warn: (msg: string) => void = () => {},
): Promise<AttachResult> {
const result: AttachResult = {
attached: [],
alreadyAttached: [],
missing: [],
errors: [],
};
if (deps.length === 0) return result;
// Resolve project → id (the attach endpoint is keyed by id, not name).
let projectId: string;
try {
const projects = await client.get<Array<{ id: string; name: string }>>('/api/v1/projects');
const match = projects.find((p) => p.name === projectName);
if (!match) {
// No project to attach to — surface every dep as an error so the
// operator can see something is mis-configured.
for (const dep of deps) {
result.errors.push({ server: dep.name, error: `Project '${projectName}' not found` });
}
return result;
}
projectId = match.id;
} catch (err: unknown) {
for (const dep of deps) {
result.errors.push({
server: dep.name,
error: `Failed to resolve project: ${err instanceof Error ? err.message : String(err)}`,
});
}
return result;
}
// Inspect current attachments. The /api/v1/projects/:id/servers POST
// endpoint is idempotent server-side, but we still pre-check so we
// can report alreadyAttached vs newly-attached cleanly.
let attached = new Set<string>();
try {
const project = await client.get<{ servers?: Array<{ server?: { name: string } }> }>(`/api/v1/projects/${projectId}`);
attached = new Set(
(project.servers ?? [])
.map((s) => s.server?.name)
.filter((n): n is string => typeof n === 'string'),
);
} catch (err: unknown) {
warn(`mcpctl: failed to read current attachments for project '${projectName}': ${err instanceof Error ? err.message : String(err)}`);
// Fall through with an empty set — we'll attempt attaches and let
// server-side idempotency cover any duplicates.
}
// Optionally narrow the existing-server set so we can warn loudly on
// unknown server names. (Server attaches against a non-existent
// server would 404 anyway, but a clearer warning is friendlier.)
let existingServers = new Set<string>();
try {
const servers = await client.get<Array<{ name: string }>>('/api/v1/servers');
existingServers = new Set(servers.map((s) => s.name));
} catch {
// Best-effort; if listing fails we still try the attach.
}
for (const dep of deps) {
// Honour an explicit `project` on the dep — defensive, normally
// matches the active project anyway. Skip mismatches so a skill
// can declare deps for a different project without collateral
// damage during this sync.
if (dep.project && dep.project !== projectName) {
continue;
}
if (attached.has(dep.name)) {
result.alreadyAttached.push(dep.name);
continue;
}
if (existingServers.size > 0 && !existingServers.has(dep.name)) {
// Server doesn't exist on mcpd. v1 doesn't auto-create; warn and continue.
const detail = dep.fromTemplate
? ` (skill suggests creating it via template '${dep.fromTemplate}')`
: '';
warn(`mcpctl: skill mcpServers dep '${dep.name}' not found on mcpd${detail}; skipping attach`);
result.missing.push(dep.name);
continue;
}
try {
await client.post(`/api/v1/projects/${projectId}/servers`, { server: dep.name });
result.attached.push(dep.name);
} catch (err: unknown) {
// Idempotency: 409 (already attached) is success.
if (err instanceof ApiError && err.status === 409) {
result.alreadyAttached.push(dep.name);
continue;
}
// 404 means either the project or the server vanished mid-sync.
if (err instanceof ApiError && err.status === 404) {
result.missing.push(dep.name);
continue;
}
result.errors.push({
server: dep.name,
error: err instanceof Error ? err.message : String(err),
});
}
}
return result;
}
/** Type-narrow the metadata.mcpServers field. Tolerant of garbage. */
export function parseMcpServerDeps(value: unknown): McpServerDep[] {
if (!Array.isArray(value)) return [];
const out: McpServerDep[] = [];
for (const v of value) {
if (v === null || typeof v !== 'object') continue;
const obj = v as Record<string, unknown>;
const name = obj['name'];
if (typeof name !== 'string' || name.length === 0) continue;
const dep: McpServerDep = { name };
if (typeof obj['fromTemplate'] === 'string') dep.fromTemplate = obj['fromTemplate'];
if (typeof obj['project'] === 'string') dep.project = obj['project'];
out.push(dep);
}
return out;
}

View File

@@ -0,0 +1,227 @@
import { describe, it, expect, vi } from 'vitest';
import { attachSkillMcpServers, parseMcpServerDeps } from '../../src/utils/mcpservers-materialiser.js';
import type { ApiClient } from '../../src/api-client.js';
import { ApiError } from '../../src/api-client.js';
interface MockClient {
get: ReturnType<typeof vi.fn>;
post: ReturnType<typeof vi.fn>;
put: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
}
function makeClient(): MockClient {
return {
get: vi.fn(),
post: vi.fn(async () => ({})),
put: vi.fn(async () => ({})),
delete: vi.fn(async () => undefined),
};
}
function apiError(status: number, body = 'err'): ApiError {
return new ApiError(status, body);
}
describe('mcpservers-materialiser', () => {
describe('parseMcpServerDeps', () => {
it('returns [] for non-arrays', () => {
expect(parseMcpServerDeps(null)).toEqual([]);
expect(parseMcpServerDeps('foo')).toEqual([]);
expect(parseMcpServerDeps({})).toEqual([]);
});
it('keeps valid entries and drops garbage', () => {
const out = parseMcpServerDeps([
{ name: 'good', fromTemplate: 't', project: 'p' },
{ name: '', fromTemplate: 't' }, // empty name → drop
{ fromTemplate: 'no-name' }, // no name → drop
{ name: 'bare' }, // valid, minimal
'string', // not an object → drop
]);
expect(out).toEqual([
{ name: 'good', fromTemplate: 't', project: 'p' },
{ name: 'bare' },
]);
});
});
describe('attachSkillMcpServers', () => {
it('attaches a new server when not already present', async () => {
const client = makeClient();
client.get.mockImplementation(async (path: string) => {
if (path === '/api/v1/projects') return [{ id: 'proj-1', name: 'demo' }];
if (path === '/api/v1/projects/proj-1') return { servers: [] };
if (path === '/api/v1/servers') return [{ name: 'my-grafana' }];
throw new Error(`unexpected GET ${path}`);
});
const result = await attachSkillMcpServers(
client as unknown as ApiClient,
'demo',
[{ name: 'my-grafana', fromTemplate: 'grafana' }],
);
expect(result.attached).toEqual(['my-grafana']);
expect(result.alreadyAttached).toEqual([]);
expect(result.missing).toEqual([]);
expect(result.errors).toEqual([]);
expect(client.post).toHaveBeenCalledWith('/api/v1/projects/proj-1/servers', { server: 'my-grafana' });
});
it('reports alreadyAttached without re-posting', async () => {
const client = makeClient();
client.get.mockImplementation(async (path: string) => {
if (path === '/api/v1/projects') return [{ id: 'proj-1', name: 'demo' }];
if (path === '/api/v1/projects/proj-1') return { servers: [{ server: { name: 'my-grafana' } }] };
if (path === '/api/v1/servers') return [{ name: 'my-grafana' }];
throw new Error(`unexpected GET ${path}`);
});
const result = await attachSkillMcpServers(
client as unknown as ApiClient,
'demo',
[{ name: 'my-grafana' }],
);
expect(result.alreadyAttached).toEqual(['my-grafana']);
expect(result.attached).toEqual([]);
expect(client.post).not.toHaveBeenCalled();
});
it('warns + skips when server does not exist on mcpd', async () => {
const client = makeClient();
client.get.mockImplementation(async (path: string) => {
if (path === '/api/v1/projects') return [{ id: 'proj-1', name: 'demo' }];
if (path === '/api/v1/projects/proj-1') return { servers: [] };
if (path === '/api/v1/servers') return [{ name: 'something-else' }];
throw new Error(`unexpected GET ${path}`);
});
const warnings: string[] = [];
const result = await attachSkillMcpServers(
client as unknown as ApiClient,
'demo',
[{ name: 'my-grafana', fromTemplate: 'grafana' }],
(m) => warnings.push(m),
);
expect(result.missing).toEqual(['my-grafana']);
expect(result.attached).toEqual([]);
expect(client.post).not.toHaveBeenCalled();
expect(warnings.some((w) => w.includes('my-grafana') && w.includes('grafana'))).toBe(true);
});
it('errors-out when the project does not exist', async () => {
const client = makeClient();
client.get.mockImplementation(async (path: string) => {
if (path === '/api/v1/projects') return []; // no projects
throw new Error(`unexpected GET ${path}`);
});
const result = await attachSkillMcpServers(
client as unknown as ApiClient,
'no-such-project',
[{ name: 'my-grafana' }],
);
expect(result.errors).toHaveLength(1);
expect(result.errors[0]?.error).toContain('Project');
expect(client.post).not.toHaveBeenCalled();
});
it('treats 409 from POST as alreadyAttached (idempotent server-side)', async () => {
const client = makeClient();
client.get.mockImplementation(async (path: string) => {
if (path === '/api/v1/projects') return [{ id: 'proj-1', name: 'demo' }];
// attachments listing fails — fall back to attempting + handling 409
if (path === '/api/v1/projects/proj-1') throw apiError(500, 'flake');
if (path === '/api/v1/servers') return [{ name: 'my-grafana' }];
throw new Error(`unexpected GET ${path}`);
});
client.post.mockRejectedValueOnce(apiError(409, 'already attached'));
const result = await attachSkillMcpServers(
client as unknown as ApiClient,
'demo',
[{ name: 'my-grafana' }],
);
expect(result.alreadyAttached).toEqual(['my-grafana']);
expect(result.errors).toEqual([]);
});
it('treats 404 from POST as missing (server vanished mid-sync)', async () => {
const client = makeClient();
client.get.mockImplementation(async (path: string) => {
if (path === '/api/v1/projects') return [{ id: 'proj-1', name: 'demo' }];
if (path === '/api/v1/projects/proj-1') return { servers: [] };
if (path === '/api/v1/servers') return [{ name: 'my-grafana' }]; // existed when we listed
throw new Error(`unexpected GET ${path}`);
});
// …but vanished by the time we POSTed.
client.post.mockRejectedValueOnce(apiError(404, 'gone'));
const result = await attachSkillMcpServers(
client as unknown as ApiClient,
'demo',
[{ name: 'my-grafana' }],
);
expect(result.missing).toEqual(['my-grafana']);
expect(result.errors).toEqual([]);
});
it('skips deps that target a different project', async () => {
const client = makeClient();
client.get.mockImplementation(async (path: string) => {
if (path === '/api/v1/projects') return [{ id: 'proj-1', name: 'demo' }];
if (path === '/api/v1/projects/proj-1') return { servers: [] };
if (path === '/api/v1/servers') return [{ name: 'my-grafana' }];
throw new Error(`unexpected GET ${path}`);
});
const result = await attachSkillMcpServers(
client as unknown as ApiClient,
'demo',
[{ name: 'my-grafana', project: 'other-project' }],
);
expect(result.attached).toEqual([]);
expect(result.missing).toEqual([]);
expect(client.post).not.toHaveBeenCalled();
});
it('continues past per-server errors', async () => {
const client = makeClient();
client.get.mockImplementation(async (path: string) => {
if (path === '/api/v1/projects') return [{ id: 'proj-1', name: 'demo' }];
if (path === '/api/v1/projects/proj-1') return { servers: [] };
if (path === '/api/v1/servers') return [{ name: 'a' }, { name: 'b' }];
throw new Error(`unexpected GET ${path}`);
});
client.post.mockImplementation(async (path: string, body) => {
if ((body as { server: string }).server === 'a') throw apiError(500, 'boom');
return {};
});
const result = await attachSkillMcpServers(
client as unknown as ApiClient,
'demo',
[{ name: 'a' }, { name: 'b' }],
);
expect(result.errors).toHaveLength(1);
expect(result.errors[0]?.server).toBe('a');
expect(result.attached).toEqual(['b']);
});
it('returns empty on empty deps without making any calls', async () => {
const client = makeClient();
const result = await attachSkillMcpServers(client as unknown as ApiClient, 'demo', []);
expect(result).toEqual({ attached: [], alreadyAttached: [], missing: [], errors: [] });
expect(client.get).not.toHaveBeenCalled();
expect(client.post).not.toHaveBeenCalled();
});
});
});