feat(pulumi): @mcpctl/pulumi — generic Pulumi provider for mcpctl
Some checks failed
CI/CD / lint (pull_request) Successful in 1m3s
CI/CD / typecheck (pull_request) Successful in 2m5s
CI/CD / test (pull_request) Successful in 1m19s
CI/CD / smoke (pull_request) Failing after 2m46s
CI/CD / build (pull_request) Successful in 2m25s
CI/CD / publish (pull_request) Has been skipped

New workspace package: a Pulumi dynamic provider (TypeScript, in-process) that
manages mcpctl resources declaratively from any Pulumi program. Motivated by
model drift: when the deployed vLLM/LiteLLM model changes, mcpctl's llm target
must follow, and a Pulumi resource makes that automatic on `pulumi up`.

- Generic core `McpctlResource({ kind, name, spec, mcpd })` round-trips ANY
  mcpctl resource kind; typed `Llm` wrapper for ergonomics.
- Engine talks direct HTTP to mcpd's REST API (ported from cli/api-client.ts),
  mirroring apply's name-keyed upsert (PUT strips immutable name/type) and
  get -o json's read field-stripping. No mcpctl binary needed → CI-friendly.
- CRUD/diff/check with correct replace semantics (name/type/kind/mcpd.url force
  delete-before-replace), 429 retry, secret token in state.
- Validation deferred to mcpd's Zod schemas, so new resource kinds work with no
  provider release.
- CommonJS build so the serialized dynamic provider's module refs are captured.
- 18 Vitest tests (mocked mcpd + Pulumi mocks); build + lint clean; README with
  auth (PAT) setup and consumer example for kubernetes-deployment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michal
2026-07-17 22:02:22 +01:00
parent e2db8d9003
commit f3d92235db
16 changed files with 2222 additions and 10 deletions

1249
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

96
src/pulumi/README.md Normal file
View File

@@ -0,0 +1,96 @@
# @mcpctl/pulumi
A [Pulumi](https://www.pulumi.com/) dynamic provider for [mcpctl](../../). Manage
mcpctl resources — `llm`, `server`, `project`, `secret`, `agent`, and any other
kind mcpctl supports — declaratively from any TypeScript Pulumi program.
It talks **directly to mcpd's REST API** (the same endpoints the `mcpctl` CLI
uses) and mirrors the CLI's two round-trip guarantees:
- `apply`'s name-keyed **upsert** (look up by `name`; PUT with immutable fields
stripped if it exists, else POST), and
- `get -o json`'s field-stripping on read, so state does not churn.
No `mcpctl` binary or `~/.mcpctl` credentials are required on the machine
running `pulumi up`.
## Install
```bash
npm install @mcpctl/pulumi @pulumi/pulumi
```
## Usage
### Typed `Llm` wrapper
```ts
import * as pulumi from "@pulumi/pulumi";
import * as mcpctl from "@mcpctl/pulumi";
const cfg = new pulumi.Config();
const mcpd = { url: cfg.require("mcpdUrl"), token: cfg.requireSecret("mcpdPat") };
new mcpctl.Llm("homelab-qwen", {
name: "qwen3-thinking", // upsert key (immutable — changing it replaces)
type: "openai", // immutable
model: servedModelName, // pulumi.Input<string> — e.g. from another resource
url: "http://litellm.nvidia-nim.svc.cluster.local:4000",
tier: "heavy",
mcpd,
});
```
When `model` (or `url`, `tier`, `description`, ...) changes, Pulumi issues an
in-place `PUT /api/v1/llms/qwen3-thinking` on the next `up`. Changing `name` or
`type` forces a replacement (delete-before-replace, since `name` is the identity).
### Generic resource — any kind
```ts
new mcpctl.McpctlResource("my-server", {
kind: "server",
name: "my-server",
spec: { /* sent verbatim to mcpd; mcpd's Zod schema validates it */ },
mcpd,
});
```
The provider does no per-kind schema validation beyond the name shape and the
required connection — it defers to mcpd. New mcpctl resource kinds work the day
mcpd ships them, without a provider release.
## Auth
The provider authenticates with a **long-lived PAT** (`mcpctl_pat_…`), passed as
`mcpd.token` (use `cfg.requireSecret(...)`).
Mint one with resource-wide `view:llms` + `edit:llms` bindings, e.g. via
`mcpctl apply -f -`:
```yaml
kind: mcptoken
name: pulumi-automation
projectId: <any-project-id> # currently required (see note)
expiresAt: null # never expires
bindings:
- { role: view, resource: llms }
- { role: edit, resource: llms }
```
The bindings are resource-wide, so the token drives `/api/v1/llms/*` regardless
of the project it is attached to (the project-scope guard only gates
`/api/v1/projects/*`). Grant broader bindings if you manage other kinds.
> **Note:** mcpd currently requires `projectId` when minting an mcptoken, so a
> management-scope PAT must attach to some (throwaway) project. A future mcpd
> change may make `projectId` optional for management tokens.
## Notes
- **Secrets in state:** dynamic-provider inputs (including the token) are stored,
encrypted, in Pulumi state. Use an encrypted state backend.
- **Serialization:** this package is CommonJS on purpose — Pulumi serializes the
provider object, and the CJS build lets its module references (`./engine`,
`./kinds`) be captured and re-required at runtime. Validate end-to-end with a
real `pulumi up` against a disposable mcpd before relying on it in production.

