Skip to main content
Glama
server.test.ts28.9 kB
import { describe, test, expect, beforeEach, afterEach } from 'vitest'; import { writeFileSync, unlinkSync, existsSync, readFileSync, mkdirSync, rmSync, } from 'fs'; // import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { readFileContent, writeFileContent, searchFiles, groupConsecutiveLines, } from '../src/server.js'; import { performSearch, formatSearchResults } from '../src/core/search-tool.js'; import { performRefactor, formatRefactorResults } from '../src/core/refactor-tool.js'; describe('MCP Refactor Server', () => { const testFilePath = 'test-file.js'; const testDir = 'test-dir'; const testFiles = [ 'test-file.js', 'test-file.ts', 'test-dir/nested-file.js', 'test-dir/model/data.go', ]; beforeEach(() => { // Clean up any existing test files and directories testFiles.forEach(file => { if (existsSync(file)) { unlinkSync(file); } }); if (existsSync(testDir)) { rmSync(testDir, { recursive: true, force: true }); } }); afterEach(() => { // Clean up test files and directories after each test testFiles.forEach(file => { if (existsSync(file)) { unlinkSync(file); } }); if (existsSync(testDir)) { rmSync(testDir, { recursive: true, force: true }); } }); describe('Helper Functions', () => { const helperTestCases = [ { name: 'should read file correctly', test: 'readFileContent', content: 'const x = 1;\nconst y = 2;', expected: 'const x = 1;\nconst y = 2;', }, { name: 'should write file correctly', test: 'writeFileContent', content: 'const z = 3;', expected: 'const z = 3;', }, ]; helperTestCases.forEach(({ name, test, content, expected }) => { if (test === 'readFileContent') { it(name, () => { writeFileSync(testFilePath, content, 'utf-8'); const result = readFileContent(testFilePath); expect(result).toBe(expected); }); } else if (test === 'writeFileContent') { it(name, () => { writeFileContent(testFilePath, content); const result = readFileSync(testFilePath, 'utf-8'); expect(result).toBe(expected); }); } }); test('searchFiles should find files matching pattern', async () => { writeFileSync(testFilePath, 'test content', 'utf-8'); const results = await searchFiles('test-*.js'); expect(results).toContain(testFilePath); }); const groupConsecutiveLinesTestCases = [ { name: 'should handle single line', input: [5], expected: ['line: 5'], }, { name: 'should handle all consecutive', input: [1, 2, 3, 4, 5], expected: ['lines: 1-5'], }, { name: 'should handle mixed consecutive and non-consecutive', input: [1, 2, 3, 5, 7, 8, 9, 12], expected: ['lines: 1-3', 'line: 5', 'lines: 7-9', 'line: 12'], }, { name: 'should handle no consecutive numbers', input: [1, 3, 5, 7], expected: ['line: 1', 'line: 3', 'line: 5', 'line: 7'], }, { name: 'should handle two consecutive groups', input: [1, 2, 5, 6], expected: ['lines: 1-2', 'lines: 5-6'], }, { name: 'should handle empty array', input: [], expected: [], }, ]; groupConsecutiveLinesTestCases.forEach(({ name, input, expected }) => { test(name, () => { expect(groupConsecutiveLines(input)).toEqual(expected); }); }); }); describe('Simple Regex Test', () => { const regexTestCases = [ { name: 'basic regex replacement should work', content: 'const foo = 1;\nconst foo = 2;', searchPattern: 'foo', replacePattern: 'bar', expected: 'const bar = 1;\nconst bar = 2;', }, { name: 'regex with capture groups should work', content: 'foo(1,2,3);\nfoo("hello");', searchPattern: 'foo\\((.+)\\)', replacePattern: 'bar($1)', expected: 'bar(1,2,3);\nbar("hello");', }, ]; regexTestCases.forEach( ({ name, content, searchPattern, replacePattern, expected }) => { test(name, () => { const result = content.replace( new RegExp(searchPattern, 'g'), replacePattern ); expect(result).toBe(expected); }); } ); }); describe('Code Refactor Tool', () => { const refactorTestCases = [ { name: 'should perform basic regex replacement in file', content: 'let k = foo(1,2,3);\nlet m = foo("hi");', searchPattern: 'foo\\((.+)\\)', replacePattern: 'bar($1)', expected: 'let k = bar(1,2,3);\nlet m = bar("hi");', }, ]; refactorTestCases.forEach( ({ name, content, searchPattern, replacePattern, expected }) => { test(name, () => { writeFileSync(testFilePath, content, 'utf-8'); const newContent = content.replace( new RegExp(searchPattern, 'g'), replacePattern ); writeFileSync(testFilePath, newContent, 'utf-8'); const result = readFileSync(testFilePath, 'utf-8'); expect(result).toBe(expected); }); } ); test('should work with context pattern filtering', () => { const content = `import ( "legacy_sdk" ) function test() { const legacy_sdk = "local variable"; }`; writeFileSync(testFilePath, content, 'utf-8'); // Test context filtering logic const searchPattern = 'legacy_sdk'; const replacePattern = 'brand_new_sdk'; const contextPattern = 'import'; // Simulate context-aware replacement const lines = content.split('\n'); let result = content; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (line.includes(searchPattern)) { // Check if context pattern exists in surrounding lines const contextStart = Math.max(0, i - 2); const contextEnd = Math.min(lines.length, i + 3); const contextArea = lines.slice(contextStart, contextEnd).join('\n'); if (new RegExp(contextPattern).test(contextArea)) { result = result.replace( line, line.replace(searchPattern, replacePattern) ); } } } expect(result).toContain('"brand_new_sdk"'); expect(result).toContain('const legacy_sdk = "local variable"'); // Should not change }); test('should work with file pattern filtering', async () => { // Create test files mkdirSync(testDir, { recursive: true }); mkdirSync(`${testDir}/model`, { recursive: true }); writeFileSync('test-file.js', 'const legacy_sdk = "js file";', 'utf-8'); writeFileSync( `${testDir}/model/data.go`, 'const legacy_sdk = "go file";', 'utf-8' ); // Test file pattern matching const goFiles = await searchFiles('**/model/*.go'); const jsFiles = await searchFiles('*.js'); expect(goFiles.some(file => file.includes('data.go'))).toBe(true); expect(jsFiles.some(file => file.includes('test-file.js'))).toBe(true); }); }); describe('Code Search Tool', () => { test('should find patterns and return file locations', () => { const content1 = `function foo(a, b) { return a + b; } const result = foo(1, 2);`; const content2 = `class Test { foo(x) { return x * 2; } }`; writeFileSync('test-file.js', content1, 'utf-8'); mkdirSync(testDir, { recursive: true }); writeFileSync(`${testDir}/nested-file.js`, content2, 'utf-8'); // Test search pattern matching const searchPattern = 'foo\\('; const regex = new RegExp(searchPattern, 'gm'); // Search in first file const matches1 = [...content1.matchAll(regex)]; expect(matches1.length).toBe(2); // function definition + call // Calculate line numbers const beforeFirstMatch = content1.substring(0, matches1[0].index!); const firstMatchLine = beforeFirstMatch.split('\n').length; expect(firstMatchLine).toBe(1); // function foo(a, b) on line 1 }); test('should recursively search through nested directories and return proper file paths', async () => { // Create deep nested structure with search targets mkdirSync('search-test', { recursive: true }); mkdirSync('search-test/deep', { recursive: true }); mkdirSync('search-test/deep/nested', { recursive: true }); mkdirSync('search-test/side-branch', { recursive: true }); const content1 = `function targetFunction() { return "found in root"; }`; const content2 = `class MyClass { targetFunction() { return "found in deep"; } }`; const content3 = `const targetFunction = () => { return "found in nested"; };`; const content4 = `// targetFunction comment function anotherFunction() { targetFunction(); }`; writeFileSync('search-test/root.js', content1, 'utf-8'); writeFileSync('search-test/deep/deep.js', content2, 'utf-8'); writeFileSync('search-test/deep/nested/nested.js', content3, 'utf-8'); writeFileSync('search-test/side-branch/side.js', content4, 'utf-8'); // Test recursive search across all files const files = await searchFiles('**/search-test/**/*.js'); expect(files.length).toBe(4); // Test pattern search in all files const searchPattern = 'targetFunction'; const regex = new RegExp(searchPattern, 'gm'); const searchResults: string[] = []; for (const filePath of files) { const content = readFileSync(filePath, 'utf-8'); const matches = [...content.matchAll(regex)]; if (matches.length > 0) { const lineNumbers: number[] = []; for (const match of matches) { const beforeMatch = content.substring(0, match.index!); const lineNumber = beforeMatch.split('\n').length; lineNumbers.push(lineNumber); } const firstLine = lineNumbers[0]; const lastLine = lineNumbers[lineNumbers.length - 1]; if (firstLine === lastLine) { searchResults.push(`${filePath} (line: ${firstLine})`); } else { searchResults.push(`${filePath} (lines: ${firstLine}-${lastLine})`); } } } // Should find targetFunction in all 4 files expect(searchResults.length).toBe(4); expect( searchResults.some(result => result.includes('search-test/root.js')) ).toBe(true); expect( searchResults.some(result => result.includes('search-test/deep/deep.js') ) ).toBe(true); expect( searchResults.some(result => result.includes('search-test/deep/nested/nested.js') ) ).toBe(true); expect( searchResults.some(result => result.includes('search-test/side-branch/side.js') ) ).toBe(true); // Clean up rmSync('search-test', { recursive: true, force: true }); }); test('should work with context pattern in search', () => { const content = `import { foo } from 'lib'; function bar() { foo(1, 2); } class Test { foo(x) { return x; } }`; writeFileSync(testFilePath, content, 'utf-8'); const searchPattern = 'foo\\('; const contextPattern = 'function|import'; const matches = [...content.matchAll(new RegExp(searchPattern, 'gm'))]; const validMatches: RegExpExecArray[] = []; for (const match of matches) { const beforeMatch = content.substring(0, match.index!); const afterMatch = content.substring(match.index! + match[0].length); const contextBefore = beforeMatch.split('\n').slice(-3).join('\n'); const contextAfter = afterMatch.split('\n').slice(0, 3).join('\n'); const contextArea = contextBefore + match[0] + contextAfter; if (new RegExp(contextPattern).test(contextArea)) { validMatches.push(match); } } // Should find the foo() call inside function bar, but not class method expect(validMatches.length).toBe(1); }); const lineRangeTestCases = [ { name: 'should return proper line ranges for multiple matches', content: `line 1 foo(1) line 3 line 4 foo(2) line 6 foo(3) line 8`, expectedLines: [2, 5, 7], expectedFormat: 'test-file.js (line: 2, line: 5, line: 7)', }, { name: 'should group consecutive line numbers in search results', content: `line 1 foo(1) foo(2) foo(3) line 5 foo(4) line 7 foo(5) foo(6)`, expectedLines: [2, 3, 4, 6, 8, 9], expectedFormat: 'test-file.js (lines: 2-4, line: 6, lines: 8-9)', }, ]; lineRangeTestCases.forEach( ({ name, content, expectedLines, expectedFormat }) => { test(name, () => { writeFileSync(testFilePath, content, 'utf-8'); const searchPattern = 'foo\\('; const matches = [ ...content.matchAll(new RegExp(searchPattern, 'gm')), ]; const lineNumbers: number[] = []; for (const match of matches) { const beforeMatch = content.substring(0, match.index!); const lineNumber = beforeMatch.split('\n').length; lineNumbers.push(lineNumber); } expect(lineNumbers).toEqual(expectedLines); const groupedLines = groupConsecutiveLines(lineNumbers); expect(`test-file.js (${groupedLines.join(', ')})`).toBe( expectedFormat ); }); } ); }); describe('Integration Tests', () => { test('should handle files with different extensions', async () => { mkdirSync(testDir, { recursive: true }); mkdirSync(`${testDir}/model`, { recursive: true }); writeFileSync('test-file.js', 'const foo = "javascript";', 'utf-8'); writeFileSync( 'test-file.ts', 'const foo: string = "typescript";', 'utf-8' ); writeFileSync(`${testDir}/model/data.go`, 'var foo = "golang"', 'utf-8'); const allFiles = await searchFiles('**/*'); const jsFiles = await searchFiles('*.js'); const tsFiles = await searchFiles('*.ts'); const goFiles = await searchFiles('**/*.go'); expect(allFiles.length).toBeGreaterThan(2); expect(jsFiles.some(file => file.includes('.js'))).toBe(true); expect(tsFiles.some(file => file.includes('.ts'))).toBe(true); expect(goFiles.some(file => file.includes('.go'))).toBe(true); }); test('should recursively search through multiple nested directories', async () => { // Create deep nested directory structure mkdirSync('level1', { recursive: true }); mkdirSync('level1/level2', { recursive: true }); mkdirSync('level1/level2/level3', { recursive: true }); mkdirSync('level1/another-branch', { recursive: true }); // Create files at different levels with search pattern writeFileSync( 'level1/file1.js', 'function searchMe() { return "level1"; }', 'utf-8' ); writeFileSync( 'level1/level2/file2.js', 'const searchMe = "level2";', 'utf-8' ); writeFileSync( 'level1/level2/level3/file3.js', 'var searchMe = "level3";', 'utf-8' ); writeFileSync( 'level1/another-branch/file4.js', 'let searchMe = "another-branch";', 'utf-8' ); // Test recursive search const allJsFiles = await searchFiles('**/level1/**/*.js'); expect(allJsFiles.length).toBe(4); expect(allJsFiles.some(file => file.includes('level1/file1.js'))).toBe( true ); expect( allJsFiles.some(file => file.includes('level1/level2/file2.js')) ).toBe(true); expect( allJsFiles.some(file => file.includes('level1/level2/level3/file3.js')) ).toBe(true); expect( allJsFiles.some(file => file.includes('level1/another-branch/file4.js')) ).toBe(true); // Test pattern search across nested files const searchPattern = 'searchMe'; const regex = new RegExp(searchPattern, 'gm'); let matchingFiles = 0; for (const filePath of allJsFiles) { const content = readFileSync(filePath, 'utf-8'); const matches = content.match(regex); if (matches && matches.length > 0) { matchingFiles++; } } expect(matchingFiles).toBe(4); // All files should contain 'searchMe' // Clean up rmSync('level1', { recursive: true, force: true }); }); test('should respect file pattern filters in recursive search', async () => { // Create mixed file types in nested structure mkdirSync('mixed', { recursive: true }); mkdirSync('mixed/js', { recursive: true }); mkdirSync('mixed/ts', { recursive: true }); mkdirSync('mixed/other', { recursive: true }); writeFileSync('mixed/js/app.js', 'const target = "js";', 'utf-8'); writeFileSync('mixed/ts/app.ts', 'const target: string = "ts";', 'utf-8'); writeFileSync('mixed/other/app.py', 'target = "python"', 'utf-8'); writeFileSync('mixed/other/app.go', 'var target = "go"', 'utf-8'); // Test specific file pattern filters const jsFiles = await searchFiles('**/mixed/**/*.js'); const tsFiles = await searchFiles('**/mixed/**/*.ts'); const pyFiles = await searchFiles('**/mixed/**/*.py'); const goFiles = await searchFiles('**/mixed/**/*.go'); expect(jsFiles.length).toBe(1); expect(tsFiles.length).toBe(1); expect(pyFiles.length).toBe(1); expect(goFiles.length).toBe(1); expect(jsFiles[0]).toContain('app.js'); expect(tsFiles[0]).toContain('app.ts'); expect(pyFiles[0]).toContain('app.py'); expect(goFiles[0]).toContain('app.go'); // Clean up rmSync('mixed', { recursive: true, force: true }); }); const specExampleTestCases = [ { name: 'should handle complex regex patterns from spec examples', content: 'let k = foo(1,2,3);\nlet m = foo("hi");', searchPattern: 'foo\\((.+)\\)', replacePattern: 'bar($1)', expected: 'let k = bar(1,2,3);\nlet m = bar("hi");', }, ]; specExampleTestCases.forEach( ({ name, content, searchPattern, replacePattern, expected }) => { test(name, () => { const result = content.replace( new RegExp(searchPattern, 'g'), replacePattern ); expect(result).toBe(expected); }); } ); test('should handle import context pattern from spec', () => { const content = `import ( "legacy_sdk" "other_package" ) const legacy_sdk = "not in import";`; // Test context pattern from spec const searchPattern = 'legacy_sdk'; const replacePattern = 'brand_new_sdk'; // const contextPattern = 'import'; // Used in pattern matching logic below // Simple context check - if the match is within import block const importBlockMatch = content.match(/import\s*\([^)]+\)/s); if (importBlockMatch) { const importBlock = importBlockMatch[0]; if (importBlock.includes('legacy_sdk')) { const result = content.replace( importBlock, importBlock.replace(searchPattern, replacePattern) ); expect(result).toContain('"brand_new_sdk"'); expect(result).toContain('const legacy_sdk = "not in import"'); } } }); test('should support directory-only patterns', async () => { // Create test directory structure mkdirSync(`${testDir}/app`, { recursive: true }); mkdirSync(`${testDir}/app/components`, { recursive: true }); mkdirSync(`${testDir}/utils`, { recursive: true }); // Create test files in test directories writeFileSync( `${testDir}/app/index.js`, 'const main = "entry";', 'utf-8' ); writeFileSync( `${testDir}/app/utils.js`, 'const util = "helper";', 'utf-8' ); writeFileSync( `${testDir}/app/components/Button.js`, 'const Button = "component";', 'utf-8' ); writeFileSync( `${testDir}/utils/helper.js`, 'const helper = "util";', 'utf-8' ); writeFileSync(`${testDir}/root.js`, 'const root = "root";', 'utf-8'); // Test directory pattern without glob syntax const appFiles = await searchFiles(`${testDir}/app`); const utilFiles = await searchFiles(`${testDir}/utils`); const componentFiles = await searchFiles(`${testDir}/app/components`); // Should find all files in app directory and subdirectories expect(appFiles.filter(f => f.includes('/app/')).length).toBe(3); expect(appFiles).toContain(`${testDir}/app/index.js`); expect(appFiles).toContain(`${testDir}/app/utils.js`); expect(appFiles).toContain(`${testDir}/app/components/Button.js`); // Should find files in utils directory expect(utilFiles.filter(f => f.includes('/utils/')).length).toBe(1); expect(utilFiles).toContain(`${testDir}/utils/helper.js`); // Should find files in nested directory expect( componentFiles.filter(f => f.includes('/app/components/')).length ).toBe(1); expect(componentFiles).toContain(`${testDir}/app/components/Button.js`); // Should not include root files expect(appFiles).not.toContain(`${testDir}/root.js`); expect(utilFiles).not.toContain(`${testDir}/root.js`); }); }); describe('MCP Server Format Options', () => { beforeEach(() => { mkdirSync(testDir, { recursive: true }); writeFileSync( `${testDir}/test-format.js`, `function testFunction() { const variable = 'test'; return variable; } export function exportedFunction() { console.log('exported'); return 'result'; }` ); }); test('should format search results with capture groups', async () => { const results = await performSearch({ searchPattern: 'function (\\w+)\\(', filePattern: `${testDir}/**/*.js`, }); const formattedWithCapture = formatSearchResults(results, { includeCaptureGroups: true, }); expect(formattedWithCapture).toContain('Search results:'); expect(formattedWithCapture).toContain(`${testDir}/test-format.js:`); expect(formattedWithCapture).toContain('Line 1:'); expect(formattedWithCapture).toContain('Line 6:'); expect(formattedWithCapture).toContain('└─ Captured: [testFunction]'); expect(formattedWithCapture).toContain('└─ Captured: [exportedFunction]'); }); test('should format search results with matched text', async () => { const results = await performSearch({ searchPattern: 'function (\\w+)\\(', filePattern: `${testDir}/**/*.js`, }); const formattedWithMatched = formatSearchResults(results, { includeMatchedText: true, }); expect(formattedWithMatched).toContain('Search results:'); expect(formattedWithMatched).toContain('Line 1: function testFunction('); expect(formattedWithMatched).toContain('Line 6: function exportedFunction('); }); test('should format search results with both options', async () => { const results = await performSearch({ searchPattern: 'function (\\w+)\\(', filePattern: `${testDir}/**/*.js`, }); const formattedWithBoth = formatSearchResults(results, { includeCaptureGroups: true, includeMatchedText: true, }); expect(formattedWithBoth).toContain('Line 1: function testFunction('); expect(formattedWithBoth).toContain('└─ Captured: [testFunction]'); expect(formattedWithBoth).toContain('Line 6: function exportedFunction('); expect(formattedWithBoth).toContain('└─ Captured: [exportedFunction]'); }); test('should format refactor results with capture groups', async () => { const results = await performRefactor({ searchPattern: 'const (\\w+) = ', replacePattern: 'let $1 = ', filePattern: `${testDir}/**/*.js`, dryRun: true, }); const formattedWithCapture = formatRefactorResults(results, { includeCaptureGroups: true, dryRun: true, }); expect(formattedWithCapture).toContain('Refactoring completed (dry run):'); expect(formattedWithCapture).toContain(`${testDir}/test-format.js:`); expect(formattedWithCapture).toContain('Line 2:'); }); test('should maintain backward compatibility for boolean parameter', async () => { const results = await performRefactor({ searchPattern: 'const (\\w+) = ', replacePattern: 'let $1 = ', filePattern: `${testDir}/**/*.js`, dryRun: true, }); const formattedOldWay = formatRefactorResults(results, true); expect(formattedOldWay).toContain('Refactoring completed:'); expect(formattedOldWay).toContain('(dry run)'); expect(formattedOldWay).toContain('replacements in'); }); }); describe('MCP Prompt Resources', () => { beforeEach(() => { mkdirSync(testDir, { recursive: true }); writeFileSync( `${testDir}/test-prompts.js`, `function testFunction() { const variable = 'test'; return variable; } export function exportedFunction() { console.log('exported'); return 'result'; } const arrowFunction = () => { return 'arrow'; }; export class TestClass { constructor() { this.data = null; } methodFunction() { return this.data; } } interface TestInterface { id: number; name: string; }` ); }); test('should provide extract-functions prompt with function type', () => { // This is a conceptual test - in practice, MCP client would call the prompt // We test the pattern generation logic const directory = `${testDir}/**/*.js`; const patternType = 'function'; // Simulate what the prompt would generate const expectedPattern = '(?:function\\s+|export\\s+function\\s+)(\\w+)\\s*\\('; expect(expectedPattern).toContain('function'); expect(expectedPattern).toContain('export'); expect(expectedPattern).toContain('\\w+'); // Capture group for function name }); test('should provide extract-functions prompt with arrow type', () => { const directory = `${testDir}/**/*.js`; const patternType = 'arrow'; // Simulate what the prompt would generate const expectedPattern = '(?:const|let|var)\\s+(\\w+)\\s*=\\s*(?:\\([^)]*\\)\\s*=>|\\w+\\s*=>)'; expect(expectedPattern).toContain('const|let|var'); expect(expectedPattern).toContain('=>'); expect(expectedPattern).toContain('\\w+'); // Capture group for variable name }); test('should provide extract-classes prompt', () => { const directory = `${testDir}/**/*.ts`; const includeInterfaces = 'true'; // Simulate what the prompt would generate const expectedPattern = '(?:export\\s+)?(?:class|interface)\\s+(\\w+)'; expect(expectedPattern).toContain('class|interface'); expect(expectedPattern).toContain('export'); expect(expectedPattern).toContain('\\w+'); // Capture group for class/interface name }); test('should provide extract-variables prompt', () => { const directory = `${testDir}/**/*.js`; const declarationType = 'const'; // Simulate what the prompt would generate const expectedPattern = 'const\\s+(\\w+)\\s*='; expect(expectedPattern).toContain('const'); expect(expectedPattern).toContain('\\w+'); // Capture group for variable name }); test('should generate comprehensive function extraction prompt', () => { const directory = `${testDir}/**/*.js`; // Test the 'all' pattern type const expectedPattern = '(?:function\\s+|export\\s+function\\s+)(\\w+)\\s*\\(|(?:const|let|var)\\s+(\\w+)\\s*=\\s*(?:\\([^)]*\\)\\s*=>|\\w+\\s*=>)|(?:public\\s+|private\\s+|protected\\s+|static\\s+)?(\\w+)\\s*\\([^)]*\\)\\s*\\{'; // Verify it includes all function types expect(expectedPattern).toContain('function'); expect(expectedPattern).toContain('const|let|var'); expect(expectedPattern).toContain('=>'); expect(expectedPattern).toContain('public\\s+|private\\s+|protected\\s+|static\\s+'); }); }); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/myuon/refactor-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server