@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.
- package/handlers/auth.js +160 -0
- package/handlers/index.js +2 -0
- package/http.js +6 -1
- package/index.js +21 -52
- package/lib/auth-guidance.js +67 -0
- package/lib/cli-credential.js +3 -12
- package/lib/create-server.js +47 -4
- package/lib/credentials.js +106 -0
- package/lib/git-helpers.js +115 -52
- package/lib/http-client.js +19 -6
- package/lib/instructions.generated.js +14 -0
- package/lib/instructions.js +37 -0
- package/lib/oauth-config.js +98 -0
- package/lib/oauth.js +353 -0
- package/lib/remote-tools.js +6 -0
- package/lib/token-store.js +136 -0
- package/lib/user-paths.js +41 -0
- package/lib/version.js +1 -1
- package/package.json +9 -6
- package/prompts/commands.generated.js +52 -0
- package/prompts/index.js +62 -29
- package/scripts/build-instructions.mjs +76 -0
- package/scripts/build-prompts.mjs +144 -0
- package/tools/auth.js +34 -0
- package/tools/index.js +4 -0
- package/prompts/ai-workflow-automation.js +0 -96
- package/prompts/zephly-usage-guide.js +0 -119
package/handlers/auth.js
ADDED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
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
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
//
|
|
88
|
-
|
|
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
|
+
}
|
package/lib/cli-credential.js
CHANGED
|
@@ -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
|
-
//
|
|
30
|
-
|
|
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() {
|
package/lib/create-server.js
CHANGED
|
@@ -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 {
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
+
}
|