feat: add mcpctl mcp STDIO bridge, rework config claude #35
@@ -2,7 +2,7 @@ _mcpctl() {
|
|||||||
local cur prev words cword
|
local cur prev words cword
|
||||||
_init_completion || return
|
_init_completion || return
|
||||||
|
|
||||||
local commands="status login logout config get describe delete logs create edit apply backup restore help"
|
local commands="status login logout config get describe delete logs create edit apply backup restore mcp help"
|
||||||
local project_commands="attach-server detach-server get describe delete logs create edit help"
|
local project_commands="attach-server detach-server get describe delete logs create edit help"
|
||||||
local global_opts="-v --version --daemon-url --direct --project -h --help"
|
local global_opts="-v --version --daemon-url --direct --project -h --help"
|
||||||
local resources="servers instances secrets templates projects users groups rbac"
|
local resources="servers instances secrets templates projects users groups rbac"
|
||||||
@@ -78,7 +78,7 @@ _mcpctl() {
|
|||||||
case "$subcmd" in
|
case "$subcmd" in
|
||||||
config)
|
config)
|
||||||
if [[ $((cword - subcmd_pos)) -eq 1 ]]; then
|
if [[ $((cword - subcmd_pos)) -eq 1 ]]; then
|
||||||
COMPREPLY=($(compgen -W "view set path reset claude-generate impersonate help" -- "$cur"))
|
COMPREPLY=($(compgen -W "view set path reset claude impersonate help" -- "$cur"))
|
||||||
fi
|
fi
|
||||||
return ;;
|
return ;;
|
||||||
status)
|
status)
|
||||||
@@ -89,6 +89,8 @@ _mcpctl() {
|
|||||||
return ;;
|
return ;;
|
||||||
logout)
|
logout)
|
||||||
return ;;
|
return ;;
|
||||||
|
mcp)
|
||||||
|
return ;;
|
||||||
get|describe|delete)
|
get|describe|delete)
|
||||||
if [[ -z "$resource_type" ]]; then
|
if [[ -z "$resource_type" ]]; then
|
||||||
COMPREPLY=($(compgen -W "$resources" -- "$cur"))
|
COMPREPLY=($(compgen -W "$resources" -- "$cur"))
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# Erase any stale completions from previous versions
|
# Erase any stale completions from previous versions
|
||||||
complete -c mcpctl -e
|
complete -c mcpctl -e
|
||||||
|
|
||||||
set -l commands status login logout config get describe delete logs create edit apply backup restore help
|
set -l commands status login logout config get describe delete logs create edit apply backup restore mcp help
|
||||||
set -l project_commands attach-server detach-server get describe delete logs create edit help
|
set -l project_commands attach-server detach-server get describe delete logs create edit help
|
||||||
|
|
||||||
# Disable file completions by default
|
# Disable file completions by default
|
||||||
@@ -196,12 +196,12 @@ complete -c mcpctl -n "__fish_seen_subcommand_from login" -l email -d 'Email add
|
|||||||
complete -c mcpctl -n "__fish_seen_subcommand_from login" -l password -d 'Password' -x
|
complete -c mcpctl -n "__fish_seen_subcommand_from login" -l password -d 'Password' -x
|
||||||
|
|
||||||
# config subcommands
|
# config subcommands
|
||||||
set -l config_cmds view set path reset claude-generate impersonate
|
set -l config_cmds view set path reset claude claude-generate impersonate
|
||||||
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a view -d 'Show configuration'
|
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a view -d 'Show configuration'
|
||||||
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a set -d 'Set a config value'
|
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a set -d 'Set a config value'
|
||||||
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a path -d 'Show config file path'
|
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a path -d 'Show config file path'
|
||||||
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a reset -d 'Reset to defaults'
|
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a reset -d 'Reset to defaults'
|
||||||
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a claude-generate -d 'Generate .mcp.json'
|
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a claude -d 'Generate .mcp.json for project'
|
||||||
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a impersonate -d 'Impersonate a user'
|
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a impersonate -d 'Impersonate a user'
|
||||||
|
|
||||||
# create subcommands
|
# create subcommands
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type { CredentialsDeps, StoredCredentials } from '../auth/index.js';
|
|||||||
import type { ApiClient } from '../api-client.js';
|
import type { ApiClient } from '../api-client.js';
|
||||||
|
|
||||||
interface McpConfig {
|
interface McpConfig {
|
||||||
mcpServers: Record<string, { command: string; args: string[]; env?: Record<string, string> }>;
|
mcpServers: Record<string, { command?: string; args?: string[]; url?: string; env?: Record<string, string> }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfigCommandDeps {
|
export interface ConfigCommandDeps {
|
||||||
@@ -84,21 +84,27 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
|
|||||||
log('Configuration reset to defaults');
|
log('Configuration reset to defaults');
|
||||||
});
|
});
|
||||||
|
|
||||||
if (apiDeps) {
|
// claude/claude-generate: generate .mcp.json pointing at mcpctl mcp bridge
|
||||||
const { client, credentialsDeps, log: apiLog } = apiDeps;
|
function registerClaudeCommand(name: string, hidden: boolean): void {
|
||||||
|
const cmd = config
|
||||||
config
|
.command(name)
|
||||||
.command('claude-generate')
|
.description(hidden ? '' : 'Generate .mcp.json that connects a project via mcpctl mcp bridge')
|
||||||
.description('Generate .mcp.json from a project configuration')
|
|
||||||
.requiredOption('--project <name>', 'Project name')
|
.requiredOption('--project <name>', 'Project name')
|
||||||
.option('-o, --output <path>', 'Output file path', '.mcp.json')
|
.option('-o, --output <path>', 'Output file path', '.mcp.json')
|
||||||
.option('--merge', 'Merge with existing .mcp.json instead of overwriting')
|
.option('--merge', 'Merge with existing .mcp.json instead of overwriting')
|
||||||
.option('--stdout', 'Print to stdout instead of writing a file')
|
.option('--stdout', 'Print to stdout instead of writing a file')
|
||||||
.action(async (opts: { project: string; output: string; merge?: boolean; stdout?: boolean }) => {
|
.action((opts: { project: string; output: string; merge?: boolean; stdout?: boolean }) => {
|
||||||
const mcpConfig = await client.get<McpConfig>(`/api/v1/projects/${opts.project}/mcp-config`);
|
const mcpConfig: McpConfig = {
|
||||||
|
mcpServers: {
|
||||||
|
[opts.project]: {
|
||||||
|
command: 'mcpctl',
|
||||||
|
args: ['mcp', '-p', opts.project],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
if (opts.stdout) {
|
if (opts.stdout) {
|
||||||
apiLog(JSON.stringify(mcpConfig, null, 2));
|
log(JSON.stringify(mcpConfig, null, 2));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,8 +127,19 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
|
|||||||
|
|
||||||
writeFileSync(outputPath, JSON.stringify(finalConfig, null, 2) + '\n');
|
writeFileSync(outputPath, JSON.stringify(finalConfig, null, 2) + '\n');
|
||||||
const serverCount = Object.keys(finalConfig.mcpServers).length;
|
const serverCount = Object.keys(finalConfig.mcpServers).length;
|
||||||
apiLog(`Wrote ${outputPath} (${serverCount} server(s))`);
|
log(`Wrote ${outputPath} (${serverCount} server(s))`);
|
||||||
});
|
});
|
||||||
|
if (hidden) {
|
||||||
|
// Commander shows empty-description commands but they won't clutter help output
|
||||||
|
void cmd; // suppress unused lint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerClaudeCommand('claude', false);
|
||||||
|
registerClaudeCommand('claude-generate', true); // backward compat
|
||||||
|
|
||||||
|
if (apiDeps) {
|
||||||
|
const { client, credentialsDeps, log: apiLog } = apiDeps;
|
||||||
|
|
||||||
config
|
config
|
||||||
.command('impersonate')
|
.command('impersonate')
|
||||||
|
|||||||
196
src/cli/src/commands/mcp.ts
Normal file
196
src/cli/src/commands/mcp.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { Command } from 'commander';
|
||||||
|
import http from 'node:http';
|
||||||
|
import { createInterface } from 'node:readline';
|
||||||
|
|
||||||
|
export interface McpBridgeOptions {
|
||||||
|
projectName: string;
|
||||||
|
mcplocalUrl: string;
|
||||||
|
token?: string | undefined;
|
||||||
|
stdin: NodeJS.ReadableStream;
|
||||||
|
stdout: NodeJS.WritableStream;
|
||||||
|
stderr: NodeJS.WritableStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
function postJsonRpc(
|
||||||
|
url: string,
|
||||||
|
body: string,
|
||||||
|
sessionId: string | undefined,
|
||||||
|
token: string | undefined,
|
||||||
|
): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
};
|
||||||
|
if (sessionId) {
|
||||||
|
headers['mcp-session-id'] = sessionId;
|
||||||
|
}
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = http.request(
|
||||||
|
{
|
||||||
|
hostname: parsed.hostname,
|
||||||
|
port: parsed.port,
|
||||||
|
path: parsed.pathname,
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
timeout: 30_000,
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
res.on('end', () => {
|
||||||
|
resolve({
|
||||||
|
status: res.statusCode ?? 0,
|
||||||
|
headers: res.headers,
|
||||||
|
body: Buffer.concat(chunks).toString('utf-8'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
req.on('error', reject);
|
||||||
|
req.on('timeout', () => {
|
||||||
|
req.destroy();
|
||||||
|
reject(new Error('Request timed out'));
|
||||||
|
});
|
||||||
|
req.write(body);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendDelete(
|
||||||
|
url: string,
|
||||||
|
sessionId: string,
|
||||||
|
token: string | undefined,
|
||||||
|
): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'mcp-session-id': sessionId,
|
||||||
|
};
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = http.request(
|
||||||
|
{
|
||||||
|
hostname: parsed.hostname,
|
||||||
|
port: parsed.port,
|
||||||
|
path: parsed.pathname,
|
||||||
|
method: 'DELETE',
|
||||||
|
headers,
|
||||||
|
timeout: 5_000,
|
||||||
|
},
|
||||||
|
() => resolve(),
|
||||||
|
);
|
||||||
|
req.on('error', () => resolve()); // Best effort cleanup
|
||||||
|
req.on('timeout', () => {
|
||||||
|
req.destroy();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* STDIO-to-Streamable-HTTP MCP bridge.
|
||||||
|
*
|
||||||
|
* Reads JSON-RPC messages line-by-line from stdin, POSTs them to
|
||||||
|
* mcplocal's project endpoint, and writes responses to stdout.
|
||||||
|
*/
|
||||||
|
export async function runMcpBridge(opts: McpBridgeOptions): Promise<void> {
|
||||||
|
const { projectName, mcplocalUrl, token, stdin, stdout, stderr } = opts;
|
||||||
|
const endpointUrl = `${mcplocalUrl.replace(/\/$/, '')}/projects/${encodeURIComponent(projectName)}/mcp`;
|
||||||
|
|
||||||
|
let sessionId: string | undefined;
|
||||||
|
|
||||||
|
const rl = createInterface({ input: stdin, crlfDelay: Infinity });
|
||||||
|
|
||||||
|
for await (const line of rl) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await postJsonRpc(endpointUrl, trimmed, sessionId, token);
|
||||||
|
|
||||||
|
// Capture session ID from first response
|
||||||
|
if (!sessionId) {
|
||||||
|
const sid = result.headers['mcp-session-id'];
|
||||||
|
if (typeof sid === 'string') {
|
||||||
|
sessionId = sid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status >= 400) {
|
||||||
|
stderr.write(`MCP bridge error: HTTP ${result.status}: ${result.body}\n`);
|
||||||
|
// Still forward the response body — it may contain a JSON-RPC error
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout.write(result.body + '\n');
|
||||||
|
} catch (err) {
|
||||||
|
stderr.write(`MCP bridge error: ${err instanceof Error ? err.message : String(err)}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stdin closed — cleanup session
|
||||||
|
if (sessionId) {
|
||||||
|
await sendDelete(endpointUrl, sessionId, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface McpCommandDeps {
|
||||||
|
getProject: () => string | undefined;
|
||||||
|
configLoader?: () => { mcplocalUrl: string };
|
||||||
|
credentialsLoader?: () => { token: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMcpCommand(deps: McpCommandDeps): Command {
|
||||||
|
const cmd = new Command('mcp')
|
||||||
|
.description('MCP STDIO transport bridge — connects stdin/stdout to a project MCP endpoint')
|
||||||
|
.action(async () => {
|
||||||
|
const projectName = deps.getProject();
|
||||||
|
if (!projectName) {
|
||||||
|
process.stderr.write('Error: --project is required for the mcp command\n');
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mcplocalUrl = 'http://localhost:3200';
|
||||||
|
if (deps.configLoader) {
|
||||||
|
mcplocalUrl = deps.configLoader().mcplocalUrl;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const { loadConfig } = await import('../config/index.js');
|
||||||
|
mcplocalUrl = loadConfig().mcplocalUrl;
|
||||||
|
} catch {
|
||||||
|
// Use default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let token: string | undefined;
|
||||||
|
if (deps.credentialsLoader) {
|
||||||
|
token = deps.credentialsLoader()?.token;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const { loadCredentials } = await import('../auth/index.js');
|
||||||
|
token = loadCredentials()?.token;
|
||||||
|
} catch {
|
||||||
|
// No credentials
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await runMcpBridge({
|
||||||
|
projectName,
|
||||||
|
mcplocalUrl,
|
||||||
|
token,
|
||||||
|
stdin: process.stdin,
|
||||||
|
stdout: process.stdout,
|
||||||
|
stderr: process.stderr,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { createEditCommand } from './commands/edit.js';
|
|||||||
import { createBackupCommand, createRestoreCommand } from './commands/backup.js';
|
import { createBackupCommand, createRestoreCommand } from './commands/backup.js';
|
||||||
import { createLoginCommand, createLogoutCommand } from './commands/auth.js';
|
import { createLoginCommand, createLogoutCommand } from './commands/auth.js';
|
||||||
import { createAttachServerCommand, createDetachServerCommand } from './commands/project-ops.js';
|
import { createAttachServerCommand, createDetachServerCommand } from './commands/project-ops.js';
|
||||||
|
import { createMcpCommand } from './commands/mcp.js';
|
||||||
import { ApiClient, ApiError } from './api-client.js';
|
import { ApiClient, ApiError } from './api-client.js';
|
||||||
import { loadConfig } from './config/index.js';
|
import { loadConfig } from './config/index.js';
|
||||||
import { loadCredentials } from './auth/index.js';
|
import { loadCredentials } from './auth/index.js';
|
||||||
@@ -150,6 +151,9 @@ export function createProgram(): Command {
|
|||||||
};
|
};
|
||||||
program.addCommand(createAttachServerCommand(projectOpsDeps), { hidden: true });
|
program.addCommand(createAttachServerCommand(projectOpsDeps), { hidden: true });
|
||||||
program.addCommand(createDetachServerCommand(projectOpsDeps), { hidden: true });
|
program.addCommand(createDetachServerCommand(projectOpsDeps), { hidden: true });
|
||||||
|
program.addCommand(createMcpCommand({
|
||||||
|
getProject: () => program.opts().project as string | undefined,
|
||||||
|
}), { hidden: true });
|
||||||
|
|
||||||
return program;
|
return program;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,19 +8,14 @@ import { saveCredentials, loadCredentials } from '../../src/auth/index.js';
|
|||||||
|
|
||||||
function mockClient(): ApiClient {
|
function mockClient(): ApiClient {
|
||||||
return {
|
return {
|
||||||
get: vi.fn(async () => ({
|
get: vi.fn(async () => ({})),
|
||||||
mcpServers: {
|
|
||||||
'slack--default': { command: 'npx', args: ['-y', '@anthropic/slack-mcp'], env: { WORKSPACE: 'test' } },
|
|
||||||
'github--default': { command: 'npx', args: ['-y', '@anthropic/github-mcp'] },
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
post: vi.fn(async () => ({ token: 'impersonated-tok', user: { email: 'other@test.com' } })),
|
post: vi.fn(async () => ({ token: 'impersonated-tok', user: { email: 'other@test.com' } })),
|
||||||
put: vi.fn(async () => ({})),
|
put: vi.fn(async () => ({})),
|
||||||
delete: vi.fn(async () => {}),
|
delete: vi.fn(async () => {}),
|
||||||
} as unknown as ApiClient;
|
} as unknown as ApiClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('config claude-generate', () => {
|
describe('config claude', () => {
|
||||||
let client: ReturnType<typeof mockClient>;
|
let client: ReturnType<typeof mockClient>;
|
||||||
let output: string[];
|
let output: string[];
|
||||||
let tmpDir: string;
|
let tmpDir: string;
|
||||||
@@ -36,18 +31,23 @@ describe('config claude-generate', () => {
|
|||||||
rmSync(tmpDir, { recursive: true, force: true });
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('generates .mcp.json from project config', async () => {
|
it('generates .mcp.json with mcpctl mcp bridge entry', async () => {
|
||||||
const outPath = join(tmpDir, '.mcp.json');
|
const outPath = join(tmpDir, '.mcp.json');
|
||||||
const cmd = createConfigCommand(
|
const cmd = createConfigCommand(
|
||||||
{ configDeps: { configDir: tmpDir }, log },
|
{ configDeps: { configDir: tmpDir }, log },
|
||||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||||
);
|
);
|
||||||
await cmd.parseAsync(['claude-generate', '--project', 'proj-1', '-o', outPath], { from: 'user' });
|
await cmd.parseAsync(['claude', '--project', 'homeautomation', '-o', outPath], { from: 'user' });
|
||||||
|
|
||||||
|
// No API call should be made
|
||||||
|
expect(client.get).not.toHaveBeenCalled();
|
||||||
|
|
||||||
expect(client.get).toHaveBeenCalledWith('/api/v1/projects/proj-1/mcp-config');
|
|
||||||
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
|
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
|
||||||
expect(written.mcpServers['slack--default']).toBeDefined();
|
expect(written.mcpServers['homeautomation']).toEqual({
|
||||||
expect(output.join('\n')).toContain('2 server(s)');
|
command: 'mcpctl',
|
||||||
|
args: ['mcp', '-p', 'homeautomation'],
|
||||||
|
});
|
||||||
|
expect(output.join('\n')).toContain('1 server(s)');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prints to stdout with --stdout', async () => {
|
it('prints to stdout with --stdout', async () => {
|
||||||
@@ -55,9 +55,13 @@ describe('config claude-generate', () => {
|
|||||||
{ configDeps: { configDir: tmpDir }, log },
|
{ configDeps: { configDir: tmpDir }, log },
|
||||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||||
);
|
);
|
||||||
await cmd.parseAsync(['claude-generate', '--project', 'proj-1', '--stdout'], { from: 'user' });
|
await cmd.parseAsync(['claude', '--project', 'myproj', '--stdout'], { from: 'user' });
|
||||||
|
|
||||||
expect(output[0]).toContain('mcpServers');
|
const parsed = JSON.parse(output[0]);
|
||||||
|
expect(parsed.mcpServers['myproj']).toEqual({
|
||||||
|
command: 'mcpctl',
|
||||||
|
args: ['mcp', '-p', 'myproj'],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('merges with existing .mcp.json', async () => {
|
it('merges with existing .mcp.json', async () => {
|
||||||
@@ -70,12 +74,41 @@ describe('config claude-generate', () => {
|
|||||||
{ configDeps: { configDir: tmpDir }, log },
|
{ configDeps: { configDir: tmpDir }, log },
|
||||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||||
);
|
);
|
||||||
await cmd.parseAsync(['claude-generate', '--project', 'proj-1', '-o', outPath, '--merge'], { from: 'user' });
|
await cmd.parseAsync(['claude', '--project', 'proj-1', '-o', outPath, '--merge'], { from: 'user' });
|
||||||
|
|
||||||
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
|
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
|
||||||
expect(written.mcpServers['existing--server']).toBeDefined();
|
expect(written.mcpServers['existing--server']).toBeDefined();
|
||||||
expect(written.mcpServers['slack--default']).toBeDefined();
|
expect(written.mcpServers['proj-1']).toEqual({
|
||||||
expect(output.join('\n')).toContain('3 server(s)');
|
command: 'mcpctl',
|
||||||
|
args: ['mcp', '-p', 'proj-1'],
|
||||||
|
});
|
||||||
|
expect(output.join('\n')).toContain('2 server(s)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('backward compat: claude-generate still works', async () => {
|
||||||
|
const outPath = join(tmpDir, '.mcp.json');
|
||||||
|
const cmd = createConfigCommand(
|
||||||
|
{ configDeps: { configDir: tmpDir }, log },
|
||||||
|
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||||
|
);
|
||||||
|
await cmd.parseAsync(['claude-generate', '--project', 'proj-1', '-o', outPath], { from: 'user' });
|
||||||
|
|
||||||
|
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
|
||||||
|
expect(written.mcpServers['proj-1']).toEqual({
|
||||||
|
command: 'mcpctl',
|
||||||
|
args: ['mcp', '-p', 'proj-1'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses project name as the server key', async () => {
|
||||||
|
const outPath = join(tmpDir, '.mcp.json');
|
||||||
|
const cmd = createConfigCommand(
|
||||||
|
{ configDeps: { configDir: tmpDir }, log },
|
||||||
|
);
|
||||||
|
await cmd.parseAsync(['claude', '--project', 'my-fancy-project', '-o', outPath], { from: 'user' });
|
||||||
|
|
||||||
|
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
|
||||||
|
expect(Object.keys(written.mcpServers)).toEqual(['my-fancy-project']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
414
src/cli/tests/commands/mcp.test.ts
Normal file
414
src/cli/tests/commands/mcp.test.ts
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
import http from 'node:http';
|
||||||
|
import { Readable, Writable } from 'node:stream';
|
||||||
|
import { runMcpBridge } from '../../src/commands/mcp.js';
|
||||||
|
|
||||||
|
// ---- Mock MCP server (simulates mcplocal project endpoint) ----
|
||||||
|
|
||||||
|
interface RecordedRequest {
|
||||||
|
method: string;
|
||||||
|
url: string;
|
||||||
|
headers: http.IncomingHttpHeaders;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mockServer: http.Server;
|
||||||
|
let mockPort: number;
|
||||||
|
const recorded: RecordedRequest[] = [];
|
||||||
|
let sessionCounter = 0;
|
||||||
|
|
||||||
|
function makeInitializeResponse(id: number | string) {
|
||||||
|
return JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id,
|
||||||
|
result: {
|
||||||
|
protocolVersion: '2024-11-05',
|
||||||
|
capabilities: { tools: {} },
|
||||||
|
serverInfo: { name: 'test-server', version: '1.0.0' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeToolsListResponse(id: number | string) {
|
||||||
|
return JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id,
|
||||||
|
result: {
|
||||||
|
tools: [
|
||||||
|
{ name: 'grafana/query', description: 'Query Grafana', inputSchema: { type: 'object', properties: {} } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeToolCallResponse(id: number | string) {
|
||||||
|
return JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id,
|
||||||
|
result: {
|
||||||
|
content: [{ type: 'text', text: 'tool result' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
mockServer = http.createServer((req, res) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
req.on('data', (c: Buffer) => chunks.push(c));
|
||||||
|
req.on('end', () => {
|
||||||
|
const body = Buffer.concat(chunks).toString('utf-8');
|
||||||
|
recorded.push({ method: req.method ?? '', url: req.url ?? '', headers: req.headers, body });
|
||||||
|
|
||||||
|
if (req.method === 'DELETE') {
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST' && req.url?.startsWith('/projects/')) {
|
||||||
|
let sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||||
|
|
||||||
|
// Assign session ID on first request
|
||||||
|
if (!sessionId) {
|
||||||
|
sessionCounter++;
|
||||||
|
sessionId = `session-${sessionCounter}`;
|
||||||
|
}
|
||||||
|
res.setHeader('mcp-session-id', sessionId);
|
||||||
|
|
||||||
|
// Parse JSON-RPC and respond based on method
|
||||||
|
try {
|
||||||
|
const rpc = JSON.parse(body) as { id: number | string; method: string };
|
||||||
|
let responseBody: string;
|
||||||
|
|
||||||
|
switch (rpc.method) {
|
||||||
|
case 'initialize':
|
||||||
|
responseBody = makeInitializeResponse(rpc.id);
|
||||||
|
break;
|
||||||
|
case 'tools/list':
|
||||||
|
responseBody = makeToolsListResponse(rpc.id);
|
||||||
|
break;
|
||||||
|
case 'tools/call':
|
||||||
|
responseBody = makeToolCallResponse(rpc.id);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
responseBody = JSON.stringify({ jsonrpc: '2.0', id: rpc.id, error: { code: -32601, message: 'Method not found' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(responseBody);
|
||||||
|
} catch {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
mockServer.listen(0, () => {
|
||||||
|
const addr = mockServer.address();
|
||||||
|
if (addr && typeof addr === 'object') {
|
||||||
|
mockPort = addr.port;
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
mockServer.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Helper to run bridge with mock streams ----
|
||||||
|
|
||||||
|
function createMockStreams() {
|
||||||
|
const stdoutChunks: string[] = [];
|
||||||
|
const stderrChunks: string[] = [];
|
||||||
|
|
||||||
|
const stdout = new Writable({
|
||||||
|
write(chunk: Buffer, _encoding, callback) {
|
||||||
|
stdoutChunks.push(chunk.toString());
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const stderr = new Writable({
|
||||||
|
write(chunk: Buffer, _encoding, callback) {
|
||||||
|
stderrChunks.push(chunk.toString());
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { stdout, stderr, stdoutChunks, stderrChunks };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushAndEnd(stdin: Readable, lines: string[]) {
|
||||||
|
for (const line of lines) {
|
||||||
|
stdin.push(line + '\n');
|
||||||
|
}
|
||||||
|
stdin.push(null); // EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Tests ----
|
||||||
|
|
||||||
|
describe('MCP STDIO Bridge', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
recorded.length = 0;
|
||||||
|
sessionCounter = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards initialize request and returns response', async () => {
|
||||||
|
recorded.length = 0;
|
||||||
|
const stdin = new Readable({ read() {} });
|
||||||
|
const { stdout, stdoutChunks } = createMockStreams();
|
||||||
|
|
||||||
|
const initMsg = JSON.stringify({
|
||||||
|
jsonrpc: '2.0', id: 1, method: 'initialize',
|
||||||
|
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
pushAndEnd(stdin, [initMsg]);
|
||||||
|
|
||||||
|
await runMcpBridge({
|
||||||
|
projectName: 'test-project',
|
||||||
|
mcplocalUrl: `http://localhost:${mockPort}`,
|
||||||
|
stdin, stdout, stderr: new Writable({ write(_, __, cb) { cb(); } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify request was made to correct URL
|
||||||
|
expect(recorded.some((r) => r.url === '/projects/test-project/mcp' && r.method === 'POST')).toBe(true);
|
||||||
|
|
||||||
|
// Verify response on stdout
|
||||||
|
const output = stdoutChunks.join('');
|
||||||
|
const parsed = JSON.parse(output.trim());
|
||||||
|
expect(parsed.result.serverInfo.name).toBe('test-server');
|
||||||
|
expect(parsed.result.protocolVersion).toBe('2024-11-05');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends session ID on subsequent requests', async () => {
|
||||||
|
recorded.length = 0;
|
||||||
|
const stdin = new Readable({ read() {} });
|
||||||
|
const { stdout, stdoutChunks } = createMockStreams();
|
||||||
|
|
||||||
|
const initMsg = JSON.stringify({
|
||||||
|
jsonrpc: '2.0', id: 1, method: 'initialize',
|
||||||
|
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } },
|
||||||
|
});
|
||||||
|
const toolsListMsg = JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
||||||
|
|
||||||
|
pushAndEnd(stdin, [initMsg, toolsListMsg]);
|
||||||
|
|
||||||
|
await runMcpBridge({
|
||||||
|
projectName: 'test-project',
|
||||||
|
mcplocalUrl: `http://localhost:${mockPort}`,
|
||||||
|
stdin, stdout, stderr: new Writable({ write(_, __, cb) { cb(); } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// First POST should NOT have mcp-session-id header
|
||||||
|
const firstPost = recorded.find((r) => r.method === 'POST' && r.body.includes('initialize'));
|
||||||
|
expect(firstPost).toBeDefined();
|
||||||
|
expect(firstPost!.headers['mcp-session-id']).toBeUndefined();
|
||||||
|
|
||||||
|
// Second POST SHOULD have mcp-session-id header
|
||||||
|
const secondPost = recorded.find((r) => r.method === 'POST' && r.body.includes('tools/list'));
|
||||||
|
expect(secondPost).toBeDefined();
|
||||||
|
expect(secondPost!.headers['mcp-session-id']).toMatch(/^session-/);
|
||||||
|
|
||||||
|
// Verify tools/list response
|
||||||
|
const lines = stdoutChunks.join('').trim().split('\n');
|
||||||
|
expect(lines.length).toBe(2);
|
||||||
|
const toolsResponse = JSON.parse(lines[1]);
|
||||||
|
expect(toolsResponse.result.tools[0].name).toBe('grafana/query');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards tools/call and returns result', async () => {
|
||||||
|
recorded.length = 0;
|
||||||
|
const stdin = new Readable({ read() {} });
|
||||||
|
const { stdout, stdoutChunks } = createMockStreams();
|
||||||
|
|
||||||
|
const initMsg = JSON.stringify({
|
||||||
|
jsonrpc: '2.0', id: 1, method: 'initialize',
|
||||||
|
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } },
|
||||||
|
});
|
||||||
|
const callMsg = JSON.stringify({
|
||||||
|
jsonrpc: '2.0', id: 2, method: 'tools/call',
|
||||||
|
params: { name: 'grafana/query', arguments: { query: 'test' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
pushAndEnd(stdin, [initMsg, callMsg]);
|
||||||
|
|
||||||
|
await runMcpBridge({
|
||||||
|
projectName: 'test-project',
|
||||||
|
mcplocalUrl: `http://localhost:${mockPort}`,
|
||||||
|
stdin, stdout, stderr: new Writable({ write(_, __, cb) { cb(); } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const lines = stdoutChunks.join('').trim().split('\n');
|
||||||
|
expect(lines.length).toBe(2);
|
||||||
|
const callResponse = JSON.parse(lines[1]);
|
||||||
|
expect(callResponse.result.content[0].text).toBe('tool result');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards Authorization header when token provided', async () => {
|
||||||
|
recorded.length = 0;
|
||||||
|
const stdin = new Readable({ read() {} });
|
||||||
|
const { stdout } = createMockStreams();
|
||||||
|
|
||||||
|
const initMsg = JSON.stringify({
|
||||||
|
jsonrpc: '2.0', id: 1, method: 'initialize',
|
||||||
|
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
pushAndEnd(stdin, [initMsg]);
|
||||||
|
|
||||||
|
await runMcpBridge({
|
||||||
|
projectName: 'test-project',
|
||||||
|
mcplocalUrl: `http://localhost:${mockPort}`,
|
||||||
|
token: 'my-secret-token',
|
||||||
|
stdin, stdout, stderr: new Writable({ write(_, __, cb) { cb(); } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const post = recorded.find((r) => r.method === 'POST');
|
||||||
|
expect(post).toBeDefined();
|
||||||
|
expect(post!.headers['authorization']).toBe('Bearer my-secret-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not send Authorization header when no token', async () => {
|
||||||
|
recorded.length = 0;
|
||||||
|
const stdin = new Readable({ read() {} });
|
||||||
|
const { stdout } = createMockStreams();
|
||||||
|
|
||||||
|
const initMsg = JSON.stringify({
|
||||||
|
jsonrpc: '2.0', id: 1, method: 'initialize',
|
||||||
|
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
pushAndEnd(stdin, [initMsg]);
|
||||||
|
|
||||||
|
await runMcpBridge({
|
||||||
|
projectName: 'test-project',
|
||||||
|
mcplocalUrl: `http://localhost:${mockPort}`,
|
||||||
|
stdin, stdout, stderr: new Writable({ write(_, __, cb) { cb(); } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const post = recorded.find((r) => r.method === 'POST');
|
||||||
|
expect(post).toBeDefined();
|
||||||
|
expect(post!.headers['authorization']).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends DELETE to clean up session on stdin EOF', async () => {
|
||||||
|
recorded.length = 0;
|
||||||
|
const stdin = new Readable({ read() {} });
|
||||||
|
const { stdout } = createMockStreams();
|
||||||
|
|
||||||
|
const initMsg = JSON.stringify({
|
||||||
|
jsonrpc: '2.0', id: 1, method: 'initialize',
|
||||||
|
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
pushAndEnd(stdin, [initMsg]);
|
||||||
|
|
||||||
|
await runMcpBridge({
|
||||||
|
projectName: 'test-project',
|
||||||
|
mcplocalUrl: `http://localhost:${mockPort}`,
|
||||||
|
stdin, stdout, stderr: new Writable({ write(_, __, cb) { cb(); } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should have a DELETE request for session cleanup
|
||||||
|
const deleteReq = recorded.find((r) => r.method === 'DELETE');
|
||||||
|
expect(deleteReq).toBeDefined();
|
||||||
|
expect(deleteReq!.headers['mcp-session-id']).toMatch(/^session-/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not send DELETE if no session was established', async () => {
|
||||||
|
recorded.length = 0;
|
||||||
|
const stdin = new Readable({ read() {} });
|
||||||
|
const { stdout } = createMockStreams();
|
||||||
|
|
||||||
|
// Push EOF immediately with no messages
|
||||||
|
stdin.push(null);
|
||||||
|
|
||||||
|
await runMcpBridge({
|
||||||
|
projectName: 'test-project',
|
||||||
|
mcplocalUrl: `http://localhost:${mockPort}`,
|
||||||
|
stdin, stdout, stderr: new Writable({ write(_, __, cb) { cb(); } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recorded.filter((r) => r.method === 'DELETE')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes errors to stderr, not stdout', async () => {
|
||||||
|
recorded.length = 0;
|
||||||
|
const stdin = new Readable({ read() {} });
|
||||||
|
const { stdout, stdoutChunks, stderr, stderrChunks } = createMockStreams();
|
||||||
|
|
||||||
|
// Send to a non-existent port to trigger connection error
|
||||||
|
const badMsg = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
|
||||||
|
pushAndEnd(stdin, [badMsg]);
|
||||||
|
|
||||||
|
await runMcpBridge({
|
||||||
|
projectName: 'test-project',
|
||||||
|
mcplocalUrl: 'http://localhost:1', // will fail to connect
|
||||||
|
stdin, stdout, stderr,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error should be on stderr
|
||||||
|
expect(stderrChunks.join('')).toContain('MCP bridge error');
|
||||||
|
// stdout should be empty (no corrupted output)
|
||||||
|
expect(stdoutChunks.join('')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips blank lines in stdin', async () => {
|
||||||
|
recorded.length = 0;
|
||||||
|
const stdin = new Readable({ read() {} });
|
||||||
|
const { stdout, stdoutChunks } = createMockStreams();
|
||||||
|
|
||||||
|
const initMsg = JSON.stringify({
|
||||||
|
jsonrpc: '2.0', id: 1, method: 'initialize',
|
||||||
|
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
pushAndEnd(stdin, ['', ' ', initMsg, '']);
|
||||||
|
|
||||||
|
await runMcpBridge({
|
||||||
|
projectName: 'test-project',
|
||||||
|
mcplocalUrl: `http://localhost:${mockPort}`,
|
||||||
|
stdin, stdout, stderr: new Writable({ write(_, __, cb) { cb(); } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only one POST (for the actual message)
|
||||||
|
const posts = recorded.filter((r) => r.method === 'POST');
|
||||||
|
expect(posts).toHaveLength(1);
|
||||||
|
|
||||||
|
// One response line
|
||||||
|
const lines = stdoutChunks.join('').trim().split('\n');
|
||||||
|
expect(lines).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('URL-encodes project name', async () => {
|
||||||
|
recorded.length = 0;
|
||||||
|
const stdin = new Readable({ read() {} });
|
||||||
|
const { stdout } = createMockStreams();
|
||||||
|
const { stderr } = createMockStreams();
|
||||||
|
|
||||||
|
const initMsg = JSON.stringify({
|
||||||
|
jsonrpc: '2.0', id: 1, method: 'initialize',
|
||||||
|
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
pushAndEnd(stdin, [initMsg]);
|
||||||
|
|
||||||
|
await runMcpBridge({
|
||||||
|
projectName: 'my project',
|
||||||
|
mcplocalUrl: `http://localhost:${mockPort}`,
|
||||||
|
stdin, stdout, stderr,
|
||||||
|
});
|
||||||
|
|
||||||
|
const post = recorded.find((r) => r.method === 'POST');
|
||||||
|
expect(post?.url).toBe('/projects/my%20project/mcp');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,7 +35,7 @@ export async function refreshProjectUpstreams(
|
|||||||
let servers: McpdServer[];
|
let servers: McpdServer[];
|
||||||
if (authToken) {
|
if (authToken) {
|
||||||
// Forward the client's auth token to mcpd so RBAC applies
|
// Forward the client's auth token to mcpd so RBAC applies
|
||||||
const result = await mcpdClient.forward('GET', path, '', undefined);
|
const result = await mcpdClient.forward('GET', path, '', undefined, authToken);
|
||||||
if (result.status >= 400) {
|
if (result.status >= 400) {
|
||||||
throw new Error(`Failed to fetch project servers: ${result.status}`);
|
throw new Error(`Failed to fetch project servers: ${result.status}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ describe('refreshProjectUpstreams', () => {
|
|||||||
const client = mockMcpdClient(servers);
|
const client = mockMcpdClient(servers);
|
||||||
|
|
||||||
await refreshProjectUpstreams(router, client as any, 'smart-home', 'user-token-123');
|
await refreshProjectUpstreams(router, client as any, 'smart-home', 'user-token-123');
|
||||||
expect(client.forward).toHaveBeenCalledWith('GET', '/api/v1/projects/smart-home/servers', '', undefined);
|
expect(client.forward).toHaveBeenCalledWith('GET', '/api/v1/projects/smart-home/servers', '', undefined, 'user-token-123');
|
||||||
expect(router.getUpstreamNames()).toContain('grafana');
|
expect(router.getUpstreamNames()).toContain('grafana');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user