feat(servers): persistent volumes + self-hosted web search and docs templates
Instances are immutable and get recreated on any server edit, so anything an MCP server wrote to its container filesystem was lost at exactly that point. That ruled out every stateful MCP server, docs-mcp among them: its index is a SQLite file (better-sqlite3 + sqlite-vec) and it has no external-database mode, so no amount of Postgres helps. A server or template can now declare volumes. The backing store is keyed on the server, not the instance — `mcpctl-<server>-<name>` — which is the whole point: an instance-scoped claim would be destroyed precisely when the data needs to survive. On Kubernetes that is a PVC ensured in the servers namespace before the pod is created and never deleted with it; on Docker, a named volume (named, not anonymous, so `removeContainer`'s `v: true` leaves it alone). Claims are ReadWriteOnce, so volumes and replicas > 1 are mutually exclusive; validation rejects that pair instead of leaving the extra replicas unschedulable. storageClassName is omitted rather than sent empty when no class is configured — to Kubernetes those mean different things. Also fixes a pre-existing bug in the same path: seedTemplates dropped `runtime`, so every PyPI-backed template seeded from YAML silently defaulted to node and would run `npx` against a package that only exists on PyPI. `unifi-network` declares `runtime: python` and had been seeding with runtime unset. Templates added, all self-hosted and none needing an API key: - duckduckgo — no backing service at all - searxng — needs a SearXNG engine (compose profile in stack/) - docs-mcp — open-source Context7/Ref alternative, uses the new volume Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
This commit is contained in:
@@ -20,6 +20,13 @@ const ServerEnvEntrySchema = z.object({
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
const VolumeSpecSchema = z.object({
|
||||
name: z.string().min(1).max(50).regex(/^[a-z0-9-]+$/),
|
||||
mountPath: z.string().min(1).regex(/^\//, 'mountPath must be absolute'),
|
||||
sizeGb: z.number().int().min(1).max(1024).default(10),
|
||||
storageClass: z.string().optional(),
|
||||
});
|
||||
|
||||
const ServerSpecSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().default(''),
|
||||
@@ -34,6 +41,7 @@ const ServerSpecSchema = z.object({
|
||||
replicas: z.number().int().min(0).max(10).default(1),
|
||||
env: z.array(ServerEnvEntrySchema).default([]),
|
||||
healthCheck: HealthCheckSchema.optional(),
|
||||
volumes: z.array(VolumeSpecSchema).default([]),
|
||||
});
|
||||
|
||||
const SecretSpecSchema = z.object({
|
||||
@@ -125,6 +133,7 @@ const TemplateSpecSchema = z.object({
|
||||
replicas: z.number().int().min(0).max(10).default(1),
|
||||
env: z.array(TemplateEnvEntrySchema).default([]),
|
||||
healthCheck: HealthCheckSchema.optional(),
|
||||
volumes: z.array(VolumeSpecSchema).default([]),
|
||||
});
|
||||
|
||||
const UserSpecSchema = z.object({
|
||||
|
||||
@@ -103,6 +103,53 @@ function parseServerEnv(entries: string[]): ServerEnvEntry[] {
|
||||
});
|
||||
}
|
||||
|
||||
export interface ServerVolume {
|
||||
name: string;
|
||||
mountPath: string;
|
||||
sizeGb: number;
|
||||
storageClass?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `NAME:/mount/path[:SIZE_GB[:STORAGE_CLASS]]`.
|
||||
*
|
||||
* Split on ':' rather than a regex so a Windows-style or otherwise odd mount
|
||||
* path fails loudly on the leading-slash check instead of being silently
|
||||
* mis-parsed into the wrong field.
|
||||
*/
|
||||
export function parseServerVolumes(entries: string[]): ServerVolume[] {
|
||||
return entries.map((entry) => {
|
||||
const parts = entry.split(':');
|
||||
if (parts.length < 2 || parts.length > 4) {
|
||||
throw new Error(
|
||||
`Invalid volume format '${entry}'. Expected NAME:/mount/path[:SIZE_GB[:STORAGE_CLASS]]`,
|
||||
);
|
||||
}
|
||||
const [name, mountPath, sizeRaw, storageClass] = parts;
|
||||
if (name === undefined || name === '') {
|
||||
throw new Error(`Invalid volume '${entry}': name is empty`);
|
||||
}
|
||||
if (mountPath === undefined || !mountPath.startsWith('/')) {
|
||||
throw new Error(`Invalid volume '${entry}': mount path must be absolute (start with '/')`);
|
||||
}
|
||||
|
||||
let sizeGb = 10;
|
||||
if (sizeRaw !== undefined && sizeRaw !== '') {
|
||||
sizeGb = Number(sizeRaw);
|
||||
if (!Number.isInteger(sizeGb) || sizeGb < 1) {
|
||||
throw new Error(`Invalid volume '${entry}': size must be a positive whole number of GiB`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
mountPath,
|
||||
sizeGb,
|
||||
...(storageClass !== undefined && storageClass !== '' ? { storageClass } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function parseEnvEntries(entries: string[]): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const entry of entries) {
|
||||
@@ -136,6 +183,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
||||
.option('--container-port <port>', 'Container port number')
|
||||
.option('--replicas <count>', 'Number of replicas')
|
||||
.option('--env <entry>', 'Env var: KEY=value (inline) or KEY=secretRef:SECRET:KEY (secret ref, repeat for multiple)', collect, [])
|
||||
.option('--volume <spec>', 'Persistent volume: NAME:/mount/path[:SIZE_GB[:STORAGE_CLASS]] (repeat for multiple)', collect, [])
|
||||
.option('--from-template <name>', 'Create from template (name or name:version)')
|
||||
.option('--env-from-secret <secret>', 'Map template env vars from a secret')
|
||||
.option('--force', 'Update if already exists')
|
||||
@@ -233,6 +281,19 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
||||
}
|
||||
body.env = merged;
|
||||
}
|
||||
if (opts.volume.length > 0) {
|
||||
// Merge by volume name, same as --env, so a CLI volume can retarget one
|
||||
// the template declared without duplicating the mount.
|
||||
const cliVolumes = parseServerVolumes(opts.volume as string[]);
|
||||
const existing = (body.volumes as ServerVolume[] | undefined) ?? [];
|
||||
const merged = [...existing];
|
||||
for (const vol of cliVolumes) {
|
||||
const idx = merged.findIndex((v) => v.name === vol.name);
|
||||
if (idx >= 0) merged[idx] = vol;
|
||||
else merged.push(vol);
|
||||
}
|
||||
body.volumes = merged;
|
||||
}
|
||||
|
||||
// Defaults when no template
|
||||
if (!opts.fromTemplate) {
|
||||
|
||||
@@ -51,6 +51,22 @@ function formatServerDetail(server: Record<string, unknown>): string {
|
||||
}
|
||||
}
|
||||
|
||||
const volumes = server.volumes as Array<{ name: string; mountPath: string; sizeGb?: number; storageClass?: string }> | undefined;
|
||||
if (volumes && volumes.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('Volumes:');
|
||||
const volW = Math.max(6, ...volumes.map((v) => v.name.length)) + 2;
|
||||
lines.push(` ${'NAME'.padEnd(volW)}MOUNT PATH${' '.repeat(12)}SIZE CLAIM`);
|
||||
for (const v of volumes) {
|
||||
const size = `${v.sizeGb ?? 10}Gi`;
|
||||
const cls = v.storageClass !== undefined && v.storageClass !== '' ? ` (${v.storageClass})` : '';
|
||||
lines.push(
|
||||
` ${v.name.padEnd(volW)}${pad(v.mountPath, 22)}${pad(size, 7)}`
|
||||
+ `mcpctl-${server.name as string}-${v.name}${cls}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const hc = server.healthCheck as { tool: string; arguments?: Record<string, unknown>; intervalSeconds?: number; timeoutSeconds?: number; failureThreshold?: number } | null;
|
||||
if (hc) {
|
||||
lines.push('');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createCreateCommand } from '../../src/commands/create.js';
|
||||
import { createCreateCommand, parseServerVolumes } from '../../src/commands/create.js';
|
||||
import { type ApiClient, ApiError } from '../../src/api-client.js';
|
||||
|
||||
function mockClient(): ApiClient {
|
||||
@@ -556,3 +556,31 @@ describe('create command', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseServerVolumes', () => {
|
||||
it('parses name, path, size and storage class', () => {
|
||||
expect(parseServerVolumes(['data:/data:20:longhorn'])).toEqual([
|
||||
{ name: 'data', mountPath: '/data', sizeGb: 20, storageClass: 'longhorn' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('defaults size to 10Gi and omits storageClass', () => {
|
||||
expect(parseServerVolumes(['data:/data'])).toEqual([
|
||||
{ name: 'data', mountPath: '/data', sizeGb: 10 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects a relative mount path', () => {
|
||||
expect(() => parseServerVolumes(['data:data'])).toThrow(/must be absolute/);
|
||||
});
|
||||
|
||||
it('rejects a non-integer or zero size', () => {
|
||||
expect(() => parseServerVolumes(['data:/data:abc'])).toThrow(/positive whole number/);
|
||||
expect(() => parseServerVolumes(['data:/data:0'])).toThrow(/positive whole number/);
|
||||
});
|
||||
|
||||
it('rejects a malformed spec', () => {
|
||||
expect(() => parseServerVolumes(['data'])).toThrow(/Invalid volume format/);
|
||||
expect(() => parseServerVolumes(['a:/b:1:c:d'])).toThrow(/Invalid volume format/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user