From 315cdb0a5fa58f541f265c97680548dd6659cb3d Mon Sep 17 00:00:00 2001 From: Alexander Goodkind Date: Mon, 29 Jun 2026 17:38:00 -0700 Subject: [PATCH 01/19] Add Apple Design docs and resource previews --- README.md | 24 +- pnpm-workspace.yaml | 5 +- src/index.ts | 103 +- src/schemas/design.schema.ts | 39 + src/schemas/index.ts | 9 +- src/tools/definitions.ts | 149 ++- src/tools/design-docs.ts | 1856 +++++++++++++++++++++++++++++ src/tools/handlers.ts | 73 +- src/utils/cache.ts | 9 +- src/utils/constants.ts | 9 +- src/utils/url-converter.ts | 49 +- tests/index.test.ts | 122 +- tests/response-format.test.ts | 122 +- tests/tools/design-docs.test.ts | 470 ++++++++ tests/tools/handlers.test.ts | 117 +- tests/utils/url-converter.test.ts | 69 +- 16 files changed, 3190 insertions(+), 35 deletions(-) create mode 100644 src/schemas/design.schema.ts create mode 100644 src/tools/design-docs.ts create mode 100644 tests/tools/design-docs.test.ts diff --git a/README.md b/README.md index 2aa946b..7d768b6 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,9 @@ Apple Developer Documentation MCP Server - Access Apple's official developer doc - 🔍 **Smart Search**: Intelligent search across Apple Developer Documentation for SwiftUI, UIKit, Foundation, CoreData, ARKit, and more - 📚 **Complete Documentation Access**: Full access to Apple's JSON API for Swift, Objective-C, and framework documentation +- 🎨 **Apple Design and HIG Access**: Read Human Interface Guidelines JSON, Apple Design pages, and Design Resources catalog entries +- 🖼️ **Design Resource Previews**: Return Apple-provided HIG images and resource thumbnails as MCP image content blocks +- 📦 **Downloadable Design Resources**: Download direct Apple-hosted templates, fonts, tools, and archives into a local MCP resource cache - 🔧 **Framework Index**: Browse hierarchical API structures for iOS, macOS, watchOS, tvOS, visionOS frameworks - 📋 **Technology Catalog**: Explore Apple technologies including SwiftUI, UIKit, Metal, Core ML, Vision, and ARKit - 📰 **Documentation Updates**: Track WWDC 2024/2025 announcements, iOS 26, macOS 26, and latest SDK releases @@ -269,6 +272,15 @@ npm install && npm run build "Get URLSession async/await methods" ``` +### 🎨 Apple Design and HIG +``` +"Search Apple Design docs for layout" +"Read the HIG page about color" +"List Apple Design Resources for iOS templates" +"Download the Apple Design resource with this resourceId" +"Show Apple Design examples for the layout HIG page" +``` + ### 🔧 Framework Exploration ``` "Show me SwiftUI framework API index" @@ -366,6 +378,11 @@ npm install && npm run build |------|-------------|--------------| | `search_apple_docs` | Search Apple Developer Documentation | Official search API, find specific APIs, classes, methods | | `get_apple_doc_content` | Get detailed documentation content | JSON API access, optional enhanced analysis (related/similar APIs, platform compatibility) | +| `search_apple_design_docs` | Search Apple Design and HIG content | HIG JSON references, Design pages, Design Resources catalog | +| `get_apple_design_content` | Read Apple Design and HIG pages | HIG JSON rendering, HTML fallback for `/design/` pages | +| `list_apple_design_resources` | List Apple Design Resources | Stable resource IDs, category/platform/format filters, previews and links | +| `download_apple_design_resource` | Download direct Apple Design resources | Local cache, MCP `resource_link` blocks, `resources/read` blob access | +| `get_apple_design_examples` | Return Apple Design visual examples | MCP `image` blocks with base64 data and MIME type | | `list_technologies` | Browse all Apple technologies | Category filtering, language support, beta status | | `search_framework_symbols` | Search symbols in specific framework | Classes, structs, protocols, wildcard patterns, type filtering | | `get_related_apis` | Find related APIs | Inheritance, conformance, "See Also" relationships | @@ -389,6 +406,7 @@ apple-docs-mcp/ │ ├── tools/ # MCP tool implementations │ │ ├── search-parser.ts # HTML search result parsing │ │ ├── doc-fetcher.ts # JSON API documentation fetching +│ │ ├── design-docs.ts # Apple Design, HIG, resources, and previews │ │ ├── list-technologies.ts # Technology catalog handling │ │ ├── get-documentation-updates.ts # Documentation updates tracking │ │ ├── get-technology-overviews.ts # Technology overviews and guides @@ -436,6 +454,10 @@ apple-docs-mcp/ | Framework Indexes | 1 hour | 100 entries | Stable structure, less frequent changes | | Technologies List | 2 hours | 50 entries | Rarely changes, large content | | Documentation Updates | 30 minutes | 100 entries | Regular updates, WWDC announcements | +| Apple Design Content | 2 hours | 100 entries | HIG and Design pages are stable during a session | +| Apple Design Resources | 2 hours | 20 entries | Catalog metadata changes less often than page reads | + +Downloaded Apple Design files are cached outside the repository by default. Set `APPLE_DOCS_MCP_CACHE_DIR` to choose a cache directory for downloaded resource files exposed through MCP `resources/list` and `resources/read`. ## 📦 WWDC Data @@ -555,4 +577,4 @@ Search Apple Developer Documentation | iOS Development | macOS Development | Swi [Report Issues](https://github.com/kimsungwhee/apple-docs-mcp/issues) • [Request Features](https://github.com/kimsungwhee/apple-docs-mcp/issues/new) • [Documentation](https://github.com/kimsungwhee/apple-docs-mcp) - \ No newline at end of file + diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e4aab11..e02dbda 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,5 @@ packages: - - '.' \ No newline at end of file + - '.' +allowBuilds: + esbuild: false + unrs-resolver: false diff --git a/src/index.ts b/src/index.ts index dc4577f..d157b0e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,13 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { + CallToolRequestSchema, + ListResourcesRequestSchema, + ListToolsRequestSchema, + ReadResourceRequestSchema, + type CallToolResult, +} from '@modelcontextprotocol/sdk/types.js'; import { parseSearchResults } from './tools/search-parser.js'; import { fetchAppleDocJson } from './tools/doc-fetcher.js'; import { handleListTechnologies } from './tools/list-technologies.js'; @@ -16,8 +22,17 @@ import { handleFindSimilarApis } from './tools/find-similar-apis.js'; import { handleGetDocumentationUpdates } from './tools/get-documentation-updates.js'; import { handleGetTechnologyOverviews } from './tools/get-technology-overviews.js'; import { handleGetSampleCode } from './tools/get-sample-code.js'; +import { + handleDownloadAppleDesignResource, + handleGetAppleDesignContent, + handleGetAppleDesignExamples, + handleListAppleDesignResources, + handleSearchAppleDesignDocs, + listCachedDesignResources, + readCachedDesignResource, +} from './tools/design-docs.js'; import { APPLE_URLS } from './utils/constants.js'; -import { isValidAppleDeveloperUrl } from './utils/url-converter.js'; +import { isAppleDesignUrl, isValidAppleDeveloperUrl } from './utils/url-converter.js'; import { validateInput, ErrorType, createStandardErrorResponse, createToolErrorResponse } from './utils/error-handler.js'; import { httpClient } from './utils/http-client.js'; import { preloadPopularFrameworks } from './utils/preloader.js'; @@ -34,7 +49,7 @@ export default class AppleDeveloperDocsMCPServer { private async handleAsyncOperation( operation: () => Promise, operationName: string, - ): Promise<{ content: Array<{ type: 'text'; text: string }>; isError?: boolean }> { + ): Promise { try { const result = await operation(); return { @@ -48,9 +63,9 @@ export default class AppleDeveloperDocsMCPServer { } catch (error) { // If error is already an AppError, use tool-specific suggestions if (error && typeof error === 'object' && 'type' in error) { - return createToolErrorResponse(error as any, operationName); + return createToolErrorResponse(error as any, operationName) as CallToolResult; } - return createStandardErrorResponse(error, operationName); + return createStandardErrorResponse(error, operationName) as CallToolResult; } } @@ -63,11 +78,13 @@ export default class AppleDeveloperDocsMCPServer { { capabilities: { tools: {}, + resources: {}, }, }, ); this.setupTools(); + this.setupResources(); this.setupErrorHandling(); } @@ -107,6 +124,16 @@ export default class AppleDeveloperDocsMCPServer { }); } + private setupResources() { + this.server.setRequestHandler(ListResourcesRequestSchema, async () => { + return await listCachedDesignResources(); + }); + + this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => { + return await readCachedDesignResource(request.params.uri); + }); + } + public async searchAppleDocs(query: string, type: string = 'all') { try { // 输入验证 @@ -155,6 +182,10 @@ export default class AppleDeveloperDocsMCPServer { }, 'get_apple_doc_content'); } + if (isAppleDesignUrl(url)) { + return await this.getAppleDesignContent(url); + } + // fetchAppleDocJson 已经返回正确的MCP响应格式,直接返回 return await fetchAppleDocJson(url, { includeRelatedApis, @@ -182,6 +213,66 @@ export default class AppleDeveloperDocsMCPServer { ); } + public async searchAppleDesignDocs( + query: string, + contentType: 'all' | 'hig' | 'resource' | 'page' = 'all', + platform: string = 'all', + limit: number = 20, + ) { + return await handleSearchAppleDesignDocs({ + query, + contentType, + platform, + limit, + }); + } + + public async getAppleDesignContent(url: string) { + return await handleGetAppleDesignContent({ url }); + } + + public async listAppleDesignResources( + category?: string, + platform?: string, + format?: string, + searchQuery?: string, + limit: number = 50, + ) { + return await handleListAppleDesignResources({ + category, + platform, + format, + searchQuery, + limit, + }); + } + + public async downloadAppleDesignResource( + resourceId?: string, + url?: string, + maxBytes?: number, + ) { + return await handleDownloadAppleDesignResource({ + resourceId, + url, + maxBytes, + }); + } + + public async getAppleDesignExamples( + url?: string, + resourceId?: string, + query?: string, + limit: number = 3, + ) { + return await handleGetAppleDesignExamples({ + url, + resourceId, + query, + limit, + }); + } + public async searchFrameworkSymbols(framework: string, symbolType: string = 'all', namePattern?: string, language: string = 'swift', limit: number = API_LIMITS.DEFAULT_FRAMEWORK_SYMBOLS_LIMIT) { return this.handleAsyncOperation( () => searchFrameworkSymbols(framework, symbolType, namePattern, language, limit), @@ -316,4 +407,4 @@ if (process.env.NODE_ENV !== 'test') { logger.error('Fatal error in main():', error); process.exit(1); }); -} \ No newline at end of file +} diff --git a/src/schemas/design.schema.ts b/src/schemas/design.schema.ts new file mode 100644 index 0000000..391b597 --- /dev/null +++ b/src/schemas/design.schema.ts @@ -0,0 +1,39 @@ +import { z } from 'zod'; + +export const searchAppleDesignDocsSchema = z.object({ + query: z.string().describe('Search query for Apple Design and HIG documentation'), + contentType: z.enum(['all', 'hig', 'resource', 'page']).default('all') + .describe('Type of Apple Design content to search'), + platform: z.string().default('all') + .describe('Platform filter such as iOS, iPadOS, macOS, watchOS, tvOS, or visionOS'), + limit: z.number().int().min(1).max(50).default(20) + .describe('Maximum number of results to return'), +}); + +export const getAppleDesignContentSchema = z.object({ + url: z.string().url().describe('Apple Design URL to read'), +}); + +export const listAppleDesignResourcesSchema = z.object({ + category: z.string().optional().describe('Resource category filter'), + platform: z.string().optional().describe('Platform or subsection filter'), + format: z.string().optional().describe('Resource format filter such as figma, sketch, dmg, or zip'), + searchQuery: z.string().optional().describe('Search query for resource titles, notes, and labels'), + limit: z.number().int().min(1).max(100).default(50) + .describe('Maximum number of resources to return'), +}); + +export const downloadAppleDesignResourceSchema = z.object({ + resourceId: z.string().optional().describe('Resource ID returned by list_apple_design_resources'), + url: z.string().url().optional().describe('Direct Apple-hosted resource URL to download'), + maxBytes: z.number().int().min(1).max(250 * 1024 * 1024).optional() + .describe('Maximum download size in bytes'), +}); + +export const getAppleDesignExamplesSchema = z.object({ + url: z.string().url().optional().describe('Apple Design, HIG, preview, or image URL'), + resourceId: z.string().optional().describe('Resource ID returned by list_apple_design_resources'), + query: z.string().optional().describe('Search query for examples and thumbnails'), + limit: z.number().int().min(1).max(10).default(3) + .describe('Maximum number of image examples to return'), +}); diff --git a/src/schemas/index.ts b/src/schemas/index.ts index caba501..9b2f2b4 100644 --- a/src/schemas/index.ts +++ b/src/schemas/index.ts @@ -8,4 +8,11 @@ export { getPlatformCompatibilitySchema } from './platform-compatibility.schema. export { findSimilarApisSchema } from './similar-apis.schema.js'; export { getDocumentationUpdatesSchema } from './documentation-updates.schema.js'; export { getTechnologyOverviewsSchema } from './technology-overviews.schema.js'; -export { getSampleCodeSchema } from './sample-code.schema.js'; \ No newline at end of file +export { getSampleCodeSchema } from './sample-code.schema.js'; +export { + searchAppleDesignDocsSchema, + getAppleDesignContentSchema, + listAppleDesignResourcesSchema, + downloadAppleDesignResourceSchema, + getAppleDesignExamplesSchema, +} from './design.schema.js'; diff --git a/src/tools/definitions.ts b/src/tools/definitions.ts index f47b834..d5dbbfa 100644 --- a/src/tools/definitions.ts +++ b/src/tools/definitions.ts @@ -66,6 +66,153 @@ export const toolDefinitions: Tool[] = [ readOnlyHint: true, }, }, + { + name: 'search_apple_design_docs', + description: 'Search Apple Design pages, Human Interface Guidelines JSON, and Design Resources catalog entries. Use this for HIG guidance, design resources, templates, fonts, product bezels, and Apple Design overview pages.', + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Search query for Apple Design or HIG content. Examples: "layout", "iOS templates", "SF Pro", "visionOS".', + }, + contentType: { + type: 'string', + enum: ['all', 'hig', 'resource', 'page'], + description: 'Content filter. Use "hig" for Human Interface Guidelines, "resource" for Design Resources, "page" for Apple Design HTML pages. Default: "all".', + }, + platform: { + type: 'string', + description: 'Platform filter such as iOS, iPadOS, macOS, watchOS, tvOS, or visionOS. Default: "all".', + }, + limit: { + type: 'number', + description: 'Maximum number of results. Default: 20.', + minimum: 1, + maximum: 50, + }, + }, + required: ['query'], + }, + annotations: { + title: 'Search Apple Design Docs', + readOnlyHint: true, + }, + }, + { + name: 'get_apple_design_content', + description: 'Read Apple Design and Human Interface Guidelines URLs. HIG pages use Apple JSON data when available, and other /design/ pages use HTML parsing.', + inputSchema: { + type: 'object', + properties: { + url: { + type: 'string', + description: 'Apple Design URL. Examples: "https://developer.apple.com/design/human-interface-guidelines/layout" or "https://developer.apple.com/design/resources/".', + }, + }, + required: ['url'], + }, + annotations: { + title: 'Get Apple Design Content', + readOnlyHint: true, + }, + }, + { + name: 'list_apple_design_resources', + description: 'List Apple Design Resources catalog entries with stable resource IDs, categories, platforms, formats, notes, preview image URLs, and download URLs.', + inputSchema: { + type: 'object', + properties: { + category: { + type: 'string', + description: 'Optional category filter such as "Design templates", "Fonts", "Tools", or "Product bezels".', + }, + platform: { + type: 'string', + description: 'Optional platform or subsection filter such as "iOS", "macOS", or "visionOS".', + }, + format: { + type: 'string', + description: 'Optional format filter such as "figma", "sketch", "dmg", "zip", or "pdf".', + }, + searchQuery: { + type: 'string', + description: 'Optional search query for titles, labels, notes, and categories.', + }, + limit: { + type: 'number', + description: 'Maximum number of resources. Default: 50.', + minimum: 1, + maximum: 100, + }, + }, + required: [], + }, + annotations: { + title: 'List Apple Design Resources', + readOnlyHint: true, + }, + }, + { + name: 'download_apple_design_resource', + description: 'Download a selected direct Apple Design resource into the local MCP cache and return MCP resource links. External Figma, Sketch web, and sketch:// links are reported as metadata and are not browser-authenticated downloads.', + inputSchema: { + type: 'object', + properties: { + resourceId: { + type: 'string', + description: 'Stable resource ID returned by list_apple_design_resources.', + }, + url: { + type: 'string', + description: 'Direct Apple-hosted resource URL to download. Allowed hosts include developer.apple.com, docs-assets.developer.apple.com, devimages-cdn.apple.com, and itunespartner.apple.com.', + }, + maxBytes: { + type: 'number', + description: 'Optional maximum download size in bytes. Default: 52428800.', + minimum: 1, + maximum: 262144000, + }, + }, + required: [], + }, + annotations: { + title: 'Download Apple Design Resource', + readOnlyHint: false, + }, + }, + { + name: 'get_apple_design_examples', + description: 'Return Apple Design visual examples as MCP image content blocks with base64 data and MIME type. Supports HIG images, resource thumbnails, and direct image asset URLs.', + inputSchema: { + type: 'object', + properties: { + url: { + type: 'string', + description: 'Apple Design URL, HIG URL, resource preview URL, or direct image URL.', + }, + resourceId: { + type: 'string', + description: 'Stable resource ID returned by list_apple_design_resources.', + }, + query: { + type: 'string', + description: 'Search query for resource thumbnails.', + }, + limit: { + type: 'number', + description: 'Maximum number of image blocks. Default: 3.', + minimum: 1, + maximum: 10, + }, + }, + required: [], + }, + annotations: { + title: 'Get Apple Design Examples', + readOnlyHint: true, + }, + }, { name: 'list_technologies', description: 'Browse all Apple technologies and frameworks by category. Essential for discovering available frameworks and understanding Apple\'s technology ecosystem. Use this when: exploring what\'s available, finding framework identifiers for search_framework_symbols, checking beta status.', @@ -530,4 +677,4 @@ export const toolDefinitions: Tool[] = [ readOnlyHint: true, }, }, -]; \ No newline at end of file +]; diff --git a/src/tools/design-docs.ts b/src/tools/design-docs.ts new file mode 100644 index 0000000..a7af054 --- /dev/null +++ b/src/tools/design-docs.ts @@ -0,0 +1,1856 @@ +import type { + CallToolResult, + ContentBlock, + ImageContent, + ListResourcesResult, + ReadResourceResult, + Resource, + ResourceLink, +} from '@modelcontextprotocol/sdk/types.js'; +import * as cheerio from 'cheerio'; +import { createHash } from 'node:crypto'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { designContentCache, designResourcesCache } from '../utils/cache.js'; +import { APPLE_URLS, REQUEST_CONFIG } from '../utils/constants.js'; +import { httpClient } from '../utils/http-client.js'; +import { convertToDesignJsonApiUrl } from '../utils/url-converter.js'; + +const APPLE_DESIGN_DOWNLOAD_HOSTS = new Set([ + 'developer.apple.com', + 'docs-assets.developer.apple.com', + 'devimages-cdn.apple.com', + 'itunespartner.apple.com', +]); +const DEFAULT_DOWNLOAD_MAX_BYTES = 50 * 1024 * 1024; +const DIRECT_RESOURCE_EXTENSIONS = new Set([ + '.dmg', + '.fig', + '.gif', + '.jpeg', + '.jpg', + '.pdf', + '.png', + '.sketch', + '.svg', + '.webp', + '.zip', +]); +const IMAGE_MIME_PREFIX = 'image/'; +const RESOURCE_URI_PREFIX = 'apple-design://cache/'; + +export interface DesignResourceCatalogEntry { + resourceId: string; + category: string; + platform?: string; + title: string; + downloadLabel: string; + downloadUrl: string; + format: string; + note?: string; + previewImageUrl?: string; + relatedGuidelineLinks: string[]; +} + +interface CachedDesignResource { + uri: string; + name: string; + title: string; + description: string; + mimeType: string; + filePath: string; + sourceUrl: string; + size: number; +} + +interface DesignSearchResult { + title: string; + type: 'hig' | 'resource' | 'page'; + url: string; + description?: string; + resourceId?: string; + platform?: string; + format?: string; + previewImageUrl?: string; +} + +interface DesignImageCandidate { + url: string; + alt?: string; +} + +export interface SearchAppleDesignDocsArgs { + query: string; + contentType?: 'all' | 'hig' | 'resource' | 'page'; + platform?: string; + limit?: number; +} + +export interface ListAppleDesignResourcesArgs { + category?: string; + platform?: string; + format?: string; + searchQuery?: string; + limit?: number; +} + +export interface DownloadAppleDesignResourceArgs { + resourceId?: string; + url?: string; + maxBytes?: number; +} + +export interface GetAppleDesignExamplesArgs { + url?: string; + resourceId?: string; + query?: string; + limit?: number; +} + +const cachedResourcesByUri = new Map(); +const cachedResourcesBySourceUrl = new Map(); +const resourceCatalogById = new Map(); + +/** + * Format Apple Design HIG JSON as readable Markdown. + * @param jsonData Apple Design JSON payload. + * @param originalUrl Source Apple Design URL. + * @returns Markdown content. + */ +export function formatAppleDesignDocument(jsonData: unknown, originalUrl: string): string { + const documentRecord = asRecord(jsonData); + if (!documentRecord) { + return `# Apple Design\n\nUnable to parse Apple Design content.\n\n---\n\n[View on Apple Developer](${originalUrl})`; + } + + const references = getRecordMap(documentRecord.references); + const metadata = asRecord(documentRecord.metadata); + const title = getString(metadata, 'title') ?? getString(documentRecord, 'title') ?? 'Apple Design'; + let content = `# ${title}\n\n`; + + const abstractText = renderInlineCollection( + getArray(documentRecord.abstract).length > 0 + ? getArray(documentRecord.abstract) + : getArray(metadata?.abstract), + references, + originalUrl, + ); + if (abstractText) { + content += `${abstractText}\n\n`; + } + + const supportedPlatforms = getSupportedPlatforms(documentRecord); + if (supportedPlatforms.length > 0) { + content += '## Supported Platforms\n\n'; + content += `${supportedPlatforms.map(platform => `- ${platform}`).join('\n')}\n\n`; + } + + const alertText = getCustomMetadataString(documentRecord, 'alert-text'); + const alertDate = getCustomMetadataString(documentRecord, 'alert-date'); + if (alertText) { + content += '## Alert\n\n'; + if (alertDate) { + content += `**${alertDate}**: `; + } + content += `${alertText}\n\n`; + } + + const primarySections = getRecordArray(documentRecord.primaryContentSections); + for (const section of primarySections) { + content += formatDesignContentSection(section, references, originalUrl, 2); + } + + const topicSections = getRecordArray(documentRecord.topicSections); + if (topicSections.length > 0) { + content += formatTopicSections(topicSections, references, originalUrl); + } + + const secondarySections = getRecordArray(documentRecord.sections); + for (const section of secondarySections) { + content += formatSecondarySection(section, references, originalUrl); + } + + content += `---\n\n[View on Apple Developer](${originalUrl})`; + return content; +} + +/** + * Parse static Apple Design HTML pages as Markdown. + * @param html Apple Design HTML. + * @param sourceUrl Source URL. + * @returns Markdown content. + */ +export function parseAppleDesignHtmlPage(html: string, sourceUrl: string): string { + const $ = cheerio.load(html); + const mainElement = $('main').first(); + const rootElement = mainElement.length > 0 ? mainElement : $('body').first(); + const title = normalizeWhitespace(rootElement.find('h1').first().text()) + || normalizeWhitespace($('title').first().text()) + || 'Apple Design'; + let content = `# ${title}\n\n`; + + rootElement.find('p').each((_index, element) => { + const paragraph = normalizeWhitespace($(element).text()); + if (paragraph) { + content += `${paragraph}\n\n`; + } + }); + + const links = new Map(); + rootElement.find('a[href]').each((_index, element) => { + const linkElement = $(element); + const href = linkElement.attr('href'); + const normalizedUrl = normalizeUrl(href, sourceUrl); + if (!normalizedUrl) { + return; + } + + const parsedUrl = safeUrl(normalizedUrl); + if (!parsedUrl || parsedUrl.hostname !== 'developer.apple.com' || !parsedUrl.pathname.startsWith('/design/')) { + return; + } + + const linkTitle = normalizeWhitespace( + linkElement.find('h2, h3, h4, h5').first().text(), + ) || normalizeWhitespace(linkElement.text()); + if (!linkTitle) { + return; + } + + const description = normalizeWhitespace(linkElement.find('p').first().text()); + links.set(normalizedUrl, { + title: linkTitle, + description: description || undefined, + }); + }); + + if (links.size > 0) { + content += '## Links\n\n'; + for (const [url, link] of links.entries()) { + content += `- [${link.title}](${url})`; + if (link.description) { + content += `: ${link.description}`; + } + content += '\n'; + } + content += '\n'; + } + + content += `---\n\n[View on Apple Developer](${sourceUrl})`; + return content; +} + +/** + * Parse Apple Design Resources HTML into catalog entries. + * @param html Apple Design Resources HTML. + * @param sourceUrl Source URL. + * @returns Resource catalog entries. + */ +export function parseDesignResourcesHtml( + html: string, + sourceUrl: string, +): DesignResourceCatalogEntry[] { + const $ = cheerio.load(html); + const entries: DesignResourceCatalogEntry[] = []; + const seenResourceIds = new Map(); + const sections = $('section.section-download'); + const sectionElements = sections.length > 0 ? sections : $('section'); + + sectionElements.each((_sectionIndex, sectionElement) => { + const section = $(sectionElement); + const category = normalizeWhitespace(section.find('h2').first().text()) || 'Design Resources'; + let currentPlatform = ''; + + section.children().each((_childIndex, childElement) => { + const child = $(childElement); + if (child.is('h3, h4')) { + currentPlatform = normalizeWhitespace(child.text()); + } + + const itemElements = child.hasClass('grid-item') ? child : child.find('.grid-item'); + itemElements.each((_itemIndex, itemElement) => { + const item = $(itemElement); + const platform = currentPlatform || findNearestHeadingText($, item); + const itemEntries = parseDesignResourceItem( + $, + item, + category, + platform, + sourceUrl, + seenResourceIds, + ); + entries.push(...itemEntries); + }); + }); + }); + + return entries; +} + +/** + * Search Apple Design, HIG, and Design Resources content. + * @param args Search arguments. + * @returns MCP tool result. + */ +export async function handleSearchAppleDesignDocs( + args: SearchAppleDesignDocsArgs, +): Promise { + const query = args.query.trim(); + const contentType = args.contentType ?? 'all'; + const platform = args.platform ?? 'all'; + const limit = args.limit ?? 20; + + const results = await collectDesignSearchResults(query, contentType, platform, limit); + return { + content: [ + createTextContent(formatDesignSearchResults(results, query)), + ], + }; +} + +/** + * Read an Apple Design URL. + * @param args Content arguments. + * @returns MCP tool result. + */ +export async function handleGetAppleDesignContent( + args: { url: string } | string, +): Promise { + const url = typeof args === 'string' ? args : args.url; + const content = await fetchAppleDesignContent(url); + return { + content: [ + createTextContent(content), + ], + }; +} + +/** + * List Apple Design Resources catalog entries. + * @param args List arguments. + * @returns MCP tool result. + */ +export async function handleListAppleDesignResources( + args: ListAppleDesignResourcesArgs = {}, +): Promise { + const resources = await getDesignResourcesCatalog(); + const filteredResources = filterDesignResources(resources, args); + const limitedResources = filteredResources.slice(0, args.limit ?? 50); + + return { + content: [ + createTextContent(formatDesignResources(limitedResources)), + ], + }; +} + +/** + * Download a direct Apple Design resource into the local MCP cache. + * @param args Download arguments. + * @returns MCP tool result. + */ +export async function handleDownloadAppleDesignResource( + args: DownloadAppleDesignResourceArgs, +): Promise { + const resource = await resolveDownloadResource(args); + if (!resource.downloadUrl) { + throw new Error('A resourceId or direct URL is required.'); + } + + if (!isDirectAppleDownloadUrl(resource.downloadUrl)) { + if (!args.resourceId) { + validateDownloadUrl(resource.downloadUrl); + } + + return { + content: [ + createTextContent(formatNonDownloadableResource(resource)), + ], + }; + } + + validateDownloadUrl(resource.downloadUrl); + const cachedResource = await downloadDesignResource(resource, args.maxBytes); + return { + content: await createDownloadedResourceContent(cachedResource), + }; +} + +/** + * Return visual examples as MCP image content blocks. + * @param args Example lookup arguments. + * @returns MCP tool result. + */ +export async function handleGetAppleDesignExamples( + args: GetAppleDesignExamplesArgs = {}, +): Promise { + const limit = args.limit ?? 3; + const candidates = await collectImageCandidates(args); + const uniqueCandidates = dedupeImageCandidates(candidates).slice(0, limit); + const content: ContentBlock[] = [ + createTextContent(`Apple Design Examples\n\nFound ${uniqueCandidates.length} image example${uniqueCandidates.length === 1 ? '' : 's'}.`), + ]; + + for (const candidate of uniqueCandidates) { + const imageContent = await fetchImageContent(candidate.url); + if (imageContent) { + content.push(imageContent); + } + } + + if (content.length === 1) { + content[0] = createTextContent('Apple Design Examples\n\nNo directly fetchable image examples were found.'); + } + + return { content }; +} + +/** + * List cached downloaded resources for MCP resources/list. + * @returns MCP resources/list result. + */ +export async function listCachedDesignResources(): Promise { + const resources: Resource[] = []; + for (const cachedResource of cachedResourcesByUri.values()) { + resources.push({ + uri: cachedResource.uri, + name: cachedResource.name, + title: cachedResource.title, + description: cachedResource.description, + mimeType: cachedResource.mimeType, + }); + } + + return { resources }; +} + +/** + * Read cached downloaded resources for MCP resources/read. + * @param uri Resource URI. + * @returns MCP resources/read result. + */ +export async function readCachedDesignResource(uri: string): Promise { + const cachedResource = cachedResourcesByUri.get(uri); + if (!cachedResource) { + throw new Error(`Unknown Apple Design resource URI: ${uri}`); + } + + const data = await readFile(cachedResource.filePath); + return { + contents: [ + { + uri: cachedResource.uri, + mimeType: cachedResource.mimeType, + blob: data.toString('base64'), + }, + ], + }; +} + +/** + * Clear process-local Design download state for tests. + * @returns Nothing. + */ +export function clearDesignResourceCacheForTesting(): void { + cachedResourcesByUri.clear(); + cachedResourcesBySourceUrl.clear(); + resourceCatalogById.clear(); +} + +async function fetchAppleDesignContent(url: string): Promise { + const cacheKey = `design-content:${url}`; + const cachedContent = designContentCache.get(cacheKey); + if (cachedContent) { + return cachedContent; + } + + const jsonUrl = convertToDesignJsonApiUrl(url); + let content: string; + if (jsonUrl) { + const jsonData = await httpClient.getJson(jsonUrl); + content = formatAppleDesignDocument(jsonData, url); + } else { + const html = await httpClient.getText(url); + if (isDesignResourcesUrl(url)) { + const resources = parseDesignResourcesHtml(html, url); + updateResourceCatalogIndex(resources); + content = `${parseAppleDesignHtmlPage(html, url)}\n\n${formatDesignResources(resources)}`; + } else { + content = parseAppleDesignHtmlPage(html, url); + } + } + + designContentCache.set(cacheKey, content); + return content; +} + +async function getDesignResourcesCatalog(): Promise { + const cachedResources = designResourcesCache.get('design-resources'); + if (cachedResources) { + updateResourceCatalogIndex(cachedResources); + return cachedResources; + } + + const html = await httpClient.getText(APPLE_URLS.DESIGN_RESOURCES); + const resources = parseDesignResourcesHtml(html, APPLE_URLS.DESIGN_RESOURCES); + updateResourceCatalogIndex(resources); + designResourcesCache.set('design-resources', resources); + return resources; +} + +async function collectDesignSearchResults( + query: string, + contentType: 'all' | 'hig' | 'resource' | 'page', + platform: string, + limit: number, +): Promise { + const normalizedQuery = query.toLowerCase(); + const results: DesignSearchResult[] = []; + + if (contentType === 'all' || contentType === 'hig') { + const higResults = await collectHigSearchResults(normalizedQuery); + results.push(...higResults); + } + + if (contentType === 'all' || contentType === 'resource') { + const resources = await getDesignResourcesCatalog(); + for (const resource of resources) { + if (!resourceMatchesPlatform(resource, platform)) { + continue; + } + const searchableText = [ + resource.title, + resource.category, + resource.platform, + resource.downloadLabel, + resource.format, + resource.note, + ].filter(Boolean).join(' ').toLowerCase(); + + if (!searchableText.includes(normalizedQuery)) { + continue; + } + + results.push({ + title: resource.title, + type: 'resource', + url: resource.downloadUrl, + description: resource.note, + resourceId: resource.resourceId, + platform: resource.platform, + format: resource.format, + previewImageUrl: resource.previewImageUrl, + }); + } + } + + if (contentType === 'all' || contentType === 'page') { + const pageResults = getStaticDesignPageResults(normalizedQuery); + results.push(...pageResults); + } + + return results.slice(0, limit); +} + +async function collectHigSearchResults(query: string): Promise { + try { + const rootJson = await httpClient.getJson(APPLE_URLS.DESIGN_HIG_JSON); + const documentRecord = asRecord(rootJson); + if (!documentRecord) { + return []; + } + + const references = getRecordMap(documentRecord.references); + const results: DesignSearchResult[] = []; + const metadata = asRecord(documentRecord.metadata); + const rootTitle = getString(metadata, 'title') ?? 'Human Interface Guidelines'; + const rootAbstract = renderInlineCollection(getArray(documentRecord.abstract), references, APPLE_URLS.DESIGN); + if (`${rootTitle} ${rootAbstract}`.toLowerCase().includes(query)) { + results.push({ + title: rootTitle, + type: 'hig', + url: 'https://developer.apple.com/design/human-interface-guidelines', + description: rootAbstract, + }); + } + + for (const reference of references.values()) { + const title = getString(reference, 'title'); + const url = normalizeReferenceUrl(reference, APPLE_URLS.DESIGN); + if (!title || !url || !url.includes('/design/human-interface-guidelines')) { + continue; + } + + const description = renderInlineCollection(getArray(reference.abstract), references, APPLE_URLS.DESIGN); + if (`${title} ${description}`.toLowerCase().includes(query)) { + results.push({ + title, + type: 'hig', + url, + description, + }); + } + } + + return results; + } catch { + return []; + } +} + +function getStaticDesignPageResults(query: string): DesignSearchResult[] { + const pages: DesignSearchResult[] = [ + { + title: 'Apple Design', + type: 'page', + url: APPLE_URLS.DESIGN, + description: 'Apple Design guidance, videos, resources, and Human Interface Guidelines.', + }, + { + title: 'Design Resources', + type: 'page', + url: APPLE_URLS.DESIGN_RESOURCES, + description: 'Templates, product bezels, fonts, tools, and downloadable resources.', + }, + { + title: 'What is new in Apple Design', + type: 'page', + url: 'https://developer.apple.com/design/whats-new/', + description: 'Recent Apple Design updates and platform guidance.', + }, + { + title: 'Get started with Apple Design', + type: 'page', + url: 'https://developer.apple.com/design/get-started/', + description: 'Introductory Apple Design guidance.', + }, + ]; + + return pages.filter(page => { + const searchableText = `${page.title} ${page.description ?? ''}`.toLowerCase(); + return searchableText.includes(query); + }); +} + +function formatDesignSearchResults(results: DesignSearchResult[], query: string): string { + let content = `# Apple Design Search Results\n\nQuery: "${query}"\n\n`; + if (results.length === 0) { + return `${content}No Apple Design results found.`; + } + + results.forEach((result, index) => { + content += `## ${index + 1}. ${result.title}\n\n`; + content += `- Type: ${result.type}\n`; + content += `- URL: ${result.url}\n`; + if (result.resourceId) { + content += `- Resource ID: ${result.resourceId}\n`; + } + if (result.platform) { + content += `- Platform: ${result.platform}\n`; + } + if (result.format) { + content += `- Format: ${result.format}\n`; + } + if (result.previewImageUrl) { + content += `- Preview: ${result.previewImageUrl}\n`; + } + if (result.description) { + content += `\n${result.description}\n`; + } + content += '\n'; + }); + + return content.trimEnd(); +} + +function filterDesignResources( + resources: DesignResourceCatalogEntry[], + args: ListAppleDesignResourcesArgs, +): DesignResourceCatalogEntry[] { + return resources.filter(resource => { + if (args.category && !containsText(resource.category, args.category)) { + return false; + } + if (args.platform && !containsText(resource.platform, args.platform)) { + return false; + } + if (args.format && !containsText(resource.format, args.format)) { + return false; + } + if (args.searchQuery) { + const searchableText = [ + resource.title, + resource.category, + resource.platform, + resource.downloadLabel, + resource.format, + resource.note, + ].filter(Boolean).join(' '); + if (!containsText(searchableText, args.searchQuery)) { + return false; + } + } + return true; + }); +} + +function formatDesignResources(resources: DesignResourceCatalogEntry[]): string { + let content = '# Apple Design Resources\n\n'; + if (resources.length === 0) { + return `${content}No matching Design Resources found.`; + } + + for (const resource of resources) { + content += `## ${resource.title}\n\n`; + content += `- Resource ID: ${resource.resourceId}\n`; + content += `- Category: ${resource.category}\n`; + if (resource.platform) { + content += `- Platform: ${resource.platform}\n`; + } + content += `- Format: ${resource.format}\n`; + content += `- Download Label: ${resource.downloadLabel}\n`; + content += `- Download URL: ${resource.downloadUrl}\n`; + if (resource.previewImageUrl) { + content += `- Preview Image URL: ${resource.previewImageUrl}\n`; + } + if (resource.note) { + content += `- Notes: ${resource.note}\n`; + } + if (resource.relatedGuidelineLinks.length > 0) { + content += `- Related HIG Links: ${resource.relatedGuidelineLinks.join(', ')}\n`; + } + content += '\n'; + } + + return content.trimEnd(); +} + +async function resolveDownloadResource( + args: DownloadAppleDesignResourceArgs, +): Promise { + if (args.resourceId) { + if (!resourceCatalogById.has(args.resourceId)) { + await getDesignResourcesCatalog(); + } + + const resource = resourceCatalogById.get(args.resourceId); + if (!resource) { + throw new Error(`Unknown Apple Design resourceId: ${args.resourceId}`); + } + return resource; + } + + if (!args.url) { + throw new Error('A resourceId or direct URL is required.'); + } + + return { + resourceId: `direct:${hashString(args.url).slice(0, 12)}`, + category: 'Direct URL', + title: getFilenameFromUrl(args.url), + downloadLabel: 'Download', + downloadUrl: args.url, + format: inferFormat(args.url, 'Download'), + relatedGuidelineLinks: [], + }; +} + +async function downloadDesignResource( + resource: DesignResourceCatalogEntry, + maxBytes: number | undefined, +): Promise { + const normalizedSourceUrl = resource.downloadUrl; + const cachedResource = cachedResourcesBySourceUrl.get(normalizedSourceUrl); + if (cachedResource) { + return cachedResource; + } + + const byteLimit = maxBytes ?? DEFAULT_DOWNLOAD_MAX_BYTES; + const response = await httpClient.get(normalizedSourceUrl, { + timeout: REQUEST_CONFIG.TIMEOUT, + headers: { + Accept: '*/*', + }, + }); + + const contentLength = getContentLength(response); + if (contentLength !== undefined && contentLength > byteLimit) { + throw new Error(`Apple Design resource exceeds the ${byteLimit} byte download limit.`); + } + + const arrayBuffer = await response.arrayBuffer(); + const data = Buffer.from(arrayBuffer); + if (data.length > byteLimit) { + throw new Error(`Apple Design resource exceeds the ${byteLimit} byte download limit.`); + } + + const mimeType = detectMimeType(response.headers.get('content-type'), normalizedSourceUrl); + const hash = hashBuffer(data); + const filename = sanitizeFilename(getFilenameFromUrl(normalizedSourceUrl)); + const cacheDirectory = getDesignCacheDirectory(); + await mkdir(cacheDirectory, { recursive: true }); + + const filePath = path.join(cacheDirectory, `${hash}-${filename}`); + await writeFile(filePath, data); + + const uri = `${RESOURCE_URI_PREFIX}${hash}/${filename}`; + const newCachedResource: CachedDesignResource = { + uri, + name: filename, + title: resource.title, + description: `${resource.category}${resource.platform ? `, ${resource.platform}` : ''}`, + mimeType, + filePath, + sourceUrl: normalizedSourceUrl, + size: data.length, + }; + + cachedResourcesByUri.set(uri, newCachedResource); + cachedResourcesBySourceUrl.set(normalizedSourceUrl, newCachedResource); + return newCachedResource; +} + +async function createDownloadedResourceContent( + cachedResource: CachedDesignResource, +): Promise { + const content: ContentBlock[] = [ + createTextContent( + `Downloaded Apple Design resource: ${cachedResource.name}\n\n` + + `- URI: ${cachedResource.uri}\n` + + `- MIME Type: ${cachedResource.mimeType}\n` + + `- Size: ${cachedResource.size} bytes`, + ), + ]; + + if (isImageMimeType(cachedResource.mimeType)) { + const data = await readFile(cachedResource.filePath); + content.push({ + type: 'image', + data: data.toString('base64'), + mimeType: cachedResource.mimeType, + }); + } + + content.push(createResourceLink(cachedResource)); + return content; +} + +function formatNonDownloadableResource(resource: DesignResourceCatalogEntry): string { + return ( + '# Apple Design Resource\n\n' + + `${resource.title} is catalog metadata or an external design-tool link, so it was not downloaded.\n\n` + + `- Resource ID: ${resource.resourceId}\n` + + `- Format: ${resource.format}\n` + + `- URL: ${resource.downloadUrl}` + ); +} + +async function collectImageCandidates(args: GetAppleDesignExamplesArgs): Promise { + const candidates: DesignImageCandidate[] = []; + + if (args.resourceId) { + if (!resourceCatalogById.has(args.resourceId)) { + await getDesignResourcesCatalog(); + } + const resource = resourceCatalogById.get(args.resourceId); + if (resource?.previewImageUrl) { + candidates.push({ + url: resource.previewImageUrl, + alt: resource.title, + }); + } + } + + if (args.query) { + const resources = filterDesignResources(await getDesignResourcesCatalog(), { + searchQuery: args.query, + limit: args.limit, + }); + for (const resource of resources) { + if (resource.previewImageUrl) { + candidates.push({ + url: resource.previewImageUrl, + alt: resource.title, + }); + } + } + } + + if (args.url) { + const url = args.url; + if (isDirectImageUrl(url)) { + candidates.push({ url }); + } else { + const jsonUrl = convertToDesignJsonApiUrl(url); + if (jsonUrl) { + const jsonData = await httpClient.getJson(jsonUrl); + candidates.push(...extractImageCandidatesFromDesignDocument(jsonData, url)); + } else { + const html = await httpClient.getText(url); + candidates.push(...extractImageCandidatesFromHtml(html, url)); + } + } + } + + return candidates; +} + +async function fetchImageContent(url: string): Promise { + const parsedUrl = safeUrl(url); + if (!parsedUrl || !APPLE_DESIGN_DOWNLOAD_HOSTS.has(parsedUrl.hostname)) { + return null; + } + + const response = await httpClient.get(url, { + timeout: REQUEST_CONFIG.TIMEOUT, + headers: { + Accept: 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8', + }, + }); + const mimeType = detectMimeType(response.headers.get('content-type'), url); + if (!isImageMimeType(mimeType)) { + return null; + } + + const data = Buffer.from(await response.arrayBuffer()); + return { + type: 'image', + data: data.toString('base64'), + mimeType, + }; +} + +function extractImageCandidatesFromDesignDocument( + jsonData: unknown, + sourceUrl: string, +): DesignImageCandidate[] { + const documentRecord = asRecord(jsonData); + if (!documentRecord) { + return []; + } + + const references = getRecordMap(documentRecord.references); + const candidates: DesignImageCandidate[] = []; + for (const reference of references.values()) { + if (getString(reference, 'type') !== 'image') { + continue; + } + const imageUrl = getImageUrlFromReference(reference, sourceUrl); + if (imageUrl) { + candidates.push({ + url: imageUrl, + alt: getString(reference, 'alt') ?? getString(reference, 'title'), + }); + } + } + + collectImageCandidatesFromContent(getArray(documentRecord.primaryContentSections), references, sourceUrl, candidates); + collectImageCandidatesFromContent(getArray(documentRecord.sections), references, sourceUrl, candidates); + return candidates; +} + +function collectImageCandidatesFromContent( + nodes: unknown[], + references: Map>, + sourceUrl: string, + candidates: DesignImageCandidate[], +): void { + for (const node of nodes) { + const record = asRecord(node); + if (!record) { + continue; + } + + const type = getString(record, 'type'); + if (type === 'image' || type === 'icon') { + const imageUrl = resolveImageBlockUrl(record, references, sourceUrl); + if (imageUrl) { + candidates.push({ + url: imageUrl, + alt: getString(record, 'alt') ?? getString(record, 'title'), + }); + } + } + + for (const nestedKey of ['content', 'items', 'rows', 'cells', 'inlineContent']) { + collectImageCandidatesFromContent(getArray(record[nestedKey]), references, sourceUrl, candidates); + } + } +} + +function extractImageCandidatesFromHtml(html: string, sourceUrl: string): DesignImageCandidate[] { + const $ = cheerio.load(html); + const candidates: DesignImageCandidate[] = []; + $('main img, body img').each((_index, element) => { + const image = $(element); + const imageUrl = normalizeUrl(image.attr('src') ?? image.attr('data-src'), sourceUrl); + if (!imageUrl) { + return; + } + candidates.push({ + url: imageUrl, + alt: image.attr('alt') ?? undefined, + }); + }); + return candidates; +} + +function dedupeImageCandidates(candidates: DesignImageCandidate[]): DesignImageCandidate[] { + const seenUrls = new Set(); + const dedupedCandidates: DesignImageCandidate[] = []; + for (const candidate of candidates) { + if (seenUrls.has(candidate.url)) { + continue; + } + seenUrls.add(candidate.url); + dedupedCandidates.push(candidate); + } + return dedupedCandidates; +} + +function parseDesignResourceItem( + $: cheerio.CheerioAPI, + item: cheerio.Cheerio, + category: string, + platform: string | undefined, + sourceUrl: string, + seenResourceIds: Map, +): DesignResourceCatalogEntry[] { + const title = normalizeWhitespace(item.find('h5, h4, h3').first().text()) || category; + const previewImageUrl = normalizeUrl( + item.find('img.download-image, img').first().attr('src'), + sourceUrl, + ); + const noteParts = item.find('.download-text-description').map((_index, element) => { + return normalizeWhitespace($(element).text()); + }).get().filter(Boolean); + const note = noteParts.join(' ') || undefined; + const relatedGuidelineLinks = item.find('a[href*="/design/human-interface-guidelines"]').map((_index, element) => { + return normalizeUrl($(element).attr('href'), sourceUrl); + }).get().filter((link): link is string => Boolean(link)); + const links = item.find('a[href]'); + const entries: DesignResourceCatalogEntry[] = []; + const seenLinks = new Set(); + + links.each((_index, element) => { + const link = $(element); + const href = link.attr('href'); + const label = normalizeWhitespace(link.text()) || 'Download'; + if (!href || !isResourceCatalogLink(link, href, label)) { + return; + } + + const downloadUrl = normalizeUrl(href, sourceUrl); + if (!downloadUrl) { + return; + } + + const linkKey = `${label}:${downloadUrl}`; + if (seenLinks.has(linkKey)) { + return; + } + seenLinks.add(linkKey); + + const format = inferFormat(downloadUrl, label); + const resourceId = createResourceId(category, platform, label, seenResourceIds); + entries.push({ + resourceId, + category, + platform: platform || undefined, + title, + downloadLabel: label, + downloadUrl, + format, + note, + previewImageUrl, + relatedGuidelineLinks: [...new Set(relatedGuidelineLinks)], + }); + }); + + return entries; +} + +function formatDesignContentSection( + section: Record, + references: Map>, + sourceUrl: string, + defaultHeadingLevel: number, +): string { + let content = ''; + const title = getString(section, 'title'); + if (title) { + content += `${'#'.repeat(defaultHeadingLevel)} ${title}\n\n`; + } + + const blocks = getArray(section.content); + for (const block of blocks) { + content += formatDesignBlock(block, references, sourceUrl, defaultHeadingLevel); + } + + return content; +} + +function formatSecondarySection( + section: Record, + references: Map>, + sourceUrl: string, +): string { + const title = getString(section, 'title') ?? ''; + const kind = getString(section, 'kind') ?? ''; + const isChangeLog = `${title} ${kind}`.toLowerCase().includes('change'); + if (isChangeLog) { + return formatChangeLogSection(section, references, sourceUrl); + } + + return formatDesignContentSection(section, references, sourceUrl, 2); +} + +function formatChangeLogSection( + section: Record, + references: Map>, + sourceUrl: string, +): string { + let content = '## Change Log\n\n'; + const items = getRecordArray(section.items); + for (const item of items) { + const date = getString(item, 'date') ?? getString(item, 'title'); + if (date) { + content += `### ${date}\n\n`; + } + for (const block of getArray(item.content)) { + content += formatDesignBlock(block, references, sourceUrl, 4); + } + } + return content; +} + +function formatTopicSections( + topicSections: Record[], + references: Map>, + sourceUrl: string, +): string { + let content = '## Topics\n\n'; + for (const section of topicSections) { + const title = getString(section, 'title'); + if (title) { + content += `### ${title}\n\n`; + } + + for (const identifier of getStringArray(section.identifiers)) { + const reference = references.get(identifier); + if (!reference) { + continue; + } + const referenceTitle = getString(reference, 'title') ?? getIdentifierTitle(identifier); + const referenceUrl = normalizeReferenceUrl(reference, sourceUrl); + const abstractText = renderInlineCollection(getArray(reference.abstract), references, sourceUrl); + if (referenceUrl) { + content += `- [${referenceTitle}](${referenceUrl})`; + } else { + content += `- ${referenceTitle}`; + } + if (abstractText) { + content += `: ${abstractText}`; + } + content += '\n'; + } + content += '\n'; + } + return content; +} + +function formatDesignBlock( + block: unknown, + references: Map>, + sourceUrl: string, + headingLevel: number, +): string { + const record = asRecord(block); + if (!record) { + return ''; + } + + const type = getString(record, 'type') ?? getString(record, 'kind'); + if (type === 'heading') { + const level = getNumber(record, 'level') ?? headingLevel; + const text = getString(record, 'text') ?? renderInlineCollection(getArray(record.inlineContent), references, sourceUrl); + return text ? `${'#'.repeat(Math.min(level, 6))} ${text}\n\n` : ''; + } + + if (type === 'paragraph') { + const text = renderInlineCollection(getArray(record.inlineContent), references, sourceUrl); + return text ? `${text}\n\n` : ''; + } + + if (type === 'image' || type === 'icon') { + const imageUrl = resolveImageBlockUrl(record, references, sourceUrl); + if (!imageUrl) { + return ''; + } + const altText = resolveImageAltText(record, references) ?? 'Apple Design image'; + return `![${altText}](${imageUrl})\n\n`; + } + + if (type === 'unorderedList' || type === 'orderedList') { + return formatListBlock(record, references, sourceUrl, type === 'orderedList'); + } + + if (type === 'table') { + return formatTableBlock(record, references, sourceUrl); + } + + if (type === 'aside' || type === 'alert') { + const blocks = getArray(record.content); + const asideText = blocks.map(item => formatDesignBlock(item, references, sourceUrl, headingLevel + 1).trim()) + .filter(Boolean) + .join('\n\n'); + return asideText ? `> ${asideText.replaceAll('\n', '\n> ')}\n\n` : ''; + } + + if (type === 'links' || type === 'linkGrid') { + return formatLinksBlock(record, references, sourceUrl); + } + + if (type === 'tabNavigator' || type === 'tabs') { + return formatTabsBlock(record, references, sourceUrl, headingLevel); + } + + if (type === 'reference') { + const identifier = getString(record, 'identifier'); + if (!identifier) { + return ''; + } + const reference = references.get(identifier); + const referenceTitle = reference ? getString(reference, 'title') : undefined; + const referenceUrl = reference ? normalizeReferenceUrl(reference, sourceUrl) : undefined; + if (referenceTitle && referenceUrl) { + return `- [${referenceTitle}](${referenceUrl})\n\n`; + } + } + + const nestedContent = getArray(record.content); + if (nestedContent.length > 0) { + return nestedContent.map(item => formatDesignBlock(item, references, sourceUrl, headingLevel)).join(''); + } + + return renderInlineCollection(getArray(record.inlineContent), references, sourceUrl); +} + +function formatListBlock( + block: Record, + references: Map>, + sourceUrl: string, + ordered: boolean, +): string { + let content = ''; + const items = getArray(block.items); + items.forEach((item, index) => { + const itemRecord = asRecord(item); + if (!itemRecord) { + return; + } + + const itemContent = getArray(itemRecord.content) + .map(contentBlock => formatDesignBlock(contentBlock, references, sourceUrl, 4).trim()) + .filter(Boolean) + .join(' '); + if (itemContent) { + content += `${ordered ? `${index + 1}.` : '-'} ${itemContent}\n`; + } + }); + + return content ? `${content}\n` : ''; +} + +function formatTableBlock( + block: Record, + references: Map>, + sourceUrl: string, +): string { + const headerCells = getArray(block.header).map(cell => renderTableCell(cell, references, sourceUrl)); + const rows = getRecordArray(block.rows); + if (headerCells.length === 0 || rows.length === 0) { + return ''; + } + + let content = `| ${headerCells.join(' | ')} |\n`; + content += `| ${headerCells.map(() => '---').join(' | ')} |\n`; + for (const row of rows) { + const cells = getArray(row.cells).map(cell => renderTableCell(cell, references, sourceUrl)); + content += `| ${cells.join(' | ')} |\n`; + } + return `${content}\n`; +} + +function formatLinksBlock( + block: Record, + references: Map>, + sourceUrl: string, +): string { + let content = ''; + for (const identifier of getStringArray(block.identifiers)) { + const reference = references.get(identifier); + if (!reference) { + continue; + } + const title = getString(reference, 'title') ?? getIdentifierTitle(identifier); + const url = normalizeReferenceUrl(reference, sourceUrl); + if (url) { + content += `- [${title}](${url})\n`; + } + } + + const links = getRecordArray(block.links); + for (const link of links) { + const title = getString(link, 'title') ?? renderInlineCollection(getArray(link.inlineContent), references, sourceUrl); + const url = normalizeUrl(getString(link, 'url'), sourceUrl); + if (title && url) { + content += `- [${title}](${url})\n`; + } + } + + return content ? `${content}\n` : ''; +} + +function formatTabsBlock( + block: Record, + references: Map>, + sourceUrl: string, + headingLevel: number, +): string { + let content = ''; + const tabs = getRecordArray(block.tabs); + for (const tab of tabs) { + const title = getString(tab, 'title'); + if (title) { + content += `${'#'.repeat(Math.min(headingLevel + 1, 6))} ${title}\n\n`; + } + for (const tabBlock of getArray(tab.content)) { + content += formatDesignBlock(tabBlock, references, sourceUrl, headingLevel + 2); + } + } + return content; +} + +function renderTableCell( + cell: unknown, + references: Map>, + sourceUrl: string, +): string { + const cellRecord = asRecord(cell); + if (!cellRecord) { + return ''; + } + + const inlineText = renderInlineCollection(getArray(cellRecord.inlineContent), references, sourceUrl); + if (inlineText) { + return inlineText.replaceAll('|', '\\|'); + } + + const blockText = getArray(cellRecord.content) + .map(block => formatDesignBlock(block, references, sourceUrl, 4).trim()) + .join(' '); + return blockText.replaceAll('|', '\\|'); +} + +function renderInlineCollection( + inlineContent: unknown[], + references: Map>, + sourceUrl: string, +): string { + return inlineContent.map(item => renderInlineContent(item, references, sourceUrl)).join(''); +} + +function renderInlineContent( + inline: unknown, + references: Map>, + sourceUrl: string, +): string { + const record = asRecord(inline); + if (!record) { + return ''; + } + + const type = getString(record, 'type'); + if (type === 'text') { + return getString(record, 'text') ?? ''; + } + + if (type === 'strong') { + return `**${renderInlineCollection(getArray(record.inlineContent), references, sourceUrl)}**`; + } + + if (type === 'emphasis') { + return `*${renderInlineCollection(getArray(record.inlineContent), references, sourceUrl)}*`; + } + + if (type === 'codeVoice' || type === 'code') { + return `\`${getString(record, 'code') ?? getString(record, 'text') ?? ''}\``; + } + + if (type === 'reference') { + const identifier = getString(record, 'identifier'); + if (!identifier) { + return ''; + } + const reference = references.get(identifier); + const title = getString(record, 'title') ?? getString(reference, 'title') ?? getIdentifierTitle(identifier); + const url = reference ? normalizeReferenceUrl(reference, sourceUrl) : undefined; + if (url) { + return `[${title}](${url})`; + } + return title; + } + + if (type === 'link') { + const title = getString(record, 'title') + ?? getString(record, 'text') + ?? renderInlineCollection(getArray(record.inlineContent), references, sourceUrl); + const url = normalizeUrl(getString(record, 'url'), sourceUrl); + if (title && url) { + return `[${title}](${url})`; + } + return title; + } + + const nestedInlineContent = getArray(record.inlineContent); + if (nestedInlineContent.length > 0) { + return renderInlineCollection(nestedInlineContent, references, sourceUrl); + } + + return getString(record, 'text') ?? ''; +} + +function getSupportedPlatforms(documentRecord: Record): string[] { + const customMetadata = asRecord(documentRecord.customMetadata); + const supportedPlatforms = customMetadata?.['supported-platforms'] ?? customMetadata?.supportedPlatforms; + return getStringArray(supportedPlatforms); +} + +function getCustomMetadataString(documentRecord: Record, key: string): string | undefined { + const customMetadata = asRecord(documentRecord.customMetadata); + return getString(customMetadata, key); +} + +function resolveImageBlockUrl( + block: Record, + references: Map>, + sourceUrl: string, +): string | undefined { + const directUrl = normalizeUrl(getString(block, 'url') ?? getString(block, 'source'), sourceUrl); + if (directUrl) { + return directUrl; + } + + const identifier = getString(block, 'identifier'); + if (!identifier) { + return undefined; + } + + const reference = references.get(identifier); + if (!reference) { + return undefined; + } + + return getImageUrlFromReference(reference, sourceUrl); +} + +function resolveImageAltText( + block: Record, + references: Map>, +): string | undefined { + const directAlt = getString(block, 'alt') ?? getString(block, 'title'); + if (directAlt) { + return directAlt; + } + + const identifier = getString(block, 'identifier'); + if (!identifier) { + return undefined; + } + + const reference = references.get(identifier); + return getString(reference, 'alt') ?? getString(reference, 'title'); +} + +function getImageUrlFromReference( + reference: Record, + sourceUrl: string, +): string | undefined { + const directUrl = normalizeUrl(getString(reference, 'url'), sourceUrl); + if (directUrl && isDirectImageUrl(directUrl)) { + return directUrl; + } + + const variants = getRecordArray(reference.variants); + for (const variant of variants) { + const variantUrl = normalizeUrl(getString(variant, 'url'), sourceUrl); + if (variantUrl) { + return variantUrl; + } + } + + const images = getRecordArray(reference.images); + for (const image of images) { + const imageUrl = normalizeUrl(getString(image, 'url'), sourceUrl); + if (imageUrl) { + return imageUrl; + } + } + + return undefined; +} + +function normalizeReferenceUrl( + reference: Record | undefined, + sourceUrl: string, +): string | undefined { + if (!reference) { + return undefined; + } + + const referenceUrl = getString(reference, 'url'); + return normalizeUrl(referenceUrl, sourceUrl); +} + +function updateResourceCatalogIndex(resources: DesignResourceCatalogEntry[]): void { + for (const resource of resources) { + resourceCatalogById.set(resource.resourceId, resource); + } +} + +function isResourceCatalogLink( + link: cheerio.Cheerio, + href: string, + label: string, +): boolean { + if (link.hasClass('download-text-link')) { + return true; + } + + if (href.startsWith('sketch://') || href.includes('figma.com')) { + return true; + } + + const parsedUrl = safeUrl(href); + const pathname = parsedUrl?.pathname ?? href; + const extension = path.extname(pathname).toLowerCase(); + if (DIRECT_RESOURCE_EXTENSIONS.has(extension)) { + return true; + } + + const normalizedLabel = label.toLowerCase(); + return normalizedLabel.includes('download') + || normalizedLabel.includes('figma') + || normalizedLabel.includes('sketch'); +} + +function createResourceId( + category: string, + platform: string | undefined, + label: string, + seenResourceIds: Map, +): string { + const baseId = [ + 'design-resource', + slugify(category), + slugify(platform || 'all'), + slugify(label), + ].join(':'); + const seenCount = seenResourceIds.get(baseId) ?? 0; + seenResourceIds.set(baseId, seenCount + 1); + if (seenCount === 0) { + return baseId; + } + return `${baseId}:${seenCount + 1}`; +} + +function findNearestHeadingText( + $: cheerio.CheerioAPI, + item: cheerio.Cheerio, +): string | undefined { + const previousHeading = item.prevAll('h4, h3').first(); + if (previousHeading.length > 0) { + return normalizeWhitespace(previousHeading.text()); + } + + const parentPreviousHeading = item.parent().prevAll('h4, h3').first(); + if (parentPreviousHeading.length > 0) { + return normalizeWhitespace(parentPreviousHeading.text()); + } + + const sectionHeading = item.closest('section').find('h4, h3').first(); + const headingText = normalizeWhitespace($(sectionHeading).text()); + return headingText || undefined; +} + +function normalizeUrl(url: string | undefined, sourceUrl: string): string | undefined { + if (!url) { + return undefined; + } + + try { + return new URL(url, sourceUrl).toString(); + } catch { + return undefined; + } +} + +function safeUrl(url: string): URL | undefined { + try { + return new URL(url); + } catch { + return undefined; + } +} + +function isDesignResourcesUrl(url: string): boolean { + const parsedUrl = safeUrl(url); + return parsedUrl?.hostname === 'developer.apple.com' && parsedUrl.pathname.startsWith('/design/resources'); +} + +function isDirectAppleDownloadUrl(url: string): boolean { + const parsedUrl = safeUrl(url); + return Boolean(parsedUrl && parsedUrl.protocol === 'https:' && APPLE_DESIGN_DOWNLOAD_HOSTS.has(parsedUrl.hostname)); +} + +function validateDownloadUrl(url: string): void { + const parsedUrl = safeUrl(url); + if (!parsedUrl || parsedUrl.protocol !== 'https:') { + throw new Error('Apple Design downloads must use HTTPS URLs.'); + } + + if (!APPLE_DESIGN_DOWNLOAD_HOSTS.has(parsedUrl.hostname)) { + throw new Error(`Download domain is not allowed: ${parsedUrl.hostname}`); + } +} + +function isDirectImageUrl(url: string): boolean { + const mimeType = detectMimeType(undefined, url); + return isImageMimeType(mimeType); +} + +function inferFormat(url: string, label: string): string { + const normalizedLabel = label.toLowerCase(); + if (normalizedLabel.includes('figma') || url.includes('figma.com')) { + return 'figma'; + } + if (normalizedLabel.includes('sketch') || url.startsWith('sketch://')) { + return 'sketch'; + } + + const parsedUrl = safeUrl(url); + const extension = path.extname(parsedUrl?.pathname ?? url).replace('.', '').toLowerCase(); + if (extension) { + return extension; + } + + return 'external'; +} + +function detectMimeType(contentType: string | null | undefined, url: string): string { + if (contentType) { + return contentType.split(';')[0].trim().toLowerCase(); + } + + const parsedUrl = safeUrl(url); + const extension = path.extname(parsedUrl?.pathname ?? url).toLowerCase(); + switch (extension) { + case '.dmg': + return 'application/x-apple-diskimage'; + case '.gif': + return 'image/gif'; + case '.jpeg': + case '.jpg': + return 'image/jpeg'; + case '.pdf': + return 'application/pdf'; + case '.png': + return 'image/png'; + case '.svg': + return 'image/svg+xml'; + case '.webp': + return 'image/webp'; + case '.zip': + return 'application/zip'; + default: + return 'application/octet-stream'; + } +} + +function isImageMimeType(mimeType: string): boolean { + return mimeType.startsWith(IMAGE_MIME_PREFIX); +} + +function getContentLength(response: Response): number | undefined { + const contentLengthHeader = response.headers.get('content-length'); + if (!contentLengthHeader) { + return undefined; + } + + const contentLength = Number.parseInt(contentLengthHeader, 10); + if (Number.isNaN(contentLength)) { + return undefined; + } + return contentLength; +} + +function getDesignCacheDirectory(): string { + return process.env.APPLE_DOCS_MCP_CACHE_DIR + || path.join(tmpdir(), 'apple-docs-mcp', 'design-resources'); +} + +function createTextContent(text: string): ContentBlock { + return { + type: 'text', + text, + }; +} + +function createResourceLink(cachedResource: CachedDesignResource): ResourceLink { + return { + type: 'resource_link', + uri: cachedResource.uri, + name: cachedResource.name, + title: cachedResource.title, + description: cachedResource.description, + mimeType: cachedResource.mimeType, + }; +} + +function containsText(source: string | undefined, query: string): boolean { + if (!source) { + return false; + } + return source.toLowerCase().includes(query.toLowerCase()); +} + +function resourceMatchesPlatform(resource: DesignResourceCatalogEntry, platform: string): boolean { + if (platform === 'all') { + return true; + } + return containsText(resource.platform, platform) || containsText(resource.title, platform); +} + +function getFilenameFromUrl(url: string): string { + const parsedUrl = safeUrl(url); + const basename = path.basename(parsedUrl?.pathname ?? url); + return basename || 'apple-design-resource'; +} + +function sanitizeFilename(filename: string): string { + const sanitizedFilename = filename.replace(/[^a-zA-Z0-9._-]/g, '-'); + return sanitizedFilename || 'apple-design-resource'; +} + +function slugify(value: string): string { + const slug = value.toLowerCase() + .replaceAll('&', 'and') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + return slug || 'resource'; +} + +function normalizeWhitespace(value: string): string { + return value.replace(/\s+/g, ' ').trim(); +} + +function hashString(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +function hashBuffer(value: Buffer): string { + return createHash('sha256').update(value).digest('hex'); +} + +function getIdentifierTitle(identifier: string): string { + return identifier.split('/').pop() ?? identifier; +} + +function asRecord(value: unknown): Record | undefined { + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + return value as Record; + } + return undefined; +} + +function getRecordMap(value: unknown): Map> { + const valueRecord = asRecord(value); + const map = new Map>(); + if (!valueRecord) { + return map; + } + + for (const [key, entryValue] of Object.entries(valueRecord)) { + const entryRecord = asRecord(entryValue); + if (entryRecord) { + map.set(key, entryRecord); + } + } + + return map; +} + +function getRecordArray(value: unknown): Record[] { + return getArray(value).flatMap(item => { + const record = asRecord(item); + if (!record) { + return []; + } + return [record]; + }); +} + +function getArray(value: unknown): unknown[] { + if (Array.isArray(value)) { + return value; + } + return []; +} + +function getString(value: unknown, key: string): string | undefined { + const record = asRecord(value); + if (!record) { + return undefined; + } + + const propertyValue = record[key]; + if (typeof propertyValue === 'string') { + return propertyValue; + } + return undefined; +} + +function getNumber(value: unknown, key: string): number | undefined { + const record = asRecord(value); + if (!record) { + return undefined; + } + + const propertyValue = record[key]; + if (typeof propertyValue === 'number') { + return propertyValue; + } + return undefined; +} + +function getStringArray(value: unknown): string[] { + if (Array.isArray(value)) { + return value.filter((item): item is string => typeof item === 'string'); + } + + if (typeof value === 'string') { + return [value]; + } + + return []; +} + +export async function removeCachedDesignResourcesForTesting(): Promise { + const cacheDirectory = getDesignCacheDirectory(); + await rm(cacheDirectory, { + force: true, + recursive: true, + }); +} diff --git a/src/tools/handlers.ts b/src/tools/handlers.ts index 0b5c443..2f0d7ce 100644 --- a/src/tools/handlers.ts +++ b/src/tools/handlers.ts @@ -2,6 +2,7 @@ * Tool handlers for Apple Developer Documentation MCP Server */ +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { searchAppleDocsSchema, getAppleDocContentSchema, @@ -14,6 +15,11 @@ import { getDocumentationUpdatesSchema, getTechnologyOverviewsSchema, getSampleCodeSchema, + searchAppleDesignDocsSchema, + getAppleDesignContentSchema, + listAppleDesignResourcesSchema, + downloadAppleDesignResourceSchema, + getAppleDesignExamplesSchema, } from '../schemas/index.js'; import { listWWDCVideosSchema, @@ -39,7 +45,7 @@ import { export type ToolHandler = ( args: unknown, server: any -) => Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }>; +) => Promise; /** * Map of tool names to their handlers @@ -90,7 +96,17 @@ export const toolHandlers: Record = { }, get_cache_stats: async () => { - const { apiCache, searchCache, indexCache, technologiesCache, updatesCache, sampleCodeCache, technologyOverviewsCache } = await import('../utils/cache.js'); + const { + apiCache, + searchCache, + indexCache, + technologiesCache, + updatesCache, + sampleCodeCache, + technologyOverviewsCache, + designContentCache, + designResourcesCache, + } = await import('../utils/cache.js'); const stats = { apiCache: apiCache.getStats(), @@ -100,6 +116,8 @@ export const toolHandlers: Record = { updatesCache: updatesCache.getStats(), sampleCodeCache: sampleCodeCache.getStats(), technologyOverviewsCache: technologyOverviewsCache.getStats(), + designContentCache: designContentCache.getStats(), + designResourcesCache: designResourcesCache.getStats(), }; let report = '# Cache Statistics Report\n\n'; @@ -138,6 +156,51 @@ export const toolHandlers: Record = { ); }, + search_apple_design_docs: async (args, server) => { + const validatedArgs = searchAppleDesignDocsSchema.parse(args); + return await server.searchAppleDesignDocs( + validatedArgs.query, + validatedArgs.contentType, + validatedArgs.platform, + validatedArgs.limit, + ); + }, + + get_apple_design_content: async (args, server) => { + const validatedArgs = getAppleDesignContentSchema.parse(args); + return await server.getAppleDesignContent(validatedArgs.url); + }, + + list_apple_design_resources: async (args, server) => { + const validatedArgs = listAppleDesignResourcesSchema.parse(args); + return await server.listAppleDesignResources( + validatedArgs.category, + validatedArgs.platform, + validatedArgs.format, + validatedArgs.searchQuery, + validatedArgs.limit, + ); + }, + + download_apple_design_resource: async (args, server) => { + const validatedArgs = downloadAppleDesignResourceSchema.parse(args); + return await server.downloadAppleDesignResource( + validatedArgs.resourceId, + validatedArgs.url, + validatedArgs.maxBytes, + ); + }, + + get_apple_design_examples: async (args, server) => { + const validatedArgs = getAppleDesignExamplesSchema.parse(args); + return await server.getAppleDesignExamples( + validatedArgs.url, + validatedArgs.resourceId, + validatedArgs.query, + validatedArgs.limit, + ); + }, + list_technologies: async (args, server) => { const validatedArgs = listTechnologiesSchema.parse(args); return await server.listTechnologies( @@ -314,14 +377,14 @@ export async function handleToolCall( toolName: string, args: unknown, server: any, -): Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }> { +): Promise { try { const handler = toolHandlers[toolName]; if (!handler) { throw new Error(`Unknown tool: ${toolName}`); } - return await handler(args, server) as { content: Array<{ type: string; text: string }>; isError?: boolean }; + return await handler(args, server); } catch (error) { // Return error response for validation errors and unknown tools return { @@ -332,4 +395,4 @@ export async function handleToolCall( isError: true, }; } -} \ No newline at end of file +} diff --git a/src/utils/cache.ts b/src/utils/cache.ts index fa21c0a..cdbca2e 100644 --- a/src/utils/cache.ts +++ b/src/utils/cache.ts @@ -172,6 +172,14 @@ export const technologyOverviewsCache = new MemoryCache( CACHE_SIZE.TECHNOLOGY_OVERVIEWS, CACHE_TTL.TECHNOLOGY_OVERVIEWS, ); +export const designContentCache = new MemoryCache( + CACHE_SIZE.DESIGN_CONTENT, + CACHE_TTL.DESIGN_CONTENT, +); +export const designResourcesCache = new MemoryCache( + CACHE_SIZE.DESIGN_RESOURCES, + CACHE_TTL.DESIGN_RESOURCES, +); export const wwdcDataCache = new MemoryCache(100, 30 * 60 * 1000); // 30 minutes TTL /** @@ -295,4 +303,3 @@ export function getCacheInstance( } return cacheInstances.get(name)!; } - diff --git a/src/utils/constants.ts b/src/utils/constants.ts index be9f181..9e2047e 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -44,6 +44,8 @@ export const CACHE_TTL = { UPDATES: 30 * 60 * 1000, // 30 minutes SAMPLE_CODE: 2 * 60 * 60 * 1000, // 2 hours TECHNOLOGY_OVERVIEWS: 2 * 60 * 60 * 1000, // 2 hours + DESIGN_CONTENT: 2 * 60 * 60 * 1000, // 2 hours + DESIGN_RESOURCES: 2 * 60 * 60 * 1000, // 2 hours } as const; // Cache Size Configuration @@ -55,6 +57,8 @@ export const CACHE_SIZE = { UPDATES: 100, SAMPLE_CODE: 100, TECHNOLOGY_OVERVIEWS: 100, + DESIGN_CONTENT: 100, + DESIGN_RESOURCES: 20, // Default cache configuration DEFAULT_CACHE_SIZE: 1000, @@ -233,6 +237,9 @@ export const APPLE_URLS = { TECHNOLOGY_OVERVIEWS_INDEX_JSON: 'https://developer.apple.com/tutorials/data/index/technologyoverviews', SAMPLE_CODE_JSON: 'https://developer.apple.com/tutorials/data/documentation/SampleCode.json', SAMPLE_CODE_INDEX_JSON: 'https://developer.apple.com/tutorials/data/index/samplecode', + DESIGN: 'https://developer.apple.com/design/', + DESIGN_RESOURCES: 'https://developer.apple.com/design/resources/', + DESIGN_HIG_JSON: 'https://developer.apple.com/tutorials/data/design/human-interface-guidelines.json', } as const; // WWDC URLs @@ -297,4 +304,4 @@ export const ERROR_MESSAGES = { CACHE_ERROR: 'Cache operation failed, but the request will continue.', VALIDATION_ERROR: 'Input validation failed. Please check your parameters.', SERVICE_UNAVAILABLE: 'Apple Developer Documentation service is temporarily unavailable.', -} as const; \ No newline at end of file +} as const; diff --git a/src/utils/url-converter.ts b/src/utils/url-converter.ts index 57a3241..1b46613 100644 --- a/src/utils/url-converter.ts +++ b/src/utils/url-converter.ts @@ -22,6 +22,11 @@ export function convertToJsonApiUrl(webUrl: string): string | null { return null; } + const designJsonUrl = convertToDesignJsonApiUrl(webUrl); + if (designJsonUrl) { + return designJsonUrl; + } + let path = urlObj.pathname; // For documentation URLs, format for the JSON API @@ -47,6 +52,34 @@ export function convertToJsonApiUrl(webUrl: string): string | null { } } +/** + * Convert a Human Interface Guidelines web URL to a JSON API URL + * @param webUrl The web URL to convert + * @returns The corresponding HIG JSON API URL + */ +export function convertToDesignJsonApiUrl(webUrl: string): string | null { + try { + const urlObj = new URL(webUrl); + if (urlObj.hostname !== 'developer.apple.com') { + return null; + } + + let path = urlObj.pathname; + if (path.endsWith('/')) { + path = path.slice(0, -1); + } + + if (path !== '/design/human-interface-guidelines' && !path.startsWith('/design/human-interface-guidelines/')) { + return null; + } + + const designPath = path.replace('/design/', ''); + return `https://developer.apple.com/tutorials/data/design/${designPath}.json`; + } catch { + return null; + } +} + /** * Validate if URL is from Apple Developer domain * @param url The URL to validate @@ -61,6 +94,20 @@ export function isValidAppleDeveloperUrl(url: string): boolean { } } +/** + * Validate if URL is an Apple Design URL + * @param url The URL to validate + * @returns True if valid Apple Design URL + */ +export function isAppleDesignUrl(url: string): boolean { + try { + const urlObj = new URL(url); + return urlObj.hostname === 'developer.apple.com' && urlObj.pathname.startsWith('/design/'); + } catch { + return false; + } +} + /** * Extract API name from URL * @param url The URL to extract name from @@ -72,4 +119,4 @@ export function extractApiNameFromUrl(url: string): string { } catch { return 'Unknown API'; } -} \ No newline at end of file +} diff --git a/tests/index.test.ts b/tests/index.test.ts index 4a58443..d53f19f 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -1,7 +1,12 @@ import { describe, it, expect, jest, beforeEach } from '@jest/globals'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { + CallToolRequestSchema, + ListResourcesRequestSchema, + ListToolsRequestSchema, + ReadResourceRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; // Mock all dependencies jest.mock('@modelcontextprotocol/sdk/server/index.js'); @@ -26,9 +31,48 @@ jest.mock('../src/tools/handlers.js', () => ({ content: [{ type: 'text', text: 'Test result' }], }), })); +jest.mock('../src/tools/design-docs.js', () => ({ + handleSearchAppleDesignDocs: jest.fn().mockResolvedValue({ + content: [{ type: 'text', text: 'Design search results' }], + }), + handleGetAppleDesignContent: jest.fn().mockResolvedValue({ + content: [{ type: 'text', text: 'Design content' }], + }), + handleListAppleDesignResources: jest.fn().mockResolvedValue({ + content: [{ type: 'text', text: 'Design resources' }], + }), + handleDownloadAppleDesignResource: jest.fn().mockResolvedValue({ + content: [{ type: 'text', text: 'Downloaded design resource' }], + }), + handleGetAppleDesignExamples: jest.fn().mockResolvedValue({ + content: [{ type: 'image', data: 'aW1hZ2U=', mimeType: 'image/png' }], + }), + listCachedDesignResources: jest.fn().mockResolvedValue({ + resources: [ + { + uri: 'apple-design://cache/test/example.zip', + name: 'example.zip', + mimeType: 'application/zip', + }, + ], + }), + readCachedDesignResource: jest.fn().mockResolvedValue({ + contents: [ + { + uri: 'apple-design://cache/test/example.zip', + mimeType: 'application/zip', + blob: 'emlw', + }, + ], + }), +})); // Import after mocks import { handleToolCall } from '../src/tools/handlers.js'; +import { + listCachedDesignResources, + readCachedDesignResource, +} from '../src/tools/design-docs.js'; describe('AppleDeveloperDocsMCPServer', () => { let mockServer: any; @@ -66,6 +110,7 @@ describe('AppleDeveloperDocsMCPServer', () => { { capabilities: { tools: {}, + resources: {}, }, } ); @@ -74,8 +119,8 @@ describe('AppleDeveloperDocsMCPServer', () => { it('should setup tools and error handling', () => { new AppleDeveloperDocsMCPServer(); - // Should register two request handlers - expect(mockServer.setRequestHandler).toHaveBeenCalledTimes(2); + // Should register tools and resources request handlers + expect(mockServer.setRequestHandler).toHaveBeenCalledTimes(4); // First call should be for ListToolsRequestSchema expect(mockServer.setRequestHandler).toHaveBeenNthCalledWith( @@ -90,6 +135,18 @@ describe('AppleDeveloperDocsMCPServer', () => { CallToolRequestSchema, expect.any(Function) ); + + expect(mockServer.setRequestHandler).toHaveBeenNthCalledWith( + 3, + ListResourcesRequestSchema, + expect.any(Function) + ); + + expect(mockServer.setRequestHandler).toHaveBeenNthCalledWith( + 4, + ReadResourceRequestSchema, + expect.any(Function) + ); }); }); @@ -187,6 +244,50 @@ describe('AppleDeveloperDocsMCPServer', () => { }); }); + describe('resource listing and reading', () => { + it('should list cached Apple Design resources', async () => { + new AppleDeveloperDocsMCPServer(); + + const listResourcesHandler = mockServer.setRequestHandler.mock.calls[2][1]; + const result = await listResourcesHandler({}); + + expect(listCachedDesignResources).toHaveBeenCalledWith(); + expect(result).toEqual({ + resources: [ + { + uri: 'apple-design://cache/test/example.zip', + name: 'example.zip', + mimeType: 'application/zip', + }, + ], + }); + }); + + it('should read cached Apple Design resources as blob contents', async () => { + new AppleDeveloperDocsMCPServer(); + + const readResourcesHandler = mockServer.setRequestHandler.mock.calls[3][1]; + const result = await readResourcesHandler({ + params: { + uri: 'apple-design://cache/test/example.zip', + }, + }); + + expect(readCachedDesignResource).toHaveBeenCalledWith( + 'apple-design://cache/test/example.zip', + ); + expect(result).toEqual({ + contents: [ + { + uri: 'apple-design://cache/test/example.zip', + mimeType: 'application/zip', + blob: 'emlw', + }, + ], + }); + }); + }); + describe('run method', () => { it('should create transport and start server', async () => { const server = new AppleDeveloperDocsMCPServer(); @@ -258,5 +359,18 @@ describe('AppleDeveloperDocsMCPServer', () => { expect(server.getSampleCode).toBeDefined(); expect(typeof server.getSampleCode).toBe('function'); }); + + it('should have Apple Design public methods', () => { + expect(server.searchAppleDesignDocs).toBeDefined(); + expect(typeof server.searchAppleDesignDocs).toBe('function'); + expect(server.getAppleDesignContent).toBeDefined(); + expect(typeof server.getAppleDesignContent).toBe('function'); + expect(server.listAppleDesignResources).toBeDefined(); + expect(typeof server.listAppleDesignResources).toBe('function'); + expect(server.downloadAppleDesignResource).toBeDefined(); + expect(typeof server.downloadAppleDesignResource).toBe('function'); + expect(server.getAppleDesignExamples).toBeDefined(); + expect(typeof server.getAppleDesignExamples).toBe('function'); + }); }); -}); \ No newline at end of file +}); diff --git a/tests/response-format.test.ts b/tests/response-format.test.ts index 2091dc5..9760e45 100644 --- a/tests/response-format.test.ts +++ b/tests/response-format.test.ts @@ -95,6 +95,60 @@ jest.mock('../src/tools/doc-fetcher.js', () => ({ }) })); +jest.mock('../src/tools/design-docs.js', () => ({ + handleSearchAppleDesignDocs: jest.fn().mockResolvedValue({ + content: [{ + type: 'text', + text: 'Mock Apple Design search results' + }] + }), + handleGetAppleDesignContent: jest.fn().mockResolvedValue({ + content: [{ + type: 'text', + text: 'Mock Apple Design content' + }] + }), + handleListAppleDesignResources: jest.fn().mockResolvedValue({ + content: [{ + type: 'text', + text: 'Mock Apple Design resources' + }] + }), + handleDownloadAppleDesignResource: jest.fn().mockResolvedValue({ + content: [ + { + type: 'text', + text: 'Mock downloaded Apple Design resource' + }, + { + type: 'resource_link', + uri: 'apple-design://cache/test/example.zip', + name: 'example.zip', + mimeType: 'application/zip' + } + ] + }), + handleGetAppleDesignExamples: jest.fn().mockResolvedValue({ + content: [ + { + type: 'text', + text: 'Mock Apple Design examples' + }, + { + type: 'image', + data: Buffer.from('image').toString('base64'), + mimeType: 'image/png' + } + ] + }), + listCachedDesignResources: jest.fn().mockResolvedValue({ + resources: [] + }), + readCachedDesignResource: jest.fn().mockResolvedValue({ + contents: [] + }) +})); + describe('Response Format Validation', () => { let server: AppleDeveloperDocsMCPServer; @@ -108,8 +162,10 @@ describe('Response Format Validation', () => { * { * content: [ * { - * type: 'text', - * text: string + * type: 'text' | 'image' | 'resource_link' | 'resource', + * text?: string, + * data?: string, + * uri?: string * } * ], * isError?: boolean @@ -122,13 +178,30 @@ describe('Response Format Validation', () => { response.content.forEach((item: any) => { expect(item).toHaveProperty('type'); - expect(item.type).toBe('text'); - expect(item).toHaveProperty('text'); - expect(typeof item.text).toBe('string'); - - // Ensure text is not a nested object (the main issue we're preventing) - expect(typeof item.text).not.toBe('object'); - expect(item.text).not.toHaveProperty('content'); + expect(['text', 'image', 'resource_link', 'resource']).toContain(item.type); + + if (item.type === 'text') { + expect(item).toHaveProperty('text'); + expect(typeof item.text).toBe('string'); + + // Ensure text is not a nested object (the main issue we're preventing) + expect(typeof item.text).not.toBe('object'); + expect(item.text).not.toHaveProperty('content'); + } + + if (item.type === 'image') { + expect(item).toHaveProperty('data'); + expect(item).toHaveProperty('mimeType'); + expect(typeof item.data).toBe('string'); + expect(typeof item.mimeType).toBe('string'); + } + + if (item.type === 'resource_link') { + expect(item).toHaveProperty('uri'); + expect(item).toHaveProperty('name'); + expect(typeof item.uri).toBe('string'); + expect(typeof item.name).toBe('string'); + } }); }; @@ -192,6 +265,26 @@ describe('Response Format Validation', () => { name: 'getSampleCode', method: () => server.getSampleCode(), }, + { + name: 'searchAppleDesignDocs', + method: () => server.searchAppleDesignDocs('layout'), + }, + { + name: 'getAppleDesignContent', + method: () => server.getAppleDesignContent('https://developer.apple.com/design/human-interface-guidelines/layout'), + }, + { + name: 'listAppleDesignResources', + method: () => server.listAppleDesignResources(), + }, + { + name: 'downloadAppleDesignResource', + method: () => server.downloadAppleDesignResource(undefined, 'https://developer.apple.com/design/downloads/templates.zip'), + }, + { + name: 'getAppleDesignExamples', + method: () => server.getAppleDesignExamples('https://developer.apple.com/design/human-interface-guidelines/layout'), + }, ]; toolTests.forEach(({ name, method }) => { @@ -202,6 +295,15 @@ describe('Response Format Validation', () => { expect(typeof response.content[0].text).toBe('string'); }); }); + + it('should delegate Apple Design URLs from getAppleDocContent without nesting', async () => { + const response = await server.getAppleDocContent( + 'https://developer.apple.com/design/human-interface-guidelines/layout' + ); + + validateResponseFormat(response); + expect(response.content[0].text).toContain('Mock Apple Design content'); + }); }); describe('Error response format consistency', () => { @@ -353,4 +455,4 @@ describe('Response Format Validation', () => { expect(response.content[0].text).toBeDefined(); }); }); -}); \ No newline at end of file +}); diff --git a/tests/tools/design-docs.test.ts b/tests/tools/design-docs.test.ts new file mode 100644 index 0000000..a7ab016 --- /dev/null +++ b/tests/tools/design-docs.test.ts @@ -0,0 +1,470 @@ +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { + clearDesignResourceCacheForTesting, + formatAppleDesignDocument, + handleDownloadAppleDesignResource, + handleGetAppleDesignExamples, + listCachedDesignResources, + parseAppleDesignHtmlPage, + parseDesignResourcesHtml, + readCachedDesignResource, +} from '../../src/tools/design-docs.js'; +import { httpClient } from '../../src/utils/http-client.js'; + +jest.mock('../../src/utils/http-client.js', () => ({ + httpClient: { + get: jest.fn(), + getJson: jest.fn(), + getText: jest.fn(), + }, +})); + +const SAMPLE_HIG_DOCUMENT = { + metadata: { + title: 'Layout', + role: 'article', + abstract: [ + { + type: 'text', + text: 'Place content in ways that help people understand your interface.', + }, + ], + }, + abstract: [ + { + type: 'text', + text: 'Arrange views so people can understand and interact with them.', + }, + ], + customMetadata: { + 'supported-platforms': [ + 'iOS', + 'iPadOS', + 'macOS', + ], + }, + references: { + 'doc://image/layout-hero': { + type: 'image', + alt: 'A layout example', + variants: [ + { + url: '/assets/elements/icons/layout-hero.png', + }, + ], + }, + 'doc://topic/color': { + title: 'Color', + url: '/design/human-interface-guidelines/color', + abstract: [ + { + type: 'text', + text: 'Use color consistently.', + }, + ], + }, + }, + primaryContentSections: [ + { + kind: 'content', + content: [ + { + type: 'heading', + text: 'Best practices', + level: 2, + }, + { + type: 'paragraph', + inlineContent: [ + { + type: 'text', + text: 'Use a ', + }, + { + type: 'strong', + inlineContent: [ + { + type: 'text', + text: 'consistent', + }, + ], + }, + { + type: 'text', + text: ' grid.', + }, + ], + }, + { + type: 'image', + identifier: 'doc://image/layout-hero', + }, + { + type: 'unorderedList', + items: [ + { + content: [ + { + type: 'paragraph', + inlineContent: [ + { + type: 'text', + text: 'Align related controls.', + }, + ], + }, + ], + }, + ], + }, + { + type: 'table', + header: [ + { + inlineContent: [ + { + type: 'text', + text: 'Platform', + }, + ], + }, + { + inlineContent: [ + { + type: 'text', + text: 'Spacing', + }, + ], + }, + ], + rows: [ + { + cells: [ + { + inlineContent: [ + { + type: 'text', + text: 'iOS', + }, + ], + }, + { + inlineContent: [ + { + type: 'text', + text: '8 pt', + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + topicSections: [ + { + title: 'Foundations', + identifiers: [ + 'doc://topic/color', + ], + }, + ], + sections: [ + { + title: 'Change log', + kind: 'changes', + items: [ + { + date: '2026-06-09', + content: [ + { + type: 'paragraph', + inlineContent: [ + { + type: 'text', + text: 'Updated platform guidance.', + }, + ], + }, + ], + }, + ], + }, + ], +}; + +const SAMPLE_RESOURCES_HTML = ` +
+
+

