apple-developer-docs-mcp
Version:
An MCP server that fetches the right data from Apple's developer documentation site
365 lines (358 loc) • 16 kB
JavaScript
import fetch from 'node-fetch';
import AdmZip from 'adm-zip';
import { promises as fs } from 'fs';
import path from 'path';
import { homedir } from 'os';
/**
* Extract the sample code download URL from an Apple Documentation page
*
* @param jsonUrl URL of the Apple Developer Documentation page
* @returns The sample code download URL in the format: https://docs-assets.developer.apple.com/published/[identifier]/[filename].zip
*/
export async function getSampleCodeDownloadUrl(jsonUrl) {
try {
console.error(`Fetching download URL from documentation page: ${jsonUrl}`);
let jsonData;
// Handle file:// URLs for testing with local files
if (jsonUrl.startsWith('file://')) {
const filePath = new URL(jsonUrl).pathname;
const fileContent = await fs.readFile(filePath, 'utf-8');
try {
jsonData = JSON.parse(fileContent);
}
catch (error) {
throw new Error(`Failed to parse JSON from file. The file may not be valid JSON: ${error instanceof Error ? error.message : String(error)}`);
}
}
else {
// Validate that this is an Apple Developer URL for web URLs
if (!jsonUrl.includes('developer.apple.com')) {
throw new Error(`URL must be from developer.apple.com (e.g., https://developer.apple.com/documentation/...)`);
}
// If the URL is not a JSON URL, convert it to the JSON API URL
const jsonApiUrl = jsonUrl.endsWith('.json') ? jsonUrl :
jsonUrl.replace('/documentation/', '/tutorials/data/documentation/') + '.json';
console.error(`Fetching documentation JSON from: ${jsonApiUrl}`);
// Fetch the documentation JSON
const response = await fetch(jsonApiUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
'Accept': 'application/json',
},
});
if (!response.ok) {
throw new Error(`Failed to fetch JSON content: HTTP ${response.status} - ${response.statusText}`);
}
try {
// Parse the JSON response
jsonData = await response.json();
}
catch (error) {
throw new Error(`Failed to parse JSON response: ${error instanceof Error ? error.message : String(error)}`);
}
}
// Extract the sample code download identifier
if (!jsonData.sampleCodeDownload?.action?.identifier) {
throw new Error(`No sample code download URL found in the documentation.
This documentation page might not have a downloadable sample code.
Try searching for a different example that includes sample code.`);
}
const downloadIdentifier = jsonData.sampleCodeDownload.action.identifier;
// The identifier is already in the format "f14a9bc447c5/DisplayingOverlaysOnAMap.zip"
// Construct the download URL
const downloadUrl = `https://docs-assets.developer.apple.com/published/${downloadIdentifier}`;
console.error(`Found sample code download URL: ${downloadUrl}`);
return downloadUrl;
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to extract sample code download URL: ${errorMessage}`);
}
}
/**
* Downloads, unzips, and analyzes an Apple Developer code sample from a ZIP file
* Extracts the sample to the user's home directory
*
* @param zipUrl URL of the Apple Developer code sample ZIP file or documentation page URL
* @returns Formatted information about the code sample or error response
*/
export async function downloadAndAnalyzeCodeSample(url) {
try {
let downloadUrl = url;
// Check if this is a documentation URL or a direct ZIP URL
if (url.includes('developer.apple.com') && !url.includes('docs-assets.developer.apple.com')) {
// This is a documentation URL, extract the download URL from it
try {
console.error(`Attempting to extract download URL from documentation: ${url}`);
downloadUrl = await getSampleCodeDownloadUrl(url);
console.error(`Successfully extracted download URL: ${downloadUrl}`);
}
catch (error) {
throw new Error(`Failed to extract download URL from documentation: ${error instanceof Error ? error.message : String(error)}
To use this tool with a ZIP URL from previous tool results:
1. In the JSON output from get_apple_doc_content, look for the sampleCodeDownload section:
"sampleCodeDownload": {
"action": {
"identifier": "f14a9bc447c5/DisplayingOverlaysOnAMap.zip"
}
}
2. Extract the "identifier" value (e.g., "f14a9bc447c5/DisplayingOverlaysOnAMap.zip")
3. Create the complete ZIP URL by prepending "https://docs-assets.developer.apple.com/published/":
https://docs-assets.developer.apple.com/published/f14a9bc447c5/DisplayingOverlaysOnAMap.zip
4. Use this complete URL as the zipUrl parameter
mcp_apple-develop_download_apple_code_sample zipUrl="https://docs-assets.developer.apple.com/published/f14a9bc447c5/DisplayingOverlaysOnAMap.zip"
Alternatively, you can just use a documentation URL directly:
mcp_apple-develop_download_apple_code_sample zipUrl="https://developer.apple.com/documentation/mapkit/displaying-overlays-on-a-map"`);
}
}
// Validate that this is an Apple docs-assets URL
if (!downloadUrl.includes('docs-assets.developer.apple.com')) {
throw new Error(`Invalid URL format: ${downloadUrl}
The zipUrl parameter must be either:
1. A documentation page URL from developer.apple.com, or
2. A direct ZIP download URL from docs-assets.developer.apple.com
How to create a correct direct ZIP URL from get_apple_doc_content results:
1. Find the "sampleCodeDownload.action.identifier" value in the JSON output
2. Prepend "https://docs-assets.developer.apple.com/published/" to that identifier
3. Example:
If identifier is "f14a9bc447c5/DisplayingOverlaysOnAMap.zip"
Then the correct URL is "https://docs-assets.developer.apple.com/published/f14a9bc447c5/DisplayingOverlaysOnAMap.zip"`);
}
console.error(`Downloading code sample from: ${downloadUrl}`);
// Create a samples directory in the user's home directory
const samplesDir = path.join(homedir(), 'AppleSampleCode');
try {
await fs.mkdir(samplesDir, { recursive: true });
}
catch (error) {
console.error(`Error creating samples directory: ${error}`);
}
// Extract the sample name from the ZIP URL
const urlParts = downloadUrl.split('/');
const filenameWithExt = urlParts[urlParts.length - 1];
const sampleName = filenameWithExt.replace('.zip', '');
const extractionDir = path.join(samplesDir, sampleName);
// Check if the sample already exists
try {
const stat = await fs.stat(extractionDir);
if (stat.isDirectory()) {
console.error(`Sample already exists at: ${extractionDir}`);
}
}
catch (error) {
// Directory doesn't exist, which is what we want
}
// Download the ZIP file
const response = await fetch(downloadUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
},
});
if (!response.ok) {
throw new Error(`Failed to download ZIP file: ${response.status}`);
}
const zipBuffer = await response.buffer();
// Extract the ZIP file to the user's home directory
const zip = new AdmZip(zipBuffer);
zip.extractAllTo(extractionDir, true);
console.error(`Extracted sample to: ${extractionDir}`);
// Analyze the extracted contents
const files = await getAllFiles(extractionDir);
// Count files by extension
const fileExtCounts = countFileExtensions(files);
// Get the README content if available
const readmeContent = await getReadmeContent(extractionDir);
// Get some representative code samples
const codeSamples = await getRepresentativeCodeSamples(extractionDir, files);
// Build the content
let markdownContent = `# Code Sample: ${sampleName}\n\n`;
markdownContent += `**Source:** [${downloadUrl}](${downloadUrl})\n\n`;
markdownContent += `**Original URL:** ${url !== downloadUrl ? url : 'Same as download URL'}\n\n`;
markdownContent += `**Extracted to:** ${extractionDir}\n\n`;
if (readmeContent) {
markdownContent += `## README\n\n${readmeContent}\n\n`;
}
markdownContent += `## Contents\n\n`;
markdownContent += `The sample contains ${files.length} files:\n\n`;
// Add file extension breakdown
markdownContent += `### File Types\n\n`;
for (const [ext, count] of Object.entries(fileExtCounts)) {
markdownContent += `- ${ext}: ${count} files\n`;
}
markdownContent += '\n';
// Add representative code samples
if (codeSamples.length > 0) {
markdownContent += `## Representative Code Samples\n\n`;
codeSamples.forEach(sample => {
const relativePath = path.relative(extractionDir, sample.filePath);
markdownContent += `### ${relativePath}\n\n`;
const language = getLanguageFromFilename(sample.filePath);
markdownContent += `\`\`\`${language}\n${sample.content}\n\`\`\`\n\n`;
});
}
// Find and suggest opening interesting files
const interestingFiles = findInterestingFiles(files);
if (interestingFiles.length > 0) {
markdownContent += `## Key Files to Explore\n\n`;
interestingFiles.forEach(file => {
const relativePath = path.relative(extractionDir, file);
markdownContent += `- \`${relativePath}\`\n`;
});
markdownContent += '\n';
}
// Add instructions for opening the project
markdownContent += `## Opening the Project\n\n`;
markdownContent += `You can open this project in Xcode by:\n\n`;
markdownContent += `1. Looking for a .xcodeproj or .xcworkspace file in the extracted directory\n`;
markdownContent += `2. Double-clicking the project file or opening it from Xcode's "Open..." menu\n\n`;
markdownContent += `The sample code has been downloaded and extracted to: \`${extractionDir}\`\n`;
return {
content: [
{
type: "text",
text: markdownContent,
},
],
};
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
content: [
{
type: "text",
text: `Error: Failed to download and analyze code sample: ${errorMessage}`,
}
],
isError: true
};
}
}
/**
* Recursively get all files in a directory
*/
async function getAllFiles(dirPath) {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
const files = await Promise.all(entries.map(async (entry) => {
const fullPath = path.join(dirPath, entry.name);
return entry.isDirectory() ? getAllFiles(fullPath) : [fullPath];
}));
return files.flat();
}
/**
* Count file extensions in the sample
*/
function countFileExtensions(files) {
const counts = {};
files.forEach(file => {
const ext = path.extname(file) || '(no extension)';
counts[ext] = (counts[ext] || 0) + 1;
});
return counts;
}
/**
* Get the content of README file if available
*/
async function getReadmeContent(dirPath) {
try {
const entries = await fs.readdir(dirPath);
// Look for README files with various extensions
const readmeRegex = /^readme(\.(md|txt))?$/i;
const readmeFile = entries.find(entry => readmeRegex.test(entry));
if (readmeFile) {
const content = await fs.readFile(path.join(dirPath, readmeFile), 'utf-8');
return content.substring(0, 2000); // Limit size to prevent too much content
}
return null;
}
catch (error) {
console.error('Error reading README:', error);
return null;
}
}
/**
* Get some representative code samples
*/
async function getRepresentativeCodeSamples(dirPath, files) {
const samples = [];
// Get code files with interesting extensions
const codeExtensions = ['.swift', '.m', '.h', '.c', '.cpp', '.java', '.kt', '.js', '.py'];
const codeFiles = files.filter(file => codeExtensions.includes(path.extname(file)));
// Get at most 3 representative files
const representativeFiles = codeFiles.slice(0, 3);
for (const file of representativeFiles) {
try {
const content = await fs.readFile(file, 'utf-8');
// Limit content to a reasonable size
const limitedContent = content.split('\n').slice(0, 50).join('\n');
samples.push({
filePath: file,
content: limitedContent
});
}
catch (error) {
console.error(`Error reading code sample ${file}:`, error);
}
}
return samples;
}
/**
* Find interesting files that might be good starting points
*/
function findInterestingFiles(files) {
const interestingFiles = [];
// Look for key files like readmes, main files, etc.
const patterns = [
/readme\.(md|txt)/i, // README files
/^main\.(swift|m|java|kt|js)$/i, // Main files
/\.xcodeproj$/, // Xcode project
/\.xcworkspace$/, // Xcode workspace
/AppDelegate\.(swift|m)$/, // iOS app delegate
/SceneDelegate\.(swift|m)$/, // iOS scene delegate
/ViewController\.(swift|m)$/, // View controllers
/ContentView\.swift$/, // SwiftUI content view
/build\.gradle$/, // Android build file
/index\.(html|js)$/, // Web main files
/package\.json$/ // Node.js package file
];
// Find files matching our patterns
files.forEach(file => {
const filename = path.basename(file);
if (patterns.some(pattern => pattern.test(filename))) {
interestingFiles.push(file);
}
});
// Also add some main.* files directly from the root
const rootMainFiles = files.filter(file => {
const dirname = path.dirname(file);
const filename = path.basename(file);
return filename.startsWith('Main') && path.basename(dirname) !== 'Resources';
});
return [...new Set([...interestingFiles, ...rootMainFiles])];
}
/**
* Get language identifier for code blocks from filename
*/
function getLanguageFromFilename(filename) {
const ext = path.extname(filename).toLowerCase();
switch (ext) {
case '.swift': return 'swift';
case '.m':
case '.h': return 'objective-c';
case '.c': return 'c';
case '.cpp':
case '.cc':
case '.cxx': return 'cpp';
case '.java': return 'java';
case '.kt': return 'kotlin';
case '.js': return 'javascript';
case '.py': return 'python';
case '.rb': return 'ruby';
case '.sh': return 'bash';
case '.json': return 'json';
case '.xml': return 'xml';
case '.md': return 'markdown';
default: return '';
}
}
//# sourceMappingURL=download-helper.js.map