@goke/mcp 0.0.11 → 0.0.13

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/README.md CHANGED
@@ -25,9 +25,9 @@ MCP server Your CLI
25
25
 
26
26
  1. **Discover** — calls `tools/list` on the MCP server to get every tool + its JSON Schema
27
27
  2. **Register** — creates a CLI command per tool with `--options` derived from the schema
28
- 3. **Cache** — tools and session ID are cached for 1 hour (no network on subsequent runs)
28
+ 3. **Cache** — tools and session ID are cached for 1 hour (no network on subsequent runs). Expired cache is still used when a live fetch is impossible (no token, 401 on `--help`)
29
29
  4. **Execute** — on invocation, connects to the server and calls the tool with coerced arguments
30
- 5. **OAuth** — if the server returns 401, automatically opens the browser for OAuth, then retries
30
+ 5. **OAuth** — if the server returns 401, automatically opens the browser for OAuth, then retries. `--help`, `--version`, `completions`, and no-args never start OAuth
31
31
 
32
32
  ## Quick start
33
33
 
@@ -55,6 +55,24 @@ cli.completions()
55
55
  cli.parse()
56
56
  ```
57
57
 
58
+ If the MCP server is HTTP, pass `getMcpUrl` even when the user has no token. `--help` and no-args must still run so the CLI can show `config` / login instructions. Use `getMcpTransport` when you need stdio or a custom transport.
59
+
60
+ For an HTTP Bearer token, pass `getHeaders`:
61
+
62
+ ```ts
63
+ await addMcpCommands({
64
+ cli,
65
+ getMcpUrl: () => 'https://api.example.com/mcp',
66
+ getHeaders: () => {
67
+ const token = process.env.EXAMPLE_TOKEN || loadConfig().token
68
+ if (!token) return
69
+ return { Authorization: `Bearer ${token}` }
70
+ },
71
+ loadCache: () => loadConfig().cache,
72
+ saveCache: (cache) => saveConfig({ cache }),
73
+ })
74
+ ```
75
+
58
76
  That's it. Every tool the MCP server exposes becomes a CLI command:
59
77
 
60
78
  ```bash
@@ -467,7 +485,10 @@ Registers MCP tool commands on a goke CLI instance.
467
485
  | Option | Type | Default | Description |
468
486
  |--------|------|---------|-------------|
469
487
  | `cli` | `Goke` | **required** | The goke CLI instance to add commands to |
470
- | `getMcpUrl` | `() => string \| undefined` | — | Returns the MCP server URL |
488
+ | `getMcpUrl` | `() => string \| undefined` | — | Returns the MCP server URL. Return the URL even when the user is not logged in so `--help` still works |
489
+ | `getMcpTransport` | `(sessionId?) => Transport \| null` | — | Custom transport. Use for stdio or anything `getMcpUrl` cannot express |
490
+ | `getHeaders` | `() => Record<string, string> \| undefined` | — | Extra HTTP headers (for example `Authorization`). Used with `getMcpUrl` |
491
+ | `argv` | `string[]` | `process.argv.slice(2)` | Args used to skip live discovery on help and already registered commands |
471
492
  | `commandPrefix` | `string` | `''` | Prefix for commands (e.g. `'mcp'` makes `mcp notion-search`) |
472
493
  | `clientName` | `string` | `'mcp-cli-client'` | Name sent to the MCP server during connection |
473
494
  | `oauth` | `McpOAuthConfig` | — | OAuth config for servers that require authentication |
@@ -537,6 +558,8 @@ Tools and the MCP session ID are cached for **1 hour** to avoid connecting on ev
537
558
 
538
559
  When the cache expires or a tool call fails, the cache is cleared and tools are re-fetched on the next run.
539
560
 
561
+ If a live `tools/list` cannot run (no transport, 401 during `--help`), `addMcpCommands` still registers tools from an expired cache so help stays useful.
562
+
540
563
  ## License
541
564
 
