# @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 — 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: # 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. For the same reason the package intentionally has **no `exports` map**: Pulumi's closure serializer reaches the internal `dist/*.js` submodules by subpath, which an `exports` allowlist would block ("package.json export path ... not found"). Validate end-to-end with a real `pulumi up` against a disposable mcpd before relying on it in production.