fix(cli): https support in status + api-client; add demo-mcp-call.py
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

- 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>
This commit is contained in:
Michal
2026-04-17 22:34:00 +01:00
parent 2127b41d9f
commit f68e123821
4 changed files with 248 additions and 47 deletions

View File

@@ -12,4 +12,3 @@ dist
.env.* .env.*
deploy/docker-compose.yml deploy/docker-compose.yml
src/cli src/cli
src/mcplocal

169
scripts/demo-mcp-call.py Executable file
View File

@@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""
Demo: make an MCP request against mcplocal using an McpToken bearer.
This is the standalone counterpart to `mcpctl test mcp` — intended to show
exactly what a non-Claude client (e.g. a vLLM-driven agent) would do.
Usage:
# Default: localhost mcplocal, sre project, token from $MCPCTL_TOKEN
export MCPCTL_TOKEN=mcpctl_pat_...
python3 scripts/demo-mcp-call.py
# Custom URL/project/tool
python3 scripts/demo-mcp-call.py \\
--url https://mcp.ad.itaz.eu \\
--project sre \\
--token "$MCPCTL_TOKEN" \\
--tool begin_session \\
--args '{"description":"hello"}'
No third-party deps — pure stdlib. Mirrors the protocol that
src/shared/src/mcp-http/index.ts implements on the TypeScript side.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import urllib.error
import urllib.request
from typing import Any
def _parse_sse(body: str) -> list[dict[str, Any]]:
"""Parse a text/event-stream body into a list of JSON-RPC messages."""
out: list[dict[str, Any]] = []
for line in body.splitlines():
if line.startswith("data: "):
try:
out.append(json.loads(line[6:]))
except json.JSONDecodeError:
pass
return out
class McpSession:
def __init__(self, url: str, bearer: str | None = None, timeout: float = 30.0):
self.url = url
self.bearer = bearer
self.timeout = timeout
self.session_id: str | None = None
self._next_id = 1
def _headers(self) -> dict[str, str]:
h = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
}
if self.bearer:
h["Authorization"] = f"Bearer {self.bearer}"
if self.session_id:
h["mcp-session-id"] = self.session_id
return h
def send(self, method: str, params: dict[str, Any] | None = None) -> Any:
rid = self._next_id
self._next_id += 1
payload = {"jsonrpc": "2.0", "id": rid, "method": method, "params": params or {}}
req = urllib.request.Request(
self.url,
data=json.dumps(payload).encode("utf-8"),
headers=self._headers(),
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
body = resp.read().decode("utf-8")
content_type = resp.headers.get("content-type", "")
# First successful response carries the session id.
if self.session_id is None:
sid = resp.headers.get("mcp-session-id")
if sid:
self.session_id = sid
messages: list[dict[str, Any]] = (
_parse_sse(body) if "text/event-stream" in content_type else [json.loads(body)]
)
except urllib.error.HTTPError as e:
err_body = e.read().decode("utf-8", errors="replace")
raise SystemExit(f"HTTP {e.code} from {self.url}: {err_body}") from None
except urllib.error.URLError as e:
raise SystemExit(f"transport error reaching {self.url}: {e.reason}") from None
# Pick the response matching our id; fall back to first message.
matched = next((m for m in messages if m.get("id") == rid), messages[0] if messages else None)
if matched is None:
raise SystemExit(f"no response for {method}")
if "error" in matched:
err = matched["error"]
raise SystemExit(f"MCP error {err.get('code')}: {err.get('message')}")
return matched.get("result")
def initialize(self) -> dict[str, Any]:
return self.send(
"initialize",
{
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "demo-mcp-call.py", "version": "1.0.0"},
},
)
def list_tools(self) -> list[dict[str, Any]]:
result = self.send("tools/list")
return result.get("tools", []) if isinstance(result, dict) else []
def call_tool(self, name: str, args: dict[str, Any]) -> Any:
return self.send("tools/call", {"name": name, "arguments": args})
def main() -> int:
ap = argparse.ArgumentParser(description="Demo MCP request via McpToken bearer.")
ap.add_argument("--url", default=os.environ.get("MCPGW_URL", "http://localhost:3200"),
help="Base URL of mcplocal (default: $MCPGW_URL or http://localhost:3200)")
ap.add_argument("--project", default="sre",
help="Project name (default: sre). Must match the token's bound project.")
ap.add_argument("--token", default=os.environ.get("MCPCTL_TOKEN"),
help="Raw mcpctl_pat_* bearer (default: $MCPCTL_TOKEN)")
ap.add_argument("--tool", help="Optionally call a tool after tools/list")
ap.add_argument("--args", default="{}", help="JSON-encoded arguments for --tool")
ap.add_argument("--timeout", type=float, default=30.0)
opts = ap.parse_args()
if not opts.token:
ap.error("--token or $MCPCTL_TOKEN required")
endpoint = f"{opts.url.rstrip('/')}/projects/{opts.project}/mcp"
print(f"→ POST {endpoint}")
print(f" Bearer: {opts.token[:16]}")
print()
sess = McpSession(endpoint, bearer=opts.token, timeout=opts.timeout)
info = sess.initialize()
server_info = info.get("serverInfo", {}) if isinstance(info, dict) else {}
print(f"initialize: protocol={info.get('protocolVersion') if isinstance(info, dict) else '?'} "
f"server={server_info.get('name', '?')}/{server_info.get('version', '?')} "
f"sessionId={sess.session_id}")
tools = sess.list_tools()
print(f"tools/list: {len(tools)} tool(s)")
for t in tools:
desc = (t.get("description") or "").splitlines()[0][:80]
print(f" - {t['name']} {desc}")
if opts.tool:
try:
args = json.loads(opts.args)
except json.JSONDecodeError as e:
raise SystemExit(f"--args must be valid JSON: {e}")
print(f"\ntools/call: {opts.tool} {args}")
result = sess.call_tool(opts.tool, args)
print(json.dumps(result, indent=2)[:2000])
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,4 +1,5 @@
import http from 'node:http'; import http from 'node:http';
import https from 'node:https';
export interface ApiClientOptions { export interface ApiClientOptions {
baseUrl: string; baseUrl: string;
@@ -31,16 +32,18 @@ function request<T>(method: string, url: string, timeout: number, body?: unknown
if (token) { if (token) {
headers['Authorization'] = `Bearer ${token}`; headers['Authorization'] = `Bearer ${token}`;
} }
const isHttps = parsed.protocol === 'https:';
const opts: http.RequestOptions = { const opts: http.RequestOptions = {
hostname: parsed.hostname, hostname: parsed.hostname,
port: parsed.port, port: parsed.port || (isHttps ? 443 : 80),
path: parsed.pathname + parsed.search, path: parsed.pathname + parsed.search,
method, method,
timeout, timeout,
headers, headers,
}; };
const req = http.request(opts, (res) => { const driver = isHttps ? https : http;
const req = driver.request(opts, (res) => {
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk)); res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => { res.on('end', () => {

View File

@@ -1,5 +1,11 @@
import { Command } from 'commander'; import { Command } from 'commander';
import http from 'node:http'; import http from 'node:http';
import https from 'node:https';
/** Pick the http or https driver based on the URL scheme. */
function httpDriverFor(url: string): typeof http | typeof https {
return new URL(url).protocol === 'https:' ? https : http;
}
import { loadConfig } from '../config/index.js'; import { loadConfig } from '../config/index.js';
import type { ConfigLoaderDeps } from '../config/index.js'; import type { ConfigLoaderDeps } from '../config/index.js';
import { loadCredentials } from '../auth/index.js'; import { loadCredentials } from '../auth/index.js';
@@ -45,10 +51,16 @@ export interface StatusCommandDeps {
function defaultCheckHealth(url: string): Promise<boolean> { function defaultCheckHealth(url: string): Promise<boolean> {
return new Promise((resolve) => { return new Promise((resolve) => {
const req = http.get(`${url}/health`, { timeout: 3000 }, (res) => { let req: http.ClientRequest;
resolve(res.statusCode !== undefined && res.statusCode >= 200 && res.statusCode < 400); try {
res.resume(); req = httpDriverFor(url).get(`${url}/health`, { timeout: 3000 }, (res) => {
}); resolve(res.statusCode !== undefined && res.statusCode >= 200 && res.statusCode < 400);
res.resume();
});
} catch {
resolve(false);
return;
}
req.on('error', () => resolve(false)); req.on('error', () => resolve(false));
req.on('timeout', () => { req.on('timeout', () => {
req.destroy(); req.destroy();
@@ -63,26 +75,32 @@ function defaultCheckHealth(url: string): Promise<boolean> {
*/ */
function defaultCheckLlm(mcplocalUrl: string): Promise<string> { function defaultCheckLlm(mcplocalUrl: string): Promise<string> {
return new Promise((resolve) => { return new Promise((resolve) => {
const req = http.get(`${mcplocalUrl}/llm/health`, { timeout: 45000 }, (res) => { let req: http.ClientRequest;
const chunks: Buffer[] = []; try {
res.on('data', (chunk: Buffer) => chunks.push(chunk)); req = httpDriverFor(mcplocalUrl).get(`${mcplocalUrl}/llm/health`, { timeout: 45000 }, (res) => {
res.on('end', () => { const chunks: Buffer[] = [];
try { res.on('data', (chunk: Buffer) => chunks.push(chunk));
const body = JSON.parse(Buffer.concat(chunks).toString('utf-8')) as { status: string; error?: string }; res.on('end', () => {
if (body.status === 'ok') { try {
resolve('ok'); const body = JSON.parse(Buffer.concat(chunks).toString('utf-8')) as { status: string; error?: string };
} else if (body.status === 'not configured') { if (body.status === 'ok') {
resolve('not configured'); resolve('ok');
} else if (body.error) { } else if (body.status === 'not configured') {
resolve(body.error.slice(0, 80)); resolve('not configured');
} else { } else if (body.error) {
resolve(body.status); resolve(body.error.slice(0, 80));
} else {
resolve(body.status);
}
} catch {
resolve('invalid response');
} }
} catch { });
resolve('invalid response');
}
}); });
}); } catch {
resolve('mcplocal unreachable');
return;
}
req.on('error', () => resolve('mcplocal unreachable')); req.on('error', () => resolve('mcplocal unreachable'));
req.on('timeout', () => { req.destroy(); resolve('timeout'); }); req.on('timeout', () => { req.destroy(); resolve('timeout'); });
}); });
@@ -90,18 +108,24 @@ function defaultCheckLlm(mcplocalUrl: string): Promise<string> {
function defaultFetchModels(mcplocalUrl: string): Promise<string[]> { function defaultFetchModels(mcplocalUrl: string): Promise<string[]> {
return new Promise((resolve) => { return new Promise((resolve) => {
const req = http.get(`${mcplocalUrl}/llm/models`, { timeout: 5000 }, (res) => { let req: http.ClientRequest;
const chunks: Buffer[] = []; try {
res.on('data', (chunk: Buffer) => chunks.push(chunk)); req = httpDriverFor(mcplocalUrl).get(`${mcplocalUrl}/llm/models`, { timeout: 5000 }, (res) => {
res.on('end', () => { const chunks: Buffer[] = [];
try { res.on('data', (chunk: Buffer) => chunks.push(chunk));
const body = JSON.parse(Buffer.concat(chunks).toString('utf-8')) as { models?: string[] }; res.on('end', () => {
resolve(body.models ?? []); try {
} catch { const body = JSON.parse(Buffer.concat(chunks).toString('utf-8')) as { models?: string[] };
resolve([]); resolve(body.models ?? []);
} } catch {
resolve([]);
}
});
}); });
}); } catch {
resolve([]);
return;
}
req.on('error', () => resolve([])); req.on('error', () => resolve([]));
req.on('timeout', () => { req.destroy(); resolve([]); }); req.on('timeout', () => { req.destroy(); resolve([]); });
}); });
@@ -109,18 +133,24 @@ function defaultFetchModels(mcplocalUrl: string): Promise<string[]> {
function defaultFetchProviders(mcplocalUrl: string): Promise<ProvidersInfo | null> { function defaultFetchProviders(mcplocalUrl: string): Promise<ProvidersInfo | null> {
return new Promise((resolve) => { return new Promise((resolve) => {
const req = http.get(`${mcplocalUrl}/llm/providers`, { timeout: 5000 }, (res) => { let req: http.ClientRequest;
const chunks: Buffer[] = []; try {
res.on('data', (chunk: Buffer) => chunks.push(chunk)); req = httpDriverFor(mcplocalUrl).get(`${mcplocalUrl}/llm/providers`, { timeout: 5000 }, (res) => {
res.on('end', () => { const chunks: Buffer[] = [];
try { res.on('data', (chunk: Buffer) => chunks.push(chunk));
const body = JSON.parse(Buffer.concat(chunks).toString('utf-8')) as ProvidersInfo; res.on('end', () => {
resolve(body); try {
} catch { const body = JSON.parse(Buffer.concat(chunks).toString('utf-8')) as ProvidersInfo;
resolve(null); resolve(body);
} } catch {
resolve(null);
}
});
}); });
}); } catch {
resolve(null);
return;
}
req.on('error', () => resolve(null)); req.on('error', () => resolve(null));
req.on('timeout', () => { req.destroy(); resolve(null); }); req.on('timeout', () => { req.destroy(); resolve(null); });
}); });