34
src/pulumi/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "@mcpctl/pulumi",
"version": "0.0.1",
"description": "Pulumi dynamic provider for mcpctl resources (llms, servers, projects, ...). Manage mcpctl declaratively from any TypeScript Pulumi program.",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"exports": {
".": {
"require": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsc --build",
"clean": "rimraf dist",
"test": "vitest",
"test:run": "vitest run"
},
"keywords": [
"pulumi",
"mcpctl",
"mcp",
"provider"
],
"peerDependencies": {
"@pulumi/pulumi": "^3.0.0"
},
"devDependencies": {
"@pulumi/pulumi": "^3.0.0"
}
}

179
src/pulumi/src/engine.ts Normal file
View File

@@ -0,0 +1,179 @@
/**
* Dependency-free HTTP engine for the mcpctl Pulumi provider.
*
* Talks to mcpd's REST API directly — the *same* endpoints the `mcpctl` CLI
* uses (`GET/POST/PUT/DELETE /api/v1/<plural>[/<id>]`) — and mirrors two
* contracts the CLI guarantees:
*
* 1. `apply`'s name-keyed upsert: look the resource up by `name`; if it
* exists, PUT with immutable fields stripped; otherwise POST.
* (see src/cli/src/commands/apply.ts `applyConfig`)
* 2. name resolution by listing `/api/v1/<plural>` and matching `name`,
* which works uniformly for every resource kind.
* (see src/cli/src/commands/shared.ts `resolveNameOrId`)
*
* No `mcpctl` binary or `~/.mcpctl` credentials are required — every call
* carries its own `{ url, token }`. Ported from src/cli/src/api-client.ts.
*/
import http from 'node:http';
import https from 'node:https';
/** Connection + auth for a single mcpd daemon. */
export interface McpdConn {
url: string;
token: string;
timeoutMs?: number;
}
/** A resource row as mcpd returns it (always carries an `id`). */
export interface McpctlEntity {
id: string;
name?: string;
[key: string]: unknown;
}
export class McpdHttpError extends Error {
readonly status: number;
readonly body: string;
constructor(status: number, body: string, method: string, path: string) {
super(`mcpd ${method} ${path} -> HTTP ${status}: ${body}`);
this.name = 'McpdHttpError';
this.status = status;
this.body = body;
}
}
interface RawResponse {
status: number;
body: string;
}
const MAX_RETRIES = 3;
const DEFAULT_TIMEOUT_MS = 15_000;
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function rawRequest(
method: string,
fullUrl: string,
token: string,
timeoutMs: number,
body?: unknown,
): Promise<RawResponse> {
return new Promise((resolve, reject) => {
const parsed = new URL(fullUrl);
const isHttps = parsed.protocol === 'https:';
const headers: Record<string, string> = { Authorization: `Bearer ${token}` };
let payload: string | undefined;
if (body !== undefined) {
payload = JSON.stringify(body);
headers['Content-Type'] = 'application/json';
headers['Content-Length'] = String(Buffer.byteLength(payload));
}
const driver = isHttps ? https : http;
const req = driver.request(
{
hostname: parsed.hostname,
port: parsed.port || (isHttps ? 443 : 80),
path: parsed.pathname + parsed.search,
method,
timeout: timeoutMs,
headers,
},
(res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf-8') }));
},
);
req.on('error', reject);
req.on('timeout', () => {
req.destroy();
reject(new Error(`Request to ${fullUrl} timed out after ${timeoutMs}ms`));
});
if (payload !== undefined) req.write(payload);
req.end();
});
}
/**
* Perform a request, retrying on 429 with exponential backoff (mirrors the
* CLI's apply backoff). Throws {@link McpdHttpError} on any >=400 status;
* callers that tolerate 404 catch it.
*/
async function request<T>(conn: McpdConn, method: string, path: string, body?: unknown): Promise<T | null> {
const timeout = conn.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const base = conn.url.replace(/\/$/, '');
let attempt = 0;
for (;;) {
const res = await rawRequest(method, `${base}${path}`, conn.token, timeout, body);
if (res.status === 429 && attempt < MAX_RETRIES) {
attempt += 1;
await sleep(250 * 2 ** (attempt - 1));
continue;
}
if (res.status >= 400) {
throw new McpdHttpError(res.status, res.body, method, path);
}
if (!res.body) return null;
try {
return JSON.parse(res.body) as T;
} catch {
return null;
}
}
}
/** List all rows of a resource kind (RBAC-filtered by the token). */
export async function listResource(conn: McpdConn, resource: string): Promise<McpctlEntity[]> {
const data = await request<McpctlEntity[]>(conn, 'GET', `/api/v1/${resource}`);
return Array.isArray(data) ? data : [];
}
/** Resolve a resource by human name (list + match), like the CLI does. */
export async function findByName(conn: McpdConn, resource: string, name: string): Promise<McpctlEntity | null> {
const items = await listResource(conn, resource);
return items.find((item) => item.name === name) ?? null;
}
/**
* Name-keyed upsert. `doc` is the full body (must include `name`). If a row
* with that name exists, PUT it with `immutable` fields stripped; otherwise
* POST `doc`. Mirrors apply.ts exactly.
*/
export async function upsert(
conn: McpdConn,
resource: string,
doc: Record<string, unknown>,
immutable: string[],
): Promise<McpctlEntity> {
const name = String(doc['name'] ?? '');
const existing = await findByName(conn, resource, name);
if (existing) {
const updateBody: Record<string, unknown> = {};
for (const [key, value] of Object.entries(doc)) {
if (!immutable.includes(key)) updateBody[key] = value;
}
const updated = await request<McpctlEntity>(conn, 'PUT', `/api/v1/${resource}/${existing.id}`, updateBody);
return updated ?? { ...existing, ...updateBody };
}
const created = await request<McpctlEntity>(conn, 'POST', `/api/v1/${resource}`, doc);
if (!created || typeof created.id !== 'string') {
throw new Error(`create ${resource}/${name}: mcpd returned no id`);
}
return created;
}
/** Delete a resource by name. No-op if it is already gone (idempotent). */
export async function del(conn: McpdConn, resource: string, name: string): Promise<void> {
const existing = await findByName(conn, resource, name);
if (!existing) return;
try {
await request(conn, 'DELETE', `/api/v1/${resource}/${existing.id}`);
} catch (err) {
if (err instanceof McpdHttpError && err.status === 404) return;
throw err;
}
}

