flutterflow-mcp 0.1.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/README.md +124 -0
- package/build/api/flutterflow.d.ts +11 -0
- package/build/api/flutterflow.js +61 -0
- package/build/index.d.ts +2 -0
- package/build/index.js +54 -0
- package/build/prompts/dev-workflow.d.ts +2 -0
- package/build/prompts/dev-workflow.js +68 -0
- package/build/prompts/generate-page.d.ts +2 -0
- package/build/prompts/generate-page.js +36 -0
- package/build/prompts/inspect-project.d.ts +2 -0
- package/build/prompts/inspect-project.js +30 -0
- package/build/prompts/modify-component.d.ts +2 -0
- package/build/prompts/modify-component.js +39 -0
- package/build/resources/docs.d.ts +2 -0
- package/build/resources/docs.js +76 -0
- package/build/resources/projects.d.ts +3 -0
- package/build/resources/projects.js +60 -0
- package/build/tools/find-component-usages.d.ts +7 -0
- package/build/tools/find-component-usages.js +225 -0
- package/build/tools/find-page-navigations.d.ts +7 -0
- package/build/tools/find-page-navigations.js +228 -0
- package/build/tools/get-component-summary.d.ts +22 -0
- package/build/tools/get-component-summary.js +193 -0
- package/build/tools/get-page-by-name.d.ts +3 -0
- package/build/tools/get-page-by-name.js +56 -0
- package/build/tools/get-page-summary.d.ts +22 -0
- package/build/tools/get-page-summary.js +220 -0
- package/build/tools/get-yaml-docs.d.ts +6 -0
- package/build/tools/get-yaml-docs.js +217 -0
- package/build/tools/get-yaml.d.ts +3 -0
- package/build/tools/get-yaml.js +47 -0
- package/build/tools/list-files.d.ts +3 -0
- package/build/tools/list-files.js +30 -0
- package/build/tools/list-pages.d.ts +25 -0
- package/build/tools/list-pages.js +101 -0
- package/build/tools/list-projects.d.ts +3 -0
- package/build/tools/list-projects.js +19 -0
- package/build/tools/sync-project.d.ts +3 -0
- package/build/tools/sync-project.js +144 -0
- package/build/tools/update-yaml.d.ts +3 -0
- package/build/tools/update-yaml.js +24 -0
- package/build/tools/validate-yaml.d.ts +3 -0
- package/build/tools/validate-yaml.js +22 -0
- package/build/utils/cache.d.ts +48 -0
- package/build/utils/cache.js +162 -0
- package/build/utils/decode-yaml.d.ts +7 -0
- package/build/utils/decode-yaml.js +31 -0
- package/build/utils/page-summary/action-summarizer.d.ts +9 -0
- package/build/utils/page-summary/action-summarizer.js +291 -0
- package/build/utils/page-summary/formatter.d.ts +13 -0
- package/build/utils/page-summary/formatter.js +121 -0
- package/build/utils/page-summary/node-extractor.d.ts +17 -0
- package/build/utils/page-summary/node-extractor.js +207 -0
- package/build/utils/page-summary/tree-walker.d.ts +6 -0
- package/build/utils/page-summary/tree-walker.js +55 -0
- package/build/utils/page-summary/types.d.ts +56 -0
- package/build/utils/page-summary/types.js +4 -0
- package/build/utils/parse-folders.d.ts +9 -0
- package/build/utils/parse-folders.js +29 -0
- package/docs/ff-yaml/00-overview.md +137 -0
- package/docs/ff-yaml/01-project-files.md +513 -0
- package/docs/ff-yaml/02-pages.md +572 -0
- package/docs/ff-yaml/03-components.md +413 -0
- package/docs/ff-yaml/04-widgets/README.md +122 -0
- package/docs/ff-yaml/04-widgets/button.md +444 -0
- package/docs/ff-yaml/04-widgets/container.md +358 -0
- package/docs/ff-yaml/04-widgets/dropdown.md +579 -0
- package/docs/ff-yaml/04-widgets/form.md +256 -0
- package/docs/ff-yaml/04-widgets/image.md +276 -0
- package/docs/ff-yaml/04-widgets/layout.md +355 -0
- package/docs/ff-yaml/04-widgets/misc.md +553 -0
- package/docs/ff-yaml/04-widgets/text-field.md +326 -0
- package/docs/ff-yaml/04-widgets/text.md +302 -0
- package/docs/ff-yaml/05-actions.md +843 -0
- package/docs/ff-yaml/06-variables.md +834 -0
- package/docs/ff-yaml/07-data.md +591 -0
- package/docs/ff-yaml/08-custom-code.md +715 -0
- package/docs/ff-yaml/09-theming.md +592 -0
- package/docs/ff-yaml/10-editing-guide.md +454 -0
- package/docs/ff-yaml/README.md +105 -0
- package/package.json +55 -0
- package/skills/ff-widget-patterns.md +141 -0
- package/skills/ff-yaml-dev.md +58 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import YAML from "yaml";
|
|
3
|
+
import { cacheRead, cacheMeta, listCachedKeys, } from "../utils/cache.js";
|
|
4
|
+
import { resolveComponent } from "./get-component-summary.js";
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Helpers
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
/** Batch-process items in groups to avoid overwhelming the file system. */
|
|
9
|
+
async function batchProcess(items, batchSize, fn) {
|
|
10
|
+
const results = [];
|
|
11
|
+
for (let i = 0; i < items.length; i += batchSize) {
|
|
12
|
+
const batch = items.slice(i, i + batchSize);
|
|
13
|
+
const batchResults = await Promise.all(batch.map(fn));
|
|
14
|
+
results.push(...batchResults);
|
|
15
|
+
}
|
|
16
|
+
return results;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Resolve the value of a parameter pass to a readable string.
|
|
20
|
+
*/
|
|
21
|
+
function resolveParamValue(paramObj) {
|
|
22
|
+
// Check for variable source (e.g. INTERNATIONALIZATION)
|
|
23
|
+
const variable = paramObj.variable;
|
|
24
|
+
if (variable) {
|
|
25
|
+
const source = variable.source;
|
|
26
|
+
if (source === "INTERNATIONALIZATION") {
|
|
27
|
+
const fc = variable.functionCall;
|
|
28
|
+
const values = fc?.values;
|
|
29
|
+
if (values && values.length > 0) {
|
|
30
|
+
const first = values[0];
|
|
31
|
+
const iv = first.inputValue;
|
|
32
|
+
const sv = iv?.serializedValue;
|
|
33
|
+
return sv ? `"${sv}" (i18n)` : "[i18n]";
|
|
34
|
+
}
|
|
35
|
+
return "[i18n]";
|
|
36
|
+
}
|
|
37
|
+
if (source)
|
|
38
|
+
return `[${source}]`;
|
|
39
|
+
return "[dynamic]";
|
|
40
|
+
}
|
|
41
|
+
// Check for inputValue
|
|
42
|
+
const inputValue = paramObj.inputValue;
|
|
43
|
+
if (inputValue != null) {
|
|
44
|
+
if (typeof inputValue === "string" || typeof inputValue === "number") {
|
|
45
|
+
return `"${inputValue}"`;
|
|
46
|
+
}
|
|
47
|
+
if (typeof inputValue === "object") {
|
|
48
|
+
const ivo = inputValue;
|
|
49
|
+
if ("serializedValue" in ivo)
|
|
50
|
+
return `"${ivo.serializedValue}"`;
|
|
51
|
+
if ("themeColor" in ivo)
|
|
52
|
+
return `[theme:${ivo.themeColor}]`;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return "[dynamic]";
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Extract parent context (page or component name + ID) from a file key path.
|
|
59
|
+
* Examples:
|
|
60
|
+
* "page/id-Scaffold_xxx/page-widget-tree-outline/node/id-Widget_yyy"
|
|
61
|
+
* "component/id-Container_xxx/component-widget-tree-outline/node/id-Widget_yyy"
|
|
62
|
+
*/
|
|
63
|
+
function parseParentFromKey(fileKey) {
|
|
64
|
+
const pageMatch = fileKey.match(/^page\/id-(Scaffold_\w+)\//);
|
|
65
|
+
if (pageMatch)
|
|
66
|
+
return { type: "page", id: pageMatch[1] };
|
|
67
|
+
const compMatch = fileKey.match(/^component\/id-(Container_\w+)\//);
|
|
68
|
+
if (compMatch)
|
|
69
|
+
return { type: "component", id: compMatch[1] };
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Resolve the name of a page or component from its top-level YAML cache.
|
|
74
|
+
*/
|
|
75
|
+
async function resolveParentName(projectId, parentType, parentId) {
|
|
76
|
+
const prefix = parentType === "page" ? "page" : "component";
|
|
77
|
+
const fileKey = `${prefix}/id-${parentId}`;
|
|
78
|
+
const content = await cacheRead(projectId, fileKey);
|
|
79
|
+
if (!content)
|
|
80
|
+
return parentId;
|
|
81
|
+
const nameMatch = content.match(/^name:\s*(.+)$/m);
|
|
82
|
+
return nameMatch ? nameMatch[1].trim() : parentId;
|
|
83
|
+
}
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Tool registration
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
export function registerFindComponentUsagesTool(server) {
|
|
88
|
+
server.tool("find_component_usages", "Find all pages and components where a given component is used, with parameter pass details. Cache-only, no API calls. Run sync_project first.", {
|
|
89
|
+
projectId: z.string().describe("The FlutterFlow project ID"),
|
|
90
|
+
componentName: z
|
|
91
|
+
.string()
|
|
92
|
+
.optional()
|
|
93
|
+
.describe("Human-readable component name (e.g. 'PremuimContentWall'). Case-insensitive."),
|
|
94
|
+
componentId: z
|
|
95
|
+
.string()
|
|
96
|
+
.optional()
|
|
97
|
+
.describe("Container ID (e.g. 'Container_ffzg5wc5')."),
|
|
98
|
+
}, async ({ projectId, componentName, componentId }) => {
|
|
99
|
+
// Check cache exists
|
|
100
|
+
const meta = await cacheMeta(projectId);
|
|
101
|
+
if (!meta) {
|
|
102
|
+
return {
|
|
103
|
+
content: [
|
|
104
|
+
{
|
|
105
|
+
type: "text",
|
|
106
|
+
text: `No cache found for project "${projectId}". Run sync_project first to download the project YAML files.`,
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
if (!componentName && !componentId) {
|
|
112
|
+
return {
|
|
113
|
+
content: [
|
|
114
|
+
{
|
|
115
|
+
type: "text",
|
|
116
|
+
text: "Provide either componentName or componentId.",
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
// Resolve component to its key
|
|
122
|
+
const resolved = await resolveComponent(projectId, componentName, componentId);
|
|
123
|
+
if (!resolved.ok) {
|
|
124
|
+
const list = resolved.available.map((n) => ` - ${n}`).join("\n");
|
|
125
|
+
const searchTerm = componentName || componentId || "";
|
|
126
|
+
return {
|
|
127
|
+
content: [
|
|
128
|
+
{
|
|
129
|
+
type: "text",
|
|
130
|
+
text: `Component "${searchTerm}" not found in cache. Available components:\n${list}`,
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
const targetId = resolved.component.containerId;
|
|
136
|
+
// Get component name for display
|
|
137
|
+
const componentContent = await cacheRead(projectId, resolved.component.componentFileKey);
|
|
138
|
+
const displayName = componentContent
|
|
139
|
+
? (componentContent.match(/^name:\s*(.+)$/m)?.[1]?.trim() || targetId)
|
|
140
|
+
: targetId;
|
|
141
|
+
// Scan all node files for componentClassKeyRef matching this component
|
|
142
|
+
const allKeys = await listCachedKeys(projectId);
|
|
143
|
+
const nodeKeys = allKeys.filter((k) => /\/node\/id-[A-Z]/.test(k));
|
|
144
|
+
// Read node files in batches to check for component references
|
|
145
|
+
const usages = [];
|
|
146
|
+
await batchProcess(nodeKeys, 20, async (nodeKey) => {
|
|
147
|
+
const content = await cacheRead(projectId, nodeKey);
|
|
148
|
+
if (!content)
|
|
149
|
+
return;
|
|
150
|
+
// Quick string check before parsing YAML
|
|
151
|
+
if (!content.includes(targetId))
|
|
152
|
+
return;
|
|
153
|
+
let doc;
|
|
154
|
+
try {
|
|
155
|
+
doc = YAML.parse(content);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
// Check componentClassKeyRef
|
|
161
|
+
const classKeyRef = doc.componentClassKeyRef;
|
|
162
|
+
if (!classKeyRef)
|
|
163
|
+
return;
|
|
164
|
+
const refKey = classKeyRef.key;
|
|
165
|
+
if (refKey !== targetId)
|
|
166
|
+
return;
|
|
167
|
+
// Found a usage! Extract parent context
|
|
168
|
+
const parent = parseParentFromKey(nodeKey);
|
|
169
|
+
if (!parent)
|
|
170
|
+
return;
|
|
171
|
+
// Extract the widget key from the file key
|
|
172
|
+
const widgetKeyMatch = nodeKey.match(/\/node\/id-(\w+)$/);
|
|
173
|
+
const widgetKey = widgetKeyMatch ? widgetKeyMatch[1] : "unknown";
|
|
174
|
+
// Extract parameter passes
|
|
175
|
+
const params = [];
|
|
176
|
+
const parameterValues = doc.parameterValues;
|
|
177
|
+
if (parameterValues) {
|
|
178
|
+
for (const [, paramVal] of Object.entries(parameterValues)) {
|
|
179
|
+
const paramId = paramVal.paramIdentifier;
|
|
180
|
+
const value = resolveParamValue(paramVal);
|
|
181
|
+
params.push({
|
|
182
|
+
paramName: paramId || "unknown",
|
|
183
|
+
value,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// Resolve parent name
|
|
188
|
+
const parentName = await resolveParentName(projectId, parent.type, parent.id);
|
|
189
|
+
usages.push({
|
|
190
|
+
parentType: parent.type,
|
|
191
|
+
parentName,
|
|
192
|
+
parentId: parent.id,
|
|
193
|
+
widgetKey,
|
|
194
|
+
params,
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
// Format output
|
|
198
|
+
if (usages.length === 0) {
|
|
199
|
+
return {
|
|
200
|
+
content: [
|
|
201
|
+
{
|
|
202
|
+
type: "text",
|
|
203
|
+
text: `Component: ${displayName} (${targetId})\nNo usages found in cached files.`,
|
|
204
|
+
},
|
|
205
|
+
],
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
const lines = [];
|
|
209
|
+
lines.push(`Component: ${displayName} (${targetId})`);
|
|
210
|
+
lines.push(`Found ${usages.length} usage${usages.length === 1 ? "" : "s"}:`);
|
|
211
|
+
lines.push("");
|
|
212
|
+
for (let i = 0; i < usages.length; i++) {
|
|
213
|
+
const u = usages[i];
|
|
214
|
+
const parentLabel = u.parentType === "page" ? u.parentName : `[component] ${u.parentName}`;
|
|
215
|
+
lines.push(`${i + 1}. ${parentLabel} (${u.parentId}) \u2192 ${u.widgetKey}`);
|
|
216
|
+
if (u.params.length > 0) {
|
|
217
|
+
const paramStrs = u.params.map((p) => `${p.paramName} = ${p.value}`);
|
|
218
|
+
lines.push(` Params: ${paramStrs.join(", ")}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
223
|
+
};
|
|
224
|
+
});
|
|
225
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* find_page_navigations tool — scans cached action files to find all places
|
|
3
|
+
* that navigate to a given page.
|
|
4
|
+
* Zero API calls: everything comes from the local .ff-cache.
|
|
5
|
+
*/
|
|
6
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
|
+
export declare function registerFindPageNavigationsTool(server: McpServer): void;
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import YAML from "yaml";
|
|
3
|
+
import { cacheRead, cacheMeta, listCachedKeys, } from "../utils/cache.js";
|
|
4
|
+
import { resolvePage } from "./get-page-summary.js";
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Helpers
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
async function batchProcess(items, batchSize, fn) {
|
|
9
|
+
const results = [];
|
|
10
|
+
for (let i = 0; i < items.length; i += batchSize) {
|
|
11
|
+
const batch = items.slice(i, i + batchSize);
|
|
12
|
+
const batchResults = await Promise.all(batch.map(fn));
|
|
13
|
+
results.push(...batchResults);
|
|
14
|
+
}
|
|
15
|
+
return results;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Parse parent context from an action file key.
|
|
19
|
+
* Example: "page/id-Scaffold_XXX/page-widget-tree-outline/node/id-Widget_YYY/trigger_actions/id-ON_TAP/action/id-zzz"
|
|
20
|
+
*/
|
|
21
|
+
function parseActionContext(fileKey) {
|
|
22
|
+
// Page action
|
|
23
|
+
const pageMatch = fileKey.match(/^page\/id-(Scaffold_\w+)\/.*\/node\/id-(\w+)\/trigger_actions\/id-([^/]+)\/action\//);
|
|
24
|
+
if (pageMatch) {
|
|
25
|
+
return {
|
|
26
|
+
parentType: "page",
|
|
27
|
+
parentId: pageMatch[1],
|
|
28
|
+
widgetKey: pageMatch[2],
|
|
29
|
+
trigger: pageMatch[3],
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
// Component action
|
|
33
|
+
const compMatch = fileKey.match(/^component\/id-(Container_\w+)\/.*\/node\/id-(\w+)\/trigger_actions\/id-([^/]+)\/action\//);
|
|
34
|
+
if (compMatch) {
|
|
35
|
+
return {
|
|
36
|
+
parentType: "component",
|
|
37
|
+
parentId: compMatch[1],
|
|
38
|
+
widgetKey: compMatch[2],
|
|
39
|
+
trigger: compMatch[3],
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Recursively search an object for a navigate action targeting the given scaffold ID.
|
|
46
|
+
* Returns navigate details if found, including whether it's inside a disableAction.
|
|
47
|
+
*/
|
|
48
|
+
function findNavigateAction(obj, targetScaffoldId, isDisabled, depth) {
|
|
49
|
+
if (!obj || typeof obj !== "object" || depth > 12)
|
|
50
|
+
return null;
|
|
51
|
+
const o = obj;
|
|
52
|
+
// Check for disableAction wrapper
|
|
53
|
+
if ("disableAction" in o) {
|
|
54
|
+
const da = o.disableAction;
|
|
55
|
+
return findNavigateAction(da, targetScaffoldId, true, depth + 1);
|
|
56
|
+
}
|
|
57
|
+
// Check for navigate action with matching pageNodeKeyRef
|
|
58
|
+
if ("navigate" in o) {
|
|
59
|
+
const nav = o.navigate;
|
|
60
|
+
const pageRef = nav.pageNodeKeyRef;
|
|
61
|
+
if (pageRef?.key === targetScaffoldId) {
|
|
62
|
+
const allowBack = nav.allowBack ?? true;
|
|
63
|
+
const passedParams = [];
|
|
64
|
+
const params = nav.passedParameters;
|
|
65
|
+
if (params) {
|
|
66
|
+
for (const [key, val] of Object.entries(params)) {
|
|
67
|
+
if (key === "widgetClassNodeKeyRef")
|
|
68
|
+
continue;
|
|
69
|
+
passedParams.push(key);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { disabled: isDisabled, allowBack, passedParams };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// Recurse into values
|
|
76
|
+
for (const val of Object.values(o)) {
|
|
77
|
+
if (val && typeof val === "object") {
|
|
78
|
+
const found = findNavigateAction(val, targetScaffoldId, isDisabled, depth + 1);
|
|
79
|
+
if (found)
|
|
80
|
+
return found;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Resolve the name of a page or component from its top-level YAML cache.
|
|
87
|
+
*/
|
|
88
|
+
async function resolveParentName(projectId, parentType, parentId) {
|
|
89
|
+
const prefix = parentType === "page" ? "page" : "component";
|
|
90
|
+
const fileKey = `${prefix}/id-${parentId}`;
|
|
91
|
+
const content = await cacheRead(projectId, fileKey);
|
|
92
|
+
if (!content)
|
|
93
|
+
return parentId;
|
|
94
|
+
const nameMatch = content.match(/^name:\s*(.+)$/m);
|
|
95
|
+
return nameMatch ? nameMatch[1].trim() : parentId;
|
|
96
|
+
}
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Tool registration
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
export function registerFindPageNavigationsTool(server) {
|
|
101
|
+
server.tool("find_page_navigations", "Find all actions that navigate to a given page, showing source page, trigger, disabled status, and passed parameters. Cache-only, no API calls. Run sync_project first.", {
|
|
102
|
+
projectId: z.string().describe("The FlutterFlow project ID"),
|
|
103
|
+
pageName: z
|
|
104
|
+
.string()
|
|
105
|
+
.optional()
|
|
106
|
+
.describe("Human-readable page name (e.g. 'PaywallPage'). Case-insensitive."),
|
|
107
|
+
scaffoldId: z
|
|
108
|
+
.string()
|
|
109
|
+
.optional()
|
|
110
|
+
.describe("Scaffold ID (e.g. 'Scaffold_tydsj8ql')."),
|
|
111
|
+
}, async ({ projectId, pageName, scaffoldId }) => {
|
|
112
|
+
// Check cache exists
|
|
113
|
+
const meta = await cacheMeta(projectId);
|
|
114
|
+
if (!meta) {
|
|
115
|
+
return {
|
|
116
|
+
content: [
|
|
117
|
+
{
|
|
118
|
+
type: "text",
|
|
119
|
+
text: `No cache found for project "${projectId}". Run sync_project first to download the project YAML files.`,
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
if (!pageName && !scaffoldId) {
|
|
125
|
+
return {
|
|
126
|
+
content: [
|
|
127
|
+
{
|
|
128
|
+
type: "text",
|
|
129
|
+
text: "Provide either pageName or scaffoldId.",
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
// Resolve page
|
|
135
|
+
const resolved = await resolvePage(projectId, pageName, scaffoldId);
|
|
136
|
+
if (!resolved.ok) {
|
|
137
|
+
const list = resolved.available.map((n) => ` - ${n}`).join("\n");
|
|
138
|
+
const searchTerm = pageName || scaffoldId || "";
|
|
139
|
+
return {
|
|
140
|
+
content: [
|
|
141
|
+
{
|
|
142
|
+
type: "text",
|
|
143
|
+
text: `Page "${searchTerm}" not found in cache. Available pages:\n${list}`,
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
const targetScaffoldId = resolved.page.scaffoldId;
|
|
149
|
+
// Get target page name for display
|
|
150
|
+
const targetContent = await cacheRead(projectId, resolved.page.pageFileKey);
|
|
151
|
+
const displayName = targetContent
|
|
152
|
+
? (targetContent.match(/^name:\s*(.+)$/m)?.[1]?.trim() || targetScaffoldId)
|
|
153
|
+
: targetScaffoldId;
|
|
154
|
+
// Scan all action files for navigate references
|
|
155
|
+
const allKeys = await listCachedKeys(projectId);
|
|
156
|
+
const actionKeys = allKeys.filter((k) => /\/action\/id-/.test(k));
|
|
157
|
+
const refs = [];
|
|
158
|
+
await batchProcess(actionKeys, 20, async (actionKey) => {
|
|
159
|
+
const content = await cacheRead(projectId, actionKey);
|
|
160
|
+
if (!content)
|
|
161
|
+
return;
|
|
162
|
+
// Quick string check before YAML parsing
|
|
163
|
+
if (!content.includes(targetScaffoldId))
|
|
164
|
+
return;
|
|
165
|
+
let doc;
|
|
166
|
+
try {
|
|
167
|
+
doc = YAML.parse(content);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const navInfo = findNavigateAction(doc, targetScaffoldId, false, 0);
|
|
173
|
+
if (!navInfo)
|
|
174
|
+
return;
|
|
175
|
+
const ctx = parseActionContext(actionKey);
|
|
176
|
+
if (!ctx)
|
|
177
|
+
return;
|
|
178
|
+
const parentName = await resolveParentName(projectId, ctx.parentType, ctx.parentId);
|
|
179
|
+
refs.push({
|
|
180
|
+
parentType: ctx.parentType,
|
|
181
|
+
parentName,
|
|
182
|
+
parentId: ctx.parentId,
|
|
183
|
+
widgetKey: ctx.widgetKey,
|
|
184
|
+
trigger: ctx.trigger,
|
|
185
|
+
disabled: navInfo.disabled,
|
|
186
|
+
allowBack: navInfo.allowBack,
|
|
187
|
+
passedParams: navInfo.passedParams,
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
// Deduplicate (same parent + widget + trigger)
|
|
191
|
+
const seen = new Set();
|
|
192
|
+
const uniqueRefs = refs.filter((r) => {
|
|
193
|
+
const key = `${r.parentId}:${r.widgetKey}:${r.trigger}:${r.disabled}`;
|
|
194
|
+
if (seen.has(key))
|
|
195
|
+
return false;
|
|
196
|
+
seen.add(key);
|
|
197
|
+
return true;
|
|
198
|
+
});
|
|
199
|
+
// Format output
|
|
200
|
+
if (uniqueRefs.length === 0) {
|
|
201
|
+
return {
|
|
202
|
+
content: [
|
|
203
|
+
{
|
|
204
|
+
type: "text",
|
|
205
|
+
text: `Page: ${displayName} (${targetScaffoldId})\nNo navigations found in cached action files.`,
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
const lines = [];
|
|
211
|
+
lines.push(`Page: ${displayName} (${targetScaffoldId})`);
|
|
212
|
+
lines.push(`Found ${uniqueRefs.length} navigation${uniqueRefs.length === 1 ? "" : "s"}:`);
|
|
213
|
+
lines.push("");
|
|
214
|
+
for (let i = 0; i < uniqueRefs.length; i++) {
|
|
215
|
+
const r = uniqueRefs[i];
|
|
216
|
+
const status = r.disabled ? "[DISABLED] " : "";
|
|
217
|
+
const parentLabel = r.parentType === "page" ? r.parentName : `[component] ${r.parentName}`;
|
|
218
|
+
const back = r.allowBack ? "" : " (no back)";
|
|
219
|
+
lines.push(`${i + 1}. ${status}${parentLabel} (${r.parentId}) → ${r.trigger} on ${r.widgetKey}${back}`);
|
|
220
|
+
if (r.passedParams.length > 0) {
|
|
221
|
+
lines.push(` Params: ${r.passedParams.join(", ")}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
226
|
+
};
|
|
227
|
+
});
|
|
228
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* get_component_summary tool — assembles cached sub-files into a readable
|
|
3
|
+
* component summary. Zero API calls: everything comes from the local .ff-cache.
|
|
4
|
+
*/
|
|
5
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
6
|
+
interface ResolvedComponent {
|
|
7
|
+
containerId: string;
|
|
8
|
+
componentFileKey: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Resolve a component name or container ID to its cache file key.
|
|
12
|
+
* Returns all available component names if no match found.
|
|
13
|
+
*/
|
|
14
|
+
export declare function resolveComponent(projectId: string, componentName?: string, componentId?: string): Promise<{
|
|
15
|
+
ok: true;
|
|
16
|
+
component: ResolvedComponent;
|
|
17
|
+
} | {
|
|
18
|
+
ok: false;
|
|
19
|
+
available: string[];
|
|
20
|
+
}>;
|
|
21
|
+
export declare function registerGetComponentSummaryTool(server: McpServer): void;
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import YAML from "yaml";
|
|
3
|
+
import { cacheRead, cacheMeta, listCachedKeys, } from "../utils/cache.js";
|
|
4
|
+
import { parseTreeOutline } from "../utils/page-summary/tree-walker.js";
|
|
5
|
+
import { extractNodeInfo } from "../utils/page-summary/node-extractor.js";
|
|
6
|
+
import { summarizeTriggers } from "../utils/page-summary/action-summarizer.js";
|
|
7
|
+
import { formatComponentSummary } from "../utils/page-summary/formatter.js";
|
|
8
|
+
/**
|
|
9
|
+
* Resolve a component name or container ID to its cache file key.
|
|
10
|
+
* Returns all available component names if no match found.
|
|
11
|
+
*/
|
|
12
|
+
export async function resolveComponent(projectId, componentName, componentId) {
|
|
13
|
+
if (componentId) {
|
|
14
|
+
const componentFileKey = `component/id-${componentId}`;
|
|
15
|
+
const content = await cacheRead(projectId, componentFileKey);
|
|
16
|
+
if (content) {
|
|
17
|
+
return { ok: true, component: { containerId: componentId, componentFileKey } };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
// List all cached component top-level files
|
|
21
|
+
const allKeys = await listCachedKeys(projectId, "component/id-");
|
|
22
|
+
// Filter to top-level component files only (not sub-files)
|
|
23
|
+
const componentKeys = allKeys.filter((k) => /^component\/id-Container_\w+$/.test(k));
|
|
24
|
+
const available = [];
|
|
25
|
+
for (const key of componentKeys) {
|
|
26
|
+
const content = await cacheRead(projectId, key);
|
|
27
|
+
if (!content)
|
|
28
|
+
continue;
|
|
29
|
+
const nameMatch = content.match(/^name:\s*(.+)$/m);
|
|
30
|
+
const name = nameMatch ? nameMatch[1].trim() : "";
|
|
31
|
+
if (componentName && name.toLowerCase() === componentName.toLowerCase()) {
|
|
32
|
+
const cid = key.match(/^component\/id-(Container_\w+)$/)?.[1] || "";
|
|
33
|
+
return { ok: true, component: { containerId: cid, componentFileKey: key } };
|
|
34
|
+
}
|
|
35
|
+
if (name)
|
|
36
|
+
available.push(name);
|
|
37
|
+
}
|
|
38
|
+
return { ok: false, available };
|
|
39
|
+
}
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Metadata extraction
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
/** Resolve a scalar/list data type to a readable string. */
|
|
44
|
+
function resolveDataType(dt) {
|
|
45
|
+
if (dt.listType) {
|
|
46
|
+
const inner = dt.listType;
|
|
47
|
+
return `List<${inner.scalarType || "unknown"}>`;
|
|
48
|
+
}
|
|
49
|
+
if (dt.scalarType === "DataStruct") {
|
|
50
|
+
const sub = dt.subType;
|
|
51
|
+
const dsi = sub?.dataStructIdentifier;
|
|
52
|
+
return dsi?.name ? `DataStruct:${dsi.name}` : "DataStruct";
|
|
53
|
+
}
|
|
54
|
+
if (dt.enumType) {
|
|
55
|
+
const en = dt.enumType;
|
|
56
|
+
const eid = en.enumIdentifier;
|
|
57
|
+
return eid?.name ? `Enum:${eid.name}` : "Enum";
|
|
58
|
+
}
|
|
59
|
+
return dt.scalarType || "unknown";
|
|
60
|
+
}
|
|
61
|
+
/** Extract component metadata (name, description, params) from the top-level YAML. */
|
|
62
|
+
async function extractComponentMeta(projectId, component) {
|
|
63
|
+
const content = await cacheRead(projectId, component.componentFileKey);
|
|
64
|
+
if (!content) {
|
|
65
|
+
return {
|
|
66
|
+
componentName: component.containerId,
|
|
67
|
+
containerId: component.containerId,
|
|
68
|
+
description: "",
|
|
69
|
+
params: [],
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const doc = YAML.parse(content);
|
|
73
|
+
const componentName = doc.name || component.containerId;
|
|
74
|
+
const description = doc.description || "";
|
|
75
|
+
// Params
|
|
76
|
+
const params = [];
|
|
77
|
+
const rawParams = doc.params;
|
|
78
|
+
if (rawParams) {
|
|
79
|
+
for (const val of Object.values(rawParams)) {
|
|
80
|
+
const id = val.identifier;
|
|
81
|
+
const name = id?.name || "unknown";
|
|
82
|
+
const dt = val.dataType || {};
|
|
83
|
+
const defaultVal = val.defaultValue;
|
|
84
|
+
params.push({
|
|
85
|
+
name,
|
|
86
|
+
dataType: resolveDataType(dt),
|
|
87
|
+
defaultValue: defaultVal?.serializedValue,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { componentName, containerId: component.containerId, description, params };
|
|
92
|
+
}
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// Tree enrichment (reused from page summary)
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
/**
|
|
97
|
+
* Recursively enrich an OutlineNode tree with node info and trigger summaries.
|
|
98
|
+
*/
|
|
99
|
+
async function enrichNode(projectId, treePrefix, // e.g. "component/id-Container_xxx/component-widget-tree-outline"
|
|
100
|
+
outline) {
|
|
101
|
+
const nodeInfo = await extractNodeInfo(projectId, treePrefix, outline.key);
|
|
102
|
+
// Check for triggers
|
|
103
|
+
const nodeFilePrefix = `${treePrefix}/node/id-${outline.key}`;
|
|
104
|
+
const triggers = await summarizeTriggers(projectId, nodeFilePrefix);
|
|
105
|
+
// Enrich children
|
|
106
|
+
const children = await Promise.all(outline.children.map((child) => enrichNode(projectId, treePrefix, child)));
|
|
107
|
+
return {
|
|
108
|
+
key: outline.key,
|
|
109
|
+
type: nodeInfo.type,
|
|
110
|
+
name: nodeInfo.name,
|
|
111
|
+
slot: outline.slot,
|
|
112
|
+
detail: nodeInfo.detail,
|
|
113
|
+
triggers,
|
|
114
|
+
children,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Tool registration
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
export function registerGetComponentSummaryTool(server) {
|
|
121
|
+
server.tool("get_component_summary", "Get a readable summary of a FlutterFlow component from local cache — widget tree, actions, params. No API calls. Run sync_project first if not cached.", {
|
|
122
|
+
projectId: z.string().describe("The FlutterFlow project ID"),
|
|
123
|
+
componentName: z
|
|
124
|
+
.string()
|
|
125
|
+
.optional()
|
|
126
|
+
.describe("Human-readable component name (e.g. 'PremuimContentWall'). Case-insensitive. Provide either componentName or componentId."),
|
|
127
|
+
componentId: z
|
|
128
|
+
.string()
|
|
129
|
+
.optional()
|
|
130
|
+
.describe("Container ID (e.g. 'Container_ffzg5wc5'). Provide either componentName or componentId."),
|
|
131
|
+
}, async ({ projectId, componentName, componentId }) => {
|
|
132
|
+
// Check cache exists
|
|
133
|
+
const meta = await cacheMeta(projectId);
|
|
134
|
+
if (!meta) {
|
|
135
|
+
return {
|
|
136
|
+
content: [
|
|
137
|
+
{
|
|
138
|
+
type: "text",
|
|
139
|
+
text: `No cache found for project "${projectId}". Run sync_project first to download the project YAML files.`,
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
if (!componentName && !componentId) {
|
|
145
|
+
return {
|
|
146
|
+
content: [
|
|
147
|
+
{
|
|
148
|
+
type: "text",
|
|
149
|
+
text: "Provide either componentName or componentId.",
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
// Resolve component
|
|
155
|
+
const resolved = await resolveComponent(projectId, componentName, componentId);
|
|
156
|
+
if (!resolved.ok) {
|
|
157
|
+
const list = resolved.available.map((n) => ` - ${n}`).join("\n");
|
|
158
|
+
const searchTerm = componentName || componentId || "";
|
|
159
|
+
return {
|
|
160
|
+
content: [
|
|
161
|
+
{
|
|
162
|
+
type: "text",
|
|
163
|
+
text: `Component "${searchTerm}" not found in cache. Available components:\n${list}`,
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
const component = resolved.component;
|
|
169
|
+
// Extract metadata
|
|
170
|
+
const componentMeta = await extractComponentMeta(projectId, component);
|
|
171
|
+
// Parse widget tree outline
|
|
172
|
+
const outlineKey = `${component.componentFileKey}/component-widget-tree-outline`;
|
|
173
|
+
const outlineContent = await cacheRead(projectId, outlineKey);
|
|
174
|
+
if (!outlineContent) {
|
|
175
|
+
return {
|
|
176
|
+
content: [
|
|
177
|
+
{
|
|
178
|
+
type: "text",
|
|
179
|
+
text: `Component "${componentMeta.componentName}" found but widget tree outline is not cached. Re-run sync_project to fetch all sub-files.`,
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
const outlineTree = parseTreeOutline(outlineContent);
|
|
185
|
+
// Enrich the tree with node info and triggers
|
|
186
|
+
const enrichedTree = await enrichNode(projectId, outlineKey, outlineTree);
|
|
187
|
+
// Format output
|
|
188
|
+
const summary = formatComponentSummary(componentMeta, enrichedTree);
|
|
189
|
+
return {
|
|
190
|
+
content: [{ type: "text", text: summary }],
|
|
191
|
+
};
|
|
192
|
+
});
|
|
193
|
+
}
|