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.
@@ -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
+ }
@@ -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
+ }