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
108 changes: 73 additions & 35 deletions src/mcp/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ import {
allClients,
type McpServerEntry,
} from './install'
import {
parseFlags,
buildEntryFromFlags,
type ParsedFlags,
} from './install/flags'
import { installSkill, type SkillInstallResult } from './install/skills'
import { getSkillContent } from './skills/thinkfleet-memory'

const HELP = `agentmark-mcp — Model Context Protocol server for AgentMark

Expand All @@ -47,7 +54,27 @@ 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.
--skill=<name> Also install a skill that teaches the agent when /
how to use the tools you just wired. Repeatable.
Known: thinkfleet-memory. Native-skill clients get a
skill.md file; rules-file clients get a marker block.
--dry-run Show what would change without writing

EXAMPLE — wire Claude Code to ThinkFleet memory + install skill:
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 \\
--skill=thinkfleet-memory
`

async function main(argv: string[]): Promise<number> {
Expand Down Expand Up @@ -80,15 +107,42 @@ 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,
entryName: flags.name?.[0],
dryRun: flags.dryRun,
})
printInstallResult(result, flags.dryRun ? 'dry-run' : 'install')
return result.clients.every((r) => r.action !== 'error') ? 0 : 1

// After the MCP entry is wired, optionally install skill files
// that teach the agent when/how to use those tools. Opt-in via
// `--skill=<name>` so callers who only want the MCP wiring
// (and not opinions injected into their agent prompts) can
// still install just the server entry.
let skillsOk = true
if (flags.skill && flags.skill.length > 0) {
for (const name of flags.skill) {
const skill = getSkillContent(name)
if (!skill) {
process.stderr.write(`Unknown skill: ${name}. Skipping.\n`)
skillsOk = false
continue
}
const skillResult = await installSkill({
skillName: name,
content: skill.content,
clientIds: flags.client,
dryRun: flags.dryRun,
})
printSkillResult(name, skillResult, flags.dryRun ? 'dry-run' : 'install')
if (!skillResult.ok) skillsOk = false
}
}

const installOk = result.clients.every((r) => r.action !== 'error')
return installOk && skillsOk ? 0 : 1
}

async function runUninstall(args: string[]): Promise<number> {
Expand All @@ -115,39 +169,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 Expand Up @@ -194,6 +217,21 @@ function pkgVersion(): string {
}
}

function printSkillResult(skillName: string, result: SkillInstallResult, label: string): void {
process.stdout.write(`agentmark-mcp ${label} — skill "${skillName}":\n\n`)
for (const c of result.clients) {
const flag =
c.action === 'added' || c.action === 'updated' ? '✓'
: c.action === 'already_present' ? '·'
: c.action === 'skipped' ? '⏭'
: '✗'
const padded = `${c.clientName} [${c.action}]`.padEnd(40)
process.stdout.write(` ${flag} ${padded} ${c.path}\n`)
if (c.message) process.stdout.write(` ${c.message}\n`)
}
process.stdout.write('\n')
}

function printInstallResult(result: { clients: Array<{ id: string; name: string; path: string; action: string; message?: string }> }, label: string): void {
process.stdout.write(`agentmark-mcp ${label} — results:\n\n`)
for (const c of result.clients) {
Expand Down
139 changes: 139 additions & 0 deletions src/mcp/install/flags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/**
* 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
/** Skill names requested via `--skill=<name>`. Deduplicated. */
skill: 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

/** Skill names — same shape as env-var keys, restricted to a sane
* set so a malformed value can't be interpreted as a path on disk. */
const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*$/

export function parseFlags(args: string[], emit?: (line: string) => void): ParsedFlags {
const client: string[] = []
const name: string[] = []
const command: string[] = []
const env: Record<string, string> = {}
const skills = new Set<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|skill)(?:=(.*))?$/)
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
}
if (m[1] === 'skill') {
const skillName = value.trim()
if (!SKILL_NAME_PATTERN.test(skillName)) {
throw new Error(
`--skill name "${skillName}" is invalid. Must match `
+ '[a-z0-9][a-z0-9-]* (lowercase letters / digits / hyphens).',
)
}
skills.add(skillName)
}
}

return {
client: client.length > 0 ? client : undefined,
name: name.length > 0 ? name : undefined,
command: command.length > 0 ? command : undefined,
env: envSeen ? env : undefined,
skill: skills.size > 0 ? Array.from(skills) : 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
}
Loading
Loading