npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

najm-mcp

v0.0.4

Published

MCP plugin for Najm framework

Readme

najm-mcp

MCP (Model Context Protocol) plugin for the Najm framework

Build MCP tools, resources, and prompts with decorators — connect any LLM to your API in minutes.

npm version license najm


What is this?

najm-mcp is a Najm plugin that exposes your API as an MCP server — compatible with Claude, ChatGPT, Gemini, Cursor, Windsurf, VS Code Copilot, and any other MCP-enabled LLM client.

You write one decorator. Every LLM can use it.

@McpServer()
export class ProductTools {
  constructor(private svc: ProductService) {} // same DI as your REST controllers

  @Tool({ name: 'search_products', description: 'Search the product catalog' })
  async search(
    @Arg('query', z.string()) query: string,
    @Arg('limit', z.number().default(10)) limit: number,
  ) {
    return this.svc.search(query, limit); // reuse your existing service
  }
}

That's it. No transport config. No SDK boilerplate. No duplicate business logic.


LLM Compatibility

| Client | Transport | Config key | |---|---|---| | Claude.ai | Streamable HTTP | Remote URL | | Claude Desktop | SSE or stdio | Config file | | ChatGPT | Streamable HTTP | Remote URL | | Gemini / AI Studio | Streamable HTTP | Remote URL | | Cursor | SSE or stdio | .cursor/mcp.json | | Windsurf | SSE | mcp_config.json | | VS Code Copilot | SSE | .vscode/mcp.json |

All three MCP transports supported: Streamable HTTP, SSE, stdio.


Installation

bun add najm-mcp @modelcontextprotocol/sdk zod
# or
npm install najm-mcp @modelcontextprotocol/sdk zod

Peer dependencies: najm-core, hono, reflect-metadata, zod


What's New (DX)

Latest DX enhancements make MCP tools much leaner:

  • Automatic return normalization in tools (string, object/array, null/undefined, and explicit content payloads)
  • Pretty JSON serialization by default for object/array returns
  • null / undefined tool returns now normalize to "OK"
  • New helpers: McpResult(...) and McpError(...)
  • New arg presets: IdArg(...) and IntArg(...)
  • Optional catchErrors on @Tool(...) and @Bridge(...) for stable fallback errors

Quick migration example:

// Before
@Tool({ name: 'list_books', description: 'List books' })
async listBooks() {
  try {
    const books = await this.repo.list();
    return McpJson(books);
  } catch (error) {
    return McpError(`Failed to fetch books: ${String(error)}`);
  }
}

// After
@Tool({ name: 'list_books', description: 'List books', catchErrors: 'Failed to fetch books' })
async listBooks() {
  return await this.repo.list();
}

Quick Start

1. Register the plugin

// src/server.ts
import 'reflect-metadata';
import { Server } from 'najm-core';
import { mcp } from 'najm-mcp';
import * as features from './features';

export const server = new Server()
  .use(mcp({
    name: 'my-api',
    version: '1.0.0',
  }))
  .base('/api')
  .load(features);

2. Define tools

// src/features/products/product.tools.ts
import { McpServer, Tool, Arg } from 'najm-mcp';
import { z } from 'zod';
import { ProductService } from './product.service';

@McpServer()
export class ProductTools {
  constructor(private svc: ProductService) {}

  @Tool({ name: 'get_product', description: 'Get a product by ID' })
  async getProduct(
    @Arg('id', z.string().uuid().describe('Product ID')) id: string,
  ) {
    return this.svc.findById(id);
  }
}

3. Export from barrel

// src/features/products/index.ts
export { ProductController } from './product.controller';
export { ProductService }    from './product.service';
export { ProductTools }      from './product.tools'; // add this

4. Start server

bun run src/main.ts

Your MCP server is now live:

  • POST /api/mcp — Streamable HTTP (Claude.ai, ChatGPT, Gemini)
  • GET /api/mcp — Server probe
  • GET /api/mcp/tools — Human-readable discovery

Transports

Configure which transports to enable:

.use(mcp({
  name: 'my-api',
  version: '1.0.0',
  transports: ['http', 'sse'], // default: ['http']
  path: '/mcp',                // default: '/mcp'
  cors: true,                  // default: true
}))

Streamable HTTP (default)

Stateless. Works in serverless, Next.js, Vercel, and any runtime.

POST /mcp   ← Claude.ai, ChatGPT, Gemini
GET  /mcp   ← probe endpoint
GET  /mcp/tools ← discovery endpoint

SSE (legacy, opt-in)