20
src/pulumi/src/index.ts Normal file
View File

@@ -0,0 +1,20 @@
/**
* @mcpctl/pulumi — a Pulumi dynamic provider for mcpctl.
*
* Manage mcpctl resources (llms, servers, projects, ...) declaratively from any
* TypeScript Pulumi program. Talks directly to mcpd's REST API; no `mcpctl`
* binary required. See README for auth setup.
*/
export { McpctlResource } from './resource';
export type { McpctlResourceArgs, McpdConnInput } from './resource';
export { Llm, LLM_TYPES, LLM_TIERS } from './llm';
export type { LlmArgs, LlmType, LlmTier, LlmApiKeyRef } from './llm';
export { mcpctlProvider } from './provider';
export type { McpctlResourceInputs } from './provider';
export { KIND_TO_RESOURCE } from './kinds';
export { McpdHttpError } from './engine';
export type { McpdConn, McpctlEntity } from './engine';

88
src/pulumi/src/kinds.ts Normal file
View File

@@ -0,0 +1,88 @@
/**
* Per-kind metadata, kept deliberately small and generic. The provider defers
* all real validation to mcpd's own Zod schemas — this table only covers the
* few things a client must know to round-trip correctly:
*
* - singular kind -> plural REST resource (mirrors apply.ts KIND_TO_RESOURCE)
* - which fields mcpd cannot change in place (stripped from PUT bodies)
* - which server-managed/runtime fields to drop on read so state does not
* churn (mirrors get.ts toApplyDocs)
* - the Zod defaults mcpd applies, so `check` can normalize inputs and avoid
* spurious diffs
*
* Unknown kinds still work (resource falls back to the kind string) — new mcpd
* resource kinds are usable the day they ship, without a provider release.
*/
/** singular kind -> plural resource key (mirror of apply.ts KIND_TO_RESOURCE). */
export const KIND_TO_RESOURCE: Record<string, string> = {
server: 'servers',
project: 'projects',
secret: 'secrets',
template: 'templates',
user: 'users',
group: 'groups',
rbac: 'rbac',
prompt: 'prompts',
promptrequest: 'promptrequests',
serverattachment: 'serverattachments',
mcptoken: 'mcptokens',
secretbackend: 'secretbackends',
llm: 'llms',
agent: 'agents',
};
/**
* Fields mcpd treats as immutable on update (beyond `name`, which is always
* immutable). Changing one forces a replacement. Mirrors the update bodies in
* apply.ts (e.g. llms strip `type`).
*/
export const KIND_IMMUTABLE_FIELDS: Record<string, string[]> = {
llm: ['type'],
};
/**
* Server-managed/runtime fields dropped on read so desired-vs-live comparison
* stays clean. Mirrors get.ts `toApplyDocs`. `ALWAYS_STRIPPED` applies to every
* kind.
*/
export const ALWAYS_STRIPPED: readonly string[] = ['id', 'createdAt', 'updatedAt', 'version'];
export const KIND_STRIPPED_ON_READ: Record<string, string[]> = {
llm: ['kind', 'status', 'lastHeartbeatAt', 'inactiveSince', 'providerSessionId'],
agent: ['kind', 'status', 'lastHeartbeatAt', 'inactiveSince', 'providerSessionId'],
};
/** Zod defaults mcpd applies; mirrored so `check` normalization avoids diffs. */
export const KIND_SPEC_DEFAULTS: Record<string, Record<string, unknown>> = {
llm: { tier: 'fast', description: '', extraConfig: {}, poolName: null },
};
export function resourceForKind(kind: string): string {
return KIND_TO_RESOURCE[kind] ?? kind;
}
/** Immutable fields for a kind, including the always-immutable `name`. */
export function immutableFieldsForKind(kind: string): string[] {
return ['name', ...(KIND_IMMUTABLE_FIELDS[kind] ?? [])];
}
/** Drop undefined-valued keys and apply the kind's defaults (spec wins). */
export function normalizeSpec(kind: string, spec: Record<string, unknown>): Record<string, unknown> {
const defaults = KIND_SPEC_DEFAULTS[kind] ?? {};
const out: Record<string, unknown> = { ...defaults };
for (const [key, value] of Object.entries(spec)) {
if (value !== undefined) out[key] = value;
}
return out;
}
/** Strip server-managed + kind-specific runtime fields from a live row. */
export function stripForRead(kind: string, entity: Record<string, unknown>): Record<string, unknown> {
const drop = new Set<string>([...ALWAYS_STRIPPED, ...(KIND_STRIPPED_ON_READ[kind] ?? [])]);
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(entity)) {
if (!drop.has(key)) out[key] = value;
}
return out;
}

