diff --git a/CHANGELOG.md b/CHANGELOG.md index 44d159c..b1dacfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Print OAuth manual-completion URLs at the default warning log level so headless users can copy them. (PR #143 / issue #139, thanks @stainlu) - Support repeatable `--header KEY=value` flags for ad-hoc HTTP servers and persisted ad-hoc entries. (Issue #117) - Let generated CLIs use `--raw` without also passing required flags, and parse array flags containing JSON object items. (Issues #102 and #103) +- Preserve `auth: "oauth"` when an ad-hoc HTTP server is OAuth-promoted and saved with `--persist`. (Issue #82) ### Config diff --git a/docs/config.md b/docs/config.md index c9836d9..2896baa 100644 --- a/docs/config.md +++ b/docs/config.md @@ -149,6 +149,7 @@ Use `--scope home|project` with `mcporter config add` to pick the write target e - Names default to slugified hostnames or executable/script combos. Supply `--name` to improve reuse; mcporter uses that slug for OAuth caches even before persistence. - `--allow-http` is mandatory for cleartext endpoints so we never downgrade transport silently. - Add `--persist ` (defaulting to `config/mcporter.json` when omitted) to copy the ad-hoc definition into config. We reuse the same serializer as the import pipeline, so copying from Cursor → local config produces identical structure and preserves custom env/header fields. +- When an ad-hoc HTTP server returns an OAuth challenge during `list`, `call`, or `auth`, the persisted entry is rewritten with `auth: "oauth"` so later commands use the cached OAuth path instead of retrying unauthenticated HTTP. - `--env KEY=VAL` entries merge with existing `env` dictionaries if you later persist the same server; nothing is lost when you alternate between CLI flags and JSON edits. - `--header KEY=VAL` entries merge into the persisted HTTP `headers` object when used with `--persist`; values support the same `$env:VAR`, `${VAR}`, and `${VAR:-fallback}` placeholders as config-file headers. diff --git a/src/cli/auth-command.ts b/src/cli/auth-command.ts index 7fb2a68..4583036 100644 --- a/src/cli/auth-command.ts +++ b/src/cli/auth-command.ts @@ -5,7 +5,7 @@ import { clearOAuthCaches } from '../oauth-persistence.js'; import type { createRuntime } from '../runtime.js'; import type { EphemeralServerSpec } from './adhoc-server.js'; import { extractEphemeralServerFlags } from './ephemeral-flags.js'; -import { prepareEphemeralServerTarget } from './ephemeral-target.js'; +import { persistPreparedEphemeralServer, prepareEphemeralServerTarget } from './ephemeral-target.js'; import { looksLikeHttpUrl } from './http-utils.js'; import { buildConnectionIssueEnvelope } from './json-output.js'; import { logInfo, logWarn } from './logger-context.js'; @@ -53,8 +53,12 @@ export async function handleAuth(runtime: Runtime, args: string[]): Promise; timeoutMs: number; + ephemeralTarget?: PrepareEphemeralServerTargetResult; } export async function handleCall(runtime: Runtime, args: string[]): Promise { - const prepared = await prepareCallRequest(runtime, args); - if (!prepared) { - return; - } + let prepared: PreparedCallRequest | undefined; + try { + prepared = await prepareCallRequest(runtime, args); + if (!prepared) { + return; + } - const invocation = await invokePreparedCall(runtime, prepared); - if (!invocation) { - return; - } + const invocation = await invokePreparedCall(runtime, prepared); + if (!invocation) { + return; + } - renderCallResult(invocation.result, prepared.parsed); + renderCallResult(invocation.result, prepared.parsed); + } finally { + await persistPreparedEphemeralServer(runtime, prepared?.ephemeralTarget); + } } async function prepareCallRequest(runtime: Runtime, args: string[]): Promise { const parsed = parseCallArguments(args); - await normalizeParsedCallArguments(runtime, parsed); + const ephemeralTarget = await normalizeParsedCallArguments(runtime, parsed); const { server, tool } = await resolveServerAndTool(runtime, parsed); if (await maybeDescribeServer(runtime, server, tool, parsed.output)) { @@ -72,10 +82,13 @@ async function prepareCallRequest(runtime: Runtime, args: string[]): Promise
 {
+async function normalizeParsedCallArguments(
+  runtime: Runtime,
+  parsed: CallArgsParseResult
+): Promise {
   let ephemeralSpec = parsed.ephemeral ? { ...parsed.ephemeral } : undefined;
   const nameHints: string[] = [];
   const absorbUrlCandidate = (value: string | undefined): string | undefined => {
@@ -122,6 +135,7 @@ async function normalizeParsedCallArguments(runtime: Runtime, parsed: CallArgsPa
   if (!parsed.selector) {
     parsed.selector = prepared.target;
   }
+  return prepared;
 }
 
 async function resolveServerAndTool(runtime: Runtime, parsed: CallArgsParseResult): Promise {
diff --git a/src/cli/ephemeral-target.ts b/src/cli/ephemeral-target.ts
index 066c85e..68e8d53 100644
--- a/src/cli/ephemeral-target.ts
+++ b/src/cli/ephemeral-target.ts
@@ -17,9 +17,10 @@ interface PrepareEphemeralServerTargetOptions {
   reuseFromSpec?: boolean;
 }
 
-interface PrepareEphemeralServerTargetResult {
+export interface PrepareEphemeralServerTargetResult {
   target?: string;
   resolution?: EphemeralServerResolution;
+  persistPath?: string;
 }
 
 export async function prepareEphemeralServerTarget(
@@ -85,7 +86,33 @@ export async function prepareEphemeralServerTarget(
     await persistEphemeralServer(resolution, spec.persistPath);
   }
   const resolvedTarget = target ?? resolution.name;
-  return { target: resolvedTarget, resolution };
+  return { target: resolvedTarget, resolution, persistPath: spec.persistPath };
+}
+
+export async function persistPreparedEphemeralServer(
+  runtime: Runtime,
+  prepared: PrepareEphemeralServerTargetResult | undefined
+): Promise {
+  if (!prepared?.resolution || !prepared.persistPath) {
+    return;
+  }
+  let currentDefinition;
+  try {
+    currentDefinition = runtime.getDefinition(prepared.resolution.name);
+  } catch {
+    return;
+  }
+  const persistedEntry = { ...prepared.resolution.persistedEntry };
+  if (currentDefinition.auth === 'oauth') {
+    persistedEntry.auth = 'oauth';
+  }
+  await persistEphemeralServer(
+    {
+      ...prepared.resolution,
+      persistedEntry,
+    },
+    prepared.persistPath
+  );
 }
 
 function applyNameHints(spec: EphemeralServerSpec | undefined, hints: string[] | undefined): void {
diff --git a/src/cli/list-command.ts b/src/cli/list-command.ts
index 7d4a34b..5ddd609 100644
--- a/src/cli/list-command.ts
+++ b/src/cli/list-command.ts
@@ -4,7 +4,7 @@ import { MCPORTER_VERSION } from '../runtime.js';
 import { setStdioLogMode } from '../sdk-patches.js';
 import type { EphemeralServerSpec } from './adhoc-server.js';
 import { extractEphemeralServerFlags } from './ephemeral-flags.js';
-import { prepareEphemeralServerTarget } from './ephemeral-target.js';
+import { persistPreparedEphemeralServer, prepareEphemeralServerTarget } from './ephemeral-target.js';
 import { splitHttpToolSelector } from './http-utils.js';
 import { chooseClosestIdentifier, renderIdentifierResolutionMessages } from './identifier-helpers.js';
 import { formatExampleBlock } from './list-detail-helpers.js';
@@ -253,6 +253,7 @@ export async function handleList(
   if (flags.format === 'json') {
     try {
       const metadataEntries = await withTimeout(loadToolMetadata(runtime, target, { includeSchema: true }), timeoutMs);
+      await persistPreparedEphemeralServer(runtime, prepared);
       const durationMs = Date.now() - startedAt;
       const payload = {
         mode: 'server',
@@ -274,6 +275,7 @@ export async function handleList(
       console.log(JSON.stringify(payload, null, 2));
       return;
     } catch (error) {
+      await persistPreparedEphemeralServer(runtime, prepared);
       const durationMs = Date.now() - startedAt;
       const authCommand = buildAuthCommandHint(definition);
       const advice = classifyListError(error, definition.name, timeoutMs, { authCommand });
@@ -298,6 +300,7 @@ export async function handleList(
   try {
     // Always request schemas so we can render CLI-style parameter hints without re-querying per tool.
     const metadataEntries = await withTimeout(loadToolMetadata(runtime, target, { includeSchema: true }), timeoutMs);
+    await persistPreparedEphemeralServer(runtime, prepared);
     const durationMs = Date.now() - startedAt;
     const summaryLine = printSingleServerHeader(
       definition,
@@ -338,6 +341,7 @@ export async function handleList(
     console.log('');
     return;
   } catch (error) {
+    await persistPreparedEphemeralServer(runtime, prepared);
     const durationMs = Date.now() - startedAt;
     printSingleServerHeader(definition, undefined, durationMs, transportSummary, sourcePath);
     const message = error instanceof Error ? error.message : 'Failed to load tool list.';
diff --git a/tests/cli-list-classification.test.ts b/tests/cli-list-classification.test.ts
index d6d5d70..8134fe9 100644
--- a/tests/cli-list-classification.test.ts
+++ b/tests/cli-list-classification.test.ts
@@ -1,3 +1,6 @@
+import fs from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
 import { describe, expect, it, vi } from 'vitest';
 import type { ServerDefinition } from '../src/config.js';
 import { cliModulePromise, linearDefinition } from './fixtures/cli-list-fixtures.js';
@@ -115,6 +118,51 @@ describe('CLI list classification and routing', () => {
     logSpy.mockRestore();
   });
 
+  it('persists OAuth promotion for ad-hoc HTTP servers', async () => {
+    const { handleList } = await cliModulePromise;
+    const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-persist-oauth-'));
+    const persistPath = path.join(tempDir, 'mcporter.json');
+    const definitions = new Map();
+    const runtime = {
+      registerDefinition: vi.fn((definition: ServerDefinition) => {
+        definitions.set(definition.name, definition);
+      }),
+      getDefinition: vi.fn((name: string) => {
+        const entry = definitions.get(name);
+        if (!entry) {
+          throw new Error(`Unknown MCP server '${name}'.`);
+        }
+        return entry;
+      }),
+      getDefinitions: () => Array.from(definitions.values()),
+      listTools: vi.fn(async (name: string) => {
+        const entry = definitions.get(name);
+        if (!entry) {
+          throw new Error(`Unknown MCP server '${name}'.`);
+        }
+        definitions.set(name, { ...entry, auth: 'oauth' });
+        return [{ name: 'ok' }];
+      }),
+    } as unknown as Awaited>;
+
+    const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
+
+    try {
+      await handleList(runtime, ['https://mcp.granola.ai/mcp', '--persist', persistPath]);
+
+      const parsed = JSON.parse(await fs.readFile(persistPath, 'utf8')) as {
+        mcpServers: Record;
+      };
+      expect(parsed.mcpServers['mcp-granola-ai-mcp']).toMatchObject({
+        baseUrl: 'https://mcp.granola.ai/mcp',
+        auth: 'oauth',
+      });
+    } finally {
+      logSpy.mockRestore();
+      await fs.rm(tempDir, { recursive: true, force: true });
+    }
+  });
+
   it('reuses configured servers when listing by URL', async () => {
     const { handleList } = await cliModulePromise;
     const definition: ServerDefinition = {