@ezmodo/mcp-server 0.13.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.
Files changed (98) hide show
  1. package/README.md +305 -0
  2. package/config/development.js +20 -0
  3. package/config/endpoint-map.js +351 -0
  4. package/config/index.js +34 -0
  5. package/config/production.js +18 -0
  6. package/config/staging.js +18 -0
  7. package/handlers/access.js +141 -0
  8. package/handlers/activity.js +112 -0
  9. package/handlers/agents.js +95 -0
  10. package/handlers/ai-intelligence.js +55 -0
  11. package/handlers/attachments.js +30 -0
  12. package/handlers/catalogs.js +169 -0
  13. package/handlers/components.js +282 -0
  14. package/handlers/context-manifest.js +1150 -0
  15. package/handlers/decisions.js +114 -0
  16. package/handlers/designs.js +118 -0
  17. package/handlers/documents.js +227 -0
  18. package/handlers/entities.js +95 -0
  19. package/handlers/epics.js +190 -0
  20. package/handlers/facts.js +62 -0
  21. package/handlers/feature-flags.js +142 -0
  22. package/handlers/features.js +137 -0
  23. package/handlers/folders.js +127 -0
  24. package/handlers/git-context.js +917 -0
  25. package/handlers/github.js +72 -0
  26. package/handlers/graph.js +23 -0
  27. package/handlers/index.js +205 -0
  28. package/handlers/links.js +156 -0
  29. package/handlers/milestones.js +131 -0
  30. package/handlers/organizations.js +14 -0
  31. package/handlers/projects.js +122 -0
  32. package/handlers/recurring-tasks.js +33 -0
  33. package/handlers/tags.js +124 -0
  34. package/handlers/tasks.js +561 -0
  35. package/handlers/testing.js +116 -0
  36. package/handlers/todos.js +43 -0
  37. package/handlers/watchers.js +54 -0
  38. package/handlers/work-templates.js +32 -0
  39. package/index.js +175 -0
  40. package/lib/active-session.js +86 -0
  41. package/lib/auto-assign.js +93 -0
  42. package/lib/autolink.js +176 -0
  43. package/lib/changed-files.js +22 -0
  44. package/lib/env.js +45 -0
  45. package/lib/git-helpers.js +553 -0
  46. package/lib/git-utils.js +73 -0
  47. package/lib/http-client.js +164 -0
  48. package/lib/links-at-create.js +94 -0
  49. package/lib/local-cache.js +140 -0
  50. package/lib/logger.js +109 -0
  51. package/lib/manifest-loader.js +182 -0
  52. package/lib/manifest-query.js +686 -0
  53. package/lib/repo-config-dir.js +118 -0
  54. package/lib/version.js +10 -0
  55. package/lib/web-url.js +69 -0
  56. package/lib/worktree-tools.js +950 -0
  57. package/package.json +62 -0
  58. package/prompts/ai-workflow-automation.js +96 -0
  59. package/prompts/index.js +39 -0
  60. package/prompts/zephly-usage-guide-content.txt +631 -0
  61. package/prompts/zephly-usage-guide.js +119 -0
  62. package/tools/access-entity-types.js +28 -0
  63. package/tools/access.js +152 -0
  64. package/tools/activity.js +38 -0
  65. package/tools/agents.js +208 -0
  66. package/tools/ai-intelligence.js +111 -0
  67. package/tools/attachments.js +92 -0
  68. package/tools/catalogs.js +341 -0
  69. package/tools/components.js +249 -0
  70. package/tools/context-manifest.js +236 -0
  71. package/tools/decisions.js +168 -0
  72. package/tools/designs.js +222 -0
  73. package/tools/documents.js +287 -0
  74. package/tools/entities.js +223 -0
  75. package/tools/epics.js +267 -0
  76. package/tools/facts.js +70 -0
  77. package/tools/feature-flags.js +300 -0
  78. package/tools/features.js +246 -0
  79. package/tools/folders.js +122 -0
  80. package/tools/git-context.js +109 -0
  81. package/tools/github.js +172 -0
  82. package/tools/graph.js +70 -0
  83. package/tools/index.js +77 -0
  84. package/tools/link-params.js +93 -0
  85. package/tools/linkable-types.js +36 -0
  86. package/tools/links.js +199 -0
  87. package/tools/milestones.js +176 -0
  88. package/tools/organizations.js +23 -0
  89. package/tools/projects.js +172 -0
  90. package/tools/recurring-tasks.js +115 -0
  91. package/tools/tags.js +219 -0
  92. package/tools/task-item-schema.js +57 -0
  93. package/tools/task-type.js +33 -0
  94. package/tools/tasks.js +680 -0
  95. package/tools/testing.js +344 -0
  96. package/tools/todos.js +69 -0
  97. package/tools/watchers.js +81 -0
  98. package/tools/work-templates.js +96 -0