51
src/pulumi/src/llm.ts Normal file
View File

@@ -0,0 +1,51 @@
/**
* Typed convenience wrapper for the mcpctl `llm` resource. Compiles down to a
* generic {@link McpctlResource} with `kind: "llm"`. Field types mirror mcpd's
* CreateLlmSchema / UpdateLlmSchema (src/mcpd/src/validation/llm.schema.ts).
*
* new mcpctl.Llm("homelab-qwen", {
* name: "qwen3-thinking",
* type: "openai",
* model: servedModelName, // pulumi.Input<string>
* url: litellmUrl,
* tier: "heavy",
* mcpd: { url, token: cfg.requireSecret("mcpdPat") },
* });
*/
import type { Input, CustomResourceOptions } from '@pulumi/pulumi';
import { McpctlResource, McpdConnInput } from './resource';
export const LLM_TYPES = ['anthropic', 'openai', 'deepseek', 'vllm', 'ollama', 'gemini-cli'] as const;
export type LlmType = (typeof LLM_TYPES)[number];
export const LLM_TIERS = ['fast', 'heavy'] as const;
export type LlmTier = (typeof LLM_TIERS)[number];
/** Reference to a key inside a Secret; mcpd resolves it server-side. */
export interface LlmApiKeyRef {
name: Input<string>;
key: Input<string>;
}
export interface LlmArgs {
/** llm resource name (upsert key). Immutable — changing it replaces. */
name: Input<string>;
/** Provider type. Immutable — changing it replaces. */
type: Input<LlmType>;
model: Input<string>;
/** Endpoint URL; omit for the provider default. */
url?: Input<string>;
tier?: Input<LlmTier>;
description?: Input<string>;
apiKeyRef?: Input<LlmApiKeyRef>;
poolName?: Input<string>;
extraConfig?: Input<Record<string, unknown>>;
mcpd: Input<McpdConnInput>;
}
export class Llm extends McpctlResource {
constructor(name: string, args: LlmArgs, opts?: CustomResourceOptions) {
const { name: llmName, mcpd, ...spec } = args;
super(name, { kind: 'llm', name: llmName, spec: spec as Record<string, unknown>, mcpd }, opts);
}
}

123
src/pulumi/src/provider.ts Normal file
View File

