Add NexusAI: universal MCP service (TTS/STT/image/video/music/web-search)

Monorepo-lite with shared @nexusai/core and @nexusai/mcp-server (stdio).
Provider-agnostic tools with explicit provider selection; NordRouter adapter
(media generate/poll/download, estimate, upload, models) and search via
perplexity/sonar. Local STT via faster-whisper uv sidecar. SQLite journal of
every generation for usage stats and future admin. 10 MCP tools.
This commit is contained in:
OpenCode
2026-08-17 17:06:47 +07:00
parent 82087be1b3
commit 02fc536e85
32 changed files with 3861 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@nexusai/mcp-server",
"version": "0.1.0",
"description": "NexusAI MCP server (stdio): TTS, STT, image/video/music generation, web search",
"main": "dist/index.js",
"type": "commonjs",
"bin": {
"nexusai-mcp": "dist/index.js"
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"start": "node dist/index.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.0",
"@nexusai/core": "*",
"zod": "^3.25.0",
"zod-to-json-schema": "^3.25.0"
},
"devDependencies": {
"@types/node": "^22.15.0",
"typescript": "^5.8.0"
}
}
@@ -0,0 +1,46 @@
import {
Journal,
MediaStorage,
ProviderRegistry,
createNordRouterProviders,
loadConfig,
LocalWhisperProvider,
createLogger,
type NexusConfig,
} from '@nexusai/core';
const log = createLogger('context');
export interface AppContext {
config: NexusConfig;
registry: ProviderRegistry;
storage: MediaStorage;
journal: Journal;
}
/**
* Wire up all providers, storage and the journal once at startup.
*/
export function buildContext(): AppContext {
const config = loadConfig();
const storage = new MediaStorage(config.mediaDir);
const journal = new Journal(config.dbPath);
const registry = new ProviderRegistry();
// NordRouter (media + search). Registered even without a key so error messages
// are clear when a tool is actually invoked.
try {
const nr = createNordRouterProviders({ config: config.nordrouter, storage, save: true });
registry.registerMedia(nr.media);
registry.registerSearch(nr.search);
log.info('registered nordrouter provider (media + search)');
} catch (err) {
log.warn('nordrouter not registered', err instanceof Error ? err.message : String(err));
}
// Local STT (faster-whisper sidecar).
registry.registerStt(new LocalWhisperProvider(config.stt));
log.info('registered local STT provider');
return { config, registry, storage, journal };
}
+275
View File
@@ -0,0 +1,275 @@
import { randomUUID } from 'crypto';
import {
NexusError,
buildEmbeddedContent,
createLogger,
type Capability,
type MediaResult,
} from '@nexusai/core';
import type { AppContext } from './context.js';
import {
ImageSchema,
MediaEstimateSchema,
MediaModelsSchema,
MediaUploadSchema,
MusicSchema,
SttSchema,
TtsSchema,
UsageStatsSchema,
VideoSchema,
WebSearchSchema,
} from './schemas.js';
const log = createLogger('handlers');
type McpContent =
| { type: 'text'; text: string }
| { type: 'image'; data: string; mimeType: string }
| { type: 'audio'; data: string; mimeType: string };
export interface McpResult {
content: McpContent[];
isError?: boolean;
}
function jsonBlock(obj: unknown): McpContent {
return { type: 'text', text: JSON.stringify(obj, null, 2) };
}
function summarize(input: Record<string, unknown>): string {
const s = JSON.stringify(input);
return s.length > 200 ? s.slice(0, 197) + '...' : s;
}
export class Handlers {
constructor(private readonly ctx: AppContext) {}
private providerName(explicit?: string): string {
return explicit || this.ctx.config.defaultProvider;
}
async handle(name: string, args: Record<string, unknown>): Promise<McpResult> {
switch (name) {
case 'nexus_tts':
return this.media('tts', TtsSchema.parse(args));
case 'nexus_image':
return this.media('image', ImageSchema.parse(args));
case 'nexus_video':
return this.media('video', VideoSchema.parse(args));
case 'nexus_music':
return this.media('music', MusicSchema.parse(args));
case 'nexus_stt':
return this.stt(SttSchema.parse(args));
case 'nexus_web_search':
return this.search(WebSearchSchema.parse(args));
case 'nexus_media_models':
return this.models(MediaModelsSchema.parse(args));
case 'nexus_media_estimate':
return this.estimate(MediaEstimateSchema.parse(args));
case 'nexus_media_upload':
return this.upload(MediaUploadSchema.parse(args));
case 'nexus_usage_stats':
return this.usage(UsageStatsSchema.parse(args));
default:
throw new NexusError('NOT_FOUND', `Unknown tool: ${name}`);
}
}
private async media(
capability: Capability,
args: { provider?: string; model: string; input: Record<string, unknown>; output?: { save?: boolean; embed?: boolean } }
): Promise<McpResult> {
const providerName = this.providerName(args.provider);
const provider = this.ctx.registry.getMedia(providerName, capability);
const started = Date.now();
const id = randomUUID();
try {
const result = await provider.generate(capability, { model: args.model, input: args.input });
this.ctx.journal.record({
id,
provider: providerName,
capability,
model: args.model,
inputSummary: summarize(args.input),
status: 'done',
costUsd: result.costUsd ?? null,
filePath: result.filePath ?? null,
resultUrl: result.resultUrl ?? null,
error: null,
durationMs: Date.now() - started,
createdAt: new Date().toISOString(),
});
return this.mediaResult(result, args.output?.embed ?? false);
} catch (err) {
const e = NexusError.from(err);
this.ctx.journal.record({
id,
provider: providerName,
capability,
model: args.model,
inputSummary: summarize(args.input),
status: 'failed',
costUsd: null,
filePath: null,
resultUrl: null,
error: `${e.code}: ${e.message}`,
durationMs: Date.now() - started,
createdAt: new Date().toISOString(),
});
throw e;
}
}
private mediaResult(result: MediaResult, embed: boolean): McpResult {
const content: McpContent[] = [
jsonBlock({
provider: result.provider,
capability: result.capability,
model: result.model,
status: result.status,
file_path: result.filePath,
mime_type: result.mimeType,
result_url: result.resultUrl,
result_url_2: result.resultUrl2,
cost_usd: result.costUsd,
}),
];
if (embed && result.filePath) {
const block = buildEmbeddedContent(result.filePath, result.mimeType, this.ctx.config.embedMaxBytes);
if (block) content.push(block);
else content.push({ type: 'text', text: '(embed skipped: file too large or not embeddable)' });
}
return { content };
}
private async stt(args: {
provider?: string;
input: { audioPath?: string; audioUrl?: string; audioBase64?: string; language?: string; model?: string };
}): Promise<McpResult> {
const providerName = args.provider || 'local';
const provider = this.ctx.registry.getStt(providerName);
const started = Date.now();
const id = randomUUID();
try {
const res = await provider.transcribe(args.input);
this.ctx.journal.record({
id,
provider: providerName,
capability: 'stt',
model: args.input.model || 'default',
inputSummary: summarize({ ...args.input, audioBase64: args.input.audioBase64 ? '[base64]' : undefined }),
status: 'done',
costUsd: 0,
filePath: args.input.audioPath ?? null,
resultUrl: null,
error: null,
durationMs: Date.now() - started,
createdAt: new Date().toISOString(),
});
return {
content: [
jsonBlock({
provider: res.provider,
language: res.language,
duration_sec: res.durationSec,
text: res.text,
segments: res.segments,
}),
],
};
} catch (err) {
const e = NexusError.from(err);
this.ctx.journal.record({
id,
provider: providerName,
capability: 'stt',
model: args.input.model || 'default',
inputSummary: 'stt',
status: 'failed',
costUsd: null,
filePath: null,
resultUrl: null,
error: `${e.code}: ${e.message}`,
durationMs: Date.now() - started,
createdAt: new Date().toISOString(),
});
throw e;
}
}
private async search(args: {
provider?: string;
query: string;
model?: string;
recency?: 'day' | 'week' | 'month' | 'year';
maxResults?: number;
}): Promise<McpResult> {
const providerName = this.providerName(args.provider);
const provider = this.ctx.registry.getSearch(providerName);
const started = Date.now();
const id = randomUUID();
const res = await provider.search({
query: args.query,
model: args.model,
recency: args.recency,
maxResults: args.maxResults,
});
this.ctx.journal.record({
id,
provider: providerName,
capability: 'search',
model: res.model,
inputSummary: summarize({ query: args.query }),
status: 'done',
costUsd: res.costUsd ?? null,
filePath: null,
resultUrl: null,
error: null,
durationMs: Date.now() - started,
createdAt: new Date().toISOString(),
});
return {
content: [jsonBlock({ provider: res.provider, model: res.model, answer: res.answer, citations: res.citations })],
};
}
private async models(args: { provider?: string; type?: string; search?: string }): Promise<McpResult> {
const providerName = this.providerName(args.provider);
// any capability that this provider supports is fine to fetch the catalog
const provider = this.ctx.registry.getMedia(providerName, 'image');
if (!provider.listModels) throw new NexusError('UNSUPPORTED', `Provider ${providerName} cannot list models`);
let models = await provider.listModels();
if (args.type) models = models.filter((m) => m.type === args.type);
if (args.search) {
const q = args.search.toLowerCase();
models = models.filter((m) => m.id.toLowerCase().includes(q) || (m.label || '').toLowerCase().includes(q));
}
return { content: [jsonBlock({ count: models.length, models })] };
}
private async estimate(args: { provider?: string; model: string; input: Record<string, unknown> }): Promise<McpResult> {
const providerName = this.providerName(args.provider);
const provider = this.ctx.registry.getMedia(providerName, 'image');
if (!provider.estimate) throw new NexusError('UNSUPPORTED', `Provider ${providerName} cannot estimate`);
const est = await provider.estimate({ model: args.model, input: args.input });
return { content: [jsonBlock({ model: args.model, usd: est.usd })] };
}
private async upload(args: { provider?: string; path: string; name?: string; mimeType?: string }): Promise<McpResult> {
const providerName = this.providerName(args.provider);
const provider = this.ctx.registry.getMedia(providerName, 'image');
if (!provider.upload) throw new NexusError('UNSUPPORTED', `Provider ${providerName} cannot upload`);
const res = await provider.upload({ path: args.path, name: args.name, mimeType: args.mimeType });
return { content: [jsonBlock({ url: res.url, ref: res.ref })] };
}
private async usage(args: { sinceDays?: number; list?: boolean; limit: number }): Promise<McpResult> {
const stats = this.ctx.journal.stats(args.sinceDays);
const payload: Record<string, unknown> = { stats };
if (args.list) payload.recent = this.ctx.journal.list(args.limit);
log.debug('usage stats requested');
return { content: [jsonBlock(payload)] };
}
}
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
type Tool,
} from '@modelcontextprotocol/sdk/types.js';
import { NexusError, createLogger } from '@nexusai/core';
import { buildContext } from './context.js';
import { Handlers } from './handlers.js';
import { TOOLS } from './tools.js';
const log = createLogger('server');
function main(): void {
const ctx = buildContext();
const handlers = new Handlers(ctx);
const server = new Server(
{ name: 'nexusai-mcp-server', version: '0.1.0' },
{ capabilities: { tools: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: TOOLS.map((t) => ({
name: t.name,
description: t.description,
inputSchema: t.inputSchema,
})) as Tool[],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
return (await handlers.handle(name, args ?? {})) as never;
} catch (error) {
const e = NexusError.from(error);
log.error(`tool ${name} failed`, `${e.code}: ${e.message}`);
return {
content: [{ type: 'text', text: `Error [${e.code}]: ${e.message}` }],
isError: true,
} as never;
}
});
const transport = new StdioServerTransport();
server.connect(transport).catch((error: unknown) => {
log.error('failed to start MCP server', error instanceof Error ? error.message : String(error));
process.exit(1);
});
log.info(`NexusAI MCP server started with ${TOOLS.length} tools`);
}
main();
+138
View File
@@ -0,0 +1,138 @@
import { z } from 'zod';
/** Common output options for media tools. */
const OutputOptions = z
.object({
save: z.boolean().default(true).describe('Save the result to a local file.'),
embed: z
.boolean()
.default(false)
.describe('Also embed image/audio directly in the MCP result (size-limited).'),
})
.partial()
.default({});
const provider = z
.string()
.optional()
.describe('Provider name. Defaults to the configured default provider (e.g. "nordrouter").');
export const TtsSchema = z.object({
provider,
model: z
.string()
.default('audio/elevenlabs-tts')
.describe('TTS model id, e.g. audio/elevenlabs-tts, audio/elevenlabs-multilingual, audio/gemini-3.1-flash-tts.'),
input: z
.object({
text: z.string().describe('Text to synthesize.'),
voice: z.string().optional().describe('Voice name (see nexus_media_models for options).'),
voice_id: z.string().optional(),
speed: z.union([z.string(), z.number()]).optional().describe('Speed, e.g. "0.8" | "1.0" | "1.2".'),
})
.passthrough()
.describe('Provider input. Extra model-specific fields are passed through.'),
output: OutputOptions,
});
export const ImageSchema = z.object({
provider,
model: z
.string()
.default('image/nano-banana-2')
.describe('Image model id, e.g. image/nano-banana-2, image/flux2-pro, image/gpt-image-2, image/z-image. Use *-edit models for editing.'),
input: z
.object({
prompt: z.string().describe('Image description. For edits also pass "image".'),
image: z.string().optional().describe('Source image URL for edit/i2i models.'),
aspect_ratio: z.string().optional(),
resolution: z.string().optional().describe('1K | 2K | 4K (model dependent).'),
})
.passthrough(),
output: OutputOptions,
});
export const VideoSchema = z.object({
provider,
model: z
.string()
.default('video/kling-2.6-t2v')
.describe('Video model id, e.g. video/kling-2.6-t2v, video/veo-3.1-fast, video/seedance-2. Use i2v models with an "image".'),
input: z
.object({
prompt: z.string().describe('Video description.'),
image: z.string().optional().describe('First-frame image URL for i2v models.'),
image2: z.string().optional().describe('Last-frame image URL (transition models).'),
duration: z.union([z.string(), z.number()]).optional(),
resolution: z.string().optional(),
aspect_ratio: z.string().optional(),
sound: z.boolean().optional(),
})
.passthrough(),
output: OutputOptions,
});
export const MusicSchema = z.object({
provider,
model: z
.string()
.default('music/suno-v5')
.describe('Music model id, e.g. music/suno-v5, music/suno-cover, music/suno-sounds.'),
input: z
.object({
title: z.string().optional(),
style: z.string().optional(),
prompt: z.string().optional().describe('Lyrics/prompt.'),
instrumental: z.boolean().optional(),
version: z.string().optional().describe('V4 | V4_5 | V4_5PLUS | V4_5ALL | V5 | V5_5.'),
})
.passthrough(),
output: OutputOptions,
});
export const SttSchema = z.object({
provider: z.string().optional().describe('STT provider. Defaults to "local" (faster-whisper).'),
input: z.object({
audioPath: z.string().optional().describe('Local audio file path.'),
audioUrl: z.string().optional().describe('Remote audio URL.'),
audioBase64: z.string().optional().describe('Base64 or data-URL audio.'),
language: z.string().optional().describe('Language hint, e.g. "ru", "en". Auto-detect if omitted.'),
model: z.string().optional().describe('Whisper size: tiny|base|small|medium|large-v3.'),
}),
});
export const WebSearchSchema = z.object({
provider,
query: z.string().describe('Search query / question.'),
model: z
.string()
.optional()
.describe('Search model, e.g. perplexity/sonar, perplexity/sonar-pro, or search/<model>.'),
recency: z.enum(['day', 'week', 'month', 'year']).optional(),
maxResults: z.number().int().positive().max(50).optional(),
});
export const MediaModelsSchema = z.object({
provider,
type: z.enum(['image', 'video', 'music', 'audio', 'upscale']).optional().describe('Filter by model type.'),
search: z.string().optional().describe('Substring filter on model id/label.'),
});
export const MediaEstimateSchema = z.object({
provider,
model: z.string().describe('Model id to estimate.'),
input: z.record(z.unknown()).default({}).describe('Same input you would pass to generate.'),
});
export const MediaUploadSchema = z.object({
provider,
path: z.string().describe('Local file path to upload.'),
name: z.string().optional(),
mimeType: z.string().optional(),
});
export const UsageStatsSchema = z.object({
sinceDays: z.number().int().positive().optional().describe('Limit stats to the last N days.'),
list: z.boolean().default(false).describe('Also return recent generations.'),
limit: z.number().int().positive().max(200).default(20),
});
+78
View File
@@ -0,0 +1,78 @@
import { zodToJsonSchema } from 'zod-to-json-schema';
import {
ImageSchema,
MediaEstimateSchema,
MediaModelsSchema,
MediaUploadSchema,
MusicSchema,
SttSchema,
TtsSchema,
UsageStatsSchema,
VideoSchema,
WebSearchSchema,
} from './schemas.js';
export interface ToolDef {
name: string;
description: string;
inputSchema: object;
}
function make(name: string, description: string, schema: unknown): ToolDef {
const json = zodToJsonSchema(schema as any, { name, $refStrategy: 'none' }) as any;
const def = json.definitions?.[name] ?? json;
return { name, description, inputSchema: def };
}
export const TOOLS: ToolDef[] = [
make(
'nexus_tts',
'Text-to-speech. Generates spoken audio from text via a provider. Returns a saved audio file path and optional embedded audio.',
TtsSchema
),
make(
'nexus_image',
'Generate or edit images from a text prompt (and optional source image URL). Returns a saved image file and optional embedded image.',
ImageSchema
),
make(
'nexus_video',
'Generate video from text or an image (t2v / i2v). Async, may take minutes. Returns a saved MP4 file path.',
VideoSchema
),
make(
'nexus_music',
'Generate music from a style/prompt. Returns saved audio file(s); some models produce two tracks.',
MusicSchema
),
make(
'nexus_stt',
'Speech-to-text. Transcribe local/remote/base64 audio using a local whisper model. Returns text, language and segments.',
SttSchema
),
make(
'nexus_web_search',
'Web search that returns an answer with cited source URLs (via search-augmented models).',
WebSearchSchema
),
make(
'nexus_media_models',
'List available media models (id, type, label, estimated price) for a provider. Use to discover valid model ids and parameters.',
MediaModelsSchema
),
make(
'nexus_media_estimate',
'Estimate the USD cost of a media generation before running it.',
MediaEstimateSchema
),
make(
'nexus_media_upload',
'Upload a local media file to the provider and get a reference URL for use as input (image/audio/video).',
MediaUploadSchema
),
make(
'nexus_usage_stats',
'Show usage and spend recorded in the local journal (totals by provider, capability, day) and optionally recent generations.',
UsageStatsSchema
),
];
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"declaration": false,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"]
}