├── .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 | 
4 |
5 |
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 |
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 | 
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 | 
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 | 
163 | 
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 |
--------------------------------------------------------------------------------