Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 23 additions & 34 deletions src/mcp/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -47,7 +52,22 @@ OPTIONS (install / setup / uninstall)
Known: ${allClients().map((c) => c.id).join(', ')}
--name=<name> Entry name to register under (default: agentmark)
--command=<path> 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<number> {
Expand Down Expand Up @@ -80,7 +100,7 @@ async function main(argv: string[]): Promise<number> {

async function runInstall(args: string[]): Promise<number> {
const flags = parseFlags(args)
const entry = buildEntryFromFlags(flags)
const entry = entryForCli(flags)
const result = await installToClients({
clientIds: flags.client,
entry,
Expand Down Expand Up @@ -115,39 +135,8 @@ async function runDoctor(): Promise<number> {
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() })
}

/**
Expand Down
121 changes: 121 additions & 0 deletions src/mcp/install/flags.ts
Original file line number Diff line number Diff line change
@@ -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=<id> repeatable, comma-separable
* --name=<name> entry name (default: agentmark)
* --command=<path> 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<string, string> | 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<string, string> = {}
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
}
165 changes: 165 additions & 0 deletions test/mcp/install-flags.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
Loading