@@ -0,0 +1,123 @@
/**
* The mcpctl Pulumi dynamic provider.
*
* A dynamic provider's object is *serialized* into the deployment. This package
* compiles to CommonJS, so these top-level imports emit as `require(...)` in the
* built output; Pulumi's closure serializer captures those module references
* (the helpers are pure and dependency-light) and re-requires them at runtime.
* The `@pulumi/pulumi` import is type-only, so it is erased and leaves no
* runtime reference in the serialized object.
*
* CRUD maps onto mcpd's REST API via ./engine, mirroring the CLI's `apply`
* (upsert) and `get -o json` (round-trip) contracts. Validation is deferred to
* mcpd's Zod schemas — this provider only enforces the few client-side
* invariants (name shape, required connection).
*/
import type * as pulumi from '@pulumi/pulumi';
import { upsert, findByName, del } from './engine';
import {
resourceForKind,
immutableFieldsForKind,
normalizeSpec,
stripForRead,
KIND_IMMUTABLE_FIELDS,
} from './kinds';
/** Inputs for a generic mcpctl resource (all values resolved by the time the
* provider sees them). */
export interface McpctlResourceInputs {
kind: string;
name: string;
spec: Record<string, unknown>;
mcpd: { url: string; token: string; timeoutMs?: number };
}
/** Deterministic JSON for order-insensitive equality checks. */
function stableStringify(value: unknown): string {
if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
const obj = value as Record<string, unknown>;
const keys = Object.keys(obj).sort();
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(',')}}`;
}
export const mcpctlProvider: pulumi.dynamic.ResourceProvider = {
async check(_olds: unknown, news: unknown): Promise<pulumi.dynamic.CheckResult> {
const input = (news ?? {}) as Partial<McpctlResourceInputs>;
const failures: pulumi.dynamic.CheckFailure[] = [];
if (typeof input.kind !== 'string' || input.kind.length === 0) {
failures.push({ property: 'kind', reason: 'kind is required (e.g. "llm", "server", "project")' });
}
if (typeof input.name !== 'string' || !/^[a-z0-9-]+$/.test(input.name)) {
failures.push({ property: 'name', reason: 'name is required and must be lowercase alphanumeric with hyphens' });
}
if (input.spec === null || typeof input.spec !== 'object' || Array.isArray(input.spec)) {
failures.push({ property: 'spec', reason: 'spec must be an object' });
}
const mcpd = input.mcpd;
if (mcpd === undefined || typeof mcpd.url !== 'string' || mcpd.url.length === 0) {
failures.push({ property: 'mcpd', reason: 'mcpd.url is required' });
}
if (mcpd === undefined || typeof mcpd.token !== 'string' || mcpd.token.length === 0) {
failures.push({ property: 'mcpd', reason: 'mcpd.token is required' });
}
if (failures.length > 0) return { failures };
const kind = input.kind as string;
const spec = input.spec as Record<string, unknown>;
return { inputs: { ...input, spec: normalizeSpec(kind, spec) } };
},
async diff(_id: string, olds: unknown, news: unknown): Promise<pulumi.dynamic.DiffResult> {
const o = olds as McpctlResourceInputs;
const n = news as McpctlResourceInputs;
const replaces: string[] = [];
if (o.kind !== n.kind) replaces.push('kind');
if (o.name !== n.name) replaces.push('name');
// Re-pointing at a different daemon is a different resource.
if (o.mcpd?.url !== n.mcpd?.url) replaces.push('mcpd');
const immutable = KIND_IMMUTABLE_FIELDS[n.kind] ?? [];
for (const field of immutable) {
if (stableStringify(o.spec?.[field]) !== stableStringify(n.spec?.[field])) {
replaces.push(`spec.${field}`);
}
}
const specChanged = stableStringify(o.spec ?? {}) !== stableStringify(n.spec ?? {});
const changes = replaces.length > 0 || specChanged;
// Any replacement keeps or reuses the unique `name`, so the old row must go
// first to avoid a uniqueness collision (or an orphan on rename).
return { changes, replaces, deleteBeforeReplace: replaces.length > 0 };
},
async create(inputs: unknown): Promise<pulumi.dynamic.CreateResult> {
const inp = inputs as McpctlResourceInputs;
const spec = normalizeSpec(inp.kind, inp.spec ?? {});
const entity = await upsert(inp.mcpd, resourceForKind(inp.kind), { name: inp.name, ...spec }, immutableFieldsForKind(inp.kind));
return { id: `${inp.kind}/${inp.name}`, outs: { kind: inp.kind, name: inp.name, spec, mcpd: inp.mcpd, resourceId: entity.id } };
},
async read(id: string, props: unknown): Promise<pulumi.dynamic.ReadResult> {
const p = props as McpctlResourceInputs;
const entity = await findByName(p.mcpd, resourceForKind(p.kind), p.name);
if (!entity) return {}; // gone — Pulumi will recreate
const spec = stripForRead(p.kind, entity);
delete spec['name'];
return { id, props: { kind: p.kind, name: p.name, mcpd: p.mcpd, spec, resourceId: entity.id } };
},
async update(_id: string, _olds: unknown, news: unknown): Promise<pulumi.dynamic.UpdateResult> {
const n = news as McpctlResourceInputs;
const spec = normalizeSpec(n.kind, n.spec ?? {});
const entity = await upsert(n.mcpd, resourceForKind(n.kind), { name: n.name, ...spec }, immutableFieldsForKind(n.kind));
return { outs: { kind: n.kind, name: n.name, spec, mcpd: n.mcpd, resourceId: entity.id } };
},
async delete(_id: string, props: unknown): Promise<void> {
const p = props as McpctlResourceInputs;
await del(p.mcpd, resourceForKind(p.kind), p.name);
},
};

View File

@@ -0,0 +1,50 @@
/**
* Generic mcpctl resource. Round-trips ANY mcpctl resource kind through the
* dynamic provider: give it a `kind`, a `name`, an opaque `spec` (sent
* verbatim to mcpd, which validates it), and an `mcpd` connection.
*
* new mcpctl.McpctlResource("my-server", {
* kind: "server",
* name: "my-server",
* spec: { image: "...", transport: "stdio", ... },
* mcpd: { url, token },
* });
*/
import * as pulumi from '@pulumi/pulumi';
import { mcpctlProvider } from './provider';
/** mcpd connection + auth. `token` should be a Pulumi secret. */
export interface McpdConnInput {
url: pulumi.Input<string>;
token: pulumi.Input<string>;
timeoutMs?: pulumi.Input<number>;
}
export interface McpctlResourceArgs {
/** Singular resource kind, e.g. "llm", "server", "project", "secret". */
kind: pulumi.Input<string>;
/** Human name — the upsert key (must be unique for the kind). */
name: pulumi.Input<string>;
/** Resource body sent verbatim to mcpd (mcpd's Zod schema validates it). */
spec: pulumi.Input<Record<string, unknown>>;
/** Target daemon + credentials. */
mcpd: pulumi.Input<McpdConnInput>;
}
export class McpctlResource extends pulumi.dynamic.Resource {
/** mcpd's own resource id (CUID) for the managed row. */
public readonly resourceId!: pulumi.Output<string>;
constructor(name: string, args: McpctlResourceArgs, opts?: pulumi.CustomResourceOptions) {
super(
mcpctlProvider,
name,
{ ...args, resourceId: undefined },
{
...opts,
// The connection carries a bearer token — keep it encrypted in state.
additionalSecretOutputs: [...(opts?.additionalSecretOutputs ?? []), 'mcpd'],
},
);
}
}

View File

@@ -0,0 +1,70 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { upsert, findByName, del, McpdHttpError } from '../src/engine';
import { startMockMcpd, type MockMcpd } from './mock-mcpd';
let mock: MockMcpd;
const conn = () => ({ url: mock.baseUrl, token: 'test-token' });
beforeEach(async () => {
mock = await startMockMcpd();
});
afterEach(async () => {
await mock.close();
});
describe('engine.upsert', () => {
it('creates when absent: GET lookup then POST full spec', async () => {
const row = await upsert(conn(), 'llms', { name: 'qwen', type: 'openai', model: 'qwen3' }, ['name', 'type']);
expect(row.id).toMatch(/^cmock/);
const methods = mock.requests.map((r) => `${r.method} ${r.path}`);
expect(methods).toEqual(['GET /api/v1/llms', 'POST /api/v1/llms']);
const post = mock.requests.find((r) => r.method === 'POST')!;
expect(post.body).toEqual({ name: 'qwen', type: 'openai', model: 'qwen3' });
});
it('updates when present: PUT with immutable name+type STRIPPED', async () => {
mock.seed('llms', { id: 'cmock9', name: 'qwen', type: 'openai', model: 'old' });
await upsert(conn(), 'llms', { name: 'qwen', type: 'openai', model: 'new-model' }, ['name', 'type']);
const put = mock.requests.find((r) => r.method === 'PUT')!;
expect(put.path).toBe('/api/v1/llms/cmock9');
// The immutability contract: name and type must NOT be in the PUT body.
expect(put.body).toEqual({ model: 'new-model' });
expect(mock.store['llms']!.get('cmock9')).toMatchObject({ model: 'new-model', type: 'openai' });
});
it('sends Authorization: Bearer on every call', async () => {
await upsert(conn(), 'llms', { name: 'q', type: 'openai', model: 'm' }, ['name', 'type']);
expect(mock.requests.every((r) => r.auth === 'Bearer test-token')).toBe(true);
});
it('retries on 429 then succeeds', async () => {
mock.failNextWith(429, 1); // first request (the GET lookup) 429s once
const row = await upsert(conn(), 'llms', { name: 'q', type: 'openai', model: 'm' }, ['name', 'type']);
expect(row.id).toMatch(/^cmock/);
expect(mock.requests.filter((r) => r.method === 'GET').length).toBe(2); // retried
});
});
describe('engine.findByName / del', () => {
it('findByName returns null when absent', async () => {
expect(await findByName(conn(), 'llms', 'nope')).toBeNull();
});
it('del is idempotent when the row is already gone', async () => {
await expect(del(conn(), 'llms', 'ghost')).resolves.toBeUndefined();
expect(mock.requests.some((r) => r.method === 'DELETE')).toBe(false);
});
it('del removes an existing row by resolved id', async () => {
mock.seed('llms', { id: 'cmock3', name: 'gone', type: 'openai', model: 'm' });
await del(conn(), 'llms', 'gone');
expect(mock.store['llms']!.has('cmock3')).toBe(false);
});
});
describe('engine error surface', () => {
it('throws McpdHttpError on a 500', async () => {
mock.failNextWith(500, 10);
await expect(findByName(conn(), 'llms', 'x')).rejects.toBeInstanceOf(McpdHttpError);
});
});

View File

@@ -0,0 +1,43 @@
import { describe, it, expect, beforeAll } from 'vitest';
import * as pulumi from '@pulumi/pulumi';
let captured: Record<string, any> | undefined;
beforeAll(() => {
pulumi.runtime.setMocks({
newResource: (args: pulumi.runtime.MockResourceArgs) => {
// Dynamic resources carry a serialized __provider plus our inputs.
captured = args.inputs;
return { id: `${args.inputs.name}-id`, state: args.inputs };
},
call: () => ({}),
});
});
function promiseOf<T>(o: pulumi.Output<T>): Promise<T> {
return new Promise((resolve) => o.apply(resolve));
}
describe('Llm wrapper', () => {
it('maps typed args onto a generic {kind:"llm", name, spec, mcpd} resource', async () => {
// Import after setMocks so resource registration is intercepted.
const { Llm } = await import('../src/llm');
const llm = new Llm('homelab-qwen', {
name: 'qwen3-thinking',
type: 'openai',
model: 'qwen3-thinking',
url: 'http://litellm:4000',
tier: 'heavy',
mcpd: { url: 'http://mcpd', token: 'secret-pat' },
});
await promiseOf(llm.urn);
expect(captured).toBeDefined();
expect(captured!.kind).toBe('llm');
expect(captured!.name).toBe('qwen3-thinking');
expect(captured!.spec).toMatchObject({ type: 'openai', model: 'qwen3-thinking', url: 'http://litellm:4000', tier: 'heavy' });
// name is a top-level field, not part of spec.
expect(captured!.spec).not.toHaveProperty('name');
expect(captured!.mcpd).toMatchObject({ url: 'http://mcpd', token: 'secret-pat' });
});
});

View File

@@ -0,0 +1,107 @@
/**
* In-memory mcpd mock for provider/engine tests. Implements the generic REST
* shape the provider relies on: GET list, POST create, PUT /:id, DELETE /:id.
* Records every request for assertions.
*/
import http from 'node:http';
import type { AddressInfo } from 'node:net';
export interface RecordedRequest {
method: string;
path: string;
auth: string | undefined;
body: unknown;
}
export interface MockMcpd {
baseUrl: string;
requests: RecordedRequest[];
/** Seed/read the store directly: store[resource][id] = row. */
store: Record<string, Map<string, Record<string, unknown>>>;
seed(resource: string, row: Record<string, unknown>): void;
/** Force the next N responses (any method) to a given status (e.g. 429). */
failNextWith(status: number, times: number): void;
close(): Promise<void>;
}
export async function startMockMcpd(): Promise<MockMcpd> {
const store: Record<string, Map<string, Record<string, unknown>>> = {};
const requests: RecordedRequest[] = [];
let idCounter = 0;
let forcedStatus: number | null = null;
let forcedTimes = 0;
const ensure = (resource: string) => {
if (!store[resource]) store[resource] = new Map();
return store[resource]!;
};
const server = http.createServer((req, res) => {
const chunks: Buffer[] = [];
req.on('data', (c: Buffer) => chunks.push(c));
req.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf-8');
const body = raw ? JSON.parse(raw) : undefined;
const url = new URL(req.url ?? '/', 'http://mock');
requests.push({ method: req.method ?? '', path: url.pathname, auth: req.headers['authorization'], body });
if (forcedStatus !== null && forcedTimes > 0) {
forcedTimes -= 1;
res.statusCode = forcedStatus;
res.end(JSON.stringify({ error: 'forced' }));
return;
}
// /api/v1/<resource>[/<id>]
const parts = url.pathname.split('/').filter(Boolean); // ["api","v1","<resource>", "<id>?"]
const resource = parts[2] ?? '';
const id = parts[3];
const table = ensure(resource);
const send = (status: number, payload?: unknown) => {
res.statusCode = status;
res.end(payload === undefined ? '' : JSON.stringify(payload));
};
if (req.method === 'GET' && !id) return send(200, [...table.values()]);
if (req.method === 'POST' && !id) {
idCounter += 1;
const row = { id: `cmock${idCounter}0000000000000000000`, ...(body as Record<string, unknown>) };
table.set(row.id, row);
return send(201, row);
}
if (req.method === 'PUT' && id) {
const existing = table.get(id);
if (!existing) return send(404, { error: 'not found' });
const updated = { ...existing, ...(body as Record<string, unknown>) };
table.set(id, updated);
return send(200, updated);
}
if (req.method === 'DELETE' && id) {
if (!table.has(id)) return send(404, { error: 'not found' });
table.delete(id);
return send(200, { ok: true });
}
return send(404, { error: 'unhandled' });
});
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address() as AddressInfo;
return {
baseUrl: `http://127.0.0.1:${port}`,
requests,
store,
seed(resource, row) {
ensure(resource).set(String(row['id']), row);
},
failNextWith(status, times) {
forcedStatus = status;
forcedTimes = times;
},
close() {
return new Promise<void>((resolve) => server.close(() => resolve()));
},
};
}

