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
102 changes: 102 additions & 0 deletions cli/__tests__/mcp-config.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { jest } from '@jest/globals';
import fs from 'fs';
import os from 'os';
import path from 'path';

const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-config-'));
jest.unstable_mockModule('os', () => ({ ...os, default: { ...os, homedir: () => tmp }, homedir: () => tmp }));

const { buildMcpConfig } = await import('../src/commands/mcp.js');
const { performDetach, saveAgentToken } = await import('../src/commands/agent.js');

const TOKEN = 'cm_agent_secretvalue123';
const tokensDir = path.join(tmp, '.commonly', 'tokens');

const seed = (name, extra = {}) => {
fs.mkdirSync(tokensDir, { recursive: true });
fs.writeFileSync(path.join(tokensDir, `${name}.json`),
JSON.stringify({ runtimeToken: TOKEN, instanceUrl: 'https://api.example.test', podId: 'p1', ...extra }));
};

beforeEach(() => fs.rmSync(path.join(tmp, '.commonly'), { recursive: true, force: true }));

describe('commonly mcp config', () => {
it('points at a 0600 token file and never prints the token', () => {
seed('lead');
const config = buildMcpConfig({});
const env = config.mcpServers.commonly.env;
expect(JSON.stringify(config)).not.toContain(TOKEN);
expect(env.COMMONLY_API_URL).toBe('https://api.example.test');
expect(fs.readFileSync(env.COMMONLY_TOKEN_FILE, 'utf8').trim()).toBe(TOKEN);
if (process.platform !== 'win32') expect(fs.statSync(env.COMMONLY_TOKEN_FILE).mode & 0o777).toBe(0o600);
});

it('keeps 0600 when the file already exists with a looser mode', () => {
seed('lead');
const first = buildMcpConfig({}).mcpServers.commonly.env.COMMONLY_TOKEN_FILE;
fs.chmodSync(first, 0o644);
buildMcpConfig({ agent: 'lead' });
if (process.platform !== 'win32') expect(fs.statSync(first).mode & 0o777).toBe(0o600);
});

it('does not write through a symlink at the token path', () => {
seed('lead');
const dir = path.join(tmp, '.commonly', 'mcp');
fs.mkdirSync(dir, { recursive: true });
const victim = path.join(tmp, 'victim');
fs.writeFileSync(victim, 'keep');
fs.symlinkSync(victim, path.join(dir, 'lead.token'));
buildMcpConfig({ agent: 'lead' });
expect(fs.readFileSync(victim, 'utf8')).toBe('keep');
});

it('tightens a pre-existing loose mcp dir and refuses a non-agent token', () => {
seed('lead');
const dir = path.join(tmp, '.commonly', 'mcp');
fs.mkdirSync(dir, { recursive: true, mode: 0o755 });
buildMcpConfig({ agent: 'lead' });
if (process.platform !== 'win32') expect(fs.statSync(dir).mode & 0o777).toBe(0o700);
seed('user', { runtimeToken: 'eyJuser.jwt' });
expect(() => buildMcpConfig({ agent: 'user' })).toThrow(/cm_agent_/);
});

it('detach (even --force, local only) deletes the MCP copy', async () => {
seed('lead');
const copy = buildMcpConfig({}).mcpServers.commonly.env.COMMONLY_TOKEN_FILE;
await performDetach({ agentName: 'lead', skipBackend: true });
expect(fs.existsSync(copy)).toBe(false);
});

it('reattach (saveAgentToken) rewrites an existing MCP copy, and creates none otherwise', () => {
seed('lead');
saveAgentToken('lead', { runtimeToken: 'cm_agent_before', instanceUrl: 'https://api.example.test' });
expect(fs.existsSync(path.join(tmp, '.commonly', 'mcp', 'lead.token'))).toBe(false);
const copy = buildMcpConfig({}).mcpServers.commonly.env.COMMONLY_TOKEN_FILE;
saveAgentToken('lead', { runtimeToken: 'cm_agent_after', instanceUrl: 'https://api.example.test' });
expect(fs.readFileSync(copy, 'utf8').trim()).toBe('cm_agent_after');
if (process.platform !== 'win32') expect(fs.statSync(copy).mode & 0o777).toBe(0o600);
});

it('a traversal name cannot make remove or refresh touch anything, and does not block detach', async () => {
const outside = path.join(tmp, '.commonly', 'x.token');
fs.mkdirSync(path.dirname(outside), { recursive: true });
fs.writeFileSync(outside, 'keep');
saveAgentToken('../x', { runtimeToken: 'cm_agent_t', instanceUrl: 'u' });
await performDetach({ agentName: '../x', skipBackend: true });
expect(fs.readFileSync(outside, 'utf8')).toBe('keep');
});

it('names the attach command when no agent exists', () => {
expect(() => buildMcpConfig({})).toThrow(/commonly agent attach/);
});

it('asks for --agent when several are attached', () => {
seed('a'); seed('b');
expect(() => buildMcpConfig({})).toThrow(/--agent/);
expect(buildMcpConfig({ agent: 'b' }).mcpServers.commonly.env.COMMONLY_TOKEN_FILE).toMatch(/b\.token$/);
});

it('rejects a path-like agent name', () => {
expect(() => buildMcpConfig({ agent: '../x' })).toThrow(/Invalid agent name/);
});
});
8 changes: 8 additions & 0 deletions cli/src/commands/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { createRuntimeReporter } from '../lib/runtime-observation.js';
* heartbeat — manually trigger an agent's heartbeat
*/

