Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/hip-ducks-act.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@e18e/mcp': patch
---

feat: add `lookup-replacement` tool
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"changeset:version": "changeset version && pnpm --filter @e18e/mcp run update:version && git add --all",
"lint": "eslint . && prettier . --check",
"lint:fix": "eslint . --fix && prettier . --write",
"format": "prettier . --write",
"release": "changeset publish",
"test": "vitest"
},
Expand Down
4 changes: 2 additions & 2 deletions packages/stdio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
}
},
"scripts": {
"build": "pnpm generate:icons && tsgo && pnpm generate:docs && publint",
"build": "pnpm generate:icons && pnpm -C ../.. format && tsgo && pnpm generate:docs && publint",
"generate:docs:dev": "node ./scripts/fetch-docs.ts dev",
"generate:docs": "node ./scripts/fetch-docs.ts",
"generate:icons": "node ./scripts/generate-icons.ts",
Expand All @@ -48,7 +48,7 @@
"@tmcp/adapter-valibot": "catalog:mcp",
"@tmcp/transport-stdio": "catalog:mcp",
"es-module-lexer": "catalog:tooling",
"module-replacements": "^2.10.1",
"module-replacements": "catalog:e18e",
"tmcp": "catalog:mcp",
"valibot": "catalog:tooling"
},
Expand Down
80 changes: 76 additions & 4 deletions packages/stdio/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ describe('npm-i-checker', () => {

expect(tool.structuredContent).toEqual({
suggestions: [
"Don't use `arr-diff` instead Use a.filter((item) => !b.includes(item))",
"Don't use `arr-diff` instead `const difference = (a, b) => a.filter((item) => !b.includes(item))`",
],
});
});
Expand All @@ -118,7 +118,7 @@ describe('npm-i-checker', () => {

expect(tool.structuredContent).toEqual({
suggestions: [
"Don't use `arr-diff` instead Use a.filter((item) => !b.includes(item))",
"Don't use `arr-diff` instead `const difference = (a, b) => a.filter((item) => !b.includes(item))`",
expect.stringContaining(
"Don't use `chalk` instead read the following document:",
),
Expand Down Expand Up @@ -178,7 +178,7 @@ console.log(diff(a, b));`,

expect(tool.structuredContent).toEqual({
suggestions: [
"Don't use `arr-diff` instead Use a.filter((item) => !b.includes(item))",
"Don't use `arr-diff` instead `const difference = (a, b) => a.filter((item) => !b.includes(item))`",
],
});
});
Expand All @@ -203,8 +203,80 @@ onMount(()=>{
expect.stringContaining(
"Don't use `chalk` instead read the following document:",
),
"Don't use `arr-diff` instead Use a.filter((item) => !b.includes(item))",
"Don't use `arr-diff` instead `const difference = (a, b) => a.filter((item) => !b.includes(item))`",
],
});
});
});

describe('lookup-replacement', () => {
it('is an available tool', async () => {
const tools = await session.listTools();
expect(tools.tools).toContainEqual(
expect.objectContaining({
name: 'lookup-replacement',
}),
);
});

it("doesn't return anything for empty queries", async () => {
const tool = await session.callTool('lookup-replacement', {
query: ' ',
});

expect(tool.structuredContent).toEqual({
results: [],
});
});

it('finds preferred package replacements by package name', async () => {
const tool = await session.callTool('lookup-replacement', {
query: 'chalk',
});

expect(tool.structuredContent).toEqual({
results: expect.arrayContaining([
expect.objectContaining({
source: 'preferred',
module_name: 'chalk',
type: 'documented',
documentation: expect.stringContaining('chalk'),
}),
]),
});
});

it('finds micro utility replacements by replacement text', async () => {
const tool = await session.callTool('lookup-replacement', {
query: 'includes',
});

expect(tool.structuredContent).toEqual({
results: expect.arrayContaining([
expect.objectContaining({
source: 'micro-utility',
module_name: 'arr-diff',
replacement:
'`const difference = (a, b) => a.filter((item) => !b.includes(item))`',
}),
]),
});
});

it('finds native replacements by package name', async () => {
const tool = await session.callTool('lookup-replacement', {
query: 'array-includes',
});

expect(tool.structuredContent).toEqual({
results: expect.arrayContaining([
expect.objectContaining({
source: 'native',
module_name: 'array-includes',
replacement: 'Array.prototype.includes',
url: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/includes',
}),
]),
});
});
});
1 change: 1 addition & 0 deletions packages/stdio/src/tools/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { npm_i_checker } from './npm-i-checker/index.js';
export { code_checker } from './code-checker/index.js';
export { lookup_replacement } from './lookup-replacement/index.js';
42 changes: 42 additions & 0 deletions packages/stdio/src/tools/lookup-replacement/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { tool } from 'tmcp/utils';
import * as v from 'valibot';
import { icons } from '../../icons/index.js';
import type { E18EMcpServer } from '../../index.js';
import { lookup_replacements } from '../utils.js';

export function lookup_replacement(server: E18EMcpServer) {
server.tool(
{
name: 'lookup-replacement',
description:
'Check if a package has a more performant, maintained or efficient replacement by package name, replacement text, or topic. Returns a list of suggestions with descriptions and documentation links.',
icons,
schema: v.object({
query: v.pipe(
v.string(),
v.description(
'The package name, replacement text, or topic to search for, e.g. `chalk`, `filter`, or `Array.prototype.map`.',
),
),
}),
outputSchema: v.object({
results: v.array(
v.object({
source: v.picklist(['native', 'micro-utility', 'preferred']),
module_name: v.string(),
type: v.string(),
description: v.optional(v.string()),
documentation: v.optional(v.string()),
replacement: v.optional(v.string()),
url: v.optional(v.string()),
}),
),
}),
},
async ({ query }) => {
return tool.structured({
results: lookup_replacements(query),
});
},
);
}
193 changes: 171 additions & 22 deletions packages/stdio/src/tools/utils.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,180 @@
import microUtilsReplacements from 'module-replacements/manifests/micro-utilities.json' with { type: 'json' };
import preferredReplacements from 'module-replacements/manifests/preferred.json' with { type: 'json' };
import type { ModuleReplacement } from 'module-replacements';
import type {
KnownUrl,
ManifestModule,
ModuleReplacement,
NativeModuleReplacement,
ModuleReplacementMapping,
SimpleModuleReplacement,
} from 'module-replacements';
import {
microUtilsReplacements,
nativeReplacements,
preferredReplacements,
resolveDocUrl,
} from 'module-replacements';
import { get_docs } from '../docs/index.js';

const docs = await get_docs();

function get_mapping_replacements(
manifest: ManifestModule,
mapping: ModuleReplacementMapping,
) {
return mapping.replacements
.map((replacement_id) => manifest.replacements[replacement_id])
.filter((replacement) => replacement !== undefined);
}

function get_e18e_doc_path(url?: KnownUrl) {
return typeof url === 'object' && url.type === 'e18e'
? `${url.id}.md`
: undefined;
}

function describe_replacement(replacement: ModuleReplacement) {
if (replacement.type === 'documented') return replacement.replacementModule;
if (replacement.type === 'native')
return replacement.description ?? replacement.id;
if (replacement.type === 'simple' && replacement.example)
return `\`${replacement.example}\``;
return replacement.description;
}

function format_replacements(
mapping: ModuleReplacementMapping,
replacements: ModuleReplacement[],
) {
return replacements.length > 0
? replacements.map(describe_replacement).join('\n\n')
: `use ${mapping.replacements.join(', ')}`;
}

export type LookupResult = {
source: 'native' | 'micro-utility' | 'preferred';
module_name: string;
type: string;
description?: string;
documentation?: string;
replacement?: string;
url?: string;
};

export function get_suggestions_for_package(pkg: string) {
let replacement = (
microUtilsReplacements.moduleReplacements as ModuleReplacement[]
).find((replacement) => replacement.moduleName === pkg);
replacement =
replacement ??
(preferredReplacements.moduleReplacements as ModuleReplacement[]).find(
(replacement) => replacement.moduleName === pkg,
);
if (
replacement &&
replacement.type !== 'native' &&
replacement.type !== 'none'
) {
if (replacement.type === 'documented') {
if (!docs[replacement.docPath + '.md']) return;
return `Don't use \`${pkg}\` instead read the following document:\n\n${
docs[replacement.docPath + '.md']
}`;
const micro_utils_mapping = microUtilsReplacements.mappings[pkg];
if (micro_utils_mapping) {
return `Don't use \`${pkg}\` instead ${format_replacements(
micro_utils_mapping,
get_mapping_replacements(microUtilsReplacements, micro_utils_mapping),
)}`;
}

const preferred_mapping = preferredReplacements.mappings[pkg];
if (preferred_mapping) {
const doc_path = get_e18e_doc_path(preferred_mapping.url);
const documentation = doc_path ? docs[doc_path] : undefined;
if (documentation) {
return `Don't use \`${pkg}\` instead read the following document:\n\n${documentation}`;
}
return `Don't use \`${pkg}\` instead ${replacement.replacement}`;

return `Don't use \`${pkg}\` instead ${format_replacements(
preferred_mapping,
get_mapping_replacements(preferredReplacements, preferred_mapping),
)}`;
}

return;
}

export function lookup_replacements(query: string) {
const normalized_query = query.toLowerCase().trim();
if (!normalized_query) return [];

const results: LookupResult[] = [];

for (const mapping of Object.values(nativeReplacements.mappings)) {
const replacements = get_mapping_replacements(nativeReplacements, mapping);
if (
!mapping.moduleName.toLowerCase().includes(normalized_query) &&
!mapping.replacements.some((replacement) =>
replacement.toLowerCase().includes(normalized_query),
)
) {
continue;
}
const native_replacement = replacements.find(
(replacement): replacement is NativeModuleReplacement =>
replacement.type === 'native',
);
const node_engine = native_replacement?.engines?.find(
(engine) => engine.engine === 'nodejs',
);
const url = resolveDocUrl(native_replacement?.url ?? mapping.url);

results.push({
source: 'native',
module_name: mapping.moduleName,
type: native_replacement?.type ?? mapping.type,
description: node_engine?.minVersion
? `Native replacement available in Node.js ${node_engine.minVersion} and newer. Use the built-in API instead.`
: 'Native replacement available. Use the built-in API instead.',
replacement: mapping.replacements.join(', '),
...(url ? { url } : {}),
});
}

for (const mapping of Object.values(microUtilsReplacements.mappings)) {
const replacements = get_mapping_replacements(
microUtilsReplacements,
mapping,
);
const simple_replacements = replacements.filter(
(replacement): replacement is SimpleModuleReplacement =>
replacement.type === 'simple',
);
if (
!mapping.moduleName.toLowerCase().includes(normalized_query) &&
!simple_replacements.some((replacement) =>
`${replacement.description}\n${replacement.example ?? ''}`
.toLowerCase()
.includes(normalized_query),
)
) {
continue;
}

results.push({
source: 'micro-utility',
module_name: mapping.moduleName,
type: simple_replacements[0]?.type ?? mapping.type,
description: format_replacements(mapping, simple_replacements),
replacement: format_replacements(mapping, simple_replacements),
});
}

for (const mapping of Object.values(preferredReplacements.mappings)) {
const doc_path = get_e18e_doc_path(mapping.url);
const documentation = doc_path ? docs[doc_path] : undefined;
const url = resolveDocUrl(mapping.url);
if (
!mapping.moduleName.toLowerCase().includes(normalized_query) &&
!mapping.replacements.some((replacement) =>
replacement.toLowerCase().includes(normalized_query),
) &&
!(doc_path ?? '').toLowerCase().includes(normalized_query) &&
!documentation?.toLowerCase().includes(normalized_query)
) {
continue;
}

results.push({
source: 'preferred',
module_name: mapping.moduleName,
type: documentation ? 'documented' : mapping.type,
documentation,
replacement: mapping.replacements.join(', '),
...(url ? { url } : {}),
});
}

return results;
}
Loading