wp-devdocs-mcp 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/LICENSE +21 -0
- package/README.md +407 -0
- package/bin/wp-hooks.js +533 -0
- package/package.json +62 -0
- package/src/constants.js +22 -0
- package/src/db/sqlite.js +1012 -0
- package/src/docs/doc-index-manager.js +128 -0
- package/src/docs/parsers/admin-handbook-parser.js +48 -0
- package/src/docs/parsers/base-doc-parser.js +175 -0
- package/src/docs/parsers/block-editor-parser.js +49 -0
- package/src/docs/parsers/general-doc-parser.js +53 -0
- package/src/docs/parsers/plugin-handbook-parser.js +60 -0
- package/src/docs/parsers/rest-api-parser.js +57 -0
- package/src/docs/parsers/wp-cli-parser.js +61 -0
- package/src/indexer/index-manager.js +187 -0
- package/src/indexer/js-parser.js +306 -0
- package/src/indexer/parser-utils.js +158 -0
- package/src/indexer/php-parser.js +205 -0
- package/src/indexer/sources/github-private.js +57 -0
- package/src/indexer/sources/github-public.js +38 -0
- package/src/indexer/sources/index.js +19 -0
- package/src/indexer/sources/local-folder.js +17 -0
- package/src/mcp-entry.js +109 -0
- package/src/presets.js +68 -0
- package/src/server/tools/get-doc.js +79 -0
- package/src/server/tools/get-hook-context.js +67 -0
- package/src/server/tools/list-docs.js +71 -0
- package/src/server/tools/search-block-apis.js +72 -0
- package/src/server/tools/search-docs.js +55 -0
- package/src/server/tools/search-hooks.js +64 -0
- package/src/server/tools/validate-hook.js +66 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getLineNumber,
|
|
3
|
+
extractCodeWindow,
|
|
4
|
+
generateContentHash,
|
|
5
|
+
inferDescription,
|
|
6
|
+
extractDocblock,
|
|
7
|
+
findEnclosingFunction,
|
|
8
|
+
findEnclosingClass,
|
|
9
|
+
} from './parser-utils.js';
|
|
10
|
+
|
|
11
|
+
// Matches do_action(), apply_filters(), do_action_ref_array(), apply_filters_ref_array()
|
|
12
|
+
const HOOK_REGEX = /\b(do_action|apply_filters|do_action_ref_array|apply_filters_ref_array)\s*\(\s*/g;
|
|
13
|
+
|
|
14
|
+
const TYPE_MAP = {
|
|
15
|
+
do_action: 'action',
|
|
16
|
+
apply_filters: 'filter',
|
|
17
|
+
do_action_ref_array: 'action_ref_array',
|
|
18
|
+
apply_filters_ref_array: 'filter_ref_array',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Parse a PHP file and extract all WordPress hooks.
|
|
23
|
+
* @param {string} content - File content
|
|
24
|
+
* @param {string} filePath - Relative file path
|
|
25
|
+
* @param {number} sourceId - Source ID
|
|
26
|
+
* @returns {Array} Array of hook data objects
|
|
27
|
+
*/
|
|
28
|
+
export function parsePhpFile(content, filePath, sourceId) {
|
|
29
|
+
const hooks = [];
|
|
30
|
+
const lines = content.split('\n');
|
|
31
|
+
|
|
32
|
+
let match;
|
|
33
|
+
HOOK_REGEX.lastIndex = 0;
|
|
34
|
+
|
|
35
|
+
while ((match = HOOK_REGEX.exec(content)) !== null) {
|
|
36
|
+
const funcName = match[1];
|
|
37
|
+
const type = TYPE_MAP[funcName];
|
|
38
|
+
const startOffset = match.index + match[0].length;
|
|
39
|
+
|
|
40
|
+
// Extract the arguments string (handle nested parentheses)
|
|
41
|
+
const argsStr = extractArguments(content, startOffset);
|
|
42
|
+
if (!argsStr) continue;
|
|
43
|
+
|
|
44
|
+
const args = splitArguments(argsStr);
|
|
45
|
+
if (args.length === 0) continue;
|
|
46
|
+
|
|
47
|
+
// First arg is the hook name
|
|
48
|
+
const rawName = args[0].trim();
|
|
49
|
+
const hookName = cleanHookName(rawName);
|
|
50
|
+
if (!hookName) continue;
|
|
51
|
+
|
|
52
|
+
const isDynamic = rawName.includes('$') || (rawName.includes('.') && !rawName.startsWith("'") && !rawName.startsWith('"'));
|
|
53
|
+
|
|
54
|
+
const lineNumber = getLineNumber(content, match.index);
|
|
55
|
+
const lineIndex = lineNumber - 1;
|
|
56
|
+
|
|
57
|
+
const params = args.slice(1).map(p => p.trim()).filter(Boolean);
|
|
58
|
+
const paramCount = params.length;
|
|
59
|
+
|
|
60
|
+
const docblock = extractDocblock(lines, lineIndex);
|
|
61
|
+
const { codeBefore, hookLine, codeAfter } = extractCodeWindow(lines, lineIndex);
|
|
62
|
+
const phpFunction = findEnclosingFunction(lines, lineIndex);
|
|
63
|
+
const className = findEnclosingClass(lines, lineIndex);
|
|
64
|
+
|
|
65
|
+
const hookData = {
|
|
66
|
+
source_id: sourceId,
|
|
67
|
+
file_path: filePath,
|
|
68
|
+
line_number: lineNumber,
|
|
69
|
+
name: hookName,
|
|
70
|
+
type,
|
|
71
|
+
php_function: phpFunction || null,
|
|
72
|
+
params: params.join(', ') || null,
|
|
73
|
+
param_count: paramCount,
|
|
74
|
+
docblock: docblock || null,
|
|
75
|
+
inferred_description: null,
|
|
76
|
+
function_context: phpFunction || null,
|
|
77
|
+
class_name: className || null,
|
|
78
|
+
code_before: codeBefore || null,
|
|
79
|
+
code_after: codeAfter || null,
|
|
80
|
+
hook_line: hookLine || null,
|
|
81
|
+
is_dynamic: isDynamic ? 1 : 0,
|
|
82
|
+
content_hash: null,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
hookData.inferred_description = inferDescription(hookData);
|
|
86
|
+
hookData.content_hash = generateContentHash(hookData);
|
|
87
|
+
|
|
88
|
+
hooks.push(hookData);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return hooks;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Extract arguments string from content starting at the position after opening paren.
|
|
96
|
+
*/
|
|
97
|
+
function extractArguments(content, start) {
|
|
98
|
+
let depth = 1;
|
|
99
|
+
let i = start;
|
|
100
|
+
const max = Math.min(start + 2000, content.length); // Safety limit
|
|
101
|
+
|
|
102
|
+
while (i < max && depth > 0) {
|
|
103
|
+
const ch = content[i];
|
|
104
|
+
if (ch === '(') depth++;
|
|
105
|
+
else if (ch === ')') depth--;
|
|
106
|
+
else if (ch === "'" || ch === '"') {
|
|
107
|
+
// Skip string literals
|
|
108
|
+
i = skipString(content, i);
|
|
109
|
+
}
|
|
110
|
+
if (depth > 0) i++;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (depth !== 0) return null;
|
|
114
|
+
return content.slice(start, i);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Skip a string literal starting at position i.
|
|
119
|
+
*/
|
|
120
|
+
function skipString(content, i) {
|
|
121
|
+
const quote = content[i];
|
|
122
|
+
i++;
|
|
123
|
+
while (i < content.length) {
|
|
124
|
+
if (content[i] === '\\') {
|
|
125
|
+
i += 2;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (content[i] === quote) return i;
|
|
129
|
+
i++;
|
|
130
|
+
}
|
|
131
|
+
return i;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Split arguments by comma, respecting nesting and strings.
|
|
136
|
+
*/
|
|
137
|
+
function splitArguments(argsStr) {
|
|
138
|
+
const args = [];
|
|
139
|
+
let current = '';
|
|
140
|
+
let depth = 0;
|
|
141
|
+
let inString = false;
|
|
142
|
+
let stringChar = '';
|
|
143
|
+
|
|
144
|
+
for (let i = 0; i < argsStr.length; i++) {
|
|
145
|
+
const ch = argsStr[i];
|
|
146
|
+
|
|
147
|
+
if (inString) {
|
|
148
|
+
current += ch;
|
|
149
|
+
if (ch === '\\') {
|
|
150
|
+
i++;
|
|
151
|
+
if (i < argsStr.length) current += argsStr[i];
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (ch === stringChar) inString = false;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (ch === "'" || ch === '"') {
|
|
159
|
+
inString = true;
|
|
160
|
+
stringChar = ch;
|
|
161
|
+
current += ch;
|
|
162
|
+
} else if (ch === '(' || ch === '[' || ch === '{') {
|
|
163
|
+
depth++;
|
|
164
|
+
current += ch;
|
|
165
|
+
} else if (ch === ')' || ch === ']' || ch === '}') {
|
|
166
|
+
depth--;
|
|
167
|
+
current += ch;
|
|
168
|
+
} else if (ch === ',' && depth === 0) {
|
|
169
|
+
args.push(current);
|
|
170
|
+
current = '';
|
|
171
|
+
} else {
|
|
172
|
+
current += ch;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (current.trim()) args.push(current);
|
|
177
|
+
return args;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Clean a hook name string — remove quotes, handle concatenation.
|
|
182
|
+
*/
|
|
183
|
+
function cleanHookName(raw) {
|
|
184
|
+
const trimmed = raw.trim();
|
|
185
|
+
|
|
186
|
+
// Simple quoted string: 'hook_name' or "hook_name"
|
|
187
|
+
const simpleMatch = trimmed.match(/^['"]([^'"]+)['"]$/);
|
|
188
|
+
if (simpleMatch) return simpleMatch[1];
|
|
189
|
+
|
|
190
|
+
// Concatenated string: 'prefix_' . $var . '_suffix' → prefix_{dynamic}_suffix
|
|
191
|
+
if (trimmed.includes('.') || trimmed.includes('$')) {
|
|
192
|
+
const parts = trimmed.split(/\s*\.\s*/);
|
|
193
|
+
const cleaned = parts.map(part => {
|
|
194
|
+
const qm = part.trim().match(/^['"]([^'"]*)['"]$/);
|
|
195
|
+
if (qm) return qm[1];
|
|
196
|
+
return '{dynamic}';
|
|
197
|
+
}).join('');
|
|
198
|
+
return cleaned || null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Variable only
|
|
202
|
+
if (trimmed.startsWith('$')) return '{dynamic}';
|
|
203
|
+
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
3
|
+
import simpleGit from 'simple-git';
|
|
4
|
+
import { CACHE_DIR } from '../../constants.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Fetch a private GitHub repo using a token from an env var.
|
|
8
|
+
* Token is injected into the URL at clone time — never stored.
|
|
9
|
+
*/
|
|
10
|
+
export async function fetchGithubPrivate(source) {
|
|
11
|
+
const tokenEnvVar = source.token_env_var;
|
|
12
|
+
if (!tokenEnvVar) {
|
|
13
|
+
throw new Error(`Source "${source.name}" is type github-private but has no token_env_var configured`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const token = process.env[tokenEnvVar];
|
|
17
|
+
if (!token) {
|
|
18
|
+
throw new Error(`Environment variable "${tokenEnvVar}" is not set. Required for private repo "${source.name}"`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const repoName = source.repo_url.replace(/.*\/\/[^/]+\//, '').replace(/\.git$/, '').replace(/\//g, '--');
|
|
22
|
+
const cloneDir = join(CACHE_DIR, repoName);
|
|
23
|
+
mkdirSync(CACHE_DIR, { recursive: true });
|
|
24
|
+
|
|
25
|
+
// Inject token into URL: https://token@github.com/...
|
|
26
|
+
const authedUrl = source.repo_url.replace('https://', `https://${token}@`);
|
|
27
|
+
|
|
28
|
+
const git = simpleGit();
|
|
29
|
+
|
|
30
|
+
if (existsSync(join(cloneDir, '.git'))) {
|
|
31
|
+
const repoGit = simpleGit(cloneDir);
|
|
32
|
+
try {
|
|
33
|
+
// Set remote URL with token temporarily for fetch
|
|
34
|
+
await repoGit.remote(['set-url', 'origin', authedUrl]);
|
|
35
|
+
await repoGit.fetch('origin', source.branch || 'main', ['--depth=1']);
|
|
36
|
+
await repoGit.reset(['--hard', `origin/${source.branch || 'main'}`]);
|
|
37
|
+
// Remove token from stored remote
|
|
38
|
+
await repoGit.remote(['set-url', 'origin', source.repo_url]);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
// Clean up token from remote even on error
|
|
41
|
+
try { await repoGit.remote(['set-url', 'origin', source.repo_url]); } catch (_) { /* token cleanup is best-effort */ }
|
|
42
|
+
console.error(`Warning: pull failed for ${source.name}: ${err.message}`);
|
|
43
|
+
}
|
|
44
|
+
} else {
|
|
45
|
+
await git.clone(authedUrl, cloneDir, [
|
|
46
|
+
'--depth=1',
|
|
47
|
+
'--branch', source.branch || 'main',
|
|
48
|
+
'--single-branch',
|
|
49
|
+
]);
|
|
50
|
+
// Remove token from stored remote
|
|
51
|
+
const repoGit = simpleGit(cloneDir);
|
|
52
|
+
await repoGit.remote(['set-url', 'origin', source.repo_url]);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const localPath = source.subfolder ? join(cloneDir, source.subfolder) : cloneDir;
|
|
56
|
+
return localPath;
|
|
57
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import simpleGit from 'simple-git';
|
|
4
|
+
import { CACHE_DIR } from '../../constants.js';
|
|
5
|
+
import { mkdirSync } from 'node:fs';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Fetch a public GitHub repo via shallow clone / pull.
|
|
9
|
+
* Returns the local path to the (optionally subfoldered) source.
|
|
10
|
+
*/
|
|
11
|
+
export async function fetchGithubPublic(source) {
|
|
12
|
+
const repoName = source.repo_url.replace(/.*\/\/[^/]+\//, '').replace(/\.git$/, '').replace(/\//g, '--');
|
|
13
|
+
const cloneDir = join(CACHE_DIR, repoName);
|
|
14
|
+
mkdirSync(CACHE_DIR, { recursive: true });
|
|
15
|
+
|
|
16
|
+
const git = simpleGit();
|
|
17
|
+
|
|
18
|
+
if (existsSync(join(cloneDir, '.git'))) {
|
|
19
|
+
// Pull latest
|
|
20
|
+
const repoGit = simpleGit(cloneDir);
|
|
21
|
+
try {
|
|
22
|
+
await repoGit.fetch('origin', source.branch || 'main', ['--depth=1']);
|
|
23
|
+
await repoGit.reset(['--hard', `origin/${source.branch || 'main'}`]);
|
|
24
|
+
} catch (err) {
|
|
25
|
+
console.error(`Warning: pull failed for ${source.name}, using cached version: ${err.message}`);
|
|
26
|
+
}
|
|
27
|
+
} else {
|
|
28
|
+
// Shallow clone
|
|
29
|
+
await git.clone(source.repo_url, cloneDir, [
|
|
30
|
+
'--depth=1',
|
|
31
|
+
'--branch', source.branch || 'main',
|
|
32
|
+
'--single-branch',
|
|
33
|
+
]);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const localPath = source.subfolder ? join(cloneDir, source.subfolder) : cloneDir;
|
|
37
|
+
return localPath;
|
|
38
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { fetchGithubPublic } from './github-public.js';
|
|
2
|
+
import { fetchGithubPrivate } from './github-private.js';
|
|
3
|
+
import { fetchLocalFolder } from './local-folder.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Unified dispatcher — fetches/validates a source and returns the local path.
|
|
7
|
+
*/
|
|
8
|
+
export async function fetchSource(source) {
|
|
9
|
+
switch (source.type) {
|
|
10
|
+
case 'github-public':
|
|
11
|
+
return fetchGithubPublic(source);
|
|
12
|
+
case 'github-private':
|
|
13
|
+
return fetchGithubPrivate(source);
|
|
14
|
+
case 'local-folder':
|
|
15
|
+
return fetchLocalFolder(source);
|
|
16
|
+
default:
|
|
17
|
+
throw new Error(`Unknown source type: ${source.type}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Validate and return a local folder path.
|
|
5
|
+
*/
|
|
6
|
+
export async function fetchLocalFolder(source) {
|
|
7
|
+
const localPath = source.local_path;
|
|
8
|
+
if (!localPath) {
|
|
9
|
+
throw new Error(`Source "${source.name}" is type local-folder but has no local_path configured`);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (!existsSync(localPath)) {
|
|
13
|
+
throw new Error(`Local path does not exist: ${localPath}`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return localPath;
|
|
17
|
+
}
|
package/src/mcp-entry.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
5
|
+
|
|
6
|
+
import { searchHooksSchema, handleSearchHooks } from './server/tools/search-hooks.js';
|
|
7
|
+
import { validateHookSchema, handleValidateHook } from './server/tools/validate-hook.js';
|
|
8
|
+
import { getHookContextSchema, handleGetHookContext } from './server/tools/get-hook-context.js';
|
|
9
|
+
import { searchBlockApisSchema, handleSearchBlockApis } from './server/tools/search-block-apis.js';
|
|
10
|
+
import { searchDocsSchema, handleSearchDocs } from './server/tools/search-docs.js';
|
|
11
|
+
import { getDocSchema, handleGetDoc } from './server/tools/get-doc.js';
|
|
12
|
+
import { listDocsSchema, handleListDocs } from './server/tools/list-docs.js';
|
|
13
|
+
|
|
14
|
+
// Initialize DB on import (side effect)
|
|
15
|
+
import { getDb, getStaleSources } from './db/sqlite.js';
|
|
16
|
+
import { indexSources } from './indexer/index-manager.js';
|
|
17
|
+
|
|
18
|
+
const server = new McpServer({
|
|
19
|
+
name: 'wp-devdocs-mcp',
|
|
20
|
+
version: '1.1.0',
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// Register tools
|
|
24
|
+
server.tool(
|
|
25
|
+
searchHooksSchema.name,
|
|
26
|
+
searchHooksSchema.description,
|
|
27
|
+
searchHooksSchema.inputSchema,
|
|
28
|
+
handleSearchHooks,
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
server.tool(
|
|
32
|
+
validateHookSchema.name,
|
|
33
|
+
validateHookSchema.description,
|
|
34
|
+
validateHookSchema.inputSchema,
|
|
35
|
+
handleValidateHook,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
server.tool(
|
|
39
|
+
getHookContextSchema.name,
|
|
40
|
+
getHookContextSchema.description,
|
|
41
|
+
getHookContextSchema.inputSchema,
|
|
42
|
+
handleGetHookContext,
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
server.tool(
|
|
46
|
+
searchBlockApisSchema.name,
|
|
47
|
+
searchBlockApisSchema.description,
|
|
48
|
+
searchBlockApisSchema.inputSchema,
|
|
49
|
+
handleSearchBlockApis,
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
server.tool(
|
|
53
|
+
searchDocsSchema.name,
|
|
54
|
+
searchDocsSchema.description,
|
|
55
|
+
searchDocsSchema.inputSchema,
|
|
56
|
+
handleSearchDocs,
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
server.tool(
|
|
60
|
+
getDocSchema.name,
|
|
61
|
+
getDocSchema.description,
|
|
62
|
+
getDocSchema.inputSchema,
|
|
63
|
+
handleGetDoc,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
server.tool(
|
|
67
|
+
listDocsSchema.name,
|
|
68
|
+
listDocsSchema.description,
|
|
69
|
+
listDocsSchema.inputSchema,
|
|
70
|
+
handleListDocs,
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
// Ensure DB is ready
|
|
74
|
+
try {
|
|
75
|
+
getDb();
|
|
76
|
+
} catch (err) {
|
|
77
|
+
process.stderr.write(`Failed to initialize database: ${err.message}\n`);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Start
|
|
82
|
+
const transport = new StdioServerTransport();
|
|
83
|
+
await server.connect(transport);
|
|
84
|
+
|
|
85
|
+
// Background auto-update of stale sources (fire-and-forget)
|
|
86
|
+
async function autoUpdate() {
|
|
87
|
+
if (process.env.WP_MCP_AUTO_UPDATE === 'false') return;
|
|
88
|
+
try {
|
|
89
|
+
const staleSources = getStaleSources(24 * 60 * 60 * 1000);
|
|
90
|
+
if (staleSources.length === 0) return;
|
|
91
|
+
|
|
92
|
+
process.stderr.write(`Auto-updating ${staleSources.length} stale source(s)...\n`);
|
|
93
|
+
|
|
94
|
+
for (const source of staleSources) {
|
|
95
|
+
try {
|
|
96
|
+
await indexSources({ sourceName: source.name });
|
|
97
|
+
process.stderr.write(` Updated: ${source.name}\n`);
|
|
98
|
+
} catch (err) {
|
|
99
|
+
process.stderr.write(` Error updating ${source.name}: ${err.message}\n`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
process.stderr.write(`Auto-update complete.\n`);
|
|
104
|
+
} catch (err) {
|
|
105
|
+
process.stderr.write(`Auto-update failed: ${err.message}\n`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
autoUpdate();
|
package/src/presets.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export const PRESETS = {
|
|
2
|
+
'wp-core': {
|
|
3
|
+
name: 'wp-core',
|
|
4
|
+
type: 'github-public',
|
|
5
|
+
repo_url: 'https://github.com/WordPress/wordpress-develop.git',
|
|
6
|
+
branch: 'trunk',
|
|
7
|
+
content_type: 'source',
|
|
8
|
+
},
|
|
9
|
+
'gutenberg-source': {
|
|
10
|
+
name: 'gutenberg-source',
|
|
11
|
+
type: 'github-public',
|
|
12
|
+
repo_url: 'https://github.com/WordPress/gutenberg.git',
|
|
13
|
+
branch: 'trunk',
|
|
14
|
+
content_type: 'source',
|
|
15
|
+
},
|
|
16
|
+
'gutenberg-docs': {
|
|
17
|
+
name: 'gutenberg-docs',
|
|
18
|
+
type: 'github-public',
|
|
19
|
+
repo_url: 'https://github.com/WordPress/gutenberg.git',
|
|
20
|
+
subfolder: 'docs',
|
|
21
|
+
branch: 'trunk',
|
|
22
|
+
content_type: 'docs',
|
|
23
|
+
},
|
|
24
|
+
'plugin-handbook': {
|
|
25
|
+
name: 'plugin-handbook',
|
|
26
|
+
type: 'github-public',
|
|
27
|
+
repo_url: 'https://github.com/WordPress/developer-plugins-handbook.git',
|
|
28
|
+
branch: 'main',
|
|
29
|
+
content_type: 'docs',
|
|
30
|
+
},
|
|
31
|
+
'rest-api-handbook': {
|
|
32
|
+
name: 'rest-api-handbook',
|
|
33
|
+
type: 'github-public',
|
|
34
|
+
repo_url: 'https://github.com/WP-API/docs.git',
|
|
35
|
+
branch: 'master',
|
|
36
|
+
content_type: 'docs',
|
|
37
|
+
},
|
|
38
|
+
'wp-cli-handbook': {
|
|
39
|
+
name: 'wp-cli-handbook',
|
|
40
|
+
type: 'github-public',
|
|
41
|
+
repo_url: 'https://github.com/wp-cli/handbook.git',
|
|
42
|
+
branch: 'main',
|
|
43
|
+
content_type: 'docs',
|
|
44
|
+
},
|
|
45
|
+
'admin-handbook': {
|
|
46
|
+
name: 'admin-handbook',
|
|
47
|
+
type: 'github-public',
|
|
48
|
+
repo_url: 'https://github.com/WordPress/Advanced-administration-handbook.git',
|
|
49
|
+
branch: 'main',
|
|
50
|
+
content_type: 'docs',
|
|
51
|
+
},
|
|
52
|
+
'woocommerce': {
|
|
53
|
+
name: 'woocommerce',
|
|
54
|
+
type: 'github-public',
|
|
55
|
+
repo_url: 'https://github.com/woocommerce/woocommerce.git',
|
|
56
|
+
subfolder: 'plugins/woocommerce',
|
|
57
|
+
branch: 'trunk',
|
|
58
|
+
content_type: 'source',
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export function getPreset(name) {
|
|
63
|
+
return PRESETS[name] || null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function listPresets() {
|
|
67
|
+
return Object.values(PRESETS);
|
|
68
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { getDoc } from '../../db/sqlite.js';
|
|
3
|
+
|
|
4
|
+
export const getDocSchema = {
|
|
5
|
+
name: 'get_doc',
|
|
6
|
+
description: 'Get the full content of a WordPress documentation page by its ID (numeric) or slug. Returns the complete markdown content along with metadata.',
|
|
7
|
+
inputSchema: {
|
|
8
|
+
doc: z.string().describe('Document ID (numeric) or slug string'),
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function handleGetDoc(args) {
|
|
13
|
+
try {
|
|
14
|
+
const doc = getDoc(args.doc);
|
|
15
|
+
|
|
16
|
+
if (!doc) {
|
|
17
|
+
return {
|
|
18
|
+
content: [{ type: 'text', text: `Document not found: "${args.doc}". Use search_docs to find documents first.` }],
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const sections = [
|
|
23
|
+
`## ${doc.title}`,
|
|
24
|
+
`**Type:** ${doc.doc_type} | **Category:** ${doc.category || 'general'} | **Source:** ${doc.source_name}`,
|
|
25
|
+
`**File:** ${doc.file_path}`,
|
|
26
|
+
`**Slug:** ${doc.slug}`,
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
if (doc.subcategory) sections.push(`**Subcategory:** ${doc.subcategory}`);
|
|
30
|
+
if (doc.status === 'removed') sections.push('**Status:** REMOVED');
|
|
31
|
+
|
|
32
|
+
if (doc.description) {
|
|
33
|
+
sections.push(`\n### Summary\n${doc.description}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
sections.push(`\n### Content\n${doc.content}`);
|
|
37
|
+
|
|
38
|
+
if (doc.code_examples) {
|
|
39
|
+
try {
|
|
40
|
+
const examples = JSON.parse(doc.code_examples);
|
|
41
|
+
if (examples.length > 0) {
|
|
42
|
+
sections.push(`\n### Code Examples (${examples.length})`);
|
|
43
|
+
for (const ex of examples) {
|
|
44
|
+
sections.push(`\`\`\`${ex.language}\n${ex.code}\n\`\`\``);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
// Invalid JSON — skip
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (doc.metadata) {
|
|
53
|
+
try {
|
|
54
|
+
const meta = JSON.parse(doc.metadata);
|
|
55
|
+
const metaParts = [];
|
|
56
|
+
if (meta.endpoints) metaParts.push(`**Endpoints:** ${meta.endpoints.map(e => `${e.method} ${e.route}`).join(', ')}`);
|
|
57
|
+
if (meta.commands) metaParts.push(`**Commands:** ${meta.commands.join(', ')}`);
|
|
58
|
+
if (meta.package_refs) metaParts.push(`**Packages:** ${meta.package_refs.join(', ')}`);
|
|
59
|
+
if (meta.function_refs) metaParts.push(`**Functions:** ${meta.function_refs.join(', ')}`);
|
|
60
|
+
if (meta.hook_refs) metaParts.push(`**Hooks:** ${meta.hook_refs.join(', ')}`);
|
|
61
|
+
if (meta.config_defines) metaParts.push(`**Config defines:** ${meta.config_defines.join(', ')}`);
|
|
62
|
+
if (metaParts.length > 0) {
|
|
63
|
+
sections.push(`\n### Metadata\n${metaParts.join('\n')}`);
|
|
64
|
+
}
|
|
65
|
+
} catch {
|
|
66
|
+
// Invalid JSON — skip
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
content: [{ type: 'text', text: sections.join('\n') }],
|
|
72
|
+
};
|
|
73
|
+
} catch (err) {
|
|
74
|
+
return {
|
|
75
|
+
content: [{ type: 'text', text: `Error getting document: ${err.message}` }],
|
|
76
|
+
isError: true,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { getHookContext } from '../../db/sqlite.js';
|
|
3
|
+
|
|
4
|
+
export const getHookContextSchema = {
|
|
5
|
+
name: 'get_hook_context',
|
|
6
|
+
description: 'Get full surrounding code context for a specific WordPress hook. Provide a hook ID (from search results) or exact hook name. Returns the code window around the hook, including the enclosing function, docblock, and parameters.',
|
|
7
|
+
inputSchema: {
|
|
8
|
+
hook: z.string().describe('Hook ID (numeric) or exact hook name'),
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* MCP tool handler — get full surrounding code context for a hook.
|
|
14
|
+
* @param {object} args - { hook } where hook is a numeric ID or exact name
|
|
15
|
+
* @returns {{ content: Array<{ type: string, text: string }>, isError?: boolean }}
|
|
16
|
+
*/
|
|
17
|
+
export function handleGetHookContext(args) {
|
|
18
|
+
try {
|
|
19
|
+
const hook = getHookContext(args.hook);
|
|
20
|
+
|
|
21
|
+
if (!hook) {
|
|
22
|
+
return {
|
|
23
|
+
content: [{ type: 'text', text: `Hook not found: "${args.hook}". Use search_hooks to find hooks first.` }],
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const sections = [
|
|
28
|
+
`## ${hook.name}`,
|
|
29
|
+
`**Type:** ${hook.type} | **Source:** ${hook.source_name}`,
|
|
30
|
+
`**File:** ${hook.file_path}:${hook.line_number}`,
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
if (hook.status === 'removed') sections.push('**Status:** REMOVED');
|
|
34
|
+
if (hook.class_name) sections.push(`**Class:** ${hook.class_name}`);
|
|
35
|
+
if (hook.php_function) sections.push(`**Function:** ${hook.php_function}()`);
|
|
36
|
+
if (hook.params) sections.push(`**Parameters:** ${hook.params}`);
|
|
37
|
+
if (hook.is_dynamic) sections.push('**Dynamic name:** yes');
|
|
38
|
+
|
|
39
|
+
if (hook.docblock) {
|
|
40
|
+
sections.push(`\n### Docblock\n\`\`\`\n${hook.docblock}\n\`\`\``);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (hook.inferred_description) {
|
|
44
|
+
sections.push(`\n### Description\n${hook.inferred_description}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Code context
|
|
48
|
+
const codeLines = [];
|
|
49
|
+
if (hook.code_before) codeLines.push(hook.code_before);
|
|
50
|
+
if (hook.hook_line) codeLines.push(`>>> ${hook.hook_line}`);
|
|
51
|
+
if (hook.code_after) codeLines.push(hook.code_after);
|
|
52
|
+
|
|
53
|
+
if (codeLines.length > 0) {
|
|
54
|
+
const lang = hook.type.startsWith('js_') ? 'js' : 'php';
|
|
55
|
+
sections.push(`\n### Code Context\n\`\`\`${lang}\n${codeLines.join('\n')}\n\`\`\``);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
content: [{ type: 'text', text: sections.join('\n') }],
|
|
60
|
+
};
|
|
61
|
+
} catch (err) {
|
|
62
|
+
return {
|
|
63
|
+
content: [{ type: 'text', text: `Error getting hook context: ${err.message}` }],
|
|
64
|
+
isError: true,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
}
|