feat: mcpctl v0.0.1 — first public release
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:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mcpctl/mcpd",
|
||||
"version": "0.1.0",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -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.`,
|
||||
},
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 */ }
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
188
src/mcpd/src/services/transport/persistent-stdio.ts
Normal file
188
src/mcpd/src/services/transport/persistent-stdio.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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: [] }],
|
||||
|
||||
Reference in New Issue
Block a user