Design templates

+

iOS 18 and iPadOS 18

+
+ iOS template +
iOS 18 and iPadOS 18
+ Figma + Sketch Library + Download +

Requires macOS Sonoma or later.

+

Layout

+
+
+
+

Fonts

+

System fonts

+
+ +
SF Pro
+ Download +
+
+
+`; + +let temporaryCacheDirectory: string | undefined; + +function createResponse( + data: Buffer, + contentType: string | undefined, + contentLength?: number, +): Response { + const headers = new Headers(); + if (contentType) { + headers.set('content-type', contentType); + } + if (contentLength !== undefined) { + headers.set('content-length', String(contentLength)); + } else { + headers.set('content-length', String(data.length)); + } + + return new Response(data, { + status: 200, + headers, + }); +} + +beforeEach(async () => { + temporaryCacheDirectory = await mkdtemp(path.join(tmpdir(), 'apple-design-test-')); + process.env.APPLE_DOCS_MCP_CACHE_DIR = temporaryCacheDirectory; + clearDesignResourceCacheForTesting(); + jest.clearAllMocks(); +}); + +afterEach(async () => { + clearDesignResourceCacheForTesting(); + delete process.env.APPLE_DOCS_MCP_CACHE_DIR; + + if (temporaryCacheDirectory) { + await rm(temporaryCacheDirectory, { + force: true, + recursive: true, + }); + } +}); + +describe('Apple Design document formatting', () => { + it('should format HIG JSON content with images, tables, links, platforms, and change logs', () => { + const result = formatAppleDesignDocument( + SAMPLE_HIG_DOCUMENT, + 'https://developer.apple.com/design/human-interface-guidelines/layout', + ); + + expect(result).toContain('# Layout'); + expect(result).toContain('Arrange views so people can understand and interact with them.'); + expect(result).toContain('## Supported Platforms'); + expect(result).toContain('iOS'); + expect(result).toContain('**consistent** grid.'); + expect(result).toContain('![A layout example](https://developer.apple.com/assets/elements/icons/layout-hero.png)'); + expect(result).toContain('| Platform | Spacing |'); + expect(result).toContain('- [Color](https://developer.apple.com/design/human-interface-guidelines/color)'); + expect(result).toContain('## Change Log'); + expect(result).toContain('2026-06-09'); + }); + + it('should parse Apple Design HTML cards when JSON is not available', () => { + const html = ` +
+

