feat: mcpctl v0.0.1 — first public release
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions

Comprehensive MCP server management with kubectl-style CLI.

Key features in this release:
- Declarative YAML apply/get round-trip with project cloning support
- Gated sessions with prompt intelligence for Claude
- Interactive MCP console with traffic inspector
- Persistent STDIO connections for containerized servers
- RBAC with name-scoped bindings
- Shell completions (fish + bash) auto-generated
- Rate-limit retry with exponential backoff in apply
- Project-scoped prompt management
- Credential scrubbing from git history

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-02-27 17:05:05 +00:00
parent 414a8d3774
commit 69867bd47a
65 changed files with 5710 additions and 695 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@mcpctl/mcpd",
"version": "0.1.0",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "./dist/index.js",

View File

@@ -23,14 +23,11 @@ const SYSTEM_PROMPTS: SystemPromptDef[] = [
{
name: 'gate-instructions',
priority: 10,
content: `This project uses a gated session. Before you can access tools, you must describe your current task by calling begin_session with 3-7 keywords.
content: `This project uses a gated session. Before you can access tools, you must start a session by calling begin_session.
After calling begin_session, you will receive:
1. Relevant project prompts matched to your keywords
2. A list of other available prompts
3. Full access to all project tools
Call begin_session immediately using the arguments it requires (check its input schema). If it accepts a description, briefly describe the user's task. If it accepts tags, provide 3-7 keywords relevant to the user's request.
Choose your keywords carefully — they determine which context you receive.`,
The available tools and prompts are listed below. After calling begin_session, you will receive relevant project context and full access to all tools.`,
},
{
name: 'gate-encouragement',
@@ -46,12 +43,19 @@ It is better to check and not need it than to proceed without important context.
Review this context carefully — it may contain important guidelines, constraints, or patterns relevant to your work. If you need more context, use read_prompts({ tags: [...] }) at any time.`,
},
{
name: 'gate-session-active',
priority: 10,
content: `The session is now active with full tool access. Proceed with the user's original request using the tools listed above.`,
},
{
name: 'session-greeting',
priority: 10,
content: `Welcome to this project. To get started, call begin_session with keywords describing your task.
content: `Welcome to this project. To get started, call begin_session with the arguments it requires.
Example: begin_session({ tags: ["zigbee", "pairing", "mqtt"] })
Examples:
begin_session({ tags: ["zigbee", "pairing", "mqtt"] })
begin_session({ description: "I want to pair a new Zigbee device" })
This will load relevant project context, policies, and guidelines tailored to your work.`,
},

View File

@@ -35,7 +35,10 @@ export class PromptRepository implements IPromptRepository {
}
async findById(id: string): Promise<Prompt | null> {
return this.prisma.prompt.findUnique({ where: { id } });
return this.prisma.prompt.findUnique({
where: { id },
include: { project: { select: { name: true } } },
});
}
async findByNameAndProject(name: string, projectId: string | null): Promise<Prompt | null> {

View File

@@ -6,6 +6,7 @@ import type {
ContainerInfo,
ContainerLogs,
ExecResult,
InteractiveExec,
} from '../orchestrator.js';
import { DEFAULT_MEMORY_LIMIT } from '../orchestrator.js';
@@ -239,4 +240,32 @@ export class DockerContainerManager implements McpOrchestrator {
});
});
}
async execInteractive(containerId: string, cmd: string[]): Promise<InteractiveExec> {
const container = this.docker.getContainer(containerId);
const exec = await container.exec({
Cmd: cmd,
AttachStdin: true,
AttachStdout: true,
AttachStderr: true,
});
const stream = await exec.start({ hijack: true, stdin: true });
// Demux Docker's multiplexed stream into separate stdout/stderr
const stdout = new PassThrough();
const stderr = new PassThrough();
this.docker.modem.demuxStream(stream, stdout, stderr);
return {
stdout,
write(data: string) {
stream.write(data);
},
close() {
try { stream.end(); } catch { /* ignore */ }
},
};
}
}

View File

@@ -218,7 +218,7 @@ export class HealthProbeRunner {
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json, text/event-stream' },
body: JSON.stringify({
jsonrpc: '2.0', id: 1, method: 'initialize',
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'mcpctl-health', version: '0.1.0' } },
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'mcpctl-health', version: '0.0.1' } },
}),
signal: controller.signal,
});
@@ -333,7 +333,7 @@ export class HealthProbeRunner {
method: 'POST', headers: postHeaders,
body: JSON.stringify({
jsonrpc: '2.0', id: 1, method: 'initialize',
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'mcpctl-health', version: '0.1.0' } },
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'mcpctl-health', version: '0.0.1' } },
}),
signal: controller.signal,
});
@@ -424,9 +424,16 @@ export class HealthProbeRunner {
const start = Date.now();
const packageName = server.packageName as string | null;
const command = server.command as string[] | null;
if (!packageName) {
return { healthy: false, latencyMs: 0, message: 'No package name for STDIO server' };
// Determine how to spawn the MCP server inside the container
let spawnCmd: string[];
if (packageName) {
spawnCmd = ['npx', '--prefer-offline', '-y', packageName];
} else if (command && command.length > 0) {
spawnCmd = command;
} else {
return { healthy: false, latencyMs: 0, message: 'No packageName or command for STDIO server' };
}
// Build JSON-RPC messages for the health probe
@@ -435,7 +442,7 @@ export class HealthProbeRunner {
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'mcpctl-health', version: '0.1.0' },
clientInfo: { name: 'mcpctl-health', version: '0.0.1' },
},
});
const initializedMsg = JSON.stringify({
@@ -447,13 +454,15 @@ export class HealthProbeRunner {
});
// Use a Node.js inline script that:
// 1. Spawns the MCP server binary via npx
// 1. Spawns the MCP server binary
// 2. Sends initialize + initialized + tool call via stdin
// 3. Reads responses from stdout
// 4. Exits with 0 if tool call succeeds, 1 if it fails
const spawnArgs = JSON.stringify(spawnCmd);
const probeScript = `
const { spawn } = require('child_process');
const proc = spawn('npx', ['--prefer-offline', '-y', ${JSON.stringify(packageName)}], { stdio: ['pipe', 'pipe', 'pipe'] });
const args = ${spawnArgs};
const proc = spawn(args[0], args.slice(1), { stdio: ['pipe', 'pipe', 'pipe'] });
let output = '';
let responded = false;
proc.stdout.on('data', d => {

View File

@@ -5,6 +5,7 @@ import { NotFoundError } from './mcp-server.service.js';
import { InvalidStateError } from './instance.service.js';
import { sendViaSse } from './transport/sse-client.js';
import { sendViaStdio } from './transport/stdio-client.js';
import { PersistentStdioClient } from './transport/persistent-stdio.js';
export interface McpProxyRequest {
serverId: string;
@@ -37,6 +38,8 @@ function parseStreamableResponse(body: string): McpProxyResponse {
export class McpProxyService {
/** Session IDs per server for streamable-http protocol */
private sessions = new Map<string, string>();
/** Persistent STDIO connections keyed by containerId */
private stdioClients = new Map<string, PersistentStdioClient>();
constructor(
private readonly instanceRepo: IMcpInstanceRepository,
@@ -44,6 +47,23 @@ export class McpProxyService {
private readonly orchestrator?: McpOrchestrator,
) {}
/** Clean up all persistent connections (call on shutdown). */
closeAll(): void {
for (const [, client] of this.stdioClients) {
client.close();
}
this.stdioClients.clear();
}
/** Remove persistent connection for a container (call when instance stops). */
removeClient(containerId: string): void {
const client = this.stdioClients.get(containerId);
if (client) {
client.close();
this.stdioClients.delete(containerId);
}
}
async execute(request: McpProxyRequest): Promise<McpProxyResponse> {
const server = await this.serverRepo.findById(request.serverId);
if (!server) {
@@ -95,7 +115,7 @@ export class McpProxyService {
): Promise<McpProxyResponse> {
const transport = server.transport as string;
// STDIO: use docker exec
// STDIO: use persistent connection (falls back to one-shot on error)
if (transport === 'STDIO') {
if (!this.orchestrator) {
throw new InvalidStateError('Orchestrator required for STDIO transport');
@@ -104,10 +124,24 @@ export class McpProxyService {
throw new InvalidStateError(`Instance '${instance.id}' has no container ID`);
}
const packageName = server.packageName as string | null;
if (!packageName) {
throw new InvalidStateError(`Server '${server.id}' has no package name for STDIO transport`);
const command = server.command as string[] | null;
if (!packageName && (!command || command.length === 0)) {
throw new InvalidStateError(`Server '${server.id}' has no packageName or command for STDIO transport`);
}
// Build the spawn command for persistent mode
const spawnCmd = command && command.length > 0
? command
: ['npx', '--prefer-offline', '-y', packageName!];
// Try persistent connection first
try {
return await this.sendViaPersistentStdio(instance.containerId, spawnCmd, method, params);
} catch {
// Persistent failed — fall back to one-shot
this.removeClient(instance.containerId);
return sendViaStdio(this.orchestrator, instance.containerId, packageName, method, params, 120_000, command);
}
return sendViaStdio(this.orchestrator, instance.containerId, packageName, method, params);
}
// SSE or STREAMABLE_HTTP: need a base URL
@@ -121,6 +155,23 @@ export class McpProxyService {
return this.sendStreamableHttp(server.id, baseUrl, method, params);
}
/**
* Send via a persistent STDIO connection (reused across calls).
*/
private async sendViaPersistentStdio(
containerId: string,
command: string[],
method: string,
params?: Record<string, unknown>,
): Promise<McpProxyResponse> {
let client = this.stdioClients.get(containerId);
if (!client) {
client = new PersistentStdioClient(this.orchestrator!, containerId, command);
this.stdioClients.set(containerId, client);
}
return client.send(method, params);
}
/**
* Resolve the base URL for an HTTP-based managed server.
* Prefers container internal IP on Docker network, falls back to localhost:port.
@@ -218,7 +269,7 @@ export class McpProxyService {
params: {
protocolVersion: '2025-03-26',
capabilities: {},
clientInfo: { name: 'mcpctl', version: '0.1.0' },
clientInfo: { name: 'mcpctl', version: '0.0.1' },
},
};

View File

@@ -68,10 +68,23 @@ export interface McpOrchestrator {
/** Execute a command inside a running container with optional stdin */
execInContainer(containerId: string, cmd: string[], opts?: { stdin?: string; timeoutMs?: number }): Promise<ExecResult>;
/** Start a long-running interactive exec session (bidirectional stdio stream). */
execInteractive?(containerId: string, cmd: string[]): Promise<InteractiveExec>;
/** Check if the orchestrator runtime is available */
ping(): Promise<boolean>;
}
/** A bidirectional stream to an interactive exec session. */
export interface InteractiveExec {
/** Demuxed stdout stream (JSON-RPC responses come here). */
stdout: NodeJS.ReadableStream;
/** Write raw bytes to the process stdin. */
write(data: string): void;
/** Kill the exec process. */
close(): void;
}
/** Default resource limits */
export const DEFAULT_MEMORY_LIMIT = 512 * 1024 * 1024; // 512 MB
export const DEFAULT_NANO_CPUS = 500_000_000; // 0.5 CPU

View File

@@ -176,20 +176,20 @@ export class PromptService {
async getVisiblePrompts(
projectId?: string,
sessionId?: string,
): Promise<Array<{ name: string; content: string; type: 'prompt' | 'promptrequest' }>> {
const results: Array<{ name: string; content: string; type: 'prompt' | 'promptrequest' }> = [];
): Promise<Array<{ name: string; content: string; priority: number; summary: string | null; chapters: string[] | null; linkTarget: string | null; type: 'prompt' | 'promptrequest' }>> {
const results: Array<{ name: string; content: string; priority: number; summary: string | null; chapters: string[] | null; linkTarget: string | null; type: 'prompt' | 'promptrequest' }> = [];
// Approved prompts (project-scoped + global)
const prompts = await this.promptRepo.findAll(projectId);
for (const p of prompts) {
results.push({ name: p.name, content: p.content, type: 'prompt' });
results.push({ name: p.name, content: p.content, priority: p.priority, summary: p.summary, chapters: p.chapters as string[] | null, linkTarget: p.linkTarget, type: 'prompt' });
}
// Session's own pending requests
if (sessionId) {
const requests = await this.promptRequestRepo.findBySession(sessionId, projectId);
for (const r of requests) {
results.push({ name: r.name, content: r.content, type: 'promptrequest' });
results.push({ name: r.name, content: r.content, priority: 5, summary: null, chapters: null, linkTarget: null, type: 'promptrequest' });
}
}

View File

@@ -0,0 +1,188 @@
import type { McpOrchestrator, InteractiveExec } from '../orchestrator.js';
import type { McpProxyResponse } from '../mcp-proxy-service.js';
/**
* Persistent STDIO connection to an MCP server running inside a Docker container.
*
* Instead of cold-starting a new process per call (docker exec one-shot), this keeps
* a long-running `docker exec -i <cmd>` session alive. The MCP init handshake runs
* once, then tool calls are multiplexed over the same stdin/stdout pipe.
*
* Falls back gracefully: if the process dies, the next call will reconnect.
*/
export class PersistentStdioClient {
private exec: InteractiveExec | null = null;
private buffer = '';
private nextId = 1;
private initialized = false;
private connecting: Promise<void> | null = null;
private pendingRequests = new Map<number, {
resolve: (res: McpProxyResponse) => void;
reject: (err: Error) => void;
timer: ReturnType<typeof setTimeout>;
}>();
constructor(
private readonly orchestrator: McpOrchestrator,
private readonly containerId: string,
private readonly command: string[],
private readonly timeoutMs = 120_000,
) {}
/**
* Send a JSON-RPC request and wait for the matching response.
*/
async send(method: string, params?: Record<string, unknown>): Promise<McpProxyResponse> {
await this.ensureReady();
const id = this.nextId++;
const request: Record<string, unknown> = { jsonrpc: '2.0', id, method };
if (params !== undefined) {
request.params = params;
}
return new Promise<McpProxyResponse>((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingRequests.delete(id);
reject(new Error(`Request timed out after ${this.timeoutMs}ms`));
}, this.timeoutMs);
this.pendingRequests.set(id, { resolve, reject, timer });
this.write(request);
});
}
/** Shut down the persistent connection. */
close(): void {
if (this.exec) {
this.exec.close();
this.exec = null;
}
this.initialized = false;
this.connecting = null;
for (const [, pending] of this.pendingRequests) {
clearTimeout(pending.timer);
pending.reject(new Error('Connection closed'));
}
this.pendingRequests.clear();
}
get isConnected(): boolean {
return this.initialized && this.exec !== null;
}
// ── internals ──
private async ensureReady(): Promise<void> {
if (this.initialized && this.exec) return;
if (this.connecting) {
await this.connecting;
return;
}
this.connecting = this.connect();
try {
await this.connecting;
} finally {
this.connecting = null;
}
}
private async connect(): Promise<void> {
this.close();
if (!this.orchestrator.execInteractive) {
throw new Error('Orchestrator does not support interactive exec');
}
const exec = await this.orchestrator.execInteractive(this.containerId, this.command);
this.exec = exec;
this.buffer = '';
// Parse JSON-RPC responses line by line from stdout
exec.stdout.on('data', (chunk: Buffer) => {
this.buffer += chunk.toString('utf-8');
this.processBuffer();
});
exec.stdout.on('end', () => {
this.initialized = false;
this.exec = null;
for (const [, pending] of this.pendingRequests) {
clearTimeout(pending.timer);
pending.reject(new Error('STDIO process exited'));
}
this.pendingRequests.clear();
});
// Run MCP init handshake
const initId = this.nextId++;
const initPromise = new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingRequests.delete(initId);
reject(new Error('MCP init handshake timed out'));
}, 30_000);
this.pendingRequests.set(initId, {
resolve: () => {
clearTimeout(timer);
resolve();
},
reject: (err) => {
clearTimeout(timer);
reject(err);
},
timer,
});
});
this.write({
jsonrpc: '2.0',
id: initId,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'mcpctl-proxy', version: '0.0.1' },
},
});
await initPromise;
// Send initialized notification (no response expected)
this.write({ jsonrpc: '2.0', method: 'notifications/initialized' });
// Small delay to let the server process the notification
await new Promise((r) => setTimeout(r, 100));
this.initialized = true;
}
private write(msg: Record<string, unknown>): void {
if (!this.exec) throw new Error('Not connected');
this.exec.write(JSON.stringify(msg) + '\n');
}
private processBuffer(): void {
const lines = this.buffer.split('\n');
this.buffer = lines.pop() ?? '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const msg = JSON.parse(trimmed) as Record<string, unknown>;
if ('id' in msg && msg.id !== undefined) {
const pending = this.pendingRequests.get(msg.id as number);
if (pending) {
this.pendingRequests.delete(msg.id as number);
clearTimeout(pending.timer);
pending.resolve(msg as unknown as McpProxyResponse);
}
}
// Notifications from server are ignored (not needed for proxy)
} catch {
// Skip non-JSON lines
}
}
}
}

View File

@@ -73,7 +73,7 @@ export async function sendViaSse(
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'mcpctl-proxy', version: '0.1.0' },
clientInfo: { name: 'mcpctl-proxy', version: '0.0.1' },
},
}),
signal: controller.signal,

View File

@@ -12,10 +12,11 @@ import type { McpProxyResponse } from '../mcp-proxy-service.js';
export async function sendViaStdio(
orchestrator: McpOrchestrator,
containerId: string,
packageName: string,
packageName: string | null,
method: string,
params?: Record<string, unknown>,
timeoutMs = 30_000,
command?: string[] | null,
): Promise<McpProxyResponse> {
const initMsg = JSON.stringify({
jsonrpc: '2.0',
@@ -24,7 +25,7 @@ export async function sendViaStdio(
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'mcpctl-proxy', version: '0.1.0' },
clientInfo: { name: 'mcpctl-proxy', version: '0.0.1' },
},
});
const initializedMsg = JSON.stringify({
@@ -42,14 +43,26 @@ export async function sendViaStdio(
}
const requestMsg = JSON.stringify(requestBody);
// Determine spawn command
let spawnCmd: string[];
if (packageName) {
spawnCmd = ['npx', '--prefer-offline', '-y', packageName];
} else if (command && command.length > 0) {
spawnCmd = command;
} else {
return errorResponse('No packageName or command for STDIO server');
}
const spawnArgs = JSON.stringify(spawnCmd);
// Inline Node.js script that:
// 1. Spawns the MCP server binary via npx
// 1. Spawns the MCP server binary
// 2. Sends initialize → initialized → actual request via stdin
// 3. Reads stdout for JSON-RPC response with id: 2
// 4. Outputs the full JSON-RPC response to stdout
const probeScript = `
const { spawn } = require('child_process');
const proc = spawn('npx', ['--prefer-offline', '-y', ${JSON.stringify(packageName)}], { stdio: ['pipe', 'pipe', 'pipe'] });
const args = ${spawnArgs};
const proc = spawn(args[0], args.slice(1), { stdio: ['pipe', 'pipe', 'pipe'] });
let output = '';
let responded = false;
proc.stdout.on('data', d => {

View File

@@ -301,7 +301,7 @@ describe('RestoreService', () => {
const validBundle = {
version: '1',
mcpctlVersion: '0.1.0',
mcpctlVersion: '0.0.1',
createdAt: new Date().toISOString(),
encrypted: false,
servers: [{ name: 'github', description: 'GitHub', packageName: null, dockerImage: null, transport: 'STDIO', repositoryUrl: null, env: [] }],