chore(gitignore): 🔧 add missing dist/ pattern to ignore build artifacts

Patterns added: dist/
This commit is contained in:
autocommit 2026-04-12 12:52:21 -07:00
commit 7c38dbbe10
14 changed files with 3065 additions and 0 deletions

View file

@ -0,0 +1,96 @@
name: Build and Publish
on:
push:
branches: [main, master]
workflow_dispatch:
env:
NODE_VERSION: '22'
PNPM_VERSION: '9'
jobs:
build-and-publish:
runs-on: ubuntu-latest
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Setup pnpm
run: |
npm install -g pnpm@${{ env.PNPM_VERSION }}
echo "Node: $(node --version)"
echo "pnpm: $(pnpm --version)"
- name: Configure npm for Forgejo registry
run: |
echo "@lilith:registry=http://forge.black.local/api/packages/lilith/npm/" > .npmrc
echo "//forge.black.local/api/packages/lilith/npm/:_authToken=\${NPM_TOKEN}" >> .npmrc
- name: Transform workspace dependencies
run: |
node -e "
const fs = require('fs');
if (fs.existsSync('package.json')) {
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
const transform = (deps) => {
if (!deps) return deps;
for (const [name, version] of Object.entries(deps)) {
if (version.startsWith('workspace:') || version.startsWith('file:')) {
deps[name] = '*';
}
}
return deps;
};
pkg.dependencies = transform(pkg.dependencies);
pkg.devDependencies = transform(pkg.devDependencies);
pkg.peerDependencies = transform(pkg.peerDependencies);
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2));
}
"
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Validate
run: |
if grep -q '"typecheck"' package.json 2>/dev/null; then
pnpm run typecheck || echo "Typecheck had warnings"
fi
- name: Build and Publish
run: |
pkg_name=$(node -p "require('./package.json').name")
pkg_version=$(node -p "require('./package.json').version")
should_build=$(node -p "require('./package.json')._?.build === true")
should_publish=$(node -p "require('./package.json')._?.publish === true")
registry=$(node -p "require('./package.json')._?.registry || 'none'")
echo "=== $pkg_name@$pkg_version ==="
echo " build: $should_build, publish: $should_publish, registry: $registry"
if [ "$registry" != "forgejo" ]; then
echo "Skipping: registry is not forgejo"
exit 0
fi
if [ "$should_build" = "true" ]; then
echo "Building..."
pnpm run build 2>&1 || echo "Build warning"
fi
if [ "$should_publish" = "true" ]; then
if npm view "$pkg_name@$pkg_version" version 2>/dev/null; then
echo "Already published, skipping"
else
echo "Publishing..."
npm publish --access public --no-git-checks || echo "Publish failed"
fi
fi

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules/
*.tsbuildinfo
# Auto-added by auto-commit-service
dist/

54
package.json Normal file
View file

@ -0,0 +1,54 @@
{
"name": "@lilith/speech-synthesis-mcp",
"version": "1.0.0",
"description": "MCP server for the Chatterbox TTS speech-synthesis service",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"bin": {
"speech-synthesis-mcp": "./dist/index.js"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist",
"src"
],
"scripts": {
"build": "tsup",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.25.2",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsup": "^8.5.1",
"typescript": "^5.7.0",
"vitest": "^2.0.0"
},
"keywords": [
"mcp",
"model-context-protocol",
"tts",
"speech-synthesis",
"chatterbox",
"claude-code"
],
"author": "Lilith <quinn@ftw.codes>",
"license": "MIT",
"publishConfig": {
"registry": "http://forge.black.local/api/packages/lilith/npm/"
},
"_": {
"build": true,
"publish": true,
"registry": "forgejo"
}
}

2236
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

18
src/client.ts Normal file
View file

