Files
mcpctl/src/pulumi
Michal a517443828
Some checks failed
CI/CD / lint (pull_request) Successful in 1m1s
CI/CD / typecheck (pull_request) Successful in 2m6s
CI/CD / test (pull_request) Successful in 1m19s
CI/CD / build (pull_request) Successful in 2m24s
CI/CD / smoke (pull_request) Failing after 3m9s
CI/CD / publish (pull_request) Has been skipped
fix(pulumi): drop exports map so Pulumi can serialize the provider (v0.0.2)
Pulumi's closure serializer reaches the dynamic provider's internal submodules
(dist/engine.js, dist/kinds.js) by subpath. An `exports` allowlist with only
"." blocks that: `pulumi preview` failed with "package.json export path for
.../dist/kinds.js not found". Removing `exports` (keeping main/types) lets the
serializer resolve them. Verified end-to-end: a real `pulumi up` from
kubernetes-deployment updated mcpd's qwen3-thinking llm and it probes 200.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 22:32:41 +01:00
..

@mcpctl/pulumi

A Pulumi 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

npm install @mcpctl/pulumi @pulumi/pulumi

Usage

Typed Llm wrapper

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

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 -:

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.