Apple Design

+

Design great apps and games.

+ +

Design resources

+

Download templates, components, and tools.

+
+
+ `; + + const result = parseAppleDesignHtmlPage(html, 'https://developer.apple.com/design/'); + + expect(result).toContain('# Apple Design'); + expect(result).toContain('Design great apps and games.'); + expect(result).toContain('[Design resources](https://developer.apple.com/design/resources/)'); + }); +}); + +describe('Apple Design Resources parser', () => { + it('should extract templates, fonts, notes, previews, formats, and related HIG links', () => { + const resources = parseDesignResourcesHtml( + SAMPLE_RESOURCES_HTML, + 'https://developer.apple.com/design/resources/', + ); + + expect(resources).toHaveLength(4); + expect(resources[0]).toMatchObject({ + resourceId: 'design-resource:design-templates:ios-18-and-ipados-18:figma', + category: 'Design templates', + platform: 'iOS 18 and iPadOS 18', + title: 'iOS 18 and iPadOS 18', + format: 'figma', + downloadLabel: 'Figma', + downloadUrl: 'https://www.figma.com/community/file/123', + note: 'Requires macOS Sonoma or later. Layout', + previewImageUrl: 'https://developer.apple.com/assets/elements/icons/ios-template.png', + }); + expect(resources[0].relatedGuidelineLinks).toEqual([ + 'https://developer.apple.com/design/human-interface-guidelines/layout', + ]); + expect(resources[1]).toMatchObject({ + format: 'sketch', + downloadUrl: 'sketch://add-library?url=https://example.com/library.sketch', + }); + expect(resources[2]).toMatchObject({ + format: 'dmg', + downloadUrl: 'https://developer.apple.com/design/downloads/iOS-18.dmg', + }); + expect(resources[3]).toMatchObject({ + category: 'Fonts', + format: 'zip', + title: 'SF Pro', + }); + }); +}); + +describe('Apple Design downloads and resources', () => { + it('should download direct images, return image content, and expose BlobResourceContents', async () => { + const imageBytes = Buffer.from('image-bytes'); + (httpClient.get as jest.Mock).mockResolvedValue( + createResponse(imageBytes, 'image/png'), + ); + + const result = await handleDownloadAppleDesignResource({ + url: 'https://developer.apple.com/design/images/example.png', + }); + + const imageContent = result.content.find(content => content.type === 'image'); + const resourceLink = result.content.find(content => content.type === 'resource_link'); + + expect(imageContent).toMatchObject({ + type: 'image', + data: imageBytes.toString('base64'), + mimeType: 'image/png', + }); + expect(resourceLink).toMatchObject({ + type: 'resource_link', + mimeType: 'image/png', + name: 'example.png', + }); + + const resources = await listCachedDesignResources(); + expect(resources.resources).toHaveLength(1); + + const resource = await readCachedDesignResource(resourceLink?.uri ?? ''); + expect(resource.contents[0]).toMatchObject({ + uri: resourceLink?.uri, + mimeType: 'image/png', + blob: imageBytes.toString('base64'), + }); + }); + + it('should return resource links for non-image downloads with MIME fallback', async () => { + const archiveBytes = Buffer.from('zip-bytes'); + (httpClient.get as jest.Mock).mockResolvedValue( + createResponse(archiveBytes, undefined), + ); + + const result = await handleDownloadAppleDesignResource({ + url: 'https://developer.apple.com/design/downloads/templates.zip', + }); + + expect(result.content).toEqual([ + expect.objectContaining({ + type: 'text', + text: expect.stringContaining('Downloaded Apple Design resource'), + }), + expect.objectContaining({ + type: 'resource_link', + mimeType: 'application/zip', + name: 'templates.zip', + }), + ]); + }); + + it('should reject blocked download domains', async () => { + await expect(handleDownloadAppleDesignResource({ + url: 'https://example.com/design-kit.zip', + })).rejects.toThrow('not allowed'); + }); + + it('should reject oversized downloads before reading the body', async () => { + const archiveBytes = Buffer.from('zip-bytes'); + (httpClient.get as jest.Mock).mockResolvedValue( + createResponse(archiveBytes, 'application/zip', 11), + ); + + await expect(handleDownloadAppleDesignResource({ + url: 'https://developer.apple.com/design/downloads/templates.zip', + maxBytes: 10, + })).rejects.toThrow('exceeds'); + }); + + it('should reuse duplicate cache hits for the same URL', async () => { + const archiveBytes = Buffer.from('zip-bytes'); + (httpClient.get as jest.Mock).mockResolvedValue( + createResponse(archiveBytes, 'application/zip'), + ); + + const firstResult = await handleDownloadAppleDesignResource({ + url: 'https://developer.apple.com/design/downloads/templates.zip', + }); + const secondResult = await handleDownloadAppleDesignResource({ + url: 'https://developer.apple.com/design/downloads/templates.zip', + }); + + const firstLink = firstResult.content.find(content => content.type === 'resource_link'); + const secondLink = secondResult.content.find(content => content.type === 'resource_link'); + + expect(httpClient.get).toHaveBeenCalledTimes(1); + expect(firstLink?.uri).toBe(secondLink?.uri); + }); +}); + +describe('Apple Design examples', () => { + it('should return HIG image references as MCP image content blocks', async () => { + const imageBytes = Buffer.from('hig-image'); + (httpClient.getJson as jest.Mock).mockResolvedValue(SAMPLE_HIG_DOCUMENT); + (httpClient.get as jest.Mock).mockResolvedValue( + createResponse(imageBytes, 'image/png'), + ); + + const result = await handleGetAppleDesignExamples({ + url: 'https://developer.apple.com/design/human-interface-guidelines/layout', + limit: 1, + }); + + expect(result.content).toEqual([ + expect.objectContaining({ + type: 'text', + text: expect.stringContaining('Apple Design Examples'), + }), + expect.objectContaining({ + type: 'image', + data: imageBytes.toString('base64'), + mimeType: 'image/png', + }), + ]); + }); +}); diff --git a/tests/tools/handlers.test.ts b/tests/tools/handlers.test.ts index 696dd49..01c8104 100644 --- a/tests/tools/handlers.test.ts +++ b/tests/tools/handlers.test.ts @@ -70,6 +70,36 @@ describe('Tool Handlers', () => { getSampleCode: jest.fn().mockResolvedValue({ content: [{ type: 'text', text: 'Sample code' }], }), + searchAppleDesignDocs: jest.fn().mockResolvedValue({ + content: [{ type: 'text', text: 'Design search results' }], + }), + getAppleDesignContent: jest.fn().mockResolvedValue({ + content: [{ type: 'text', text: 'Design content' }], + }), + listAppleDesignResources: jest.fn().mockResolvedValue({ + content: [{ type: 'text', text: 'Design resources' }], + }), + downloadAppleDesignResource: jest.fn().mockResolvedValue({ + content: [ + { type: 'text', text: 'Downloaded resource' }, + { + type: 'resource_link', + uri: 'apple-design://cache/test/example.zip', + name: 'example.zip', + mimeType: 'application/zip', + }, + ], + }), + getAppleDesignExamples: jest.fn().mockResolvedValue({ + content: [ + { type: 'text', text: 'Design examples' }, + { + type: 'image', + data: Buffer.from('image').toString('base64'), + mimeType: 'image/png', + }, + ], + }), }; }); @@ -110,6 +140,86 @@ describe('Tool Handlers', () => { }); }); + it('should handle search_apple_design_docs tool', async () => { + const args = { query: 'layout', contentType: 'all', platform: 'iOS', limit: 5 }; + const result = await handleToolCall('search_apple_design_docs', args, mockServer); + + expect(mockServer.searchAppleDesignDocs).toHaveBeenCalledWith('layout', 'all', 'iOS', 5); + expect(result).toEqual({ + content: [{ type: 'text', text: 'Design search results' }], + }); + }); + + it('should handle get_apple_design_content tool', async () => { + const args = { url: 'https://developer.apple.com/design/human-interface-guidelines/layout' }; + const result = await handleToolCall('get_apple_design_content', args, mockServer); + + expect(mockServer.getAppleDesignContent).toHaveBeenCalledWith(args.url); + expect(result).toEqual({ + content: [{ type: 'text', text: 'Design content' }], + }); + }); + + it('should handle list_apple_design_resources tool', async () => { + const args = { + category: 'Design templates', + platform: 'iOS', + format: 'figma', + searchQuery: 'template', + limit: 10, + }; + const result = await handleToolCall('list_apple_design_resources', args, mockServer); + + expect(mockServer.listAppleDesignResources).toHaveBeenCalledWith( + 'Design templates', + 'iOS', + 'figma', + 'template', + 10, + ); + expect(result).toEqual({ + content: [{ type: 'text', text: 'Design resources' }], + }); + }); + + it('should handle download_apple_design_resource tool with resource links', async () => { + const args = { resourceId: 'design-resource:templates:ios:download' }; + const result = await handleToolCall('download_apple_design_resource', args, mockServer); + + expect(mockServer.downloadAppleDesignResource).toHaveBeenCalledWith( + 'design-resource:templates:ios:download', + undefined, + undefined, + ); + expect(result.content).toEqual([ + { type: 'text', text: 'Downloaded resource' }, + { + type: 'resource_link', + uri: 'apple-design://cache/test/example.zip', + name: 'example.zip', + mimeType: 'application/zip', + }, + ]); + }); + + it('should handle get_apple_design_examples tool with image blocks', async () => { + const args = { + url: 'https://developer.apple.com/design/human-interface-guidelines/layout', + limit: 1, + }; + const result = await handleToolCall('get_apple_design_examples', args, mockServer); + + expect(mockServer.getAppleDesignExamples).toHaveBeenCalledWith(args.url, undefined, undefined, 1); + expect(result.content).toEqual([ + { type: 'text', text: 'Design examples' }, + { + type: 'image', + data: Buffer.from('image').toString('base64'), + mimeType: 'image/png', + }, + ]); + }); + it('should handle validation errors gracefully', async () => { // Mock schema parse to throw validation error jest.spyOn(schemas.searchAppleDocsSchema, 'parse').mockImplementationOnce(() => { @@ -136,6 +246,11 @@ describe('Tool Handlers', () => { 'get_documentation_updates', 'get_technology_overviews', 'get_sample_code', + 'search_apple_design_docs', + 'get_apple_design_content', + 'list_apple_design_resources', + 'download_apple_design_resource', + 'get_apple_design_examples', ]; expectedTools.forEach(tool => { @@ -206,4 +321,4 @@ describe('Tool Handlers', () => { ); }); }); -}); \ No newline at end of file +}); diff --git a/tests/utils/url-converter.test.ts b/tests/utils/url-converter.test.ts index 62edf76..87d73fb 100644 --- a/tests/utils/url-converter.test.ts +++ b/tests/utils/url-converter.test.ts @@ -2,7 +2,13 @@ * Tests for URL converter utilities */ -import { convertToJsonApiUrl, isValidAppleDeveloperUrl, extractApiNameFromUrl } from '../../src/utils/url-converter.js'; +import { + convertToDesignJsonApiUrl, + convertToJsonApiUrl, + extractApiNameFromUrl, + isAppleDesignUrl, + isValidAppleDeveloperUrl, +} from '../../src/utils/url-converter.js'; describe('URL Converter', () => { describe('convertToJsonApiUrl', () => { @@ -32,6 +38,38 @@ describe('URL Converter', () => { expect(convertToJsonApiUrl(webUrl)).toBe(webUrl); }); + + it('should convert Human Interface Guidelines root URL to JSON API URL', () => { + const webUrl = 'https://developer.apple.com/design/human-interface-guidelines'; + const expected = 'https://developer.apple.com/tutorials/data/design/human-interface-guidelines.json'; + + expect(convertToJsonApiUrl(webUrl)).toBe(expected); + expect(convertToDesignJsonApiUrl(webUrl)).toBe(expected); + }); + + it('should convert Human Interface Guidelines child URL to JSON API URL', () => { + const webUrl = 'https://developer.apple.com/design/human-interface-guidelines/layout'; + const expected = 'https://developer.apple.com/tutorials/data/design/human-interface-guidelines/layout.json'; + + expect(convertToJsonApiUrl(webUrl)).toBe(expected); + expect(convertToDesignJsonApiUrl(webUrl)).toBe(expected); + }); + + it('should convert Human Interface Guidelines URL with trailing slash', () => { + const webUrl = 'https://developer.apple.com/design/human-interface-guidelines/layout/'; + const expected = 'https://developer.apple.com/tutorials/data/design/human-interface-guidelines/layout.json'; + + expect(convertToJsonApiUrl(webUrl)).toBe(expected); + expect(convertToDesignJsonApiUrl(webUrl)).toBe(expected); + }); + + it('should convert Human Interface Guidelines URL with anchor', () => { + const webUrl = 'https://developer.apple.com/design/human-interface-guidelines/layout#columns'; + const expected = 'https://developer.apple.com/tutorials/data/design/human-interface-guidelines/layout.json'; + + expect(convertToJsonApiUrl(webUrl)).toBe(expected); + expect(convertToDesignJsonApiUrl(webUrl)).toBe(expected); + }); }); describe('isValidAppleDeveloperUrl', () => { @@ -61,6 +99,33 @@ describe('URL Converter', () => { }); }); + describe('isAppleDesignUrl', () => { + it('should return true for Apple Design URLs', () => { + const validUrls = [ + 'https://developer.apple.com/design/', + 'https://developer.apple.com/design/resources/', + 'https://developer.apple.com/design/human-interface-guidelines/layout', + ]; + + validUrls.forEach(url => { + expect(isAppleDesignUrl(url)).toBe(true); + }); + }); + + it('should return false for non-design URLs', () => { + const invalidUrls = [ + 'https://developer.apple.com/documentation/swiftui', + 'https://developer.apple.com/news/', + 'https://apple.com/design/', + 'not-a-url', + ]; + + invalidUrls.forEach(url => { + expect(isAppleDesignUrl(url)).toBe(false); + }); + }); + }); + describe('extractApiNameFromUrl', () => { it('should extract API name from URL', () => { const testCases = [ @@ -92,4 +157,4 @@ describe('URL Converter', () => { expect(extractApiNameFromUrl('not-a-url')).toBe('Unknown API'); }); }); -}); \ No newline at end of file +}); From bcf233097d30a0dcdbb7c6f455074254f1fe1310 Mon Sep 17 00:00:00 2001 From: Alexander Goodkind Date: Mon, 29 Jun 2026 18:00:17 -0700 Subject: [PATCH 02/19] Read Apple Design metadata from live HIG payloads --- src/tools/design-docs.ts | 16 ++++++++++++++-- tests/tools/design-docs.test.ts | 18 +++++++++++------- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/tools/design-docs.ts b/src/tools/design-docs.ts index a7af054..3f1fa7f 100644 --- a/src/tools/design-docs.ts +++ b/src/tools/design-docs.ts @@ -1423,16 +1423,28 @@ function renderInlineContent( } function getSupportedPlatforms(documentRecord: Record): string[] { - const customMetadata = asRecord(documentRecord.customMetadata); + const customMetadata = getDesignCustomMetadata(documentRecord); const supportedPlatforms = customMetadata?.['supported-platforms'] ?? customMetadata?.supportedPlatforms; return getStringArray(supportedPlatforms); } function getCustomMetadataString(documentRecord: Record, key: string): string | undefined { - const customMetadata = asRecord(documentRecord.customMetadata); + const customMetadata = getDesignCustomMetadata(documentRecord); return getString(customMetadata, key); } +function getDesignCustomMetadata( + documentRecord: Record, +): Record | undefined { + const rootCustomMetadata = asRecord(documentRecord.customMetadata); + if (rootCustomMetadata) { + return rootCustomMetadata; + } + + const metadata = asRecord(documentRecord.metadata); + return asRecord(metadata?.customMetadata); +} + function resolveImageBlockUrl( block: Record, references: Map>, diff --git a/tests/tools/design-docs.test.ts b/tests/tools/design-docs.test.ts index a7ab016..1eb2247 100644 --- a/tests/tools/design-docs.test.ts +++ b/tests/tools/design-docs.test.ts @@ -32,6 +32,15 @@ const SAMPLE_HIG_DOCUMENT = { text: 'Place content in ways that help people understand your interface.', }, ], + customMetadata: { + 'supported-platforms': [ + 'iOS', + 'iPadOS', + 'macOS', + ], + 'alert-text': 'Use layout guidance with platform conventions.', + 'alert-date': '2026-06-09', + }, }, abstract: [ { @@ -39,13 +48,6 @@ const SAMPLE_HIG_DOCUMENT = { text: 'Arrange views so people can understand and interact with them.', }, ], - customMetadata: { - 'supported-platforms': [ - 'iOS', - 'iPadOS', - 'macOS', - ], - }, references: { 'doc://image/layout-hero': { type: 'image', @@ -278,6 +280,8 @@ describe('Apple Design document formatting', () => { expect(result).toContain('Arrange views so people can understand and interact with them.'); expect(result).toContain('## Supported Platforms'); expect(result).toContain('iOS'); + expect(result).toContain('## Alert'); + expect(result).toContain('Use layout guidance with platform conventions.'); expect(result).toContain('**consistent** grid.'); expect(result).toContain('![A layout example](https://developer.apple.com/assets/elements/icons/layout-hero.png)'); expect(result).toContain('| Platform | Spacing |'); From 25409d26596e55dc91490757d7e6b79dcdb4ce82 Mon Sep 17 00:00:00 2001 From: Alex Goodkind Date: Mon, 29 Jun 2026 18:07:31 -0700 Subject: [PATCH 03/19] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/utils/url-converter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/url-converter.ts b/src/utils/url-converter.ts index 1b46613..544ccee 100644 --- a/src/utils/url-converter.ts +++ b/src/utils/url-converter.ts @@ -102,7 +102,7 @@ export function isValidAppleDeveloperUrl(url: string): boolean { export function isAppleDesignUrl(url: string): boolean { try { const urlObj = new URL(url); - return urlObj.hostname === 'developer.apple.com' && urlObj.pathname.startsWith('/design/'); + return urlObj.hostname === 'developer.apple.com' && (urlObj.pathname === '/design' || urlObj.pathname.startsWith('/design/')); } catch { return false; } From ab883ee7d680e35e786646b1f8e36cc68c3c709c Mon Sep 17 00:00:00 2001 From: Alexander Goodkind Date: Mon, 29 Jun 2026 21:39:52 -0700 Subject: [PATCH 04/19] Harden Apple Design URL and preview handling --- src/tools/design-docs.ts | 106 ++++++++++++++++++++++++++---- src/utils/url-converter.ts | 3 +- tests/tools/design-docs.test.ts | 76 +++++++++++++++++++++ tests/utils/url-converter.test.ts | 1 + 4 files changed, 173 insertions(+), 13 deletions(-) diff --git a/src/tools/design-docs.ts b/src/tools/design-docs.ts index 3f1fa7f..7ae4325 100644 --- a/src/tools/design-docs.ts +++ b/src/tools/design-docs.ts @@ -24,6 +24,7 @@ const APPLE_DESIGN_DOWNLOAD_HOSTS = new Set([ 'itunespartner.apple.com', ]); const DEFAULT_DOWNLOAD_MAX_BYTES = 50 * 1024 * 1024; +const DEFAULT_PREVIEW_IMAGE_MAX_BYTES = 10 * 1024 * 1024; const DIRECT_RESOURCE_EXTENSIONS = new Set([ '.dmg', '.fig', @@ -318,6 +319,7 @@ export async function handleGetAppleDesignContent( args: { url: string } | string, ): Promise { const url = typeof args === 'string' ? args : args.url; + validateAppleDesignContentUrl(url); const content = await fetchAppleDesignContent(url); return { content: [ @@ -774,16 +776,11 @@ async function downloadDesignResource( }, }); - const contentLength = getContentLength(response); - if (contentLength !== undefined && contentLength > byteLimit) { - throw new Error(`Apple Design resource exceeds the ${byteLimit} byte download limit.`); - } - - const arrayBuffer = await response.arrayBuffer(); - const data = Buffer.from(arrayBuffer); - if (data.length > byteLimit) { - throw new Error(`Apple Design resource exceeds the ${byteLimit} byte download limit.`); - } + const data = await readLimitedResponseBytes( + response, + byteLimit, + 'Apple Design resource', + ); const mimeType = detectMimeType(response.headers.get('content-type'), normalizedSourceUrl); const hash = hashBuffer(data); @@ -879,6 +876,7 @@ async function collectImageCandidates(args: GetAppleDesignExamplesArgs): Promise if (args.url) { const url = args.url; + validateAppleDesignExampleUrl(url); if (isDirectImageUrl(url)) { candidates.push({ url }); } else { @@ -898,7 +896,11 @@ async function collectImageCandidates(args: GetAppleDesignExamplesArgs): Promise async function fetchImageContent(url: string): Promise { const parsedUrl = safeUrl(url); - if (!parsedUrl || !APPLE_DESIGN_DOWNLOAD_HOSTS.has(parsedUrl.hostname)) { + if ( + !parsedUrl + || parsedUrl.protocol !== 'https:' + || !APPLE_DESIGN_DOWNLOAD_HOSTS.has(parsedUrl.hostname) + ) { return null; } @@ -913,7 +915,11 @@ async function fetchImageContent(url: string): Promise { return null; } - const data = Buffer.from(await response.arrayBuffer()); + const data = await readLimitedResponseBytes( + response, + DEFAULT_PREVIEW_IMAGE_MAX_BYTES, + 'Apple Design image preview', + ); return { type: 'image', data: data.toString('base64'), @@ -1622,6 +1628,38 @@ function isDesignResourcesUrl(url: string): boolean { return parsedUrl?.hostname === 'developer.apple.com' && parsedUrl.pathname.startsWith('/design/resources'); } +function validateAppleDesignContentUrl(url: string): void { + if (!isAppleDesignContentUrl(url)) { + throw new Error('Apple Design content URLs must use https://developer.apple.com/design/.'); + } +} + +function validateAppleDesignExampleUrl(url: string): void { + if (isDirectImageUrl(url)) { + validateAppleDesignImageUrl(url); + return; + } + + validateAppleDesignContentUrl(url); +} + +function validateAppleDesignImageUrl(url: string): void { + const parsedUrl = safeUrl(url); + if (!parsedUrl || parsedUrl.protocol !== 'https:' || !APPLE_DESIGN_DOWNLOAD_HOSTS.has(parsedUrl.hostname)) { + throw new Error('Apple Design image URLs must use HTTPS and an allowed Apple host.'); + } +} + +function isAppleDesignContentUrl(url: string): boolean { + const parsedUrl = safeUrl(url); + return Boolean( + parsedUrl + && parsedUrl.protocol === 'https:' + && parsedUrl.hostname === 'developer.apple.com' + && (parsedUrl.pathname === '/design' || parsedUrl.pathname.startsWith('/design/')), + ); +} + function isDirectAppleDownloadUrl(url: string): boolean { const parsedUrl = safeUrl(url); return Boolean(parsedUrl && parsedUrl.protocol === 'https:' && APPLE_DESIGN_DOWNLOAD_HOSTS.has(parsedUrl.hostname)); @@ -1708,6 +1746,50 @@ function getContentLength(response: Response): number | undefined { return contentLength; } +async function readLimitedResponseBytes( + response: Response, + byteLimit: number, + description: string, +): Promise { + const contentLength = getContentLength(response); + if (contentLength !== undefined && contentLength > byteLimit) { + throw new Error(`${description} exceeds the ${byteLimit} byte download limit.`); + } + + if (!response.body) { + const data = Buffer.from(await response.arrayBuffer()); + if (data.length > byteLimit) { + throw new Error(`${description} exceeds the ${byteLimit} byte download limit.`); + } + return data; + } + + const reader = response.body.getReader(); + const chunks: Buffer[] = []; + let totalBytes = 0; + + try { + while (true) { + const result = await reader.read(); + if (result.done) { + break; + } + + totalBytes += result.value.byteLength; + if (totalBytes > byteLimit) { + await reader.cancel().catch(() => undefined); + throw new Error(`${description} exceeds the ${byteLimit} byte download limit.`); + } + + chunks.push(Buffer.from(result.value)); + } + } finally { + reader.releaseLock(); + } + + return Buffer.concat(chunks, totalBytes); +} + function getDesignCacheDirectory(): string { return process.env.APPLE_DOCS_MCP_CACHE_DIR || path.join(tmpdir(), 'apple-docs-mcp', 'design-resources'); diff --git a/src/utils/url-converter.ts b/src/utils/url-converter.ts index 544ccee..ae29f66 100644 --- a/src/utils/url-converter.ts +++ b/src/utils/url-converter.ts @@ -102,7 +102,8 @@ export function isValidAppleDeveloperUrl(url: string): boolean { export function isAppleDesignUrl(url: string): boolean { try { const urlObj = new URL(url); - return urlObj.hostname === 'developer.apple.com' && (urlObj.pathname === '/design' || urlObj.pathname.startsWith('/design/')); + return urlObj.hostname === 'developer.apple.com' + && (urlObj.pathname === '/design' || urlObj.pathname.startsWith('/design/')); } catch { return false; } diff --git a/tests/tools/design-docs.test.ts b/tests/tools/design-docs.test.ts index 1eb2247..61d0007 100644 --- a/tests/tools/design-docs.test.ts +++ b/tests/tools/design-docs.test.ts @@ -6,6 +6,7 @@ import { clearDesignResourceCacheForTesting, formatAppleDesignDocument, handleDownloadAppleDesignResource, + handleGetAppleDesignContent, handleGetAppleDesignExamples, listCachedDesignResources, parseAppleDesignHtmlPage, @@ -270,6 +271,15 @@ afterEach(async () => { }); describe('Apple Design document formatting', () => { + it('should reject non-Apple Design content URLs before fetching', async () => { + await expect(handleGetAppleDesignContent({ + url: 'https://example.com/design/', + })).rejects.toThrow('Apple Design content URLs'); + + expect(httpClient.getText).not.toHaveBeenCalled(); + expect(httpClient.getJson).not.toHaveBeenCalled(); + }); + it('should format HIG JSON content with images, tables, links, platforms, and change logs', () => { const result = formatAppleDesignDocument( SAMPLE_HIG_DOCUMENT, @@ -447,6 +457,24 @@ describe('Apple Design downloads and resources', () => { }); describe('Apple Design examples', () => { + it('should reject non-Apple example page URLs before fetching', async () => { + await expect(handleGetAppleDesignExamples({ + url: 'https://example.com/design/example-page', + })).rejects.toThrow('Apple Design content URLs'); + + expect(httpClient.getText).not.toHaveBeenCalled(); + expect(httpClient.get).not.toHaveBeenCalled(); + }); + + it('should reject direct image URLs from non-Apple hosts before fetching', async () => { + await expect(handleGetAppleDesignExamples({ + url: 'https://example.com/design/example.png', + })).rejects.toThrow('allowed Apple host'); + + expect(httpClient.getText).not.toHaveBeenCalled(); + expect(httpClient.get).not.toHaveBeenCalled(); + }); + it('should return HIG image references as MCP image content blocks', async () => { const imageBytes = Buffer.from('hig-image'); (httpClient.getJson as jest.Mock).mockResolvedValue(SAMPLE_HIG_DOCUMENT); @@ -471,4 +499,52 @@ describe('Apple Design examples', () => { }), ]); }); + + it('should return direct Apple image URLs as MCP image content blocks', async () => { + const imageBytes = Buffer.from('preview-image'); + (httpClient.get as jest.Mock).mockResolvedValue( + createResponse(imageBytes, 'image/png'), + ); + + const result = await handleGetAppleDesignExamples({ + url: 'https://developer.apple.com/design/images/example.png', + limit: 1, + }); + + expect(result.content).toEqual([ + expect.objectContaining({ + type: 'text', + text: expect.stringContaining('Apple Design Examples'), + }), + expect.objectContaining({ + type: 'image', + data: imageBytes.toString('base64'), + mimeType: 'image/png', + }), + ]); + expect(httpClient.get).toHaveBeenCalledWith( + 'https://developer.apple.com/design/images/example.png', + expect.any(Object), + ); + }); + + it('should reject oversized direct image examples before reading the body', async () => { + const arrayBuffer = jest.fn(); + const oversizedResponse = { + headers: new Headers({ + 'content-length': String(11 * 1024 * 1024), + 'content-type': 'image/png', + }), + body: null, + arrayBuffer, + } as unknown as Response; + (httpClient.get as jest.Mock).mockResolvedValue(oversizedResponse); + + await expect(handleGetAppleDesignExamples({ + url: 'https://developer.apple.com/design/images/huge.png', + limit: 1, + })).rejects.toThrow('Apple Design image preview exceeds'); + + expect(arrayBuffer).not.toHaveBeenCalled(); + }); }); diff --git a/tests/utils/url-converter.test.ts b/tests/utils/url-converter.test.ts index 87d73fb..45b0cbe 100644 --- a/tests/utils/url-converter.test.ts +++ b/tests/utils/url-converter.test.ts @@ -102,6 +102,7 @@ describe('URL Converter', () => { describe('isAppleDesignUrl', () => { it('should return true for Apple Design URLs', () => { const validUrls = [ + 'https://developer.apple.com/design', 'https://developer.apple.com/design/', 'https://developer.apple.com/design/resources/', 'https://developer.apple.com/design/human-interface-guidelines/layout', From 934e5047cb74e47c24b393bfdd134ad546730107 Mon Sep 17 00:00:00 2001 From: Alexander Goodkind Date: Mon, 29 Jun 2026 21:47:23 -0700 Subject: [PATCH 05/19] Validate Apple Design redirects and optional args --- src/tools/design-docs.ts | 28 +++++++++++++----- src/tools/handlers.ts | 6 ++-- tests/tools/design-docs.test.ts | 52 +++++++++++++++++++++++++++++++++ tests/tools/handlers.test.ts | 44 ++++++++++++++++++++++++++++ 4 files changed, 120 insertions(+), 10 deletions(-) diff --git a/src/tools/design-docs.ts b/src/tools/design-docs.ts index 7ae4325..912671d 100644 --- a/src/tools/design-docs.ts +++ b/src/tools/design-docs.ts @@ -763,18 +763,22 @@ async function downloadDesignResource( maxBytes: number | undefined, ): Promise { const normalizedSourceUrl = resource.downloadUrl; + const byteLimit = maxBytes ?? DEFAULT_DOWNLOAD_MAX_BYTES; const cachedResource = cachedResourcesBySourceUrl.get(normalizedSourceUrl); if (cachedResource) { + if (cachedResource.size > byteLimit) { + throw new Error(`Apple Design resource exceeds the ${byteLimit} byte download limit.`); + } return cachedResource; } - const byteLimit = maxBytes ?? DEFAULT_DOWNLOAD_MAX_BYTES; const response = await httpClient.get(normalizedSourceUrl, { timeout: REQUEST_CONFIG.TIMEOUT, headers: { Accept: '*/*', }, }); + validateAppleDesignFinalResponseUrl(response, 'Apple Design resource'); const data = await readLimitedResponseBytes( response, @@ -910,6 +914,8 @@ async function fetchImageContent(url: string): Promise { Accept: 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8', }, }); + validateAppleDesignFinalResponseUrl(response, 'Apple Design image preview'); + const mimeType = detectMimeType(response.headers.get('content-type'), url); if (!isImageMimeType(mimeType)) { return null; @@ -1676,6 +1682,17 @@ function validateDownloadUrl(url: string): void { } } +function validateAppleDesignFinalResponseUrl(response: Response, description: string): void { + if (!response.url) { + return; + } + + const parsedUrl = safeUrl(response.url); + if (!parsedUrl || parsedUrl.protocol !== 'https:' || !APPLE_DESIGN_DOWNLOAD_HOSTS.has(parsedUrl.hostname)) { + throw new Error(`${description} redirected to a URL outside the Apple Design allowlist.`); + } +} + function isDirectImageUrl(url: string): boolean { const mimeType = detectMimeType(undefined, url); return isImageMimeType(mimeType); @@ -1769,12 +1786,8 @@ async function readLimitedResponseBytes( let totalBytes = 0; try { - while (true) { - const result = await reader.read(); - if (result.done) { - break; - } - + let result = await reader.read(); + while (!result.done) { totalBytes += result.value.byteLength; if (totalBytes > byteLimit) { await reader.cancel().catch(() => undefined); @@ -1782,6 +1795,7 @@ async function readLimitedResponseBytes( } chunks.push(Buffer.from(result.value)); + result = await reader.read(); } } finally { reader.releaseLock(); diff --git a/src/tools/handlers.ts b/src/tools/handlers.ts index 2f0d7ce..56c3f03 100644 --- a/src/tools/handlers.ts +++ b/src/tools/handlers.ts @@ -172,7 +172,7 @@ export const toolHandlers: Record = { }, list_apple_design_resources: async (args, server) => { - const validatedArgs = listAppleDesignResourcesSchema.parse(args); + const validatedArgs = listAppleDesignResourcesSchema.parse(args ?? {}); return await server.listAppleDesignResources( validatedArgs.category, validatedArgs.platform, @@ -183,7 +183,7 @@ export const toolHandlers: Record = { }, download_apple_design_resource: async (args, server) => { - const validatedArgs = downloadAppleDesignResourceSchema.parse(args); + const validatedArgs = downloadAppleDesignResourceSchema.parse(args ?? {}); return await server.downloadAppleDesignResource( validatedArgs.resourceId, validatedArgs.url, @@ -192,7 +192,7 @@ export const toolHandlers: Record = { }, get_apple_design_examples: async (args, server) => { - const validatedArgs = getAppleDesignExamplesSchema.parse(args); + const validatedArgs = getAppleDesignExamplesSchema.parse(args ?? {}); return await server.getAppleDesignExamples( validatedArgs.url, validatedArgs.resourceId, diff --git a/tests/tools/design-docs.test.ts b/tests/tools/design-docs.test.ts index 61d0007..a343164 100644 --- a/tests/tools/design-docs.test.ts +++ b/tests/tools/design-docs.test.ts @@ -251,6 +251,18 @@ function createResponse( }); } +function createResponseWithUrl( + data: Buffer, + contentType: string | undefined, + url: string, +): Response { + const response = createResponse(data, contentType); + Object.defineProperty(response, 'url', { + value: url, + }); + return response; +} + beforeEach(async () => { temporaryCacheDirectory = await mkdtemp(path.join(tmpdir(), 'apple-design-test-')); process.env.APPLE_DOCS_MCP_CACHE_DIR = temporaryCacheDirectory; @@ -423,6 +435,17 @@ describe('Apple Design downloads and resources', () => { })).rejects.toThrow('not allowed'); }); + it('should reject downloads redirected outside the Apple allowlist', async () => { + const archiveBytes = Buffer.from('zip-bytes'); + (httpClient.get as jest.Mock).mockResolvedValue( + createResponseWithUrl(archiveBytes, 'application/zip', 'https://example.com/templates.zip'), + ); + + await expect(handleDownloadAppleDesignResource({ + url: 'https://developer.apple.com/design/downloads/templates.zip', + })).rejects.toThrow('outside the Apple Design allowlist'); + }); + it('should reject oversized downloads before reading the body', async () => { const archiveBytes = Buffer.from('zip-bytes'); (httpClient.get as jest.Mock).mockResolvedValue( @@ -454,6 +477,23 @@ describe('Apple Design downloads and resources', () => { expect(httpClient.get).toHaveBeenCalledTimes(1); expect(firstLink?.uri).toBe(secondLink?.uri); }); + + it('should enforce smaller maxBytes limits on duplicate cache hits', async () => { + const archiveBytes = Buffer.from('zip-bytes'); + (httpClient.get as jest.Mock).mockResolvedValue( + createResponse(archiveBytes, 'application/zip'), + ); + + await handleDownloadAppleDesignResource({ + url: 'https://developer.apple.com/design/downloads/templates.zip', + }); + + await expect(handleDownloadAppleDesignResource({ + url: 'https://developer.apple.com/design/downloads/templates.zip', + maxBytes: 4, + })).rejects.toThrow('exceeds'); + expect(httpClient.get).toHaveBeenCalledTimes(1); + }); }); describe('Apple Design examples', () => { @@ -528,6 +568,18 @@ describe('Apple Design examples', () => { ); }); + it('should reject direct image examples redirected outside the Apple allowlist', async () => { + const imageBytes = Buffer.from('preview-image'); + (httpClient.get as jest.Mock).mockResolvedValue( + createResponseWithUrl(imageBytes, 'image/png', 'https://example.com/example.png'), + ); + + await expect(handleGetAppleDesignExamples({ + url: 'https://developer.apple.com/design/images/example.png', + limit: 1, + })).rejects.toThrow('outside the Apple Design allowlist'); + }); + it('should reject oversized direct image examples before reading the body', async () => { const arrayBuffer = jest.fn(); const oversizedResponse = { diff --git a/tests/tools/handlers.test.ts b/tests/tools/handlers.test.ts index 01c8104..e057982 100644 --- a/tests/tools/handlers.test.ts +++ b/tests/tools/handlers.test.ts @@ -182,6 +182,21 @@ describe('Tool Handlers', () => { }); }); + it('should handle list_apple_design_resources tool with omitted arguments', async () => { + const result = await handleToolCall('list_apple_design_resources', undefined, mockServer); + + expect(mockServer.listAppleDesignResources).toHaveBeenCalledWith( + undefined, + undefined, + undefined, + undefined, + 50, + ); + expect(result).toEqual({ + content: [{ type: 'text', text: 'Design resources' }], + }); + }); + it('should handle download_apple_design_resource tool with resource links', async () => { const args = { resourceId: 'design-resource:templates:ios:download' }; const result = await handleToolCall('download_apple_design_resource', args, mockServer); @@ -202,6 +217,16 @@ describe('Tool Handlers', () => { ]); }); + it('should handle download_apple_design_resource tool with omitted arguments', async () => { + await handleToolCall('download_apple_design_resource', undefined, mockServer); + + expect(mockServer.downloadAppleDesignResource).toHaveBeenCalledWith( + undefined, + undefined, + undefined, + ); + }); + it('should handle get_apple_design_examples tool with image blocks', async () => { const args = { url: 'https://developer.apple.com/design/human-interface-guidelines/layout', @@ -220,6 +245,25 @@ describe('Tool Handlers', () => { ]); }); + it('should handle get_apple_design_examples tool with omitted arguments', async () => { + const result = await handleToolCall('get_apple_design_examples', undefined, mockServer); + + expect(mockServer.getAppleDesignExamples).toHaveBeenCalledWith( + undefined, + undefined, + undefined, + 3, + ); + expect(result.content).toEqual([ + { type: 'text', text: 'Design examples' }, + { + type: 'image', + data: Buffer.from('image').toString('base64'), + mimeType: 'image/png', + }, + ]); + }); + it('should handle validation errors gracefully', async () => { // Mock schema parse to throw validation error jest.spyOn(schemas.searchAppleDocsSchema, 'parse').mockImplementationOnce(() => { From aae85ae94e237d270a97dc7dd0e1ac99cb5ab9f3 Mon Sep 17 00:00:00 2001 From: Alexander Goodkind Date: Mon, 29 Jun 2026 21:51:36 -0700 Subject: [PATCH 06/19] Disable automatic redirects for Apple Design fetches --- src/__tests__/http-client-headers-integration.test.ts | 11 ++++++++++- src/tools/design-docs.ts | 2 ++ src/utils/http-client.ts | 6 +++++- tests/tools/design-docs.test.ts | 8 ++++++++ 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/__tests__/http-client-headers-integration.test.ts b/src/__tests__/http-client-headers-integration.test.ts index e5e6626..ae95f98 100644 --- a/src/__tests__/http-client-headers-integration.test.ts +++ b/src/__tests__/http-client-headers-integration.test.ts @@ -70,6 +70,15 @@ describe('HTTP Client Headers Integration', () => { expect(options.headers['User-Agent']).toBe('Custom-Agent/1.0'); expect(options.headers['Custom-Header']).toBe('custom-value'); }); + + test('should pass redirect handling options to fetch', async () => { + await httpClient.get('https://example.com/api', { + redirect: 'manual', + }); + + const [, options] = mockFetch.mock.calls[0]; + expect(options.redirect).toBe('manual'); + }); }); describe('getText with generated headers', () => { @@ -232,4 +241,4 @@ describe('HTTP Client Headers Integration', () => { expect(options.headers).toHaveProperty('Accept-Encoding'); }); }); -}); \ No newline at end of file +}); diff --git a/src/tools/design-docs.ts b/src/tools/design-docs.ts index 912671d..6a4b54b 100644 --- a/src/tools/design-docs.ts +++ b/src/tools/design-docs.ts @@ -774,6 +774,7 @@ async function downloadDesignResource( const response = await httpClient.get(normalizedSourceUrl, { timeout: REQUEST_CONFIG.TIMEOUT, + redirect: 'manual', headers: { Accept: '*/*', }, @@ -910,6 +911,7 @@ async function fetchImageContent(url: string): Promise { const response = await httpClient.get(url, { timeout: REQUEST_CONFIG.TIMEOUT, + redirect: 'manual', headers: { Accept: 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8', }, diff --git a/src/utils/http-client.ts b/src/utils/http-client.ts index 288ef91..146d26f 100644 --- a/src/utils/http-client.ts +++ b/src/utils/http-client.ts @@ -33,6 +33,8 @@ interface RequestOptions { retries?: number; /** Delay between retries in milliseconds */ retryDelay?: number; + /** Redirect handling mode for fetch */ + redirect?: RequestRedirect; /** Additional headers to include in the request */ headers?: Record; } @@ -141,6 +143,7 @@ class HttpClient { timeout = REQUEST_CONFIG.TIMEOUT, retries = REQUEST_CONFIG.MAX_RETRIES, retryDelay = REQUEST_CONFIG.RETRY_DELAY, + redirect, headers = {}, } = options; @@ -156,6 +159,7 @@ class HttpClient { return this.fetchWithRetry(url, { method: 'GET', headers: requestHeaders, + redirect, signal: AbortSignal.timeout(timeout), }, retries, retryDelay); }); @@ -570,4 +574,4 @@ class HttpClient { } // Export singleton instance -export const httpClient = new HttpClient(); \ No newline at end of file +export const httpClient = new HttpClient(); diff --git a/tests/tools/design-docs.test.ts b/tests/tools/design-docs.test.ts index a343164..629374f 100644 --- a/tests/tools/design-docs.test.ts +++ b/tests/tools/design-docs.test.ts @@ -444,6 +444,10 @@ describe('Apple Design downloads and resources', () => { await expect(handleDownloadAppleDesignResource({ url: 'https://developer.apple.com/design/downloads/templates.zip', })).rejects.toThrow('outside the Apple Design allowlist'); + expect(httpClient.get).toHaveBeenCalledWith( + 'https://developer.apple.com/design/downloads/templates.zip', + expect.objectContaining({ redirect: 'manual' }), + ); }); it('should reject oversized downloads before reading the body', async () => { @@ -578,6 +582,10 @@ describe('Apple Design examples', () => { url: 'https://developer.apple.com/design/images/example.png', limit: 1, })).rejects.toThrow('outside the Apple Design allowlist'); + expect(httpClient.get).toHaveBeenCalledWith( + 'https://developer.apple.com/design/images/example.png', + expect.objectContaining({ redirect: 'manual' }), + ); }); it('should reject oversized direct image examples before reading the body', async () => { From 58b894ff881fafe4b2c4b8ecbb2f829ebe398964 Mon Sep 17 00:00:00 2001 From: Alexander Goodkind Date: Mon, 29 Jun 2026 22:00:26 -0700 Subject: [PATCH 07/19] Follow Apple Design asset redirects safely --- src/tools/design-docs.ts | 174 +++++++++++++++++++++++++++++--- src/utils/http-client.ts | 21 +++- tests/tools/design-docs.test.ts | 157 +++++++++++++++++++++++++++- 3 files changed, 328 insertions(+), 24 deletions(-) diff --git a/src/tools/design-docs.ts b/src/tools/design-docs.ts index 6a4b54b..6b4aa5f 100644 --- a/src/tools/design-docs.ts +++ b/src/tools/design-docs.ts @@ -9,7 +9,8 @@ import type { } from '@modelcontextprotocol/sdk/types.js'; import * as cheerio from 'cheerio'; import { createHash } from 'node:crypto'; -import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import type { Dirent } from 'node:fs'; +import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { designContentCache, designResourcesCache } from '../utils/cache.js'; @@ -24,7 +25,9 @@ const APPLE_DESIGN_DOWNLOAD_HOSTS = new Set([ 'itunespartner.apple.com', ]); const DEFAULT_DOWNLOAD_MAX_BYTES = 50 * 1024 * 1024; +const DEFAULT_DOWNLOAD_CACHE_MAX_BYTES = 1024 * 1024 * 1024; const DEFAULT_PREVIEW_IMAGE_MAX_BYTES = 10 * 1024 * 1024; +const MAX_APPLE_DESIGN_REDIRECTS = 5; const DIRECT_RESOURCE_EXTENSIONS = new Set([ '.dmg', '.fig', @@ -772,14 +775,14 @@ async function downloadDesignResource( return cachedResource; } - const response = await httpClient.get(normalizedSourceUrl, { - timeout: REQUEST_CONFIG.TIMEOUT, - redirect: 'manual', + const { + response, + finalUrl, + } = await fetchAppleDesignAssetResponse(normalizedSourceUrl, 'Apple Design resource', { headers: { Accept: '*/*', }, }); - validateAppleDesignFinalResponseUrl(response, 'Apple Design resource'); const data = await readLimitedResponseBytes( response, @@ -787,13 +790,14 @@ async function downloadDesignResource( 'Apple Design resource', ); - const mimeType = detectMimeType(response.headers.get('content-type'), normalizedSourceUrl); + const mimeType = detectMimeType(response.headers.get('content-type'), finalUrl); const hash = hashBuffer(data); - const filename = sanitizeFilename(getFilenameFromUrl(normalizedSourceUrl)); + const filename = sanitizeFilename(getFilenameFromUrl(finalUrl)); const cacheDirectory = getDesignCacheDirectory(); await mkdir(cacheDirectory, { recursive: true }); const filePath = path.join(cacheDirectory, `${hash}-${filename}`); + await assertDesignCacheCapacity(cacheDirectory, filePath, data.length); await writeFile(filePath, data); const uri = `${RESOURCE_URI_PREFIX}${hash}/${filename}`; @@ -909,16 +913,16 @@ async function fetchImageContent(url: string): Promise { return null; } - const response = await httpClient.get(url, { - timeout: REQUEST_CONFIG.TIMEOUT, - redirect: 'manual', + const { + response, + finalUrl, + } = await fetchAppleDesignAssetResponse(url, 'Apple Design image preview', { headers: { Accept: 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8', }, }); - validateAppleDesignFinalResponseUrl(response, 'Apple Design image preview'); - const mimeType = detectMimeType(response.headers.get('content-type'), url); + const mimeType = detectMimeType(response.headers.get('content-type'), finalUrl); if (!isImageMimeType(mimeType)) { return null; } @@ -1684,17 +1688,79 @@ function validateDownloadUrl(url: string): void { } } -function validateAppleDesignFinalResponseUrl(response: Response, description: string): void { - if (!response.url) { - return; +async function fetchAppleDesignAssetResponse( + url: string, + description: string, + options: { headers: Record }, +): Promise<{ response: Response; finalUrl: string }> { + let currentUrl = url; + validateAppleDesignRedirectUrl(currentUrl, description); + + for (let redirectCount = 0; redirectCount <= MAX_APPLE_DESIGN_REDIRECTS; redirectCount++) { + const response = await httpClient.get(currentUrl, { + timeout: REQUEST_CONFIG.TIMEOUT, + redirect: 'manual', + allowManualRedirect: true, + headers: options.headers, + }); + validateAppleDesignFinalResponseUrl(response, description); + + if (!isRedirectResponse(response)) { + return { + response, + finalUrl: response.url || currentUrl, + }; + } + + if (redirectCount === MAX_APPLE_DESIGN_REDIRECTS) { + throw new Error(`${description} exceeded the ${MAX_APPLE_DESIGN_REDIRECTS} redirect limit.`); + } + + const redirectUrl = getAppleDesignRedirectUrl(response, currentUrl, description); + validateAppleDesignRedirectUrl(redirectUrl, description); + currentUrl = redirectUrl; + } + + throw new Error(`${description} exceeded the ${MAX_APPLE_DESIGN_REDIRECTS} redirect limit.`); +} + +function getAppleDesignRedirectUrl( + response: Response, + currentUrl: string, + description: string, +): string { + const location = response.headers.get('location'); + if (!location) { + throw new Error(`${description} returned a redirect without a Location header.`); + } + + const redirectUrl = normalizeUrl(location, currentUrl); + if (!redirectUrl) { + throw new Error(`${description} returned an invalid redirect URL.`); } - const parsedUrl = safeUrl(response.url); + return redirectUrl; +} + +function isRedirectResponse(response: Response): boolean { + return response.status >= 300 && response.status < 400; +} + +function validateAppleDesignRedirectUrl(url: string, description: string): void { + const parsedUrl = safeUrl(url); if (!parsedUrl || parsedUrl.protocol !== 'https:' || !APPLE_DESIGN_DOWNLOAD_HOSTS.has(parsedUrl.hostname)) { throw new Error(`${description} redirected to a URL outside the Apple Design allowlist.`); } } +function validateAppleDesignFinalResponseUrl(response: Response, description: string): void { + if (!response.url) { + return; + } + + validateAppleDesignRedirectUrl(response.url, description); +} + function isDirectImageUrl(url: string): boolean { const mimeType = detectMimeType(undefined, url); return isImageMimeType(mimeType); @@ -1806,6 +1872,82 @@ async function readLimitedResponseBytes( return Buffer.concat(chunks, totalBytes); } +async function assertDesignCacheCapacity( + cacheDirectory: string, + filePath: string, + incomingBytes: number, +): Promise { + const cacheMaxBytes = getDesignCacheMaxBytes(); + const currentBytes = await getDirectoryFileBytes(cacheDirectory); + const existingBytes = await getFileSize(filePath); + const projectedBytes = Math.max(0, currentBytes - existingBytes) + incomingBytes; + + if (projectedBytes > cacheMaxBytes) { + throw new Error( + `Apple Design download cache exceeds the ${cacheMaxBytes} byte cache limit.`, + ); + } +} + +function getDesignCacheMaxBytes(): number { + const configuredLimit = process.env.APPLE_DOCS_MCP_CACHE_MAX_BYTES; + if (!configuredLimit) { + return DEFAULT_DOWNLOAD_CACHE_MAX_BYTES; + } + + const parsedLimit = Number.parseInt(configuredLimit, 10); + if (Number.isNaN(parsedLimit) || parsedLimit <= 0) { + return DEFAULT_DOWNLOAD_CACHE_MAX_BYTES; + } + + return parsedLimit; +} + +async function getDirectoryFileBytes(directory: string): Promise { + let entries: Dirent[]; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) { + return 0; + } + throw error; + } + + let totalBytes = 0; + for (const entry of entries) { + if (entry.isFile()) { + totalBytes += await getFileSize(path.join(directory, entry.name)); + } + } + + return totalBytes; +} + +async function getFileSize(filePath: string): Promise { + try { + const fileStats = await stat(filePath); + if (!fileStats.isFile()) { + return 0; + } + return fileStats.size; + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) { + return 0; + } + throw error; + } +} + +function hasErrorCode(error: unknown, code: string): boolean { + if (typeof error !== 'object' || error === null) { + return false; + } + + const errorWithCode = error as { code?: unknown }; + return errorWithCode.code === code; +} + function getDesignCacheDirectory(): string { return process.env.APPLE_DOCS_MCP_CACHE_DIR || path.join(tmpdir(), 'apple-docs-mcp', 'design-resources'); diff --git a/src/utils/http-client.ts b/src/utils/http-client.ts index 146d26f..16c6b49 100644 --- a/src/utils/http-client.ts +++ b/src/utils/http-client.ts @@ -35,6 +35,8 @@ interface RequestOptions { retryDelay?: number; /** Redirect handling mode for fetch */ redirect?: RequestRedirect; + /** Return manual redirect responses instead of treating them as failed requests */ + allowManualRedirect?: boolean; /** Additional headers to include in the request */ headers?: Record; } @@ -144,6 +146,7 @@ class HttpClient { retries = REQUEST_CONFIG.MAX_RETRIES, retryDelay = REQUEST_CONFIG.RETRY_DELAY, redirect, + allowManualRedirect = false, headers = {}, } = options; @@ -161,7 +164,7 @@ class HttpClient { headers: requestHeaders, redirect, signal: AbortSignal.timeout(timeout), - }, retries, retryDelay); + }, retries, retryDelay, allowManualRedirect); }); } @@ -206,6 +209,7 @@ class HttpClient { options: RequestInit, retries: number, retryDelay: number, + allowManualRedirect: boolean, ): Promise { const startTime = Date.now(); const domain = new URL(url).hostname; @@ -233,15 +237,23 @@ class HttpClient { this.updateStats(response.status, responseTime, true); } + const isAllowedManualRedirect = allowManualRedirect + && response.status >= 300 + && response.status < 400; + // Mark User-Agent success/failure in pool if (pool && currentUserAgent) { - if (response?.ok) { + if (response?.ok || isAllowedManualRedirect) { await pool.markSuccess(currentUserAgent); } else { await pool.markFailure(currentUserAgent, response?.status); } } + if (isAllowedManualRedirect) { + return response; + } + if (!response?.ok) { if (response && response.status === 404) { throw new Error(`${ERROR_MESSAGES.NOT_FOUND} (${response.status})`); @@ -392,6 +404,8 @@ class HttpClient { timeout = REQUEST_CONFIG.TIMEOUT, retries = REQUEST_CONFIG.MAX_RETRIES, retryDelay = REQUEST_CONFIG.RETRY_DELAY, + redirect, + allowManualRedirect = false, headers = {}, } = options; @@ -411,8 +425,9 @@ class HttpClient { const response = await this.fetchWithRetry(url, { method: 'GET', headers: requestHeaders, + redirect, signal: AbortSignal.timeout(timeout), - }, retries, retryDelay); + }, retries, retryDelay, allowManualRedirect); return await response.text(); }); diff --git a/tests/tools/design-docs.test.ts b/tests/tools/design-docs.test.ts index 629374f..bbd98be 100644 --- a/tests/tools/design-docs.test.ts +++ b/tests/tools/design-docs.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; -import { mkdtemp, rm } from 'node:fs/promises'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { @@ -263,6 +263,15 @@ function createResponseWithUrl( return response; } +function createRedirectResponse(location: string): Response { + return new Response(null, { + status: 302, + headers: { + location, + }, + }); +} + beforeEach(async () => { temporaryCacheDirectory = await mkdtemp(path.join(tmpdir(), 'apple-design-test-')); process.env.APPLE_DOCS_MCP_CACHE_DIR = temporaryCacheDirectory; @@ -273,6 +282,7 @@ beforeEach(async () => { afterEach(async () => { clearDesignResourceCacheForTesting(); delete process.env.APPLE_DOCS_MCP_CACHE_DIR; + delete process.env.APPLE_DOCS_MCP_CACHE_MAX_BYTES; if (temporaryCacheDirectory) { await rm(temporaryCacheDirectory, { @@ -435,7 +445,57 @@ describe('Apple Design downloads and resources', () => { })).rejects.toThrow('not allowed'); }); - it('should reject downloads redirected outside the Apple allowlist', async () => { + it('should follow allowed Apple download redirects manually', async () => { + const archiveBytes = Buffer.from('zip-bytes'); + (httpClient.get as jest.Mock) + .mockResolvedValueOnce(createRedirectResponse('https://devimages-cdn.apple.com/design/templates.zip')) + .mockResolvedValueOnce(createResponse(archiveBytes, 'application/zip')); + + const result = await handleDownloadAppleDesignResource({ + url: 'https://developer.apple.com/design/downloads/templates.zip', + }); + + expect(result.content).toEqual([ + expect.objectContaining({ + type: 'text', + text: expect.stringContaining('Downloaded Apple Design resource'), + }), + expect.objectContaining({ + type: 'resource_link', + mimeType: 'application/zip', + name: 'templates.zip', + }), + ]); + expect(httpClient.get).toHaveBeenNthCalledWith( + 1, + 'https://developer.apple.com/design/downloads/templates.zip', + expect.objectContaining({ + allowManualRedirect: true, + redirect: 'manual', + }), + ); + expect(httpClient.get).toHaveBeenNthCalledWith( + 2, + 'https://devimages-cdn.apple.com/design/templates.zip', + expect.objectContaining({ + allowManualRedirect: true, + redirect: 'manual', + }), + ); + }); + + it('should reject download redirects outside the Apple allowlist before fetching them', async () => { + (httpClient.get as jest.Mock).mockResolvedValue( + createRedirectResponse('https://example.com/templates.zip'), + ); + + await expect(handleDownloadAppleDesignResource({ + url: 'https://developer.apple.com/design/downloads/templates.zip', + })).rejects.toThrow('outside the Apple Design allowlist'); + expect(httpClient.get).toHaveBeenCalledTimes(1); + }); + + it('should reject download responses whose final URL is outside the Apple allowlist', async () => { const archiveBytes = Buffer.from('zip-bytes'); (httpClient.get as jest.Mock).mockResolvedValue( createResponseWithUrl(archiveBytes, 'application/zip', 'https://example.com/templates.zip'), @@ -446,7 +506,10 @@ describe('Apple Design downloads and resources', () => { })).rejects.toThrow('outside the Apple Design allowlist'); expect(httpClient.get).toHaveBeenCalledWith( 'https://developer.apple.com/design/downloads/templates.zip', - expect.objectContaining({ redirect: 'manual' }), + expect.objectContaining({ + allowManualRedirect: true, + redirect: 'manual', + }), ); }); @@ -498,6 +561,35 @@ describe('Apple Design downloads and resources', () => { })).rejects.toThrow('exceeds'); expect(httpClient.get).toHaveBeenCalledTimes(1); }); + + it('should reject downloads that exceed the aggregate cache limit', async () => { + process.env.APPLE_DOCS_MCP_CACHE_MAX_BYTES = '10'; + const firstArchiveBytes = Buffer.from('123456'); + const secondArchiveBytes = Buffer.from('abcdef'); + (httpClient.get as jest.Mock) + .mockResolvedValueOnce(createResponse(firstArchiveBytes, 'application/zip')) + .mockResolvedValueOnce(createResponse(secondArchiveBytes, 'application/zip')); + + await handleDownloadAppleDesignResource({ + url: 'https://developer.apple.com/design/downloads/one.zip', + }); + + await expect(handleDownloadAppleDesignResource({ + url: 'https://developer.apple.com/design/downloads/two.zip', + })).rejects.toThrow('cache limit'); + }); + + it('should count existing cache-directory files toward the aggregate cache limit', async () => { + process.env.APPLE_DOCS_MCP_CACHE_MAX_BYTES = '10'; + await writeFile(path.join(temporaryCacheDirectory ?? '', 'old-download.zip'), '12345678'); + (httpClient.get as jest.Mock).mockResolvedValue( + createResponse(Buffer.from('abc'), 'application/zip'), + ); + + await expect(handleDownloadAppleDesignResource({ + url: 'https://developer.apple.com/design/downloads/new.zip', + })).rejects.toThrow('cache limit'); + }); }); describe('Apple Design examples', () => { @@ -572,7 +664,59 @@ describe('Apple Design examples', () => { ); }); - it('should reject direct image examples redirected outside the Apple allowlist', async () => { + it('should follow allowed Apple image redirects manually', async () => { + const imageBytes = Buffer.from('preview-image'); + (httpClient.get as jest.Mock) + .mockResolvedValueOnce(createRedirectResponse('https://docs-assets.developer.apple.com/design/example.png')) + .mockResolvedValueOnce(createResponse(imageBytes, 'image/png')); + + const result = await handleGetAppleDesignExamples({ + url: 'https://developer.apple.com/design/images/example.png', + limit: 1, + }); + + expect(result.content).toEqual([ + expect.objectContaining({ + type: 'text', + text: expect.stringContaining('Apple Design Examples'), + }), + expect.objectContaining({ + type: 'image', + data: imageBytes.toString('base64'), + mimeType: 'image/png', + }), + ]); + expect(httpClient.get).toHaveBeenNthCalledWith( + 1, + 'https://developer.apple.com/design/images/example.png', + expect.objectContaining({ + allowManualRedirect: true, + redirect: 'manual', + }), + ); + expect(httpClient.get).toHaveBeenNthCalledWith( + 2, + 'https://docs-assets.developer.apple.com/design/example.png', + expect.objectContaining({ + allowManualRedirect: true, + redirect: 'manual', + }), + ); + }); + + it('should reject direct image examples redirected outside the Apple allowlist before fetching them', async () => { + (httpClient.get as jest.Mock).mockResolvedValue( + createRedirectResponse('https://example.com/example.png'), + ); + + await expect(handleGetAppleDesignExamples({ + url: 'https://developer.apple.com/design/images/example.png', + limit: 1, + })).rejects.toThrow('outside the Apple Design allowlist'); + expect(httpClient.get).toHaveBeenCalledTimes(1); + }); + + it('should reject direct image example responses whose final URL is outside the Apple allowlist', async () => { const imageBytes = Buffer.from('preview-image'); (httpClient.get as jest.Mock).mockResolvedValue( createResponseWithUrl(imageBytes, 'image/png', 'https://example.com/example.png'), @@ -584,7 +728,10 @@ describe('Apple Design examples', () => { })).rejects.toThrow('outside the Apple Design allowlist'); expect(httpClient.get).toHaveBeenCalledWith( 'https://developer.apple.com/design/images/example.png', - expect.objectContaining({ redirect: 'manual' }), + expect.objectContaining({ + allowManualRedirect: true, + redirect: 'manual', + }), ); }); From d185980828e7d167c6c0465e006c67a99d9a5bb7 Mon Sep 17 00:00:00 2001 From: Alexander Goodkind Date: Mon, 29 Jun 2026 22:09:32 -0700 Subject: [PATCH 08/19] Harden Apple Design cache file writes --- src/tools/design-docs.ts | 59 +++++++++++++++++++++++++++------ tests/tools/design-docs.test.ts | 4 +++ 2 files changed, 52 insertions(+), 11 deletions(-) diff --git a/src/tools/design-docs.ts b/src/tools/design-docs.ts index 6b4aa5f..481a0c4 100644 --- a/src/tools/design-docs.ts +++ b/src/tools/design-docs.ts @@ -8,9 +8,9 @@ import type { ResourceLink, } from '@modelcontextprotocol/sdk/types.js'; import * as cheerio from 'cheerio'; -import { createHash } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; import type { Dirent } from 'node:fs'; -import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; +import { mkdir, open, readFile, readdir, rm, stat } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { designContentCache, designResourcesCache } from '../utils/cache.js'; @@ -794,13 +794,15 @@ async function downloadDesignResource( const hash = hashBuffer(data); const filename = sanitizeFilename(getFilenameFromUrl(finalUrl)); const cacheDirectory = getDesignCacheDirectory(); - await mkdir(cacheDirectory, { recursive: true }); + await mkdir(cacheDirectory, { recursive: true, mode: 0o700 }); + await assertDesignCacheCapacity(cacheDirectory, data.length); - const filePath = path.join(cacheDirectory, `${hash}-${filename}`); - await assertDesignCacheCapacity(cacheDirectory, filePath, data.length); - await writeFile(filePath, data); + const { + cacheFilename, + filePath, + } = await writeExclusiveDesignCacheFile(cacheDirectory, hash, filename, data); - const uri = `${RESOURCE_URI_PREFIX}${hash}/${filename}`; + const uri = `${RESOURCE_URI_PREFIX}${hash}/${cacheFilename}`; const newCachedResource: CachedDesignResource = { uri, name: filename, @@ -1874,13 +1876,11 @@ async function readLimitedResponseBytes( async function assertDesignCacheCapacity( cacheDirectory: string, - filePath: string, incomingBytes: number, ): Promise { const cacheMaxBytes = getDesignCacheMaxBytes(); const currentBytes = await getDirectoryFileBytes(cacheDirectory); - const existingBytes = await getFileSize(filePath); - const projectedBytes = Math.max(0, currentBytes - existingBytes) + incomingBytes; + const projectedBytes = currentBytes + incomingBytes; if (projectedBytes > cacheMaxBytes) { throw new Error( @@ -1889,6 +1889,43 @@ async function assertDesignCacheCapacity( } } +async function writeExclusiveDesignCacheFile( + cacheDirectory: string, + hash: string, + filename: string, + data: Buffer, +): Promise<{ cacheFilename: string; filePath: string }> { + for (let attemptCount = 0; attemptCount < 3; attemptCount++) { + const cacheFilename = createCacheFilename(hash, filename); + const filePath = path.join(cacheDirectory, cacheFilename); + + try { + const fileHandle = await open(filePath, 'wx', 0o600); + try { + await fileHandle.writeFile(data); + } finally { + await fileHandle.close(); + } + + return { + cacheFilename, + filePath, + }; + } catch (error) { + if (hasErrorCode(error, 'EEXIST')) { + continue; + } + throw error; + } + } + + throw new Error('Apple Design cache could not create a unique resource file.'); +} + +function createCacheFilename(hash: string, filename: string): string { + return `${hash}-${randomUUID()}-${filename}`; +} + function getDesignCacheMaxBytes(): number { const configuredLimit = process.env.APPLE_DOCS_MCP_CACHE_MAX_BYTES; if (!configuredLimit) { @@ -1950,7 +1987,7 @@ function hasErrorCode(error: unknown, code: string): boolean { function getDesignCacheDirectory(): string { return process.env.APPLE_DOCS_MCP_CACHE_DIR - || path.join(tmpdir(), 'apple-docs-mcp', 'design-resources'); + ?? path.join(tmpdir(), 'apple-docs-mcp', 'design-resources'); } function createTextContent(text: string): ContentBlock { diff --git a/tests/tools/design-docs.test.ts b/tests/tools/design-docs.test.ts index bbd98be..a3e9f64 100644 --- a/tests/tools/design-docs.test.ts +++ b/tests/tools/design-docs.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { createHash } from 'node:crypto'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -404,6 +405,9 @@ describe('Apple Design downloads and resources', () => { mimeType: 'image/png', name: 'example.png', }); + const imageHash = createHash('sha256').update(imageBytes).digest('hex'); + expect(resourceLink?.uri).toContain(`apple-design://cache/${imageHash}/${imageHash}-`); + expect(resourceLink?.uri).not.toBe(`apple-design://cache/${imageHash}/example.png`); const resources = await listCachedDesignResources(); expect(resources.resources).toHaveLength(1); From 79174ba4d235b286d3b1043bf45a51c0c5250515 Mon Sep 17 00:00:00 2001 From: Alexander Goodkind Date: Mon, 29 Jun 2026 22:13:23 -0700 Subject: [PATCH 09/19] Serialize Apple Design cache writes --- src/tools/design-docs.ts | 46 ++++++++++++++++++++++++++++----- tests/tools/design-docs.test.ts | 19 ++++++++++++++ 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/src/tools/design-docs.ts b/src/tools/design-docs.ts index 481a0c4..673b7b0 100644 --- a/src/tools/design-docs.ts +++ b/src/tools/design-docs.ts @@ -9,7 +9,7 @@ import type { } from '@modelcontextprotocol/sdk/types.js'; import * as cheerio from 'cheerio'; import { createHash, randomUUID } from 'node:crypto'; -import type { Dirent } from 'node:fs'; +import { mkdtempSync, type Dirent } from 'node:fs'; import { mkdir, open, readFile, readdir, rm, stat } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -115,6 +115,8 @@ export interface GetAppleDesignExamplesArgs { const cachedResourcesByUri = new Map(); const cachedResourcesBySourceUrl = new Map(); const resourceCatalogById = new Map(); +let defaultDesignCacheDirectory: string | undefined; +let designCacheWriteQueue: Promise = Promise.resolve(); /** * Format Apple Design HIG JSON as readable Markdown. @@ -461,6 +463,7 @@ export function clearDesignResourceCacheForTesting(): void { cachedResourcesByUri.clear(); cachedResourcesBySourceUrl.clear(); resourceCatalogById.clear(); + designCacheWriteQueue = Promise.resolve(); } async function fetchAppleDesignContent(url: string): Promise { @@ -794,13 +797,14 @@ async function downloadDesignResource( const hash = hashBuffer(data); const filename = sanitizeFilename(getFilenameFromUrl(finalUrl)); const cacheDirectory = getDesignCacheDirectory(); - await mkdir(cacheDirectory, { recursive: true, mode: 0o700 }); - await assertDesignCacheCapacity(cacheDirectory, data.length); - const { cacheFilename, filePath, - } = await writeExclusiveDesignCacheFile(cacheDirectory, hash, filename, data); + } = await withDesignCacheWriteLock(async () => { + await mkdir(cacheDirectory, { recursive: true, mode: 0o700 }); + await assertDesignCacheCapacity(cacheDirectory, data.length); + return await writeExclusiveDesignCacheFile(cacheDirectory, hash, filename, data); + }); const uri = `${RESOURCE_URI_PREFIX}${hash}/${cacheFilename}`; const newCachedResource: CachedDesignResource = { @@ -1922,6 +1926,23 @@ async function writeExclusiveDesignCacheFile( throw new Error('Apple Design cache could not create a unique resource file.'); } +async function withDesignCacheWriteLock(operation: () => Promise): Promise { + const previousWrite = designCacheWriteQueue; + let releaseLock: (() => void) | undefined; + designCacheWriteQueue = new Promise((resolve) => { + releaseLock = resolve; + }); + + await previousWrite.catch(() => undefined); + try { + return await operation(); + } finally { + if (releaseLock) { + releaseLock(); + } + } +} + function createCacheFilename(hash: string, filename: string): string { return `${hash}-${randomUUID()}-${filename}`; } @@ -1986,8 +2007,16 @@ function hasErrorCode(error: unknown, code: string): boolean { } function getDesignCacheDirectory(): string { - return process.env.APPLE_DOCS_MCP_CACHE_DIR - ?? path.join(tmpdir(), 'apple-docs-mcp', 'design-resources'); + const configuredCacheDirectory = process.env.APPLE_DOCS_MCP_CACHE_DIR; + if (configuredCacheDirectory) { + return configuredCacheDirectory; + } + + if (!defaultDesignCacheDirectory) { + defaultDesignCacheDirectory = mkdtempSync(path.join(tmpdir(), 'apple-docs-mcp-design-')); + } + + return defaultDesignCacheDirectory; } function createTextContent(text: string): ContentBlock { @@ -2142,4 +2171,7 @@ export async function removeCachedDesignResourcesForTesting(): Promise { force: true, recursive: true, }); + if (!process.env.APPLE_DOCS_MCP_CACHE_DIR) { + defaultDesignCacheDirectory = undefined; + } } diff --git a/tests/tools/design-docs.test.ts b/tests/tools/design-docs.test.ts index a3e9f64..b4ee415 100644 --- a/tests/tools/design-docs.test.ts +++ b/tests/tools/design-docs.test.ts @@ -594,6 +594,25 @@ describe('Apple Design downloads and resources', () => { url: 'https://developer.apple.com/design/downloads/new.zip', })).rejects.toThrow('cache limit'); }); + + it('should enforce the aggregate cache limit across concurrent downloads', async () => { + process.env.APPLE_DOCS_MCP_CACHE_MAX_BYTES = '10'; + (httpClient.get as jest.Mock) + .mockResolvedValueOnce(createResponse(Buffer.from('123456'), 'application/zip')) + .mockResolvedValueOnce(createResponse(Buffer.from('abcdef'), 'application/zip')); + + const results = await Promise.allSettled([ + handleDownloadAppleDesignResource({ + url: 'https://developer.apple.com/design/downloads/concurrent-one.zip', + }), + handleDownloadAppleDesignResource({ + url: 'https://developer.apple.com/design/downloads/concurrent-two.zip', + }), + ]); + + expect(results.filter(result => result.status === 'fulfilled')).toHaveLength(1); + expect(results.filter(result => result.status === 'rejected')).toHaveLength(1); + }); }); describe('Apple Design examples', () => { From 735bff1c20f94739ea6b4ef41d468bf84d7fe351 Mon Sep 17 00:00:00 2001 From: Alexander Goodkind Date: Mon, 29 Jun 2026 22:17:49 -0700 Subject: [PATCH 10/19] Reject unknown Apple Design example resources --- src/tools/design-docs.ts | 6 +++++- tests/tools/design-docs.test.ts | 10 ++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/tools/design-docs.ts b/src/tools/design-docs.ts index 673b7b0..6fcae74 100644 --- a/src/tools/design-docs.ts +++ b/src/tools/design-docs.ts @@ -866,7 +866,11 @@ async function collectImageCandidates(args: GetAppleDesignExamplesArgs): Promise await getDesignResourcesCatalog(); } const resource = resourceCatalogById.get(args.resourceId); - if (resource?.previewImageUrl) { + if (!resource) { + throw new Error(`Unknown Apple Design resourceId: ${args.resourceId}`); + } + + if (resource.previewImageUrl) { candidates.push({ url: resource.previewImageUrl, alt: resource.title, diff --git a/tests/tools/design-docs.test.ts b/tests/tools/design-docs.test.ts index b4ee415..daebc89 100644 --- a/tests/tools/design-docs.test.ts +++ b/tests/tools/design-docs.test.ts @@ -634,6 +634,16 @@ describe('Apple Design examples', () => { expect(httpClient.get).not.toHaveBeenCalled(); }); + it('should reject unknown resource IDs for examples', async () => { + (httpClient.getText as jest.Mock).mockResolvedValue(''); + + await expect(handleGetAppleDesignExamples({ + resourceId: 'missing-resource-id', + })).rejects.toThrow('Unknown Apple Design resourceId'); + + expect(httpClient.get).not.toHaveBeenCalled(); + }); + it('should return HIG image references as MCP image content blocks', async () => { const imageBytes = Buffer.from('hig-image'); (httpClient.getJson as jest.Mock).mockResolvedValue(SAMPLE_HIG_DOCUMENT); From 0ae7c1931e04030b7e022bab73d6eb38c8f89524 Mon Sep 17 00:00:00 2001 From: Alexander Goodkind Date: Mon, 29 Jun 2026 22:28:26 -0700 Subject: [PATCH 11/19] Harden Apple Design content fetches --- src/tools/design-docs.ts | 167 ++++++++++++++++++++++++++----- tests/tools/design-docs.test.ts | 169 +++++++++++++++++++++++++++++++- 2 files changed, 308 insertions(+), 28 deletions(-) diff --git a/src/tools/design-docs.ts b/src/tools/design-docs.ts index 6fcae74..2e83337 100644 --- a/src/tools/design-docs.ts +++ b/src/tools/design-docs.ts @@ -84,6 +84,8 @@ interface DesignImageCandidate { alt?: string; } +type AppleDesignUrlValidator = (url: string, description: string) => void; + export interface SearchAppleDesignDocsArgs { query: string; contentType?: 'all' | 'hig' | 'resource' | 'page'; @@ -460,6 +462,8 @@ export async function readCachedDesignResource(uri: string): Promise { const jsonUrl = convertToDesignJsonApiUrl(url); let content: string; if (jsonUrl) { - const jsonData = await httpClient.getJson(jsonUrl); + const jsonData = await fetchAppleDesignJson(jsonUrl); content = formatAppleDesignDocument(jsonData, url); } else { - const html = await httpClient.getText(url); - if (isDesignResourcesUrl(url)) { - const resources = parseDesignResourcesHtml(html, url); + const { html, finalUrl } = await fetchAppleDesignHtml(url); + if (isDesignResourcesUrl(finalUrl)) { + const resources = parseDesignResourcesHtml(html, finalUrl); updateResourceCatalogIndex(resources); - content = `${parseAppleDesignHtmlPage(html, url)}\n\n${formatDesignResources(resources)}`; + content = `${parseAppleDesignHtmlPage(html, finalUrl)}\n\n${formatDesignResources(resources)}`; } else { - content = parseAppleDesignHtmlPage(html, url); + content = parseAppleDesignHtmlPage(html, finalUrl); } } @@ -500,8 +504,8 @@ async function getDesignResourcesCatalog(): Promise { +async function collectHigSearchResults(query: string, platform: string): Promise { try { - const rootJson = await httpClient.getJson(APPLE_URLS.DESIGN_HIG_JSON); + const rootJson = await fetchAppleDesignJson(APPLE_URLS.DESIGN_HIG_JSON); const documentRecord = asRecord(rootJson); if (!documentRecord) { return []; @@ -574,7 +578,10 @@ async function collectHigSearchResults(query: string): Promise(jsonUrl); + const jsonData = await fetchAppleDesignJson(jsonUrl); candidates.push(...extractImageCandidatesFromDesignDocument(jsonData, url)); } else { - const html = await httpClient.getText(url); - candidates.push(...extractImageCandidatesFromHtml(html, url)); + const { html, finalUrl } = await fetchAppleDesignHtml(url); + candidates.push(...extractImageCandidatesFromHtml(html, finalUrl)); } } } @@ -1001,7 +1011,7 @@ function collectImageCandidatesFromContent( } } - for (const nestedKey of ['content', 'items', 'rows', 'cells', 'inlineContent']) { + for (const nestedKey of ['content', 'items', 'rows', 'cells', 'inlineContent', 'tabs']) { collectImageCandidatesFromContent(getArray(record[nestedKey]), references, sourceUrl, candidates); } } @@ -1452,10 +1462,31 @@ function renderInlineContent( function getSupportedPlatforms(documentRecord: Record): string[] { const customMetadata = getDesignCustomMetadata(documentRecord); - const supportedPlatforms = customMetadata?.['supported-platforms'] ?? customMetadata?.supportedPlatforms; + const supportedPlatforms = customMetadata?.['supported-platforms'] + ?? customMetadata?.supportedPlatforms + ?? documentRecord['supported-platforms'] + ?? documentRecord.supportedPlatforms + ?? documentRecord.platforms; return getStringArray(supportedPlatforms); } +function designRecordMatchesPlatform( + record: Record, + platform: string, + fallbackText: string, +): boolean { + if (platform === 'all') { + return true; + } + + const supportedPlatforms = getSupportedPlatforms(record); + if (supportedPlatforms.length === 0) { + return containsText(fallbackText, platform); + } + + return supportedPlatforms.some(supportedPlatform => containsText(supportedPlatform, platform)); +} + function getCustomMetadataString(documentRecord: Record, key: string): string | undefined { const customMetadata = getDesignCustomMetadata(documentRecord); return getString(customMetadata, key); @@ -1657,7 +1688,7 @@ function validateAppleDesignContentUrl(url: string): void { } function validateAppleDesignExampleUrl(url: string): void { - if (isDirectImageUrl(url)) { + if (isDirectImageUrl(url) || isDirectAppleImageCandidateUrl(url)) { validateAppleDesignImageUrl(url); return; } @@ -1682,6 +1713,16 @@ function isAppleDesignContentUrl(url: string): boolean { ); } +function isAppleDesignJsonUrl(url: string): boolean { + const parsedUrl = safeUrl(url); + return Boolean( + parsedUrl + && parsedUrl.protocol === 'https:' + && parsedUrl.hostname === 'developer.apple.com' + && parsedUrl.pathname.startsWith('/tutorials/data/design/'), + ); +} + function isDirectAppleDownloadUrl(url: string): boolean { const parsedUrl = safeUrl(url); return Boolean(parsedUrl && parsedUrl.protocol === 'https:' && APPLE_DESIGN_DOWNLOAD_HOSTS.has(parsedUrl.hostname)); @@ -1698,13 +1739,60 @@ function validateDownloadUrl(url: string): void { } } +async function fetchAppleDesignHtml(url: string): Promise<{ html: string; finalUrl: string }> { + const { response, finalUrl } = await fetchAppleDesignManualRedirectResponse( + url, + 'Apple Design content page', + { + headers: { + Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + }, + validateUrl: validateAppleDesignContentRedirectUrl, + }, + ); + + return { + html: await response.text(), + finalUrl, + }; +} + +async function fetchAppleDesignJson(url: string): Promise { + const { response } = await fetchAppleDesignManualRedirectResponse( + url, + 'Apple Design JSON', + { + headers: { + Accept: 'application/json,*/*;q=0.8', + }, + validateUrl: validateAppleDesignJsonRedirectUrl, + }, + ); + + return await response.json() as unknown; +} + async function fetchAppleDesignAssetResponse( url: string, description: string, options: { headers: Record }, +): Promise<{ response: Response; finalUrl: string }> { + return fetchAppleDesignManualRedirectResponse(url, description, { + headers: options.headers, + validateUrl: validateAppleDesignRedirectUrl, + }); +} + +async function fetchAppleDesignManualRedirectResponse( + url: string, + description: string, + options: { + headers: Record; + validateUrl: AppleDesignUrlValidator; + }, ): Promise<{ response: Response; finalUrl: string }> { let currentUrl = url; - validateAppleDesignRedirectUrl(currentUrl, description); + options.validateUrl(currentUrl, description); for (let redirectCount = 0; redirectCount <= MAX_APPLE_DESIGN_REDIRECTS; redirectCount++) { const response = await httpClient.get(currentUrl, { @@ -1713,7 +1801,7 @@ async function fetchAppleDesignAssetResponse( allowManualRedirect: true, headers: options.headers, }); - validateAppleDesignFinalResponseUrl(response, description); + validateAppleDesignFinalResponseUrl(response, description, options.validateUrl); if (!isRedirectResponse(response)) { return { @@ -1727,7 +1815,7 @@ async function fetchAppleDesignAssetResponse( } const redirectUrl = getAppleDesignRedirectUrl(response, currentUrl, description); - validateAppleDesignRedirectUrl(redirectUrl, description); + options.validateUrl(redirectUrl, description); currentUrl = redirectUrl; } @@ -1763,12 +1851,41 @@ function validateAppleDesignRedirectUrl(url: string, description: string): void } } -function validateAppleDesignFinalResponseUrl(response: Response, description: string): void { +function validateAppleDesignContentRedirectUrl(url: string, description: string): void { + if (!isAppleDesignContentUrl(url)) { + throw new Error(`${description} redirected to a URL outside the Apple Design content allowlist.`); + } +} + +function validateAppleDesignJsonRedirectUrl(url: string, description: string): void { + if (!isAppleDesignJsonUrl(url)) { + throw new Error(`${description} redirected to a URL outside the Apple Design JSON allowlist.`); + } +} + +function validateAppleDesignFinalResponseUrl( + response: Response, + description: string, + validateUrl: AppleDesignUrlValidator, +): void { if (!response.url) { return; } - validateAppleDesignRedirectUrl(response.url, description); + validateUrl(response.url, description); +} + +function isDirectAppleImageCandidateUrl(url: string): boolean { + const parsedUrl = safeUrl(url); + if (!parsedUrl || parsedUrl.protocol !== 'https:' || !APPLE_DESIGN_DOWNLOAD_HOSTS.has(parsedUrl.hostname)) { + return false; + } + + if (parsedUrl.hostname !== 'developer.apple.com') { + return true; + } + + return isDirectImageUrl(url); } function isDirectImageUrl(url: string): boolean { diff --git a/tests/tools/design-docs.test.ts b/tests/tools/design-docs.test.ts index daebc89..b9159c5 100644 --- a/tests/tools/design-docs.test.ts +++ b/tests/tools/design-docs.test.ts @@ -9,6 +9,7 @@ import { handleDownloadAppleDesignResource, handleGetAppleDesignContent, handleGetAppleDesignExamples, + handleSearchAppleDesignDocs, listCachedDesignResources, parseAppleDesignHtmlPage, parseDesignResourcesHtml, @@ -252,6 +253,10 @@ function createResponse( }); } +function createJsonResponse(data: unknown): Response { + return createResponse(Buffer.from(JSON.stringify(data)), 'application/json'); +} + function createResponseWithUrl( data: Buffer, contentType: string | undefined, @@ -303,6 +308,54 @@ describe('Apple Design document formatting', () => { expect(httpClient.getJson).not.toHaveBeenCalled(); }); + it('should follow allowed Apple Design content redirects manually', async () => { + const html = ` +
+

