├── .cursor └── mcp.json ├── .gitignore ├── .vscode └── settings.json ├── Dockerfile ├── LICENSE ├── README.md ├── bin └── cli.mjs ├── build.config.ts ├── eslint.config.js ├── package.json ├── pnpm-lock.yaml ├── public ├── banner.png ├── inspect.jpg ├── mcp-sse-starter.jpg ├── starter2.jpg ├── stdio-mcp-starter.jpg └── streamable2.jpg ├── scripts └── release.ts ├── src ├── index.ts ├── server.ts ├── tools │ └── mytool.ts ├── types.ts └── utils.ts ├── tests └── server.test.ts └── vite.config.ts /.cursor/mcp.json: -------------------------------------------------------------------------------- 1 | { 2 | "mcpServers": { 3 | "mcp-starter-stdio": { 4 | "command": "node", 5 | "args": ["./bin/cli.mjs"] 6 | }, 7 | "mcp-starter-http": { 8 | "url": "http://localhost:4200/mcp" 9 | }, 10 | "mcp-starter-sse": { 11 | "url": "http://localhost:4201/sse" 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependency directories 2 | node_modules/ 3 | 4 | # TypeScript output 5 | dist/ 6 | 7 | # Environment variables 8 | .env 9 | .env.local 10 | 11 | # Debug logs 12 | npm-debug.log* 13 | yarn-debug.log* 14 | yarn-error.log* 15 | 16 | # Editor directories and files 17 | .idea/ 18 | *.swp 19 | *.swo 20 | 21 | # OS specific 22 | .DS_Store 23 | Thumbs.db 24 | .hidden 25 | llms_*.txt 26 | 27 | .hidden -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.experimental.useFlatConfig": true, 3 | "editor.codeActionsOnSave": { 4 | "source.fixAll.eslint": "explicit" 5 | }, 6 | "editor.formatOnSave": true, // format after eslint fixes 7 | "eslint.validate": [ // add more languages if you need 8 | "javascript", 9 | "javascriptreact", 10 | "typescript", 11 | "typescriptreact" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/instructa/mcp-starter/34173a764c98656ef214b8c49ee8f585f7db28ca/Dockerfile -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Kevin Kern 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MCP Server Starter 2 | 3 | ![mcp starter](/public/banner.png) 4 | 5 |
6 | 10 | Created by
11 | 12 | Follow @kregenrek on Twitter 13 | 14 |
15 | 16 | **Want to build your own MCP server?** 17 | 18 | MCP Server Starter gives you a basic structure to run local tools with Cursor, Claude, and others using the MCP standard. 19 | 20 | --- 21 | 22 | 23 | Starter MCP server 24 | 25 | 26 | ## Features 27 | 28 | - 📡 **Flexible Communication** 29 | - Supports multiple communication protocols between client and server, 30 | - `stdio`: Local usage 31 | - `Streamable HTTP`: Remote and local useage 32 | - `sse`: Remote and local usage (deprecated)~~ 33 | 34 | - 📦 **Minimal Setup** - Get started quickly with a basic server implementation. 35 | - 🤖 **Cursor AI Integration** - Includes example `.cursor/mcp.json` configuration. 36 | - ⌨️ **TypeScript** - Add type safety to your project. 37 | 38 | ## Todo 39 | 40 | - [ ] Add option to publish your own packages 41 | - [ ] Better CLI support for scaffolding 42 | - [ ] Prompts to build tools on the fly 43 | 44 | ## Getting Started 45 | 46 | ### Prerequisites 47 | 48 | - [Node.js](https://nodejs.org/) (Specify version if necessary) 49 | - An MCP-compatible client (e.g., [Cursor](https://cursor.com/)) 50 | 51 | ## Usage 52 | 53 | ### Supported Transport Options 54 | 55 | Model Context Protocol Supports multiple Transport methods. 56 | 57 | ### stdio 58 | 59 | ![mcp starter](/public/stdio-mcp-starter.jpg) 60 | 61 | Recommend for local setups 62 | 63 | #### Code Editor Support 64 | 65 | Add the code snippets below 66 | 67 | * Cursor: `.cursor/mcp.json` 68 | 69 | **Local development/testing** 70 | 71 | Use this if you want to test your mcp server locally 72 | 73 | ```json 74 | { 75 | "mcpServers": { 76 | "my-starter-mcp-stdio": { 77 | "command": "node", 78 | "args": ["./bin/cli.mjs", "--stdio"] 79 | } 80 | } 81 | } 82 | ``` 83 | 84 | **Published Package** 85 | 86 | Use this when you have published your package in the npm registry 87 | 88 | ```json 89 | { 90 | "mcpServers": { 91 | "my-starter-mcp-stdio": { 92 | "command": "npx", 93 | "args": ["my-mcp-server", "--stdio"] 94 | } 95 | } 96 | } 97 | ``` 98 | 99 | ### Streamable HTTP 100 | 101 | ![mcp starter](/public/mcp-sse-starter.jpg) 102 | 103 | >Important: Streamable HTTP is not supported in Cursor yet 104 | 105 | Recommend for remote server usage 106 | 107 | **Important:** In contrast to stdio you need also to run the server with the correct flag 108 | 109 | **Local development** 110 | Use the `streamable http` transport 111 | 112 | 1. Start the MCP Server 113 | Run this in your terminal 114 | ```bash 115 | node ./bin/cli.mjs --http --port 4200 116 | ``` 117 | 118 | Or with mcp inspector 119 | ``` 120 | npm run dev-http 121 | # npm run dev-sse (deprecated) 122 | ``` 123 | 124 | 2. Add this to your config 125 | ```json 126 | { 127 | "mcpServers": { 128 | "my-starter-mcp-http": { 129 | "command": "node", 130 | "args": ["./bin/cli.mjs", "--http", "--port", "4001"] 131 | // "args": ["./bin/cli.mjs", "--sse", "--port", "4002"] (or deprecated sse usage) 132 | } 133 | } 134 | } 135 | ``` 136 | 137 | **Published Package** 138 | 139 | Use this when you have published your package in the npm registry 140 | 141 | Run this in your terminal 142 | ```bash 143 | npx my-mcp-server --http --port 4200 144 | # npx my-mcp-server --sse --port 4201 (deprecated) 145 | ``` 146 | 147 | ```json 148 | { 149 | "mcpServers": { 150 | "my-starter-mcp-http": { 151 | "url": "http://localhost:4200/mcp" 152 | // "url": "http://localhost:4201/sse" 153 | } 154 | } 155 | } 156 | ``` 157 | 158 | ## Use the Inspector 159 | 160 | Use the `inspect` command to debug your mcp server 161 | 162 | ![mcp starter](/public/inspect.jpg) 163 | ![mcp starter](/public/streamable2.jpg) 164 | 165 | ## Command-Line Options 166 | 167 | ### Protocol Selection 168 | 169 | | Protocol | Description | Flags | Notes | 170 | | :------- | :--------------------- | :--------------------------------------------------- | :-------------- | 171 | | `stdio` | Standard I/O | (None) | Default | 172 | | `http` | HTTP REST | `--port ` (def: 3000), `--endpoint ` (def: `/mcp`) | | 173 | | `sse` | Server-Sent Events | `--port ` (def: 3000) | Deprecated | 174 | 175 | ## License 176 | 177 | This project is licensed under the MIT License - see the LICENSE file for details. 178 | 179 | --- 180 | 181 | ## Courses 182 | - Learn to build software with AI: [instructa.ai](https://www.instructa.ai) 183 | -------------------------------------------------------------------------------- /bin/cli.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { fileURLToPath } from 'node:url' 4 | import { runMain } from '../dist/index.mjs' 5 | 6 | globalThis.__mcp_starter_cli__ = { 7 | startTime: Date.now(), 8 | entry: fileURLToPath(import.meta.url), 9 | } 10 | 11 | runMain() 12 | -------------------------------------------------------------------------------- /build.config.ts: -------------------------------------------------------------------------------- 1 | import { defineBuildConfig } from 'unbuild' 2 | 3 | export default defineBuildConfig({ 4 | entries: [ 5 | { input: 'src/index.ts' }, 6 | ], 7 | clean: true, 8 | rollup: { 9 | inlineDependencies: true, 10 | esbuild: { 11 | target: 'node16', 12 | minify: true, 13 | }, 14 | }, 15 | }) 16 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import antfu from '@antfu/eslint-config' 3 | 4 | export default antfu( 5 | { 6 | type: 'app', 7 | pnpm: true, 8 | rules: { 9 | 'pnpm/json-enforce-catalog': 'off', 10 | 'no-console': 'warn', 11 | 'node/prefer-global/process': 'off', 12 | }, 13 | }, 14 | ) 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@instructa/mcp-starter", 3 | "type": "module", 4 | "version": "0.0.3", 5 | "packageManager": "pnpm@9.14.4+sha512.c8180b3fbe4e4bca02c94234717896b5529740a6cbadf19fa78254270403ea2f27d4e1d46a08a0f56c89b63dc8ebfd3ee53326da720273794e6200fcf0d184ab", 6 | "description": "Simple MCP Starter Package", 7 | "contributors": [ 8 | { 9 | "name": "Kevin Kern", 10 | "email": "kevin@instructa.org" 11 | } 12 | ], 13 | "license": "MIT", 14 | "homepage": "https://instructa.ai", 15 | "repository": { 16 | "type": "git", 17 | "url": "git+ssh://git@github.com/instructa/mcp-starter.git" 18 | }, 19 | "keywords": [ 20 | "mcp", 21 | "mcp-starter", 22 | "model-context-protocol" 23 | ], 24 | "exports": { 25 | ".": "./dist/index.mjs", 26 | "./cli": "./bin/cli.mjs" 27 | }, 28 | "bin": { 29 | "mcp-instruct": "./bin/cli.mjs" 30 | }, 31 | "files": [ 32 | "bin", 33 | "dist" 34 | ], 35 | "engines": { 36 | "node": ">=18.0.0" 37 | }, 38 | "scripts": { 39 | "build": "unbuild && npm run chmod-run", 40 | "chmod-run": "node -e \"fs.chmodSync('dist/index.mjs', '755'); if (require('fs').existsSync('dist/cli.mjs')) require('fs').chmodSync('dist/cli.mjs', '755');\"", 41 | "start": "nodemon --exec 'tsx src/index.ts'", 42 | "dev:prepare": "nr build", 43 | "inspect": "npx @modelcontextprotocol/inspector@latest", 44 | "dev": "npx concurrently 'unbuild --stub' 'npm run inspect'", 45 | "run-cli": "node bin/cli.mjs", 46 | "dev-stdio": "npx concurrently 'npm run run-cli' 'npm run inspect node ./bin/cli.mjs'", 47 | "dev-http": "npx concurrently 'npm run run-cli -- --http --port 4200' 'npm run inspect http://localhost:4200/mcp'", 48 | "dev-sse": "npx concurrently 'npm run run-cli -- --sse --port 4201' 'npm run inspect http://localhost:4201/sse'", 49 | "lint": "eslint", 50 | "lint:fix": "eslint --fix", 51 | "typecheck": "tsc --noEmit", 52 | "test": "vitest", 53 | "release": "tsx scripts/release.ts" 54 | }, 55 | "dependencies": { 56 | "@chatmcp/sdk": "^1.0.6", 57 | "@modelcontextprotocol/sdk": "^1.9.0", 58 | "citty": "^0.1.6", 59 | "h3": "^1.15.1", 60 | "ofetch": "^1.4.1", 61 | "zod": "^3.24.3" 62 | }, 63 | "devDependencies": { 64 | "@antfu/eslint-config": "^4.12.0", 65 | "@types/node": "^22.14.1", 66 | "dotenv": "^16.5.0", 67 | "esbuild": "^0.25.2", 68 | "nodemon": "^3.1.9", 69 | "tsx": "^4.19.3", 70 | "typescript": "^5.8.3", 71 | "unbuild": "^3.5.0", 72 | "vite": "^6.3.1", 73 | "vitest": "^3.1.1" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /public/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/instructa/mcp-starter/34173a764c98656ef214b8c49ee8f585f7db28ca/public/banner.png -------------------------------------------------------------------------------- /public/inspect.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/instructa/mcp-starter/34173a764c98656ef214b8c49ee8f585f7db28ca/public/inspect.jpg -------------------------------------------------------------------------------- /public/mcp-sse-starter.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/instructa/mcp-starter/34173a764c98656ef214b8c49ee8f585f7db28ca/public/mcp-sse-starter.jpg -------------------------------------------------------------------------------- /public/starter2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/instructa/mcp-starter/34173a764c98656ef214b8c49ee8f585f7db28ca/public/starter2.jpg -------------------------------------------------------------------------------- /public/stdio-mcp-starter.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/instructa/mcp-starter/34173a764c98656ef214b8c49ee8f585f7db28ca/public/stdio-mcp-starter.jpg -------------------------------------------------------------------------------- /public/streamable2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/instructa/mcp-starter/34173a764c98656ef214b8c49ee8f585f7db28ca/public/streamable2.jpg -------------------------------------------------------------------------------- /scripts/release.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env tsx 2 | /** 3 | * Release Script 4 | * 5 | * This script automates the process of creating and publishing releases 6 | * for the current package. 7 | * 8 | * Usage: 9 | * pnpm tsx scripts/release.ts [version-type] [--alpha] [--no-git] 10 | * 11 | * version-type: 'major', 'minor', 'patch', or specific version (default: 'patch') 12 | * --alpha: Create an alpha release 13 | * --no-git: Skip git commit and tag 14 | */ 15 | 16 | import { execSync } from 'node:child_process' 17 | import fs from 'node:fs' 18 | import path from 'node:path' 19 | 20 | // Parse command line arguments 21 | const args = process.argv.slice(2) 22 | const versionBumpArg = args.find(arg => !arg.startsWith('--')) || 'patch' 23 | const isAlpha = args.includes('--alpha') 24 | const skipGit = args.includes('--no-git') 25 | 26 | const rootPath = path.resolve('.') 27 | 28 | function run(command: string, cwd: string) { 29 | console.log(`Executing: ${command} in ${cwd}`) 30 | execSync(command, { stdio: 'inherit', cwd }) 31 | } 32 | 33 | /** 34 | * Bump version in package.json 35 | * @param pkgPath Path to the package directory (project root) 36 | * @param type Version bump type: 'major', 'minor', 'patch', or specific version 37 | * @param isAlpha Whether to create an alpha version 38 | * @returns The new version 39 | */ 40 | function bumpVersion(pkgPath: string, type: 'major' | 'minor' | 'patch' | string, isAlpha: boolean = false): string { 41 | const pkgJsonPath = path.join(pkgPath, 'package.json') 42 | const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')) 43 | const currentVersion = pkgJson.version 44 | let newVersion: string 45 | 46 | // Parse current version to check if it's already an alpha version 47 | const versionRegex = /^(\d+\.\d+\.\d+)(?:-alpha\.(\d+))?$/ 48 | const match = currentVersion.match(versionRegex) 49 | 50 | if (!match) { 51 | throw new Error(`Invalid version format: ${currentVersion}`) 52 | } 53 | 54 | let baseVersion = match[1] 55 | const currentAlphaVersion = match[2] ? Number.parseInt(match[2], 10) : -1 56 | 57 | // Handle version bumping 58 | if (type === 'major' || type === 'minor' || type === 'patch') { 59 | const [major, minor, patch] = baseVersion.split('.').map(Number) 60 | 61 | // Bump version according to type 62 | if (type === 'major') { 63 | baseVersion = `${major + 1}.0.0` 64 | } 65 | else if (type === 'minor') { 66 | baseVersion = `${major}.${minor + 1}.0` 67 | } 68 | else { // patch 69 | baseVersion = `${major}.${minor}.${patch + 1}` 70 | } 71 | } 72 | else if (type.match(/^\d+\.\d+\.\d+$/)) { 73 | // Use the provided version string directly as base version 74 | baseVersion = type 75 | } 76 | else { 77 | throw new Error(`Invalid version bump type: ${type}. Use 'major', 'minor', 'patch', or a specific version like '1.2.3'.`) 78 | } 79 | 80 | // Create final version string 81 | if (isAlpha) { 82 | // For alpha releases, always start at alpha.0 when base version changes 83 | // If the base version is the same, increment the alpha number. 84 | const alphaVersion = baseVersion === match[1] ? currentAlphaVersion + 1 : 0 85 | if (alphaVersion < 0) { 86 | throw new Error(`Cannot create alpha version from non-alpha version ${currentVersion} without bumping base version (major, minor, patch, or specific).`) 87 | } 88 | newVersion = `${baseVersion}-alpha.${alphaVersion}` 89 | } 90 | else { 91 | // If bumping from an alpha version to a stable version, use the current or bumped baseVersion 92 | newVersion = baseVersion 93 | } 94 | 95 | // Update package.json 96 | pkgJson.version = newVersion 97 | fs.writeFileSync(pkgJsonPath, `${JSON.stringify(pkgJson, null, 2)}\n`) 98 | 99 | console.log(`Bumped version from ${currentVersion} to ${newVersion} in ${pkgJsonPath}`) 100 | return newVersion 101 | } 102 | 103 | /** 104 | * Create a git commit and tag for the release 105 | * @param version The version to tag 106 | * @param isAlpha Whether this is an alpha release 107 | */ 108 | function createGitCommitAndTag(version: string, isAlpha: boolean = false) { 109 | console.log('Creating git commit and tag...') 110 | 111 | try { 112 | // Stage package.json and any other changes 113 | run('git add package.json', rootPath) // Specifically add package.json 114 | // Optional: Add other specific files if needed, or 'git add .' if all changes should be included 115 | 116 | // Create commit with version message 117 | const commitMsg = isAlpha 118 | ? `chore: alpha release v${version}` 119 | : `chore: release v${version}` 120 | run(`git commit -m "${commitMsg}"`, rootPath) 121 | 122 | // Create tag 123 | const tagMsg = isAlpha 124 | ? `Alpha Release v${version}` 125 | : `Release v${version}` 126 | run(`git tag -a v${version} -m "${tagMsg}"`, rootPath) 127 | 128 | // Push commit and tag to remote 129 | console.log('Pushing commit and tag to remote...') 130 | run('git push', rootPath) 131 | run('git push --tags', rootPath) 132 | 133 | console.log(`Successfully created and pushed git tag v${version}`) 134 | } 135 | catch (error) { 136 | console.error('Failed to create git commit and tag:', error) 137 | // Decide if we should proceed with publishing even if git fails 138 | // For now, let's throw to stop the process. 139 | throw error 140 | } 141 | } 142 | 143 | async function publishPackage() { 144 | console.log(`🚀 Starting ${isAlpha ? 'alpha' : ''} release process...`) 145 | console.log(`📝 Version bump: ${versionBumpArg}`) 146 | 147 | // Build package first (assuming a build script exists in package.json) 148 | console.log('🔨 Building package...') 149 | run('pnpm build', rootPath) // Use the build script from package.json 150 | 151 | // Bump the version in the root package.json 152 | const newVersion = bumpVersion(rootPath, versionBumpArg, isAlpha) 153 | 154 | // Create git commit and tag if not skipped 155 | if (!skipGit) { 156 | createGitCommitAndTag(newVersion, isAlpha) 157 | } 158 | 159 | // Publish the package to npm 160 | console.log(`📤 Publishing package@${newVersion} to npm...`) 161 | 162 | const publishCmd = isAlpha 163 | ? 'pnpm publish --tag alpha --no-git-checks --access public' 164 | : 'pnpm publish --no-git-checks --access public' // --no-git-checks is often needed if git tagging is manual or separate 165 | 166 | run(publishCmd, rootPath) 167 | 168 | console.log(`✅ Successfully completed ${isAlpha ? 'alpha' : ''} release v${newVersion}!`) 169 | } 170 | 171 | // Run the publish process 172 | publishPackage().catch((error) => { 173 | console.error('❌ Error during release process:', error) 174 | process.exit(1) 175 | }) 176 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import type { McpToolContext } from './types' 3 | import { runMain as _runMain, defineCommand } from 'citty' 4 | import { version } from '../package.json' 5 | import { createServer, startServer, stopServer } from './server' 6 | import { registerMyTool } from './tools/mytool' 7 | 8 | const cli = defineCommand({ 9 | meta: { 10 | name: 'mcp-instruct', 11 | version, 12 | description: 'Run the MCP starter with stdio, http, or sse transport', 13 | }, 14 | args: { 15 | http: { type: 'boolean', description: 'Run with HTTP transport' }, 16 | sse: { type: 'boolean', description: 'Run with SSE transport' }, 17 | stdio: { type: 'boolean', description: 'Run with stdio transport (default)' }, 18 | port: { type: 'string', description: 'Port for http/sse (default 3000)', default: '3000' }, 19 | endpoint: { type: 'string', description: 'HTTP endpoint (default /mcp)', default: '/mcp' }, 20 | }, 21 | async run({ args }) { 22 | const mode = args.http ? 'http' : args.sse ? 'sse' : 'stdio' 23 | const mcp = createServer({ name: 'my-mcp-server', version }) 24 | 25 | process.on('SIGTERM', () => stopServer(mcp)) 26 | process.on('SIGINT', () => stopServer(mcp)) 27 | 28 | registerMyTool({ mcp } as McpToolContext) 29 | 30 | if (mode === 'http') { 31 | await startServer(mcp, { type: 'http', port: Number(args.port), endpoint: args.endpoint }) 32 | } 33 | else if (mode === 'sse') { 34 | console.log('Starting SSE server...') 35 | await startServer(mcp, { type: 'sse', port: Number(args.port) }) 36 | } 37 | else if (mode === 'stdio') { 38 | await startServer(mcp, { type: 'stdio' }) 39 | } 40 | }, 41 | }) 42 | 43 | export const runMain = () => _runMain(cli) 44 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' 2 | import { createServer as createNodeServer } from 'node:http' 3 | import { RestServerTransport } from '@chatmcp/sdk/server/rest.js' 4 | import { McpServer as Server } from '@modelcontextprotocol/sdk/server/mcp.js' 5 | import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js' 6 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' 7 | import { createApp, createRouter, defineEventHandler, getQuery, setResponseStatus, toNodeListener } from 'h3' 8 | 9 | /** Create the bare MCP server instance */ 10 | export function createServer(options: { name: string, version: string }): McpServer { 11 | const { name, version } = options 12 | return new Server({ name, version }) 13 | } 14 | 15 | interface StdioOptions { type: 'stdio' } 16 | interface HttpOptions { type: 'http', port?: number, endpoint?: string } 17 | interface SseOptions { type: 'sse', port?: number } 18 | 19 | export type StartOptions = StdioOptions | HttpOptions | SseOptions 20 | 21 | /** 22 | * Starts the given MCP server with the selected transport. 23 | * Defaults to stdio when no options are provided. 24 | */ 25 | export async function startServer( 26 | server: McpServer, 27 | options: StartOptions = { type: 'stdio' }, 28 | ): Promise { 29 | if (options.type === 'stdio') { 30 | const transport = new StdioServerTransport() 31 | await server.connect(transport) 32 | return 33 | } 34 | 35 | if (options.type === 'http') { 36 | const port = options.port ?? 3000 37 | const endpoint = options.endpoint ?? '/mcp' 38 | const transport = new RestServerTransport({ port, endpoint }) 39 | await server.connect(transport) 40 | await transport.startServer() 41 | console.log(`HTTP server listening → http://localhost:${port}${endpoint}`) 42 | return 43 | } 44 | 45 | // SSE 46 | const port = options.port ?? 3000 47 | const transports = new Map() 48 | 49 | // Create h3 app and router 50 | const app = createApp() 51 | const router = createRouter() 52 | 53 | // SSE endpoint 54 | router.get('/sse', defineEventHandler(async (event) => { 55 | const res = event.node.res 56 | const transport = new SSEServerTransport('/messages', res) 57 | transports.set(transport.sessionId, transport) 58 | res.on('close', () => transports.delete(transport.sessionId)) 59 | await server.connect(transport) 60 | })) 61 | 62 | // Messages endpoint 63 | router.post('/messages', defineEventHandler(async (event) => { 64 | const { sessionId } = getQuery(event) as { sessionId?: string } 65 | const transport = sessionId ? transports.get(sessionId) : undefined 66 | if (transport) { 67 | await transport.handlePostMessage(event.node.req, event.node.res) 68 | } 69 | else { 70 | setResponseStatus(event, 400) 71 | return 'No transport found for sessionId' 72 | } 73 | })) 74 | 75 | app.use(router) 76 | 77 | // Start Node server using h3's Node adapter 78 | const nodeServer = createNodeServer(toNodeListener(app)) 79 | nodeServer.listen(port) 80 | console.log(`SSE server listening → http://localhost:${port}/sse`) 81 | } 82 | 83 | export async function stopServer(server: McpServer) { 84 | try { 85 | await server.close() 86 | } 87 | catch (error) { 88 | console.error('Error occurred during server stop:', error) 89 | } 90 | finally { 91 | process.exit(0) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/tools/mytool.ts: -------------------------------------------------------------------------------- 1 | import type { McpToolContext } from '../types' 2 | import * as dotenv from 'dotenv' 3 | import { z } from 'zod' 4 | 5 | dotenv.config() 6 | 7 | export function registerMyTool({ mcp }: McpToolContext): void { 8 | mcp.tool( 9 | 'doSomething', 10 | 'What is the capital of Austria?', 11 | { 12 | param1: z.string().describe('The name of the track to search for'), 13 | param2: z.string().describe('The name of the track to search for'), 14 | }, 15 | async ({ param1, param2 }) => { 16 | return { 17 | content: [{ type: 'text', text: `Hello ${param1} and ${param2}` }], 18 | } 19 | }, 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' 2 | 3 | export interface McpToolContext { 4 | mcp: McpServer 5 | } 6 | 7 | // Define the options type 8 | export interface McpServerOptions { 9 | name: string 10 | version: string 11 | } 12 | 13 | export type Tools = (context: McpToolContext) => void 14 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import type { McpToolContext, Tools } from './types' // Assuming McpToolContext is defined in types.ts 2 | import fs from 'node:fs' 3 | import path from 'node:path' 4 | 5 | interface PackageJson { 6 | name?: string 7 | version: string 8 | [key: string]: any 9 | } 10 | 11 | export function getPackageJson(): PackageJson | null { 12 | try { 13 | const packageJsonPath = path.resolve(process.cwd(), 'package.json') 14 | if (!fs.existsSync(packageJsonPath)) { 15 | console.error('Error: package.json not found at', packageJsonPath) 16 | return null 17 | } 18 | const packageJsonContent = fs.readFileSync(packageJsonPath, 'utf-8') 19 | const packageJson: PackageJson = JSON.parse(packageJsonContent) 20 | 21 | if (!packageJson.version) { 22 | console.error('Error: package.json is missing the required \'version\' field.') 23 | return null 24 | } 25 | 26 | return packageJson 27 | } 28 | catch (error) { 29 | console.error('Error reading or parsing package.json:', error) 30 | return null // Return null on error 31 | } 32 | } 33 | 34 | export function registerTools(context: McpToolContext, tools: Tools[]): void { 35 | tools.forEach(register => register(context)) 36 | } 37 | -------------------------------------------------------------------------------- /tests/server.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' 2 | // Import mock internals at the top 3 | // Note: Vitest often handles hoisting, but dynamic import in loadModule might affect this. 4 | // We'll revisit using vi.mocked if these direct imports cause issues later. 5 | import { __mocks as restMocks } from '@chatmcp/sdk/server/rest.js' 6 | import { McpServer as MockMcpServer, __spies as mcpSpies } from '@modelcontextprotocol/sdk/server/mcp.js' 7 | import { __mocks as sseMocks } from '@modelcontextprotocol/sdk/server/sse.js' 8 | import { __mocks as stdioMocks } from '@modelcontextprotocol/sdk/server/stdio.js' 9 | // Mock h3 internals needed for assertions 10 | import { __mocks as h3Mocks } from 'h3' 11 | 12 | // --------------------------------------------------------------------------- 13 | // Mocking external dependencies used by src/server.ts 14 | // --------------------------------------------------------------------------- 15 | 16 | /** 17 | * Mock the MCP server class so we can track calls to `connect`/`close` without 18 | * requiring the actual implementation provided by the SDK. 19 | */ 20 | vi.mock('@modelcontextprotocol/sdk/server/mcp.js', () => { 21 | const connectSpy = vi.fn().mockResolvedValue(undefined) 22 | const closeSpy = vi.fn().mockResolvedValue(undefined) 23 | let lastInstance: unknown 24 | 25 | // Simple mock class replicating the public surface we rely on 26 | class McpServer { 27 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 28 | constructor(public readonly opts: any) { 29 | lastInstance = this 30 | } 31 | 32 | connect = connectSpy 33 | 34 | close = closeSpy 35 | 36 | // Helper to access the last created instance for assertions 37 | static __getLastInstance = () => lastInstance 38 | } 39 | 40 | return { 41 | McpServer, 42 | /** spies exported for assertion purposes */ 43 | __spies: { connectSpy, closeSpy }, 44 | } 45 | }) 46 | 47 | /** 48 | * Mock STDIO transport 49 | */ 50 | vi.mock('@modelcontextprotocol/sdk/server/stdio.js', () => { 51 | let lastInstance: unknown 52 | class StdioServerTransport { 53 | constructor() { 54 | // Assign instance upon creation 55 | lastInstance = this 56 | } 57 | } 58 | return { 59 | StdioServerTransport, 60 | // Expose a way to get the last instance 61 | __mocks: { getLastInstance: () => lastInstance }, 62 | } 63 | }) 64 | 65 | /** 66 | * Mock REST (streamable HTTP) transport 67 | */ 68 | vi.mock('@chatmcp/sdk/server/rest.js', () => { 69 | let lastInstance: unknown 70 | const startServerSpy = vi.fn().mockResolvedValue(undefined) 71 | class RestServerTransport { 72 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 73 | constructor(public readonly opts: any) { 74 | // Assign instance upon creation 75 | lastInstance = this 76 | } 77 | 78 | startServer = startServerSpy 79 | } 80 | return { 81 | RestServerTransport, 82 | __mocks: { 83 | getLastInstance: () => lastInstance, 84 | startServerSpy, 85 | }, 86 | } 87 | }) 88 | 89 | /** 90 | * Mock SSE transport 91 | */ 92 | vi.mock('@modelcontextprotocol/sdk/server/sse.js', () => { 93 | let lastInstance: unknown 94 | class SSEServerTransport { 95 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 96 | constructor(public readonly path: string, public readonly res: any) { 97 | // Assign instance upon creation 98 | lastInstance = this 99 | } 100 | 101 | sessionId = 'mock-session-id' 102 | 103 | handlePostMessage = vi.fn().mockResolvedValue(undefined) 104 | } 105 | return { 106 | SSEServerTransport, 107 | // Expose a way to get the last instance 108 | __mocks: { getLastInstance: () => lastInstance }, 109 | } 110 | }) 111 | 112 | /** 113 | * Mock `h3` – we do not want to start a real HTTP server. We simply need 114 | * the API surface (`createApp`, `createRouter`, `defineEventHandler`, `listen`) that src/server.ts expects. 115 | */ 116 | vi.mock('h3', () => { 117 | const appUseSpy = vi.fn() 118 | const routerGetSpy = vi.fn().mockReturnThis() 119 | const routerPostSpy = vi.fn().mockReturnThis() 120 | const routerUseSpy = vi.fn().mockReturnThis() 121 | 122 | const createAppMock = vi.fn(() => ({ 123 | use: appUseSpy, 124 | handler: vi.fn(), 125 | })) 126 | const createRouterMock = vi.fn(() => ({ 127 | get: routerGetSpy, 128 | post: routerPostSpy, 129 | use: routerUseSpy, 130 | handler: vi.fn(), 131 | })) 132 | // Ensure the event handler function itself is returned to be executed 133 | const defineEventHandlerMock = vi.fn(fn => fn) 134 | const listenMock = vi.fn() 135 | const toNodeListenerMock = vi.fn() 136 | 137 | return { 138 | createApp: createAppMock, 139 | createRouter: createRouterMock, 140 | defineEventHandler: defineEventHandlerMock, 141 | listen: listenMock, 142 | toNodeListener: toNodeListenerMock, 143 | // Expose spies for detailed assertions 144 | __mocks: { 145 | createAppMock, 146 | createRouterMock, 147 | defineEventHandlerMock, 148 | listenMock, 149 | toNodeListenerMock, 150 | appUseSpy, 151 | routerGetSpy, 152 | routerPostSpy, 153 | routerUseSpy, 154 | }, 155 | } 156 | }) 157 | 158 | 159 | // --------------------------------------------------------------------------- 160 | // Actual tests start here – we import the module under test AFTER the mocks. 161 | // --------------------------------------------------------------------------- 162 | 163 | // Removed unused StartOptions import 164 | 165 | // Utility loader so we import the fresh module within each test after mocks 166 | async function loadModule() { 167 | return await import('../src/server') 168 | } 169 | 170 | // Imports for mock spies/helpers moved to the top 171 | 172 | beforeEach(() => { 173 | // Reset mocks before each test 174 | vi.clearAllMocks() 175 | }) 176 | 177 | afterEach(() => { 178 | // Restore mocks after each test 179 | vi.restoreAllMocks() 180 | }) 181 | 182 | /** 183 | * createServer tests 184 | */ 185 | describe('createServer', () => { 186 | it('should return an instance of McpServer with provided options', async () => { 187 | const { createServer } = await loadModule() 188 | const options = { name: 'test-server', version: '1.2.3' } 189 | const server = createServer(options) 190 | 191 | // Check if it's an instance of our *mocked* McpServer 192 | expect(server).toBeInstanceOf(MockMcpServer) 193 | // Check if the constructor was called with the correct options 194 | const lastInstance = MockMcpServer.__getLastInstance() as any 195 | expect(lastInstance?.opts).toEqual(options) 196 | }) 197 | }) 198 | 199 | /** 200 | * STDIO transport tests 201 | */ 202 | describe('startServer – stdio transport', () => { 203 | it('invokes StdioServerTransport and connects', async () => { 204 | const { createServer, startServer } = await loadModule() 205 | const server = createServer({ name: 'test', version: '1.0.0' }) 206 | 207 | await startServer(server, { type: 'stdio' }) 208 | 209 | const transportInstance = stdioMocks.getLastInstance() 210 | expect(transportInstance).toBeDefined() 211 | expect(mcpSpies.connectSpy).toHaveBeenCalledTimes(1) 212 | expect(mcpSpies.connectSpy).toHaveBeenCalledWith(transportInstance) 213 | }) 214 | }) 215 | 216 | /** 217 | * HTTP (REST) transport tests 218 | */ 219 | describe('startServer – streamable HTTP transport', () => { 220 | it('invokes RestServerTransport with defaults and starts server', async () => { 221 | const { createServer, startServer } = await loadModule() 222 | const server = createServer({ name: 'test', version: '1.0.0' }) 223 | 224 | await startServer(server, { type: 'http' }) 225 | 226 | const transportInstance = restMocks.getLastInstance() as any 227 | expect(transportInstance).toBeDefined() 228 | expect(transportInstance.opts).toEqual({ port: 3000, endpoint: '/mcp' }) 229 | 230 | expect(mcpSpies.connectSpy).toHaveBeenCalledTimes(1) 231 | expect(mcpSpies.connectSpy).toHaveBeenCalledWith(transportInstance) 232 | expect(restMocks.startServerSpy).toHaveBeenCalledTimes(1) 233 | }) 234 | 235 | it('invokes RestServerTransport with custom options and starts server', async () => { 236 | const { createServer, startServer } = await loadModule() 237 | const server = createServer({ name: 'test', version: '1.0.0' }) 238 | const customOptions = { type: 'http' as const, port: 8080, endpoint: '/api/mcp' } 239 | 240 | await startServer(server, customOptions) 241 | 242 | const transportInstance = restMocks.getLastInstance() as any 243 | expect(transportInstance).toBeDefined() 244 | expect(transportInstance.opts).toEqual({ port: customOptions.port, endpoint: customOptions.endpoint }) 245 | 246 | expect(mcpSpies.connectSpy).toHaveBeenCalledTimes(1) 247 | expect(mcpSpies.connectSpy).toHaveBeenCalledWith(transportInstance) 248 | expect(restMocks.startServerSpy).toHaveBeenCalledTimes(1) 249 | }) 250 | }) 251 | 252 | /** 253 | * SSE transport tests 254 | */ 255 | describe('startServer – SSE transport', () => { 256 | it('sets up h3 server and listens on default port', async () => { 257 | const { createServer, startServer } = await loadModule() 258 | const server = createServer({ name: 'test', version: '1.0.0' }) 259 | 260 | await startServer(server, { type: 'sse' }) // Default port 3000 261 | 262 | expect(h3Mocks.createAppMock).toHaveBeenCalledTimes(1) 263 | expect(h3Mocks.createRouterMock).toHaveBeenCalledTimes(1) 264 | 265 | // Check router configuration 266 | expect(h3Mocks.routerGetSpy).toHaveBeenCalledWith('/sse', expect.any(Function)) 267 | expect(h3Mocks.routerPostSpy).toHaveBeenCalledWith('/messages', expect.any(Function)) 268 | expect(h3Mocks.appUseSpy).toHaveBeenCalledWith(expect.anything()) // Router passed to app.use 269 | 270 | // Check server listening (either via listen or toNodeListener) 271 | expect( 272 | h3Mocks.listenMock.mock.calls.length > 0 273 | || h3Mocks.toNodeListenerMock.mock.calls.length > 0, 274 | ).toBe(true) 275 | 276 | // If using toNodeListener (preferred), check listen was called on the node server 277 | if (h3Mocks.toNodeListenerMock.mock.calls.length > 0) { 278 | // We need to mock node:http createServer to check .listen() 279 | // This adds complexity, maybe checking toNodeListener is sufficient for this test level 280 | } 281 | }) 282 | 283 | it('sets up h3 server and listens on custom port', async () => { 284 | const { createServer, startServer } = await loadModule() 285 | const server = createServer({ name: 'test', version: '1.0.0' }) 286 | const customPort = 9000 287 | 288 | await startServer(server, { type: 'sse', port: customPort }) 289 | 290 | // We need a way to check the port passed to listen/createNodeServer 291 | // This requires mocking 'node:http' or refining the h3 mock further 292 | // For now, we assert the basic setup happened 293 | expect(h3Mocks.createAppMock).toHaveBeenCalledTimes(1) 294 | expect(h3Mocks.createRouterMock).toHaveBeenCalledTimes(1) 295 | expect( 296 | h3Mocks.listenMock.mock.calls.length > 0 297 | || h3Mocks.toNodeListenerMock.mock.calls.length > 0, 298 | ).toBe(true) 299 | }) 300 | 301 | // TODO: Add more detailed SSE tests: 302 | // - Simulate GET /sse -> check transport created, connect called, transport stored 303 | // - Simulate POST /messages -> check handlePostMessage called 304 | // - Simulate POST /messages (invalid session) -> check 400 status 305 | // - Simulate client disconnect -> check transport removed 306 | }) 307 | 308 | 309 | /** 310 | * stopServer tests 311 | */ 312 | // TODO: Add stopServer tests 313 | // - Mock process.exit 314 | // - Assert server.close() is called 315 | // - Test error handling in server.close() 316 | // ... existing code ... 317 | // Example structure: 318 | // describe('stopServer', () => { 319 | // let exitSpy: MockInstance; 320 | 321 | // beforeEach(() => { 322 | // // Prevent tests from exiting 323 | // exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any); 324 | // }); 325 | 326 | // afterEach(() => { 327 | // exitSpy.mockRestore(); 328 | // }); 329 | 330 | // it('calls server.close and process.exit(0) on success', async () => { 331 | // const { createServer, stopServer } = await loadModule(); 332 | // const server = createServer({ name: 'test', version: '1.0.0' }); 333 | // // Ensure close resolves successfully 334 | // mcpSpies.closeSpy.mockResolvedValue(undefined); 335 | 336 | // await stopServer(server); 337 | 338 | // expect(mcpSpies.closeSpy).toHaveBeenCalledTimes(1); 339 | // expect(exitSpy).toHaveBeenCalledWith(0); 340 | // }); 341 | 342 | // it('calls process.exit(0) even if server.close rejects', async () => { 343 | // const { createServer, stopServer } = await loadModule(); 344 | // const server = createServer({ name: 'test', version: '1.0.0' }); 345 | // const closeError = new Error('Close failed'); 346 | // mcpSpies.closeSpy.mockRejectedValue(closeError); 347 | // // Mock console.error to suppress output during test 348 | // const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); 349 | 350 | // await stopServer(server); 351 | 352 | // expect(mcpSpies.closeSpy).toHaveBeenCalledTimes(1); 353 | // expect(errorSpy).toHaveBeenCalledWith('Error occurred during server stop:', closeError); 354 | // expect(exitSpy).toHaveBeenCalledWith(0); 355 | 356 | // errorSpy.mockRestore(); 357 | // }); 358 | // }); 359 | // ... existing code ... 360 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | 3 | export default defineConfig({ 4 | build: { 5 | target: 'node18', 6 | ssr: true, 7 | outDir: 'dist', 8 | rollupOptions: { 9 | output: { 10 | format: 'esm', 11 | entryFileNames: 'server.mjs', 12 | }, 13 | }, 14 | }, 15 | test: { 16 | environment: 'node', 17 | }, 18 | }) 19 | --------------------------------------------------------------------------------