feat: HTTP-mode mcplocal container + mcpctl test mcp + token-auth preHandler
Delivers the final piece of the mcptoken stack: a containerized,
network-accessible mcplocal that serves Streamable-HTTP MCP to off-host
clients (the vLLM use case), authenticated by project-scoped McpTokens.
New binary (same package, new entry):
- src/mcplocal/src/serve.ts — HTTP-only entry. Reads MCPLOCAL_MCPD_URL,
MCPLOCAL_MCPD_TOKEN, MCPLOCAL_HTTP_HOST/PORT, MCPLOCAL_CACHE_DIR from
env. No StdioProxyServer, no --upstream.
- src/mcplocal/src/http/token-auth.ts — Fastify preHandler that
validates mcpctl_pat_ bearers via mcpd's /api/v1/mcptokens/introspect.
30s positive / 5s negative TTL. Rejects wrong-project with 403.
Shared HTTP MCP client:
- src/shared/src/mcp-http/ — reusable McpHttpSession with initialize,
listTools, callTool, close. Handles http+https, SSE, id correlation,
distinct McpProtocolError / McpTransportError. Plus mcpHealthCheck
and deriveBaseUrl helpers.
New CLI verb `mcpctl test mcp <url>`:
- Flags: --token (also $MCPCTL_TOKEN), --tool, --args (JSON),
--expect-tools, --timeout, -o text|json, --no-health.
- Exit codes: 0 PASS, 1 TRANSPORT/AUTH FAIL, 2 CONTRACT FAIL.
Container + deploy:
- deploy/Dockerfile.mcplocal (Node 20 alpine, multi-stage, pnpm
workspace, CMD node src/mcplocal/dist/serve.js, VOLUME
/var/lib/mcplocal/cache, HEALTHCHECK on :3200/healthz).
- scripts/build-mcplocal.sh mirrors build-mcpd.sh.
- fulldeploy.sh is now a 4-step pipeline that also builds + rolls out
mcplocal (gated on `kubectl get deployment/mcplocal` so the script
stays green before the Pulumi stack lands).
Audit + cache:
- project-mcp-endpoint.ts passes MCPLOCAL_CACHE_DIR into FileCache at
both construction sites and, when request.mcpToken is present, calls
collector.setSessionMcpToken(id, ...) so audit events carry the
tokenName/tokenSha.
Tests:
- 9 unit cases on `mcpctl test mcp` (happy path, health miss,
expect-tools hit/miss, transport throw, tool isError, json report,
$MCPCTL_TOKEN env fallback, invalid --args).
- Smoke test src/mcplocal/tests/smoke/mcptoken.smoke.test.ts —
gated on healthz($MCPGW_URL), skipped cleanly when unreachable.
Covers happy path, wrong-project 403, --expect-tools contract
failure, and revocation 401 within the negative-cache window.
1773/1773 workspace tests pass. Pulumi resources (Deployment, Service,
Ingress, PVC, Secret, NetworkPolicy) still need to land in
../kubernetes-deployment before the smoke gate flips on.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
176
src/cli/src/commands/test-mcp.ts
Normal file
176
src/cli/src/commands/test-mcp.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { Command } from 'commander';
|
||||
import { McpHttpSession, McpProtocolError, McpTransportError, deriveBaseUrl, mcpHealthCheck } from '@mcpctl/shared';
|
||||
|
||||
export interface TestMcpCommandDeps {
|
||||
log: (...args: unknown[]) => void;
|
||||
/**
|
||||
* Inject a session factory for testing. The default creates a real `McpHttpSession`.
|
||||
*/
|
||||
createSession?: (url: string, opts: { bearer?: string; timeoutMs?: number }) => {
|
||||
initialize(): Promise<unknown>;
|
||||
listTools(): Promise<Array<{ name: string }>>;
|
||||
callTool(name: string, args: Record<string, unknown>): Promise<unknown>;
|
||||
close(): Promise<void>;
|
||||
};
|
||||
healthCheck?: (baseUrl: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export type TestMcpExitCode = 0 | 1 | 2;
|
||||
|
||||
export interface TestMcpReport {
|
||||
url: string;
|
||||
health: 'ok' | 'fail' | 'skipped';
|
||||
initialize: 'ok' | 'fail';
|
||||
tools: string[] | null;
|
||||
toolCall?: { name: string; result: unknown; isError?: boolean };
|
||||
missingTools?: string[];
|
||||
exitCode: TestMcpExitCode;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function createTestCommand(deps: TestMcpCommandDeps): Command {
|
||||
const { log } = deps;
|
||||
const createSession = deps.createSession ?? ((url, opts) => new McpHttpSession(url, opts));
|
||||
const healthCheck = deps.healthCheck ?? mcpHealthCheck;
|
||||
|
||||
const test = new Command('test').description('Utilities for testing MCP endpoints and config');
|
||||
|
||||
test
|
||||
.command('mcp')
|
||||
.description('Verify a Streamable-HTTP MCP endpoint: health, initialize, tools/list, optionally call a tool.')
|
||||
.argument('<url>', 'Full URL of the MCP endpoint (e.g. https://mcp.example.com/projects/foo/mcp)')
|
||||
.option('--token <bearer>', 'Bearer token (also reads $MCPCTL_TOKEN)')
|
||||
.option('--tool <name>', 'Invoke a specific tool after listing')
|
||||
.option('--args <json>', 'JSON-encoded arguments for --tool', '{}')
|
||||
.option('--expect-tools <list>', 'Comma-separated tool names that MUST appear; fails otherwise')
|
||||
.option('--timeout <seconds>', 'Per-request timeout in seconds', '10')
|
||||
.option('-o, --output <format>', 'Output format: text or json', 'text')
|
||||
.option('--no-health', 'Skip the /healthz preflight check')
|
||||
.action(async (url: string, opts: {
|
||||
token?: string;
|
||||
tool?: string;
|
||||
args: string;
|
||||
expectTools?: string;
|
||||
timeout: string;
|
||||
output: string;
|
||||
health: boolean;
|
||||
}) => {
|
||||
const bearer = opts.token ?? process.env.MCPCTL_TOKEN;
|
||||
const timeoutMs = Number(opts.timeout) * 1000;
|
||||
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
||||
throw new Error(`--timeout must be a positive number of seconds (got '${opts.timeout}')`);
|
||||
}
|
||||
|
||||
const report: TestMcpReport = {
|
||||
url,
|
||||
health: 'skipped',
|
||||
initialize: 'fail',
|
||||
tools: null,
|
||||
exitCode: 1,
|
||||
};
|
||||
|
||||
// 1. Health preflight
|
||||
if (opts.health !== false) {
|
||||
const baseUrl = deriveBaseUrl(url);
|
||||
const ok = await healthCheck(baseUrl);
|
||||
report.health = ok ? 'ok' : 'fail';
|
||||
if (!ok) {
|
||||
report.error = `healthz preflight failed at ${baseUrl}/healthz`;
|
||||
return emit(report, opts.output, log);
|
||||
}
|
||||
}
|
||||
|
||||
const sessionOpts: { bearer?: string; timeoutMs: number } = { timeoutMs };
|
||||
if (bearer !== undefined) sessionOpts.bearer = bearer;
|
||||
const session = createSession(url, sessionOpts);
|
||||
|
||||
try {
|
||||
// 2. Initialize
|
||||
await session.initialize();
|
||||
report.initialize = 'ok';
|
||||
|
||||
// 3. tools/list
|
||||
const tools = await session.listTools();
|
||||
report.tools = tools.map((t) => t.name);
|
||||
|
||||
// 4. --expect-tools check
|
||||
if (opts.expectTools !== undefined && opts.expectTools.trim() !== '') {
|
||||
const expected = opts.expectTools.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const missing = expected.filter((name) => !report.tools!.includes(name));
|
||||
if (missing.length > 0) {
|
||||
report.missingTools = missing;
|
||||
report.exitCode = 2;
|
||||
report.error = `Missing tools: ${missing.join(', ')}`;
|
||||
return emit(report, opts.output, log);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Optional --tool call
|
||||
if (opts.tool !== undefined) {
|
||||
let parsedArgs: Record<string, unknown> = {};
|
||||
try {
|
||||
parsedArgs = JSON.parse(opts.args) as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new Error(`--args must be valid JSON (got '${opts.args}')`);
|
||||
}
|
||||
const result = await session.callTool(opts.tool, parsedArgs);
|
||||
const toolCall: TestMcpReport['toolCall'] = { name: opts.tool, result };
|
||||
if (typeof result === 'object' && result !== null && 'isError' in result) {
|
||||
toolCall.isError = Boolean((result as { isError?: boolean }).isError);
|
||||
}
|
||||
report.toolCall = toolCall;
|
||||
if (toolCall.isError) {
|
||||
report.exitCode = 2;
|
||||
report.error = `Tool '${opts.tool}' returned isError=true`;
|
||||
return emit(report, opts.output, log);
|
||||
}
|
||||
}
|
||||
|
||||
report.exitCode = 0;
|
||||
} catch (err) {
|
||||
if (err instanceof McpProtocolError) {
|
||||
report.exitCode = 1;
|
||||
report.error = `protocol error ${err.code}: ${err.message}`;
|
||||
} else if (err instanceof McpTransportError) {
|
||||
report.exitCode = 1;
|
||||
report.error = `transport error (HTTP ${err.status}): ${err.message}`;
|
||||
} else {
|
||||
report.exitCode = 1;
|
||||
report.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
} finally {
|
||||
await session.close().catch(() => { /* best-effort */ });
|
||||
}
|
||||
|
||||
return emit(report, opts.output, log);
|
||||
});
|
||||
|
||||
return test;
|
||||
}
|
||||
|
||||
function emit(report: TestMcpReport, output: string, log: (...args: unknown[]) => void): void {
|
||||
if (output === 'json') {
|
||||
log(JSON.stringify(report, null, 2));
|
||||
} else {
|
||||
log(`URL: ${report.url}`);
|
||||
log(`Health: ${report.health}`);
|
||||
log(`Initialize: ${report.initialize}`);
|
||||
if (report.tools !== null) {
|
||||
log(`Tools (${report.tools.length}): ${report.tools.slice(0, 10).join(', ')}${report.tools.length > 10 ? `, …(+${report.tools.length - 10})` : ''}`);
|
||||
}
|
||||
if (report.missingTools !== undefined) {
|
||||
log(`Missing: ${report.missingTools.join(', ')}`);
|
||||
}
|
||||
if (report.toolCall !== undefined) {
|
||||
log(`Tool call: ${report.toolCall.name} → ${report.toolCall.isError ? 'ERROR' : 'ok'}`);
|
||||
}
|
||||
if (report.error !== undefined) {
|
||||
log(`Error: ${report.error}`);
|
||||
}
|
||||
log(`Result: ${report.exitCode === 0 ? 'PASS' : report.exitCode === 2 ? 'CONTRACT FAIL' : 'TRANSPORT/AUTH FAIL'}`);
|
||||
}
|
||||
|
||||
if (report.exitCode !== 0) {
|
||||
process.exitCode = report.exitCode;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { createDescribeCommand } from './commands/describe.js';
|
||||
import { createDeleteCommand } from './commands/delete.js';
|
||||
import { createLogsCommand } from './commands/logs.js';
|
||||
import { createApplyCommand } from './commands/apply.js';
|
||||
import { createTestCommand } from './commands/test-mcp.js';
|
||||
import { createCreateCommand } from './commands/create.js';
|
||||
import { createEditCommand } from './commands/edit.js';
|
||||
import { createBackupCommand } from './commands/backup.js';
|
||||
@@ -244,6 +245,10 @@ export function createProgram(): Command {
|
||||
mcplocalUrl: config.mcplocalUrl,
|
||||
}));
|
||||
|
||||
program.addCommand(createTestCommand({
|
||||
log: (...args) => console.log(...args),
|
||||
}));
|
||||
|
||||
return program;
|
||||
}
|
||||
|
||||
|
||||
168
src/cli/tests/commands/test-mcp.test.ts
Normal file
168
src/cli/tests/commands/test-mcp.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { createTestCommand } from '../../src/commands/test-mcp.js';
|
||||
|
||||
function makeSession(overrides: Partial<{
|
||||
initialize: () => Promise<unknown>;
|
||||
listTools: () => Promise<Array<{ name: string }>>;
|
||||
callTool: (name: string, args: Record<string, unknown>) => Promise<unknown>;
|
||||
close: () => Promise<void>;
|
||||
}> = {}) {
|
||||
return {
|
||||
initialize: overrides.initialize ?? vi.fn(async () => ({ protocolVersion: '2024-11-05' })),
|
||||
listTools: overrides.listTools ?? vi.fn(async () => [{ name: 'echo' }, { name: 'search' }]),
|
||||
callTool: overrides.callTool ?? vi.fn(async () => ({ content: [{ type: 'text', text: 'hi' }] })),
|
||||
close: overrides.close ?? vi.fn(async () => { /* no-op */ }),
|
||||
};
|
||||
}
|
||||
|
||||
describe('mcpctl test mcp', () => {
|
||||
const output: string[] = [];
|
||||
const log = (...args: unknown[]) => {
|
||||
output.push(args.map(String).join(' '));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
output.length = 0;
|
||||
process.exitCode = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.exitCode = 0;
|
||||
});
|
||||
|
||||
it('exits 0 on happy path (health + initialize + tools/list)', async () => {
|
||||
const session = makeSession();
|
||||
const cmd = createTestCommand({
|
||||
log,
|
||||
createSession: () => session,
|
||||
healthCheck: async () => true,
|
||||
});
|
||||
await cmd.parseAsync(['mcp', 'https://mcp.example.com/projects/foo/mcp'], { from: 'user' });
|
||||
expect(process.exitCode).toBe(0);
|
||||
expect(session.initialize).toHaveBeenCalled();
|
||||
expect(session.listTools).toHaveBeenCalled();
|
||||
expect(output.join('\n')).toContain('Result: PASS');
|
||||
});
|
||||
|
||||
it('exits 1 when the /healthz preflight fails', async () => {
|
||||
const cmd = createTestCommand({
|
||||
log,
|
||||
createSession: () => makeSession(),
|
||||
healthCheck: async () => false,
|
||||
});
|
||||
await cmd.parseAsync(['mcp', 'https://mcp.example.com/projects/foo/mcp'], { from: 'user' });
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(output.join('\n')).toContain('healthz preflight failed');
|
||||
});
|
||||
|
||||
it('exits 2 (contract fail) when --expect-tools are missing', async () => {
|
||||
const cmd = createTestCommand({
|
||||
log,
|
||||
createSession: () => makeSession({
|
||||
listTools: async () => [{ name: 'echo' }],
|
||||
}),
|
||||
healthCheck: async () => true,
|
||||
});
|
||||
await cmd.parseAsync(
|
||||
['mcp', 'https://mcp.example.com/projects/foo/mcp', '--expect-tools', 'echo,search'],
|
||||
{ from: 'user' },
|
||||
);
|
||||
expect(process.exitCode).toBe(2);
|
||||
expect(output.join('\n')).toContain('Missing: search');
|
||||
expect(output.join('\n')).toContain('CONTRACT FAIL');
|
||||
});
|
||||
|
||||
it('exits 0 when --expect-tools all match', async () => {
|
||||
const cmd = createTestCommand({
|
||||
log,
|
||||
createSession: () => makeSession({
|
||||
listTools: async () => [{ name: 'echo' }, { name: 'search' }, { name: 'x' }],
|
||||
}),
|
||||
healthCheck: async () => true,
|
||||
});
|
||||
await cmd.parseAsync(
|
||||
['mcp', 'https://mcp.example.com/projects/foo/mcp', '--expect-tools', 'echo,search'],
|
||||
{ from: 'user' },
|
||||
);
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it('exits 1 on transport/auth failure (initialize throws)', async () => {
|
||||
const cmd = createTestCommand({
|
||||
log,
|
||||
createSession: () => makeSession({
|
||||
initialize: async () => { throw new Error('HTTP 401: unauthorized'); },
|
||||
}),
|
||||
healthCheck: async () => true,
|
||||
});
|
||||
await cmd.parseAsync(['mcp', 'https://mcp.example.com/projects/foo/mcp'], { from: 'user' });
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(output.join('\n')).toContain('Error:');
|
||||
expect(output.join('\n')).toContain('TRANSPORT/AUTH FAIL');
|
||||
});
|
||||
|
||||
it('invokes --tool with --args and reports isError', async () => {
|
||||
const callTool = vi.fn(async () => ({ content: [{ type: 'text', text: 'oops' }], isError: true }));
|
||||
const cmd = createTestCommand({
|
||||
log,
|
||||
createSession: () => makeSession({ callTool }),
|
||||
healthCheck: async () => true,
|
||||
});
|
||||
await cmd.parseAsync(
|
||||
['mcp', 'https://mcp.example.com/projects/foo/mcp', '--tool', 'echo', '--args', '{"msg":"hi"}'],
|
||||
{ from: 'user' },
|
||||
);
|
||||
expect(callTool).toHaveBeenCalledWith('echo', { msg: 'hi' });
|
||||
expect(process.exitCode).toBe(2);
|
||||
});
|
||||
|
||||
it('outputs a JSON report with -o json', async () => {
|
||||
const cmd = createTestCommand({
|
||||
log,
|
||||
createSession: () => makeSession(),
|
||||
healthCheck: async () => true,
|
||||
});
|
||||
await cmd.parseAsync(
|
||||
['mcp', 'https://mcp.example.com/projects/foo/mcp', '-o', 'json'],
|
||||
{ from: 'user' },
|
||||
);
|
||||
const parsed = JSON.parse(output.join('\n')) as { exitCode: number; tools: string[] };
|
||||
expect(parsed.exitCode).toBe(0);
|
||||
expect(parsed.tools).toEqual(['echo', 'search']);
|
||||
});
|
||||
|
||||
it('reads $MCPCTL_TOKEN when --token is not given', async () => {
|
||||
let observedBearer: string | undefined;
|
||||
const cmd = createTestCommand({
|
||||
log,
|
||||
createSession: (_url, opts) => {
|
||||
observedBearer = opts.bearer;
|
||||
return makeSession();
|
||||
},
|
||||
healthCheck: async () => true,
|
||||
});
|
||||
const prev = process.env.MCPCTL_TOKEN;
|
||||
process.env.MCPCTL_TOKEN = 'mcpctl_pat_fromenv';
|
||||
try {
|
||||
await cmd.parseAsync(['mcp', 'https://mcp.example.com/projects/foo/mcp'], { from: 'user' });
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.MCPCTL_TOKEN;
|
||||
else process.env.MCPCTL_TOKEN = prev;
|
||||
}
|
||||
expect(observedBearer).toBe('mcpctl_pat_fromenv');
|
||||
});
|
||||
|
||||
it('rejects invalid --args as JSON', async () => {
|
||||
const cmd = createTestCommand({
|
||||
log,
|
||||
createSession: () => makeSession(),
|
||||
healthCheck: async () => true,
|
||||
});
|
||||
await cmd.parseAsync(
|
||||
['mcp', 'https://mcp.example.com/projects/foo/mcp', '--tool', 'echo', '--args', 'not-json'],
|
||||
{ from: 'user' },
|
||||
);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(output.join('\n')).toContain('must be valid JSON');
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@
|
||||
"clean": "rimraf dist",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "node dist/index.js",
|
||||
"serve": "node dist/serve.js",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"test:smoke": "vitest run --config vitest.smoke.config.ts"
|
||||
|
||||
@@ -97,7 +97,8 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
|
||||
?? effectiveRegistry?.getActiveName()
|
||||
?? 'none';
|
||||
const llmModel = resolvedModel ?? 'default';
|
||||
const cache = new FileCache(`${llmProvider}--${llmModel}--${proxyModelName}`);
|
||||
const cacheConfig = process.env.MCPLOCAL_CACHE_DIR ? { dir: process.env.MCPLOCAL_CACHE_DIR } : undefined;
|
||||
const cache = new FileCache(`${llmProvider}--${llmModel}--${proxyModelName}`, cacheConfig);
|
||||
router.setProxyModel(proxyModelName, llmAdapter, cache);
|
||||
|
||||
// Per-server proxymodel overrides (if mcpd provides them)
|
||||
@@ -200,6 +201,17 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
|
||||
void ensureUserName().then((name) => {
|
||||
if (name) collector.setSessionUserName(id, name);
|
||||
});
|
||||
|
||||
// HTTP-mode mcplocal: if the token-auth preHandler attached an McpToken
|
||||
// principal to the request, tag the session so audit events carry the
|
||||
// tokenName/tokenSha alongside (or instead of) userName.
|
||||
const principal = request.mcpToken;
|
||||
if (principal) {
|
||||
collector.setSessionMcpToken(id, {
|
||||
tokenName: principal.tokenName,
|
||||
tokenSha: principal.tokenSha,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Audit: session_bind
|
||||
@@ -388,7 +400,7 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
|
||||
const llmAdapter = providerRegistry
|
||||
? new LLMProviderAdapter(providerRegistry)
|
||||
: { complete: async () => '', available: () => false };
|
||||
const cache = new FileCache('dynamic');
|
||||
const cache = new FileCache('dynamic', process.env.MCPLOCAL_CACHE_DIR ? { dir: process.env.MCPLOCAL_CACHE_DIR } : undefined);
|
||||
|
||||
if (serverName && serverProxyModel) {
|
||||
entry.router.setServerProxyModel(serverName, serverProxyModel, llmAdapter, cache);
|
||||
|
||||
114
src/mcplocal/src/http/token-auth.ts
Normal file
114
src/mcplocal/src/http/token-auth.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Fastify preHandler that authenticates `/projects/*` and `/mcp` requests
|
||||
* against mcpd's McpToken introspection endpoint.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Reject non-Bearer and non-`mcpctl_pat_` auth up front.
|
||||
* 2. Call `GET <mcpd>/api/v1/mcptokens/introspect` with the raw bearer.
|
||||
* 3. Cache the result (positive + negative TTLs) to avoid a round-trip per MCP call.
|
||||
* 4. Enforce `request.params.projectName === response.projectName`.
|
||||
* 5. Stash the principal on `request.mcpToken` for the audit collector.
|
||||
*/
|
||||
import type { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { isMcpToken, hashToken } from '@mcpctl/shared';
|
||||
|
||||
export interface TokenAuthOptions {
|
||||
mcpdUrl: string;
|
||||
/** TTL for a successful introspection, ms. Default 30_000. */
|
||||
positiveTtlMs?: number;
|
||||
/** TTL for a failed introspection, ms. Default 5_000. */
|
||||
negativeTtlMs?: number;
|
||||
/** Injectable HTTP fetcher for tests. Defaults to `fetch`. */
|
||||
fetch?: (url: string, init?: RequestInit) => Promise<Response>;
|
||||
}
|
||||
|
||||
export interface McpTokenPrincipal {
|
||||
tokenName: string;
|
||||
tokenSha: string;
|
||||
projectName: string;
|
||||
}
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
/** Populated by the token-auth preHandler when the bearer was a McpToken. */
|
||||
mcpToken?: McpTokenPrincipal;
|
||||
}
|
||||
}
|
||||
|
||||
interface IntrospectResponse {
|
||||
ok: boolean;
|
||||
tokenName?: string;
|
||||
tokenSha?: string;
|
||||
projectName?: string;
|
||||
revoked?: boolean;
|
||||
expired?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
result: IntrospectResponse;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export function createTokenAuthMiddleware(opts: TokenAuthOptions) {
|
||||
const positiveTtl = opts.positiveTtlMs ?? 30_000;
|
||||
const negativeTtl = opts.negativeTtlMs ?? 5_000;
|
||||
const fetchImpl = opts.fetch ?? (globalThis.fetch as typeof fetch);
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
|
||||
async function introspect(raw: string): Promise<IntrospectResponse> {
|
||||
const key = hashToken(raw);
|
||||
const now = Date.now();
|
||||
const hit = cache.get(key);
|
||||
if (hit && hit.expiresAt > now) return hit.result;
|
||||
|
||||
try {
|
||||
const res = await fetchImpl(`${opts.mcpdUrl.replace(/\/$/, '')}/api/v1/mcptokens/introspect`, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: `Bearer ${raw}` },
|
||||
});
|
||||
const body = (await res.json().catch(() => ({ ok: false, error: 'unreadable body' }))) as IntrospectResponse;
|
||||
const result: IntrospectResponse = res.ok ? body : { ...body, ok: false };
|
||||
cache.set(key, { result, expiresAt: now + (result.ok ? positiveTtl : negativeTtl) });
|
||||
return result;
|
||||
} catch (err) {
|
||||
const result: IntrospectResponse = { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
cache.set(key, { result, expiresAt: now + negativeTtl });
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return async function tokenAuth(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
const header = request.headers.authorization;
|
||||
if (header === undefined || !header.startsWith('Bearer ')) {
|
||||
reply.code(401).send({ error: 'Missing Authorization bearer' });
|
||||
return;
|
||||
}
|
||||
const raw = header.slice(7);
|
||||
if (!isMcpToken(raw)) {
|
||||
reply.code(401).send({ error: 'Only mcpctl_pat_ bearers are accepted on this endpoint' });
|
||||
return;
|
||||
}
|
||||
|
||||
const introspection = await introspect(raw);
|
||||
if (!introspection.ok) {
|
||||
reply.code(401).send({
|
||||
error: introspection.revoked ? 'Token revoked' : introspection.expired ? 'Token expired' : 'Invalid token',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Project-scope check: token.projectName must match the path param.
|
||||
const params = request.params as { projectName?: string } | undefined;
|
||||
if (params?.projectName !== undefined && params.projectName !== introspection.projectName) {
|
||||
reply.code(403).send({ error: `Token is not valid for project '${params.projectName}'` });
|
||||
return;
|
||||
}
|
||||
|
||||
request.mcpToken = {
|
||||
tokenName: introspection.tokenName!,
|
||||
tokenSha: introspection.tokenSha!,
|
||||
projectName: introspection.projectName!,
|
||||
};
|
||||
};
|
||||
}
|
||||
99
src/mcplocal/src/serve.ts
Normal file
99
src/mcplocal/src/serve.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* HTTP-only entry for the containerized mcplocal (deployed behind Ingress as `mcp.ad.itaz.eu`).
|
||||
*
|
||||
* Differences from main.ts (the STDIO/systemd entry):
|
||||
* - No StdioProxyServer (there's no stdin/stdout MCP client in a pod).
|
||||
* - No `--upstream` flag (upstreams come from mcpd project discovery).
|
||||
* - Host + port from env (MCPLOCAL_HTTP_HOST / MCPLOCAL_HTTP_PORT).
|
||||
* - Requires MCPLOCAL_MCPD_URL to point at mcpd inside the cluster.
|
||||
* - Registers a token-auth preHandler on `/projects/*` and `/mcp`.
|
||||
* - FileCache directory honours MCPLOCAL_CACHE_DIR (wired via project-mcp-endpoint).
|
||||
*/
|
||||
import { McpRouter } from './router.js';
|
||||
import { createHttpServer } from './http/server.js';
|
||||
import { loadHttpConfig, loadLlmProviders } from './http/config.js';
|
||||
import { createProvidersFromConfig } from './llm-config.js';
|
||||
import { createSecretStore } from '@mcpctl/shared';
|
||||
import { reloadStages, startWatchers, stopWatchers } from './proxymodel/watcher.js';
|
||||
import { createTokenAuthMiddleware } from './http/token-auth.js';
|
||||
|
||||
function requireEnv(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (value === undefined || value === '') {
|
||||
throw new Error(`Required env var ${name} is not set`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function serve(): Promise<void> {
|
||||
const mcpdUrl = requireEnv('MCPLOCAL_MCPD_URL');
|
||||
const httpHost = process.env.MCPLOCAL_HTTP_HOST ?? '0.0.0.0';
|
||||
const httpPort = Number(process.env.MCPLOCAL_HTTP_PORT ?? '3200');
|
||||
if (!Number.isFinite(httpPort) || httpPort <= 0) {
|
||||
throw new Error(`Invalid MCPLOCAL_HTTP_PORT: ${process.env.MCPLOCAL_HTTP_PORT}`);
|
||||
}
|
||||
// MCPLOCAL_CACHE_DIR is optional; FileCache reads it directly.
|
||||
const cacheDir = process.env.MCPLOCAL_CACHE_DIR;
|
||||
|
||||
// loadHttpConfig reads user-level config.json; we override with env.
|
||||
const baseConfig = loadHttpConfig();
|
||||
const httpConfig = {
|
||||
...baseConfig,
|
||||
httpHost,
|
||||
httpPort,
|
||||
mcpdUrl,
|
||||
};
|
||||
|
||||
// LLM providers (configured via mounted ConfigMap at ~/.mcpctl/config.json or env).
|
||||
const llmEntries = loadLlmProviders();
|
||||
const secretStore = await createSecretStore();
|
||||
const providerRegistry = await createProvidersFromConfig(llmEntries, secretStore);
|
||||
|
||||
process.stderr.write(
|
||||
`mcplocal-serve: mcpd=${mcpdUrl} host=${httpHost} port=${httpPort} cache=${cacheDir ?? '~/.mcpctl/cache'}\n`,
|
||||
);
|
||||
|
||||
const router = new McpRouter();
|
||||
|
||||
const httpServer = await createHttpServer(httpConfig, { router, providerRegistry });
|
||||
|
||||
// Auth preHandler: only protect the MCP surfaces. /health, /healthz, /proxymodels etc stay open.
|
||||
const tokenAuth = createTokenAuthMiddleware({ mcpdUrl });
|
||||
httpServer.addHook('preHandler', async (request, reply) => {
|
||||
const url = request.url;
|
||||
if (!url.startsWith('/projects/') && !url.startsWith('/mcp')) return;
|
||||
await tokenAuth(request, reply);
|
||||
});
|
||||
|
||||
await httpServer.listen({ port: httpPort, host: httpHost });
|
||||
process.stderr.write(`mcplocal-serve listening on ${httpHost}:${httpPort}\n`);
|
||||
|
||||
// Hot-reload proxymodel stages from ~/.mcpctl/stages (same as main.ts).
|
||||
await reloadStages();
|
||||
startWatchers();
|
||||
|
||||
let shuttingDown = false;
|
||||
const shutdown = async () => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
stopWatchers();
|
||||
providerRegistry.disposeAll();
|
||||
await httpServer.close();
|
||||
await router.closeAll();
|
||||
process.exit(0);
|
||||
};
|
||||
process.on('SIGTERM', () => void shutdown());
|
||||
process.on('SIGINT', () => void shutdown());
|
||||
}
|
||||
|
||||
const isMain =
|
||||
process.argv[1]?.endsWith('serve.js') ||
|
||||
process.argv[1]?.endsWith('serve.ts');
|
||||
|
||||
if (isMain) {
|
||||
serve().catch((err) => {
|
||||
process.stderr.write(`Fatal: ${err}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
143
src/mcplocal/tests/smoke/mcptoken.smoke.test.ts
Normal file
143
src/mcplocal/tests/smoke/mcptoken.smoke.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Smoke tests: McpToken + HTTP-mode mcplocal end-to-end.
|
||||
*
|
||||
* Exercises the full public CLI contract:
|
||||
* 1. `mcpctl create project` + `mcpctl create mcptoken`
|
||||
* 2. `mcpctl test mcp <url> --token $TOK --expect-tools …` → exit 0
|
||||
* 3. Same token against a different project → exit 1 (403)
|
||||
* 4. Revoke the token, retry → exit 1 (401) within the negative-cache window
|
||||
* 5. --expect-tools <nonexistent> → exit 2 (contract failure)
|
||||
*
|
||||
* Target endpoint: $MCPGW_URL (default https://mcp.ad.itaz.eu). The containerized
|
||||
* mcplocal must be deployed and reachable. If the /healthz preflight fails we
|
||||
* skip the whole suite with a clear message.
|
||||
*
|
||||
* Run with: pnpm test:smoke
|
||||
*/
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
const MCPGW_URL = process.env.MCPGW_URL ?? 'https://mcp.ad.itaz.eu';
|
||||
const PROJECT_NAME = `smoke-mcptoken-${Date.now().toString(36)}`;
|
||||
const TOKEN_NAME = 'smoketok';
|
||||
const OTHER_PROJECT = 'smoke-mcptoken-other';
|
||||
|
||||
interface CliResult { code: number; stdout: string; stderr: string }
|
||||
|
||||
function run(args: string): CliResult {
|
||||
try {
|
||||
const stdout = execSync(`mcpctl ${args}`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
return { code: 0, stdout: stdout.trim(), stderr: '' };
|
||||
} catch (err) {
|
||||
const e = err as { status?: number; stdout?: Buffer | string; stderr?: Buffer | string };
|
||||
return {
|
||||
code: e.status ?? 1,
|
||||
stdout: e.stdout ? (typeof e.stdout === 'string' ? e.stdout : e.stdout.toString('utf-8')) : '',
|
||||
stderr: e.stderr ? (typeof e.stderr === 'string' ? e.stderr : e.stderr.toString('utf-8')) : '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function healthz(url: string, timeoutMs = 5000): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const parsed = new URL(`${url.replace(/\/$/, '')}/healthz`);
|
||||
const driver = parsed.protocol === 'https:' ? https : http;
|
||||
const req = driver.get(
|
||||
{
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
||||
path: parsed.pathname,
|
||||
timeout: timeoutMs,
|
||||
},
|
||||
(res) => {
|
||||
resolve((res.statusCode ?? 500) < 500);
|
||||
res.resume();
|
||||
},
|
||||
);
|
||||
req.on('error', () => resolve(false));
|
||||
req.on('timeout', () => { req.destroy(); resolve(false); });
|
||||
});
|
||||
}
|
||||
|
||||
let gatewayUp = false;
|
||||
let rawToken = '';
|
||||
let knownToolName: string | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
gatewayUp = await healthz(MCPGW_URL);
|
||||
}, 20_000);
|
||||
|
||||
describe.skipIf(!gatewayUp)('mcptoken smoke (MCPGW_URL=' + MCPGW_URL + ')', () => {
|
||||
it('creates the project and a project-scoped mcptoken', () => {
|
||||
run(`delete project ${PROJECT_NAME} --force`); // cleanup leftovers — best-effort
|
||||
const createProj = run(`create project ${PROJECT_NAME} --force`);
|
||||
expect(createProj.code).toBe(0);
|
||||
|
||||
const createTok = run(`create mcptoken ${TOKEN_NAME} --project ${PROJECT_NAME} --rbac clone`);
|
||||
expect(createTok.code).toBe(0);
|
||||
const match = createTok.stdout.match(/mcpctl_pat_[A-Za-z0-9]+/);
|
||||
expect(match, 'raw token was printed to stdout').not.toBeNull();
|
||||
rawToken = match![0];
|
||||
});
|
||||
|
||||
it('passes `mcpctl test mcp` against the token\'s project endpoint', () => {
|
||||
const result = run(`test mcp ${MCPGW_URL}/projects/${PROJECT_NAME}/mcp --token ${rawToken} -o json`);
|
||||
expect(result.code, result.stderr || result.stdout).toBe(0);
|
||||
const report = JSON.parse(result.stdout.slice(result.stdout.indexOf('{'))) as {
|
||||
exitCode: number;
|
||||
tools: string[] | null;
|
||||
initialize: string;
|
||||
};
|
||||
expect(report.exitCode).toBe(0);
|
||||
expect(report.initialize).toBe('ok');
|
||||
expect(Array.isArray(report.tools)).toBe(true);
|
||||
// Remember a tool name for the next negative --expect-tools assertion
|
||||
knownToolName = report.tools?.[0];
|
||||
});
|
||||
|
||||
it('fails `mcpctl test mcp` against a different project with 403', () => {
|
||||
run(`create project ${OTHER_PROJECT} --force`);
|
||||
const result = run(`test mcp ${MCPGW_URL}/projects/${OTHER_PROJECT}/mcp --token ${rawToken} -o json`);
|
||||
expect(result.code).toBe(1);
|
||||
const report = JSON.parse(result.stdout.slice(result.stdout.indexOf('{'))) as { error?: string };
|
||||
expect(report.error ?? '').toMatch(/403|not valid for|project/i);
|
||||
});
|
||||
|
||||
it('exits 2 (contract failure) when --expect-tools names a nonexistent tool', () => {
|
||||
const result = run(`test mcp ${MCPGW_URL}/projects/${PROJECT_NAME}/mcp --token ${rawToken} --expect-tools __nonexistent_tool_xyz__`);
|
||||
expect(result.code).toBe(2);
|
||||
});
|
||||
|
||||
it('returns 401 after the token is revoked (within the negative-cache window)', async () => {
|
||||
const del = run(`delete mcptoken ${TOKEN_NAME} --project ${PROJECT_NAME}`);
|
||||
expect(del.code).toBe(0);
|
||||
// Let the mcplocal negative-cache window expire. Introspection negative TTL
|
||||
// defaults to 5s; we wait 7s to be safe.
|
||||
await new Promise((r) => setTimeout(r, 7_000));
|
||||
const result = run(`test mcp ${MCPGW_URL}/projects/${PROJECT_NAME}/mcp --token ${rawToken} -o json`);
|
||||
expect(result.code).toBe(1);
|
||||
const report = JSON.parse(result.stdout.slice(result.stdout.indexOf('{'))) as { error?: string };
|
||||
expect(report.error ?? '').toMatch(/401|revoked|Invalid token/i);
|
||||
}, 20_000);
|
||||
|
||||
it('cleans up test fixtures', () => {
|
||||
run(`delete project ${PROJECT_NAME} --force`);
|
||||
run(`delete project ${OTHER_PROJECT} --force`);
|
||||
// Suppress the unused-var warning in strict setups
|
||||
expect(knownToolName === undefined || typeof knownToolName === 'string').toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(gatewayUp)('mcptoken smoke (SKIPPED)', () => {
|
||||
it('is skipped because MCPGW_URL is unreachable', () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`mcptoken smoke: skipped — ${MCPGW_URL}/healthz unreachable. Set MCPGW_URL to override.`);
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -4,3 +4,4 @@ export * from './constants/index.js';
|
||||
export * from './utils/index.js';
|
||||
export * from './secrets/index.js';
|
||||
export * from './tokens/index.js';
|
||||
export * from './mcp-http/index.js';
|
||||
|
||||
246
src/shared/src/mcp-http/index.ts
Normal file
246
src/shared/src/mcp-http/index.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Reusable Streamable-HTTP MCP client.
|
||||
*
|
||||
* Handles:
|
||||
* - Bearer auth (session tokens or McpToken PATs)
|
||||
* - mcp-session-id round-trip
|
||||
* - Both JSON and text/event-stream response bodies
|
||||
* - JSON-RPC id correlation when a response is multiplexed with notifications
|
||||
*
|
||||
* Used by the smoke suite (`SmokeMcpSession` is a thin wrapper around this)
|
||||
* and by `mcpctl test mcp <url>`.
|
||||
*/
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
|
||||
export interface McpHttpSessionOptions {
|
||||
/** Bearer to send on every request. Accepts raw tokens (no "Bearer " prefix). */
|
||||
bearer?: string;
|
||||
/** Additional headers merged into every request. */
|
||||
headers?: Record<string, string>;
|
||||
/** Timeout per HTTP request in milliseconds. Defaults to 30_000. */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface ToolInfo {
|
||||
name: string;
|
||||
description?: string;
|
||||
inputSchema?: unknown;
|
||||
}
|
||||
|
||||
export interface ToolCallResult {
|
||||
content: Array<{ type: string; text?: string }>;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
interface HttpRequestArgs {
|
||||
url: string;
|
||||
method: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
interface HttpRequestResult {
|
||||
status: number;
|
||||
headers: http.IncomingHttpHeaders;
|
||||
body: string;
|
||||
}
|
||||
|
||||
function rawHttpRequest(opts: HttpRequestArgs): Promise<HttpRequestResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(opts.url);
|
||||
const driver = parsed.protocol === 'https:' ? https : http;
|
||||
const req = driver.request(
|
||||
{
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
||||
path: parsed.pathname + parsed.search,
|
||||
method: opts.method,
|
||||
headers: opts.headers,
|
||||
timeout: opts.timeoutMs ?? 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'));
|
||||
});
|
||||
if (opts.body) req.write(opts.body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function parseSse(body: string): unknown[] {
|
||||
const messages: unknown[] = [];
|
||||
for (const line of body.split('\n')) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
messages.push(JSON.parse(line.slice(6)));
|
||||
} catch {
|
||||
// skip malformed SSE data line
|
||||
}
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
/** Thrown when the server returned a response JSON-RPC error payload. */
|
||||
export class McpProtocolError extends Error {
|
||||
constructor(public readonly code: number, message: string) {
|
||||
super(`MCP error ${code}: ${message}`);
|
||||
this.name = 'McpProtocolError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown when the HTTP layer rejected the request (auth, transport, 5xx). */
|
||||
export class McpTransportError extends Error {
|
||||
constructor(public readonly status: number, public readonly body: string, message?: string) {
|
||||
super(message ?? `HTTP ${status}: ${body.slice(0, 200)}`);
|
||||
this.name = 'McpTransportError';
|
||||
}
|
||||
}
|
||||
|
||||
export class McpHttpSession {
|
||||
private sessionId: string | undefined;
|
||||
private nextId = 1;
|
||||
|
||||
constructor(
|
||||
/** Full URL of the MCP endpoint (e.g. `https://mcp.example.com/projects/foo/mcp`). */
|
||||
public readonly url: string,
|
||||
private readonly options: McpHttpSessionOptions = {},
|
||||
) {}
|
||||
|
||||
private buildHeaders(extra: Record<string, string> = {}): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json, text/event-stream',
|
||||
...(this.options.headers ?? {}),
|
||||
...extra,
|
||||
};
|
||||
if (this.sessionId) headers['mcp-session-id'] = this.sessionId;
|
||||
if (this.options.bearer) headers['Authorization'] = `Bearer ${this.options.bearer}`;
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a JSON-RPC request and wait for the response with a matching id.
|
||||
* Handles both single JSON and multiplexed SSE bodies.
|
||||
*/
|
||||
async send(method: string, params: Record<string, unknown> = {}): Promise<unknown> {
|
||||
const id = this.nextId++;
|
||||
const request = { jsonrpc: '2.0', id, method, params };
|
||||
|
||||
const args: HttpRequestArgs = {
|
||||
url: this.url,
|
||||
method: 'POST',
|
||||
headers: this.buildHeaders(),
|
||||
body: JSON.stringify(request),
|
||||
};
|
||||
if (this.options.timeoutMs !== undefined) args.timeoutMs = this.options.timeoutMs;
|
||||
const result = await rawHttpRequest(args);
|
||||
|
||||
if (!this.sessionId) {
|
||||
const sid = result.headers['mcp-session-id'];
|
||||
if (typeof sid === 'string') this.sessionId = sid;
|
||||
}
|
||||
|
||||
if (result.status >= 400) {
|
||||
let message = `HTTP ${result.status}`;
|
||||
try {
|
||||
const body = JSON.parse(result.body) as { error?: string | { message?: string } };
|
||||
const errField = body.error;
|
||||
if (typeof errField === 'string') message = errField;
|
||||
else if (errField && typeof errField === 'object' && typeof errField.message === 'string') message = errField.message;
|
||||
} catch {
|
||||
message = `HTTP ${result.status}: ${result.body.slice(0, 200)}`;
|
||||
}
|
||||
throw new McpTransportError(result.status, result.body, message);
|
||||
}
|
||||
|
||||
const messages = result.headers['content-type']?.includes('text/event-stream')
|
||||
? parseSse(result.body)
|
||||
: [JSON.parse(result.body)];
|
||||
|
||||
const matched = messages.find((m) => {
|
||||
const msg = m as { id?: unknown };
|
||||
return msg.id === id;
|
||||
}) as { result?: unknown; error?: { code: number; message: string } } | undefined;
|
||||
|
||||
const parsed = matched ?? messages[0] as { result?: unknown; error?: { code: number; message: string } } | undefined;
|
||||
if (!parsed) throw new Error(`No response for ${method}`);
|
||||
if (parsed.error) throw new McpProtocolError(parsed.error.code, parsed.error.message);
|
||||
return parsed.result;
|
||||
}
|
||||
|
||||
async sendNotification(method: string, params: Record<string, unknown> = {}): Promise<void> {
|
||||
const notification = { jsonrpc: '2.0', method, params };
|
||||
const args: HttpRequestArgs = {
|
||||
url: this.url,
|
||||
method: 'POST',
|
||||
headers: this.buildHeaders(),
|
||||
body: JSON.stringify(notification),
|
||||
};
|
||||
if (this.options.timeoutMs !== undefined) args.timeoutMs = this.options.timeoutMs;
|
||||
await rawHttpRequest(args).catch(() => { /* best-effort */ });
|
||||
}
|
||||
|
||||
/** MCP `initialize` handshake. */
|
||||
async initialize(): Promise<{ protocolVersion?: string; serverInfo?: { name?: string; version?: string }; capabilities?: unknown }> {
|
||||
return await this.send('initialize', {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'mcpctl-mcp-http-client', version: '1.0.0' },
|
||||
}) as { protocolVersion?: string; serverInfo?: { name?: string; version?: string }; capabilities?: unknown };
|
||||
}
|
||||
|
||||
/** List tools exposed by the endpoint. */
|
||||
async listTools(): Promise<ToolInfo[]> {
|
||||
const result = await this.send('tools/list') as { tools?: ToolInfo[] };
|
||||
return result.tools ?? [];
|
||||
}
|
||||
|
||||
/** Call a tool and return its `content` payload. */
|
||||
async callTool(name: string, args: Record<string, unknown> = {}): Promise<ToolCallResult> {
|
||||
return await this.send('tools/call', { name, arguments: args }) as ToolCallResult;
|
||||
}
|
||||
|
||||
/** Clean-close the session with a DELETE. Safe to call when no sessionId has been negotiated. */
|
||||
async close(): Promise<void> {
|
||||
if (this.sessionId === undefined) return;
|
||||
await rawHttpRequest({
|
||||
url: this.url,
|
||||
method: 'DELETE',
|
||||
headers: this.buildHeaders(),
|
||||
timeoutMs: 5_000,
|
||||
}).catch(() => { /* best-effort */ });
|
||||
this.sessionId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Best-effort healthcheck against `<base>/healthz`. */
|
||||
export async function mcpHealthCheck(baseUrl: string, timeoutMs = 5_000): Promise<boolean> {
|
||||
try {
|
||||
const res = await rawHttpRequest({ url: `${baseUrl.replace(/\/$/, '')}/healthz`, method: 'GET', timeoutMs });
|
||||
return res.status >= 200 && res.status < 500;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Derive `<scheme>://<host>[:port]` from a full MCP endpoint URL (for healthcheck). */
|
||||
export function deriveBaseUrl(mcpUrl: string): string {
|
||||
const u = new URL(mcpUrl);
|
||||
return `${u.protocol}//${u.host}`;
|
||||
}
|
||||
Reference in New Issue
Block a user