Design Resources

+

Download templates.

+
+ `; + (httpClient.get as jest.Mock) + .mockResolvedValueOnce(createRedirectResponse('https://developer.apple.com/design/resources/')) + .mockResolvedValueOnce(createResponse(Buffer.from(html), 'text/html')); + + const result = await handleGetAppleDesignContent({ + url: 'https://developer.apple.com/design/', + }); + + expect(result.content[0]).toMatchObject({ + type: 'text', + text: expect.stringContaining('# Design Resources'), + }); + expect(httpClient.get).toHaveBeenNthCalledWith( + 1, + 'https://developer.apple.com/design/', + expect.objectContaining({ + allowManualRedirect: true, + redirect: 'manual', + }), + ); + expect(httpClient.get).toHaveBeenNthCalledWith( + 2, + 'https://developer.apple.com/design/resources/', + expect.objectContaining({ + allowManualRedirect: true, + redirect: 'manual', + }), + ); + }); + + it('should reject Apple Design content redirects outside the content allowlist', async () => { + (httpClient.get as jest.Mock).mockResolvedValue( + createRedirectResponse('https://example.com/design/'), + ); + + await expect(handleGetAppleDesignContent({ + url: 'https://developer.apple.com/design/', + })).rejects.toThrow('outside the Apple Design content allowlist'); + expect(httpClient.get).toHaveBeenCalledTimes(1); + }); + it('should format HIG JSON content with images, tables, links, platforms, and change logs', () => { const result = formatAppleDesignDocument( SAMPLE_HIG_DOCUMENT, @@ -343,6 +396,30 @@ describe('Apple Design document formatting', () => { }); }); +describe('Apple Design search', () => { + it('should apply platform filters to HIG search results', async () => { + (httpClient.get as jest.Mock).mockResolvedValue(createJsonResponse(SAMPLE_HIG_DOCUMENT)); + + const result = await handleSearchAppleDesignDocs({ + query: 'Layout', + contentType: 'hig', + platform: 'watchOS', + }); + + expect(result.content[0]).toMatchObject({ + type: 'text', + text: expect.stringContaining('No Apple Design results found.'), + }); + expect(httpClient.get).toHaveBeenCalledWith( + 'https://developer.apple.com/tutorials/data/design/human-interface-guidelines.json', + expect.objectContaining({ + allowManualRedirect: true, + redirect: 'manual', + }), + ); + }); +}); + describe('Apple Design Resources parser', () => { it('should extract templates, fonts, notes, previews, formats, and related HIG links', () => { const resources = parseDesignResourcesHtml( @@ -635,22 +712,108 @@ describe('Apple Design examples', () => { }); it('should reject unknown resource IDs for examples', async () => { - (httpClient.getText as jest.Mock).mockResolvedValue(''); + (httpClient.get as jest.Mock).mockResolvedValue( + createResponse(Buffer.from(''), 'text/html'), + ); await expect(handleGetAppleDesignExamples({ resourceId: 'missing-resource-id', })).rejects.toThrow('Unknown Apple Design resourceId'); - expect(httpClient.get).not.toHaveBeenCalled(); + expect(httpClient.get).toHaveBeenCalledTimes(1); }); it('should return HIG image references as MCP image content blocks', async () => { const imageBytes = Buffer.from('hig-image'); - (httpClient.getJson as jest.Mock).mockResolvedValue(SAMPLE_HIG_DOCUMENT); + (httpClient.get as jest.Mock) + .mockResolvedValueOnce(createJsonResponse(SAMPLE_HIG_DOCUMENT)) + .mockResolvedValueOnce(createResponse(imageBytes, 'image/png')); + + const result = await handleGetAppleDesignExamples({ + url: 'https://developer.apple.com/design/human-interface-guidelines/layout', + limit: 1, + }); + + expect(result.content).toEqual([ + expect.objectContaining({ + type: 'text', + text: expect.stringContaining('Apple Design Examples'), + }), + expect.objectContaining({ + type: 'image', + data: imageBytes.toString('base64'), + mimeType: 'image/png', + }), + ]); + }); + + it('should return direct Apple CDN images without path extensions', async () => { + const imageBytes = Buffer.from('cdn-preview-image'); (httpClient.get as jest.Mock).mockResolvedValue( createResponse(imageBytes, 'image/png'), ); + const result = await handleGetAppleDesignExamples({ + url: 'https://docs-assets.developer.apple.com/design/preview', + limit: 1, + }); + + expect(result.content).toEqual([ + expect.objectContaining({ + type: 'text', + text: expect.stringContaining('Apple Design Examples'), + }), + expect.objectContaining({ + type: 'image', + data: imageBytes.toString('base64'), + mimeType: 'image/png', + }), + ]); + }); + + it('should reject Apple Design example page redirects outside the content allowlist', async () => { + (httpClient.get as jest.Mock).mockResolvedValue( + createRedirectResponse('https://example.com/design/example-page'), + ); + + await expect(handleGetAppleDesignExamples({ + url: 'https://developer.apple.com/design/get-started/', + })).rejects.toThrow('outside the Apple Design content allowlist'); + expect(httpClient.get).toHaveBeenCalledTimes(1); + }); + + it('should extract HIG image examples from tabs', async () => { + const imageBytes = Buffer.from('tab-image'); + const documentWithTabs = { + ...SAMPLE_HIG_DOCUMENT, + references: {}, + primaryContentSections: [ + { + kind: 'content', + content: [ + { + type: 'tabNavigator', + tabs: [ + { + title: 'iOS', + content: [ + { + type: 'image', + url: '/assets/elements/icons/tab-example.png', + alt: 'A tabbed image', + }, + ], + }, + ], + }, + ], + }, + ], + }; + (httpClient.get as jest.Mock) + .mockResolvedValueOnce(createJsonResponse(documentWithTabs)) + .mockResolvedValueOnce(createResponse(imageBytes, 'image/png')); + const result = await handleGetAppleDesignExamples({ url: 'https://developer.apple.com/design/human-interface-guidelines/layout', limit: 1, From e3e99ffefdf77b0def06f4fed2d7cb757ef1c908 Mon Sep 17 00:00:00 2001 From: Alexander Goodkind Date: Mon, 29 Jun 2026 22:32:45 -0700 Subject: [PATCH 12/19] Try later Apple Design example candidates --- src/tools/design-docs.ts | 27 +++++++++++++++++++-------- tests/tools/design-docs.test.ts | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/src/tools/design-docs.ts b/src/tools/design-docs.ts index 2e83337..2895092 100644 --- a/src/tools/design-docs.ts +++ b/src/tools/design-docs.ts @@ -396,23 +396,34 @@ export async function handleGetAppleDesignExamples( ): Promise { const limit = args.limit ?? 3; const candidates = await collectImageCandidates(args); - const uniqueCandidates = dedupeImageCandidates(candidates).slice(0, limit); - const content: ContentBlock[] = [ - createTextContent(`Apple Design Examples\n\nFound ${uniqueCandidates.length} image example${uniqueCandidates.length === 1 ? '' : 's'}.`), - ]; + const uniqueCandidates = dedupeImageCandidates(candidates); + const imageContentBlocks: ContentBlock[] = []; for (const candidate of uniqueCandidates) { + if (imageContentBlocks.length >= limit) { + break; + } + const imageContent = await fetchImageContent(candidate.url); if (imageContent) { - content.push(imageContent); + imageContentBlocks.push(imageContent); } } - if (content.length === 1) { - content[0] = createTextContent('Apple Design Examples\n\nNo directly fetchable image examples were found.'); + if (imageContentBlocks.length === 0) { + return { + content: [ + createTextContent('Apple Design Examples\n\nNo directly fetchable image examples were found.'), + ], + }; } - return { content }; + return { + content: [ + createTextContent(`Apple Design Examples\n\nFound ${imageContentBlocks.length} image example${imageContentBlocks.length === 1 ? '' : 's'}.`), + ...imageContentBlocks, + ], + }; } /** diff --git a/tests/tools/design-docs.test.ts b/tests/tools/design-docs.test.ts index b9159c5..76d331e 100644 --- a/tests/tools/design-docs.test.ts +++ b/tests/tools/design-docs.test.ts @@ -782,6 +782,38 @@ describe('Apple Design examples', () => { expect(httpClient.get).toHaveBeenCalledTimes(1); }); + it('should keep trying example candidates until limit fetchable images are found', async () => { + const html = ` +
+ + +
+ `; + const imageBytes = Buffer.from('valid-preview'); + (httpClient.get as jest.Mock) + .mockResolvedValueOnce(createResponse(Buffer.from(html), 'text/html')) + .mockResolvedValueOnce(createResponse(Buffer.from('not-image'), 'text/plain')) + .mockResolvedValueOnce(createResponse(imageBytes, 'image/png')); + + const result = await handleGetAppleDesignExamples({ + url: 'https://developer.apple.com/design/get-started/', + limit: 1, + }); + + expect(result.content).toEqual([ + expect.objectContaining({ + type: 'text', + text: expect.stringContaining('Found 1 image example.'), + }), + expect.objectContaining({ + type: 'image', + data: imageBytes.toString('base64'), + mimeType: 'image/png', + }), + ]); + expect(httpClient.get).toHaveBeenCalledTimes(3); + }); + it('should extract HIG image examples from tabs', async () => { const imageBytes = Buffer.from('tab-image'); const documentWithTabs = { From 29f7f0915f59cde949b0de4e22dc59b300129e2c Mon Sep 17 00:00:00 2001 From: Alexander Goodkind Date: Mon, 29 Jun 2026 22:37:04 -0700 Subject: [PATCH 13/19] Skip failed Apple Design example thumbnails --- src/tools/design-docs.ts | 14 ++++++++++++-- tests/tools/design-docs.test.ts | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/tools/design-docs.ts b/src/tools/design-docs.ts index 2895092..f0a5bc5 100644 --- a/src/tools/design-docs.ts +++ b/src/tools/design-docs.ts @@ -82,6 +82,7 @@ interface DesignSearchResult { interface DesignImageCandidate { url: string; alt?: string; + strict?: boolean; } type AppleDesignUrlValidator = (url: string, description: string) => void; @@ -404,7 +405,16 @@ export async function handleGetAppleDesignExamples( break; } - const imageContent = await fetchImageContent(candidate.url); + let imageContent: ImageContent | null; + try { + imageContent = await fetchImageContent(candidate.url); + } catch (error) { + if (candidate.strict) { + throw error; + } + continue; + } + if (imageContent) { imageContentBlocks.push(imageContent); } @@ -918,7 +928,7 @@ async function collectImageCandidates(args: GetAppleDesignExamplesArgs): Promise const url = args.url; validateAppleDesignExampleUrl(url); if (isDirectAppleImageCandidateUrl(url)) { - candidates.push({ url }); + candidates.push({ url, strict: true }); } else { const jsonUrl = convertToDesignJsonApiUrl(url); if (jsonUrl) { diff --git a/tests/tools/design-docs.test.ts b/tests/tools/design-docs.test.ts index 76d331e..e877641 100644 --- a/tests/tools/design-docs.test.ts +++ b/tests/tools/design-docs.test.ts @@ -814,6 +814,38 @@ describe('Apple Design examples', () => { expect(httpClient.get).toHaveBeenCalledTimes(3); }); + it('should skip failed discovered image candidates and keep trying later candidates', async () => { + const html = ` +
+ + +
+ `; + const imageBytes = Buffer.from('fresh-preview'); + (httpClient.get as jest.Mock) + .mockResolvedValueOnce(createResponse(Buffer.from(html), 'text/html')) + .mockRejectedValueOnce(new Error('HTTP 404: Not Found')) + .mockResolvedValueOnce(createResponse(imageBytes, 'image/png')); + + const result = await handleGetAppleDesignExamples({ + url: 'https://developer.apple.com/design/get-started/', + limit: 1, + }); + + expect(result.content).toEqual([ + expect.objectContaining({ + type: 'text', + text: expect.stringContaining('Found 1 image example.'), + }), + expect.objectContaining({ + type: 'image', + data: imageBytes.toString('base64'), + mimeType: 'image/png', + }), + ]); + expect(httpClient.get).toHaveBeenCalledTimes(3); + }); + it('should extract HIG image examples from tabs', async () => { const imageBytes = Buffer.from('tab-image'); const documentWithTabs = { From 26ec39bd69962152d3936468b04d2a889562a7e9 Mon Sep 17 00:00:00 2001 From: Alexander Goodkind Date: Mon, 29 Jun 2026 22:45:09 -0700 Subject: [PATCH 14/19] Cap inline Apple Design download images --- src/tools/design-docs.ts | 6 +++++- tests/tools/design-docs.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/tools/design-docs.ts b/src/tools/design-docs.ts index f0a5bc5..6f5602d 100644 --- a/src/tools/design-docs.ts +++ b/src/tools/design-docs.ts @@ -27,6 +27,7 @@ const APPLE_DESIGN_DOWNLOAD_HOSTS = new Set([ const DEFAULT_DOWNLOAD_MAX_BYTES = 50 * 1024 * 1024; const DEFAULT_DOWNLOAD_CACHE_MAX_BYTES = 1024 * 1024 * 1024; const DEFAULT_PREVIEW_IMAGE_MAX_BYTES = 10 * 1024 * 1024; +const DEFAULT_INLINE_DOWNLOAD_IMAGE_MAX_BYTES = DEFAULT_PREVIEW_IMAGE_MAX_BYTES; const MAX_APPLE_DESIGN_REDIRECTS = 5; const DIRECT_RESOURCE_EXTENSIONS = new Set([ '.dmg', @@ -866,7 +867,10 @@ async function createDownloadedResourceContent( ), ]; - if (isImageMimeType(cachedResource.mimeType)) { + if ( + isImageMimeType(cachedResource.mimeType) + && cachedResource.size <= DEFAULT_INLINE_DOWNLOAD_IMAGE_MAX_BYTES + ) { const data = await readFile(cachedResource.filePath); content.push({ type: 'image', diff --git a/tests/tools/design-docs.test.ts b/tests/tools/design-docs.test.ts index e877641..e196d72 100644 --- a/tests/tools/design-docs.test.ts +++ b/tests/tools/design-docs.test.ts @@ -497,6 +497,31 @@ describe('Apple Design downloads and resources', () => { }); }); + it('should avoid inlining large downloaded images', async () => { + const imageBytes = Buffer.alloc((10 * 1024 * 1024) + 1, 1); + (httpClient.get as jest.Mock).mockResolvedValue( + createResponse(imageBytes, 'image/png'), + ); + + const result = await handleDownloadAppleDesignResource({ + url: 'https://developer.apple.com/design/images/large-preview.png', + maxBytes: imageBytes.length, + }); + + expect(result.content).toEqual([ + expect.objectContaining({ + type: 'text', + text: expect.stringContaining('Downloaded Apple Design resource'), + }), + expect.objectContaining({ + type: 'resource_link', + mimeType: 'image/png', + name: 'large-preview.png', + }), + ]); + expect(result.content.some(content => content.type === 'image')).toBe(false); + }); + it('should return resource links for non-image downloads with MIME fallback', async () => { const archiveBytes = Buffer.from('zip-bytes'); (httpClient.get as jest.Mock).mockResolvedValue( From f9585dedc39421b6125d60110f73126cda099c78 Mon Sep 17 00:00:00 2001 From: Alexander Goodkind Date: Mon, 29 Jun 2026 22:52:26 -0700 Subject: [PATCH 15/19] Stabilize Apple Design resource IDs --- src/tools/design-docs.ts | 6 +++- tests/tools/design-docs.test.ts | 59 ++++++++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/tools/design-docs.ts b/src/tools/design-docs.ts index 6f5602d..8d91bb5 100644 --- a/src/tools/design-docs.ts +++ b/src/tools/design-docs.ts @@ -1116,7 +1116,7 @@ function parseDesignResourceItem( seenLinks.add(linkKey); const format = inferFormat(downloadUrl, label); - const resourceId = createResourceId(category, platform, label, seenResourceIds); + const resourceId = createResourceId(category, platform, title, label, downloadUrl, seenResourceIds); entries.push({ resourceId, category, @@ -1645,14 +1645,18 @@ function isResourceCatalogLink( function createResourceId( category: string, platform: string | undefined, + title: string, label: string, + downloadUrl: string, seenResourceIds: Map, ): string { const baseId = [ 'design-resource', slugify(category), slugify(platform || 'all'), + slugify(title), slugify(label), + hashString(downloadUrl).slice(0, 12), ].join(':'); const seenCount = seenResourceIds.get(baseId) ?? 0; seenResourceIds.set(baseId, seenCount + 1); diff --git a/tests/tools/design-docs.test.ts b/tests/tools/design-docs.test.ts index e196d72..9feee12 100644 --- a/tests/tools/design-docs.test.ts +++ b/tests/tools/design-docs.test.ts @@ -257,6 +257,10 @@ function createJsonResponse(data: unknown): Response { return createResponse(Buffer.from(JSON.stringify(data)), 'application/json'); } +function createShortHash(value: string): string { + return createHash('sha256').update(value).digest('hex').slice(0, 12); +} + function createResponseWithUrl( data: Buffer, contentType: string | undefined, @@ -429,7 +433,7 @@ describe('Apple Design Resources parser', () => { expect(resources).toHaveLength(4); expect(resources[0]).toMatchObject({ - resourceId: 'design-resource:design-templates:ios-18-and-ipados-18:figma', + resourceId: `design-resource:design-templates:ios-18-and-ipados-18:ios-18-and-ipados-18:figma:${createShortHash('https://www.figma.com/community/file/123')}`, category: 'Design templates', platform: 'iOS 18 and iPadOS 18', title: 'iOS 18 and iPadOS 18', @@ -456,6 +460,59 @@ describe('Apple Design Resources parser', () => { title: 'SF Pro', }); }); + + it('should keep resource IDs stable when same-label resources reorder', () => { + const firstHtml = ` +
+
+

