Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
"$schema": "https://biomejs.dev/schemas/2.2.4/schema.json",
"assist": { "actions": { "source": { "organizeImports": "on" } } },
"linter": {
"enabled": true,
Expand Down
23 changes: 11 additions & 12 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"test:unit": "vitest --run tests/unit",
"test:e2e": "vitest --run tests/e2e",
"test:unit:coverage": "vitest --run tests/unit --coverage.enabled --coverage.thresholds.100 --coverage.include='src/**'",
"prebuild": "rimraf dist/*",
"prebuild": "rimraf dist/* tsconfig.tsbuildinfo",
"build": "tsc --build",
"lint": "biome lint .",
"lint:fix": "biome check --write .",
Expand Down
4 changes: 2 additions & 2 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import {
const MCP_SERVER_NAME = 'powertools-for-aws-mcp' as const;

// Allowed domain for security
const ALLOWED_DOMAIN = 'docs.powertools.aws.dev';
const ALLOWED_DOMAIN = 'docs.aws.amazon.com';
// Base URL for Powertools documentation
const POWERTOOLS_BASE_URL = 'https://docs.powertools.aws.dev/lambda';
const POWERTOOLS_BASE_URL = 'https://docs.aws.amazon.com/powertools';

const FETCH_TIMEOUT_MS = 15000; // 15 seconds timeout for fetch operations

Expand Down
76 changes: 53 additions & 23 deletions src/tools/searchDocs/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,21 @@ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import lunr from 'lunr';
import {
POWERTOOLS_BASE_URL,
SEARCH_CONFIDENCE_THRESHOLD,
} from '../../constants.ts';
import { logger } from '../../logger.ts';
import { buildResponse } from '../shared/buildResponse.ts';
import { fetchWithCache } from '../shared/fetchWithCache.ts';
import type { ToolProps } from './types.ts';
import type { ToolProps, MkDocsSearchIndex } from './types.ts';

/**
* Search for documentation based on the provided parameters.
*
* This tool fetches a search index from the Powertools for AWS documentation,
* hydrates it into a Lunr index, and performs a search based on the provided query.
*
* The search index is expected to be in a specific format, and the results
* are filtered based on a confidence threshold to ensure relevance. This threshold
* can be configured via the `SEARCH_CONFIDENCE_THRESHOLD` environment variable.
* The search index is expected to be in the full MkDocs Material format with proper
* field boosting (1000x for titles, 1M for tags) to match the online search experience.
* Results are filtered based on a confidence threshold to ensure relevance.
*
* This tool is designed to work with the Powertools for AWS documentation
* for various runtimes, including Python and TypeScript, and supports versioning.
Expand All @@ -45,19 +44,36 @@ const tool = async (props: ToolProps): Promise<CallToolResult> => {
const urlSuffix = '/search/search_index.json';
url.pathname = `${url.pathname}${urlSuffix}`;

let searchIndexContent: {
docs: { location: string; title: string; text: string }[];
};
let searchIndex: MkDocsSearchIndex;
try {
const content = await fetchWithCache({
url,
contentType: 'application/json',
});
searchIndexContent = JSON.parse(content);
if (
isNullOrUndefined(searchIndexContent.docs) ||
!Array.isArray(searchIndexContent.docs)
) {
const rawIndex = JSON.parse(content);

// Handle both full MkDocs index and simplified format for backward compatibility
if (rawIndex.docs && Array.isArray(rawIndex.docs)) {
if (rawIndex.config?.lang) {
// Full MkDocs search index format
searchIndex = rawIndex as MkDocsSearchIndex;
} else {
// Simplified format - convert to full structure
searchIndex = {
config: {
lang: ['en'],
separator: '[\\s\\-]+',
pipeline: ['stopWordFilter', 'stemmer']
},
docs: rawIndex.docs,
options: { suggest: false }
};
}
} else {
throw new Error('Invalid search index format: missing docs array');
}

if (isNullOrUndefined(searchIndex.docs) || !Array.isArray(searchIndex.docs)) {
throw new Error(
`Invalid search index format for ${runtime} ${version}: missing 'docs' property`
);
Expand All @@ -72,36 +88,50 @@ const tool = async (props: ToolProps): Promise<CallToolResult> => {
});
}

// TODO: consume built/exported search index - #79
// Build Lunr index with proper MkDocs Material configuration
const index = lunr(function () {
// Apply language configuration if not English
if (searchIndex.config.lang.length === 1 && searchIndex.config.lang[0] !== "en") {
// Note: This would require language-specific Lunr plugins
logger.debug(`Language configuration detected: ${searchIndex.config.lang[0]}`);
} else if (searchIndex.config.lang.length > 1) {
logger.debug(`Multi-language configuration detected: ${searchIndex.config.lang.join(', ')}`);
}

this.ref('location');
this.field('title', { boost: 10 });
this.field('text');
// Use proper MkDocs Material field boosting
this.field('title', { boost: 1000 }); // 1000x boost for titles
this.field('text', { boost: 1 }); // 1x boost for text
this.field('tags', { boost: 1000000 }); // 1M boost for tags

for (const doc of searchIndexContent.docs) {
for (const doc of searchIndex.docs) {
if (!doc.location || !doc.title || !doc.text) continue;

this.add({
const indexDoc: Record<string, unknown> = {
location: doc.location,
title: doc.title,
text: doc.text,
});
};

// Add tags if present
if (doc.tags && doc.tags.length > 0) {
indexDoc.tags = doc.tags.join(' ');
}

this.add(indexDoc, { boost: doc.boost || 1 });
}
});

const results = [];
for (const result of index.search(search)) {
if (result.score < SEARCH_CONFIDENCE_THRESHOLD) break; // Results are sorted by score, so we can stop early
results.push({
title: result.ref,
url: `${baseUrl}/${result.ref}`,
score: result.score,
});
}

logger.debug(
`Search results with confidence >= ${SEARCH_CONFIDENCE_THRESHOLD} found: ${results.length}`
);
logger.debug(`Search results found: ${results.length}`);

return buildResponse({
content: results,
Expand Down
35 changes: 23 additions & 12 deletions src/tools/searchDocs/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,29 @@ type ToolProps = {
};

// Define the structure of MkDocs search index
interface SearchConfig {
lang: string[];
separator: string;
pipeline: string[];
}

interface SearchDocument {
location: string;
title: string;
text: string;
tags?: string[];
boost?: number;
parent?: SearchDocument;
}

interface SearchOptions {
suggest: boolean;
}

interface MkDocsSearchIndex {
config: {
lang: string[];
separator: string;
pipeline: string[];
};
docs: Array<{
location: string;
title: string;
text: string;
tags?: string[];
}>;
config: SearchConfig;
docs: SearchDocument[];
options?: SearchOptions;
}

export type { ToolProps, MkDocsSearchIndex };
export type { ToolProps, MkDocsSearchIndex, SearchConfig, SearchDocument, SearchOptions };
2 changes: 2 additions & 0 deletions tests/e2e/stdio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ describe('MCP Server e2e (child process)', () => {
},
},
});
console.error('hello')
console.log(response.content[0].text);

// Assess
expect(response.content[0].type).toBe('text');
Expand Down
8 changes: 7 additions & 1 deletion tests/unit/searchDocs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,17 @@ describe('tool', () => {
() =>
HttpResponse.text(
JSON.stringify({
config: {
lang: ['en'],
separator: '[\\s\\-]+',
pipeline: ['stopWordFilter', 'stemmer']
},
docs: [
{
location: 'features/logger/#buffering-logs',
title: 'Buffering logs',
text: "<p>Log buffering enables you to buffer logs for a specific request or invocation. Enable log buffering by passing <code>logBufferOptions</code> when initializing a Logger instance. You can buffer logs at the <code>WARNING</code>, <code>INFO</code>, <code>DEBUG</code>, or <code>TRACE</code> level, and flush them automatically on error or manually as needed.</p> <p>This is useful when you want to reduce the number of log messages emitted while still having detailed logs when needed, such as when troubleshooting issues.</p> logBufferingGettingStarted.ts <pre><code>import { Logger } from '@aws-lambda-powertools/logger';\n\nconst logger = new Logger({\n logBufferOptions: {\n maxBytes: 20480,\n flushOnErrorLog: true,\n },\n});\n\nlogger.debug('This is a debug message'); // This is NOT buffered\n\nexport const handler = async () =&gt; {\n logger.debug('This is a debug message'); // This is buffered\n logger.info('This is an info message');\n\n // your business logic here\n\n logger.error('This is an error message'); // This also flushes the buffer\n // or logger.flushBuffer(); // to flush the buffer manually\n};\n</code></pre>",
tags: ['logger', 'buffering', 'debug']
},
{
location: 'features/logger/#configuring-the-buffer',
Expand Down Expand Up @@ -175,7 +181,7 @@ describe('tool', () => {

// Assess
expect(result.content).toBeResponseWithText(
`Failed to fetch search index for java latest: Invalid search index format for java latest: missing 'docs' property`
'Failed to fetch search index for java latest: Invalid search index format: missing docs array'
);
expect(result.isError).toBe(true);
});
Expand Down
Loading