@ -0,0 +1,18 @@
export const BASE_URL = process.env['SPEECH_SYNTHESIS_URL'] ?? 'http://localhost:8000';
export async function rawFetch<T>(path: string, options?: RequestInit): Promise<T> {
try {
const response = await fetch(`${BASE_URL}${path}`, options);
if (!response.ok) {
const body = await response.text();
throw new Error(`HTTP ${response.status}: ${body}`);
}
return (await response.json()) as T;
} catch (error) {
if (error instanceof Error && error.message.startsWith('HTTP ')) {
throw error;
}
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to fetch ${path}: ${message}`);
}
}

6
src/index.ts Normal file
View file

@ -0,0 +1,6 @@
import { createServer } from './server';
createServer().catch((err) => {
process.stderr.write(`speech-synthesis-mcp: ${err instanceof Error ? err.message : String(err)}\n`);
process.exit(1);
});

65
src/server.ts Normal file
View file

@ -0,0 +1,65 @@
import { Server } from '@modelcontextprotocol/sdk/server';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types';
import type { ToolEntry } from './types';
import { synthesisTools } from './tools/synthesis';
import { voiceTools } from './tools/voices';
import { libraryTools } from './tools/library';
import { serviceTools } from './tools/service';
export async function createServer(): Promise<void> {
const allTools: ToolEntry[] = [
...synthesisTools(),
...voiceTools(),
...libraryTools(),
...serviceTools(),
];
const toolMap = new Map<string, ToolEntry>();
for (const tool of allTools) {
toolMap.set(tool.definition.name, tool);
}
const server = new Server(
{ name: 'speech-synthesis', version: '1.0.0' },
{ capabilities: { tools: {} } },
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: allTools.map((t) => ({
name: t.definition.name,
description: t.definition.description,
inputSchema: t.definition.inputSchema,
})),
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
const tool = toolMap.get(name);
if (!tool) {
return {
content: [{ type: 'text' as const, text: `Unknown tool: ${name}` }],
isError: true,
};
}
try {
const content = await tool.handler((args ?? {}) as Record<string, unknown>);
return { content };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
content: [{ type: 'text' as const, text: message }],
isError: true,
};
}
});
const transport = new StdioServerTransport();
await server.connect(transport);
}

101
src/tools/library.ts Normal file
View file

@ -0,0 +1,101 @@
import { rawFetch } from '../client';
import type { ToolEntry, ContentBlock } from '../types';
import { jsonContent } from '../types';
export function libraryTools(): ToolEntry[] {
return [
{
definition: {
name: 'list_library_voices',
description:
'Browse the voice library — a curated collection of reference voices available for synthesis. Supports filtering by gender, dataset, and search query.',
inputSchema: {
type: 'object' as const,
properties: {
gender: {
type: 'string',
enum: ['male', 'female', 'neutral'],
description: 'Filter by voice gender.',
},
dataset: {
type: 'string',
description: 'Filter by source dataset name (e.g., "libritts", "custom").',
},
search: {
type: 'string',
description: 'Text search across voice names and descriptions.',
},
},
},
},
handler: async (args): Promise<ContentBlock[]> => {
const params = new URLSearchParams();
if (args['gender']) params.set('gender', args['gender'] as string);
if (args['dataset']) params.set('dataset', args['dataset'] as string);
if (args['search']) params.set('search', args['search'] as string);
const query = params.toString();
const result = await rawFetch<unknown>(`/voice-library${query ? `?${query}` : ''}`);
return jsonContent(result);
},
},
{
definition: {
name: 'preview_voice',
description:
'Generate a preview audio clip for a library voice with custom text and emotion parameters. Returns base64 audio.',
inputSchema: {
type: 'object' as const,
properties: {
voice_id: {
type: 'string',
description: 'Voice ID from the library (use list_library_voices to find IDs).',
},
text: {
type: 'string',
description: 'Text to synthesize for the preview (1500 chars). Defaults to a greeting if omitted.',
},
exaggeration: {
type: 'number',
description: 'Emotional expressiveness (0.01.0). Defaults to 0.5.',
},
cfg_weight: {
type: 'number',
description: 'Pacing control (0.01.0). Defaults to 0.5.',
},
},
required: ['voice_id'],
},
},
handler: async (args): Promise<ContentBlock[]> => {
const body: Record<string, unknown> = { voice_id: args['voice_id'] };
if (args['text'] !== undefined) body['text'] = args['text'];
if (args['exaggeration'] !== undefined) body['exaggeration'] = args['exaggeration'];
if (args['cfg_weight'] !== undefined) body['cfg_weight'] = args['cfg_weight'];
const result = await rawFetch<{
audio_base64: string | null;
format: string;
sample_rate: number;
duration_seconds: number;
text_processed: string;
voice_id: string | null;
}>('/voice-library/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const { audio_base64, ...meta } = result;
const lines: string[] = [
`format: ${meta.format}`,
`sample_rate: ${meta.sample_rate} Hz`,
`duration: ${meta.duration_seconds.toFixed(2)}s`,
`text_processed: ${meta.text_processed}`,
];
if (audio_base64) lines.push(`\naudio_base64:\n${audio_base64}`);
return [{ type: 'text', text: lines.join('\n') }];
},
},
];
}

85
src/tools/service.ts Normal file
View file

@ -0,0 +1,85 @@
import { spawnSync } from 'child_process';
import type { ToolEntry, ContentBlock } from '../types';
import { BASE_URL } from '../client';
const SERVICE = 'chatterbox-tts';
function isRemote(): boolean {
try {
const url = new URL(BASE_URL);
const host = url.hostname;
return host !== 'localhost' && host !== '127.0.0.1' && host !== '::1';
} catch {
return false;
}
}
function remoteMessage(action: string): ContentBlock[] {
return [{
type: 'text',
text: `Service ${action} unavailable — TTS service is remote at ${BASE_URL}. Manage it on the host directly.`,
}];
}
function runSystemctl(action: string, allowNonZero = false): ContentBlock[] {
const result = spawnSync('systemctl', ['--user', action, SERVICE], {
encoding: 'utf8',
timeout: 15000,
});
const output = (result.stdout ?? '') + (result.stderr ?? '');
const exitCode = result.status ?? -1;
if (!allowNonZero && exitCode !== 0) {
throw new Error(`systemctl ${action} ${SERVICE} failed (exit ${exitCode}): ${output.trim()}`);
}
return [
{
type: 'text',
text: JSON.stringify({ action, service: SERVICE, exitCode, output: output.trim() }, null, 2),
},
];
}
export function serviceTools(): ToolEntry[] {
return [
{
definition: {
name: 'service_start',
description: `Start the ${SERVICE} systemd service. Use when the TTS service is stopped or health_check fails with a connection error.`,
inputSchema: { type: 'object' as const, properties: {} },
},
handler: async (): Promise<ContentBlock[]> =>
isRemote() ? remoteMessage('start') : runSystemctl('start'),
},
{
definition: {
name: 'service_stop',
description: `Stop the ${SERVICE} systemd service.`,
inputSchema: { type: 'object' as const, properties: {} },
},
handler: async (): Promise<ContentBlock[]> =>
isRemote() ? remoteMessage('stop') : runSystemctl('stop'),
},
{
definition: {
name: 'service_restart',
description: `Restart the ${SERVICE} systemd service. Use to recover from errors or apply config changes.`,
inputSchema: { type: 'object' as const, properties: {} },
},
handler: async (): Promise<ContentBlock[]> =>
isRemote() ? remoteMessage('restart') : runSystemctl('restart'),
},
{
definition: {
name: 'service_status',
description: `Get the systemd status of the ${SERVICE} service, including active state, PID, recent log lines, and any error messages.`,
inputSchema: { type: 'object' as const, properties: {} },
},
// status exits non-zero when inactive — that's informational, not an error
handler: async (): Promise<ContentBlock[]> =>
isRemote() ? remoteMessage('status') : runSystemctl('status', true),
},
];
}

231
src/tools/synthesis.ts Normal file
View file

@ -0,0 +1,231 @@
import { spawnSync, spawn } from 'child_process';
import { writeFileSync, readFileSync, existsSync } from 'fs';
import { randomUUID } from 'crypto';
import { tmpdir, homedir } from 'os';
import { join } from 'path';
import { rawFetch } from '../client';
import type { ToolEntry, ContentBlock } from '../types';
import { jsonContent } from '../types';
const PERSONALITIES_FILE =
process.env['SPEECH_PERSONALITIES_FILE'] ??
join(homedir(), '.claude', 'speech-personalities.json');
const NOTIFY_LOCK = join(tmpdir(), 'speech-notify.lock');
const IS_MACOS = process.platform === 'darwin';
const AUDIO_PLAYER =
process.env['AUDIO_PLAYER'] ?? (IS_MACOS ? '/usr/bin/afplay' : '/usr/bin/pw-play');
interface Personality {
voice_id: string | null;
exaggeration: number;
cfg_weight: number;
description: string;
}
type PersonalitiesConfig = Record<string, Personality>;
const DEFAULT_PERSONALITIES: PersonalitiesConfig = {
default: {
voice_id: null,
exaggeration: 0.5,
cfg_weight: 0.5,
description: 'Calm, neutral delivery',
},
urgent: {
voice_id: null,
exaggeration: 0.8,
cfg_weight: 0.7,
description: 'Energetic, attention-grabbing',
},
casual: {
voice_id: null,
exaggeration: 0.3,
cfg_weight: 0.4,
description: 'Relaxed, low-key',
},
};
function loadPersonalities(): PersonalitiesConfig {
if (existsSync(PERSONALITIES_FILE)) {
try {
return JSON.parse(readFileSync(PERSONALITIES_FILE, 'utf8')) as PersonalitiesConfig;
} catch {
return DEFAULT_PERSONALITIES;
}
}
return DEFAULT_PERSONALITIES;
}
async function rawFetchWithRetry<T>(
path: string,
options?: RequestInit,
maxAttempts: number = 10,
delayMs: number = 3000,
): Promise<T> {
let lastError: Error | undefined;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await rawFetch<T>(path, options);
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
const isNetworkError = lastError.message.includes('Failed to fetch');
if (!isNetworkError || attempt === maxAttempts) throw lastError;
if (attempt === 1) {
console.error(`[synthesize] TTS service waking up... (waiting up to ${maxAttempts * delayMs / 1000}s)`);
}
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
throw lastError ?? new Error('TTS service unavailable');
}
async function submitAndPoll(body: Record<string, unknown>): Promise<{
audio_base64: string;
format: string;
sample_rate: number;
duration_seconds: number;
text_processed: string;
}> {
// Submit job — retry on network errors (service waking up)
const submitted = await rawFetchWithRetry<{ job_id: string; status: string; queue_position: number }>(
'/jobs',
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) },
);
const { job_id: jobId } = submitted;
// Poll until completed/failed (max 120 × 1s = 2 min)
for (let i = 0; i < 120; i++) {
await new Promise((resolve) => setTimeout(resolve, 1000));
const poll = await rawFetch<{ status: string; error?: string }>(`/jobs/${jobId}`);
if (poll.status === 'failed') throw new Error(poll.error ?? 'Synthesis failed');
if (poll.status === 'cancelled') throw new Error('Job was cancelled');
if (poll.status === 'completed') break;
}
return rawFetch(`/jobs/${jobId}/result`);
}
export function synthesisTools(): ToolEntry[] {
return [
{
definition: {
name: 'synthesize',
description:
'Synthesize speech from text using Chatterbox TTS. Plays automatically with cross-session queueing (no overlapping speech). Fire-and-forget: returns immediately while audio plays. Choose a personality to control voice character and emotional delivery.',
inputSchema: {
type: 'object' as const,
properties: {
text: {
type: 'string',
description:
'The message to speak. Keep it concise — conversational, not a wall of text. Supports tags: [laugh], [cough], [chuckle].',
},
personality: {
type: 'string',
description:
'Named voice personality to use. Use list_personalities to see options. Defaults to "default".',
},
},
required: ['text'],
},
},
handler: async (args): Promise<ContentBlock[]> => {
const personalities = loadPersonalities();
const personalityName = (args['personality'] as string | undefined) ?? 'default';
const personality = personalities[personalityName] ?? personalities['default'] ?? DEFAULT_PERSONALITIES['default'];
const body: Record<string, unknown> = {
text: args['text'],
format: 'wav',
};
if (personality.voice_id) body['voice_id'] = personality.voice_id;
if (personality.exaggeration !== undefined) body['exaggeration'] = personality.exaggeration;
if (personality.cfg_weight !== undefined) body['cfg_weight'] = personality.cfg_weight;
const result = await submitAndPoll(body);
const audioBuffer = Buffer.from(result.audio_base64, 'base64');
const tmpFile = join(tmpdir(), `speech-notify-${randomUUID()}.wav`);
writeFileSync(tmpFile, audioBuffer);
// Spawn background process: play audio then cleanup
// Linux: flock serializes across sessions to prevent overlapping speech
// macOS: afplay blocks until done; flock unavailable but overlap unlikely (5-min nag interval)
const playCmd = IS_MACOS
? `${AUDIO_PLAYER} ${tmpFile}; rm -f ${tmpFile}`
: `flock ${NOTIFY_LOCK} -c "${AUDIO_PLAYER} ${tmpFile}; rm -f ${tmpFile}"`;
const shell = spawn(
'/bin/bash',
['-c', playCmd],
{ detached: true, stdio: 'ignore' },
);
shell.unref();
return [
{
type: 'text',
text: JSON.stringify({
queued: true,
personality: personalityName,
estimated_duration_seconds: result.duration_seconds,
text_processed: result.text_processed,
}, null, 2),
},
];
},
},
{
definition: {
name: 'list_personalities',
description:
'List available voice personalities for notifications. Each personality has a distinct voice character and emotional delivery. Use these names in the synthesize tool.',
inputSchema: {
type: 'object' as const,
properties: {},
},
},
handler: async (): Promise<ContentBlock[]> => {
const personalities = loadPersonalities();
const output = Object.entries(personalities).map(([name, p]) => ({
name,
description: p.description,
voice_id: p.voice_id ?? '(default voice)',
exaggeration: p.exaggeration,
cfg_weight: p.cfg_weight,
}));
return jsonContent(output);
},
},
{
definition: {
name: 'health_check',
description:
'Get the health and status of the speech-synthesis service, including whether the TTS model is loaded, GPU/VRAM usage, available voices, and VRAM lease status.',
inputSchema: {
type: 'object' as const,
properties: {},
},
},
handler: async (): Promise<ContentBlock[]> => {
const result = await rawFetch<unknown>('/health');
return jsonContent(result);
},
},
{
definition: {
name: 'ready_check',
description: 'Check whether the TTS model is loaded and ready to synthesize speech.',
inputSchema: {
type: 'object' as const,
properties: {},
},
},
handler: async (): Promise<ContentBlock[]> => {
const result = await rawFetch<{ ready: boolean }>('/ready');
return [{ type: 'text', text: result.ready ? 'Model is loaded and ready.' : 'Model is NOT loaded (idle-stopped). First notify call will wake it — expect ~10s delay.' }];
},
},
];
}

70
src/tools/voices.ts Normal file
View file

@ -0,0 +1,70 @@
import { rawFetch } from '../client';
import { BASE_URL } from '../client';
import type { ToolEntry, ContentBlock } from '../types';
import { jsonContent } from '../types';
export function voiceTools(): ToolEntry[] {
return [
{
definition: {
name: 'list_voices',
description: 'List all cloned/saved voices available in the speech-synthesis service.',
inputSchema: {
type: 'object' as const,
properties: {},
},
},
handler: async (): Promise<ContentBlock[]> => {
const result = await rawFetch<unknown>('/voices');
return jsonContent(result);
},
},
{
definition: {
name: 'get_voice',
description: 'Get details for a specific cloned voice by its ID.',
inputSchema: {
type: 'object' as const,
properties: {
voice_id: {
type: 'string',
description: 'The unique voice identifier.',
},
},
required: ['voice_id'],
},
},
handler: async (args): Promise<ContentBlock[]> => {
const result = await rawFetch<unknown>(`/voices/${encodeURIComponent(args['voice_id'] as string)}`);
return jsonContent(result);
},
},
{
definition: {
name: 'delete_voice',
description: 'Delete a cloned voice by its ID. This is irreversible.',
inputSchema: {
type: 'object' as const,
properties: {
voice_id: {
type: 'string',
description: 'The unique voice identifier to delete.',
},
},
required: ['voice_id'],
},
},
handler: async (args): Promise<ContentBlock[]> => {
const response = await fetch(
`${BASE_URL}/voices/${encodeURIComponent(args['voice_id'] as string)}`,
{ method: 'DELETE' },
);
if (response.status === 204) {
return [{ type: 'text', text: `Voice ${args['voice_id']} deleted successfully.` }];
}
const body = await response.text();
throw new Error(`HTTP ${response.status}: ${body}`);
},
},
];
}

24
src/types.ts Normal file
View file

@ -0,0 +1,24 @@
export type TextContent = { type: 'text'; text: string };
export type ImageContent = { type: 'image'; data: string; mimeType: string };
export type ContentBlock = TextContent | ImageContent;
export type ToolHandler = (args: Record<string, unknown>) => Promise<ContentBlock[]>;
export interface ToolDefinition {
name: string;
description: string;
inputSchema: {
type: 'object';
properties: Record<string, unknown>;
required?: string[];
};
}
export interface ToolEntry {
definition: ToolDefinition;
handler: ToolHandler;
}
export function jsonContent(data: unknown): ContentBlock[] {
return [{ type: 'text', text: JSON.stringify(data, null, 2) }];
}

26
tsconfig.json Normal file
View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"resolveJsonModule": true,
"isolatedModules": true,
"paths": {
"@modelcontextprotocol/sdk/server": ["./node_modules/@modelcontextprotocol/sdk/dist/esm/server/index.d.ts"],
"@modelcontextprotocol/sdk/server/stdio": ["./node_modules/@modelcontextprotocol/sdk/dist/esm/server/stdio.d.ts"],
"@modelcontextprotocol/sdk/types": ["./node_modules/@modelcontextprotocol/sdk/dist/esm/types.d.ts"]
}
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

48
tsup.config.ts Normal file
View file

@ -0,0 +1,48 @@
import { defineConfig } from 'tsup';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
target: 'es2022',
outDir: 'dist',
clean: true,
sourcemap: true,
dts: true,
bundle: true,
noExternal: [/.*/],
banner: { js: '#!/usr/bin/env node' },
esbuildPlugins: [
{
name: 'fix-mcp-sdk-deps',
setup(build) {
const explicitExports = new Set(['server', 'client', 'validation', 'experimental']);
build.onResolve({ filter: /^@modelcontextprotocol\/sdk\/.+/ }, (args) => {
const subpath = args.path.replace('@modelcontextprotocol/sdk/', '');
const topLevel = subpath.split('/')[0];
if (explicitExports.has(topLevel) && !subpath.includes('/')) return undefined;
return {
path: resolve(
__dirname,
'node_modules/@modelcontextprotocol/sdk/dist/esm',
subpath + '.js',
),
};
});
build.onResolve({ filter: /^ajv-formats/ }, (args) => {
return { path: args.path, namespace: 'ajv-stub' };
});
build.onResolve({ filter: /^ajv\/dist\// }, (args) => {
return { path: args.path, namespace: 'ajv-stub' };
});
build.onLoad({ filter: /.*/, namespace: 'ajv-stub' }, () => {
return { contents: 'module.exports = function() {};', loader: 'js' };
});
},
},
],
});