diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 658c9fe47..f8b4249f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,20 @@ on: workflow_dispatch: jobs: + cli: + name: CLI (test, build) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm test -w packages/cli + - run: npm run build -w packages/cli + - run: test ! -e packages/cli/dist/cli.test.js + backend: name: Backend (lint, typecheck, test, build) runs-on: ubuntu-latest diff --git a/Dockerfile b/Dockerfile index e503ca13c..e3fe3f63d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,6 +26,7 @@ WORKDIR /app COPY package.json package-lock.json ./ COPY packages/backend/package.json ./packages/backend/ COPY packages/frontend/package.json ./packages/frontend/ +COPY packages/cli/package.json ./packages/cli/ # Install all workspace dependencies # Extended timeout for ARM64 QEMU emulation in CI @@ -44,6 +45,10 @@ COPY packages/backend/package.json ./packages/backend/ RUN mkdir -p packages/frontend && \ echo '{"name":"@anythingmcp/frontend","version":"0.1.1","private":true}' > packages/frontend/package.json +# The CLI is a workspace but does not ship in the server image. +RUN mkdir -p packages/cli && \ + echo '{"name":"@anythingmcp/cli","version":"0.1.0","private":true}' > packages/cli/package.json + RUN npm install --omit=dev --network-timeout=600000 && \ rm -rf node_modules/typescript node_modules/react-dom node_modules/react diff --git a/docs/api-reference.md b/docs/api-reference.md index cb06437eb..a347c0c6e 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -324,6 +324,21 @@ with that server's tools. Other servers answer `403`. The server must be one of an organization you belong to, and deleting it deactivates its keys. A key created without `mcpServerId` reaches every server of your organization. +### CLI + +The repository includes the [`amcp` CLI](../packages/cli/README.md) for logging +in with an MCP API key and listing tools on a specific server. Build it with +`npm run build -w packages/cli`, then run: + +```bash +node packages/cli/dist/index.js login --url http://localhost:4000 --server YOUR_SERVER_ID +node packages/cli/dist/index.js tools ls +``` + +The CLI constructs `/mcp/` from the instance URL and server ID. +The `/api/*` endpoints in this reference use a JWT, while the CLI connects to +MCP with an MCP API key. + --- ## Site Settings (Admin) diff --git a/package-lock.json b/package-lock.json index d25c5ce92..d8007a65e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "license": "AGPL-3.0-only", "workspaces": [ "packages/backend", - "packages/frontend" + "packages/frontend", + "packages/cli" ], "dependencies": { "class-transformer": "^0.5.1", @@ -113,6 +114,10 @@ "resolved": "packages/backend", "link": true }, + "node_modules/@anythingmcp/cli": { + "resolved": "packages/cli", + "link": true + }, "node_modules/@anythingmcp/frontend": { "resolved": "packages/frontend", "link": true @@ -25291,6 +25296,41 @@ "dev": true, "license": "MIT" }, + "packages/cli": { + "name": "@anythingmcp/cli", + "version": "0.1.0", + "license": "AGPL-3.0-only", + "dependencies": { + "@modelcontextprotocol/client": "^2.2.0" + }, + "bin": { + "amcp": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.9.0" + }, + "engines": { + "node": ">=22.12" + } + }, + "packages/cli/node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "packages/cli/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "packages/frontend": { "name": "@anythingmcp/frontend", "version": "0.29.0", diff --git a/package.json b/package.json index 74115cee6..3e82948b2 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ }, "workspaces": [ "packages/backend", - "packages/frontend" + "packages/frontend", + "packages/cli" ], "scripts": { "dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"", diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 000000000..67288f4b6 --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,43 @@ +# AnythingMCP CLI + +The `amcp` command connects to one AnythingMCP MCP server from your terminal. +This first version supports login, logout, profile inspection and tool listing. + +From the repository root: + +```bash +npm install +npm run build -w packages/cli +node packages/cli/dist/index.js login --url http://localhost:4000 --server YOUR_SERVER_ID +node packages/cli/dist/index.js tools ls +``` + +`login` prompts for an MCP API key without displaying it. For scripts, use +`--key-stdin` or the `AMCP_API_KEY` environment variable. Never put the key in +a command argument. The CLI verifies the key by requesting `tools/list` before +saving it. It always connects to `/mcp/`, including on Cloud, because +the bare `/mcp` endpoint exposes a different tool set there. Pass the instance +base URL to `--url`, without `/mcp/`. HTTPS is required except for +`localhost`, `127.0.0.0/8`, and `::1` during local development. Redirects are +refused so the API key cannot follow a request to another origin. + +```bash +printf '%s\n' "$AMCP_API_KEY" | amcp login --url https://cloud.anythingmcp.com \ + --server YOUR_SERVER_ID --key-stdin --profile cloud +amcp tools ls --profile cloud --filter customer +amcp whoami --profile cloud +amcp logout --profile cloud +``` + +For CI, `AMCP_URL`, `AMCP_SERVER` and `AMCP_API_KEY` can provide connection +details without saving a profile. `--url` and `--server` take precedence over +their matching environment variables. If a URL is supplied by `--url` or +`AMCP_URL`, supply `AMCP_API_KEY` too; a saved profile key is used only with its +saved origin. Profiles live in +`$XDG_CONFIG_HOME/amcp/config.json` or `~/.config/amcp/config.json`, with file +mode `0600` on Unix. Relative `XDG_CONFIG_HOME` values are ignored. `whoami` +shows only a key prefix. + +`tools ls` shows each tool's name, read-only/destructive hint, and the first +line of its description. Tool execution and JSON output are planned for the +next phase of [issue #1009](https://github.com/HelpCode-ai/anythingmcp/issues/1009). diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 000000000..0efd566fa --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,16 @@ +{ + "name": "@anythingmcp/cli", + "version": "0.1.0", + "private": true, + "description": "AnythingMCP command-line client", + "license": "AGPL-3.0-only", + "bin": { "amcp": "dist/index.js" }, + "engines": { "node": ">=22.12" }, + "scripts": { + "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.build.json && node -e \"require('node:fs').chmodSync('dist/index.js', 0o755)\"", + "lint": "tsc --noEmit -p tsconfig.json", + "test": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.json && node --test dist/cli.test.js" + }, + "dependencies": { "@modelcontextprotocol/client": "^2.2.0" }, + "devDependencies": { "@types/node": "^22.0.0", "typescript": "^5.9.0" } +} diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts new file mode 100644 index 000000000..a12d82ba9 --- /dev/null +++ b/packages/cli/src/cli.test.ts @@ -0,0 +1,229 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { createServer } from 'node:http'; +import { mkdtemp, readFile, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { execFile, spawn } from 'node:child_process'; +import { promisify } from 'node:util'; +import { configPath, writeConfig } from './config.js'; +import { serverUrl } from './mcp.js'; + +const execFileAsync = promisify(execFile); + +test('login verifies MCP tools and saves a private profile; tools ls and whoami hide the key', async () => { + const requests: { method: string | undefined; path: string; key: string | undefined }[] = []; + const server = createServer(async (req, res) => { + requests.push({ method: req.method, path: req.url || '', key: req.headers['x-api-key'] as string | undefined }); + if (req.headers['x-api-key'] !== 'mcp_secret-test') { res.writeHead(401); res.end(); return; } + if (req.method === 'GET') { res.writeHead(405); res.end(); return; } + if (req.method === 'DELETE') { res.writeHead(200); res.end(); return; } + let body = ''; + for await (const chunk of req) body += chunk.toString(); + const message = JSON.parse(body); + if (message.method === 'notifications/initialized') { res.writeHead(202); res.end(); return; } + const result = message.method === 'initialize' + ? { protocolVersion: '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'fake', version: '1.0.0' } } + : { tools: [ + { name: 'find_customer', description: 'Find a customer\nDetails', inputSchema: { type: 'object', properties: {} }, annotations: { readOnlyHint: true } }, + { name: 'evil\rforged', description: 'untrusted\ttext\u001b[31m', inputSchema: { type: 'object', properties: {} } }, + ] }; + res.writeHead(200, { 'Content-Type': 'application/json', ...(message.method === 'initialize' + ? { 'Mcp-Session-Id': 'cli-test-session' } : {}) }); + res.end(JSON.stringify({ jsonrpc: '2.0', id: message.id, result })); + }); + server.listen(0, '127.0.0.1'); + await new Promise((resolve) => server.once('listening', resolve)); + try { + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Missing address'); + const directory = await mkdtemp(join(tmpdir(), 'amcp-cli-test-')); + const env = { ...process.env, XDG_CONFIG_HOME: directory, AMCP_API_KEY: 'mcp_secret-test' }; + const cli = join(__dirname, 'index.js'); + const base = `http://127.0.0.1:${address.port}`; + const login = await execFileAsync(process.execPath, [cli, 'login', '--url', base, '--server', 'test'], { env }); + assert.match(login.stdout, /Logged in/); + assert.doesNotMatch(login.stdout + login.stderr, /mcp_secret-test/); + const path = join(directory, 'amcp', 'config.json'); + const stored = JSON.parse(await readFile(path, 'utf8')); + assert.equal(stored.profiles.default.key, 'mcp_secret-test'); + if (process.platform !== 'win32') assert.equal((await stat(path)).mode & 0o777, 0o600); + const storedEnv: NodeJS.ProcessEnv = { ...env }; + delete storedEnv.AMCP_API_KEY; + delete storedEnv.AMCP_URL; + const listing = await execFileAsync(process.execPath, [cli, 'tools', 'ls'], { env: storedEnv }); + assert.match(listing.stdout, /find_customer\tread-only\tFind a customer/); + assert.match(listing.stdout, /evil forged\tunspecified\tuntrusted text \[31m/); + assert.doesNotMatch(listing.stdout, /\u001b|\r/); + assert.doesNotMatch(listing.stdout + listing.stderr, /mcp_secret-test/); + const identity = await execFileAsync(process.execPath, [cli, 'whoami'], { env }); + assert.doesNotMatch(identity.stdout + identity.stderr, /mcp_secret-test/); + const filtered = await execFileAsync(process.execPath, [cli, 'tools', 'ls', '--filter', 'missing'], { env: storedEnv }); + assert.equal(filtered.stdout, ''); + await execFileAsync(process.execPath, [cli, 'logout'], { env }); + assert.deepEqual(JSON.parse(await readFile(path, 'utf8')).profiles, {}); + assert.ok(requests.every((request) => request.path === '/mcp/test' && request.key === 'mcp_secret-test')); + assert.ok(requests.some((request) => request.method === 'DELETE'), 'sessions must be terminated remotely'); + await writeFile(path, 'corrupt config'); + const envOnly = await execFileAsync(process.execPath, [cli, 'tools', 'ls'], { + env: { ...env, AMCP_URL: base, AMCP_SERVER: 'test' }, + }); + assert.match(envOnly.stdout, /find_customer/); + } finally { + server.close(); + } +}); + +test('HTTP is limited to loopback addresses', () => { + for (const host of ['localhost', '127.0.0.1', '127.8.9.10', '[::1]']) { + assert.equal(serverUrl({ url: `http://${host}:4000`, server: 'test' }).pathname, '/mcp/test'); + } + for (const host of ['192.168.1.10', 'example.com', '[::2]']) { + assert.throws(() => serverUrl({ url: `http://${host}:4000`, server: 'test' }), /HTTP is allowed only/); + } +}); + +test('relative XDG_CONFIG_HOME is ignored', () => { + assert.equal(configPath({ XDG_CONFIG_HOME: 'relative/path' }), configPath({})); +}); + +test('invalid arguments return usage exit code 2', async () => { + const directory = await mkdtemp(join(tmpdir(), 'amcp-usage-')); + const env: NodeJS.ProcessEnv = { ...process.env, XDG_CONFIG_HOME: directory }; + delete env.AMCP_URL; + delete env.AMCP_API_KEY; + for (const args of [ + ['whoami', '--profile', '!invalid'], + ['login', '--url'], + ]) { + await assert.rejects(execFileAsync(process.execPath, [join(__dirname, 'index.js'), ...args], { env }), + (error: unknown) => { + assert.equal((error as { code: number }).code, 2); + return true; + }); + } +}); + +test('redirects never forward an API key to another server', async () => { + const received: Array = []; + const target = createServer((req, res) => { + received.push(req.headers['x-api-key'] as string | undefined); + res.writeHead(200); res.end(); + }); + target.listen(0, '127.0.0.1'); + await new Promise((resolve) => target.once('listening', resolve)); + const targetAddress = target.address(); + if (!targetAddress || typeof targetAddress === 'string') throw new Error('Missing target address'); + const redirect = createServer((_req, res) => { + res.writeHead(307, { Location: `http://127.0.0.1:${targetAddress.port}/capture` }); res.end(); + }); + redirect.listen(0, '127.0.0.1'); + await new Promise((resolve) => redirect.once('listening', resolve)); + try { + const address = redirect.address(); + if (!address || typeof address === 'string') throw new Error('Missing redirect address'); + const directory = await mkdtemp(join(tmpdir(), 'amcp-redirect-')); + await assert.rejects(execFileAsync(process.execPath, [join(__dirname, 'index.js'), 'login', + '--url', `http://127.0.0.1:${address.port}`, '--server', 'test'], { + env: { ...process.env, XDG_CONFIG_HOME: directory, AMCP_API_KEY: 'mcp_redirect-secret' }, + }), (error: unknown) => { + assert.equal((error as { code: number }).code, 4); + return true; + }); + assert.deepEqual(received, []); + } finally { + redirect.close(); target.close(); + } +}); + +test('URL overrides require an explicit environment key', async () => { + const received: Array = []; + const target = createServer((req, res) => { + received.push(req.headers['x-api-key'] as string | undefined); + res.writeHead(401); res.end(); + }); + target.listen(0, '127.0.0.1'); + await new Promise((resolve) => target.once('listening', resolve)); + try { + const address = target.address(); + if (!address || typeof address === 'string') throw new Error('Missing address'); + const directory = await mkdtemp(join(tmpdir(), 'amcp-override-')); + await writeConfig({ profiles: { default: { + url: 'https://original.example', server: 'test', key: 'mcp_stored-secret', + } } }, join(directory, 'amcp', 'config.json')); + const baseEnv: NodeJS.ProcessEnv = { ...process.env, XDG_CONFIG_HOME: directory }; + delete baseEnv.AMCP_API_KEY; + delete baseEnv.AMCP_URL; + const url = `http://127.0.0.1:${address.port}`; + for (const [extraArgs, extraEnv] of [ + [['--url', url], {}], + [[], { AMCP_URL: url }], + ] as const) { + await assert.rejects(execFileAsync(process.execPath, + [join(__dirname, 'index.js'), 'tools', 'ls', ...extraArgs], + { env: { ...baseEnv, ...extraEnv } }), (error: unknown) => { + assert.equal((error as { code: number }).code, 2); + return true; + }); + } + assert.deepEqual(received, []); + } finally { target.close(); } +}); + +test('bad key is rejected without writing a profile or printing the key', async () => { + const server = createServer((_req, res) => { res.writeHead(401); res.end(); }); + server.listen(0, '127.0.0.1'); + await new Promise((resolve) => server.once('listening', resolve)); + try { + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Missing address'); + const directory = await mkdtemp(join(tmpdir(), 'amcp-cli-denied-')); + const env = { ...process.env, XDG_CONFIG_HOME: directory, AMCP_API_KEY: 'mcp_bad-secret' }; + await assert.rejects(execFileAsync(process.execPath, [join(__dirname, 'index.js'), 'login', '--url', + `http://127.0.0.1:${address.port}`, '--server', 'test'], { env }), (error: unknown) => { + const failure = error as { code: number; stdout: string; stderr: string }; + assert.equal(failure.code, 3); + assert.doesNotMatch(failure.stdout + failure.stderr, /mcp_bad-secret/); + return true; + }); + await assert.rejects(readFile(join(directory, 'amcp', 'config.json'))); + } finally { + server.close(); + } +}); + +test('server failures report only the HTTP status', async () => { + const server = createServer((_req, res) => { res.writeHead(503); res.end('sensitive upstream text'); }); + server.listen(0, '127.0.0.1'); + await new Promise((resolve) => server.once('listening', resolve)); + try { + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Missing address'); + const directory = await mkdtemp(join(tmpdir(), 'amcp-503-')); + await assert.rejects(execFileAsync(process.execPath, [join(__dirname, 'index.js'), 'login', '--url', + `http://127.0.0.1:${address.port}`, '--server', 'test'], { + env: { ...process.env, XDG_CONFIG_HOME: directory, AMCP_API_KEY: 'mcp_status-secret' }, + }), (error: unknown) => { + const failure = error as { code: number; stderr: string }; + assert.equal(failure.code, 4); + assert.match(failure.stderr, /HTTP 503/); + assert.doesNotMatch(failure.stderr, /sensitive upstream text|mcp_status-secret/); + return true; + }); + } finally { server.close(); } +}); + +test('key stdin is accepted and never echoed', async () => { + const directory = await mkdtemp(join(tmpdir(), 'amcp-cli-stdin-')); + const child = spawn(process.execPath, [join(__dirname, 'index.js'), 'login', '--url', + 'http://127.0.0.1:1', '--server', 'test', '--key-stdin'], + { env: { ...process.env, XDG_CONFIG_HOME: directory, AMCP_API_KEY: 'mcp_ignore-this-env' }, stdio: 'pipe' }); + let output = ''; + child.stdout.on('data', (chunk) => { output += chunk.toString(); }); + child.stderr.on('data', (chunk) => { output += chunk.toString(); }); + child.stdin.end('mcp_stdin-secret\n'); + const code = await new Promise((resolve) => child.on('close', resolve)); + assert.equal(code, 4); // Unreachable server; key input itself was accepted. + assert.doesNotMatch(output, /mcp_stdin-secret/); + assert.doesNotMatch(output, /Use --key-stdin/); +}); diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts new file mode 100644 index 000000000..dd92d1429 --- /dev/null +++ b/packages/cli/src/config.ts @@ -0,0 +1,71 @@ +import { chmod, mkdir, open, rename, unlink, writeFile } from 'node:fs/promises'; +import { constants } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, isAbsolute, join } from 'node:path'; +import { randomUUID } from 'node:crypto'; + +export interface Profile { + url: string; + server: string; + key: string; +} + +type Config = { profiles: Record }; + +export function configPath(env: NodeJS.ProcessEnv = process.env): string { + const base = env.XDG_CONFIG_HOME && isAbsolute(env.XDG_CONFIG_HOME) + ? env.XDG_CONFIG_HOME : join(homedir(), '.config'); + return join(base, 'amcp', 'config.json'); +} + +function validProfile(value: unknown): value is Profile { + if (!value || typeof value !== 'object') return false; + const p = value as Record; + return typeof p.url === 'string' && typeof p.server === 'string' && typeof p.key === 'string'; +} + +export async function readConfig(path = configPath()): Promise { + try { + // Check and read through one handle to avoid a pathname race. O_NOFOLLOW + // rejects symlinks on platforms that support it. + const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); + let parsed: unknown; + try { + if (!(await file.stat()).isFile()) throw new Error('Configuration path is not a regular file'); + parsed = JSON.parse(await file.readFile('utf8')); + } finally { + await file.close(); + } + if (!parsed || typeof parsed !== 'object') throw new Error('Invalid CLI configuration'); + const profiles = (parsed as Record).profiles; + if (!profiles || typeof profiles !== 'object' || Array.isArray(profiles) || + !Object.values(profiles).every(validProfile)) throw new Error('Invalid CLI configuration'); + return { profiles: profiles as Record }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { profiles: {} }; + throw error; + } +} + +export async function writeConfig(config: Config, path = configPath()): Promise { + const directory = dirname(path); + await mkdir(directory, { recursive: true, mode: 0o700 }); + const temporary = join(directory, `.config-${randomUUID()}.tmp`); + try { + await writeFile(temporary, JSON.stringify(config, null, 2) + '\n', { flag: 'wx', mode: 0o600 }); + await chmod(temporary, 0o600); + await rename(temporary, path); + await chmod(path, 0o600); + } finally { + await unlink(temporary).catch((error: NodeJS.ErrnoException) => { + if (error.code !== 'ENOENT') throw error; + }); + } +} + +export function profileName(value: string): string { + if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/.test(value)) { + throw new Error('Profile name must contain only letters, digits, _, - or .'); + } + return value; +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts new file mode 100644 index 000000000..178a18b89 --- /dev/null +++ b/packages/cli/src/index.ts @@ -0,0 +1,184 @@ +#!/usr/bin/env node +import { parseArgs } from 'node:util'; +import { readConfig, writeConfig, profileName, type Profile } from './config.js'; +import { listTools, serverUrl } from './mcp.js'; + +class CliError extends Error { + constructor(message: string, readonly exitCode: number) { super(message); } +} + +function sameOrigin(url: string, storedUrl: string): boolean { + try { return new URL(url).origin === new URL(storedUrl).origin; } + catch { return false; } +} + +async function readKey(stdin: NodeJS.ReadStream, stderr: NodeJS.WriteStream, fromStdin: boolean): Promise { + if (fromStdin) { + let input = ''; + for await (const chunk of stdin) input += chunk.toString(); + const key = input.trim(); + if (!key) throw new CliError('API key was empty', 2); + return key; + } + if (!stdin.isTTY || !stdin.setRawMode) { + throw new CliError('Use --key-stdin when input is not a terminal', 2); + } + stderr.write('MCP API key: '); + return new Promise((resolve, reject) => { + let key = ''; + const cleanup = () => { + stdin.off('data', onData); + stdin.off('end', onEnd); + stdin.off('error', onError); + stdin.setRawMode(false); + stdin.pause(); + stderr.write('\n'); + }; + const onEnd = () => { cleanup(); reject(new CliError('API key input ended', 2)); }; + const onError = () => { cleanup(); reject(new CliError('Could not read API key', 2)); }; + stdin.setRawMode(true); + stdin.resume(); + const onData = (chunk: Buffer) => { + for (const byte of chunk) { + if (byte === 3 || byte === 4) { + cleanup(); reject(new CliError('Cancelled', 2)); return; + } + if (byte === 13 || byte === 10) { + cleanup(); + if (!key) reject(new CliError('API key was empty', 2)); else resolve(key); + return; + } + if (byte === 8 || byte === 127) key = key.slice(0, -1); + else if (byte >= 32 && byte <= 126) key += String.fromCharCode(byte); + } + }; + stdin.on('data', onData); + stdin.on('end', onEnd); + stdin.on('error', onError); + }); +} + +function classifyConnectionError(error: unknown): CliError { + // MCP/HTTP errors may include request details. Do not print their message. + const message = error instanceof Error ? error.message : ''; + const detail = error as { status?: number; data?: { status?: number }; cause?: { code?: string } } | null; + const status = detail?.status ?? detail?.data?.status; + if (status === 401 || status === 403 || /\b(401|403)\b/.test(message) || + (error instanceof Error && error.name === 'UnauthorizedError')) { + return new CliError('Authentication failed (401/403)', 3); + } + if (typeof status === 'number' && status >= 400 && status <= 599) { + return new CliError(`MCP server returned HTTP ${status}`, 4); + } + const code = detail?.cause?.code; + if (code && /^(?:ENOTFOUND|ECONNREFUSED|ETIMEDOUT|EHOSTUNREACH|ENETUNREACH|CERT_[A-Z_]+|ERR_TLS_[A-Z_]+|UNABLE_TO_VERIFY_LEAF_SIGNATURE)$/.test(code)) { + return new CliError(`Could not connect to the MCP server (${code})`, 4); + } + return new CliError('Could not connect to the MCP server or list tools', 4); +} + +function usage(): string { + return `Usage: + amcp login --url --server [--key-stdin] [--profile ] + amcp logout [--profile ] + amcp whoami [--profile ] + amcp tools ls [--filter ] [--profile ] + +For CI, set AMCP_URL, AMCP_SERVER and AMCP_API_KEY. The key is never accepted as a command argument.`; +} + +export async function run(args = process.argv.slice(2)): Promise { + try { + const { values, positionals } = parseArgs({ + args, + options: { + url: { type: 'string' }, server: { type: 'string' }, profile: { type: 'string' }, + 'key-stdin': { type: 'boolean' }, filter: { type: 'string' }, help: { type: 'boolean', short: 'h' }, + }, + allowPositionals: true, + }); + if (values.help || positionals.length === 0) { process.stdout.write(usage() + '\n'); return 0; } + let name: string; + try { name = profileName(values.profile || 'default'); } + catch { throw new CliError('Invalid profile name', 2); } + const [command, subcommand] = positionals; + // Complete environment/flag credentials do not depend on a local profile. + const hasConnection = (values.url || process.env.AMCP_URL) && + (values.server || process.env.AMCP_SERVER) && process.env.AMCP_API_KEY; + const config = command === 'tools' && subcommand === 'ls' && hasConnection + ? { profiles: {} as Record } : await readConfig(); + if (command === 'login' && positionals.length === 1) { + const url = values.url || process.env.AMCP_URL; + const server = values.server || process.env.AMCP_SERVER; + if (!url || !server) throw new CliError('login requires --url and --server', 2); + const profile: Profile = { + url, server, + key: values['key-stdin'] ? await readKey(process.stdin, process.stderr, true) + : process.env.AMCP_API_KEY || await readKey(process.stdin, process.stderr, false), + }; + try { serverUrl(profile); } catch (error) { + throw new CliError(error instanceof Error ? error.message : 'Invalid instance URL or server ID', 2); + } + try { await listTools(profile); } catch (error) { throw classifyConnectionError(error); } + config.profiles[name] = profile; + await writeConfig(config); + process.stdout.write(`Logged in to ${serverUrl(profile).origin} (profile: ${name}).\n`); + return 0; + } + if (command === 'logout' && positionals.length === 1) { + delete config.profiles[name]; + await writeConfig(config); + process.stdout.write(`Logged out of profile ${name}.\n`); + return 0; + } + const stored = Object.hasOwn(config.profiles, name) ? config.profiles[name] : undefined; + if (command === 'whoami' && positionals.length === 1) { + if (!stored) throw new CliError('Profile not found; run amcp login', 2); + const keyHint = stored.key.startsWith('mcp_') && stored.key.length > 8 + ? `${stored.key.slice(0, 8)}…` : '[hidden]'; + process.stdout.write(`Profile: ${name}\nURL: ${serverUrl(stored).toString()}\nKey: ${keyHint}\n`); + return 0; + } + if (command === 'tools' && subcommand === 'ls' && positionals.length === 2) { + const urlOverridden = !!(values.url || process.env.AMCP_URL); + const url = values.url || process.env.AMCP_URL || stored?.url || ''; + const storedKey = stored && !urlOverridden && sameOrigin(url, stored.url) + ? stored.key : ''; + const profile: Profile = { + url, + server: values.server || process.env.AMCP_SERVER || stored?.server || '', + key: process.env.AMCP_API_KEY || storedKey, + }; + if (!profile.url || !profile.server || !profile.key) { + throw new CliError('Log in first, or set AMCP_URL, AMCP_SERVER and AMCP_API_KEY', 2); + } + try { serverUrl(profile); } catch (error) { + throw new CliError(error instanceof Error ? error.message : 'Invalid instance URL or server ID', 2); + } + let tools; + try { tools = await listTools(profile); } catch (error) { throw classifyConnectionError(error); } + const filter = values.filter?.toLowerCase(); + for (const tool of tools) { + if (filter && !tool.name.toLowerCase().includes(filter)) continue; + const hint = tool.annotations?.destructiveHint ? 'destructive' : + tool.annotations?.readOnlyHint ? 'read-only' : 'unspecified'; + const printable = (value: string) => value.replace(/[\x00-\x1f\x7f-\x9f]/g, ' '); + process.stdout.write(`${printable(tool.name)}\t${hint}\t${printable(tool.description?.split(/\r?\n/)[0] || '')}\n`); + } + return 0; + } + throw new CliError(usage(), 2); + } catch (error) { + if (error instanceof CliError) { process.stderr.write(`${error.message}\n`); return error.exitCode; } + if (error instanceof TypeError && + (error as NodeJS.ErrnoException).code?.startsWith('ERR_PARSE_ARGS_')) { + process.stderr.write(`${usage()}\n`); return 2; + } + process.stderr.write('CLI error; check arguments and configuration permissions\n'); + return 4; + } +} + +if (require.main === module) { + void run().then((code) => { process.exitCode = code; }); +} diff --git a/packages/cli/src/mcp.ts b/packages/cli/src/mcp.ts new file mode 100644 index 000000000..d9f7339a0 --- /dev/null +++ b/packages/cli/src/mcp.ts @@ -0,0 +1,46 @@ +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import type { Profile } from './config.js'; + +export function serverUrl(profile: Pick): URL { + const base = new URL(profile.url); + if (!['http:', 'https:'].includes(base.protocol) || base.username || base.password || + base.search || base.hash) throw new Error('Invalid server URL'); + if (base.protocol === 'http:' && base.hostname !== 'localhost' && + base.hostname !== '[::1]' && !/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(base.hostname)) { + throw new Error('HTTP is allowed only for localhost or loopback addresses'); + } + if (!/^[a-zA-Z0-9_-]+$/.test(profile.server)) throw new Error('Invalid server ID'); + if (base.pathname.endsWith('/mcp') || /\/mcp\//.test(base.pathname)) { + throw new Error('Pass the instance base URL, without /mcp'); + } + base.pathname = `${base.pathname.replace(/\/$/, '')}/mcp/${profile.server}`; + return base; +} + +export async function withClient(profile: Profile, action: (client: Client) => Promise): Promise { + const transport = new StreamableHTTPClientTransport(serverUrl(profile), { + requestInit: { redirect: 'error', headers: { 'X-API-Key': profile.key } }, + }); + const client = new Client({ name: 'amcp-cli', version: '0.1.0' }); + try { + await client.connect(transport); + return await action(client); + } finally { + await transport.terminateSession().catch(() => undefined); + await client.close().catch(() => undefined); + } +} + +export async function listTools(profile: Profile) { + return withClient(profile, async (client) => { + const tools: Awaited>['tools'] = []; + let cursor: string | undefined; + for (let page = 0; page < 50; page++) { + const result = await client.listTools(cursor ? { cursor } : undefined); + tools.push(...result.tools); + if (!result.nextCursor) return tools; + cursor = result.nextCursor; + } + throw new Error('Tool list exceeded 50 pages'); + }); +} diff --git a/packages/cli/tsconfig.build.json b/packages/cli/tsconfig.build.json new file mode 100644 index 000000000..0a11719fe --- /dev/null +++ b/packages/cli/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 000000000..298be0a13 --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src/**/*.ts"] +}