@ezmodo/mcp-server 0.13.5 → 0.14.1
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 +112 -42
- 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 +175 -0
- package/lib/cli-credential.js +3 -12
- package/lib/create-server.js +97 -4
- package/lib/credentials.js +106 -0
- package/lib/git-helpers.js +115 -52
- package/lib/http-client.js +25 -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
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-disk store for the OAuth tokens this server obtains for itself (#2631).
|
|
3
|
+
*
|
|
4
|
+
* Lives at <config dir>/mcp-oauth.json, beside the CLI's own `credentials`
|
|
5
|
+
* file. A DISTINCT filename on purpose: the CLI owns `credentials` and
|
|
6
|
+
* `oauth.json`, and two programs writing one file is how a working login
|
|
7
|
+
* disappears the next time the other one runs. This server only ever reads the
|
|
8
|
+
* CLI's files (see lib/cli-credential.js) and only ever writes its own.
|
|
9
|
+
*
|
|
10
|
+
* Three properties this module is responsible for:
|
|
11
|
+
*
|
|
12
|
+
* 1. It never throws. It sits on the path every tool call takes to resolve a
|
|
13
|
+
* credential. A malformed or unreadable file must degrade to "not signed
|
|
14
|
+
* in" — which prompts a fresh sign-in and fixes itself — rather than
|
|
15
|
+
* taking down the server, which does not.
|
|
16
|
+
* 2. It writes 0600, and creates the directory 0700. These are bearer
|
|
17
|
+
* tokens for the user's whole account; a world-readable file in a shared
|
|
18
|
+
* home directory hands the account over.
|
|
19
|
+
* 3. It writes atomically, via a temp file in the same directory and a
|
|
20
|
+
* rename. A torn write here is indistinguishable from corruption, and
|
|
21
|
+
* corruption logs a user out.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'fs';
|
|
25
|
+
import { join } from 'path';
|
|
26
|
+
import { configDir } from './user-paths.js';
|
|
27
|
+
import { getLogger } from './logger.js';
|
|
28
|
+
|
|
29
|
+
const FILENAME = 'mcp-oauth.json';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Treat a token as expired this many milliseconds BEFORE it actually expires.
|
|
33
|
+
*
|
|
34
|
+
* Without a margin, a token that passes the check can still be rejected by the
|
|
35
|
+
* time the request lands — clock skew between this machine and Keycloak, plus
|
|
36
|
+
* the request's own flight time. Thirty seconds costs nothing (the refresh is
|
|
37
|
+
* one round trip) and removes a class of intermittent 401 that would look like
|
|
38
|
+
* a server bug rather than a clock.
|
|
39
|
+
*/
|
|
40
|
+
const EXPIRY_MARGIN_MS = 30_000;
|
|
41
|
+
|
|
42
|
+
function tokenPath() {
|
|
43
|
+
return join(configDir(), FILENAME);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @typedef {object} StoredTokens
|
|
48
|
+
* @property {string} accessToken
|
|
49
|
+
* @property {string} [refreshToken]
|
|
50
|
+
* @property {string} expiresAt ISO 8601
|
|
51
|
+
* @property {string} [userId]
|
|
52
|
+
* @property {string} [email]
|
|
53
|
+
* @property {string} [scope]
|
|
54
|
+
* @property {string} [issuer] Which Keycloak issued these
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Read the stored tokens, or null if there are none to read.
|
|
59
|
+
*
|
|
60
|
+
* @returns {StoredTokens|null}
|
|
61
|
+
*/
|
|
62
|
+
export function readTokens() {
|
|
63
|
+
const path = tokenPath();
|
|
64
|
+
if (!existsSync(path)) return null;
|
|
65
|
+
try {
|
|
66
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8'));
|
|
67
|
+
if (!parsed || typeof parsed.accessToken !== 'string' || !parsed.accessToken.trim()) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
return parsed;
|
|
71
|
+
} catch (error) {
|
|
72
|
+
// Malformed, truncated, or unreadable. Reporting "not signed in" sends the
|
|
73
|
+
// user through a sign-in that overwrites it; throwing would strand them.
|
|
74
|
+
getLogger().warn('Ignoring unreadable OAuth token file', { path, error: error.message });
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Persist tokens, replacing whatever was there.
|
|
81
|
+
*
|
|
82
|
+
* @param {StoredTokens} tokens
|
|
83
|
+
* @returns {boolean} whether the write landed
|
|
84
|
+
*/
|
|
85
|
+
export function writeTokens(tokens) {
|
|
86
|
+
const dir = configDir();
|
|
87
|
+
const path = tokenPath();
|
|
88
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
89
|
+
try {
|
|
90
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
91
|
+
writeFileSync(temp, JSON.stringify(tokens, null, 2), { encoding: 'utf-8', mode: 0o600 });
|
|
92
|
+
renameSync(temp, path);
|
|
93
|
+
return true;
|
|
94
|
+
} catch (error) {
|
|
95
|
+
getLogger().warn('Could not persist OAuth tokens', { path, error: error.message });
|
|
96
|
+
try {
|
|
97
|
+
if (existsSync(temp)) unlinkSync(temp);
|
|
98
|
+
} catch {
|
|
99
|
+
// Nothing useful to do about a leftover temp file.
|
|
100
|
+
}
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Forget the stored tokens. Used by sign-out and by an unrecoverable refresh. */
|
|
106
|
+
export function clearTokens() {
|
|
107
|
+
const path = tokenPath();
|
|
108
|
+
try {
|
|
109
|
+
if (existsSync(path)) unlinkSync(path);
|
|
110
|
+
return true;
|
|
111
|
+
} catch (error) {
|
|
112
|
+
getLogger().warn('Could not clear OAuth tokens', { path, error: error.message });
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Whether an access token is past use, margin included.
|
|
119
|
+
*
|
|
120
|
+
* Missing or unparseable expiry counts as expired: if we cannot tell, the safe
|
|
121
|
+
* answer is the one that triggers a refresh rather than the one that sends a
|
|
122
|
+
* possibly-dead token to the API.
|
|
123
|
+
*
|
|
124
|
+
* @param {StoredTokens|null} tokens
|
|
125
|
+
*/
|
|
126
|
+
export function isExpired(tokens) {
|
|
127
|
+
if (!tokens?.expiresAt) return true;
|
|
128
|
+
const expiry = Date.parse(tokens.expiresAt);
|
|
129
|
+
if (Number.isNaN(expiry)) return true;
|
|
130
|
+
return Date.now() >= expiry - EXPIRY_MARGIN_MS;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The path tokens are stored at, for diagnostics and messages. */
|
|
134
|
+
export function getTokenPath() {
|
|
135
|
+
return tokenPath();
|
|
136
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where this server keeps per-user state on disk.
|
|
3
|
+
*
|
|
4
|
+
* Extracted so the credential reader and the OAuth token store cannot disagree
|
|
5
|
+
* about where "the ezmodo config directory" is. They had better not: the two
|
|
6
|
+
* files live side by side, and a writer that picks a different directory from
|
|
7
|
+
* the reader produces a login that appears to succeed and then is never found
|
|
8
|
+
* again.
|
|
9
|
+
*
|
|
10
|
+
* `.config/ezmodo` is current, `.config/zephly` the pre-rebrand name still
|
|
11
|
+
* present in older installs. Current always wins; legacy is READ-ONLY. Nothing
|
|
12
|
+
* here ever writes to the legacy directory — migrating it is the CLI's job
|
|
13
|
+
* (cli/src/lib/user-paths.ts owns that), and a second migrator racing the first
|
|
14
|
+
* over the same files is worse than not migrating at all.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { homedir } from 'os';
|
|
18
|
+
import { join } from 'path';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Every directory to SEARCH, current first.
|
|
22
|
+
*
|
|
23
|
+
* @returns {string[]}
|
|
24
|
+
*/
|
|
25
|
+
export function configDirs() {
|
|
26
|
+
const home = homedir();
|
|
27
|
+
if (process.platform === 'win32') {
|
|
28
|
+
const base = process.env.APPDATA || home;
|
|
29
|
+
return [join(base, 'ezmodo'), join(base, 'zephly')];
|
|
30
|
+
}
|
|
31
|
+
return [join(home, '.config', 'ezmodo'), join(home, '.config', 'zephly')];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The single directory to WRITE to. Always the current name.
|
|
36
|
+
*
|
|
37
|
+
* @returns {string}
|
|
38
|
+
*/
|
|
39
|
+
export function configDir() {
|
|
40
|
+
return configDirs()[0];
|
|
41
|
+
}
|
package/lib/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ezmodo/mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.1",
|
|
4
4
|
"description": "MCP server for ezmodo - AI-first project management",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -21,7 +21,9 @@
|
|
|
21
21
|
"test:watch": "BUILD_ENV=development NODE_OPTIONS=--experimental-vm-modules jest --watch",
|
|
22
22
|
"test:coverage": "BUILD_ENV=development NODE_OPTIONS=--experimental-vm-modules jest --coverage",
|
|
23
23
|
"test:smoke": "BUILD_ENV=development node test.js",
|
|
24
|
-
"lint": "eslint . --fix"
|
|
24
|
+
"lint": "eslint . --fix",
|
|
25
|
+
"generate:instructions": "node scripts/build-instructions.mjs",
|
|
26
|
+
"generate:prompts": "node scripts/build-prompts.mjs"
|
|
25
27
|
},
|
|
26
28
|
"keywords": [
|
|
27
29
|
"mcp",
|
|
@@ -36,14 +38,15 @@
|
|
|
36
38
|
"access": "public"
|
|
37
39
|
},
|
|
38
40
|
"files": [
|
|
39
|
-
"
|
|
40
|
-
"http.js",
|
|
41
|
+
"README.md",
|
|
41
42
|
"config/",
|
|
42
43
|
"handlers/",
|
|
44
|
+
"http.js",
|
|
45
|
+
"index.js",
|
|
43
46
|
"lib/",
|
|
44
47
|
"prompts/",
|
|
45
|
-
"
|
|
46
|
-
"
|
|
48
|
+
"scripts/",
|
|
49
|
+
"tools/"
|
|
47
50
|
],
|
|
48
51
|
"homepage": "https://ezmodo.com/docs/emo/ezmodo/help/cli-mcp",
|
|
49
52
|
"bugs": {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GENERATED FILE — DO NOT EDIT.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from plugins/ezmodo/commands/ by scripts/build-prompts.mjs.
|
|
5
|
+
* Edit the command, then run:
|
|
6
|
+
*
|
|
7
|
+
* npm run generate:prompts --workspace=@ezmodo/mcp-server
|
|
8
|
+
*
|
|
9
|
+
* __tests__/prompts.test.js fails if this drifts from the commands.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const COMMAND_PROMPTS = [
|
|
13
|
+
{
|
|
14
|
+
"name": "start",
|
|
15
|
+
"description": "Create an EzModo task for what you are about to build, and start it",
|
|
16
|
+
"argumentHint": "[what you are about to work on]",
|
|
17
|
+
"surfaces": [
|
|
18
|
+
"local",
|
|
19
|
+
"remote"
|
|
20
|
+
],
|
|
21
|
+
"body": "Start tracked work on: **$ARGUMENTS**\n\nFollow the work-tracking contract in this server's instructions. In short:\n\n1. `get_current_project_context()` — cache the `projectId`, note the components,\n tags and `terminology`.\n2. `get_context` with a keyword query drawn from the request above. Read what\n comes back before writing anything: it tells you which files exist, what\n patterns they follow, and what the change will touch.\n3. `resolve_links` on the paths you expect to change. A component you did not\n expect means the work is broader than the request sounds.\n4. Create the work:\n - **Single scope** (a fix, a small feature, a config or docs change) —\n `manage_task action:\"create\"` with `status:\"in_progress\"`, a description\n that says why/where/how, steps that name real files, `componentIds` for\n every component involved, and the right `taskType`.\n - **Multi scope** (spanning areas, or a large refactor) — `manage_epic\n action:\"create\"` with its child tasks in the same request, ordered by\n dependency.\n\nThen report the task number and web URL and begin. Do not edit anything before\nthe task exists — a task created afterwards is a task written from memory.\n\nIf no `.ezmodo/config.json` is found, say so and stop rather than guessing at a\nproject."
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"name": "resume",
|
|
25
|
+
"description": "Load an EzModo task or epic by number and continue where the last session stopped",
|
|
26
|
+
"argumentHint": "<task number, epic number, or id>",
|
|
27
|
+
"surfaces": [
|
|
28
|
+
"local",
|
|
29
|
+
"remote"
|
|
30
|
+
],
|
|
31
|
+
"body": "Resume: **$ARGUMENTS**\n\nLoad it **directly** — `get_task` (with `taskNumber` + `projectId`, or `taskId`)\nor `get_epic`. Do not search; a number or id is an exact address, and\n`search_tasks` / `search_epics` are for when you have neither.\n\nYou will need the `projectId` from `get_current_project_context()` to resolve a\ntask number.\n\nThen, before doing anything:\n\n1. Read **every** knowledge item. That is where the previous session put its\n reasoning — root causes, decisions and what they rejected, blockers.\n2. Look for knowledge tagged `progress-checkpoint` for the latest status.\n3. Note which steps are already complete. Do not redo them.\n\nReport back: what the task is, what has been done, what the last session\nlearned that changes how you would approach the rest, and which step you are\npicking up. Then continue from there, following the work-tracking contract in this server's instructions for the rest.\n\nIf the task is already `in_review` or `completed`, say so and ask before\nreopening it."
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"name": "submit",
|
|
35
|
+
"description": "Finish the active EzModo task — steps, knowledge, commit links, then in_review",
|
|
36
|
+
"argumentHint": "[anything to note in the completion summary]",
|
|
37
|
+
"surfaces": [
|
|
38
|
+
"local"
|
|
39
|
+
],
|
|
40
|
+
"body": "Close out the active task.\n\nHEAD: run `git rev-parse HEAD` and use its output\nRecent commits: run `git log --oneline -5` and use its output\n\nWork through, in order:\n\n1. **Steps** — `toggleSteps` for everything now done. If work happened that no\n step covered, `addStep` it first rather than leaving it unrecorded. If a step\n was deliberately not done, leave it open and say why in the notes.\n2. **Knowledge** — `addKnowledge` for anything the next session would have to\n rediscover: root causes (`fact`), decisions and what they rejected\n (`decision`), blockers (`fact`). Specific: file paths, function names, exact\n error messages.\n3. **Commits** — `link_commit` for every commit not yet linked, using the full\n 40-character SHA above. A short SHA is rejected.\n4. **Link suggestions** — check `list_agent_suggestions action:\"link\"` for this\n task and clear the queue with `resolve_link_suggestions`. Reject with a real\n reason; leaving them pending is the only wrong outcome.\n5. **Test cases** — only if `autoGenerateTestCases` is true in the project\n context. 3-6 cases covering happy path, edges and errors.\n6. **Submit** — `manage_task action:\"update\"` with `status:\"in_review\"` and\n `completionNotes`.\n\nThe notes are the deliverable. They must say what was done, what was verified\nand how, and — explicitly — anything in scope that was **not** done and why.\nA summary that omits the gap is worse than none, because the reviewer trusts it.\n\nDo **not** call `manage_task action:\"complete\"`. A human completes the task.\n\nAnything to include: $ARGUMENTS"
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"name": "untracked",
|
|
44
|
+
"description": "Retroactively capture work already in progress that has no EzModo task",
|
|
45
|
+
"argumentHint": "[what the work was, if the diff does not make it obvious]",
|
|
46
|
+
"surfaces": [
|
|
47
|
+
"local",
|
|
48
|
+
"remote"
|
|
49
|
+
],
|
|
50
|
+
"body": "Capture the current uncommitted work as a task.\n\nCurrent branch: run `git rev-parse --abbrev-ref HEAD` and use its output\nChanged files: run `git status --porcelain` and use its output\n\nUse `report_untracked_work` with:\n\n- `projectId` from `get_current_project_context()`\n- `title` — concise, describing what was actually done\n- `description` — what and **why**. Do not restate the branch or file list; they\n are appended automatically as evidence.\n- `changedFiles` — the paths above\n- `branch` — as above\n- `componentIds` — resolve the changed paths with `resolve_links` rather than\n guessing; untracked work often spans more than one area, which is part of why\n it went untracked\n- `origin`:\n - `discovered` — found while working on another task (set `discoveredDuringTaskId`)\n - `scope-creep` — went beyond the active task's scope (set `discoveredDuringTaskId`)\n - `rework` — redoing prior work\n - `untracked` — unplanned standalone work (the default)\n\nIf there is an active task in this session, prefer `discovered` or\n`scope-creep` and link it — the discovery chain is the point of the\nclassification.\n\nThe new task comes back `in_progress` and becomes the active one. Track against\nit for the rest of the work.\n\nExtra context from the user, if any: $ARGUMENTS"
|
|
51
|
+
}
|
|
52
|
+
];
|
package/prompts/index.js
CHANGED
|
@@ -1,39 +1,72 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* MCP
|
|
3
|
-
*
|
|
2
|
+
* MCP prompts — the things a USER deliberately invokes (#2634).
|
|
3
|
+
*
|
|
4
|
+
* The split that decides what belongs here, now that the server also serves
|
|
5
|
+
* `instructions` (#2633):
|
|
6
|
+
*
|
|
7
|
+
* instructions = what the agent should ALWAYS do. Injected into system
|
|
8
|
+
* context on every session, so it must be short and it must
|
|
9
|
+
* not need asking for.
|
|
10
|
+
* prompts = actions a person chooses. Clients surface them as slash
|
|
11
|
+
* commands, so they are invoked, not absorbed.
|
|
12
|
+
*
|
|
13
|
+
* Serving the always-on discipline in both places would be the same duplicate
|
|
14
|
+
* that #2594 removed from every repo's CLAUDE.md, so it lives in exactly one.
|
|
15
|
+
*
|
|
16
|
+
* WHAT WAS REMOVED HERE, and why it is a fix rather than a loss. This module
|
|
17
|
+
* used to serve `zephly-usage-guide` and `ai-workflow-automation`. Both were
|
|
18
|
+
* pre-rebrand in the user-visible prompt NAME, and both instructed agents to
|
|
19
|
+
* open a session with `list_organizations()` and `list_projects()` — tools that
|
|
20
|
+
* no longer exist in TOOLS. A prompt that names missing tools is not stale
|
|
21
|
+
* documentation, it is an instruction to make a call that fails, and the
|
|
22
|
+
* work-tracking content that replaced it now ships as `instructions`.
|
|
4
23
|
*/
|
|
5
24
|
|
|
6
|
-
|
|
7
|
-
import { ZEPHLY_USAGE_GUIDE } from './zephly-usage-guide.js';
|
|
8
|
-
import { AI_WORKFLOW_AUTOMATION } from './ai-workflow-automation.js';
|
|
25
|
+
import { COMMAND_PROMPTS } from './commands.generated.js';
|
|
9
26
|
|
|
10
27
|
/**
|
|
11
|
-
*
|
|
28
|
+
* The placeholder a Claude Code command uses for its argument. Kept identical
|
|
29
|
+
* so one body serves both surfaces without a second copy.
|
|
12
30
|
*/
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
31
|
+
const ARGUMENTS_TOKEN = '$ARGUMENTS';
|
|
32
|
+
|
|
33
|
+
/** Prompts available on a surface, in the MCP list shape. */
|
|
34
|
+
export function listPrompts(surface = 'local') {
|
|
35
|
+
return COMMAND_PROMPTS.filter((prompt) => prompt.surfaces.includes(surface)).map(
|
|
36
|
+
({ name, description, argumentHint }) => ({
|
|
37
|
+
name,
|
|
38
|
+
description,
|
|
39
|
+
arguments: argumentHint
|
|
40
|
+
? [
|
|
41
|
+
{
|
|
42
|
+
name: 'arguments',
|
|
43
|
+
description: argumentHint.replace(/^[[<]|[\]>]$/g, ''),
|
|
44
|
+
// Never required. `submit` and `untracked` are perfectly usable
|
|
45
|
+
// with nothing to add, and a required argument would make a
|
|
46
|
+
// client refuse to run them at all.
|
|
47
|
+
required: false,
|
|
48
|
+
},
|
|
49
|
+
]
|
|
50
|
+
: [],
|
|
51
|
+
})
|
|
52
|
+
);
|
|
53
|
+
}
|
|
24
54
|
|
|
25
55
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
56
|
+
* The body of one prompt, with the user's argument substituted.
|
|
57
|
+
*
|
|
58
|
+
* Returns null for a name this surface does not serve — including one it holds
|
|
59
|
+
* but does not serve remotely, so `submit` is as absent over the connector as
|
|
60
|
+
* a name that never existed. Reporting it differently would tell a caller a
|
|
61
|
+
* prompt is there and then refuse it.
|
|
29
62
|
*/
|
|
30
|
-
export function getPromptContent(name) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
default:
|
|
37
|
-
return null;
|
|
38
|
-
}
|
|
63
|
+
export function getPromptContent(name, args = {}, surface = 'local') {
|
|
64
|
+
const prompt = COMMAND_PROMPTS.find((candidate) => candidate.name === name);
|
|
65
|
+
if (!prompt || !prompt.surfaces.includes(surface)) return null;
|
|
66
|
+
|
|
67
|
+
const supplied = args?.arguments ?? '';
|
|
68
|
+
return prompt.body.split(ARGUMENTS_TOKEN).join(supplied);
|
|
39
69
|
}
|
|
70
|
+
|
|
71
|
+
/** Back-compat for callers that only want the list. */
|
|
72
|
+
export const PROMPTS = listPrompts('local');
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Generate lib/instructions.generated.js from the work-tracking skill.
|
|
4
|
+
*
|
|
5
|
+
* WHY GENERATE RATHER THAN WRITE IT TWICE. The MCP `instructions` string and
|
|
6
|
+
* plugins/ezmodo/skills/work-tracking/SKILL.md say the same thing to different
|
|
7
|
+
* audiences: the string reaches every MCP client on every session, the skill
|
|
8
|
+
* reaches Claude Code on demand and in full. Two hand-maintained prose copies
|
|
9
|
+
* of one contract is precisely the failure that moved these rules out of every
|
|
10
|
+
* repo's CLAUDE.md and into the plugin in the first place (#2594) — copies
|
|
11
|
+
* drift, and the drift is invisible until someone follows the stale one.
|
|
12
|
+
*
|
|
13
|
+
* WHY GENERATE AT BUILD TIME RATHER THAN READ THE SKILL AT RUNTIME. This
|
|
14
|
+
* package is published to npm and launched with `npx @ezmodo/mcp-server`. It
|
|
15
|
+
* ships no plugins/ directory and cannot: the skill lives in the plugin, which
|
|
16
|
+
* is a different artifact. So the extracted text is committed here, and
|
|
17
|
+
* __tests__/instructions.test.js re-extracts it whenever the skill IS present
|
|
18
|
+
* (i.e. in the repo, never in a published install) and fails on any difference.
|
|
19
|
+
* That test is the drift guard; there is no CI wiring to forget.
|
|
20
|
+
*
|
|
21
|
+
* Run: npm run generate:instructions --workspace=@ezmodo/mcp-server
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { readFileSync, writeFileSync } from 'fs';
|
|
25
|
+
import { dirname, join } from 'path';
|
|
26
|
+
import { fileURLToPath } from 'url';
|
|
27
|
+
|
|
28
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
export const SKILL_PATH = join(here, '..', '..', 'plugins', 'ezmodo', 'skills', 'work-tracking', 'SKILL.md');
|
|
30
|
+
const OUTPUT_PATH = join(here, '..', 'lib', 'instructions.generated.js');
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Pull the text between a marker pair.
|
|
34
|
+
*
|
|
35
|
+
* Throws rather than returning empty on a missing marker: a silently empty
|
|
36
|
+
* instructions string would ship a server that says nothing, and look fine.
|
|
37
|
+
*/
|
|
38
|
+
export function extract(markdown, marker) {
|
|
39
|
+
const open = `<!-- mcp:${marker}:start -->`;
|
|
40
|
+
const close = `<!-- mcp:${marker}:end -->`;
|
|
41
|
+
const from = markdown.indexOf(open);
|
|
42
|
+
const to = markdown.indexOf(close);
|
|
43
|
+
if (from === -1 || to === -1 || to < from) {
|
|
44
|
+
throw new Error(`Marker mcp:${marker} not found (or inverted) in the skill.`);
|
|
45
|
+
}
|
|
46
|
+
return markdown.slice(from + open.length, to).trim();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Build the module source from a skill document. */
|
|
50
|
+
export function render(markdown) {
|
|
51
|
+
const core = extract(markdown, 'core');
|
|
52
|
+
const local = extract(markdown, 'local');
|
|
53
|
+
|
|
54
|
+
return `/**
|
|
55
|
+
* GENERATED FILE — DO NOT EDIT.
|
|
56
|
+
*
|
|
57
|
+
* Extracted from plugins/ezmodo/skills/work-tracking/SKILL.md by
|
|
58
|
+
* scripts/build-instructions.mjs. Edit the SKILL, then run:
|
|
59
|
+
*
|
|
60
|
+
* npm run generate:instructions --workspace=@ezmodo/mcp-server
|
|
61
|
+
*
|
|
62
|
+
* __tests__/instructions.test.js fails if this drifts from the skill.
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
export const WORK_TRACKING_CORE = ${JSON.stringify(core)};
|
|
66
|
+
|
|
67
|
+
export const WORK_TRACKING_LOCAL = ${JSON.stringify(local)};
|
|
68
|
+
`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Only write when run directly, so the test can import the helpers above.
|
|
72
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
73
|
+
const skill = readFileSync(SKILL_PATH, 'utf-8');
|
|
74
|
+
writeFileSync(OUTPUT_PATH, render(skill), 'utf-8');
|
|
75
|
+
console.log(`Wrote ${OUTPUT_PATH}`);
|
|
76
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Generate prompts/commands.generated.js from the plugin's slash commands.
|
|
4
|
+
*
|
|
5
|
+
* WHAT PROMPTS ARE FOR, now that #2633 exists. The two overlap enough to be
|
|
6
|
+
* worth stating: `instructions` is what the agent should ALWAYS do, injected
|
|
7
|
+
* into system context on every session. Prompts are things a USER deliberately
|
|
8
|
+
* invokes. That split is what decides the content — the four plugin commands
|
|
9
|
+
* (start, resume, submit, untracked) are user-invoked actions, so they belong
|
|
10
|
+
* here; the always-on discipline does not, and serving it twice would be the
|
|
11
|
+
* drift problem again.
|
|
12
|
+
*
|
|
13
|
+
* Same generate-don't-retype rule as scripts/build-instructions.mjs, for the
|
|
14
|
+
* same reason: plugins/ is a different artifact and is absent from a published
|
|
15
|
+
* npx install, so the text is extracted at build time and committed, with
|
|
16
|
+
* __tests__/prompts.test.js re-extracting whenever the commands ARE present.
|
|
17
|
+
*
|
|
18
|
+
* TWO TRANSLATIONS ARE NECESSARY, neither cosmetic. Both are cases where the
|
|
19
|
+
* command body refers to something only Claude Code has, which an MCP client
|
|
20
|
+
* would read as an instruction it cannot carry out:
|
|
21
|
+
*
|
|
22
|
+
* 1. !`cmd` shell interpolation. The Claude Code CLI runs it before the model
|
|
23
|
+
* sees it. MCP has no such preprocessor, so the raw syntax arrives as
|
|
24
|
+
* literal text and the model reads a backtick as a command result.
|
|
25
|
+
* Rewritten into an explicit instruction to run the command.
|
|
26
|
+
* 2. "the **EzModo Work Tracking** skill". There are no skills outside Claude
|
|
27
|
+
* Code — but the same contract IS served to every client as the MCP
|
|
28
|
+
* `instructions` string (#2633), so the reference is repointed there
|
|
29
|
+
* rather than deleted. A reference to a sibling skill with no MCP
|
|
30
|
+
* equivalent (Link Upkeep) is dropped instead: the step that cites it
|
|
31
|
+
* already names the tools it needs, and pointing at something absent is
|
|
32
|
+
* worse than not pointing.
|
|
33
|
+
*
|
|
34
|
+
* Tests assert that neither form survives into a served prompt.
|
|
35
|
+
*
|
|
36
|
+
* Run: npm run generate:prompts --workspace=@ezmodo/mcp-server
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
40
|
+
import { dirname, join } from 'path';
|
|
41
|
+
import { fileURLToPath } from 'url';
|
|
42
|
+
|
|
43
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
44
|
+
export const COMMANDS_DIR = join(here, '..', '..', 'plugins', 'ezmodo', 'commands');
|
|
45
|
+
const OUTPUT_PATH = join(here, '..', 'prompts', 'commands.generated.js');
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Which surfaces each command is served on.
|
|
49
|
+
*
|
|
50
|
+
* `submit` is local-only: it reads git SHAs and links commits, neither of which
|
|
51
|
+
* exists on a hosted server. Serving it remotely would hand someone a checklist
|
|
52
|
+
* whose third step cannot be done.
|
|
53
|
+
*/
|
|
54
|
+
export const COMMANDS = [
|
|
55
|
+
{ file: 'start.md', name: 'start', surfaces: ['local', 'remote'] },
|
|
56
|
+
{ file: 'resume.md', name: 'resume', surfaces: ['local', 'remote'] },
|
|
57
|
+
{ file: 'submit.md', name: 'submit', surfaces: ['local'] },
|
|
58
|
+
{ file: 'untracked.md', name: 'untracked', surfaces: ['local', 'remote'] },
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
/** Split YAML-ish frontmatter from the body. Only the two keys we use. */
|
|
62
|
+
export function parseCommand(markdown) {
|
|
63
|
+
const match = /^---\n([\s\S]*?)\n---\n?/.exec(markdown);
|
|
64
|
+
if (!match) {
|
|
65
|
+
return { description: '', argumentHint: '', body: markdown.trim() };
|
|
66
|
+
}
|
|
67
|
+
const meta = {};
|
|
68
|
+
for (const line of match[1].split('\n')) {
|
|
69
|
+
const at = line.indexOf(':');
|
|
70
|
+
if (at === -1) continue;
|
|
71
|
+
meta[line.slice(0, at).trim()] = line.slice(at + 1).trim();
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
description: meta.description || '',
|
|
75
|
+
argumentHint: meta['argument-hint'] || '',
|
|
76
|
+
body: markdown.slice(match[0].length).trim(),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Rewrite Claude Code's !`cmd` shell interpolation into a plain instruction.
|
|
82
|
+
*
|
|
83
|
+
* `HEAD: !`git rev-parse HEAD`` becomes
|
|
84
|
+
* `HEAD: run `git rev-parse HEAD` and use its output`.
|
|
85
|
+
*/
|
|
86
|
+
export function stripShellInterpolation(body) {
|
|
87
|
+
return body.replace(/!`([^`]+)`/g, (_, command) => `run \`${command}\` and use its output`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Repoint or remove references to Claude Code skills.
|
|
92
|
+
*
|
|
93
|
+
* The patterns allow \s+ between words rather than a literal space: these
|
|
94
|
+
* bodies are hard-wrapped markdown, so a reference routinely straddles a line
|
|
95
|
+
* break and a space-literal pattern silently misses exactly those.
|
|
96
|
+
*
|
|
97
|
+
* Work Tracking is repointed because its content genuinely reaches every
|
|
98
|
+
* client, as this server's `instructions`. Anything else is removed with its
|
|
99
|
+
* sentence, because it does not.
|
|
100
|
+
*/
|
|
101
|
+
export function retargetSkillReferences(body) {
|
|
102
|
+
return body
|
|
103
|
+
.replace(
|
|
104
|
+
/the\s+\*\*EzModo\s+Work\s+Tracking\*\*\s+skill/g,
|
|
105
|
+
'the work-tracking contract in this server\'s instructions'
|
|
106
|
+
)
|
|
107
|
+
.replace(/\s*See\s+the\s+\*\*EzModo\s+[^*]+\*\*\s+skill\./g, '');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function render(read = (file) => readFileSync(join(COMMANDS_DIR, file), 'utf-8')) {
|
|
111
|
+
const entries = COMMANDS.map(({ file, name, surfaces }) => {
|
|
112
|
+
const { description, argumentHint, body } = parseCommand(read(file));
|
|
113
|
+
if (!description) throw new Error(`${file} has no description in its frontmatter.`);
|
|
114
|
+
return {
|
|
115
|
+
name,
|
|
116
|
+
description,
|
|
117
|
+
argumentHint,
|
|
118
|
+
surfaces,
|
|
119
|
+
body: retargetSkillReferences(stripShellInterpolation(body)),
|
|
120
|
+
};
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
return `/**
|
|
124
|
+
* GENERATED FILE — DO NOT EDIT.
|
|
125
|
+
*
|
|
126
|
+
* Extracted from plugins/ezmodo/commands/ by scripts/build-prompts.mjs.
|
|
127
|
+
* Edit the command, then run:
|
|
128
|
+
*
|
|
129
|
+
* npm run generate:prompts --workspace=@ezmodo/mcp-server
|
|
130
|
+
*
|
|
131
|
+
* __tests__/prompts.test.js fails if this drifts from the commands.
|
|
132
|
+
*/
|
|
133
|
+
|
|
134
|
+
export const COMMAND_PROMPTS = ${JSON.stringify(entries, null, 2)};
|
|
135
|
+
`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
139
|
+
if (!existsSync(COMMANDS_DIR)) {
|
|
140
|
+
throw new Error(`No commands directory at ${COMMANDS_DIR} — run this from the repo.`);
|
|
141
|
+
}
|
|
142
|
+
writeFileSync(OUTPUT_PATH, render(), 'utf-8');
|
|
143
|
+
console.log(`Wrote ${OUTPUT_PATH}`);
|
|
144
|
+
}
|
package/tools/auth.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sign-in tools for the LOCAL (stdio) server (#2632).
|
|
3
|
+
*
|
|
4
|
+
* Never served remotely: over the connector, Claude performs its own OAuth
|
|
5
|
+
* before a single tool call is made, so an `authenticate` tool there would be
|
|
6
|
+
* an inert second sign-in offering to confuse people with. See
|
|
7
|
+
* lib/remote-tools.js.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const AUTH_TOOLS = [
|
|
11
|
+
{
|
|
12
|
+
name: 'authenticate',
|
|
13
|
+
description:
|
|
14
|
+
'Sign this EzModo MCP server in, or report who it is signed in as. Call ' +
|
|
15
|
+
'it when a tool reports that no credential is available. `login` ' +
|
|
16
|
+
'returns a URL to open in a browser — SHOW THAT URL TO THE USER, then ' +
|
|
17
|
+
'retry the original call once they say they have approved it. Not ' +
|
|
18
|
+
'needed when EZMODO_API_KEY is set.',
|
|
19
|
+
inputSchema: {
|
|
20
|
+
type: 'object',
|
|
21
|
+
properties: {
|
|
22
|
+
action: {
|
|
23
|
+
type: 'string',
|
|
24
|
+
enum: ['login', 'status', 'sign_out'],
|
|
25
|
+
description:
|
|
26
|
+
'login: start browser sign-in and return the URL to open (default). ' +
|
|
27
|
+
'status: report the current credential without changing anything. ' +
|
|
28
|
+
'sign_out: forget the stored tokens. Does not affect EZMODO_API_KEY ' +
|
|
29
|
+
'or the ezmodo CLI login, neither of which this server owns.',
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
];
|
package/tools/index.js
CHANGED
|
@@ -40,8 +40,12 @@ import { FACT_TOOLS } from './facts.js';
|
|
|
40
40
|
import { AGENT_TOOLS } from './agents.js';
|
|
41
41
|
import { RECURRING_TASK_TOOLS } from './recurring-tasks.js';
|
|
42
42
|
import { WORK_TEMPLATE_TOOLS } from './work-templates.js';
|
|
43
|
+
import { AUTH_TOOLS } from './auth.js';
|
|
43
44
|
|
|
44
45
|
export const TOOLS = [
|
|
46
|
+
// First in the list on purpose: it is the one tool that works before the
|
|
47
|
+
// server has a credential, so it should be the one an agent notices.
|
|
48
|
+
...AUTH_TOOLS,
|
|
45
49
|
...ORGANIZATION_TOOLS,
|
|
46
50
|
...PROJECT_TOOLS,
|
|
47
51
|
...COMPONENT_TOOLS,
|