# @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. 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.
