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

@@ -73,6 +73,7 @@ function setupGatedRouter(
prompts?: typeof samplePrompts;
withLlm?: boolean;
llmResponse?: string;
byteBudget?: number;
} = {},
): { router: McpRouter; mcpdClient: McpdClient } {
const router = new McpRouter();
@@ -101,6 +102,7 @@ function setupGatedRouter(
router.setGateConfig({
gated: opts.gated !== false,
providerRegistry,
byteBudget: opts.byteBudget,
});
return { router, mcpdClient };
@@ -309,16 +311,18 @@ describe('McpRouter gating', () => {
});
it('filters out already-sent prompts', async () => {
const { router } = setupGatedRouter();
// Use a tight byte budget so begin_session only sends the top-scoring prompts
const { router } = setupGatedRouter({ byteBudget: 80 });
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
// begin_session sends common-mistakes (priority 10) and zigbee-pairing
// begin_session with ['zigbee'] sends common-mistakes (priority 10, Inf) and
// zigbee-pairing (7+7=14) within 80 bytes. Lower-scored prompts overflow.
await router.route(
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee'] } } },
{ sessionId: 's1' },
);
// read_prompts for mqtt should not re-send common-mistakes
// read_prompts for mqtt should find mqtt-config (wasn't fully sent), not re-send common-mistakes
const res = await router.route(
{ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'read_prompts', arguments: { tags: ['mqtt'] } } },
{ sessionId: 's1' },
@@ -495,6 +499,121 @@ describe('McpRouter gating', () => {
});
});
describe('tool inventory', () => {
it('includes tool names but NOT descriptions in gated initialize instructions', async () => {
const { router } = setupGatedRouter();
router.addUpstream(mockUpstream('ha', { tools: [{ name: 'get_entities', description: 'Get all entities' }] }));
router.addUpstream(mockUpstream('node-red', { tools: [{ name: 'get_flows', description: 'Get all flows' }] }));
const res = await router.route(
{ jsonrpc: '2.0', id: 1, method: 'initialize' },
{ sessionId: 's1' },
);
const result = res.result as { instructions: string };
expect(result.instructions).toContain('ha/get_entities');
expect(result.instructions).toContain('node-red/get_flows');
expect(result.instructions).toContain('after begin_session');
// Descriptions should NOT be in init instructions (names only)
expect(result.instructions).not.toContain('Get all entities');
expect(result.instructions).not.toContain('Get all flows');
});
it('includes tool names but NOT descriptions in begin_session response', async () => {
const { router } = setupGatedRouter();
router.addUpstream(mockUpstream('ha', { tools: [{ name: 'get_entities', description: 'Get all entities' }] }));
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
const res = await router.route(
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee'] } } },
{ sessionId: 's1' },
);
const text = (res.result as { content: Array<{ text: string }> }).content[0]!.text;
expect(text).toContain('ha/get_entities');
expect(text).not.toContain('Get all entities');
});
it('includes retry instruction in begin_session response', async () => {
const { router } = setupGatedRouter();
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
const res = await router.route(
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee'] } } },
{ sessionId: 's1' },
);
const text = (res.result as { content: Array<{ text: string }> }).content[0]!.text;
expect(text).toContain('Proceed with');
});
it('includes tool names but NOT descriptions in gated intercept briefing', async () => {
const { router } = setupGatedRouter();
const ha = mockUpstream('ha', { tools: [{ name: 'get_entities', description: 'Get all entities' }] });
router.addUpstream(ha);
await router.discoverTools();
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
const res = await router.route(
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'ha/get_entities', arguments: {} } },
{ sessionId: 's1' },
);
const result = res.result as { content: Array<{ type: string; text: string }> };
const briefing = result.content[0]!.text;
expect(briefing).toContain('ha/get_entities');
expect(briefing).not.toContain('Get all entities');
});
});
describe('notifications after ungating', () => {
it('queues tools/list_changed after begin_session ungating', async () => {
const { router } = setupGatedRouter();
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
await router.route(
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee'] } } },
{ sessionId: 's1' },
);
const notifications = router.consumeNotifications('s1');
expect(notifications).toHaveLength(1);
expect(notifications[0]!.method).toBe('notifications/tools/list_changed');
});
it('queues tools/list_changed after gated intercept', async () => {
const { router } = setupGatedRouter();
const ha = mockUpstream('ha', { tools: [{ name: 'get_entities' }] });
router.addUpstream(ha);
await router.discoverTools();
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
await router.route(
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'ha/get_entities', arguments: {} } },
{ sessionId: 's1' },
);
const notifications = router.consumeNotifications('s1');
expect(notifications).toHaveLength(1);
expect(notifications[0]!.method).toBe('notifications/tools/list_changed');
});
it('consumeNotifications clears the queue', async () => {
const { router } = setupGatedRouter();
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
await router.route(
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee'] } } },
{ sessionId: 's1' },
);
// First consume returns the notification
expect(router.consumeNotifications('s1')).toHaveLength(1);
// Second consume returns empty
expect(router.consumeNotifications('s1')).toHaveLength(0);
});
});
describe('prompt index caching', () => {
it('caches prompt index for 60 seconds', async () => {
const { router, mcpdClient } = setupGatedRouter({ gated: false });
@@ -517,4 +636,216 @@ describe('McpRouter gating', () => {
expect(getCalls).toHaveLength(1);
});
});
describe('begin_session description field', () => {
it('accepts description and tokenizes to keywords', async () => {
const { router } = setupGatedRouter();
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
const res = await router.route(
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { description: 'I want to pair a zigbee device with mqtt' } } },
{ sessionId: 's1' },
);
expect(res.error).toBeUndefined();
const text = (res.result as { content: Array<{ text: string }> }).content[0]!.text;
// Should match zigbee-pairing and mqtt-config via tokenized keywords
expect(text).toContain('zigbee-pairing');
expect(text).toContain('mqtt-config');
});
it('prefers tags over description when both provided', async () => {
const { router } = setupGatedRouter();
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
const res = await router.route(
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['mqtt'], description: 'zigbee pairing' } } },
{ sessionId: 's1' },
);
expect(res.error).toBeUndefined();
const text = (res.result as { content: Array<{ text: string }> }).content[0]!.text;
// Tags take priority — mqtt-config should match, zigbee-pairing should not
expect(text).toContain('mqtt-config');
});
it('rejects calls with neither tags nor description', async () => {
const { router } = setupGatedRouter();
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
const res = await router.route(
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: {} } },
{ sessionId: 's1' },
);
expect(res.error).toBeDefined();
expect(res.error!.code).toBe(-32602);
expect(res.error!.message).toContain('tags or description');
});
it('rejects empty description with no tags', async () => {
const { router } = setupGatedRouter();
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
const res = await router.route(
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { description: ' ' } } },
{ sessionId: 's1' },
);
expect(res.error).toBeDefined();
expect(res.error!.code).toBe(-32602);
});
});
describe('gate config refresh', () => {
it('new sessions pick up gate config change (gated → ungated)', async () => {
const { router } = setupGatedRouter({ gated: true });
router.addUpstream(mockUpstream('ha', { tools: [{ name: 'get_entities' }] }));
// First session is gated
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
let toolsRes = await router.route(
{ jsonrpc: '2.0', id: 2, method: 'tools/list' },
{ sessionId: 's1' },
);
expect((toolsRes.result as { tools: Array<{ name: string }> }).tools[0]!.name).toBe('begin_session');
// Project config changes: gated → ungated
router.setGateConfig({ gated: false, providerRegistry: null });
// New session should be ungated
await router.route({ jsonrpc: '2.0', id: 3, method: 'initialize' }, { sessionId: 's2' });
toolsRes = await router.route(
{ jsonrpc: '2.0', id: 4, method: 'tools/list' },
{ sessionId: 's2' },
);
const names = (toolsRes.result as { tools: Array<{ name: string }> }).tools.map((t) => t.name);
expect(names).toContain('ha/get_entities');
expect(names).not.toContain('begin_session');
});
it('new sessions pick up gate config change (ungated → gated)', async () => {
const { router } = setupGatedRouter({ gated: false });
router.addUpstream(mockUpstream('ha', { tools: [{ name: 'get_entities' }] }));
// First session is ungated
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
let toolsRes = await router.route(
{ jsonrpc: '2.0', id: 2, method: 'tools/list' },
{ sessionId: 's1' },
);
let names = (toolsRes.result as { tools: Array<{ name: string }> }).tools.map((t) => t.name);
expect(names).toContain('ha/get_entities');
// Project config changes: ungated → gated
router.setGateConfig({ gated: true, providerRegistry: null });
// New session should be gated
await router.route({ jsonrpc: '2.0', id: 3, method: 'initialize' }, { sessionId: 's2' });
toolsRes = await router.route(
{ jsonrpc: '2.0', id: 4, method: 'tools/list' },
{ sessionId: 's2' },
);
names = (toolsRes.result as { tools: Array<{ name: string }> }).tools.map((t) => t.name);
expect(names).toHaveLength(1);
expect(names[0]).toBe('begin_session');
});
it('existing sessions retain gate state after config change', async () => {
const { router } = setupGatedRouter({ gated: true });
router.addUpstream(mockUpstream('ha', { tools: [{ name: 'get_entities' }] }));
// Session created while gated
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
// Config changes to ungated
router.setGateConfig({ gated: false, providerRegistry: null });
// Existing session s1 should STILL be gated (session state is immutable after creation)
const toolsRes = await router.route(
{ jsonrpc: '2.0', id: 2, method: 'tools/list' },
{ sessionId: 's1' },
);
expect((toolsRes.result as { tools: Array<{ name: string }> }).tools[0]!.name).toBe('begin_session');
});
it('already-ungated sessions remain ungated after config changes to gated', async () => {
const { router } = setupGatedRouter({ gated: false });
router.addUpstream(mockUpstream('ha', { tools: [{ name: 'get_entities' }] }));
// Session created while ungated
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
// Config changes to gated
router.setGateConfig({ gated: true, providerRegistry: null });
// Existing session s1 should remain ungated
const toolsRes = await router.route(
{ jsonrpc: '2.0', id: 2, method: 'tools/list' },
{ sessionId: 's1' },
);
const names = (toolsRes.result as { tools: Array<{ name: string }> }).tools.map((t) => t.name);
expect(names).toContain('ha/get_entities');
expect(names).not.toContain('begin_session');
});
it('config refresh does not reset sessions that ungated via begin_session', async () => {
const { router } = setupGatedRouter({ gated: true });
router.addUpstream(mockUpstream('ha', { tools: [{ name: 'get_entities' }] }));
// Session starts gated and ungates
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
await router.route(
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee'] } } },
{ sessionId: 's1' },
);
// Config refreshes (still gated)
router.setGateConfig({ gated: true, providerRegistry: null });
// Session should remain ungated — begin_session already completed
const toolsRes = await router.route(
{ jsonrpc: '2.0', id: 3, method: 'tools/list' },
{ sessionId: 's1' },
);
const names = (toolsRes.result as { tools: Array<{ name: string }> }).tools.map((t) => t.name);
expect(names).toContain('ha/get_entities');
expect(names).not.toContain('begin_session');
});
});
describe('response size cap', () => {
it('truncates begin_session response over 24K chars', async () => {
// Create prompts with very large content to exceed 24K
// Use byteBudget large enough so content is included in fullContent
const largePrompts = [
{ name: 'huge-prompt', priority: 10, summary: 'A very large prompt', chapters: null, content: 'x'.repeat(30_000) },
];
const { router } = setupGatedRouter({ prompts: largePrompts, byteBudget: 50_000 });
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
const res = await router.route(
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['huge'] } } },
{ sessionId: 's1' },
);
expect(res.error).toBeUndefined();
const text = (res.result as { content: Array<{ text: string }> }).content[0]!.text;
expect(text.length).toBeLessThanOrEqual(24_000 + 100); // allow for truncation message
expect(text).toContain('[Response truncated');
});
it('does not truncate responses under 24K chars', async () => {
const { router } = setupGatedRouter();
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
const res = await router.route(
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee'] } } },
{ sessionId: 's1' },
);
const text = (res.result as { content: Array<{ text: string }> }).content[0]!.text;
expect(text).not.toContain('[Response truncated');
});
});
});