@@ -0,0 +1,164 @@
1
+ /**
2
+ * HTTP Client for EzModo API
3
+ * Handles API requests, encoding, and response unwrapping
4
+ */
5
+
6
+ import fetch from 'node-fetch';
7
+ import zlib from 'zlib';
8
+ import { ENDPOINT_MAP } from '../config/endpoint-map.js';
9
+ import { CONFIG } from '../config/index.js';
10
+ import { MCP_VERSION } from './version.js';
11
+ import { getLogger } from './logger.js';
12
+ import { getApiKey, getApiUrl } from './env.js';
13
+
14
+ // API base URL is resolved per-request (see callZephlyAPI): getApiUrl() honors
15
+ // the EZMODO_API_URL / ZEPHLY_API_URL override; CONFIG.apiUrl is the build-time
16
+ // default. Resolving per-call (not at module load) keeps it correct regardless
17
+ // of when the env var is set, and lets a desktop/self-hosted runner point the
18
+ // MCP server at a non-production API.
19
+
20
+ /**
21
+ * Bodies larger than this (bytes, uncompressed JSON) are gzip-compressed and
22
+ * sent with Content-Encoding: gzip instead of the base64 WAF wrapper. gzip both
23
+ * shrinks the payload (~10-20x for schema/manifest JSON, vs base64's +33%
24
+ * inflation) and is WAF-safe (binary body, not pattern-matchable), so large
25
+ * uploads like DB schema snapshots stay well under Cloud Run's 32 MB request
26
+ * limit. The Go API's MCP routes decompress gzip request bodies transparently.
27
+ */
28
+ const GZIP_THRESHOLD_BYTES = 64 * 1024;
29
+
30
+ /**
31
+ * Encode string to base64
32
+ */
33
+ function encodeBase64(str) {
34
+ return Buffer.from(str, 'utf-8').toString('base64');
35
+ }
36
+
37
+ /**
38
+ * Encode entire request body to prevent WAF blocking
39
+ * Wraps the entire payload in a base64-encoded container
40
+ *
41
+ * This is simpler and more reliable than field-by-field encoding:
42
+ * - No maintenance of field lists
43
+ * - Bulletproof against all WAF rules
44
+ * - Works for any future fields automatically
45
+ */
46
+ function encodeBodyForWAF(data) {
47
+ if (data === null || data === undefined || Object.keys(data).length === 0) {
48
+ return data;
49
+ }
50
+
51
+ // Encode the entire body as a single base64 string
52
+ const jsonString = JSON.stringify(data);
53
+ return {
54
+ __base64_body: true,
55
+ value: encodeBase64(jsonString)
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Call Zephly API endpoint
61
+ * @param {string} endpoint - Endpoint name (e.g., 'mcpCreateTask')
62
+ * @param {object} data - Request data
63
+ * @returns {Promise<any>} - API response (unwrapped from Go API structure)
64
+ */
65
+ export async function callZephlyAPI(endpoint, data) {
66
+ // Get endpoint mapping (route + HTTP method)
67
+ const mapping = ENDPOINT_MAP[endpoint];
68
+
69
+ if (!mapping) {
70
+ throw new Error(`Unknown endpoint: ${endpoint}. Please update ENDPOINT_MAP.`);
71
+ }
72
+
73
+ const { route, method } = mapping;
74
+
75
+ // Build URL - for GET and DELETE requests with data, append as query params
76
+ const apiUrl = getApiUrl() || CONFIG.apiUrl;
77
+ let url = `${apiUrl}/${route}`;
78
+ let body = null;
79
+
80
+ const headers = {
81
+ 'Authorization': `Bearer ${getApiKey()}`,
82
+ 'Content-Type': 'application/json',
83
+ 'User-Agent': `ezmodo-mcp-server/${MCP_VERSION}`,
84
+ 'X-MCP-API-Version': 'v1',
85
+ };
86
+
87
+ if ((method === 'GET' || method === 'DELETE') && data && Object.keys(data).length > 0) {
88
+ // Convert data object to query parameters for GET and DELETE
89
+ // DELETE requests typically don't have a body and the Go API expects query params
90
+ const params = new URLSearchParams();
91
+ Object.entries(data).forEach(([key, value]) => {
92
+ if (value !== undefined && value !== null) {
93
+ params.append(key, String(value));
94
+ }
95
+ });
96
+ url += `?${params.toString()}`;
97
+ } else if (method !== 'GET' && method !== 'DELETE') {
98
+ // For POST/PUT, send data in body.
99
+ const isEmpty = data === null || data === undefined || Object.keys(data).length === 0;
100
+ const rawJson = JSON.stringify(data);
101
+ if (!isEmpty && Buffer.byteLength(rawJson, 'utf-8') > GZIP_THRESHOLD_BYTES) {
102
+ // Large body: gzip the raw JSON. Smaller than base64 and WAF-safe (binary),
103
+ // so big uploads (e.g. DB schema snapshots) don't inflate or time out.
104
+ body = zlib.gzipSync(Buffer.from(rawJson, 'utf-8'));
105
+ headers['Content-Encoding'] = 'gzip';
106
+ } else {
107
+ // Small body: base64-wrap the entire body to prevent WAF blocking.
108
+ body = JSON.stringify(encodeBodyForWAF(data));
109
+ }
110
+ }
111
+
112
+ const log = getLogger();
113
+ log.debug('API request', { endpoint, method, route, url });
114
+
115
+ const response = await fetch(url, {
116
+ method,
117
+ headers,
118
+ body,
119
+ });
120
+
121
+ log.debug('API response', { endpoint, status: response.status });
122
+
123
+ if (!response.ok) {
124
+ const errorText = await response.text();
125
+ log.error('API error', { endpoint, status: response.status, body: errorText });
126
+
127
+ let error;
128
+ try {
129
+ error = JSON.parse(errorText);
130
+ } catch {
131
+ error = { error: response.statusText };
132
+ }
133
+
134
+ // Keep the API's own diagnosis on the error rather than flattening the
135
+ // response to its headline. During E-228 #2252 the API was already sending
136
+ // the true cause in `details` ("failed to load task_knowledge: database
137
+ // capacity temporarily exhausted") while this line threw only
138
+ // "Task not found" — so the one caller that could have acted on it, the
139
+ // agent, was told the task did not exist.
140
+ const headline =
141
+ error.error || error.message || `HTTP ${response.status}: ${response.statusText}`;
142
+ const detail = typeof error.details === 'string' ? error.details : null;
143
+
144
+ const thrown = new Error(detail && detail !== headline ? `${headline}: ${detail}` : headline);
145
+ // status and retryable let callers back off on a 503 instead of giving up
146
+ // the way a 404 tells them to.
147
+ thrown.status = response.status;
148
+ if (typeof error.retryable === 'boolean') {
149
+ thrown.retryable = error.retryable;
150
+ }
151
+ throw thrown;
152
+ }
153
+
154
+ const result = await response.json();
155
+ log.debug('API success', { endpoint });
156
+
157
+ // Go API returns {success, data, metadata}, Cloud Functions return data directly
158
+ // For backward compatibility, unwrap Go API responses if they have the expected structure
159
+ if (result.success === true && result.data !== undefined) {
160
+ return result.data;
161
+ }
162
+
163
+ return result;
164
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Links at create time (E-225).
3
+ *
4
+ * Creation tools accept a `links: [{targetType, targetId, linkType?}]` param so
5
+ * an entity is born connected to the feature/goal/flag/document it belongs to.
6
+ * This is the shared apply step every create handler calls after the entity
7
+ * exists and its id is known.
8
+ *
9
+ * The invariant: NEVER THROWS. A link failure must not fail the entity create —
10
+ * the entity is the user's work, the link is metadata. Failures come back in
11
+ * `failed` so the agent (and the response) can see them. This mirrors
12
+ * `applyAutoTags` in handlers/tasks.js.
13
+ *
14
+ * Links are applied ONE PER REQUEST, deliberately. This shipped anticipating a
15
+ * `POST mcp/v1/links/batch` that was never implemented on the Go API, so every
16
+ * create-with-links paid a 404 before falling back to exactly this loop — an
17
+ * optimization that only ever cost a round trip. Real callers pass a handful of
18
+ * links (the desktop LinkGate and the MCP create tools: typically one to five),
19
+ * which is not enough to earn a second write surface. If some caller ever
20
+ * arrives with dozens, batching is worth revisiting; until then this is the
21
+ * whole story.
22
+ */
23
+
24
+ import { callZephlyAPI } from './http-client.js';
25
+ import { getLogger } from './logger.js';
26
+
27
+ const DEFAULT_LINK_TYPE = 'relates_to';
28
+
29
+ /** Normalize + drop malformed entries. Returns [] when there is nothing to do. */
30
+ function normalize(links) {
31
+ if (!Array.isArray(links)) return [];
32
+ return links
33
+ .filter((l) => l && l.targetType && l.targetId)
34
+ .map((l) => ({
35
+ targetType: l.targetType,
36
+ targetId: l.targetId,
37
+ linkType: l.linkType || DEFAULT_LINK_TYPE,
38
+ }));
39
+ }
40
+
41
+ /**
42
+ * Attach `links` to a just-created entity.
43
+ *
44
+ * @param {object} params
45
+ * @param {string} params.sourceType - Linkable type of the created entity.
46
+ * @param {string} params.sourceId - ID of the created entity.
47
+ * @param {Array} params.links - [{targetType, targetId, linkType?}]
48
+ * @returns {Promise<{applied: Array, failed: Array}>} never rejects
49
+ */
50
+ export async function applyLinks({ sourceType, sourceId, links }) {
51
+ const items = normalize(links);
52
+ if (!sourceType || !sourceId || items.length === 0) {
53
+ return { applied: [], failed: [] };
54
+ }
55
+
56
+ const applied = [];
57
+ const failed = [];
58
+ for (const item of items) {
59
+ try {
60
+ await callZephlyAPI('mcpAddLink', {
61
+ sourceType,
62
+ sourceId,
63
+ targetType: item.targetType,
64
+ targetId: item.targetId,
65
+ linkType: item.linkType,
66
+ });
67
+ applied.push(item);
68
+ } catch (err) {
69
+ getLogger().warn('Link failed', {
70
+ sourceType,
71
+ sourceId,
72
+ targetType: item.targetType,
73
+ targetId: item.targetId,
74
+ error: err.message,
75
+ });
76
+ failed.push({ ...item, error: err.message });
77
+ }
78
+ }
79
+ return { applied, failed };
80
+ }
81
+
82
+ /**
83
+ * Convenience wrapper for create handlers: applies the links and stamps the
84
+ * outcome onto the create result as `result.links`. No-op (and no property
85
+ * added) when there is nothing to link or the id could not be resolved.
86
+ */
87
+ export async function attachLinks(result, { sourceType, sourceId, links }) {
88
+ const items = normalize(links);
89
+ if (items.length === 0 || !sourceId || !result || typeof result !== 'object') {
90
+ return result;
91
+ }
92
+ result.links = await applyLinks({ sourceType, sourceId, links: items });
93
+ return result;
94
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Local Cache Utility
3
+ * Reads and writes the project config.json cache for tags and components.
4
+ * Looks in `.ezmodo/` first, falls back to legacy `.zephly/`.
5
+ * Used by MCP handlers to avoid unnecessary API calls for frequently-read data.
6
+ */
7
+
8
+ import fs from 'fs/promises';
9
+ import { findRepoConfigPath } from './repo-config-dir.js';
10
+
11
+ // In-memory cache of the config file path (avoids walking directories repeatedly)
12
+ let _cachedConfigPath = null;
13
+
14
+ /**
15
+ * Check if cached data is still fresh.
16
+ * Returns true if lastUpdatedAt is within ttlDays (default 1).
17
+ * Returns false for missing/malformed timestamps.
18
+ */
19
+ export function isCacheFresh(lastUpdatedAt, ttlDays = 1) {
20
+ if (!lastUpdatedAt) return false;
21
+ try {
22
+ const updated = new Date(lastUpdatedAt);
23
+ if (isNaN(updated.getTime())) return false;
24
+ const ageMs = Date.now() - updated.getTime();
25
+ return ageMs < ttlDays * 24 * 60 * 60 * 1000;
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Walk up the directory tree to find the project config.json. Tries
33
+ * `.ezmodo/config.json` first, falls back to legacy `.zephly/config.json`.
34
+ * Caches the result in memory for subsequent calls.
35
+ * @returns {Promise<string|null>} Absolute path to config.json, or null if not found
36
+ */
37
+ export async function findConfigPath(startDir = process.cwd()) {
38
+ // Try in-memory cached path first
39
+ if (_cachedConfigPath) {
40
+ try {
41
+ await fs.access(_cachedConfigPath);
42
+ return _cachedConfigPath;
43
+ } catch {
44
+ _cachedConfigPath = null;
45
+ }
46
+ }
47
+
48
+ const configPath = await findRepoConfigPath(startDir);
49
+ if (configPath) _cachedConfigPath = configPath;
50
+ return configPath;
51
+ }
52
+
53
+ /**
54
+ * Read and parse the project config.json file (current `.ezmodo/` location
55
+ * or legacy `.zephly/` fallback).
56
+ * @returns {Promise<object|null>} Parsed config, or null if not found/invalid
57
+ */
58
+ export async function readConfig() {
59
+ const configPath = await findConfigPath();
60
+ if (!configPath) return null;
61
+ try {
62
+ const content = await fs.readFile(configPath, 'utf-8');
63
+ return JSON.parse(content);
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Write the config object back to the currently-resolved project config path
71
+ * (either `.ezmodo/config.json` or, for legacy checkouts, `.zephly/config.json`).
72
+ * Writes never relocate the file — use `ezmodo migrate-config` for that.
73
+ * @returns {Promise<boolean>} true if written successfully
74
+ */
75
+ export async function writeConfig(config) {
76
+ const configPath = await findConfigPath();
77
+ if (!configPath) return false;
78
+ try {
79
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8');
80
+ return true;
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Get cached tags if the cache is fresh.
88
+ * @returns {Promise<Array|null>} Cached tags array, or null if stale/missing
89
+ */
90
+ export async function getCachedTags() {
91
+ const config = await readConfig();
92
+ if (!config) return null;
93
+ if (!isCacheFresh(config.lastUpdatedAt)) return null;
94
+ return config.tags || null;
95
+ }
96
+
97
+ /**
98
+ * Get cached components for a specific project if the cache is fresh.
99
+ * @param {string} projectId - Only return components if they match this project
100
+ * @returns {Promise<Array|null>} Cached components array, or null if stale/missing/wrong project
101
+ */
102
+ export async function getCachedComponents(projectId) {
103
+ const config = await readConfig();
104
+ if (!config) return null;
105
+ if (!isCacheFresh(config.lastUpdatedAt)) return null;
106
+ // Only return if the cached config belongs to this project
107
+ if (config.projectId !== projectId) return null;
108
+ return config.components || null;
109
+ }
110
+
111
+ /**
112
+ * Update specific sections in the config cache. Non-fatal on failure.
113
+ * @param {object} updates - Key-value pairs to merge into config (e.g., { tags: [...], components: [...] })
114
+ */
115
+ export async function updateCacheSections(updates) {
116
+ try {
117
+ const config = await readConfig();
118
+ if (!config) return;
119
+ Object.assign(config, updates);
120
+ await writeConfig(config);
121
+ } catch {
122
+ // Non-fatal — cache update failed, will refresh next time
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Invalidate a specific cache section by removing it from config.
128
+ * The next read will return null, triggering a fresh API call.
129
+ * @param {string} section - 'tags' or 'components'
130
+ */
131
+ export async function invalidateCacheSection(section) {
132
+ try {
133
+ const config = await readConfig();
134
+ if (!config) return;
135
+ delete config[section];
136
+ await writeConfig(config);
137
+ } catch {
138
+ // Non-fatal
139
+ }
140
+ }
package/lib/logger.js ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * MCP Server Logger - Structured JSON Lines logging to disk
3
+ *
4
+ * Writes to ~/.zephly/logs/zephly-YYYY-MM-DD.log
5
+ * Daily rotation, 7-day auto-cleanup on init
6
+ * Async fire-and-forget writes so logging never blocks tool execution
7
+ */
8
+
9
+ import { mkdir, appendFile, readdir, unlink } from 'fs/promises';
10
+ import { join } from 'path';
11
+ import { homedir } from 'os';
12
+
13
+ const RETENTION_DAYS = 7;
14
+ const DATE_PATTERN = /^zephly-(\d{4}-\d{2}-\d{2})\.log$/;
15
+
16
+ function getDateString() {
17
+ return new Date().toISOString().slice(0, 10);
18
+ }
19
+
20
+ function getLogFilePath(logsDir) {
21
+ return join(logsDir, `zephly-${getDateString()}.log`);
22
+ }
23
+
24
+ /**
25
+ * @param {object} [options]
26
+ * @param {string} [options.source='mcp']
27
+ * @param {boolean} [options.verbose=false]
28
+ * @param {string} [options.logsDir]
29
+ */
30
+ export function createLogger(options = {}) {
31
+ const {
32
+ source = 'mcp',
33
+ verbose = false,
34
+ logsDir = join(homedir(), '.zephly', 'logs'),
35
+ } = options;
36
+
37
+ // Ensure logs directory exists (fire-and-forget)
38
+ let dirReady = mkdir(logsDir, { recursive: true }).catch(() => {});
39
+
40
+ function write(level, msg, data) {
41
+ const entry = {
42
+ ts: new Date().toISOString(),
43
+ level,
44
+ source,
45
+ msg,
46
+ ...data,
47
+ };
48
+
49
+ // Fire-and-forget write — wait for dir, then append
50
+ dirReady.then(() => {
51
+ const filePath = getLogFilePath(logsDir);
52
+ return appendFile(filePath, JSON.stringify(entry) + '\n');
53
+ }).catch(() => {});
54
+
55
+ // stderr routing: warn/error always go to stderr; debug/info only if verbose
56
+ if (level === 'warn' || level === 'error') {
57
+ console.error(`[${source}] ${msg}`);
58
+ } else if (verbose) {
59
+ console.error(`[${source}] ${msg}`);
60
+ }
61
+ }
62
+
63
+ async function cleanup() {
64
+ try {
65
+ await dirReady;
66
+ const files = await readdir(logsDir);
67
+ const now = Date.now();
68
+ const maxAge = RETENTION_DAYS * 24 * 60 * 60 * 1000;
69
+
70
+ for (const file of files) {
71
+ const match = file.match(DATE_PATTERN);
72
+ if (!match) continue;
73
+
74
+ const fileDate = new Date(match[1]);
75
+ if (isNaN(fileDate.getTime())) continue;
76
+
77
+ if (now - fileDate.getTime() > maxAge) {
78
+ await unlink(join(logsDir, file)).catch(() => {});
79
+ }
80
+ }
81
+ } catch {
82
+ // Ignore cleanup failures
83
+ }
84
+ }
85
+
86
+ return {
87
+ debug: (msg, data) => write('debug', msg, data),
88
+ info: (msg, data) => write('info', msg, data),
89
+ warn: (msg, data) => write('warn', msg, data),
90
+ error: (msg, data) => write('error', msg, data),
91
+ cleanup,
92
+ };
93
+ }
94
+
95
+ // Singleton
96
+ let _logger = null;
97
+
98
+ export function initLogger(verbose = false, logsDir) {
99
+ _logger = createLogger({ source: 'mcp', verbose, logsDir });
100
+ _logger.cleanup();
101
+ return _logger;
102
+ }
103
+
104
+ export function getLogger() {
105
+ if (!_logger) {
106
+ _logger = createLogger({ source: 'mcp' });
107
+ }
108
+ return _logger;
109
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Context Manifest Loader
3
+ * Loads and caches the project manifest/manifest.json file for MCP tools.
4
+ * Reads from `.ezmodo/manifest/` with a `.zephly/manifest/` fallback.
5
+ * Provides a singleton cache with reload mechanism for rebuild_manifest.
6
+ */
7
+
8
+ import fs from 'fs/promises';
9
+ import path from 'path';
10
+ import { findConfigPath, readConfig } from './local-cache.js';
11
+ import { getLogger } from './logger.js';
12
+ import {
13
+ CURRENT_REPO_CONFIG_DIR,
14
+ LEGACY_REPO_CONFIG_DIR,
15
+ } from './repo-config-dir.js';
16
+
17
+ // Singleton cache
18
+ let _manifest = null;
19
+ let _manifestPath = null;
20
+ let _loadedAt = null;
21
+
22
+ /**
23
+ * Find the project root by walking up from the resolved config.json path.
24
+ * Falls back to process.cwd() if config not found.
25
+ * @returns {Promise<string>} Absolute path to project root
26
+ */
27
+ async function findProjectRoot() {
28
+ const configPath = await findConfigPath();
29
+ if (configPath) {
30
+ // <root>/<config-dir>/config.json → project root
31
+ return path.dirname(path.dirname(configPath));
32
+ }
33
+ return process.cwd();
34
+ }
35
+
36
+ /**
37
+ * Find the manifest.json file path. Probes `.ezmodo/manifest/manifest.json`
38
+ * first, then legacy `.zephly/manifest/manifest.json`.
39
+ * @returns {Promise<string|null>} Absolute path to manifest.json, or null
40
+ */
41
+ async function findManifestPath() {
42
+ if (_manifestPath) {
43
+ try {
44
+ await fs.access(_manifestPath);
45
+ return _manifestPath;
46
+ } catch {
47
+ _manifestPath = null;
48
+ }
49
+ }
50
+
51
+ const projectRoot = await findProjectRoot();
52
+ for (const dirName of [CURRENT_REPO_CONFIG_DIR, LEGACY_REPO_CONFIG_DIR]) {
53
+ const manifestPath = path.join(projectRoot, dirName, 'manifest', 'manifest.json');
54
+ try {
55
+ await fs.access(manifestPath);
56
+ _manifestPath = manifestPath;
57
+ return manifestPath;
58
+ } catch {
59
+ // try next
60
+ }
61
+ }
62
+ return null;
63
+ }
64
+
65
+ /**
66
+ * Load and parse the context manifest.
67
+ * Uses singleton cache — subsequent calls return cached version.
68
+ * Call reloadManifest() to force a refresh.
69
+ *
70
+ * @returns {Promise<object|null>} Parsed manifest object, or null if not found/invalid
71
+ */
72
+ export async function loadManifest() {
73
+ if (_manifest) return _manifest;
74
+
75
+ const manifestPath = await findManifestPath();
76
+ if (!manifestPath) return null;
77
+
78
+ try {
79
+ const content = await fs.readFile(manifestPath, 'utf-8');
80
+ _manifest = JSON.parse(content);
81
+ _loadedAt = new Date().toISOString();
82
+ return _manifest;
83
+ } catch (err) {
84
+ getLogger().warn('Failed to load context manifest', { error: err.message });
85
+ return null;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Force reload the manifest from disk.
91
+ * Clears the singleton cache and reloads.
92
+ *
93
+ * @returns {Promise<object|null>} Freshly loaded manifest, or null
94
+ */
95
+ export async function reloadManifest() {
96
+ _manifest = null;
97
+ _manifestPath = null;
98
+ _loadedAt = null;
99
+ return loadManifest();
100
+ }
101
+
102
+ /**
103
+ * Save the in-memory manifest back to disk.
104
+ * Updates metadata.entryCount and writes to the same path it was loaded from.
105
+ *
106
+ * @returns {Promise<boolean>} true if saved successfully
107
+ */
108
+ export async function saveManifest() {
109
+ if (!_manifest || !_manifestPath) return false;
110
+
111
+ try {
112
+ _manifest.metadata.entryCount = _manifest.entries.length;
113
+ await fs.writeFile(_manifestPath, JSON.stringify(_manifest, null, 2) + '\n', 'utf-8');
114
+ return true;
115
+ } catch (err) {
116
+ getLogger().warn('Failed to save context manifest', { error: err.message });
117
+ return false;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Get metadata about the current manifest cache state.
123
+ * @returns {object} Cache info including loadedAt timestamp and entry count
124
+ */
125
+ export function getManifestCacheInfo() {
126
+ return {
127
+ loaded: _manifest !== null,
128
+ loadedAt: _loadedAt,
129
+ entryCount: _manifest?.metadata?.entryCount || 0,
130
+ version: _manifest?.metadata?.version || null,
131
+ generatedAt: _manifest?.metadata?.generatedAt || null,
132
+ manifestPath: _manifestPath,
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Check if a local manifest file is available on the filesystem.
138
+ * @returns {Promise<boolean>}
139
+ */
140
+ export async function isLocalManifestAvailable() {
141
+ if (_manifest) return true;
142
+ const manifestPath = await findManifestPath();
143
+ return manifestPath !== null;
144
+ }
145
+
146
+ /**
147
+ * Determine the manifest data source: local filesystem or remote API.
148
+ * Returns { source: 'local', manifest } or { source: 'remote', projectId }.
149
+ *
150
+ * The local manifest describes the working tree it sits in, and therefore
151
+ * answers for exactly ONE project: the one in this repo's config.json. When a
152
+ * caller names a DIFFERENT project it must go remote — an agent working in repo
153
+ * A can legitimately ask for context in project B, and serving A's files as B's
154
+ * context is worse than serving none: the answer looks authoritative and is
155
+ * entirely about the wrong codebase.
156
+ *
157
+ * @param {string} [projectIdOverride] - Optional explicit projectId (from tool args)
158
+ * @returns {Promise<{source: 'local', manifest: object} | {source: 'remote', projectId: string}>}
159
+ */
160
+ export async function getManifestSource(projectIdOverride) {
161
+ const localProjectId = (await readConfig())?.projectId;
162
+ const localAnswersForRequest = !projectIdOverride || projectIdOverride === localProjectId;
163
+
164
+ if (localAnswersForRequest) {
165
+ const localManifest = await loadManifest();
166
+ if (localManifest) {
167
+ return { source: 'local', manifest: localManifest };
168
+ }
169
+ }
170
+
171
+ // No usable local manifest — need a projectId for remote.
172
+ const projectId = projectIdOverride || localProjectId;
173
+ if (projectId) {
174
+ return { source: 'remote', projectId };
175
+ }
176
+
177
+ throw new Error(
178
+ 'No local manifest found and no project configured for remote access. ' +
179
+ 'Either run "npm run manifest:regen" to generate .context/manifest.json locally, ' +
180
+ 'or run initialize_project_context to configure a project for remote API access.'
181
+ );
182
+ }