diff --git a/src/mcp/cli.ts b/src/mcp/cli.ts index 3daf77d..e8e2e4d 100644 --- a/src/mcp/cli.ts +++ b/src/mcp/cli.ts @@ -22,6 +22,11 @@ import { allClients, type McpServerEntry, } from './install' +import { + parseFlags, + buildEntryFromFlags, + type ParsedFlags, +} from './install/flags' const HELP = `agentmark-mcp — Model Context Protocol server for AgentMark @@ -47,7 +52,22 @@ OPTIONS (install / setup / uninstall) Known: ${allClients().map((c) => c.id).join(', ')} --name= Entry name to register under (default: agentmark) --command= Absolute command path to register (default: auto-detected) + --env=KEY=VALUE Add an env var that the client launches the MCP server + with. Repeatable. Used to wire THINKFLEET_* credentials + so the memory plugin talks to your ThinkFleet workspace + instead of the on-disk default backend. + + Security note: the value is written into the client's + MCP config file on disk. Prefer rotating secrets from + an OS keychain (ThinkFleet Desktop does this) rather + than passing long-lived keys on a shared machine. --dry-run Show what would change without writing + +EXAMPLE — wire Claude Code to ThinkFleet memory: + agentmark-mcp install --client=claude-code \\ + --env=THINKFLEET_BASE_URL=https://app.thinkfleet.ai \\ + --env=THINKFLEET_PROJECT_ID=proj_xxx \\ + --env=THINKFLEET_API_KEY=sk-xxx ` async function main(argv: string[]): Promise { @@ -80,7 +100,7 @@ async function main(argv: string[]): Promise { async function runInstall(args: string[]): Promise { const flags = parseFlags(args) - const entry = buildEntryFromFlags(flags) + const entry = entryForCli(flags) const result = await installToClients({ clientIds: flags.client, entry, @@ -115,39 +135,8 @@ async function runDoctor(): Promise { return 0 } -interface ParsedFlags { - client: string[] | undefined - name: string[] | undefined - command: string[] | undefined - dryRun: boolean -} - -function parseFlags(args: string[]): ParsedFlags { - const client: string[] = [] - const name: string[] = [] - const command: string[] = [] - let dryRun = false - for (const arg of args) { - if (arg === '--dry-run' || arg === '-n') { dryRun = true; continue } - const m = arg.match(/^--(client|name|command)(?:=(.*))?$/) - if (!m) continue - const value = m[2] - if (value === undefined) continue - if (m[1] === 'client') value.split(',').filter(Boolean).forEach((v) => client.push(v.trim())) - if (m[1] === 'name') name.push(value) - if (m[1] === 'command') command.push(value) - } - return { - client: client.length > 0 ? client : undefined, - name: name.length > 0 ? name : undefined, - command: command.length > 0 ? command : undefined, - dryRun, - } -} - -function buildEntryFromFlags(flags: ParsedFlags): McpServerEntry { - const command = flags.command?.[0] ?? defaultCommand() - return { command, args: [] } +function entryForCli(flags: ParsedFlags): McpServerEntry { + return buildEntryFromFlags(flags, { command: defaultCommand() }) } /** diff --git a/src/mcp/install/flags.ts b/src/mcp/install/flags.ts new file mode 100644 index 0000000..8e0ed7f --- /dev/null +++ b/src/mcp/install/flags.ts @@ -0,0 +1,121 @@ +/** + * Flag parser for `agentmark-mcp install / setup / uninstall`. + * + * Extracted into a standalone module so tests can exercise the + * parser without importing `cli.ts` (which has top-level + * side effects: it spawns the MCP server on import). + * + * Supported flags: + * --client= repeatable, comma-separable + * --name= entry name (default: agentmark) + * --command= command to register + * --env=KEY=VALUE env var for the registered server. Repeatable. + * --dry-run | -n no-op write + */ +import type { McpServerEntry } from './types' + +export interface ParsedFlags { + client: string[] | undefined + name: string[] | undefined + command: string[] | undefined + /** When supplied, the resolved env map. Empty object means + * `--env` was used but every value parsed empty (still valid). */ + env: Record | undefined + dryRun: boolean +} + +/** + * Env-var name shape. Restricted to identifier chars so a malformed + * `--env` can't be coerced into shell metacharacters that an MCP + * client's launcher might interpret. Mixed case allowed because some + * tools use them (e.g. `NodeEnv`). + */ +const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/ +const ENV_VALUE_MAX_LEN = 4 * 1024 + +export function parseFlags(args: string[], emit?: (line: string) => void): ParsedFlags { + const client: string[] = [] + const name: string[] = [] + const command: string[] = [] + const env: Record = {} + let envSeen = false + let dryRun = false + const warn = emit ?? ((line: string) => process.stderr.write(`${line}\n`)) + + for (const arg of args) { + if (arg === '--dry-run' || arg === '-n') { dryRun = true; continue } + const m = arg.match(/^--(client|name|command|env)(?:=(.*))?$/) + if (!m) continue + const value = m[2] + if (value === undefined) continue + if (m[1] === 'client') value.split(',').filter(Boolean).forEach((v) => client.push(v.trim())) + if (m[1] === 'name') name.push(value) + if (m[1] === 'command') command.push(value) + if (m[1] === 'env') { + const { key, value: envValue } = parseEnvFlag(value) + if (Object.prototype.hasOwnProperty.call(env, key)) { + warn(`warning: --env=${key}=… specified more than once; later value wins.`) + } + env[key] = envValue + envSeen = true + } + } + + return { + client: client.length > 0 ? client : undefined, + name: name.length > 0 ? name : undefined, + command: command.length > 0 ? command : undefined, + env: envSeen ? env : undefined, + dryRun, + } +} + +/** + * Parse one `KEY=value` pair. Throws on malformed input so a typo + * fails the install loudly instead of silently writing a broken + * config block to the user's AI client. + */ +export function parseEnvFlag(raw: string): { key: string; value: string } { + const idx = raw.indexOf('=') + if (idx < 1) { + throw new Error( + `--env must be in KEY=VALUE form (got "${raw}"). ` + + 'Quote the whole pair if the value contains spaces.', + ) + } + const key = raw.slice(0, idx) + const value = raw.slice(idx + 1) + if (!ENV_KEY_PATTERN.test(key)) { + throw new Error( + `--env key "${key}" is not a valid env-var name. ` + + 'Must match [A-Za-z_][A-Za-z0-9_]*.', + ) + } + if (value.length > ENV_VALUE_MAX_LEN) { + throw new Error( + `--env value for "${key}" exceeds ${ENV_VALUE_MAX_LEN} chars. ` + + 'Use a credential reference instead of inlining a long secret.', + ) + } + if (value.indexOf('\0') !== -1) { + throw new Error(`--env value for "${key}" contains a null byte; rejected.`) + } + return { key, value } +} + +/** + * Compose an {@link McpServerEntry} from parsed flags. `command` + * defaults to whatever the caller resolves; passing it in keeps + * this module pure (no PATH lookups, no fs). + */ +export function buildEntryFromFlags( + flags: ParsedFlags, + defaults: { command: string }, +): McpServerEntry { + const command = flags.command?.[0] ?? defaults.command + const entry: McpServerEntry = { command, args: [] } + if (flags.env && Object.keys(flags.env).length > 0) { + entry.env = flags.env + } + return entry +} diff --git a/test/mcp/install-flags.test.ts b/test/mcp/install-flags.test.ts new file mode 100644 index 0000000..bf09ecf --- /dev/null +++ b/test/mcp/install-flags.test.ts @@ -0,0 +1,165 @@ +/** + * Tests for the install-CLI flag parser. + * + * Focus on the new `--env=KEY=VALUE` surface introduced for the + * ThinkFleet Memory Bridge — it carries credentials into the AI + * client's MCP config, so malformed input must fail loud and the + * happy path must produce exactly the McpServerEntry shape the + * downstream writer expects. + */ +import { describe, it, expect } from 'vitest' +import { + parseFlags, + parseEnvFlag, + buildEntryFromFlags, +} from '../../src/mcp/install/flags' + +describe('parseEnvFlag', () => { + it('parses a simple KEY=VALUE pair', () => { + expect(parseEnvFlag('FOO=bar')).toEqual({ key: 'FOO', value: 'bar' }) + }) + + it('preserves "=" signs inside the value (e.g. base64)', () => { + expect(parseEnvFlag('TOKEN=abc=def==')).toEqual({ + key: 'TOKEN', + value: 'abc=def==', + }) + }) + + it('allows empty values', () => { + expect(parseEnvFlag('FLAG=')).toEqual({ key: 'FLAG', value: '' }) + }) + + it('rejects missing "=" entirely', () => { + expect(() => parseEnvFlag('NOEQUALS')).toThrowError(/KEY=VALUE form/) + }) + + it('rejects "=value" without a key', () => { + expect(() => parseEnvFlag('=oops')).toThrowError(/KEY=VALUE form/) + }) + + it('rejects keys with shell-relevant characters', () => { + expect(() => parseEnvFlag('FOO;rm=anything')).toThrowError(/not a valid env-var name/) + expect(() => parseEnvFlag('FOO BAR=x')).toThrowError(/not a valid env-var name/) + expect(() => parseEnvFlag('FOO`x`=y')).toThrowError(/not a valid env-var name/) + }) + + it('rejects keys starting with a digit', () => { + expect(() => parseEnvFlag('1FOO=bar')).toThrowError(/not a valid env-var name/) + }) + + it('accepts mixed-case keys (NodeEnv-style)', () => { + expect(parseEnvFlag('NodeEnv=production')).toEqual({ + key: 'NodeEnv', + value: 'production', + }) + }) + + it('rejects values containing a null byte', () => { + expect(() => parseEnvFlag('FOO=before\0after')).toThrowError(/null byte/) + }) + + it('rejects values over the 4KB cap', () => { + const big = 'x'.repeat(4 * 1024 + 1) + expect(() => parseEnvFlag(`FOO=${big}`)).toThrowError(/exceeds 4096/) + }) +}) + +describe('parseFlags — --env', () => { + it('returns env undefined when no --env was passed', () => { + const flags = parseFlags(['--client=cursor']) + expect(flags.env).toBeUndefined() + }) + + it('captures a single --env=KEY=VAL', () => { + const flags = parseFlags(['--env=THINKFLEET_API_KEY=sk-abc']) + expect(flags.env).toEqual({ THINKFLEET_API_KEY: 'sk-abc' }) + }) + + it('captures multiple --env flags in order', () => { + const flags = parseFlags([ + '--env=THINKFLEET_BASE_URL=https://app.thinkfleet.ai', + '--env=THINKFLEET_PROJECT_ID=proj_test', + '--env=THINKFLEET_API_KEY=sk-test', + ]) + expect(flags.env).toEqual({ + THINKFLEET_BASE_URL: 'https://app.thinkfleet.ai', + THINKFLEET_PROJECT_ID: 'proj_test', + THINKFLEET_API_KEY: 'sk-test', + }) + }) + + it('lets a later --env override an earlier one and warns on stderr', () => { + const warnings: string[] = [] + const flags = parseFlags( + ['--env=THINKFLEET_API_KEY=sk-old', '--env=THINKFLEET_API_KEY=sk-new'], + (l) => warnings.push(l), + ) + expect(flags.env).toEqual({ THINKFLEET_API_KEY: 'sk-new' }) + expect(warnings.length).toBe(1) + expect(warnings[0]).toContain('THINKFLEET_API_KEY') + expect(warnings[0]).toContain('more than once') + }) + + it('warning lines NEVER contain the env values (secret-safe)', () => { + const warnings: string[] = [] + parseFlags( + ['--env=APIKEY=sk-very-secret-original', '--env=APIKEY=sk-equally-secret-replacement'], + (l) => warnings.push(l), + ) + const joined = warnings.join('\n') + expect(joined).not.toContain('sk-very-secret-original') + expect(joined).not.toContain('sk-equally-secret-replacement') + }) + + it('coexists with the other flags', () => { + const flags = parseFlags([ + '--client=claude-code,cursor', + '--env=A=1', + '--name=thinkfleet', + '--dry-run', + ]) + expect(flags.client).toEqual(['claude-code', 'cursor']) + expect(flags.env).toEqual({ A: '1' }) + expect(flags.name).toEqual(['thinkfleet']) + expect(flags.dryRun).toBe(true) + }) + + it('throws (propagates parseEnvFlag error) on a malformed --env', () => { + expect(() => parseFlags(['--env=NOEQ'])).toThrowError(/KEY=VALUE form/) + }) + + it('returns env as an empty object when --env was used but only empty-value pairs were supplied', () => { + // Edge case: `--env=FLAG=` is legal and means "set FLAG to empty string". + const flags = parseFlags(['--env=FLAG=']) + expect(flags.env).toEqual({ FLAG: '' }) + }) +}) + +describe('buildEntryFromFlags', () => { + it('uses the default command when --command is absent', () => { + const flags = parseFlags([]) + const entry = buildEntryFromFlags(flags, { command: '/opt/agentmark/bin/agentmark-mcp' }) + expect(entry.command).toBe('/opt/agentmark/bin/agentmark-mcp') + expect(entry.args).toEqual([]) + expect(entry.env).toBeUndefined() + }) + + it('honors --command when supplied', () => { + const flags = parseFlags(['--command=/usr/local/bin/agentmark-mcp']) + const entry = buildEntryFromFlags(flags, { command: '/should/not/be/used' }) + expect(entry.command).toBe('/usr/local/bin/agentmark-mcp') + }) + + it('populates entry.env from parsed flags', () => { + const flags = parseFlags(['--env=A=1', '--env=B=2']) + const entry = buildEntryFromFlags(flags, { command: '/opt/agentmark/bin/agentmark-mcp' }) + expect(entry.env).toEqual({ A: '1', B: '2' }) + }) + + it('omits entry.env when no --env was supplied (clean JSON)', () => { + const entry = buildEntryFromFlags(parseFlags([]), { command: '/x' }) + // Strict undefined — the writer's JSON serializer will skip it. + expect(entry.env).toBeUndefined() + }) +}) diff --git a/test/mcp/install.test.ts b/test/mcp/install.test.ts index b2f4769..a1a3525 100644 --- a/test/mcp/install.test.ts +++ b/test/mcp/install.test.ts @@ -129,6 +129,37 @@ describe('Fake client end-to-end — apply + remove', () => { expect(written).toEqual({ mcpServers: { agentmark: ENTRY } }) }) + it('writes the env block when the entry carries env vars', async () => { + const client = fakeClient('test-a-env', 'a-env.json') + const envEntry: McpServerEntry = { + command: '/opt/thinkfleet/agentmark/bin/agentmark-mcp', + args: [], + env: { + THINKFLEET_BASE_URL: 'https://app.thinkfleet.ai', + THINKFLEET_PROJECT_ID: 'proj_test', + THINKFLEET_API_KEY: 'sk-test-token', + }, + } + await applyToFake(client, envEntry) + const written = JSON.parse(await readFile(client.configPath()!, 'utf8')) + expect(written.mcpServers.agentmark.env).toEqual(envEntry.env) + }) + + it('updates the env block when the install is re-run with new values', async () => { + const client = fakeClient('test-a-rotate', 'a-rotate.json') + await applyToFake(client, { + command: '/x', args: [], + env: { THINKFLEET_API_KEY: 'sk-old' }, + }) + const second = await applyToFake(client, { + command: '/x', args: [], + env: { THINKFLEET_API_KEY: 'sk-new' }, + }) + expect(second.action).toBe('updated') + const written = JSON.parse(await readFile(client.configPath()!, 'utf8')) + expect(written.mcpServers.agentmark.env.THINKFLEET_API_KEY).toBe('sk-new') + }) + it('preserves unrelated keys (mcp + other) in the config', async () => { const client = fakeClient('test-b', 'b.json') await writeJson(client.configPath()!, {