View File

@@ -0,0 +1,99 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mcpctlProvider } from '../src/provider';
import { startMockMcpd, type MockMcpd } from './mock-mcpd';
let mock: MockMcpd;
const mcpd = () => ({ url: mock.baseUrl, token: 'tok' });
beforeEach(async () => {
mock = await startMockMcpd();
});
afterEach(async () => {
await mock.close();
});
const llmInputs = (over: Record<string, unknown> = {}) => ({
kind: 'llm',
name: 'qwen3-thinking',
spec: { type: 'openai', model: 'qwen3-thinking', url: 'http://litellm:4000', tier: 'heavy' },
mcpd: mcpd(),
...over,
});
describe('check', () => {
it('accepts a valid llm and applies defaults', async () => {
const res = await mcpctlProvider.check!(undefined, llmInputs());
expect(res.failures).toBeUndefined();
// description/extraConfig/poolName defaults injected.
expect(res.inputs.spec).toMatchObject({ tier: 'heavy', description: '', extraConfig: {}, poolName: null });
});
it('rejects a bad name and missing mcpd', async () => {
const res = await mcpctlProvider.check!(undefined, { kind: 'llm', name: 'Bad Name', spec: {}, mcpd: { url: '', token: '' } });
const props = (res.failures ?? []).map((f) => f.property);
expect(props).toContain('name');
expect(props).toContain('mcpd');
});
it('does not reject unknown kinds (forward-compatible)', async () => {
const res = await mcpctlProvider.check!(undefined, { kind: 'somenewkind', name: 'x', spec: {}, mcpd: mcpd() });
expect(res.failures).toBeUndefined();
});
});
describe('diff', () => {
const base = { kind: 'llm', name: 'a', mcpd: { url: 'http://x' }, spec: { type: 'openai', model: 'm1' } };
it('model change → in-place update (changes, no replace)', async () => {
const r = await mcpctlProvider.diff!('llm/a', base, { ...base, spec: { type: 'openai', model: 'm2' } });
expect(r.changes).toBe(true);
expect(r.replaces ?? []).toEqual([]);
});
it('name change → replace + deleteBeforeReplace', async () => {
const r = await mcpctlProvider.diff!('llm/a', base, { ...base, name: 'b' });
expect(r.replaces).toContain('name');
expect(r.deleteBeforeReplace).toBe(true);
});
it('type change (immutable) → replace on spec.type', async () => {
const r = await mcpctlProvider.diff!('llm/a', base, { ...base, spec: { type: 'vllm', model: 'm1' } });
expect(r.replaces).toContain('spec.type');
expect(r.deleteBeforeReplace).toBe(true);
});
it('no change → changes:false', async () => {
const r = await mcpctlProvider.diff!('llm/a', base, { ...base });
expect(r.changes).toBe(false);
});
});
describe('create / read / update / delete round-trip', () => {
it('create posts, read reflects, update puts, delete removes', async () => {
const created = await mcpctlProvider.create!(llmInputs());
expect(created.id).toBe('llm/qwen3-thinking');
expect(created.outs.resourceId).toMatch(/^cmock/);
const read = await mcpctlProvider.read!('llm/qwen3-thinking', { ...llmInputs(), ...created.outs });
expect(read.props?.spec).toMatchObject({ model: 'qwen3-thinking', type: 'openai' });
expect(read.props?.spec).not.toHaveProperty('name'); // name lives at top level
const updated = await mcpctlProvider.update!(
'llm/qwen3-thinking',
created.outs,
llmInputs({ spec: { type: 'openai', model: 'qwen3-next', url: 'http://litellm:4000', tier: 'heavy' } }),
);
expect(updated.outs.spec.model).toBe('qwen3-next');
const put = mock.requests.find((r) => r.method === 'PUT')!;
expect(put.body).not.toHaveProperty('type'); // immutable stripped
expect(put.body).not.toHaveProperty('name');
await mcpctlProvider.delete!('llm/qwen3-thinking', created.outs);
expect([...mock.store['llms']!.values()]).toHaveLength(0);
});
it('read returns {} when the row is gone (triggers recreate)', async () => {
const res = await mcpctlProvider.read!('llm/ghost', { kind: 'llm', name: 'ghost', mcpd: mcpd(), spec: {} });
expect(res).toEqual({});
});
});

12
src/pulumi/tsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"types": ["node"],
"exactOptionalPropertyTypes": false,
"module": "CommonJS",
"moduleResolution": "Node"
},
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/**/*.test.ts'],
environment: 'node',
},
});

View File

@@ -5,6 +5,7 @@
{ "path": "src/db" },
{ "path": "src/cli" },
{ "path": "src/mcpd" },
{ "path": "src/mcplocal" }
{ "path": "src/mcplocal" },
{ "path": "src/pulumi" }
]
}