@ezmodo/mcp-server 0.13.5 → 0.14.0

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.
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Sign-in handlers (#2632).
3
+ *
4
+ * THE SHAPE THAT MATTERS: `login` returns as soon as it has a URL, and does
5
+ * NOT wait for the user.
6
+ *
7
+ * Blocking until the browser round trip finished would read better in a
8
+ * transcript — one call, "signed in as you" — but it puts a human's attention
9
+ * span on the critical path of a tool call. Clients time tool calls out at
10
+ * wildly different limits, and a client that gives up at thirty seconds would
11
+ * report a failure for a sign-in that then succeeds in the background, leaving
12
+ * the agent with a wrong answer and the user with a working login. Returning
13
+ * immediately is correct under every timeout.
14
+ *
15
+ * The flow keeps running after the return: the loopback listener is alive in
16
+ * this process, and completion writes the tokens itself. So the recovery is
17
+ * simply for the agent to retry whatever it was doing.
18
+ */
19
+
20
+ import { beginLogin, getSignedInIdentity, signOut } from '../lib/oauth.js';
21
+ import { getApiKey } from '../lib/env.js';
22
+ import { signInRequired } from '../lib/auth-guidance.js';
23
+ import { resetCredentialCache } from '../lib/credentials.js';
24
+ import { getLogger } from '../lib/logger.js';
25
+
26
+ /**
27
+ * The sign-in currently waiting on a browser, if any.
28
+ *
29
+ * Held so a second `login` while one is already open returns the SAME URL
30
+ * rather than starting a rival flow. Two live flows would mean two loopback
31
+ * listeners and two states, and whichever URL the user did not click would sit
32
+ * there until it timed out.
33
+ */
34
+ let pending = null;
35
+
36
+ /**
37
+ * Abandon a sign-in that is still waiting on a browser.
38
+ *
39
+ * Real behaviour, not just a test seam: signing out while a browser tab is
40
+ * still open should not leave a loopback listener alive that would quietly
41
+ * complete the login the user just cancelled.
42
+ */
43
+ export function cancelPendingLogin() {
44
+ if (!pending) return false;
45
+ pending.cancel();
46
+ pending = null;
47
+ return true;
48
+ }
49
+
50
+ /** Open a URL in the user's browser, best-effort. */
51
+ async function openBrowser(url) {
52
+ const { execFile } = await import('child_process');
53
+ const commands =
54
+ process.platform === 'darwin'
55
+ ? [['open', [url]]]
56
+ : process.platform === 'win32'
57
+ ? [['cmd', ['/c', 'start', '', url]]]
58
+ : [
59
+ ['xdg-open', [url]],
60
+ ['sensible-browser', [url]],
61
+ ['x-www-browser', [url]],
62
+ ];
63
+
64
+ for (const [command, args] of commands) {
65
+ const opened = await new Promise((resolve) => {
66
+ // execFile, not exec: no shell, so a URL cannot be read as shell syntax.
67
+ execFile(command, args, (error) => resolve(!error));
68
+ });
69
+ if (opened) return true;
70
+ }
71
+ // Not a failure. The URL in the result is the contract; the browser launch
72
+ // is a convenience, and it is expected to fail over SSH or in a container.
73
+ return false;
74
+ }
75
+
76
+ async function login() {
77
+ const log = getLogger();
78
+
79
+ if (pending) {
80
+ return {
81
+ ...signInRequired({ authUrl: pending.authUrl, reason: 'A sign-in is already waiting.' }),
82
+ browserOpened: pending.browserOpened,
83
+ };
84
+ }
85
+
86
+ const flow = await beginLogin();
87
+ const browserOpened = await openBrowser(flow.authUrl);
88
+
89
+ pending = { authUrl: flow.authUrl, browserOpened, cancel: flow.cancel };
90
+ flow
91
+ .complete()
92
+ .then((tokens) => {
93
+ // The credential chain memoizes the CLI lookup; drop it so the fresh
94
+ // OAuth token is what the next call sees.
95
+ resetCredentialCache();
96
+ log.info('Browser sign-in completed', { email: tokens.email });
97
+ })
98
+ .catch((error) => log.warn('Browser sign-in did not complete', { error: error.message }))
99
+ .finally(() => {
100
+ pending = null;
101
+ });
102
+
103
+
104
+ return {
105
+ ...signInRequired({
106
+ authUrl: flow.authUrl,
107
+ reason: 'Sign-in started. Waiting for you to approve it in a browser.',
108
+ }),
109
+ browserOpened,
110
+ next_step: browserOpened
111
+ ? 'A browser should have opened. Once approved, retry the call you were making.'
112
+ : 'No browser could be opened here — show the URL to the user, then retry the call.',
113
+ };
114
+ }
115
+
116
+ function status() {
117
+ const envKey = getApiKey();
118
+ if (envKey) {
119
+ return {
120
+ authenticated: true,
121
+ source: 'EZMODO_API_KEY',
122
+ note: 'An explicit key is set, so it takes precedence over any browser sign-in.',
123
+ };
124
+ }
125
+
126
+ const identity = getSignedInIdentity();
127
+ if (identity) {
128
+ return { authenticated: true, source: 'OAuth', ...identity };
129
+ }
130
+
131
+ return signInRequired({ reason: 'This server is not signed in.' });
132
+ }
133
+
134
+ /**
135
+ * @param {{ action?: 'login'|'status'|'sign_out' }} args
136
+ */
137
+ export async function authenticate(args = {}) {
138
+ switch (args.action || 'login') {
139
+ case 'status':
140
+ return status();
141
+ case 'sign_out': {
142
+ // Cancel first: a listener left alive would complete the very sign-in
143
+ // being abandoned.
144
+ const cancelled = cancelPendingLogin();
145
+ signOut();
146
+ resetCredentialCache();
147
+ return {
148
+ signedOut: true,
149
+ cancelledPendingSignIn: cancelled,
150
+ note:
151
+ 'Stored OAuth tokens removed. EZMODO_API_KEY and the ezmodo CLI login ' +
152
+ 'are untouched — this server does not own either.',
153
+ };
154
+ }
155
+ case 'login':
156
+ return login();
157
+ default:
158
+ throw new Error(`Unknown action: ${args.action}. Use login, status or sign_out.`);
159
+ }
160
+ }
package/handlers/index.js CHANGED
@@ -38,12 +38,14 @@ import * as agentHandlers from './agents.js';
38
38
  import * as recurringTaskHandlers from './recurring-tasks.js';
39
39
  import * as workTemplateHandlers from './work-templates.js';
40
40
  import { manageWorktree, listProjectWorktrees } from '../lib/worktree-tools.js';
41
+ import { authenticate } from './auth.js';
41
42
 
42
43
  /**
43
44
  * Map of tool names to handler functions
44
45
  * Used by the MCP server to route tool calls
45
46
  */
46
47
  export const HANDLERS = {
48
+ authenticate,
47
49
  // Organizations
48
50
  get_organization: organizationHandlers.getOrganization,
49
51
 
package/http.js CHANGED
@@ -91,7 +91,12 @@ export function protectedResourceMetadata() {
91
91
  authorization_servers: AUTH_SERVER ? [AUTH_SERVER] : [],
92
92
  scopes_supported: CONSENT_SCOPES,
93
93
  bearer_methods_supported: ['header'],
94
- resource_documentation: 'https://ezmodo.com/help/connectors',
94
+ // The DB-backed help centre, verified to resolve. /help/connectors was
95
+ // invented for this field and never existed — a dead link shipped inside a
96
+ // public discovery document, where nobody would notice because nothing in
97
+ // the handshake reads it.
98
+ resource_documentation:
99
+ process.env.MCP_DOCS_URL || 'https://ezmodo.com/docs/emo/ezmodo/help/cli-mcp',
95
100
  };
96
101
  }
97
102
 
package/index.js CHANGED
@@ -15,8 +15,7 @@ import { CONFIG } from './config/index.js';
15
15
  // The tool surface, shared with the HTTP entry point (http.js).
16
16
  import { createServer } from './lib/create-server.js';
17
17
  import { initLogger, getLogger } from './lib/logger.js';
18
- import { getApiKey } from './lib/env.js';
19
- import { readCliCredential } from './lib/cli-credential.js';
18
+ import { describeCredentialSync } from './lib/credentials.js';
20
19
 
21
20
  // ============================================================
22
21
  // Configuration Validation
@@ -26,66 +25,36 @@ import { readCliCredential } from './lib/cli-credential.js';
26
25
  initLogger();
27
26
  const log = getLogger();
28
27
 
29
- let apiKey = getApiKey();
30
- let apiKeySource = 'EZMODO_API_KEY';
31
-
32
- // Fall back to the credential `ezmodo auth login` already stored (#2611).
28
+ // The credential is REPORTED here, not required here.
33
29
  //
34
- // The environment always wins: an explicit key must beat an implicit one, or
35
- // overriding it for a single project becomes impossible to reason about. This
36
- // only runs when there is nothing to override.
30
+ // This used to exit(1) when it found no API key, which was right when a key
31
+ // was the only way in: a server that cannot authenticate can do nothing, so
32
+ // failing loudly beat failing on every tool call. OAuth changes that (#2631) —
33
+ // signing in happens through the browser AFTER startup, so a server with no
34
+ // credential yet is not broken, it is waiting. Exiting would make the one case
35
+ // we now want to support impossible.
37
36
  //
38
- // Assigning to process.env rather than threading the value through is
39
- // deliberate lib/http-client.js re-reads getApiKey() on every request, so
40
- // this is the one place that has to know. It is the same thing the CLI's own
41
- // startMcpServer does before importing a handler.
42
- if (!apiKey) {
43
- const stored = readCliCredential();
44
- if (stored) {
45
- process.env.EZMODO_API_KEY = stored.key;
46
- apiKey = stored.key;
47
- apiKeySource = stored.source;
48
- }
49
- }
50
-
51
- if (!apiKey) {
52
- log.error('No API key: EZMODO_API_KEY is unset and no ezmodo CLI credential was found', {
53
- settingsUrl: CONFIG.settingsUrl,
54
- });
55
- console.error('ERROR: No ezmodo API key found.');
56
- console.error('');
57
- console.error('Checked, in order:');
58
- console.error(' 1. EZMODO_API_KEY (or legacy ZEPHLY_API_KEY) — not set');
59
- console.error(' 2. the credential stored by `ezmodo auth login` — not found');
60
- console.error('');
61
- console.error('Fix it either way:');
62
- console.error(' • run `ezmodo auth login`, or');
63
- console.error(' • set EZMODO_API_KEY in the environment');
64
- console.error('');
65
- console.error(`Generate a key at: ${CONFIG.settingsUrl}`);
66
- console.error('\nOptional environment variables:');
67
- console.error('- EZMODO_API_URL (default: production Go API URL)');
68
- console.error('- EZMODO_ENVIRONMENT (dev/staging/production)');
69
- console.error('\nEnvironment-specific URLs:');
70
- console.error(' - dev: https://dev.ezmodo.com/api');
71
- console.error(' - staging: https://staging.ezmodo.com/api');
72
- console.error(' - production: https://ezmodo.com/api');
73
- console.error(' - local: http://localhost:8787/api');
74
- process.exit(1);
75
- }
37
+ // Nothing is resolved asynchronously here either: a stale token would make the
38
+ // server block on Keycloak before it ever spoke MCP. The refresh happens on
39
+ // the first call that needs one.
40
+ const credential = describeCredentialSync();
76
41
 
77
42
  log.info('MCP server starting', {
78
43
  environment: CONFIG.environment,
79
44
  apiUrl: CONFIG.apiUrl,
80
- apiKeyPrefix: apiKey.substring(0, 12),
81
- apiKeySource,
45
+ credentialSource: credential?.source ?? 'none',
82
46
  });
83
47
  console.error('🔧 ezmodo MCP Server Configuration:');
84
48
  console.error(` Environment: ${CONFIG.environment} (build-time)`);
85
49
  console.error(` API URL: ${CONFIG.apiUrl}`);
86
- // Name the source. A server that silently authenticates as whoever the CLI
87
- // happens to be logged in as, with no way to tell, is worse than one that fails.
88
- console.error(` API Key: ${apiKey.substring(0, 12)}... (from ${apiKeySource})`);
50
+ if (credential) {
51
+ // Name the source. A server that silently authenticates as whoever the CLI
52
+ // happens to be logged in as, with no way to tell, is worse than one that fails.
53
+ console.error(` Credential: ${credential.detail ?? ''} (from ${credential.source})`);
54
+ } else {
55
+ console.error(' Credential: none yet — sign in with the `authenticate` tool');
56
+ console.error(` Or set EZMODO_API_KEY. Generate a key at: ${CONFIG.settingsUrl}`);
57
+ }
89
58
  console.error('');
90
59
 
91
60
  // ============================================================
@@ -0,0 +1,67 @@
1
+ /**
2
+ * What an agent is told when a call cannot be authenticated (#2632).
3
+ *
4
+ * The hard problem this solves: a stdio MCP server is started headlessly by an
5
+ * editor, with no terminal a human is watching. It cannot say "go to this URL".
6
+ * The server's own clear startup message goes to a stderr log nobody opens —
7
+ * which is exactly how #2611 produced an opaque CONNECTION_CLOSED instead of
8
+ * the message that was right there.
9
+ *
10
+ * So don't fight it. Use the channel the agent is already reading: the tool
11
+ * result. The agent shows the URL in chat, the user clicks, the agent retries.
12
+ * This is the pattern the claude.ai connector already uses, and it is better UX
13
+ * than an environment variable rather than a workaround for one.
14
+ *
15
+ * Everything here is DATA in a tool result, never a thrown transport error: a
16
+ * result is something the model can read and act on, an exception is not.
17
+ */
18
+
19
+ import { CONFIG } from '../config/index.js';
20
+
21
+ /** Marks the "there is no credential at all" failure, so dispatch can spot it. */
22
+ export const NOT_AUTHENTICATED = 'EZMODO_NOT_AUTHENTICATED';
23
+
24
+ /**
25
+ * The two things a user cannot guess, and will otherwise hit as bare failures.
26
+ *
27
+ * Stated on every sign-in prompt on purpose. Both are consequences of decisions
28
+ * made elsewhere, and neither is discoverable from the error the user would
29
+ * otherwise see.
30
+ */
31
+ const CAVEATS = [
32
+ 'A browser is required. This connector cannot take a username and password ' +
33
+ 'directly — direct access grants are disabled on it deliberately, because ' +
34
+ 'skipping the browser also skips the consent screen, which is the whole ' +
35
+ 'reason for using OAuth here rather than a pasted key.',
36
+ 'A brand-new EzModo account belongs to no organization yet, so tools will ' +
37
+ 'return 403 until someone adds you to one. That is an access problem, not ' +
38
+ 'a sign-in problem — signing in again will not change it.',
39
+ ];
40
+
41
+ /**
42
+ * The payload returned when a sign-in is needed.
43
+ *
44
+ * @param {object} options
45
+ * @param {string} [options.authUrl] The URL to open, when a flow has started.
46
+ * @param {string} [options.reason] What went wrong, in one line.
47
+ */
48
+ export function signInRequired({ authUrl, reason } = {}) {
49
+ return {
50
+ authenticated: false,
51
+ reason: reason || 'No EzModo credential is available.',
52
+ ...(authUrl
53
+ ? {
54
+ action_required: 'Open this URL in a browser, approve the access, then retry the call.',
55
+ authUrl,
56
+ }
57
+ : {
58
+ action_required:
59
+ 'Call the `authenticate` tool to start sign-in. It returns a URL to open.',
60
+ }),
61
+ alternatives: [
62
+ `Set EZMODO_API_KEY in the environment instead — generate a key at ${CONFIG.settingsUrl}. ` +
63
+ 'This is the right path for CI and anything headless.',
64
+ ],
65
+ notes: CAVEATS,
66
+ };
67
+ }
@@ -23,19 +23,10 @@
23
23
 
24
24
  import { execFileSync } from 'child_process';
25
25
  import { existsSync, readFileSync } from 'fs';
26
- import { homedir } from 'os';
27
26
  import { join } from 'path';
28
-
29
- // `.config/ezmodo` is current, `.config/zephly` the pre-rebrand name still
30
- // present in older installs. Current wins; legacy is a read fallback only.
31
- function configDirs() {
32
- const home = homedir();
33
- if (process.platform === 'win32') {
34
- const base = process.env.APPDATA || home;
35
- return [join(base, 'ezmodo'), join(base, 'zephly')];
36
- }
37
- return [join(home, '.config', 'ezmodo'), join(home, '.config', 'zephly')];
38
- }
27
+ // Shared with lib/token-store.js so the two cannot disagree about where the
28
+ // ezmodo config directory is they write files side by side there.
29
+ import { configDirs } from './user-paths.js';
39
30
 
40
31
  /** The Linux/Windows path: a 0600 JSON file written by `ezmodo auth login`. */
41
32
  function fromCredentialsFile() {
@@ -22,10 +22,12 @@ import {
22
22
 
23
23
  import { TOOLS } from '../tools/index.js';
24
24
  import { HANDLERS } from '../handlers/index.js';
25
- import { PROMPTS, getPromptContent } from '../prompts/index.js';
25
+ import { listPrompts, getPromptContent } from '../prompts/index.js';
26
26
  import { MCP_VERSION } from './version.js';
27
27
  import { getLogger } from './logger.js';
28
28
  import { isRemoteSafe } from './remote-tools.js';
29
+ import { NOT_AUTHENTICATED, signInRequired } from './auth-guidance.js';
30
+ import { getInstructions } from './instructions.js';
29
31
 
30
32
  /**
31
33
  * @param {object} [options]
@@ -34,6 +36,23 @@ import { isRemoteSafe } from './remote-tools.js';
34
36
  * 'remote' excludes tools that operate on the local filesystem or git — see
35
37
  * lib/remote-tools.js for why that is an allowlist and not a denylist.
36
38
  */
39
+ /**
40
+ * Whether a failure means "nobody is signed in" rather than "the call went
41
+ * wrong".
42
+ *
43
+ * 401 counts as well as the no-credential case: a credential that WAS valid can
44
+ * stop being so — a revoked API key, or a refresh token whose grant expired
45
+ * while the editor sat open overnight — and telling the user to sign in is the
46
+ * right answer to both.
47
+ *
48
+ * 403 deliberately does NOT count. That means signed in but not permitted,
49
+ * most often a new account in no organization yet, and sending someone back
50
+ * through a sign-in that cannot fix it is worse than saying nothing.
51
+ */
52
+ function isAuthFailure(error) {
53
+ return error?.code === NOT_AUTHENTICATED || error?.status === 401;
54
+ }
55
+
37
56
  export function createServer({ surface = 'local' } = {}) {
38
57
  const log = getLogger();
39
58
 
@@ -46,7 +65,14 @@ export function createServer({ surface = 'local' } = {}) {
46
65
 
47
66
  const server = new Server(
48
67
  { name: 'ezmodo-mcp-server', version: MCP_VERSION },
49
- { capabilities: { tools: {}, prompts: {} } }
68
+ {
69
+ capabilities: { tools: {}, prompts: {} },
70
+ // Reaches every client on every session, which is what makes it the one
71
+ // place the work-tracking discipline can live without a per-editor
72
+ // plugin (#2633). Varies by surface for the same reason the tool list
73
+ // does.
74
+ instructions: getInstructions(surface),
75
+ }
50
76
  );
51
77
 
52
78
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
@@ -83,6 +109,19 @@ export function createServer({ surface = 'local' } = {}) {
83
109
  } catch (error) {
84
110
  const errMsg = error.message || String(error);
85
111
  log.error('Tool call failed', { tool: name, error: errMsg, durationMs: Date.now() - start });
112
+
113
+ // An authentication failure is not an error the model should relay as
114
+ // one — it is a request for the user to do something. Funnelled HERE, at
115
+ // the single dispatch point, for the same reason the surface filter is:
116
+ // every tool goes through it, so none of them can be missed or drift
117
+ // (#2632). Only on the local surface; over the connector Claude owns the
118
+ // OAuth and this advice would be wrong.
119
+ if (surface !== 'remote' && isAuthFailure(error)) {
120
+ return {
121
+ content: [{ type: 'text', text: JSON.stringify(signInRequired({ reason: errMsg }), null, 2) }],
122
+ isError: true,
123
+ };
124
+ }
86
125
  // Returned as content rather than thrown: a tool that fails is a result
87
126
  // the model can read and act on, not a transport error.
88
127
  return {
@@ -92,10 +131,14 @@ export function createServer({ surface = 'local' } = {}) {
92
131
  }
93
132
  });
94
133
 
95
- server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: PROMPTS }));
134
+ // Filtered by surface exactly as tools are, and for the same reason: `submit`
135
+ // reads git SHAs and links commits, which a hosted server cannot do.
136
+ server.setRequestHandler(ListPromptsRequestSchema, async () => ({
137
+ prompts: listPrompts(surface),
138
+ }));
96
139
 
97
140
  server.setRequestHandler(GetPromptRequestSchema, async (request) => {
98
- const content = getPromptContent(request.params.name);
141
+ const content = getPromptContent(request.params.name, request.params.arguments, surface);
99
142
  if (!content) {
100
143
  throw new Error(`Unknown prompt: ${request.params.name}`);
101
144
  }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * The one place that decides which credential a request is made with.
3
+ *
4
+ * Four sources, and the ORDER is the contract (#2631):
5
+ *
6
+ * 1. The request context. Only ever set by the HTTP transport, where one
7
+ * process serves many callers and the credential belongs to the request
8
+ * rather than the process (#2599). Over stdio this is always empty.
9
+ * 2. EZMODO_API_KEY (or legacy ZEPHLY_API_KEY). An EXPLICIT credential must
10
+ * beat an implicit one, or overriding the key for a single project becomes
11
+ * impossible to reason about — and this is what keeps CI, containers and
12
+ * anything headless working exactly as before OAuth existed.
13
+ * 3. The OAuth token this server obtained for itself, refreshed if stale.
14
+ * The zero-configuration path: nothing to paste, nothing in a profile.
15
+ * 4. The credential `ezmodo auth login` stored. Last because it belongs to
16
+ * another program: a server that silently authenticates as whoever the
17
+ * CLI happens to be logged in as should do so only when nothing else
18
+ * said otherwise.
19
+ *
20
+ * 3 above 4 is the deliberate part. Both are implicit, so neither "wins" on
21
+ * explicitness; what separates them is that the OAuth token is THIS server's
22
+ * own, obtained by a person who was shown a consent screen naming this
23
+ * connector, while the CLI's key is a credential borrowed from a different
24
+ * tool. Prefer the one whose grant the user actually saw.
25
+ */
26
+
27
+ import { getApiKey } from './env.js';
28
+ import { getRequestContext, resolveApiKey } from './request-context.js';
29
+ import { getAccessToken, getSignedInIdentity } from './oauth.js';
30
+ import { readCliCredential } from './cli-credential.js';
31
+
32
+ /**
33
+ * The CLI credential, looked up at most once.
34
+ *
35
+ * Memoized because reading it is not free: on macOS it shells out to
36
+ * `security` to read the login Keychain, and doing that on every API call
37
+ * would put a subprocess spawn — and a possible ACL prompt — on the hot path.
38
+ * `undefined` means "not looked up yet"; `null` means "looked up, nothing
39
+ * there".
40
+ */
41
+ let cachedCliCredential;
42
+
43
+ function cliCredential() {
44
+ if (cachedCliCredential === undefined) {
45
+ cachedCliCredential = readCliCredential() ?? null;
46
+ }
47
+ return cachedCliCredential;
48
+ }
49
+
50
+ /**
51
+ * Resolve the credential for the call in flight.
52
+ *
53
+ * Returns null rather than throwing when there is nothing to use. Every caller
54
+ * is on the path of a tool call, and "sign in first" is something an agent can
55
+ * act on; an exception is not.
56
+ *
57
+ * @returns {Promise<{ token: string, source: string }|null>}
58
+ */
59
+ export async function resolveCredential() {
60
+ // Steps 1 and 2 together. Deliberately NOT re-implemented here:
61
+ // resolveApiKey() already means "request context, else environment", and a
62
+ // second copy of that precedence is how the two drift apart.
63
+ const explicit = resolveApiKey();
64
+ if (explicit) {
65
+ const fromRequest = Boolean(getRequestContext()?.apiKey);
66
+ return { token: explicit, source: fromRequest ? 'request' : 'EZMODO_API_KEY' };
67
+ }
68
+
69
+ const fromOAuth = await getAccessToken();
70
+ if (fromOAuth) {
71
+ const identity = getSignedInIdentity();
72
+ return { token: fromOAuth, source: identity?.email ? `OAuth (${identity.email})` : 'OAuth' };
73
+ }
74
+
75
+ const fromCli = cliCredential();
76
+ if (fromCli) return { token: fromCli.key, source: fromCli.source };
77
+
78
+ return null;
79
+ }
80
+
81
+ /**
82
+ * What the startup banner reports, without triggering a network refresh.
83
+ *
84
+ * Startup must not block on Keycloak: an expired token at boot would make the
85
+ * server hang before it ever spoke MCP, and the refresh happens on the first
86
+ * call anyway.
87
+ *
88
+ * @returns {{ source: string, detail?: string }|null}
89
+ */
90
+ export function describeCredentialSync() {
91
+ const fromEnv = getApiKey();
92
+ if (fromEnv) return { source: 'EZMODO_API_KEY', detail: `${fromEnv.substring(0, 12)}...` };
93
+
94
+ const identity = getSignedInIdentity();
95
+ if (identity) return { source: 'OAuth', detail: identity.email };
96
+
97
+ const fromCli = cliCredential();
98
+ if (fromCli) return { source: fromCli.source, detail: `${fromCli.key.substring(0, 12)}...` };
99
+
100
+ return null;
101
+ }
102
+
103
+ /** Test seam: forget the memoized CLI lookup. */
104
+ export function resetCredentialCache() {
105
+ cachedCliCredential = undefined;
106
+ }