Persistent connection. Requires a long-lived server (Bun, Node.js). Not suitable for serverless.

GET  /mcp/sse       ← opens session (Claude Desktop, Cursor, Windsurf)
POST /mcp/messages  ← sends messages into session

stdio (local dev, CLI)

For local LLM clients (Claude Desktop, Cursor). Runs as a separate process.

Create an entrypoint file in your project:

// src/mcp.ts
import 'reflect-metadata';
import { serveMcpStdio } from 'najm-mcp';
import { server } from './server';

await serveMcpStdio(server);

Connecting LLM Clients

Claude Desktop

SSE (local server):

// ~/Library/Application Support/Claude/claude_desktop_config.json
{
  "mcpServers": {
    "my-api": {
      "url": "http://localhost:3000/mcp/sse"
    }
  }
}

stdio (recommended for local dev):

{
  "mcpServers": {
    "my-api": {
      "command": "bun",
      "args": ["run", "/absolute/path/to/src/mcp.ts"]
    }
  }
}

Claude.ai (remote)

In Claude.ai → Settings → Integrations → Add MCP Server:

URL:    https://your-domain.com/mcp
Header: Authorization: Bearer <your-token>

Cursor

// .cursor/mcp.json
{
  "mcpServers": {
    "my-api": {
      "url": "http://localhost:3000/mcp/sse"
    }
  }
}

Windsurf

// ~/.codeium/windsurf/mcp_config.json
{
  "mcpServers": {
    "my-api": {
      "serverUrl": "http://localhost:3000/mcp/sse"
    }
  }
}

VS Code Copilot

// .vscode/mcp.json
{
  "servers": {
    "my-api": {
      "type": "sse",
      "url": "http://localhost:3000/mcp/sse"
    }
  }
}

Verify with MCP Inspector

# Streamable HTTP
npx @modelcontextprotocol/inspector http://localhost:3000/mcp

# SSE
npx @modelcontextprotocol/inspector sse http://localhost:3000/mcp/sse

# stdio
npx @modelcontextprotocol/inspector bun src/mcp.ts

Next.js Integration

najm-mcp works inside the existing Najm + Next.js setup with zero changes to your route file.

// src/server.ts
import 'reflect-metadata';
import { Server } from 'najm-core';
import { mcp } from 'najm-mcp';
import * as features from './features';

export const server = new Server()
  .use(mcp({
    name: 'my-api',
    version: '1.0.0',
    path: '/mcp',              // final route becomes /api/mcp with base('/api')
    transports: ['http'],      // stateless — serverless safe
  }))
  .base('/api')
  .load(features);

// app/api/[...route]/route.ts — unchanged
import { handle } from 'najm-core';
import { server } from '@/server';

export const GET    = handle(server);
export const POST   = handle(server);
export const PUT    = handle(server);
export const PATCH  = handle(server);
export const DELETE = handle(server);
// next.config.ts
const nextConfig = {
  serverExternalPackages: ['reflect-metadata'],
};

MCP available at POST /api/mcp — works from Claude.ai, ChatGPT, and Gemini.


API Reference

mcp(config) — plugin factory

import { mcp } from 'najm-mcp';

.use(mcp({
  name: string;       // required — shown in LLM client
  version: string;    // required — e.g. '1.0.0'
  path?: string;      // default: '/mcp'
  transports?: ('http' | 'sse' | 'stdio')[];  // default: ['http']
  cors?: boolean;     // default: true
  auth?: {
    type: 'bearer' | 'api-key';
    validate: (token: string) => boolean | Promise<boolean>;
  };
}))

@McpServer() — class decorator

Marks a class as an MCP tool/resource/prompt provider. Registers it in the DI container automatically — no need to also add @Service().

@McpServer()
export class WeatherTools {
  constructor(private weatherSvc: WeatherService) {} // DI works exactly like @Controller
}

@Tool(meta) — method decorator

Exposes a method as an MCP tool.

Supports both forms:

@Tool({
  name: string;        // tool identifier (snake_case recommended)
  description: string; // shown to the LLM
  catchErrors?: string; // optional fallback prefix for unexpected errors
})

@Tool('Get current weather for a city')
// name auto-derived from method name (camelCase -> snake_case)
@Tool('Get current weather for a city')
async getWeather(
  @Arg('city', z.string().describe('City name')) city: string
) {
  return this.svc.fetch(city);
}

catchErrors is useful for converting unexpected throws into a stable error message:

