@evantahler/mcpcli 0.3.4 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evantahler/mcpcli",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "A command-line interface for MCP servers. curl for MCP.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,6 +2,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
2
2
  import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
3
3
  import { dim } from "ansis";
4
4
  import type { HttpServerConfig } from "../config/schemas.ts";
5
+ import { logger } from "../output/logger.ts";
5
6
 
6
7
  type FetchLike = (url: string | URL, init?: RequestInit) => Promise<Response>;
7
8
 
@@ -51,7 +52,7 @@ function createDebugFetch(showSecrets: boolean): FetchLike {
51
52
  }
52
53
 
53
54
  function log(line: string) {
54
- process.stderr.write(line + "\n");
55
+ logger.writeRaw(line + "\n");
55
56
  }
56
57
 
57
58
  function logHeaders(
@@ -13,7 +13,7 @@ import type {
13
13
  import type { AuthFile } from "../config/schemas.ts";
14
14
  import { saveAuth } from "../config/loader.ts";
15
15
  import type { FormatOptions } from "../output/formatter.ts";
16
- import { startSpinner } from "../output/spinner.ts";
16
+ import { logger } from "../output/logger.ts";
17
17
 
18
18
  export class McpOAuthProvider implements OAuthClientProvider {
19
19
  private serverName: string;
@@ -83,10 +83,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
83
83
  async redirectToAuthorization(url: URL): Promise<void> {
84
84
  const urlStr = url.toString();
85
85
 
86
- if (process.stderr.isTTY) {
87
- const { dim } = await import("ansis");
88
- process.stderr.write(`${dim(urlStr)}\n`);
89
- }
86
+ logger.info(urlStr);
90
87
 
91
88
  const cmd =
92
89
  process.platform === "darwin"
@@ -195,6 +192,8 @@ export class McpOAuthProvider implements OAuthClientProvider {
195
192
  });
196
193
 
197
194
  await this.saveTokens(tokens);
195
+
196
+ logger.info(`Token refreshed for "${this.serverName}"`);
198
197
  }
199
198
  }
200
199
 
@@ -269,7 +268,7 @@ export async function tryOAuthIfSupported(
269
268
  if (!oauthSupported) return false;
270
269
 
271
270
  const provider = new McpOAuthProvider({ serverName, configDir, auth });
272
- const spinner = startSpinner(`Authenticating with "${serverName}"…`, formatOptions);
271
+ const spinner = logger.startSpinner(`Authenticating with "${serverName}"…`, formatOptions);
273
272
  try {
274
273
  await runOAuthFlow(serverUrl, provider);
275
274
  spinner.success(`Authenticated with "${serverName}"`);
@@ -3,7 +3,7 @@ import { getContext } from "../context.ts";
3
3
  import { isHttpServer } from "../config/schemas.ts";
4
4
  import { saveAuth } from "../config/loader.ts";
5
5
  import { McpOAuthProvider, runOAuthFlow } from "../client/oauth.ts";
6
- import { startSpinner } from "../output/spinner.ts";
6
+ import { logger } from "../output/logger.ts";
7
7
  import { runIndex } from "./index.ts";
8
8
 
9
9
  export function registerAuthCommand(program: Command) {
@@ -41,7 +41,7 @@ export function registerAuthCommand(program: Command) {
41
41
  }
42
42
 
43
43
  if (options.refresh) {
44
- const spinner = startSpinner(`Refreshing token for "${server}"…`, formatOptions);
44
+ const spinner = logger.startSpinner(`Refreshing token for "${server}"…`, formatOptions);
45
45
  try {
46
46
  await provider.refreshIfNeeded(serverConfig.url);
47
47
  spinner.success(`Token refreshed for "${server}"`);
@@ -53,7 +53,7 @@ export function registerAuthCommand(program: Command) {
53
53
  }
54
54
 
55
55
  // Default: full OAuth flow
56
- const spinner = startSpinner(`Authenticating with "${server}"…`, formatOptions);
56
+ const spinner = logger.startSpinner(`Authenticating with "${server}"…`, formatOptions);
57
57
  try {
58
58
  await runOAuthFlow(serverConfig.url, provider);
59
59
  spinner.success(`Authenticated with "${server}"`);
@@ -6,7 +6,7 @@ import {
6
6
  formatServerTools,
7
7
  formatValidationErrors,
8
8
  } from "../output/formatter.ts";
9
- import { startSpinner } from "../output/spinner.ts";
9
+ import { logger } from "../output/logger.ts";
10
10
  import { validateToolInput } from "../validation/schema.ts";
11
11
 
12
12
  export function registerCallCommand(program: Command) {
@@ -52,7 +52,7 @@ export function registerCallCommand(program: Command) {
52
52
  }
53
53
  }
54
54
 
55
- const spinner = startSpinner(`Calling ${server}/${tool}...`, formatOptions);
55
+ const spinner = logger.startSpinner(`Calling ${server}/${tool}...`, formatOptions);
56
56
  const result = await manager.callTool(server, tool, args);
57
57
  spinner.stop();
58
58
  console.log(formatCallResult(result, formatOptions));
@@ -1,16 +1,16 @@
1
1
  import type { Command } from "commander";
2
- import { dim, yellow } from "ansis";
2
+ import { yellow } from "ansis";
3
3
  import { getContext } from "../context.ts";
4
4
  import { buildSearchIndex } from "../search/indexer.ts";
5
5
  import { getStaleServers } from "../search/staleness.ts";
6
6
  import { saveSearchIndex } from "../config/loader.ts";
7
7
  import { formatError } from "../output/formatter.ts";
8
- import { startSpinner } from "../output/spinner.ts";
8
+ import { logger } from "../output/logger.ts";
9
9
 
10
10
  /** Run the search index build. Reusable from other commands (e.g. add). */
11
11
  export async function runIndex(program: Command): Promise<void> {
12
12
  const { config, manager, formatOptions } = await getContext(program);
13
- const spinner = startSpinner("Connecting to servers...", formatOptions);
13
+ const spinner = logger.startSpinner("Connecting to servers...", formatOptions);
14
14
 
15
15
  try {
16
16
  const start = performance.now();
@@ -22,9 +22,7 @@ export async function runIndex(program: Command): Promise<void> {
22
22
  await saveSearchIndex(config.configDir, index);
23
23
  spinner.success(`Indexed ${index.tools.length} tools in ${elapsed}s`);
24
24
 
25
- if (process.stderr.isTTY) {
26
- process.stderr.write(dim(`Saved to ${config.configDir}/search.json\n`));
27
- }
25
+ logger.info(`Saved to ${config.configDir}/search.json`);
28
26
  } catch (err) {
29
27
  spinner.error("Indexing failed");
30
28
  console.error(formatError(String(err), formatOptions));
@@ -1,7 +1,7 @@
1
1
  import type { Command } from "commander";
2
2
  import { getContext } from "../context.ts";
3
3
  import { formatServerTools, formatToolSchema, formatError } from "../output/formatter.ts";
4
- import { startSpinner } from "../output/spinner.ts";
4
+ import { logger } from "../output/logger.ts";
5
5
 
6
6
  export function registerInfoCommand(program: Command) {
7
7
  program
@@ -10,7 +10,7 @@ export function registerInfoCommand(program: Command) {
10
10
  .action(async (server: string, tool: string | undefined) => {
11
11
  const { manager, formatOptions } = await getContext(program);
12
12
  const target = tool ? `${server}/${tool}` : server;
13
- const spinner = startSpinner(`Connecting to ${target}...`, formatOptions);
13
+ const spinner = logger.startSpinner(`Connecting to ${target}...`, formatOptions);
14
14
  try {
15
15
  if (tool) {
16
16
  const toolSchema = await manager.getToolSchema(server, tool);
@@ -1,12 +1,12 @@
1
1
  import type { Command } from "commander";
2
2
  import { getContext } from "../context.ts";
3
3
  import { formatToolList, formatError } from "../output/formatter.ts";
4
- import { startSpinner } from "../output/spinner.ts";
4
+ import { logger } from "../output/logger.ts";
5
5
 
6
6
  export function registerListCommand(program: Command) {
7
7
  program.action(async () => {
8
8
  const { manager, formatOptions } = await getContext(program);
9
- const spinner = startSpinner("Connecting to servers...", formatOptions);
9
+ const spinner = logger.startSpinner("Connecting to servers...", formatOptions);
10
10
  try {
11
11
  const { tools, errors } = await manager.getAllTools();
12
12
  spinner.stop();
@@ -1,10 +1,9 @@
1
1
  import type { Command } from "commander";
2
- import { yellow } from "ansis";
3
2
  import { getContext } from "../context.ts";
4
3
  import { search } from "../search/index.ts";
5
4
  import { getStaleServers } from "../search/staleness.ts";
6
5
  import { formatError, formatSearchResults } from "../output/formatter.ts";
7
- import { startSpinner } from "../output/spinner.ts";
6
+ import { logger } from "../output/logger.ts";
8
7
 
9
8
  export function registerSearchCommand(program: Command) {
10
9
  program
@@ -23,14 +22,12 @@ export function registerSearchCommand(program: Command) {
23
22
 
24
23
  const stale = getStaleServers(config.searchIndex, config.servers);
25
24
  if (stale.length > 0) {
26
- process.stderr.write(
27
- yellow(
28
- `Warning: index has tools for removed servers: ${stale.join(", ")}. Run: mcpcli index\n`,
29
- ),
25
+ logger.warn(
26
+ `Warning: index has tools for removed servers: ${stale.join(", ")}. Run: mcpcli index`,
30
27
  );
31
28
  }
32
29
 
33
- const spinner = startSpinner("Searching...", formatOptions);
30
+ const spinner = logger.startSpinner("Searching...", formatOptions);
34
31
 
35
32
  try {
36
33
  const results = await search(query, config.searchIndex, {
package/src/context.ts CHANGED
@@ -3,6 +3,7 @@ import { loadConfig, type LoadConfigOptions } from "./config/loader.ts";
3
3
  import { ServerManager } from "./client/manager.ts";
4
4
  import type { Config } from "./config/schemas.ts";
5
5
  import type { FormatOptions } from "./output/formatter.ts";
6
+ import { logger } from "./output/logger.ts";
6
7
 
7
8
  export interface AppContext {
8
9
  config: Config;
@@ -46,5 +47,7 @@ export async function getContext(program: Command): Promise<AppContext> {
46
47
  showSecrets,
47
48
  };
48
49
 
50
+ logger.configure(formatOptions);
51
+
49
52
  return { config, manager, formatOptions };
50
53
  }
@@ -0,0 +1,114 @@
1
+ import { createSpinner } from "nanospinner";
2
+ import { dim, yellow, red } from "ansis";
3
+ import type { FormatOptions } from "./formatter.ts";
4
+
5
+ export interface Spinner {
6
+ update(text: string): void;
7
+ success(text?: string): void;
8
+ error(text?: string): void;
9
+ stop(): void;
10
+ }
11
+
12
+ class Logger {
13
+ private static instance: Logger;
14
+ private activeSpinner: ReturnType<typeof createSpinner> | null = null;
15
+ private formatOptions: FormatOptions = {};
16
+
17
+ private constructor() {}
18
+
19
+ static getInstance(): Logger {
20
+ if (!Logger.instance) {
21
+ Logger.instance = new Logger();
22
+ }
23
+ return Logger.instance;
24
+ }
25
+
26
+ /** Set format options (called once during context setup) */
27
+ configure(options: FormatOptions): void {
28
+ this.formatOptions = options;
29
+ }
30
+
31
+ /** Whether interactive output is suppressed (JSON mode or non-TTY stderr) */
32
+ private isSilent(): boolean {
33
+ return !!this.formatOptions.json || !(process.stderr.isTTY ?? false);
34
+ }
35
+
36
+ /** Write a line to stderr, pausing any active spinner around the write */
37
+ private writeStderr(msg: string): void {
38
+ if (this.activeSpinner) {
39
+ this.activeSpinner.clear();
40
+ process.stderr.write(msg + "\n");
41
+ this.activeSpinner.render();
42
+ } else {
43
+ process.stderr.write(msg + "\n");
44
+ }
45
+ }
46
+
47
+ /** Info-level message (dim text on stderr). Suppressed in JSON/non-TTY mode. */
48
+ info(msg: string): void {
49
+ if (this.isSilent()) return;
50
+ this.writeStderr(dim(msg));
51
+ }
52
+
53
+ /** Warning message (yellow text on stderr). Suppressed in JSON/non-TTY mode. */
54
+ warn(msg: string): void {
55
+ if (this.isSilent()) return;
56
+ this.writeStderr(yellow(msg));
57
+ }
58
+
59
+ /** Error message (red text on stderr). Always writes. */
60
+ error(msg: string): void {
61
+ this.writeStderr(red(msg));
62
+ }
63
+
64
+ /** Debug/verbose message (dim text on stderr). Only when verbose is enabled. */
65
+ debug(msg: string): void {
66
+ if (!this.formatOptions.verbose || this.isSilent()) return;
67
+ this.writeStderr(dim(msg));
68
+ }
69
+
70
+ /** Write a raw string to stderr. Spinner-aware but no formatting or newline added. */
71
+ writeRaw(msg: string): void {
72
+ if (this.activeSpinner) {
73
+ this.activeSpinner.clear();
74
+ process.stderr.write(msg);
75
+ this.activeSpinner.render();
76
+ } else {
77
+ process.stderr.write(msg);
78
+ }
79
+ }
80
+
81
+ /** Start a spinner. Returns the Spinner interface. */
82
+ startSpinner(text: string, options?: FormatOptions): Spinner {
83
+ const opts = options ?? this.formatOptions;
84
+
85
+ // No spinner in JSON/piped mode
86
+ if (opts.json || !(process.stderr.isTTY ?? false)) {
87
+ return { update() {}, success() {}, error() {}, stop() {} };
88
+ }
89
+
90
+ const spinner = createSpinner(text, { stream: process.stderr }).start();
91
+ this.activeSpinner = spinner;
92
+
93
+ return {
94
+ update: (text: string) => {
95
+ spinner.update({ text });
96
+ },
97
+ success: (text?: string) => {
98
+ spinner.success({ text });
99
+ this.activeSpinner = null;
100
+ },
101
+ error: (text?: string) => {
102
+ spinner.error({ text });
103
+ this.activeSpinner = null;
104
+ },
105
+ stop: () => {
106
+ spinner.stop();
107
+ this.activeSpinner = null;
108
+ },
109
+ };
110
+ }
111
+ }
112
+
113
+ /** The singleton logger instance */
114
+ export const logger = Logger.getInstance();
@@ -1,6 +1,7 @@
1
1
  import type { ServerManager, ToolWithServer } from "../client/manager.ts";
2
2
  import type { SearchIndex, IndexedTool } from "../config/schemas.ts";
3
3
  import { generateEmbedding } from "./semantic.ts";
4
+ import { logger } from "../output/logger.ts";
4
5
 
5
6
  /** Extract keywords from a tool name by splitting on separators and camelCase */
6
7
  export function extractKeywords(name: string): string[] {
@@ -70,7 +71,7 @@ export async function buildSearchIndex(
70
71
 
71
72
  if (errors.length > 0) {
72
73
  for (const err of errors) {
73
- process.stderr.write(`warning: ${err.server}: ${err.message}\n`);
74
+ logger.warn(`${err.server}: ${err.message}`);
74
75
  }
75
76
  }
76
77
 
@@ -1,39 +0,0 @@
1
- import { createSpinner } from "nanospinner";
2
- import type { FormatOptions } from "./formatter.ts";
3
-
4
- export interface Spinner {
5
- update(text: string): void;
6
- success(text?: string): void;
7
- error(text?: string): void;
8
- stop(): void;
9
- }
10
-
11
- /** Create a spinner that only renders in interactive mode */
12
- export function startSpinner(text: string, options: FormatOptions): Spinner {
13
- // No spinner in JSON/piped mode
14
- if (options.json || !(process.stderr.isTTY ?? false)) {
15
- return {
16
- update() {},
17
- success() {},
18
- error() {},
19
- stop() {},
20
- };
21
- }
22
-
23
- const spinner = createSpinner(text, { stream: process.stderr }).start();
24
-
25
- return {
26
- update(text: string) {
27
- spinner.update({ text });
28
- },
29
- success(text?: string) {
30
- spinner.success({ text });
31
- },
32
- error(text?: string) {
33
- spinner.error({ text });
34
- },
35
- stop() {
36
- spinner.stop();
37
- },
38
- };
39
- }