Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- `mcpc @<session> logs` command to show or follow the bridge log file. Supports `-n/--tail <n>` (default 50), `--follow` to stream new lines, and `--since <duration|iso>` (e.g. `1h`, `30m`, `2026-04-28T12:00:00Z`). Transparently spans rotated files (`.log.1` … `.log.5`) when more lines are needed. With `--json`, returns parsed `{ ts, level, context, message }` records (or `{ ts: null, raw }` for unparseable lines); combined with `--follow`, output is NDJSON. `mcpc @<session> --json` now also exposes `_mcpc.logPath` and `_mcpc.logSize`. Error messages that previously pointed users to a raw log file path now suggest `mcpc @<session> logs` instead (#205).
- New `npm run test:conformance` script (and on-demand `Conformance` GitHub Actions workflow) that runs the `@modelcontextprotocol/conformance` framework against mcpc to verify adherence to the MCP specification. The conformance adapter now covers the `initialize`, `tools_call`, and `sse-retry` client scenarios and exercises a broader set of mcpc sub-commands (`tools-list`, `tools-get`, `tools-call` with and without `--task`, `ping`, `logging-set-level`, `resources-list`, `resources-templates-list`, `prompts-list`) against the conformance test server.
- `mcpc connect` (with no arguments) now auto-discovers standard MCP config files (`.mcp.json`, `mcp.json`, `mcp_config.json`, `.cursor/mcp.json`, `.vscode/mcp.json`, `.kiro/settings/mcp.json`, `~/.claude.json`, `~/.codeium/windsurf/mcp_config.json`, VS Code app config, Claude Desktop config, etc.) in the current directory and home directory, and connects every server defined across them. Entries with duplicate session names across files are deduplicated (project-scoped files win over global ones). Config files using VS Code's `"servers"` key (instead of `"mcpServers"`) are also supported.
- `mcpc connect` auto-connects to `mcp.apify.com` as `@apify` when the `APIFY_API_TOKEN` environment variable is set, using it as a Bearer token. Existing live sessions are reused without restart.
Expand Down
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,57 @@ MCP session commands (after connecting):
<@session> tasks-cancel <taskId>
<@session> logging-set-level <level>
<@session> ping
<@session> logs [-n N] [--follow] [--since 1h]

Run "mcpc" without arguments to show active sessions and OAuth profiles.
Run "mcpc --json" to get the same data as `{ sessions: [...], profiles: [...] }`.
```
Usage: mcpc [<@session>] [<command>] [options]

Universal command-line client for the Model Context Protocol (MCP).

Commands:
connect <server> [@session] Connect to an MCP server and start a named @session
close <@session> Close a session
restart <@session> Restart a session (losing all state)
shell <@session> Open interactive shell for a session
login <server> Interactively login to a server using OAuth and save profile
logout <server> Delete an OAuth profile for a server
clean [resources...] Clean up mcpc data (sessions, profiles, logs, all)
grep <pattern> Search tools and instructions across all active sessions
x402 [subcommand] [args...] Configure an x402 payment wallet (EXPERIMENTAL)
help [command] [subcommand] Show help for a specific command

Options:
--json Output in JSON format for scripting
--verbose Enable debug logging
--profile <name> OAuth profile for the server ("default" if not provided)
--timeout <seconds> Request timeout in seconds (default: 300)
--max-chars <n> Truncate output to n characters (ignored in --json mode)
--insecure Skip TLS certificate verification (for self-signed certs)
-v, --version Output the version number
-h, --help Display help

MCP session commands (after connecting):
<@session> Show MCP server info, capabilities, and tools overview
<@session> grep <pattern> Search tools and instructions
<@session> tools-list List all server tools
<@session> tools-get <name> Get tool details and schema
<@session> tools-call <name> [arg:=val ... | <json> | <stdin]
<@session> prompts-list
<@session> prompts-get <name> [arg:=val ... | <json> | <stdin]
<@session> resources-list
<@session> resources-read <uri>
<@session> resources-subscribe <uri>
<@session> resources-unsubscribe <uri>
<@session> resources-templates-list
<@session> tasks-list
<@session> tasks-get <taskId>
<@session> tasks-result <taskId>
<@session> tasks-cancel <taskId>
<@session> logging-set-level <level>
<@session> ping
<@session> logs [-n N] [--follow] [--since 1h]

Run "mcpc" without arguments to show active sessions and OAuth profiles.
Run "mcpc --json" to get the same data as `{ sessions: [...], profiles: [...] }`.
Expand Down
203 changes: 203 additions & 0 deletions src/cli/commands/logs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
/**
* `mcpc @<session> logs` — show or follow bridge log files.
*/

import { stat } from 'fs/promises';
import chalk from 'chalk';
import { ClientError } from '../../lib/errors.js';
import { getSession } from '../../lib/sessions.js';
import {
followLog,
getBridgeLogPath,
listLogFiles,
parseLogLine,
readRecentLogLines,
resolveSince,
type LogRecord,
} from '../../lib/log-reader.js';
import { formatJson } from '../output.js';
import type { CommandOptions } from '../../lib/types.js';

const DEFAULT_TAIL = 50;

export interface LogsCommandOptions extends CommandOptions {
tail?: number;
follow?: boolean;
since?: string;
}

/**
* Implementation of `mcpc @<session> logs`.
*
* `target` is the session name including the leading "@" (e.g. "@apify").
*/
export async function showLogs(target: string, options: LogsCommandOptions): Promise<void> {
if (!target.startsWith('@')) {
throw new ClientError(
`logs requires a session target (e.g. mcpc @<session> logs). Got: ${target}`
);
}

const session = await getSession(target);
if (!session) {
throw new ClientError(
`Session not found: ${target}\n\n` +
`List sessions with: mcpc\nCreate one with: mcpc connect <server> ${target}`
);
}

const logPath = getBridgeLogPath(target);
const files = await listLogFiles(target);

let since: Date | undefined;
if (options.since) {
const resolved = resolveSince(options.since);
if (!resolved) {
throw new ClientError(
`Invalid --since value: "${options.since}". ` +
`Use a duration (e.g. 30s, 5m, 2h, 1d, 1w) or an ISO 8601 timestamp.`
);
}
since = resolved;
}

// Default tail: 50 in non-follow mode, also used as the backlog size when --follow is set.
const tail = options.tail ?? DEFAULT_TAIL;

const emitOpts: EmitOpts = {
tail,
...(since && { since }),
...(options.follow && { follow: true }),
};

if (options.outputMode === 'json') {
await emitJson(target, logPath, files, emitOpts);
return;
}

await emitHuman(target, logPath, files, emitOpts);
}

interface EmitOpts {
tail: number;
since?: Date;
follow?: boolean;
}

async function emitHuman(
sessionName: string,
logPath: string,
files: string[],
opts: EmitOpts
): Promise<void> {
const header = await buildHeader(sessionName, logPath, files, opts);
for (const line of header) {
console.error(line);
}

const backlog = await readRecentLogLines(sessionName, {
tail: opts.tail,
...(opts.since && { since: opts.since }),
});
for (const line of backlog) {
console.log(line);
}

if (!opts.follow) {
return;
}

await new Promise<void>((resolve) => {
const sub = followLog(sessionName, (line) => {
console.log(line);
});
const onSignal = (): void => {
void sub.stop().finally(resolve);
};
process.once('SIGINT', onSignal);
process.once('SIGTERM', onSignal);
});
}

async function emitJson(
sessionName: string,
logPath: string,
files: string[],
opts: EmitOpts
): Promise<void> {
const backlog = await readRecentLogLines(sessionName, {
tail: opts.tail,
...(opts.since && { since: opts.since }),
});
const records = backlog.map(parseLogLine);

if (!opts.follow) {
console.log(formatJson(records));
return;
}

// Streaming mode: emit NDJSON (one record per line). A JSON array can't be streamed.
for (const rec of records) {
process.stdout.write(JSON.stringify(rec) + '\n');
}

await new Promise<void>((resolve) => {
const sub = followLog(sessionName, (line) => {
const rec: LogRecord = parseLogLine(line);
process.stdout.write(JSON.stringify(rec) + '\n');
});
const onSignal = (): void => {
void sub.stop().finally(resolve);
};
process.once('SIGINT', onSignal);
process.once('SIGTERM', onSignal);
});

// Suppress unused-parameter warnings for parameters kept for symmetry with emitHuman.
void logPath;
void files;
}

async function buildHeader(
sessionName: string,
logPath: string,
files: string[],
opts: EmitOpts
): Promise<string[]> {
const lines: string[] = [];
let size = 0;
let exists = false;
try {
const st = await stat(logPath);
size = st.size;
exists = true;
} catch {
// file doesn't exist yet
}

const fileCount = files.length;
const sizeStr = formatBytes(size);
const tailLabel = opts.follow
? `following (backlog ${opts.tail} lines)`
: opts.since
? `since ${opts.since.toISOString()}, last ${opts.tail} lines`
: `last ${opts.tail} lines`;

lines.push(chalk.dim(`Session ${sessionName} · ${logPath} · ${tailLabel}`));
if (fileCount > 1) {
lines.push(chalk.dim(` ${fileCount} files (current + ${fileCount - 1} rotated), ${sizeStr}`));
} else if (exists) {
lines.push(chalk.dim(` ${sizeStr}`));
} else {
lines.push(chalk.dim(` no logs yet for ${sessionName}`));
}
lines.push('');
return lines;
}

function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
18 changes: 18 additions & 0 deletions src/cli/commands/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/

import { createServer } from 'net';
import { stat } from 'fs/promises';
import {
OutputMode,
isValidSessionName,
Expand Down Expand Up @@ -759,13 +760,30 @@ export async function showServerDetails(
}),
};

// Bridge log path/size are useful debug context for callers — only meaningful
// for session targets (those starting with "@"); ad-hoc URL/config targets
// have no persistent bridge log.
let logPath: string | undefined;
let logSize: number | undefined;
if (target.startsWith('@')) {
logPath = `${getLogsDir()}/bridge-${target}.log`;
try {
const st = await stat(logPath);
logSize = st.size;
} catch {
// log file doesn't exist yet — leave logSize undefined
}
}

console.log(
formatOutput(
{
_mcpc: {
sessionName: context.sessionName,
profileName: context.profileName,
server,
...(logPath && { logPath }),
...(logSize !== undefined && { logSize }),
},
protocolVersion,
capabilities,
Expand Down
46 changes: 46 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import * as prompts from './commands/prompts.js';
import * as sessions from './commands/sessions.js';
import * as logging from './commands/logging.js';
import * as utilities from './commands/utilities.js';
import * as logs from './commands/logs.js';
import * as auth from './commands/auth.js';
import * as tasks from './commands/tasks.js';
import * as grepCmd from './commands/grep.js';
Expand Down Expand Up @@ -425,6 +426,7 @@ ${chalk.bold('MCP session commands (after connecting):')}
<@session> ${chalk.cyan('tasks-cancel')} <taskId>
<@session> ${chalk.cyan('logging-set-level')} <level>
<@session> ${chalk.cyan('ping')}
<@session> ${chalk.cyan('logs')} [-n N] [--follow] [--since 1h]

Run "mcpc" without arguments to show active sessions and OAuth profiles.
Run "mcpc --json" to get the same data as \`{ sessions: [...], profiles: [...] }\`.
Expand Down Expand Up @@ -1243,6 +1245,50 @@ ${jsonHelp('`GetPromptResult` object', '`{ description?, messages: [{ role, cont
.action(async (_options, command) => {
await utilities.ping(session, getOptionsFromCommand(command));
});

// Logs command
program
.command('logs')
.description('Show or follow the bridge log file for this session.')
.option('-n, --tail <n>', 'Number of recent lines to show (default: 50)')
.option('--follow', 'Stream new log lines as they are written')
.option(
'--since <value>',
'Only show entries newer than a duration (30s, 5m, 2h, 1d) or ISO timestamp'
)
.addHelpText(
'after',
`
${chalk.bold('Examples:')}
mcpc ${session} logs Last 50 lines
mcpc ${session} logs -n 200 Last 200 lines
mcpc ${session} logs --follow Stream new lines (Ctrl+C to stop)
mcpc ${session} logs --since 1h Lines from the last hour
mcpc ${session} logs --since 30m -n 50

${chalk.bold('Notes:')}
Reads ~/.mcpc/logs/bridge-${session}.log and transparently spans
rotated files (.log.1 … .log.5) when -n or --since needs older lines.
With --follow, output is NDJSON (one record per line) instead of a JSON array.
${jsonHelp(
'Array of `LogRecord` objects',
'`[{ ts: string|null, level: string|null, context: string|null, message?: string, raw?: string }, ...]`'
)}`
)
.action(async (opts, command) => {
const tail = opts.tail !== undefined ? parseInt(opts.tail as string, 10) : undefined;
if (tail !== undefined && (isNaN(tail) || tail < 0)) {
throw new ClientError(
`Invalid --tail value: "${opts.tail as string}". Must be a non-negative integer.`
);
}
await logs.showLogs(session, {
...getOptionsFromCommand(command),
...(tail !== undefined && { tail }),
...(opts.follow && { follow: true as boolean }),
...(opts.since && { since: opts.since as string }),
});
});
}

/**
Expand Down
8 changes: 6 additions & 2 deletions src/cli/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1463,15 +1463,19 @@ export function formatServerDetails(
commands.push(`${bullet} ${bt}mcpc ${target} logging-set-level <lvl>${bt}`);
}

if (target.startsWith('@')) {
commands.push(`${bullet} ${bt}mcpc ${target} logs${bt}`);
}
commands.push(`${bullet} ${bt}mcpc ${target} shell${bt}`);

lines.push(commands.join('\n'));
lines.push('');

// Debugging hint: bridge log file path (only shown for sessions, i.e. @name targets)
// Debugging hint: how to view logs (only shown for sessions, i.e. @name targets)
if (target.startsWith('@')) {
const logPath = join(getLogsDir(), `bridge-${target}.log`);
lines.push(chalk.dim(`Session log for debugging: ${logPath}`));
lines.push(chalk.dim(`For session logs, run: mcpc ${target} logs`));
lines.push(chalk.dim(`Log file: ${logPath}`));
lines.push('');
}

Expand Down
Loading
Loading