Files
mcpctl/src/cli/src/api-client.ts
Michal f68e123821
All checks were successful
CI/CD / lint (pull_request) Successful in 1m40s
CI/CD / typecheck (pull_request) Successful in 1m35s
CI/CD / test (pull_request) Successful in 2m16s
CI/CD / build (pull_request) Successful in 2m17s
CI/CD / smoke (pull_request) Successful in 4m37s
CI/CD / publish (pull_request) Has been skipped
fix(cli): https support in status + api-client; add demo-mcp-call.py
- status.ts + api-client.ts now dispatch on URL scheme so an https
  mcpd URL no longer crashes with "Protocol https: not supported".
  Caught by fulldeploy smoke runs — status.ts had `import http` only
  and was synchronously throwing against https://mcpctl.ad.itaz.eu.
  Each http.get call is wrapped so future scheme-mismatch errors also
  degrade to "unreachable" instead of a stack trace.
- .dockerignore no longer excludes src/mcplocal/ (the new
  Dockerfile.mcplocal needs those files).
- scripts/demo-mcp-call.py: standalone, stdlib-only Python demo that
  makes an MCP request (initialize + tools/list, optional tools/call)
  using an mcpctl_pat_ bearer. Counterpart to `mcpctl test mcp` for
  showing external (e.g. vLLM) clients how the bearer flow works.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 22:34:00 +01:00

105 lines
2.9 KiB
TypeScript

import http from 'node:http';
import https from 'node:https';
export interface ApiClientOptions {
baseUrl: string;
timeout?: number | undefined;
token?: string | undefined;
}
export interface ApiResponse<T = unknown> {
status: number;
data: T;
}
export class ApiError extends Error {
constructor(
public readonly status: number,
public readonly body: string,
) {
super(`API error ${status}: ${body}`);
this.name = 'ApiError';
}
}
function request<T>(method: string, url: string, timeout: number, body?: unknown, token?: string): Promise<ApiResponse<T>> {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const headers: Record<string, string> = {};
if (body !== undefined) {
headers['Content-Type'] = 'application/json';
}
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const isHttps = parsed.protocol === 'https:';
const opts: http.RequestOptions = {
hostname: parsed.hostname,
port: parsed.port || (isHttps ? 443 : 80),
path: parsed.pathname + parsed.search,
method,
timeout,
headers,
};
const driver = isHttps ? https : http;
const req = driver.request(opts, (res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf-8');
const status = res.statusCode ?? 0;
if (status >= 400) {
reject(new ApiError(status, raw));
return;
}
try {
resolve({ status, data: JSON.parse(raw) as T });
} catch {
resolve({ status, data: raw as unknown as T });
}
});
});
req.on('error', reject);
req.on('timeout', () => {
req.destroy();
reject(new Error(`Request to ${url} timed out`));
});
if (body !== undefined) {
req.write(JSON.stringify(body));
}
req.end();
});
}
export class ApiClient {
private baseUrl: string;
private timeout: number;
private token?: string | undefined;
constructor(opts: ApiClientOptions) {
this.baseUrl = opts.baseUrl.replace(/\/$/, '');
this.timeout = opts.timeout ?? 10000;
this.token = opts.token;
}
async get<T = unknown>(path: string): Promise<T> {
const res = await request<T>('GET', `${this.baseUrl}${path}`, this.timeout, undefined, this.token);
return res.data;
}
async post<T = unknown>(path: string, body?: unknown): Promise<T> {
const res = await request<T>('POST', `${this.baseUrl}${path}`, this.timeout, body, this.token);
return res.data;
}
async put<T = unknown>(path: string, body?: unknown): Promise<T> {
const res = await request<T>('PUT', `${this.baseUrl}${path}`, this.timeout, body, this.token);
return res.data;
}
async delete(path: string): Promise<void> {
await request('DELETE', `${this.baseUrl}${path}`, this.timeout, undefined, this.token);
}
}