@Tool({ name: 'list_books', description: 'List books', catchErrors: 'Failed to fetch books' })
async listBooks() {
  return await this.repo.list();
}
// -> on unexpected throw: { isError: true, content: [{ type: 'text', text: 'Failed to fetch books: <details>' }] }

@ToolGroup(prefix) — class decorator

Prefixes all tool names in a class to avoid collisions and improve grouping for LLMs.

@McpServer()
@ToolGroup('orders')
class OrderTools {
  @Tool('Get order by ID')
  get(@Arg('id', z.string()) id: string) {}
  // -> registered as orders_get
}

@Annotations(meta) — method decorator

Adds MCP behavioral hints to tools.

@Tool('Delete a user')
@Annotations({ destructive: true, idempotent: false })
async deleteUser(@Arg('id', z.string()) id: string) {}

@Bridge(description, options?) — method decorator

Exposes a @Service() method directly as a tool (no wrapper @McpServer class needed).

import { Service } from 'najm-core';

@Service()
class ProductService {
  @Bridge('Search product catalog', { catchErrors: 'Search failed' })
  async search(@Arg('query', z.string()) query: string) {
    return this.repo.search(query);
  }
}

@Resource(meta) — method decorator

Exposes content the LLM can read as a resource.

@Resource({
  uri: string;          // supports templates: 'products://catalog/{category}'
  name: string;
  description?: string;
  mimeType?: string;    // default: 'application/json'
})
@Resource({
  uri: 'docs://guide/{section}',
  name: 'API Guide',
  mimeType: 'text/markdown',
})
async getGuide(
  @Arg('section', z.string()) section: string,
) {
  return { content: await this.docs.getSection(section) };
}

@Prompt(meta) — method decorator

Exposes a reusable prompt template.

@Prompt({
  name: string;
  description?: string;
})
@Prompt({
  name: 'review_code',
  description: 'Generate a code review prompt',
})
async reviewCode(
  @Arg('code', z.string())     code: string,
  @Arg('language', z.string()) lang: string,
) {
  return {
    messages: [{
      role: 'user' as const,
      content: {
        type: 'text',
        text: `Review this ${lang} code:\n\n${code}`,
      },
    }],
  };
}

@Arg(name, schema) — parameter decorator

Defines a tool/resource/prompt argument with a Zod schema.

@Arg(name: string, schema: z.ZodTypeAny)
async myTool(
  @Arg('id',     z.string().uuid())                     id: string,
  @Arg('limit',  z.number().int().min(1).max(100))      limit: number,
  @Arg('filter', z.enum(['active', 'inactive']))        filter: string,
  @Arg('tags',   z.array(z.string()).optional())        tags?: string[],
) {}

Also available shorthand decorators:

import { StringArg, NumberArg, BoolArg, EnumArg, IdArg, IntArg } from 'najm-mcp';

async search(
  @StringArg('query', 'Search query') query: string,
  @NumberArg('limit', 'Max results', { min: 1, max: 100, default: 10 }) limit: number,
  @BoolArg('inStock', 'Only in-stock', { optional: true }) inStock?: boolean,
  @EnumArg('sort', 'Sort order', ['asc', 'desc']) sort: 'asc' | 'desc',
  @IdArg('bookId', 'Book identifier') bookId: string,
  @IntArg('chapter', 'Chapter number', { min: 0 }) chapter: number,
) {}

@Validate(schema) — DTO-style tool validation (recommended)

You can validate MCP tool input using the same @Validate decorator from najm-validation. This avoids writing many @Arg(...) decorators and gives one DTO-style input object.

import { Validate } from 'najm-validation';

const createOrderDto = z.object({
  productId: z.string().uuid(),
  quantity: z.number().int().min(1).default(1),
});

type CreateOrderDto = z.infer<typeof createOrderDto>;

@McpServer()
class OrderTools {
  @Tool('Create order')
  @Validate(createOrderDto)
  async createOrder(input: CreateOrderDto) {
    return { ok: true, input };
  }
}

Notes:

  • @Validate(schema) and @Validate({ body: schema }) are supported for MCP tools.
  • @Validate cannot be combined with @Arg(...) on the same method.
  • In MCP context, only body-style validation is supported.

Automatic tool return serialization

Tool methods can return plain values directly. najm-mcp will normalize them into MCP tool results:

  • string -> text content
  • object / array -> pretty JSON text
  • null / undefined -> "OK"
  • { content: [...] } (optionally with isError) -> passed through as-is

McpText, McpJson, McpResult, McpError, McpImage, McpList — typed result helpers

import { McpText, McpJson, McpResult, McpError, McpImage, McpList } from 'najm-mcp';