import { refreshMcpTokenCopy, removeMcpTokenCopy } from '../lib/mcp-token.js';
import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync, readdirSync } from 'fs';
import { join, dirname, basename, resolve as pathResolve } from 'path';
import { homedir, tmpdir } from 'os';
Expand Down Expand Up @@ -147,6 +148,12 @@ export const saveAgentToken = (name, record) => {
JSON.stringify({ ...record, savedAt: new Date().toISOString() }, null, 2),
{ encoding: 'utf8', mode: 0o600 },
);
// After the record is durable, so a failed refresh never loses a freshly minted token.
try {
refreshMcpTokenCopy(name, record?.runtimeToken);
} catch (err) {
console.warn(`Could not refresh the MCP token copy for '${name}': ${err.message}. Run: commonly mcp config --agent ${name}`);
}
};

export const loadAgentToken = (name) => {
Expand All @@ -162,6 +169,7 @@ export const loadAgentToken = (name) => {
export const deleteAgentToken = (name) => {
const file = tokenFile(name);
if (existsSync(file)) rmSync(file);
removeMcpTokenCopy(name);
};

// ── env-var bootstrap for `agent run` (#913) ────────────────────────────────
Expand Down
55 changes: 55 additions & 0 deletions cli/src/commands/mcp.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* commonly mcp config — print an MCP server config for a locally attached agent (TASK-666).
* The MCP server only accepts a cm_agent_* runtime token, so the config points
* COMMONLY_TOKEN_FILE at a 0600 copy of that token and never prints the token.
*/
import { listLocalAgents, loadAgentToken } from './agent.js';
import { validMcpTokenName, writeMcpTokenCopy } from '../lib/mcp-token.js';

const NO_AGENT_HINT = 'Attach one first: commonly agent attach <adapter> --pod <podId> --name <name>';

export const buildMcpConfig = ({ agent } = {}) => {
let name = agent;
if (!name) {
const local = listLocalAgents();
if (local.length === 0) throw new Error(`No local agent has a runtime token on this computer. ${NO_AGENT_HINT}`);
if (local.length > 1) {
throw new Error(`More than one local agent (${local.map((a) => a.name).join(', ')}). Pick one with --agent <name>.`);
}
name = local[0].name;
}
if (!validMcpTokenName(name)) throw new Error(`Invalid agent name '${name}'`);
const record = loadAgentToken(name);
const token = String(record?.runtimeToken || '').trim();
if (!token) throw new Error(`No runtime token saved for agent '${name}'. ${NO_AGENT_HINT}`);
if (!token.startsWith('cm_agent_')) throw new Error(`The saved token for '${name}' is not a cm_agent_* runtime token. Re-attach it: commonly agent attach`);
if (!record.instanceUrl) throw new Error(`Agent '${name}' has no saved instance URL. Re-attach it.`);

const tokenPath = writeMcpTokenCopy(name, token);

return {
mcpServers: {
commonly: {
command: 'npx',
args: ['-y', '@commonlyai/mcp@latest'],
env: { COMMONLY_API_URL: record.instanceUrl, COMMONLY_TOKEN_FILE: tokenPath },
},
},
};
};

export const registerMcp = (program) => {
const mcp = program.command('mcp').description('Connect an MCP client (Claude Code, Codex, Cursor) to Commonly');
mcp
.command('config')
.description('Print an MCP server config for a locally attached agent; the token stays in a 0600 file')
.option('--agent <name>', 'Local agent to use (default: the only one)')
.action((opts) => {
try {
console.log(JSON.stringify(buildMcpConfig({ agent: opts.agent }), null, 2));
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
});
};
2 changes: 2 additions & 0 deletions cli/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { registerPod } from './commands/pod.js';
import { registerDev } from './commands/dev.js';
import { registerVerify } from './commands/verify.js';
import { registerDoctor } from './commands/doctor.js';
import { registerMcp } from './commands/mcp.js';

const __dirname = dirname(fileURLToPath(import.meta.url));
const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf8'));
Expand Down Expand Up @@ -57,6 +58,7 @@ registerVerify(program);

// Install health: one line per check (TASK-660)
registerDoctor(program);
registerMcp(program);

// Each subcommand owns its own `--instance <url>` flag; a program-level
// duplicate shadowed the subcommand value on commander v12, causing
Expand Down
36 changes: 36 additions & 0 deletions cli/src/lib/mcp-token.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* The 0600 copy of an agent's runtime token that `commonly mcp config` points
* COMMONLY_TOKEN_FILE at (TASK-666). It is a second live secret, so the agent
* token's lifecycle owns it: save refreshes it, delete removes it.
*/
import { chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'fs';
import { homedir } from 'os';
import { join } from 'path';

export const validMcpTokenName = (name) => typeof name === 'string' && /^[A-Za-z0-9._-]+$/.test(name) && !name.startsWith('.');

export const mcpTokenPath = (name) => {
if (!validMcpTokenName(name)) throw new Error(`Invalid agent name '${name}'`);
return join(homedir(), '.commonly', 'mcp', `${name}.token`);
};

// Remove first, then create exclusively at 0600: no write into a looser-mode file or through a symlink.
export const writeMcpTokenCopy = (name, token) => {
const dir = join(homedir(), '.commonly', 'mcp');
mkdirSync(dir, { recursive: true, mode: 0o700 });
chmodSync(dir, 0o700);
const path = mcpTokenPath(name);
rmSync(path, { force: true });
writeFileSync(path, `${token}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
return path;
};

// A bad name never has a copy, so remove and refresh are no-ops for it and cannot block detach or save.
export const removeMcpTokenCopy = (name) => {
if (validMcpTokenName(name)) rmSync(mcpTokenPath(name), { force: true });
};

// Reattach mints a new token; a copy that already exists must follow it.
export const refreshMcpTokenCopy = (name, token) => {
if (token && validMcpTokenName(name) && existsSync(mcpTokenPath(name))) writeMcpTokenCopy(name, token);
};
7 changes: 7 additions & 0 deletions commonly-mcp/__tests__/client.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ describe('loadConfig', () => {
COMMONLY_AGENT_TOKEN: 'sk-not-an-agent-token',
})).toThrow(/cm_agent_/);
});

it('names the command that fixes a user-token refusal', () => {
expect(() => loadConfig({
COMMONLY_API_URL: 'https://x.example',
COMMONLY_AGENT_TOKEN: 'eyJuser.jwt',
})).toThrow(/commonly mcp config/);
});
});

describe('request', () => {
Expand Down
2 changes: 1 addition & 1 deletion commonly-mcp/src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export const loadConfig = (env = process.env, opts = {}) => {
throw new Error('No runtime token: COMMONLY_TOKEN_FD must name a pipe, COMMONLY_TOKEN_FILE must name a file, or COMMONLY_AGENT_TOKEN must be set (cm_agent_* runtime token)');
}
if (!token.startsWith('cm_agent_')) {
throw new Error('The runtime token must be a cm_agent_* token, whichever channel delivered it');
throw new Error('The runtime token must be a cm_agent_* token, whichever channel delivered it (a user login token is refused). Run `commonly mcp config` to get a working config for an attached agent, or `commonly agent attach` first');
}
return Object.freeze({
baseUrl: baseUrl.replace(/\/$/, ''),
Expand Down
Loading