542
565
  MIT
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=add-mcp-commands-help.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"add-mcp-commands-help.test.d.ts","sourceRoot":"","sources":["../../src/__test__/add-mcp-commands-help.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,127 @@
1
+ // First-run help and stale-cache behavior for addMcpCommands.
2
+ import http from 'node:http';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { goke } from 'goke';
5
+ import { addMcpCommands } from '../index.js';
6
+ const staleCache = (over = {}) => ({
7
+ tools: [
8
+ {
9
+ name: 'find_bookmarks',
10
+ description: 'Find bookmarks',
11
+ inputSchema: { type: 'object', properties: {} },
12
+ },
13
+ ],
14
+ timestamp: Date.now() - 2 * 60 * 60 * 1000,
15
+ sessionId: 'stale-session',
16
+ ...over,
17
+ });
18
+ function captureErrors() {
19
+ const errors = [];
20
+ const error = console.error;
21
+ console.error = (...args) => {
22
+ errors.push(args.map(String).join(' '));
23
+ };
24
+ return {
25
+ errors,
26
+ restore() {
27
+ console.error = error;
28
+ },
29
+ };
30
+ }
31
+ function listen401() {
32
+ const server = http.createServer((_req, res) => {
33
+ res.writeHead(401, { 'content-type': 'application/json' });
34
+ res.end(JSON.stringify({ error: 'unauthorized' }));
35
+ });
36
+ return new Promise((resolve) => {
37
+ server.listen(0, '127.0.0.1', () => {
38
+ const addr = server.address();
39
+ if (!addr || typeof addr === 'string')
40
+ throw new Error('no port');
41
+ resolve({
42
+ url: `http://127.0.0.1:${addr.port}/mcp`,
43
+ close: () => new Promise((done) => server.close(() => done())),
44
+ });
45
+ });
46
+ });
47
+ }
48
+ describe('addMcpCommands first-run help', () => {
49
+ it('lets --help run when there is no token and no cache', async () => {
50
+ const io = captureErrors();
51
+ const cli = goke('testcli');
52
+ cli.command('config', 'Save token').action(() => { });
53
+ try {
54
+ await addMcpCommands({
55
+ cli,
56
+ argv: ['--help'],
57
+ getMcpTransport: () => null,
58
+ loadCache: () => undefined,
59
+ saveCache: () => { },
60
+ });
61
+ }
62
+ finally {
63
+ io.restore();
64
+ }
65
+ const help = cli.helpText();
66
+ expect(io.errors.join('\n')).not.toMatch(/Failed to connect/);
67
+ expect(help).toMatch(/config/);
68
+ expect(help).not.toMatch(/find_bookmarks/);
69
+ });
70
+ it('registers stale cached tools when live fetch is impossible', async () => {
71
+ const cli = goke('testcli');
72
+ await addMcpCommands({
73
+ cli,
74
+ argv: ['--help'],
75
+ getMcpTransport: () => null,
76
+ loadCache: () => staleCache(),
77
+ saveCache: () => { },
78
+ });
79
+ expect(cli.helpText()).toMatch(/find_bookmarks/);
80
+ });
81
+ it('does not start OAuth when --help gets a 401', async () => {
82
+ const server = await listen401();
83
+ const authUrls = [];
84
+ const io = captureErrors();
85
+ const cli = goke('testcli');
86
+ try {
87
+ await addMcpCommands({
88
+ cli,
89
+ argv: ['--help'],
90
+ getMcpUrl: () => server.url,
91
+ oauth: {
92
+ clientName: 'test',
93
+ load: () => undefined,
94
+ save: () => { },
95
+ onAuthUrl: (url) => {
96
+ authUrls.push(url);
97
+ },
98
+ },
99
+ loadCache: () => undefined,
100
+ saveCache: () => { },
101
+ });
102
+ }
103
+ finally {
104
+ io.restore();
105
+ await server.close();
106
+ }
107
+ expect(authUrls).toEqual([]);
108
+ expect(io.errors.join('\n')).not.toMatch(/Authentication required/);
109
+ expect(cli.helpText()).toMatch(/Usage/);
110
+ });
111
+ it('does not connect for an already registered command', async () => {
112
+ let transportCalls = 0;
113
+ const cli = goke('testcli');
114
+ cli.command('config', 'Save token').action(() => { });
115
+ await addMcpCommands({
116
+ cli,
117
+ argv: ['config', '--token', 'x'],
118
+ getMcpTransport: () => {
119
+ transportCalls += 1;
120
+ return null;
121
+ },
122
+ loadCache: () => undefined,
123
+ saveCache: () => { },
124
+ });
125
+ expect(transportCalls).toBe(0);
126
+ });
127
+ });
package/dist/auth.d.ts CHANGED
@@ -1,10 +1,8 @@
1
1
  import type { OAuthFlowResult, StartOAuthFlowOptions } from "./types.js";
2
2
  /**
3
3
  * Start the OAuth flow for an MCP server.
4
- *
5
- * Used internally by addMcpCommands on 401 errors, but also available
6
- * for CLIs that need explicit control over the auth flow (e.g. a login
7
- * command that runs the flow in a background daemon).
4
+ * This is an internal function - consumers should not call this directly.
5
+ * It is automatically triggered by addMcpCommands when a 401 error occurs.
8
6
  *
9
7
  * This function:
10
8
  * 1. Starts a local callback server on a random port
@@ -1 +1 @@
1
- {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAiB,eAAe,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAC;AA0BxF;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,cAAc,CAAC,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,eAAe,CAAC,CA4E7F;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAazD"}
1
+ {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAiB,eAAe,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAC;AA0BxF;;;;;;;;;;;;GAYG;AACH,wBAAsB,cAAc,CAAC,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,eAAe,CAAC,CA4E7F;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAazD"}
package/dist/auth.js CHANGED
@@ -24,10 +24,8 @@ async function openBrowser(url) {
24
24
  }
25
25
  /**
26
26
  * Start the OAuth flow for an MCP server.
27
- *
28
- * Used internally by addMcpCommands on 401 errors, but also available
29
- * for CLIs that need explicit control over the auth flow (e.g. a login
30
- * command that runs the flow in a background daemon).
27
+ * This is an internal function - consumers should not call this directly.
28
+ * It is automatically triggered by addMcpCommands when a 401 error occurs.
31
29
  *
32
30
  * This function:
33
31
  * 1. Starts a local callback server on a random port
package/dist/index.d.ts CHANGED
@@ -41,12 +41,11 @@
41
41
  */
42
42
  import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
43
43
  import type { Goke } from "goke";
44
- export { startOAuthFlow } from "./auth.js";
45
44
  import type { McpOAuthConfig } from "./types.js";
46
45
  export { addCliToolsToMcp, createMcpAction } from "./cli-to-mcp.js";
47
46
  export type { AddCliToolsToMcpOptions, CreateMcpActionOptions } from "./cli-to-mcp.js";
48
47
  export type { Transport };
49
- export type { McpOAuthConfig, McpOAuthState, StartOAuthFlowOptions, OAuthFlowResult } from "./types.js";
48
+ export type { McpOAuthConfig, McpOAuthState } from "./types.js";
50
49
  export interface CachedMcpTools {
51
50
  tools: Array<{
52
51
  name: string;
@@ -76,12 +75,20 @@ export interface AddMcpCommandsOptions {
76
75
  getMcpUrl?: () => string | undefined;
77
76
  /**
78
77
  * Returns a transport to connect to the MCP server, or null if not configured.
79
- * If null is returned, no MCP tool commands will be registered.
80
- * @param sessionId - Optional session ID from cache to reuse existing session
81
- *
82
- * @deprecated Use getMcpUrl + oauth instead for simpler setup
78
+ * Use this for stdio servers or any setup `getMcpUrl` cannot express.
79
+ * @param sessionId - Optional session ID from a still-valid cache
83
80
  */
84
81
  getMcpTransport?: (sessionId?: string) => Transport | null | Promise<Transport | null>;
82
+ /**
83
+ * Argv used to decide whether to skip live discovery.
84
+ * Defaults to `process.argv.slice(2)`.
85
+ */
86
+ argv?: string[];
87
+ /**
88
+ * Extra headers for MCP HTTP requests (for example `Authorization`).
89
+ * Used with `getMcpUrl`. Ignored when `getMcpTransport` is set.
90
+ */
91
+ getHeaders?: () => Record<string, string> | undefined;
85
92
  /**
86
93
  * OAuth configuration. When provided, enables automatic OAuth authentication.
87
94
  *
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AAIH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,+CAA+C,CAAC;AAC/E,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAKjC,OAAO,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAC3C,OAAO,KAAK,EAAE,cAAc,EAAiB,MAAM,YAAY,CAAC;AAChE,OAAO,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AACpE,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,MAAM,iBAAiB,CAAC;AAGvF,YAAY,EAAE,SAAS,EAAE,CAAC;AAC1B,YAAY,EAAE,cAAc,EAAE,aAAa,EAAE,qBAAqB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAExG,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,KAAK,CAAC;QACX,IAAI,EAAE,MAAM,CAAC;QACb,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,WAAW,CAAC,EAAE,OAAO,CAAC;KACvB,CAAC,CAAC;IACH,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAID,MAAM,WAAW,qBAAqB;IACpC,GAAG,EAAE,IAAI,CAAC;IACV;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,MAAM,GAAG,SAAS,CAAC;IAErC;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,CAAC,SAAS,CAAC,EAAE,MAAM,KAAK,SAAS,GAAG,IAAI,GAAG,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC,CAAC;IAEvF;;;;;;;;;;;;;;OAcG;IACH,KAAK,CAAC,EAAE,cAAc,CAAC;IAEvB;;OAEG;IACH,SAAS,EAAE,MAAM,cAAc,GAAG,SAAS,CAAC;IAE5C;;OAEG;IACH,SAAS,EAAE,CAAC,KAAK,EAAE,cAAc,GAAG,SAAS,KAAK,IAAI,CAAC;CACxD;AAmID;;;;;;;;GAQG;AACH,wBAAsB,cAAc,CAAC,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC,CAyMlF"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AAIH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,+CAA+C,CAAC;AAC/E,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAKjC,OAAO,KAAK,EAAE,cAAc,EAAiB,MAAM,YAAY,CAAC;AAChE,OAAO,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AACpE,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,MAAM,iBAAiB,CAAC;AAGvF,YAAY,EAAE,SAAS,EAAE,CAAC;AAC1B,YAAY,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhE,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,KAAK,CAAC;QACX,IAAI,EAAE,MAAM,CAAC;QACb,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,WAAW,CAAC,EAAE,OAAO,CAAC;KACvB,CAAC,CAAC;IACH,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAID,MAAM,WAAW,qBAAqB;IACpC,GAAG,EAAE,IAAI,CAAC;IACV;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,MAAM,GAAG,SAAS,CAAC;IAErC;;;;OAIG;IACH,eAAe,CAAC,EAAE,CAAC,SAAS,CAAC,EAAE,MAAM,KAAK,SAAS,GAAG,IAAI,GAAG,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC,CAAC;IAEvF;;;OAGG;IACH,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAEhB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,CAAC;IAEtD;;;;;;;;;;;;;;OAcG;IACH,KAAK,CAAC,EAAE,cAAc,CAAC;IAEvB;;OAEG;IACH,SAAS,EAAE,MAAM,cAAc,GAAG,SAAS,CAAC;IAE5C;;OAEG;IACH,SAAS,EAAE,CAAC,KAAK,EAAE,cAAc,GAAG,SAAS,KAAK,IAAI,CAAC;CACxD;AA2JD;;;;;;;;GAQG;AACH,wBAAsB,cAAc,CAAC,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC,CA4NlF"}
package/dist/index.js CHANGED
@@ -45,7 +45,6 @@ import { wrapJsonSchema } from "goke";
45
45
  import yaml from "js-yaml";
46
46
  import { FileOAuthProvider } from "./oauth-provider.js";
47
47
  import { startOAuthFlow, isAuthRequiredError } from "./auth.js";
48
- export { startOAuthFlow } from "./auth.js";
49
48
  export { addCliToolsToMcp, createMcpAction } from "./cli-to-mcp.js";
50
49
  const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
51
50
  /**
@@ -125,7 +124,23 @@ function outputResult(result) {
125
124
  /**
126
125
  * Create a transport with optional OAuth authentication
127
126
  */
128
- function createTransportWithAuth(url, sessionId, oauthState, oauth) {
127
+ function isHelpOrMetaArgv(argv) {
128
+ if (argv.length === 0)
129
+ return true;
130
+ if (argv[0] === "completions" || argv.includes("--get-goke-completions"))
131
+ return true;
132
+ return argv.some((arg) => arg === "--help" || arg === "-h" || arg === "--version" || arg === "-v");
133
+ }
134
+ function matchesRegisteredCommand({ argv, cli }) {
135
+ const parts = argv.filter((arg) => !arg.startsWith("-"));
136
+ return cli.commands.some((cmd) => {
137
+ if (!cmd.name)
138
+ return false;
139
+ const nameParts = cmd.name.split(" ");
140
+ return nameParts.every((part, i) => parts[i] === part);
141
+ });
142
+ }
143
+ function createTransportWithAuth({ url, sessionId, oauthState, oauth, headers, }) {
129
144
  let authProvider;
130
145
  if (oauth && oauthState?.tokens) {
131
146
  authProvider = new FileOAuthProvider({
@@ -140,9 +155,11 @@ function createTransportWithAuth(url, sessionId, oauthState, oauth) {
140
155
  },
141
156
  });
142
157
  }
158
+ const hasHeaders = headers && Object.keys(headers).length > 0;
143
159
  return new StreamableHTTPClientTransport(url, {
144
160
  sessionId,
145
161
  authProvider,
162
+ requestInit: hasHeaders ? { headers } : undefined,
146
163
  });
147
164
  }
148
165
  /**
@@ -155,7 +172,7 @@ function createTransportWithAuth(url, sessionId, oauthState, oauth) {
155
172
  * After successful auth, the operation is automatically retried.
156
173
  */
157
174
  export async function addMcpCommands(options) {
158
- const { cli, commandPrefix = "", clientName = "mcp-cli-client", getMcpUrl, getMcpTransport, oauth, loadCache, saveCache, } = options;
175
+ const { cli, commandPrefix = "", clientName = "mcp-cli-client", getMcpUrl, getMcpTransport, getHeaders, oauth, loadCache, saveCache, argv = process.argv.slice(2), } = options;
159
176
  // Helper to get transport - supports both old and new API
160
177
  const getTransport = async (sessionId) => {
161
178
  // New API: getMcpUrl + oauth
@@ -166,9 +183,15 @@ export async function addMcpCommands(options) {
166
183
  }
167
184
  const url = new URL(mcpUrl);
168
185
  const oauthState = oauth?.load();
169
- return createTransportWithAuth(url, sessionId, oauthState, oauth);
186
+ return createTransportWithAuth({
187
+ url,
188
+ sessionId,
189
+ oauthState,
190
+ oauth,
191
+ headers: getHeaders?.(),
192
+ });
170
193
  }
171
- // Legacy API: getMcpTransport
194
+ // Custom / stdio transport
172
195
  if (getMcpTransport) {
173
196
  return getMcpTransport(sessionId);
174
197
  }
@@ -201,54 +224,64 @@ export async function addMcpCommands(options) {
201
224
  // Try to use cached tools first (fast path - no network)
202
225
  const cachedTools = loadCache();
203
226
  const isCacheValid = cachedTools && (Date.now() - cachedTools.timestamp) < CACHE_TTL_MS;
227
+ const skipLiveDiscovery = isHelpOrMetaArgv(argv) || matchesRegisteredCommand({ argv, cli });
204
228
  let tools;
205
229
  let cachedSessionId;
206
- if (isCacheValid) {
230
+ if (isCacheValid && cachedTools) {
207
231
  tools = cachedTools.tools;
208
232
  cachedSessionId = cachedTools.sessionId;
209
233
  }
234
+ else if (skipLiveDiscovery) {
235
+ if (cachedTools) {
236
+ tools = cachedTools.tools;
237
+ }
238
+ }
210
239
  else {
211
- // Cache invalid/missing - connect to fetch tools
212
240
  const transport = await getTransport();
213
- if (!transport) {
214
- return;
215
- }
216
- const client = new Client({ name: clientName, version: "1.0.0" }, { capabilities: {} });
217
- try {
218
- await client.connect(transport);
219
- const result = await client.listTools();
220
- tools = result.tools;
221
- const sessionId = transport.sessionId;
222
- saveCache({
223
- tools: tools.map((t) => ({
224
- name: t.name,
225
- description: t.description,
226
- inputSchema: t.inputSchema,
227
- })),
228
- timestamp: Date.now(),
229
- sessionId,
230
- });
231
- cachedSessionId = sessionId;
232
- }
233
- catch (err) {
234
- // Check if auth is required during tool discovery
235
- if (isAuthRequiredError(err) && oauth && getMcpUrl) {
236
- const mcpUrl = getMcpUrl();
237
- if (mcpUrl) {
238
- const authSuccess = await handleAuthRequired((mcpUrl).toString());
239
- if (authSuccess) {
240
- // Retry after auth
241
- return addMcpCommands(options);
241
+ if (transport) {
242
+ const client = new Client({ name: clientName, version: "1.0.0" }, { capabilities: {} });
243
+ try {
244
+ await client.connect(transport);
245
+ const result = await client.listTools();
246
+ tools = result.tools;
247
+ const sessionId = transport.sessionId;
248
+ saveCache({
249
+ tools: tools.map((t) => ({
250
+ name: t.name,
251
+ description: t.description,
252
+ inputSchema: t.inputSchema,
253
+ })),
254
+ timestamp: Date.now(),
255
+ sessionId,
256
+ });
257
+ cachedSessionId = sessionId;
258
+ }
259
+ catch (err) {
260
+ const shouldAuth = isAuthRequiredError(err) && oauth && getMcpUrl && !skipLiveDiscovery;
261
+ if (shouldAuth) {
262
+ const mcpUrl = getMcpUrl();
263
+ if (mcpUrl) {
264
+ const authSuccess = await handleAuthRequired(mcpUrl);
265
+ if (authSuccess) {
266
+ return addMcpCommands(options);
267
+ }
242
268
  }
243
269
  }
270
+ if (!skipLiveDiscovery) {
271
+ console.error(`Failed to connect to MCP server: ${err instanceof Error ? err.message : err}`);
272
+ }
273
+ }
274
+ finally {
275
+ await client.close();
244
276
  }
245
- console.error(`Failed to connect to MCP server: ${err instanceof Error ? err.message : err}`);
246
- return;
247
277
  }
248
- finally {
249
- await client.close();
278
+ if (!tools && cachedTools) {
279
+ tools = cachedTools.tools;
250
280
  }
251
281
  }
282
+ if (!tools) {
283
+ return;
284
+ }
252
285
  // Register CLI commands for each tool
253
286
  for (const tool of tools) {
254
287
  const inputSchema = tool.inputSchema;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goke/mcp",
3
- "version": "0.0.11",
3
+ "version": "0.0.13",
4
4
  "type": "module",
5
5
  "description": "Dynamically generate CLI commands from MCP server tools",
6
6
  "repository": {
@@ -51,7 +51,7 @@
51
51
  "@types/node": "^22.19.7",
52
52
  "vitest": "^3.1.0",
53
53
  "zod": "^4.3.6",
54
- "goke": "^6.13.0"
54
+ "goke": "^6.12.1"
55
55
  },
56
56
  "scripts": {
57
57
  "clean": "rm -rf dist",
@@ -0,0 +1,139 @@
1
+ // First-run help and stale-cache behavior for addMcpCommands.
2
+ import http from 'node:http'
3
+ import { describe, expect, it } from 'vitest'
4
+ import { goke } from 'goke'
5
+ import { addMcpCommands, type CachedMcpTools } from '../index.js'
6
+
7
+ const staleCache = (over: Partial<CachedMcpTools> = {}): CachedMcpTools => ({
8
+ tools: [
9
+ {
10
+ name: 'find_bookmarks',
11
+ description: 'Find bookmarks',
12
+ inputSchema: { type: 'object', properties: {} },
13
+ },
14
+ ],
15
+ timestamp: Date.now() - 2 * 60 * 60 * 1000,
16
+ sessionId: 'stale-session',
17
+ ...over,
18
+ })
19
+
20
+ function captureErrors() {
21
+ const errors: string[] = []
22
+ const error = console.error
23
+ console.error = (...args: unknown[]) => {
24
+ errors.push(args.map(String).join(' '))
25
+ }
26
+ return {
27
+ errors,
28
+ restore() {
29
+ console.error = error
30
+ },
31
+ }
32
+ }
33
+
34
+ function listen401() {
35
+ const server = http.createServer((_req, res) => {
36
+ res.writeHead(401, { 'content-type': 'application/json' })
37
+ res.end(JSON.stringify({ error: 'unauthorized' }))
38
+ })
39
+ return new Promise<{ url: string; close: () => Promise<void> }>((resolve) => {
40
+ server.listen(0, '127.0.0.1', () => {
41
+ const addr = server.address()
42
+ if (!addr || typeof addr === 'string') throw new Error('no port')
43
+ resolve({
44
+ url: `http://127.0.0.1:${addr.port}/mcp`,
45
+ close: () => new Promise((done) => server.close(() => done())),
46
+ })
47
+ })
48
+ })
49
+ }
50
+
51
+ describe('addMcpCommands first-run help', () => {
52
+ it('lets --help run when there is no token and no cache', async () => {
53
+ const io = captureErrors()
54
+ const cli = goke('testcli')
55
+ cli.command('config', 'Save token').action(() => {})
56
+
57
+ try {
58
+ await addMcpCommands({
59
+ cli,
60
+ argv: ['--help'],
61
+ getMcpTransport: () => null,
62
+ loadCache: () => undefined,
63
+ saveCache: () => {},
64
+ })
65
+ } finally {
66
+ io.restore()
67
+ }
68
+
69
+ const help = cli.helpText()
70
+ expect(io.errors.join('\n')).not.toMatch(/Failed to connect/)
71
+ expect(help).toMatch(/config/)
72
+ expect(help).not.toMatch(/find_bookmarks/)
73
+ })
74
+
75
+ it('registers stale cached tools when live fetch is impossible', async () => {
76
+ const cli = goke('testcli')
77
+
78
+ await addMcpCommands({
79
+ cli,
80
+ argv: ['--help'],
81
+ getMcpTransport: () => null,
82
+ loadCache: () => staleCache(),
83
+ saveCache: () => {},
84
+ })
85
+
86
+ expect(cli.helpText()).toMatch(/find_bookmarks/)
87
+ })
88
+
89
+ it('does not start OAuth when --help gets a 401', async () => {
90
+ const server = await listen401()
91
+ const authUrls: string[] = []
92
+ const io = captureErrors()
93
+ const cli = goke('testcli')
94
+
95
+ try {
96
+ await addMcpCommands({
97
+ cli,
98
+ argv: ['--help'],
99
+ getMcpUrl: () => server.url,
100
+ oauth: {
101
+ clientName: 'test',
102
+ load: () => undefined,
103
+ save: () => {},
104
+ onAuthUrl: (url) => {
105
+ authUrls.push(url)
106
+ },
107
+ },
108
+ loadCache: () => undefined,
109
+ saveCache: () => {},
110
+ })
111
+ } finally {
112
+ io.restore()
113
+ await server.close()
114
+ }
115
+
116
+ expect(authUrls).toEqual([])
117
+ expect(io.errors.join('\n')).not.toMatch(/Authentication required/)
118
+ expect(cli.helpText()).toMatch(/Usage/)
119
+ })
120
+
121
+ it('does not connect for an already registered command', async () => {
122
+ let transportCalls = 0
123
+ const cli = goke('testcli')
124
+ cli.command('config', 'Save token').action(() => {})
125
+
126
+ await addMcpCommands({
127
+ cli,
128
+ argv: ['config', '--token', 'x'],
129
+ getMcpTransport: () => {
130
+ transportCalls += 1
131
+ return null
132
+ },
133
+ loadCache: () => undefined,
134
+ saveCache: () => {},
135
+ })
136
+
137
+ expect(transportCalls).toBe(0)
138
+ })
139
+ })
package/src/auth.ts CHANGED
@@ -29,10 +29,8 @@ async function openBrowser(url: string): Promise<void> {
29
29
 
30
30
  /**
31
31
  * Start the OAuth flow for an MCP server.
32
- *
33
- * Used internally by addMcpCommands on 401 errors, but also available
34
- * for CLIs that need explicit control over the auth flow (e.g. a login
35
- * command that runs the flow in a background daemon).
32
+ * This is an internal function - consumers should not call this directly.
33
+ * It is automatically triggered by addMcpCommands when a 401 error occurs.
36
34
  *
37
35
  * This function:
38
36
  * 1. Starts a local callback server on a random port
package/src/index.ts CHANGED
@@ -48,14 +48,13 @@ import { wrapJsonSchema } from "goke";
48
48
  import yaml from "js-yaml";
49
49
  import { FileOAuthProvider } from "./oauth-provider.js";
50
50
  import { startOAuthFlow, isAuthRequiredError } from "./auth.js";
51
- export { startOAuthFlow } from "./auth.js";
52
51
  import type { McpOAuthConfig, McpOAuthState } from "./types.js";
53
52
  export { addCliToolsToMcp, createMcpAction } from "./cli-to-mcp.js";
54
53
  export type { AddCliToolsToMcpOptions, CreateMcpActionOptions } from "./cli-to-mcp.js";
55
54
 
56
- // Public exports
55
+ // Public exports - only types that consumers need
57
56
  export type { Transport };
58
- export type { McpOAuthConfig, McpOAuthState, StartOAuthFlowOptions, OAuthFlowResult } from "./types.js";
57
+ export type { McpOAuthConfig, McpOAuthState } from "./types.js";
59
58
 
60
59
  export interface CachedMcpTools {
61
60
  tools: Array<{
@@ -91,13 +90,23 @@ export interface AddMcpCommandsOptions {
91
90
 
92
91
  /**
93
92
  * Returns a transport to connect to the MCP server, or null if not configured.
94
- * If null is returned, no MCP tool commands will be registered.
95
- * @param sessionId - Optional session ID from cache to reuse existing session
96
- *
97
- * @deprecated Use getMcpUrl + oauth instead for simpler setup
93
+ * Use this for stdio servers or any setup `getMcpUrl` cannot express.
94
+ * @param sessionId - Optional session ID from a still-valid cache
98
95
  */
99
96
  getMcpTransport?: (sessionId?: string) => Transport | null | Promise<Transport | null>;
100
97
 
98
+ /**
99
+ * Argv used to decide whether to skip live discovery.
100
+ * Defaults to `process.argv.slice(2)`.
101
+ */
102
+ argv?: string[];
103
+
104
+ /**
105
+ * Extra headers for MCP HTTP requests (for example `Authorization`).
106
+ * Used with `getMcpUrl`. Ignored when `getMcpTransport` is set.
107
+ */
108
+ getHeaders?: () => Record<string, string> | undefined;
109
+
101
110
  /**
102
111
  * OAuth configuration. When provided, enables automatic OAuth authentication.
103
112
  *
@@ -225,12 +234,34 @@ function outputResult(result: {
225
234
  /**
226
235
  * Create a transport with optional OAuth authentication
227
236
  */
228
- function createTransportWithAuth(
229
- url: URL,
230
- sessionId: string | undefined,
231
- oauthState: McpOAuthState | undefined,
232
- oauth: McpOAuthConfig | undefined,
233
- ): StreamableHTTPClientTransport {
237
+ function isHelpOrMetaArgv(argv: string[]) {
238
+ if (argv.length === 0) return true;
239
+ if (argv[0] === "completions" || argv.includes("--get-goke-completions")) return true;
240
+ return argv.some((arg) => arg === "--help" || arg === "-h" || arg === "--version" || arg === "-v");
241
+ }
242
+
243
+ function matchesRegisteredCommand({ argv, cli }: { argv: string[]; cli: Goke }) {
244
+ const parts = argv.filter((arg) => !arg.startsWith("-"));
245
+ return cli.commands.some((cmd) => {
246
+ if (!cmd.name) return false;
247
+ const nameParts = cmd.name.split(" ");
248
+ return nameParts.every((part, i) => parts[i] === part);
249
+ });
250
+ }
251
+
252
+ function createTransportWithAuth({
253
+ url,
254
+ sessionId,
255
+ oauthState,
256
+ oauth,
257
+ headers,
258
+ }: {
259
+ url: URL
260
+ sessionId?: string
261
+ oauthState?: McpOAuthState
262
+ oauth?: McpOAuthConfig
263
+ headers?: Record<string, string>
264
+ }): StreamableHTTPClientTransport {
234
265
  let authProvider: FileOAuthProvider | undefined;
235
266
 
236
267
  if (oauth && oauthState?.tokens) {
@@ -247,9 +278,11 @@ function createTransportWithAuth(
247
278
  });
248
279
  }
249
280
 
281
+ const hasHeaders = headers && Object.keys(headers).length > 0;
250
282
  return new StreamableHTTPClientTransport(url, {
251
283
  sessionId,
252
284
  authProvider,
285
+ requestInit: hasHeaders ? { headers } : undefined,
253
286
  });
254
287
  }
255
288
 
@@ -271,9 +304,11 @@ export async function addMcpCommands(options: AddMcpCommandsOptions): Promise<vo
271
304
  clientName = "mcp-cli-client",
272
305
  getMcpUrl,
273
306
  getMcpTransport,
307
+ getHeaders,
274
308
  oauth,
275
309
  loadCache,
276
310
  saveCache,
311
+ argv = process.argv.slice(2),
277
312
  } = options;
278
313
 
279
314
  // Helper to get transport - supports both old and new API
@@ -288,10 +323,16 @@ export async function addMcpCommands(options: AddMcpCommandsOptions): Promise<vo
288
323
  const url = new URL(mcpUrl);
289
324
  const oauthState = oauth?.load();
290
325
 
291
- return createTransportWithAuth(url, sessionId, oauthState, oauth);
326
+ return createTransportWithAuth({
327
+ url,
328
+ sessionId,
329
+ oauthState,
330
+ oauth,
331
+ headers: getHeaders?.(),
332
+ });
292
333
  }
293
334
 
294
- // Legacy API: getMcpTransport
335
+ // Custom / stdio transport
295
336
  if (getMcpTransport) {
296
337
  return getMcpTransport(sessionId);
297
338
  }
@@ -331,55 +372,66 @@ export async function addMcpCommands(options: AddMcpCommandsOptions): Promise<vo
331
372
  // Try to use cached tools first (fast path - no network)
332
373
  const cachedTools = loadCache();
333
374
  const isCacheValid = cachedTools && (Date.now() - cachedTools.timestamp) < CACHE_TTL_MS;
375
+ const skipLiveDiscovery =
376
+ isHelpOrMetaArgv(argv) || matchesRegisteredCommand({ argv, cli });
334
377
 
335
- let tools: CachedMcpTools["tools"];
378
+ let tools: CachedMcpTools["tools"] | undefined;
336
379
  let cachedSessionId: string | undefined;
337
380
 
338
- if (isCacheValid) {
381
+ if (isCacheValid && cachedTools) {
339
382
  tools = cachedTools.tools;
340
383
  cachedSessionId = cachedTools.sessionId;
384
+ } else if (skipLiveDiscovery) {
385
+ if (cachedTools) {
386
+ tools = cachedTools.tools;
387
+ }
341
388
  } else {
342
- // Cache invalid/missing - connect to fetch tools
343
389
  const transport = await getTransport();
344
- if (!transport) {
345
- return;
346
- }
347
-
348
- const client = new Client({ name: clientName, version: "1.0.0" }, { capabilities: {} });
349
- try {
350
- await client.connect(transport);
351
- const result = await client.listTools();
352
- tools = result.tools;
353
-
354
- const sessionId = (transport as { sessionId?: string }).sessionId;
355
-
356
- saveCache({
357
- tools: tools.map((t) => ({
358
- name: t.name,
359
- description: t.description,
360
- inputSchema: t.inputSchema,
361
- })),
362
- timestamp: Date.now(),
363
- sessionId,
364
- });
365
- cachedSessionId = sessionId;
366
- } catch (err) {
367
- // Check if auth is required during tool discovery
368
- if (isAuthRequiredError(err) && oauth && getMcpUrl) {
369
- const mcpUrl = getMcpUrl();
370
- if (mcpUrl) {
371
- const authSuccess = await handleAuthRequired((mcpUrl).toString());
372
- if (authSuccess) {
373
- // Retry after auth
374
- return addMcpCommands(options);
390
+ if (transport) {
391
+ const client = new Client({ name: clientName, version: "1.0.0" }, { capabilities: {} });
392
+ try {
393
+ await client.connect(transport);
394
+ const result = await client.listTools();
395
+ tools = result.tools;
396
+
397
+ const sessionId = (transport as { sessionId?: string }).sessionId;
398
+
399
+ saveCache({
400
+ tools: tools.map((t) => ({
401
+ name: t.name,
402
+ description: t.description,
403
+ inputSchema: t.inputSchema,
404
+ })),
405
+ timestamp: Date.now(),
406
+ sessionId,
407
+ });
408
+ cachedSessionId = sessionId;
409
+ } catch (err) {
410
+ const shouldAuth = isAuthRequiredError(err) && oauth && getMcpUrl && !skipLiveDiscovery;
411
+ if (shouldAuth) {
412
+ const mcpUrl = getMcpUrl();
413
+ if (mcpUrl) {
414
+ const authSuccess = await handleAuthRequired(mcpUrl);
415
+ if (authSuccess) {
416
+ return addMcpCommands(options);
417
+ }
375
418
  }
376
419
  }
420
+ if (!skipLiveDiscovery) {
421
+ console.error(`Failed to connect to MCP server: ${err instanceof Error ? err.message : err}`);
422
+ }
423
+ } finally {
424
+ await client.close();
377
425
  }
378
- console.error(`Failed to connect to MCP server: ${err instanceof Error ? err.message : err}`);
379
- return;
380
- } finally {
381
- await client.close();
382
426
  }
427
+
428
+ if (!tools && cachedTools) {
429
+ tools = cachedTools.tools;
430
+ }
431
+ }
432
+
433
+ if (!tools) {
434
+ return;
383
435
  }
384
436
 
385
437
  // Register CLI commands for each tool