return McpText('ok');
return McpJson({ ok: true });
return McpResult({ success: true, message: 'saved' });
return McpError('Validation failed', { field: 'title' });
return McpImage(base64, 'image/png');
return McpList([McpText('summary'), McpJson(data)]);

McpException — semantic tool errors

import { McpException, McpErrorCode } from 'najm-mcp';

throw new McpException('User not found', McpErrorCode.NOT_FOUND);

GET /mcp/tools — discovery endpoint

Lists registered tools, resources, prompts, and argument names.

curl http://localhost:3000/mcp/tools

serveMcpStdio(server, options?) — stdio entrypoint

Starts an MCP server over stdio for use with Claude Desktop, Cursor, and other local clients. Boots the Najm DI container without starting an HTTP server.

// src/mcp.ts
import 'reflect-metadata';
import { serveMcpStdio } from 'najm-mcp';
import { server } from './server';

await serveMcpStdio(server, {
  onError: (error) => {
    console.error(error);
  },
});

Zero Duplication — REST + MCP from the same service

The key feature of najm-mcp is that @McpServer() classes share the same DI container as @Controller() classes. The same service instance powers both.

// product.service.ts — written once
@Service()
export class ProductService {
  async search(query: string, limit: number) {
    return this.repo.search(query, limit);
  }
}

// product.controller.ts — REST
@Controller('/products')
export class ProductController {
  constructor(private svc: ProductService) {}

  @Get('/search')
  search(@Query('q') q: string) {
    return this.svc.search(q, 10);
  }
}

// product.tools.ts — MCP
@McpServer()
export class ProductTools {
  constructor(private svc: ProductService) {} // same singleton

  @Tool({ name: 'search_products', description: 'Search the product catalog' })
  async search(
    @Arg('query', z.string()) query: string,
    @Arg('limit', z.number().default(10)) limit: number,
  ) {
    return this.svc.search(query, limit); // same method
  }
}

Authentication

Protect your MCP endpoints with bearer tokens or API keys:

.use(mcp({
  name: 'my-api',
  version: '1.0.0',
  auth: {
    type: 'bearer', // or 'api-key' (reads x-api-key header)
    validate: async (token) => {
      return token === process.env.MCP_SECRET;
      // or verify JWT, check database, etc.
    },
  },
}))

Full Example

import 'reflect-metadata';
import { Server, Service } from 'najm-core';
import { mcp, McpServer, Tool, Resource, Prompt, Arg } from 'najm-mcp';
import { z } from 'zod';

// ─── Service (shared with REST) ───────────────────────────────────────────────
@Service()
class WeatherService {
  async getCurrent(city: string) {
    return { city, temp: 22, condition: 'sunny' };
  }

  async getForecast(city: string, days: number) {
    return Array.from({ length: days }, (_, i) => ({
      day: i + 1, temp: 20 + i, condition: 'clear',
    }));
  }
}

// ─── MCP Tools ────────────────────────────────────────────────────────────────
@McpServer()
class WeatherTools {
  constructor(private svc: WeatherService) {}

  @Tool({ name: 'get_weather', description: 'Get current weather for a city' })
  async getWeather(
    @Arg('city', z.string().describe('City name')) city: string,
  ) {
    return this.svc.getCurrent(city);
  }

  @Tool({ name: 'get_forecast', description: 'Get weather forecast' })
  async getForecast(
    @Arg('city', z.string())                                       city: string,
    @Arg('days', z.number().int().min(1).max(7).default(3))        days: number,
  ) {
    return this.svc.getForecast(city, days);
  }

  @Resource({
    uri: 'weather://cities/{region}',
    name: 'City List',
    mimeType: 'application/json',
  })
  async getCities(
    @Arg('region', z.string()) region: string,
  ) {
    return { content: ['London', 'Paris', 'Tokyo'].filter(c => c.startsWith(region)) };
  }

  @Prompt({ name: 'weather_report', description: 'Generate a weather report prompt' })
  async weatherReport(
    @Arg('city', z.string()) city: string,
  ) {
    return {
      messages: [{
        role: 'user' as const,
        content: {
          type: 'text',
          text: `Write a friendly weather report for ${city} based on the current conditions.`,
        },
      }],
    };
  }
}

// ─── Server ───────────────────────────────────────────────────────────────────
await new Server()
  .use(mcp({
    name: 'weather-api',
    version: '1.0.0',
    transports: ['http', 'sse'],
  }))
  .load(WeatherService, WeatherTools)
  .listen(3000);

License

MIT