Design templates

+

iOS

+
+
Alpha template
+ Download +
+
+
Beta template
+ Download +
+
+
+ `; + const secondHtml = ` +
+
+

Design templates

+

iOS

+
+
Beta template
+ Download +
+
+
Alpha template
+ Download +
+
+
+ `; + + const firstResources = parseDesignResourcesHtml( + firstHtml, + 'https://developer.apple.com/design/resources/', + ); + const secondResources = parseDesignResourcesHtml( + secondHtml, + 'https://developer.apple.com/design/resources/', + ); + const firstAlpha = firstResources.find(resource => resource.downloadUrl.endsWith('/alpha.zip')); + const secondAlpha = secondResources.find(resource => resource.downloadUrl.endsWith('/alpha.zip')); + const firstBeta = firstResources.find(resource => resource.downloadUrl.endsWith('/beta.zip')); + const secondBeta = secondResources.find(resource => resource.downloadUrl.endsWith('/beta.zip')); + + expect(firstAlpha?.resourceId).toBe(secondAlpha?.resourceId); + expect(firstBeta?.resourceId).toBe(secondBeta?.resourceId); + expect(firstAlpha?.resourceId).not.toBe(firstBeta?.resourceId); + expect(firstAlpha?.resourceId).toContain(createShortHash('https://developer.apple.com/design/downloads/alpha.zip')); + }); }); describe('Apple Design downloads and resources', () => { From 2403039a5758f658a478b0d679287a677c62a01e Mon Sep 17 00:00:00 2001 From: Alexander Goodkind Date: Mon, 29 Jun 2026 23:08:01 -0700 Subject: [PATCH 16/19] Bound Apple Design content reads --- src/tools/design-docs.ts | 15 +++++++++++-- tests/tools/design-docs.test.ts | 38 +++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/tools/design-docs.ts b/src/tools/design-docs.ts index 8d91bb5..5044075 100644 --- a/src/tools/design-docs.ts +++ b/src/tools/design-docs.ts @@ -28,6 +28,7 @@ const DEFAULT_DOWNLOAD_MAX_BYTES = 50 * 1024 * 1024; const DEFAULT_DOWNLOAD_CACHE_MAX_BYTES = 1024 * 1024 * 1024; const DEFAULT_PREVIEW_IMAGE_MAX_BYTES = 10 * 1024 * 1024; const DEFAULT_INLINE_DOWNLOAD_IMAGE_MAX_BYTES = DEFAULT_PREVIEW_IMAGE_MAX_BYTES; +const DEFAULT_DESIGN_CONTENT_MAX_BYTES = 20 * 1024 * 1024; const MAX_APPLE_DESIGN_REDIRECTS = 5; const DIRECT_RESOURCE_EXTENSIONS = new Set([ '.dmg', @@ -1779,9 +1780,14 @@ async function fetchAppleDesignHtml(url: string): Promise<{ html: string; finalU validateUrl: validateAppleDesignContentRedirectUrl, }, ); + const data = await readLimitedResponseBytes( + response, + DEFAULT_DESIGN_CONTENT_MAX_BYTES, + 'Apple Design content page', + ); return { - html: await response.text(), + html: data.toString('utf8'), finalUrl, }; } @@ -1797,8 +1803,13 @@ async function fetchAppleDesignJson(url: string): Promise { validateUrl: validateAppleDesignJsonRedirectUrl, }, ); + const data = await readLimitedResponseBytes( + response, + DEFAULT_DESIGN_CONTENT_MAX_BYTES, + 'Apple Design JSON', + ); - return await response.json() as unknown; + return JSON.parse(data.toString('utf8')) as unknown; } async function fetchAppleDesignAssetResponse( diff --git a/tests/tools/design-docs.test.ts b/tests/tools/design-docs.test.ts index 9feee12..c2727b9 100644 --- a/tests/tools/design-docs.test.ts +++ b/tests/tools/design-docs.test.ts @@ -360,6 +360,44 @@ describe('Apple Design document formatting', () => { expect(httpClient.get).toHaveBeenCalledTimes(1); }); + it('should reject oversized Apple Design HTML before reading the body', async () => { + const arrayBuffer = jest.fn(); + const oversizedResponse = { + headers: new Headers({ + 'content-length': String((20 * 1024 * 1024) + 1), + 'content-type': 'text/html', + }), + status: 200, + body: null, + arrayBuffer, + } as unknown as Response; + (httpClient.get as jest.Mock).mockResolvedValue(oversizedResponse); + + await expect(handleGetAppleDesignContent({ + url: 'https://developer.apple.com/design/', + })).rejects.toThrow('Apple Design content page exceeds'); + expect(arrayBuffer).not.toHaveBeenCalled(); + }); + + it('should reject oversized Apple Design JSON before reading the body', async () => { + const arrayBuffer = jest.fn(); + const oversizedResponse = { + headers: new Headers({ + 'content-length': String((20 * 1024 * 1024) + 1), + 'content-type': 'application/json', + }), + status: 200, + body: null, + arrayBuffer, + } as unknown as Response; + (httpClient.get as jest.Mock).mockResolvedValue(oversizedResponse); + + await expect(handleGetAppleDesignContent({ + url: 'https://developer.apple.com/design/human-interface-guidelines/layout', + })).rejects.toThrow('Apple Design JSON exceeds'); + expect(arrayBuffer).not.toHaveBeenCalled(); + }); + it('should format HIG JSON content with images, tables, links, platforms, and change logs', () => { const result = formatAppleDesignDocument( SAMPLE_HIG_DOCUMENT, From cc15d2fd491d539bcd2f7bd290b188037f40c406 Mon Sep 17 00:00:00 2001 From: Alexander Goodkind Date: Mon, 29 Jun 2026 23:13:18 -0700 Subject: [PATCH 17/19] Reuse in-flight Apple Design downloads --- src/tools/design-docs.ts | 36 +++++++++++++++++++++++++++++++++ tests/tools/design-docs.test.ts | 24 ++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/src/tools/design-docs.ts b/src/tools/design-docs.ts index 5044075..04d5dba 100644 --- a/src/tools/design-docs.ts +++ b/src/tools/design-docs.ts @@ -87,6 +87,11 @@ interface DesignImageCandidate { strict?: boolean; } +interface InFlightDesignDownload { + byteLimit: number; + promise: Promise; +} + type AppleDesignUrlValidator = (url: string, description: string) => void; export interface SearchAppleDesignDocsArgs { @@ -119,6 +124,7 @@ export interface GetAppleDesignExamplesArgs { const cachedResourcesByUri = new Map(); const cachedResourcesBySourceUrl = new Map(); +const inFlightDownloadsBySourceUrl = new Map(); const resourceCatalogById = new Map(); let defaultDesignCacheDirectory: string | undefined; let designCacheWriteQueue: Promise = Promise.resolve(); @@ -489,6 +495,7 @@ export function clearDesignResourceCacheForTesting(): void { designResourcesCache.clear(); cachedResourcesByUri.clear(); cachedResourcesBySourceUrl.clear(); + inFlightDownloadsBySourceUrl.clear(); resourceCatalogById.clear(); designCacheWriteQueue = Promise.resolve(); } @@ -811,6 +818,35 @@ async function downloadDesignResource( return cachedResource; } + const inFlightDownload = inFlightDownloadsBySourceUrl.get(normalizedSourceUrl); + if (inFlightDownload && inFlightDownload.byteLimit >= byteLimit) { + const downloadedResource = await inFlightDownload.promise; + if (downloadedResource.size > byteLimit) { + throw new Error(`Apple Design resource exceeds the ${byteLimit} byte download limit.`); + } + return downloadedResource; + } + + const downloadPromise = downloadDesignResourceToCache(resource, byteLimit, normalizedSourceUrl); + inFlightDownloadsBySourceUrl.set(normalizedSourceUrl, { + byteLimit, + promise: downloadPromise, + }); + + try { + return await downloadPromise; + } finally { + if (inFlightDownloadsBySourceUrl.get(normalizedSourceUrl)?.promise === downloadPromise) { + inFlightDownloadsBySourceUrl.delete(normalizedSourceUrl); + } + } +} + +async function downloadDesignResourceToCache( + resource: DesignResourceCatalogEntry, + byteLimit: number, + normalizedSourceUrl: string, +): Promise { const { response, finalUrl, diff --git a/tests/tools/design-docs.test.ts b/tests/tools/design-docs.test.ts index c2727b9..ea397a4 100644 --- a/tests/tools/design-docs.test.ts +++ b/tests/tools/design-docs.test.ts @@ -746,6 +746,30 @@ describe('Apple Design downloads and resources', () => { expect(firstLink?.uri).toBe(secondLink?.uri); }); + it('should reuse in-flight duplicate downloads for the same URL', async () => { + const archiveBytes = Buffer.from('zip-bytes'); + let resolveResponse: (response: Response) => void = () => undefined; + const responsePromise = new Promise((resolve) => { + resolveResponse = resolve; + }); + (httpClient.get as jest.Mock).mockReturnValue(responsePromise); + + const firstPromise = handleDownloadAppleDesignResource({ + url: 'https://developer.apple.com/design/downloads/templates.zip', + }); + const secondPromise = handleDownloadAppleDesignResource({ + url: 'https://developer.apple.com/design/downloads/templates.zip', + }); + + resolveResponse(createResponse(archiveBytes, 'application/zip')); + const [firstResult, secondResult] = await Promise.all([firstPromise, secondPromise]); + const firstLink = firstResult.content.find(content => content.type === 'resource_link'); + const secondLink = secondResult.content.find(content => content.type === 'resource_link'); + + expect(httpClient.get).toHaveBeenCalledTimes(1); + expect(firstLink?.uri).toBe(secondLink?.uri); + }); + it('should enforce smaller maxBytes limits on duplicate cache hits', async () => { const archiveBytes = Buffer.from('zip-bytes'); (httpClient.get as jest.Mock).mockResolvedValue( From 111f92dbbb5dfa6f36e05a3efa041b0eef351101 Mon Sep 17 00:00:00 2001 From: Alexander Goodkind Date: Tue, 30 Jun 2026 23:28:20 -0700 Subject: [PATCH 18/19] Address Apple Design review comments --- src/index.ts | 89 ++++++++++++----- src/schemas/design.schema.ts | 6 +- src/tools/design-docs.ts | 144 +++++++++++++++++++++++++-- src/utils/http-client.ts | 1 + tests/response-format.test.ts | 13 +++ tests/tools/design-docs.test.ts | 171 +++++++++++++++++++++++++++++++- 6 files changed, 384 insertions(+), 40 deletions(-) diff --git a/src/index.ts b/src/index.ts index d157b0e..dad03d3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,6 +32,7 @@ import { readCachedDesignResource, } from './tools/design-docs.js'; import { APPLE_URLS } from './utils/constants.js'; +import type { AppError } from './types/error.js'; import { isAppleDesignUrl, isValidAppleDeveloperUrl } from './utils/url-converter.js'; import { validateInput, ErrorType, createStandardErrorResponse, createToolErrorResponse } from './utils/error-handler.js'; import { httpClient } from './utils/http-client.js'; @@ -40,6 +41,15 @@ import { warmUpCaches, schedulePeriodicCacheRefresh } from './utils/cache-warmer import { logger } from './utils/logger.js'; import { API_LIMITS } from './utils/constants.js'; +function isAppError(error: unknown): error is AppError { + return ( + typeof error === 'object' + && error !== null + && 'type' in error + && 'message' in error + ); +} + export default class AppleDeveloperDocsMCPServer { private server: Server; @@ -69,6 +79,20 @@ export default class AppleDeveloperDocsMCPServer { } } + private async handleToolResultOperation( + operation: () => Promise, + operationName: string, + ): Promise { + try { + return await operation(); + } catch (error) { + if (isAppError(error)) { + return createToolErrorResponse(error, operationName) as CallToolResult; + } + return createStandardErrorResponse(error, operationName) as CallToolResult; + } + } + constructor() { this.server = new Server( { @@ -219,16 +243,22 @@ export default class AppleDeveloperDocsMCPServer { platform: string = 'all', limit: number = 20, ) { - return await handleSearchAppleDesignDocs({ - query, - contentType, - platform, - limit, - }); + return this.handleToolResultOperation( + () => handleSearchAppleDesignDocs({ + query, + contentType, + platform, + limit, + }), + 'search_apple_design_docs', + ); } public async getAppleDesignContent(url: string) { - return await handleGetAppleDesignContent({ url }); + return this.handleToolResultOperation( + () => handleGetAppleDesignContent({ url }), + 'get_apple_design_content', + ); } public async listAppleDesignResources( @@ -238,13 +268,16 @@ export default class AppleDeveloperDocsMCPServer { searchQuery?: string, limit: number = 50, ) { - return await handleListAppleDesignResources({ - category, - platform, - format, - searchQuery, - limit, - }); + return this.handleToolResultOperation( + () => handleListAppleDesignResources({ + category, + platform, + format, + searchQuery, + limit, + }), + 'list_apple_design_resources', + ); } public async downloadAppleDesignResource( @@ -252,11 +285,14 @@ export default class AppleDeveloperDocsMCPServer { url?: string, maxBytes?: number, ) { - return await handleDownloadAppleDesignResource({ - resourceId, - url, - maxBytes, - }); + return this.handleToolResultOperation( + () => handleDownloadAppleDesignResource({ + resourceId, + url, + maxBytes, + }), + 'download_apple_design_resource', + ); } public async getAppleDesignExamples( @@ -265,12 +301,15 @@ export default class AppleDeveloperDocsMCPServer { query?: string, limit: number = 3, ) { - return await handleGetAppleDesignExamples({ - url, - resourceId, - query, - limit, - }); + return this.handleToolResultOperation( + () => handleGetAppleDesignExamples({ + url, + resourceId, + query, + limit, + }), + 'get_apple_design_examples', + ); } public async searchFrameworkSymbols(framework: string, symbolType: string = 'all', namePattern?: string, language: string = 'swift', limit: number = API_LIMITS.DEFAULT_FRAMEWORK_SYMBOLS_LIMIT) { diff --git a/src/schemas/design.schema.ts b/src/schemas/design.schema.ts index 391b597..8794296 100644 --- a/src/schemas/design.schema.ts +++ b/src/schemas/design.schema.ts @@ -11,7 +11,7 @@ export const searchAppleDesignDocsSchema = z.object({ }); export const getAppleDesignContentSchema = z.object({ - url: z.string().url().describe('Apple Design URL to read'), + url: z.url().describe('Apple Design URL to read'), }); export const listAppleDesignResourcesSchema = z.object({ @@ -25,13 +25,13 @@ export const listAppleDesignResourcesSchema = z.object({ export const downloadAppleDesignResourceSchema = z.object({ resourceId: z.string().optional().describe('Resource ID returned by list_apple_design_resources'), - url: z.string().url().optional().describe('Direct Apple-hosted resource URL to download'), + url: z.url().optional().describe('Direct Apple-hosted resource URL to download'), maxBytes: z.number().int().min(1).max(250 * 1024 * 1024).optional() .describe('Maximum download size in bytes'), }); export const getAppleDesignExamplesSchema = z.object({ - url: z.string().url().optional().describe('Apple Design, HIG, preview, or image URL'), + url: z.url().optional().describe('Apple Design, HIG, preview, or image URL'), resourceId: z.string().optional().describe('Resource ID returned by list_apple_design_resources'), query: z.string().optional().describe('Search query for examples and thumbnails'), limit: z.number().int().min(1).max(10).default(3) diff --git a/src/tools/design-docs.ts b/src/tools/design-docs.ts index 04d5dba..bf7fe09 100644 --- a/src/tools/design-docs.ts +++ b/src/tools/design-docs.ts @@ -469,11 +469,7 @@ export async function listCachedDesignResources(): Promise * @returns MCP resources/read result. */ export async function readCachedDesignResource(uri: string): Promise { - const cachedResource = cachedResourcesByUri.get(uri); - if (!cachedResource) { - throw new Error(`Unknown Apple Design resource URI: ${uri}`); - } - + const cachedResource = await getCachedResourceForRead(uri); const data = await readFile(cachedResource.filePath); return { contents: [ @@ -486,6 +482,112 @@ export async function readCachedDesignResource(uri: string): Promise { + const cachedResource = cachedResourcesByUri.get(uri); + if (cachedResource) { + return cachedResource; + } + + const persistedResource = await resolvePersistedCachedResource(uri); + if (persistedResource) { + cachedResourcesByUri.set(uri, persistedResource); + return persistedResource; + } + + throw new Error(`Unknown Apple Design resource URI: ${uri}`); +} + +async function resolvePersistedCachedResource(uri: string): Promise { + const parsedUri = parseCachedResourceUri(uri); + if (!parsedUri) { + return undefined; + } + + const cacheDirectory = path.resolve(getDesignCacheDirectory()); + const filePath = path.resolve(cacheDirectory, parsedUri.cacheFilename); + const relativePath = path.relative(cacheDirectory, filePath); + if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + return undefined; + } + + let fileStats; + try { + fileStats = await stat(filePath); + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) { + return undefined; + } + throw error; + } + + if (!fileStats.isFile()) { + return undefined; + } + + const name = getNameFromCacheFilename(parsedUri.hash, parsedUri.cacheFilename); + return { + uri, + name, + title: name, + description: 'Persisted Apple Design resource', + mimeType: detectMimeType(undefined, parsedUri.cacheFilename), + filePath, + sourceUrl: '', + size: fileStats.size, + }; +} + +function parseCachedResourceUri(uri: string): { hash: string; cacheFilename: string } | undefined { + if (!uri.startsWith(RESOURCE_URI_PREFIX)) { + return undefined; + } + + const suffix = uri.slice(RESOURCE_URI_PREFIX.length); + const parts = suffix.split('/'); + if (parts.length !== 2) { + return undefined; + } + + const [hash, cacheFilename] = parts; + if (!hash || !/^[a-f0-9]{64}$/.test(hash)) { + return undefined; + } + + if ( + !cacheFilename + || cacheFilename.includes('\\') + || cacheFilename.includes('\0') + || cacheFilename === '.' + || cacheFilename === '..' + || cacheFilename !== path.basename(cacheFilename) + || !cacheFilename.startsWith(`${hash}-`) + ) { + return undefined; + } + + return { + hash, + cacheFilename, + }; +} + +function getNameFromCacheFilename(hash: string, cacheFilename: string): string { + const filenameWithUuid = cacheFilename.slice(`${hash}-`.length); + if (filenameWithUuid.length > 37 && filenameWithUuid[36] === '-') { + return filenameWithUuid.slice(37); + } + + return cacheFilename; +} + +async function cancelResponseBody(response: Response): Promise { + if (!response.body) { + return; + } + + await response.body.cancel().catch(() => undefined); +} + /** * Clear process-local Design download state for tests. * @returns Nothing. @@ -955,12 +1057,18 @@ async function collectImageCandidates(args: GetAppleDesignExamplesArgs): Promise searchQuery: args.query, limit: args.limit, }); + const queryPreviewLimit = args.limit ?? 3; + let queryPreviewCount = 0; for (const resource of resources) { if (resource.previewImageUrl) { candidates.push({ url: resource.previewImageUrl, alt: resource.title, }); + queryPreviewCount++; + if (queryPreviewCount >= queryPreviewLimit) { + break; + } } } } @@ -1273,9 +1381,10 @@ function formatDesignBlock( const type = getString(record, 'type') ?? getString(record, 'kind'); if (type === 'heading') { - const level = getNumber(record, 'level') ?? headingLevel; + const rawLevel = getNumber(record, 'level') ?? headingLevel; + const level = Math.min(Math.max(Math.trunc(rawLevel), 1), 6); const text = getString(record, 'text') ?? renderInlineCollection(getArray(record.inlineContent), references, sourceUrl); - return text ? `${'#'.repeat(Math.min(level, 6))} ${text}\n\n` : ''; + return text ? `${'#'.repeat(level)} ${text}\n\n` : ''; } if (type === 'paragraph') { @@ -1887,11 +1996,20 @@ async function fetchAppleDesignManualRedirectResponse( } if (redirectCount === MAX_APPLE_DESIGN_REDIRECTS) { + await cancelResponseBody(response); throw new Error(`${description} exceeded the ${MAX_APPLE_DESIGN_REDIRECTS} redirect limit.`); } - const redirectUrl = getAppleDesignRedirectUrl(response, currentUrl, description); - options.validateUrl(redirectUrl, description); + let redirectUrl: string; + try { + redirectUrl = getAppleDesignRedirectUrl(response, currentUrl, description); + options.validateUrl(redirectUrl, description); + } catch (error) { + await cancelResponseBody(response); + throw error; + } + + await cancelResponseBody(response); currentUrl = redirectUrl; } @@ -2041,6 +2159,7 @@ async function readLimitedResponseBytes( ): Promise { const contentLength = getContentLength(response); if (contentLength !== undefined && contentLength > byteLimit) { + await cancelResponseBody(response); throw new Error(`${description} exceeds the ${byteLimit} byte download limit.`); } @@ -2102,11 +2221,18 @@ async function writeExclusiveDesignCacheFile( try { const fileHandle = await open(filePath, 'wx', 0o600); + let writeError: unknown; try { await fileHandle.writeFile(data); + } catch (error) { + writeError = error; } finally { await fileHandle.close(); } + if (writeError) { + await rm(filePath, { force: true }).catch(() => undefined); + throw writeError; + } return { cacheFilename, diff --git a/src/utils/http-client.ts b/src/utils/http-client.ts index 16c6b49..55e20d2 100644 --- a/src/utils/http-client.ts +++ b/src/utils/http-client.ts @@ -241,6 +241,7 @@ class HttpClient { && response.status >= 300 && response.status < 400; + // Manual redirect callers rely on redirect: 'manual' preserving the 3xx status and Location header. // Mark User-Agent success/failure in pool if (pool && currentUserAgent) { if (response?.ok || isAllowedManualRedirect) { diff --git a/tests/response-format.test.ts b/tests/response-format.test.ts index 9760e45..7d4c2e8 100644 --- a/tests/response-format.test.ts +++ b/tests/response-format.test.ts @@ -19,6 +19,7 @@ jest.mock('../src/utils/wwdc-data-source.js', () => ({ })); import AppleDeveloperDocsMCPServer from '../src/index.js'; +import { handleDownloadAppleDesignResource } from '../src/tools/design-docs.js'; // Mock external dependencies jest.mock('../src/utils/http-client.js', () => ({ @@ -326,6 +327,18 @@ describe('Response Format Validation', () => { validateResponseFormat(response); expect(response.isError).toBe(true); }); + + it('should format Apple Design handler errors without nesting', async () => { + (handleDownloadAppleDesignResource as jest.Mock).mockRejectedValueOnce( + new Error('A resourceId or direct URL is required.'), + ); + + const response = await server.downloadAppleDesignResource(); + + validateResponseFormat(response); + expect(response.isError).toBe(true); + expect(response.content[0].text).toContain('A resourceId or direct URL is required.'); + }); }); describe('Regression tests for nested response issue', () => { diff --git a/tests/tools/design-docs.test.ts b/tests/tools/design-docs.test.ts index ea397a4..13d8605 100644 --- a/tests/tools/design-docs.test.ts +++ b/tests/tools/design-docs.test.ts @@ -282,6 +282,18 @@ function createRedirectResponse(location: string): Response { }); } +function createCancelableRedirectResponse(location: string, cancel: () => Promise): Response { + return { + status: 302, + headers: new Headers({ + location, + }), + body: { + cancel, + }, + } as unknown as Response; +} + beforeEach(async () => { temporaryCacheDirectory = await mkdtemp(path.join(tmpdir(), 'apple-design-test-')); process.env.APPLE_DOCS_MCP_CACHE_DIR = temporaryCacheDirectory; @@ -319,8 +331,12 @@ describe('Apple Design document formatting', () => {

Download templates.

`; + const cancelBody = jest.fn(async () => undefined); (httpClient.get as jest.Mock) - .mockResolvedValueOnce(createRedirectResponse('https://developer.apple.com/design/resources/')) + .mockResolvedValueOnce(createCancelableRedirectResponse( + 'https://developer.apple.com/design/resources/', + cancelBody, + )) .mockResolvedValueOnce(createResponse(Buffer.from(html), 'text/html')); const result = await handleGetAppleDesignContent({ @@ -347,6 +363,7 @@ describe('Apple Design document formatting', () => { redirect: 'manual', }), ); + expect(cancelBody).toHaveBeenCalledTimes(1); }); it('should reject Apple Design content redirects outside the content allowlist', async () => { @@ -362,13 +379,16 @@ describe('Apple Design document formatting', () => { it('should reject oversized Apple Design HTML before reading the body', async () => { const arrayBuffer = jest.fn(); + const cancelBody = jest.fn(async () => undefined); const oversizedResponse = { headers: new Headers({ 'content-length': String((20 * 1024 * 1024) + 1), 'content-type': 'text/html', }), status: 200, - body: null, + body: { + cancel: cancelBody, + }, arrayBuffer, } as unknown as Response; (httpClient.get as jest.Mock).mockResolvedValue(oversizedResponse); @@ -377,6 +397,40 @@ describe('Apple Design document formatting', () => { url: 'https://developer.apple.com/design/', })).rejects.toThrow('Apple Design content page exceeds'); expect(arrayBuffer).not.toHaveBeenCalled(); + expect(cancelBody).toHaveBeenCalledTimes(1); + }); + + it('should clamp invalid HIG heading levels', () => { + const document = { + metadata: { + title: 'Heading levels', + }, + primaryContentSections: [ + { + kind: 'content', + content: [ + { + type: 'heading', + text: 'Negative heading', + level: -1, + }, + { + type: 'heading', + text: 'Large heading', + level: 20, + }, + ], + }, + ], + }; + + const result = formatAppleDesignDocument( + document, + 'https://developer.apple.com/design/human-interface-guidelines/layout', + ); + + expect(result).toContain('# Negative heading'); + expect(result).toContain('###### Large heading'); }); it('should reject oversized Apple Design JSON before reading the body', async () => { @@ -551,6 +605,38 @@ describe('Apple Design Resources parser', () => { expect(firstAlpha?.resourceId).not.toBe(firstBeta?.resourceId); expect(firstAlpha?.resourceId).toContain(createShortHash('https://developer.apple.com/design/downloads/alpha.zip')); }); + + it('should suffix resource IDs for exact duplicate resource entries', () => { + const html = ` +
+
+

Design templates

+

iOS

+
+
Duplicate template
+ Download +
+
+
Duplicate template
+ Download +
+
+
+ `; + + const resources = parseDesignResourcesHtml( + html, + 'https://developer.apple.com/design/resources/', + ); + const baseResourceId = `design-resource:design-templates:ios:duplicate-template:download:${ + createShortHash('https://developer.apple.com/design/downloads/duplicate.zip') + }`; + + expect(resources.map(resource => resource.resourceId)).toEqual([ + baseResourceId, + `${baseResourceId}:2`, + ]); + }); }); describe('Apple Design downloads and resources', () => { @@ -592,6 +678,37 @@ describe('Apple Design downloads and resources', () => { }); }); + it('should read persisted resource URIs after process-local cache state is cleared', async () => { + const imageBytes = Buffer.from('persisted-image-bytes'); + (httpClient.get as jest.Mock).mockResolvedValue( + createResponse(imageBytes, 'image/png'), + ); + + const result = await handleDownloadAppleDesignResource({ + url: 'https://developer.apple.com/design/images/persisted.png', + }); + const resourceLink = result.content.find(content => content.type === 'resource_link'); + const uri = resourceLink?.uri ?? ''; + + clearDesignResourceCacheForTesting(); + + const resource = await readCachedDesignResource(uri); + expect(resource.contents[0]).toMatchObject({ + uri, + mimeType: 'image/png', + blob: imageBytes.toString('base64'), + }); + + const resources = await listCachedDesignResources(); + expect(resources.resources).toEqual([ + expect.objectContaining({ + uri, + mimeType: 'image/png', + name: 'persisted.png', + }), + ]); + }); + it('should avoid inlining large downloaded images', async () => { const imageBytes = Buffer.alloc((10 * 1024 * 1024) + 1, 1); (httpClient.get as jest.Mock).mockResolvedValue( @@ -648,8 +765,12 @@ describe('Apple Design downloads and resources', () => { it('should follow allowed Apple download redirects manually', async () => { const archiveBytes = Buffer.from('zip-bytes'); + const cancelBody = jest.fn(async () => undefined); (httpClient.get as jest.Mock) - .mockResolvedValueOnce(createRedirectResponse('https://devimages-cdn.apple.com/design/templates.zip')) + .mockResolvedValueOnce(createCancelableRedirectResponse( + 'https://devimages-cdn.apple.com/design/templates.zip', + cancelBody, + )) .mockResolvedValueOnce(createResponse(archiveBytes, 'application/zip')); const result = await handleDownloadAppleDesignResource({ @@ -683,6 +804,7 @@ describe('Apple Design downloads and resources', () => { redirect: 'manual', }), ); + expect(cancelBody).toHaveBeenCalledTimes(1); }); it('should reject download redirects outside the Apple allowlist before fetching them', async () => { @@ -958,6 +1080,49 @@ describe('Apple Design examples', () => { expect(httpClient.get).toHaveBeenCalledTimes(3); }); + it('should cap query-derived preview fetches by the requested limit', async () => { + const html = ` +
+
+

Design templates

+

iOS

+
+ +
Template one
+ Download +
+
+ +
Template two
+ Download +
+
+
+ `; + const imageBytes = Buffer.from('template-one'); + (httpClient.get as jest.Mock) + .mockResolvedValueOnce(createResponse(Buffer.from(html), 'text/html')) + .mockResolvedValueOnce(createResponse(imageBytes, 'image/png')); + + const result = await handleGetAppleDesignExamples({ + query: 'template', + limit: 1, + }); + + expect(result.content).toEqual([ + expect.objectContaining({ + type: 'text', + text: expect.stringContaining('Found 1 image example.'), + }), + expect.objectContaining({ + type: 'image', + data: imageBytes.toString('base64'), + mimeType: 'image/png', + }), + ]); + expect(httpClient.get).toHaveBeenCalledTimes(2); + }); + it('should skip failed discovered image candidates and keep trying later candidates', async () => { const html = `
From 883d930099149489da0b1fc5666895c9ce8412a4 Mon Sep 17 00:00:00 2001 From: Alex Goodkind Date: Tue, 30 Jun 2026 23:37:10 -0700 Subject: [PATCH 19/19] Update pnpm-workspace.yaml --- pnpm-workspace.yaml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e02dbda..e4aab11 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,2 @@ packages: - - '.' -allowBuilds: - esbuild: false - unrs-resolver: false + - '.' \ No newline at end of file