@ezmodo/mcp-server 0.14.4 → 0.17.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.
@@ -1,282 +0,0 @@
1
- /**
2
- * Component Handlers
3
- * Handler functions for component-related MCP tools
4
- *
5
- * Components are project-scoped entries in the unified UI inventory (E-168).
6
- * A single Component concept spans four kinds:
7
- * - area — coarse codebase module (api, web, mobile); the legacy meaning
8
- * - screen — a mobile / Flutter screen
9
- * - page — a web route / page
10
- * - component — a reusable UI component
11
- * Components self-nest via parentComponentId (e.g. web → Sprint Board → TaskCard)
12
- * and carry sourcePath / route / framework. The task component-picker uses
13
- * kind=area. This is ONE concept — there is no separate "UI surface" entity.
14
- *
15
- * list_components uses local cache for simple requests, falling back to API.
16
- * Write operations (create, update, delete) invalidate the cache.
17
- */
18
-
19
- import { callZephlyAPI } from '../lib/http-client.js';
20
- import { getCachedComponents, updateCacheSections, invalidateCacheSection } from '../lib/local-cache.js';
21
- import { attachLinks, applyLinks } from '../lib/links-at-create.js';
22
-
23
- /**
24
- * Dispatch manage_component actions to the appropriate handler
25
- */
26
- export async function manageComponent(args) {
27
- const { action, ...params } = args;
28
- switch (action) {
29
- case 'create': return createComponent(params);
30
- case 'update': return updateComponent(params);
31
- case 'delete': return deleteComponent(params);
32
- case 'add_dependency': return addComponentDependency(params);
33
- case 'remove_dependency': return removeComponentDependency(params);
34
- case 'add_navigation': return addComponentNavigation(params);
35
- case 'remove_navigation': return removeComponentNavigation(params);
36
- case 'derive_navigation': return deriveComponentNavigation(params);
37
- case 'discover': return discoverComponents(params);
38
- case 'import': return importComponents(params);
39
- default: throw new Error(`Unknown action: ${action}`);
40
- }
41
- }
42
-
43
- /**
44
- * List components with optional enrichment data.
45
- * Uses local cache when available and fresh for basic list, falls back to API.
46
- */
47
- export async function listComponents(args) {
48
- const { componentId, componentSlug, include, ...rest } = args;
49
- const isSingleLookup = componentId || componentSlug;
50
- const includes = include || [];
51
-
52
- // Timeline is NOT a supported include. It is rejected here — loudly, and on
53
- // every path — rather than dropped, because the API never had the data:
54
- // nothing populates Component.CachedTimeline (there is no cached_timeline
55
- // column, and repository_postgres.go neither reads nor writes the field), so
56
- // Service.GetComponentTimeline always returns a zeroed struct. The single
57
- // lookup therefore did not "work" while lists silently degraded; it answered
58
- // all-zeros, which reads as a real empty result and is the worse failure of
59
- // the two. Removing it from the schema enum is not enough on its own — a
60
- // client that ignores the enum must still get an error, not a plain list.
61
- if (includes.includes('timeline')) {
62
- throw new Error(
63
- 'include:["timeline"] is not supported: component timeline data is not computed by the ' +
64
- 'API, so it would return all zeros rather than real data. Use include:["stats"] for task ' +
65
- 'counts (per component with componentId/componentSlug, or project-wide without one).'
66
- );
67
- }
68
-
69
- // Stats. With an identifier: that component. Without one: every component in
70
- // the project (the endpoint takes the identifier as optional). This branch
71
- // used to require isSingleLookup, so `include:["stats"]` on a list fell
72
- // through to the plain list and the stats request vanished without an error.
73
- if (includes.includes('stats')) {
74
- return getComponentStats({ projectId: rest.projectId, componentId, componentSlug });
75
- }
76
-
77
- // Navigation graph (project-wide screen→screen edges, E-223).
78
- if (includes.includes('navigation')) {
79
- return getComponentNavigation({ projectId: rest.projectId });
80
- }
81
-
82
- // Dependency graph (project-wide). The kind filter must be forwarded: this
83
- // branch bypasses the plain list entirely, so dropping `kind` here silently
84
- // returned the WHOLE inventory to a caller who asked for one kind.
85
- if (includes.includes('dependency_graph')) {
86
- const graphArgs = { projectId: rest.projectId };
87
- if (rest.kind) graphArgs.kind = rest.kind;
88
- return getComponentDependencyGraph(graphArgs);
89
- }
90
-
91
- // Basic list — use cache if available. Skip the cache when a kind filter is
92
- // set (E-168): the cache holds the full inventory, not kind-filtered subsets.
93
- if (rest.projectId && !isSingleLookup && !rest.kind) {
94
- const cached = await getCachedComponents(rest.projectId);
95
- if (cached !== null) {
96
- return { components: cached, cached: true };
97
- }
98
- }
99
-
100
- // The identifier must be forwarded: it is destructured out of `rest` above,
101
- // so without this a single lookup fell through to a plain list and returned
102
- // the WHOLE inventory to a caller who asked for one component — the same
103
- // dropped-filter shape as the `kind` bug, and just as silent.
104
- const listArgs = { ...rest };
105
- if (componentId) listArgs.componentId = componentId;
106
- if (componentSlug) listArgs.componentSlug = componentSlug;
107
-
108
- const result = await callZephlyAPI('mcpListComponents', listArgs);
109
-
110
- // Cache the results for future use. Only cache the FULL inventory — neither a
111
- // kind-filtered result (e.g. kind='area' from the project-context path) nor a
112
- // single lookup may overwrite the cache, or an unfiltered list_components
113
- // would then read back a subset (mirrors the cache-read skip above). The
114
- // single-lookup guard matters as of the fix above: while the identifier was
115
- // being dropped this call returned the full list, so caching it was harmless.
116
- if (rest.projectId && !isSingleLookup && !rest.kind && result?.components) {
117
- const summaries = result.components.map((c) => ({
118
- id: c.id,
119
- name: c.name,
120
- description: c.description || '',
121
- }));
122
- await updateCacheSections({ components: summaries });
123
- }
124
-
125
- return result;
126
- }
127
-
128
- /**
129
- * Create a new component
130
- */
131
- async function createComponent(args) {
132
- // `links` is applied by the MCP layer after the component exists (E-225).
133
- const { links, ...createArgs } = args;
134
- const result = await callZephlyAPI('mcpCreateComponent', createArgs);
135
- await invalidateCacheSection('components');
136
-
137
- // Attach create-time links (E-225) — best effort, never fails the create.
138
- await attachLinks(result, {
139
- sourceType: 'component',
140
- sourceId: result?.componentId,
141
- links,
142
- });
143
-
144
- return result;
145
- }
146
-
147
- /**
148
- * Update an existing component
149
- */
150
- async function updateComponent(args) {
151
- const result = await callZephlyAPI('mcpUpdateComponent', args);
152
- await invalidateCacheSection('components');
153
- return result;
154
- }
155
-
156
- /**
157
- * Delete a component
158
- */
159
- async function deleteComponent(args) {
160
- const result = await callZephlyAPI('mcpDeleteComponent', args);
161
- await invalidateCacheSection('components');
162
- return result;
163
- }
164
-
165
- /**
166
- * Get stats for a component
167
- */
168
- async function getComponentStats(args) {
169
- return callZephlyAPI('mcpGetComponentStats', args);
170
- }
171
-
172
- /**
173
- * Add a dependency between two components
174
- */
175
- async function addComponentDependency(args) {
176
- return callZephlyAPI('mcpAddComponentDependency', args);
177
- }
178
-
179
- /**
180
- * Remove a dependency between two components
181
- */
182
- async function removeComponentDependency(args) {
183
- return callZephlyAPI('mcpRemoveComponentDependency', args);
184
- }
185
-
186
- /**
187
- * Get the full dependency graph for a project's components
188
- */
189
- async function getComponentDependencyGraph(args) {
190
- return callZephlyAPI('mcpGetComponentDependencyGraph', args);
191
- }
192
-
193
- /**
194
- * Add a screen→screen navigation edge (E-223). Endpoints may be given as IDs or
195
- * slugs. Idempotent — re-adding an existing edge is a no-op.
196
- */
197
- async function addComponentNavigation(args) {
198
- return callZephlyAPI('mcpAddComponentNavigation', args);
199
- }
200
-
201
- /**
202
- * Remove a screen→screen navigation edge (E-223). Idempotent.
203
- */
204
- async function removeComponentNavigation(args) {
205
- return callZephlyAPI('mcpRemoveComponentNavigation', args);
206
- }
207
-
208
- /**
209
- * Re-derive the screen-flow map from the synced Context Manifest (E-239 #2409).
210
- *
211
- * Idempotent, and it cannot destroy hand-drawn work: it reconciles only edges
212
- * still at origin auto/inferred under its own rules, so a human's edge — or one
213
- * they promoted — is left alone and reported as `spared`.
214
- *
215
- * Returns { result: { derived, byRule, retracted, spared, unresolved }, projectId }.
216
- * `unresolved` names navigation references no component matched, which is how a
217
- * stale inventory shows itself instead of the map silently shrinking.
218
- */
219
- async function deriveComponentNavigation({ projectId }) {
220
- return callZephlyAPI('mcpDeriveComponentNavigation', { projectId });
221
- }
222
-
223
- /**
224
- * Read the project's navigation edges (E-223) so an agent can see the existing
225
- * flow before writing to it. Returns { edges, projectId }.
226
- */
227
- async function getComponentNavigation(args) {
228
- return callZephlyAPI('mcpGetComponentNavigation', args);
229
- }
230
-
231
- /**
232
- * Discover candidate UI surfaces (page / component kinds) for a project from the
233
- * Context Manifest that are not yet in the inventory (E-168). Manifest-assisted;
234
- * returns { surfaces: [{kind,name,sourcePath,route,framework,summary}], projectId, count }.
235
- */
236
- async function discoverComponents({ projectId }) {
237
- return callZephlyAPI('mcpDiscoverComponents', { projectId });
238
- }
239
-
240
- /**
241
- * Bulk-import UI surfaces as components (E-168). Each surface becomes a component,
242
- * optionally nested under parentComponentId and/or linked to featureId.
243
- * Returns { imported: [{componentId,slug,name,kind,sourcePath,linked}], count }.
244
- */
245
- async function importComponents({ projectId, parentComponentId, featureId, surfaces }) {
246
- // Per-surface `links` are applied by the MCP layer after import, so a 90-screen
247
- // import can carry its links in one call (E-225) instead of 90 manage_link
248
- // round trips.
249
- const list = Array.isArray(surfaces) ? surfaces : [];
250
- const body = {
251
- projectId,
252
- surfaces: list.map(({ links, ...surface }) => surface),
253
- };
254
- if (parentComponentId) body.parentComponentId = parentComponentId;
255
- if (featureId) body.featureId = featureId;
256
- const result = await callZephlyAPI('mcpImportComponents', body);
257
- await invalidateCacheSection('components');
258
-
259
- // Match each imported component back to the surface that asked for links.
260
- // Matching on name + sourcePath rather than index because the API is free to
261
- // skip surfaces that already exist.
262
- const imported = Array.isArray(result?.imported) ? result.imported : [];
263
- const linkResults = [];
264
- for (const surface of list) {
265
- if (!Array.isArray(surface?.links) || surface.links.length === 0) continue;
266
- const match = imported.find(
267
- (c) => c?.name === surface.name && (!surface.sourcePath || c?.sourcePath === surface.sourcePath),
268
- );
269
- if (!match?.componentId) continue;
270
- const outcome = await applyLinks({
271
- sourceType: 'component',
272
- sourceId: match.componentId,
273
- links: surface.links,
274
- });
275
- linkResults.push({ componentId: match.componentId, ...outcome });
276
- }
277
- if (linkResults.length > 0 && result && typeof result === 'object') {
278
- result.links = linkResults;
279
- }
280
-
281
- return result;
282
- }
@@ -1,249 +0,0 @@
1
- /**
2
- * Component Tools
3
- * MCP tools for the unified UI inventory (E-168).
4
- *
5
- * NAMING (E-107): a project's type renames the `area` kind only — a marketing
6
- * project calls an area a "Channel", a sales project a "Segment". The screen,
7
- * page and component kinds keep their literal names, because "Channel: screen"
8
- * is nonsense. Read the word from `get_current_project_context().terminology`.
9
- *
10
- * A Component is ONE concept that spans four kinds:
11
- * - area — coarse codebase module / domain (api, web, mcp-server); the
12
- * legacy meaning. The task component-picker uses kind=area.
13
- * - screen — a mobile / Flutter screen
14
- * - page — a web route / page
15
- * - component — a reusable UI component
16
- * There is NO separate "UI surface" entity — surfaces ARE components. Components
17
- * self-nest via parentComponentId (e.g. web → Sprint Board → TaskCard) and carry
18
- * sourcePath / route / framework. They can be discovered from the Context
19
- * Manifest and bulk-imported.
20
- */
21
-
22
- import { LINKS_ARRAY_SCHEMA } from './link-params.js';
23
-
24
- export const COMPONENT_TOOLS = [
25
- {
26
- name: 'manage_component',
27
- description: 'Create, update, delete components, manage component dependencies and screen ' +
28
- 'navigation edges, or discover/import ' +
29
- 'UI surfaces. A Component is the unified UI inventory entry — ONE concept spanning kinds ' +
30
- 'area|screen|page|component (there is no separate "UI surface" entity). "area" is the legacy ' +
31
- 'coarse codebase module (api, web, mobile) and is what the task component-picker uses; ' +
32
- '"screen"/"page"/"component" describe mobile screens, web pages, and reusable UI components. ' +
33
- 'Components self-nest via parentComponentId and carry sourcePath/route/framework. ' +
34
- '"discover" proposes page/component surfaces from the Context Manifest not yet in the inventory; ' +
35
- '"import" bulk-creates surfaces (optionally nested under a parent and/or linked to a feature).',
36
- inputSchema: {
37
- type: 'object',
38
- properties: {
39
- action: {
40
- type: 'string',
41
- enum: ['create', 'update', 'delete', 'add_dependency', 'remove_dependency',
42
- 'add_navigation', 'remove_navigation', 'derive_navigation', 'discover', 'import'],
43
- description: 'Action to perform. "discover" (pass projectId) returns candidate UI surfaces ' +
44
- 'from the Context Manifest not yet in the inventory. "import" (pass projectId + surfaces, ' +
45
- 'optionally parentComponentId + featureId) bulk-creates components from surfaces. ' +
46
- '"add_navigation"/"remove_navigation" (pass projectId + sourceComponentId + ' +
47
- 'targetComponentId) manage screen\u2192screen navigation edges for the screen-flow map ' +
48
- '(E-223) \u2014 a DIFFERENT relation from add_dependency: navigation is "you can get ' +
49
- 'there from here" and only valid between screens/pages, whereas a dependency is a code ' +
50
- 'relationship. Read the current flow with list_components include:["navigation"]. ' +
51
- '"derive_navigation" (pass projectId) RE-DERIVES the whole flow map from the synced ' +
52
- 'Context Manifest \u2014 prefer it to drawing edges by hand, because a derived map stays ' +
53
- 'true for free while an asserted one decays from the moment it is written (E-239). It is ' +
54
- 'idempotent and cannot delete a human\u2019s edge: it reconciles only edges still at ' +
55
- 'origin auto/inferred under its own rules, reporting the rest as `spared`. Check the ' +
56
- '`unresolved` list in the response \u2014 references no component matched mean the ' +
57
- 'inventory is missing a screen, not that the code has no navigation.',
58
- },
59
- // --- Identifiers ---
60
- projectId: {
61
- type: 'string',
62
- description: 'Project ID (required for create, delete, add_dependency, remove_dependency)',
63
- },
64
- componentId: {
65
- type: 'string',
66
- description: 'Component ID (required for update, delete, add_dependency, remove_dependency)',
67
- },
68
- componentSlug: {
69
- type: 'string',
70
- description: 'URL-friendly slug of the component (alternative to componentId for update, delete, add_dependency, remove_dependency)',
71
- },
72
- // --- Create / Update fields ---
73
- name: {
74
- type: 'string',
75
- description: 'Component name (required for create, e.g., "api", "web", "mcp-server", "functions")',
76
- },
77
- description: {
78
- type: 'string',
79
- description: 'Description of what this component covers (create, update). ' +
80
- 'Writing this stays plain text; it does not create a backing document.',
81
- },
82
- color: {
83
- type: 'string',
84
- description: 'Hex color for UI display (e.g., "#3B82F6"). Defaults to blue if not provided. (create, update)',
85
- },
86
- icon: {
87
- type: 'string',
88
- description: 'Icon name for the component (create, update)',
89
- },
90
- // --- Unified UI-inventory fields (E-168) (create, update) ---
91
- kind: {
92
- type: 'string',
93
- enum: ['area', 'screen', 'page', 'component'],
94
- description: 'Kind of inventory entry (default "area"). "area" = coarse codebase module ' +
95
- '(api/web/mobile — the legacy meaning, used by the task component-picker); "screen" = ' +
96
- 'mobile/Flutter screen; "page" = web route/page; "component" = reusable UI component. ' +
97
- '(create, update)',
98
- },
99
- parentComponentId: {
100
- type: 'string',
101
- description: 'Parent component ID for self-nesting (e.g. web → Sprint Board → TaskCard). ' +
102
- 'On update, pass an empty string to clear it (make top-level). (create, update)',
103
- },
104
- sourcePath: {
105
- type: 'string',
106
- description: 'Repo file/dir path this surface maps to (create, update)',
107
- },
108
- route: {
109
- type: 'string',
110
- description: 'Route this surface serves, for page/screen kinds (create, update)',
111
- },
112
- framework: {
113
- type: 'string',
114
- description: 'Framework, e.g. "nextjs", "flutter", "react" (create, update)',
115
- },
116
- // --- import fields ---
117
- featureId: {
118
- type: 'string',
119
- description: 'Optionally link every imported component to this feature (import only)',
120
- },
121
- surfaces: {
122
- type: 'array',
123
- description: 'UI surfaces to import as components (required for import)',
124
- items: {
125
- type: 'object',
126
- properties: {
127
- kind: {
128
- type: 'string',
129
- enum: ['area', 'screen', 'page', 'component'],
130
- description: 'Kind of surface',
131
- },
132
- name: { type: 'string', description: 'Surface / component name' },
133
- sourcePath: { type: 'string', description: 'Repo file/dir path it maps to' },
134
- route: { type: 'string', description: 'Route it serves (page/screen kinds)' },
135
- framework: { type: 'string', description: 'Framework (nextjs, flutter, react, ...)' },
136
- links: LINKS_ARRAY_SCHEMA,
137
- },
138
- required: ['kind', 'name'],
139
- },
140
- },
141
- links: LINKS_ARRAY_SCHEMA,
142
- // --- Create-only fields ---
143
- ownerId: {
144
- type: 'string',
145
- description: 'Owner user ID — who maintains this component (create only)',
146
- },
147
- ownerName: {
148
- type: 'string',
149
- description: 'Display name of the owner (required if ownerId is set, create only)',
150
- },
151
- teamId: {
152
- type: 'string',
153
- description: 'Team ID — which team is responsible (create, update)',
154
- },
155
- // --- Update-only fields ---
156
- order: {
157
- type: 'number',
158
- description: 'Display order, lower = first (update only)',
159
- },
160
- owner: {
161
- type: 'object',
162
- description: 'Component owner object (set to null to clear). Object with id and name fields. (update only)',
163
- properties: {
164
- id: { type: 'string', description: 'Owner user ID' },
165
- name: { type: 'string', description: 'Owner display name' },
166
- },
167
- nullable: true,
168
- },
169
- // --- Dependency fields (add_dependency, remove_dependency) ---
170
- targetComponentId: {
171
- type: 'string',
172
- description: 'The other end of the edge. For add_dependency/remove_dependency: the ' +
173
- 'component depended on. For add_navigation/remove_navigation: the screen navigated TO ' +
174
- '(must be kind screen or page).',
175
- },
176
- type: {
177
- type: 'string',
178
- enum: ['depends_on', 'blocks'],
179
- description: 'Dependency type: "depends_on" (source needs target) or "blocks" ' +
180
- '(source blocks target). Required for add_dependency. Cycles are ALLOWED \u2014 code ' +
181
- 'imports are legitimately mutual \u2014 but a dependency that closes one comes back with ' +
182
- 'a `warning` and the `cyclePath` naming the loop. Re-adding an existing dependency is ' +
183
- 'an idempotent no-op that returns `alreadyExisted: true`.',
184
- },
185
- // --- Navigation fields (add_navigation, remove_navigation) — E-223 ---
186
- sourceComponentId: {
187
- type: 'string',
188
- description: 'Screen the user navigates FROM (required for add_navigation/remove_navigation, ' +
189
- 'unless sourceComponentSlug is given). Must be kind screen or page.',
190
- },
191
- sourceComponentSlug: {
192
- type: 'string',
193
- description: 'Slug alternative to sourceComponentId (add_navigation, remove_navigation)',
194
- },
195
- targetComponentSlug: {
196
- type: 'string',
197
- description: 'Slug alternative to targetComponentId (add_navigation, remove_navigation)',
198
- },
199
- },
200
- required: ['action'],
201
- },
202
- },
203
- {
204
- name: 'list_components',
205
- description: 'List components in a project, optionally with stats, dependency graph, or navigation data. ' +
206
- 'Provide componentId (or componentSlug) to get data for a single component — the response is the ' +
207
- 'same shape as a list, with `components` holding just that one; an identifier that matches nothing ' +
208
- 'is an error, never the full inventory. ' +
209
- 'Responses include `descriptionDocumentId` — the id of the backing rich-description Document ' +
210
- 'when the description has been promoted to one (E-189), otherwise omitted.',
211
- inputSchema: {
212
- type: 'object',
213
- properties: {
214
- projectId: {
215
- type: 'string',
216
- description: 'The project ID (required)',
217
- },
218
- componentId: {
219
- type: 'string',
220
- description: 'Optional component ID — narrows the listing to that one component',
221
- },
222
- componentSlug: {
223
- type: 'string',
224
- description: 'Optional component slug (alternative to componentId; the slug wins if both are given)',
225
- },
226
- kind: {
227
- type: 'string',
228
- enum: ['area', 'screen', 'page', 'component'],
229
- description: 'Optional filter — only return components of this kind. Use kind="area" for ' +
230
- 'the coarse codebase modules the task component-picker uses.',
231
- },
232
- include: {
233
- type: 'array',
234
- items: {
235
- type: 'string',
236
- enum: ['stats', 'dependency_graph', 'navigation'],
237
- },
238
- description: 'Optional additional data to include: stats (task counts), dependency_graph ' +
239
- '(all dependencies; honours the kind filter), ' +
240
- 'navigation (screen\u2192screen navigates_to edges for the screen-flow map, E-223). ' +
241
- 'Note: "timeline" is NOT available \u2014 component timeline data is not computed by the ' +
242
- 'API (nothing populates it, so it read back as all zeros on every path). Use ' +
243
- '"stats" for task counts.',
244
- },
245
- },
246
- required: ['projectId'],
247
- },
248
- },
249
- ];