jsc-typescript-ast-mcp 1.0.11 → 1.1.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
CHANGED
|
@@ -96,6 +96,7 @@ Analyze React component structures in your codebase.
|
|
|
96
96
|
- **Input**:
|
|
97
97
|
- `entryFilePath`: Path to the entry file
|
|
98
98
|
- `maxDepth`: Maximum depth of the component tree (default: 3)
|
|
99
|
+
- `data-id` (optional): Attribute name to capture into `TreeNode.dataId` (example: `data-attribute-id`)
|
|
99
100
|
- **Output**: JSON representation of the component tree structure
|
|
100
101
|
|
|
101
102
|
## License
|
|
@@ -11,9 +11,13 @@ export const registerComponentTreeTool = (server) => {
|
|
|
11
11
|
.number()
|
|
12
12
|
.default(3)
|
|
13
13
|
.describe('Maximum depth of the component tree (default: 3)'),
|
|
14
|
+
'data-id': z
|
|
15
|
+
.string()
|
|
16
|
+
.optional()
|
|
17
|
+
.describe('Optional attribute name to capture as TreeNode.dataId (example: data-attribute-id)'),
|
|
14
18
|
},
|
|
15
|
-
}, async ({ entryFilePath, maxDepth }) => {
|
|
16
|
-
const tree = getComponentTree(entryFilePath, maxDepth);
|
|
19
|
+
}, async ({ entryFilePath, maxDepth, ['data-id']: dataIdAttribute }) => {
|
|
20
|
+
const tree = getComponentTree(entryFilePath, maxDepth, dataIdAttribute);
|
|
17
21
|
return {
|
|
18
22
|
content: [
|
|
19
23
|
{
|
|
@@ -24,7 +28,7 @@ export const registerComponentTreeTool = (server) => {
|
|
|
24
28
|
};
|
|
25
29
|
});
|
|
26
30
|
};
|
|
27
|
-
const getComponentTree = (entryFilePath, maxDepth) => {
|
|
31
|
+
const getComponentTree = (entryFilePath, maxDepth, dataIdAttribute) => {
|
|
28
32
|
const project = createProject();
|
|
29
33
|
const sourceFile = project.getSourceFile(entryFilePath);
|
|
30
34
|
if (!sourceFile) {
|
|
@@ -33,5 +37,6 @@ const getComponentTree = (entryFilePath, maxDepth) => {
|
|
|
33
37
|
const component = getComponent(sourceFile);
|
|
34
38
|
return analyzeComponent(component, project, {
|
|
35
39
|
maxDepth,
|
|
40
|
+
dataIdAttribute,
|
|
36
41
|
}, 0);
|
|
37
42
|
};
|
|
@@ -1,16 +1,52 @@
|
|
|
1
|
-
import { SyntaxKind } from 'ts-morph';
|
|
2
|
-
|
|
1
|
+
import { Node, SyntaxKind } from 'ts-morph';
|
|
2
|
+
const isComponentVariableDeclaration = (node) => {
|
|
3
|
+
if (!Node.isVariableDeclaration(node)) {
|
|
4
|
+
return false;
|
|
5
|
+
}
|
|
6
|
+
const initializer = node.getInitializer();
|
|
7
|
+
if (!initializer) {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
return (initializer.getKind() === SyntaxKind.ArrowFunction ||
|
|
11
|
+
initializer.getKind() === SyntaxKind.FunctionExpression);
|
|
12
|
+
};
|
|
13
|
+
const isComponentDeclaration = (node) => {
|
|
14
|
+
if (Node.isFunctionDeclaration(node)) {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
return isComponentVariableDeclaration(node);
|
|
18
|
+
};
|
|
19
|
+
const getFirstExportedComponent = (sourceFile) => {
|
|
20
|
+
const exportedDeclarations = sourceFile.getExportedDeclarations();
|
|
21
|
+
for (const declarations of exportedDeclarations.values()) {
|
|
22
|
+
const componentDeclaration = declarations.find(isComponentDeclaration);
|
|
23
|
+
if (componentDeclaration) {
|
|
24
|
+
return componentDeclaration;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return undefined;
|
|
28
|
+
};
|
|
3
29
|
export const getComponent = (sourceFile) => {
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
return
|
|
14
|
-
}
|
|
15
|
-
|
|
30
|
+
const defaultExportDeclaration = sourceFile
|
|
31
|
+
.getDefaultExportSymbol()
|
|
32
|
+
?.getDeclarations()
|
|
33
|
+
?.find(isComponentDeclaration);
|
|
34
|
+
if (defaultExportDeclaration) {
|
|
35
|
+
return defaultExportDeclaration;
|
|
36
|
+
}
|
|
37
|
+
const firstExportedComponent = getFirstExportedComponent(sourceFile);
|
|
38
|
+
if (firstExportedComponent) {
|
|
39
|
+
return firstExportedComponent;
|
|
40
|
+
}
|
|
41
|
+
const localVariableComponent = sourceFile
|
|
42
|
+
.getVariableDeclarations()
|
|
43
|
+
.find(isComponentVariableDeclaration);
|
|
44
|
+
if (localVariableComponent) {
|
|
45
|
+
return localVariableComponent;
|
|
46
|
+
}
|
|
47
|
+
const localFunctionComponent = sourceFile.getFunctions()[0];
|
|
48
|
+
if (localFunctionComponent) {
|
|
49
|
+
return localFunctionComponent;
|
|
50
|
+
}
|
|
51
|
+
throw new Error(`Component not found in ${sourceFile.getFilePath()}`);
|
|
16
52
|
};
|
|
@@ -2,8 +2,9 @@ import { Node, SyntaxKind, } from 'ts-morph';
|
|
|
2
2
|
import { analyzeComponent } from './analyzeComponent.js';
|
|
3
3
|
import { getComponent } from './component.js';
|
|
4
4
|
import { getTagName } from './nameHelper.js';
|
|
5
|
+
import { extractPropsFromNode } from './props.js';
|
|
5
6
|
import { handleTernary } from './ternary.js';
|
|
6
|
-
const extractFromExpression = (expression, project, options, depth) => {
|
|
7
|
+
export const extractFromExpression = (expression, project, options, depth) => {
|
|
7
8
|
if (Node.isConditionalExpression(expression)) {
|
|
8
9
|
return handleTernary(expression, project, options, depth);
|
|
9
10
|
}
|
|
@@ -15,7 +16,7 @@ const extractFromExpression = (expression, project, options, depth) => {
|
|
|
15
16
|
}
|
|
16
17
|
return extractJSX(expression, project, options, depth);
|
|
17
18
|
};
|
|
18
|
-
const handleLogicalAnd = (expression, project, options, depth) => {
|
|
19
|
+
export const handleLogicalAnd = (expression, project, options, depth) => {
|
|
19
20
|
if (expression.getOperatorToken().getText() !== '&&') {
|
|
20
21
|
return [];
|
|
21
22
|
}
|
|
@@ -90,20 +91,65 @@ const resolveComponentFile = (node) => {
|
|
|
90
91
|
const defaultImport = imp.getDefaultImport();
|
|
91
92
|
if (defaultImport?.getText() === tagName) {
|
|
92
93
|
const file = imp.getModuleSpecifierSourceFile();
|
|
93
|
-
|
|
94
|
-
|
|
94
|
+
if (!file || file.getFilePath().includes('/node_modules/')) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
const component = getComponent(file);
|
|
99
|
+
return component ?? null;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
95
104
|
}
|
|
96
105
|
for (const n of named) {
|
|
97
106
|
if (n.getName() === tagName) {
|
|
98
107
|
const file = imp.getModuleSpecifierSourceFile();
|
|
99
|
-
|
|
100
|
-
|
|
108
|
+
if (!file || file.getFilePath().includes('/node_modules/')) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
const component = getComponent(file);
|
|
113
|
+
return component ?? null;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
101
118
|
}
|
|
102
119
|
}
|
|
103
120
|
}
|
|
104
121
|
return null;
|
|
105
122
|
};
|
|
106
|
-
|
|
123
|
+
const resolveLocalComponentFilePath = (node) => {
|
|
124
|
+
const tagName = getTagName(node);
|
|
125
|
+
const sourceFile = node.getSourceFile();
|
|
126
|
+
for (const imp of sourceFile.getImportDeclarations()) {
|
|
127
|
+
const matchesDefaultImport = imp.getDefaultImport()?.getText() === tagName;
|
|
128
|
+
const matchesNamedImport = imp
|
|
129
|
+
.getNamedImports()
|
|
130
|
+
.some((namedImport) => namedImport.getName() === tagName);
|
|
131
|
+
if (!matchesDefaultImport && !matchesNamedImport) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const moduleSpecifier = imp.getModuleSpecifierValue();
|
|
135
|
+
const isLocalImport = moduleSpecifier.startsWith('.') || moduleSpecifier.startsWith('/');
|
|
136
|
+
if (!isLocalImport) {
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
const importedSourceFile = imp.getModuleSpecifierSourceFile();
|
|
140
|
+
if (!importedSourceFile) {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
const importedFilePath = importedSourceFile.getFilePath();
|
|
144
|
+
if (importedFilePath.includes('/node_modules/')) {
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
return importedFilePath;
|
|
148
|
+
}
|
|
149
|
+
// If there is no matching import, treat it as a component from the current local source file.
|
|
150
|
+
return sourceFile.getFilePath();
|
|
151
|
+
};
|
|
152
|
+
const buildNodeFromJSX = (node, project, options, depth) => {
|
|
107
153
|
const tagName = getTagName(node);
|
|
108
154
|
const isHtml = tagName[0] === tagName[0].toLowerCase();
|
|
109
155
|
const treeNode = {
|
|
@@ -111,6 +157,22 @@ function buildNodeFromJSX(node, project, options, depth) {
|
|
|
111
157
|
type: isHtml ? 'html' : 'component',
|
|
112
158
|
children: [],
|
|
113
159
|
};
|
|
160
|
+
if (!isHtml) {
|
|
161
|
+
const localComponentFilePath = resolveLocalComponentFilePath(node);
|
|
162
|
+
if (localComponentFilePath) {
|
|
163
|
+
treeNode.filePath = localComponentFilePath;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const props = extractPropsFromNode(node);
|
|
167
|
+
if (props) {
|
|
168
|
+
treeNode.props = props;
|
|
169
|
+
if (options.dataIdAttribute && options.dataIdAttribute in props) {
|
|
170
|
+
const dataIdValue = props[options.dataIdAttribute];
|
|
171
|
+
if (dataIdValue !== undefined && dataIdValue !== null) {
|
|
172
|
+
treeNode.dataId = String(dataIdValue);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
114
176
|
// Recurse into children
|
|
115
177
|
if (Node.isJsxElement(node)) {
|
|
116
178
|
const children = node.getJsxChildren();
|
|
@@ -140,12 +202,13 @@ function buildNodeFromJSX(node, project, options, depth) {
|
|
|
140
202
|
if (!isHtml && depth < options.maxDepth) {
|
|
141
203
|
const resolved = resolveComponentFile(node);
|
|
142
204
|
if (resolved) {
|
|
205
|
+
treeNode.filePath = resolved.getSourceFile().getFilePath();
|
|
143
206
|
const subTree = analyzeComponent(resolved, project, options, depth);
|
|
144
207
|
treeNode.children.push(...subTree.children);
|
|
145
208
|
}
|
|
146
209
|
}
|
|
147
210
|
return treeNode;
|
|
148
|
-
}
|
|
211
|
+
};
|
|
149
212
|
export const findReturnedJSX = (node) => {
|
|
150
213
|
const returnStmt = node.getDescendantsOfKind(SyntaxKind.ReturnStatement)[0];
|
|
151
214
|
if (returnStmt) {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Node, } from 'ts-morph';
|
|
2
|
+
const extractPropValue = (attribute) => {
|
|
3
|
+
const initializer = attribute.getInitializer();
|
|
4
|
+
if (!initializer) {
|
|
5
|
+
return true;
|
|
6
|
+
}
|
|
7
|
+
if (Node.isStringLiteral(initializer)) {
|
|
8
|
+
return initializer.getLiteralText();
|
|
9
|
+
}
|
|
10
|
+
if (Node.isJsxExpression(initializer)) {
|
|
11
|
+
const expression = initializer.getExpression();
|
|
12
|
+
if (!expression) {
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
return expression.getText();
|
|
16
|
+
}
|
|
17
|
+
return initializer.getText();
|
|
18
|
+
};
|
|
19
|
+
const addSpreadProps = (props, spreadAttribute) => {
|
|
20
|
+
const expression = spreadAttribute.getExpression();
|
|
21
|
+
const expressionText = expression.getText();
|
|
22
|
+
// Keep spread attributes as references so callers can resolve them later if needed.
|
|
23
|
+
props[`...${expressionText}`] = expressionText;
|
|
24
|
+
};
|
|
25
|
+
export const extractPropsFromNode = (node) => {
|
|
26
|
+
const attributes = Node.isJsxElement(node)
|
|
27
|
+
? node.getOpeningElement().getAttributes()
|
|
28
|
+
: node.getAttributes();
|
|
29
|
+
if (attributes.length === 0) {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
const props = {};
|
|
33
|
+
for (const attribute of attributes) {
|
|
34
|
+
if (Node.isJsxAttribute(attribute)) {
|
|
35
|
+
props[attribute.getNameNode().getText()] = extractPropValue(attribute);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (Node.isJsxSpreadAttribute(attribute)) {
|
|
39
|
+
addSpreadProps(props, attribute);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (Object.keys(props).length === 0) {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
return props;
|
|
46
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jsc-typescript-ast-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"mcpName": "io.github.jscoobyced/jsc-typescript-ast-mcp",
|
|
5
5
|
"description": "A Model Context Protocol (MCP) server that provides an abstract syntax tree (AST) representation of TypeScript code using the ts-morph library. It allows clients to analyze and manipulate TypeScript code structures, making it easier for AI models to understand and work with TypeScript projects. You can create a JSON representation of your React components, and use it for various purposes such as documentation, code analysis, or even generating new code based on existing components.",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"scripts": {
|
|
22
22
|
"format": "prettier --write .",
|
|
23
23
|
"build": "tsc",
|
|
24
|
+
"lint": "eslint . --ext .ts",
|
|
24
25
|
"test:mcp": "npx @modelcontextprotocol/inspector node dist/index.js"
|
|
25
26
|
},
|
|
26
27
|
"bin": {
|