Compare commits
No commits in common. "89f984a337f23583a91a2b279769954aa0a2f583" and "284812ac4cecb35afb323b9ed09d377bf5950757" have entirely different histories.
89f984a337
...
284812ac4c
23
.gitignore
vendored
23
.gitignore
vendored
@ -1,19 +1,8 @@
|
|||||||
# macOS
|
# MAC
|
||||||
.DS_Store
|
DS_Store
|
||||||
|
|
||||||
# dependencies
|
# package
|
||||||
node_modules/
|
node_modules
|
||||||
|
|
||||||
# build and package artifacts
|
# oh-my-opencode
|
||||||
dist/
|
.sisyphus
|
||||||
*.tgz
|
|
||||||
|
|
||||||
# test artifacts
|
|
||||||
coverage/
|
|
||||||
|
|
||||||
# local env files
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
|
|
||||||
# local tooling state
|
|
||||||
.sisyphus/
|
|
||||||
142
README.md
142
README.md
@ -1,142 +0,0 @@
|
|||||||
# TS MCP 模板(V1)
|
|
||||||
|
|
||||||
这是一个用于构建 Model Context Protocol(MCP)服务端的 Node.js + TypeScript 模板。该模板重点关注本地优先的开发体验、协议安全的 stdio 执行方式,以及显式的能力注册模式。
|
|
||||||
|
|
||||||
## 当前状态:V1 Alpha
|
|
||||||
|
|
||||||
当前模板支持:
|
|
||||||
- Node.js 20.19+ 运行时
|
|
||||||
- 单包架构
|
|
||||||
- 协议安全的本地 Stdio 传输(主要方式)
|
|
||||||
- Streamable HTTP 传输(次要方式)
|
|
||||||
- 显式的、基于函数的 tool、resource 和 prompt 注册方式
|
|
||||||
- 基于 Vitest 的传输层与核心逻辑测试
|
|
||||||
|
|
||||||
当前尚不支持 Bun、Deno、边缘运行时、内置鉴权或遥测能力。
|
|
||||||
|
|
||||||
## 快速开始:本地 Stdio
|
|
||||||
|
|
||||||
Stdio 是本模板在本地开发以及与 Claude 等桌面 LLM 客户端集成时的主要传输方式。
|
|
||||||
|
|
||||||
1. **安装依赖**
|
|
||||||
```bash
|
|
||||||
pnpm install
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **使用 MCP Inspector 运行**
|
|
||||||
模板在 `package.json` 中提供了一个仅用于工作区开发的协议安全 `mcp.stdio` 启动配置,用于避免 stdout 被污染。你可以使用以下命令测试服务端:
|
|
||||||
```bash
|
|
||||||
pnpm dlx @modelcontextprotocol/inspector node ./node_modules/tsx/dist/cli.mjs src/stdio.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **构建项目**
|
|
||||||
如果你要为生产环境做准备,或使用编译后的入口文件,可以执行:
|
|
||||||
```bash
|
|
||||||
pnpm build
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **已发布包使用 `npx` 启动**
|
|
||||||
如果你把这个模板产物发布到了 npm,并且保留了默认的 `bin` 映射,使用者可以直接通过包名启动 stdio 服务:
|
|
||||||
```bash
|
|
||||||
npx -y <your-package-name>
|
|
||||||
```
|
|
||||||
构建后,发布产物会直接提供扁平的 `dist/stdio.js` 入口,并作为 npm `bin` 暴露出来,适合分发后的 MCP client 集成场景。
|
|
||||||
|
|
||||||
### OpenCode 配置示例
|
|
||||||
|
|
||||||
如果你想在 OpenCode 中把这个模板作为本地 MCP server 挂进去,可以在项目级 `opencode.jsonc` 里加入一段本地 MCP 配置。下面这个示例和当前 `package.json` 中仅用于开发态的 `mcp.stdio` 启动参数保持一致:
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"$schema": "https://opencode.ai/config.json",
|
|
||||||
"mcp": {
|
|
||||||
"template_stdio": {
|
|
||||||
"type": "local",
|
|
||||||
"enabled": true,
|
|
||||||
"command": [
|
|
||||||
"node",
|
|
||||||
"./node_modules/tsx/dist/cli.mjs",
|
|
||||||
"src/stdio.ts"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
如果你把配置放在全局 OpenCode 配置里,需要把 `command` 里的相对路径改成这个项目的绝对路径,或者把 OpenCode 的工作目录指向仓库根目录。这样可以避免找不到 `./node_modules/tsx/dist/cli.mjs` 或 `src/stdio.ts`。
|
|
||||||
|
|
||||||
如果你是通过 npm 发布后的包来接入 OpenCode,也可以改成直接走 `npx` 的分发态启动方式:
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"$schema": "https://opencode.ai/config.json",
|
|
||||||
"mcp": {
|
|
||||||
"template_stdio": {
|
|
||||||
"type": "local",
|
|
||||||
"enabled": true,
|
|
||||||
"command": ["npx", "-y", "<your-package-name>"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
这种方式更适合已经发布的包;而模板开发阶段,仍然建议优先使用上面的源码启动配置。
|
|
||||||
|
|
||||||
## 发布面验证
|
|
||||||
|
|
||||||
- **发布入口检查**:`pnpm test:publish`(打包本地 tarball,并通过包的 `bin` 名启动一次 stdio 服务)
|
|
||||||
|
|
||||||
## 使用方式:HTTP
|
|
||||||
|
|
||||||
HTTP 传输基于 Node 原生的 `StreamableHTTPServerTransport`,适用于远程连接或基于 Web 的接入场景。
|
|
||||||
|
|
||||||
1. **启动 HTTP 服务**
|
|
||||||
```bash
|
|
||||||
pnpm dev:http
|
|
||||||
```
|
|
||||||
服务默认监听 `127.0.0.1:3000`。你也可以通过 `HTTP_PORT` 环境变量自定义端口。
|
|
||||||
|
|
||||||
2. **接口端点**
|
|
||||||
- `POST /mcp`:初始化一个新会话并处理请求。
|
|
||||||
- `GET /mcp`:处理已有会话中的后续请求。
|
|
||||||
|
|
||||||
## 开发与测试
|
|
||||||
|
|
||||||
- **类型检查**:`pnpm typecheck`
|
|
||||||
- **测试**:`pnpm test`(运行 stdio、HTTP 和核心逻辑的 Vitest smoke tests)
|
|
||||||
- **开发模式(核心逻辑)**:`pnpm dev`(执行 `src/index.ts`)
|
|
||||||
|
|
||||||
## 仓库结构
|
|
||||||
|
|
||||||
- `src/core/`:共享的 MCP 核心工厂 `createMcpCore`,负责构建服务端与注册表。
|
|
||||||
- `src/capabilities/`:tools、resources 与 prompts 的定义及处理逻辑。
|
|
||||||
- `src/stdio.ts`:stdio 入口文件及其传输层配置。
|
|
||||||
- `src/http.ts`:HTTP 入口文件与会话管理逻辑。
|
|
||||||
- `src/config/`:运行时配置与环境变量解析。
|
|
||||||
- `src/lib/`:内部工具函数、stderr 安全日志以及错误处理。
|
|
||||||
|
|
||||||
## 扩展指引
|
|
||||||
|
|
||||||
该模板使用显式的、基于函数的注册模式,而不是装饰器或反射机制。
|
|
||||||
|
|
||||||
### 1. 定义契约
|
|
||||||
|
|
||||||
在 `src/capabilities/contracts.ts` 中新增 tool 或 prompt 的 schema,以便在处理逻辑和测试之间共享类型。
|
|
||||||
|
|
||||||
### 2. 实现处理器
|
|
||||||
|
|
||||||
在 `src/capabilities/` 中创建或更新文件(例如 `tools.ts`):
|
|
||||||
1. 定义处理器逻辑。
|
|
||||||
2. 更新 `register...Capabilities` 函数,将描述信息加入注册表。
|
|
||||||
3. 更新 `register...Handlers` 函数,将逻辑挂接到 `McpServer` 实例上。
|
|
||||||
|
|
||||||
### 3. 注册到核心模块
|
|
||||||
|
|
||||||
如果你创建了新的 capability 模块,请确保它们已在 `src/capabilities/index.ts` 的 `registerCoreCapabilities` 和 `registerCoreMcpCapabilities` 函数中被调用。
|
|
||||||
|
|
||||||
## V1 限制
|
|
||||||
|
|
||||||
- **无鉴权**:HTTP 传输不包含内置鉴权能力。
|
|
||||||
- **仅支持 Node**:当前专门针对 Node.js 20.19+ 进行适配。
|
|
||||||
- **非 Monorepo**:设计目标是独立项目模板,而不是多包仓库。
|
|
||||||
- **本地优先**:V1 暂未提供面向云服务商的部署指南。
|
|
||||||
58
package.json
58
package.json
@ -1,58 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "mcp-typescript-template",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"description": "Reusable Node.js + TypeScript template for MCP servers.",
|
|
||||||
"license": "MIT",
|
|
||||||
"keywords": [
|
|
||||||
"mcp",
|
|
||||||
"model-context-protocol",
|
|
||||||
"typescript",
|
|
||||||
"template"
|
|
||||||
],
|
|
||||||
"files": [
|
|
||||||
"README.md",
|
|
||||||
"scripts",
|
|
||||||
"src",
|
|
||||||
"tests",
|
|
||||||
"dist",
|
|
||||||
"package.json",
|
|
||||||
"tsconfig.json",
|
|
||||||
"tsup.config.ts",
|
|
||||||
"vitest.config.ts"
|
|
||||||
],
|
|
||||||
"type": "module",
|
|
||||||
"packageManager": "pnpm@10.32.1",
|
|
||||||
"bin": {
|
|
||||||
"mcp-typescript-template": "./dist/stdio.js"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"dev": "tsx src/index.ts",
|
|
||||||
"dev:http": "tsx src/http.ts",
|
|
||||||
"build": "tsup",
|
|
||||||
"prepack": "pnpm build",
|
|
||||||
"test:publish": "node ./scripts/verify-publish-bin.mjs",
|
|
||||||
"typecheck": "tsc --noEmit",
|
|
||||||
"test": "vitest run"
|
|
||||||
},
|
|
||||||
"mcp": {
|
|
||||||
"stdio": {
|
|
||||||
"developmentOnly": true,
|
|
||||||
"command": "node",
|
|
||||||
"args": [
|
|
||||||
"./node_modules/tsx/dist/cli.mjs",
|
|
||||||
"src/stdio.ts"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^24.0.0",
|
|
||||||
"tsup": "^8.5.1",
|
|
||||||
"tsx": "^4.20.0",
|
|
||||||
"typescript": "^5.8.0",
|
|
||||||
"vitest": "^4.0.0"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
||||||
"zod": "^4.1.11"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
2373
pnpm-lock.yaml
generated
2373
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,123 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
import { spawn } from "node:child_process";
|
|
||||||
import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join, resolve } from "node:path";
|
|
||||||
|
|
||||||
const projectRoot = resolve(import.meta.dirname, "..");
|
|
||||||
|
|
||||||
async function readPublishedBinName() {
|
|
||||||
const packageJsonRaw = await readFile(join(projectRoot, "package.json"), "utf8");
|
|
||||||
const packageJson = JSON.parse(packageJsonRaw);
|
|
||||||
const binNames = Object.keys(packageJson.bin ?? {});
|
|
||||||
|
|
||||||
if (binNames.length !== 1) {
|
|
||||||
throw new Error(`Expected exactly one published bin entry, found ${binNames.length}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return binNames[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
function run(command, args, options = {}) {
|
|
||||||
return new Promise((resolvePromise, rejectPromise) => {
|
|
||||||
const child = spawn(command, args, {
|
|
||||||
cwd: projectRoot,
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
|
|
||||||
let stdout = "";
|
|
||||||
let stderr = "";
|
|
||||||
|
|
||||||
child.stdout.on("data", (chunk) => {
|
|
||||||
stdout += chunk.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
child.stderr.on("data", (chunk) => {
|
|
||||||
stderr += chunk.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
child.on("error", rejectPromise);
|
|
||||||
child.on("exit", (code) => {
|
|
||||||
if (code === 0) {
|
|
||||||
resolvePromise({ stdout, stderr });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
rejectPromise(
|
|
||||||
new Error(
|
|
||||||
`${command} ${args.join(" ")} failed with code ${code}\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function verifyPackagedBin() {
|
|
||||||
const packDir = await mkdtemp(join(tmpdir(), "mcp-template-pack-"));
|
|
||||||
const installDir = await mkdtemp(join(tmpdir(), "mcp-template-install-"));
|
|
||||||
const binName = await readPublishedBinName();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await run("pnpm", ["pack", "--pack-destination", packDir]);
|
|
||||||
const tarballs = (await readdir(packDir)).filter((name) => name.endsWith(".tgz"));
|
|
||||||
|
|
||||||
if (tarballs.length !== 1) {
|
|
||||||
throw new Error(`Expected exactly one tarball, found ${tarballs.length}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tarballPath = join(packDir, tarballs[0]);
|
|
||||||
await writeFile(join(installDir, "package.json"), '{"name":"publish-bin-check","private":true}\n');
|
|
||||||
await run("npm", ["install", tarballPath], { cwd: installDir });
|
|
||||||
|
|
||||||
const startup = spawn(join(installDir, "node_modules", ".bin", binName), [], {
|
|
||||||
cwd: installDir,
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const startupOutput = await new Promise((resolvePromise, rejectPromise) => {
|
|
||||||
let stderr = "";
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
startup.kill("SIGTERM");
|
|
||||||
rejectPromise(new Error(`Timed out waiting for packaged bin to start\nSTDERR:\n${stderr}`));
|
|
||||||
}, 10000);
|
|
||||||
|
|
||||||
startup.stderr.on("data", (chunk) => {
|
|
||||||
stderr += chunk.toString();
|
|
||||||
if (stderr.includes("MCP stdio server started")) {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
startup.kill("SIGTERM");
|
|
||||||
resolvePromise(stderr);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
startup.on("error", (error) => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
rejectPromise(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
startup.on("exit", (code, signal) => {
|
|
||||||
if (signal === "SIGTERM") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
clearTimeout(timeout);
|
|
||||||
rejectPromise(
|
|
||||||
new Error(
|
|
||||||
`Packaged bin exited before startup completed (code=${code}, signal=${signal})\nSTDERR:\n${stderr}`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!String(startupOutput).includes("MCP stdio server started")) {
|
|
||||||
throw new Error("Packaged bin did not emit the expected startup log");
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await rm(packDir, { recursive: true, force: true });
|
|
||||||
await rm(installDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await verifyPackagedBin();
|
|
||||||
@ -1,95 +0,0 @@
|
|||||||
import { AppError } from "../lib/errors.js";
|
|
||||||
|
|
||||||
export const EXAMPLE_ECHO_TOOL_NAME = "example.echo";
|
|
||||||
export const EXAMPLE_SUMMARY_PROMPT_NAME = "example.summary";
|
|
||||||
|
|
||||||
export type ExampleEchoToolInput = {
|
|
||||||
message: string;
|
|
||||||
uppercase?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ExampleEchoToolResult = {
|
|
||||||
output: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ExampleSummaryPromptInput = {
|
|
||||||
topic: string;
|
|
||||||
audience?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ObjectRecord = Record<string, unknown>;
|
|
||||||
|
|
||||||
function asRecord(value: unknown, context: string): ObjectRecord {
|
|
||||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
||||||
return value as ObjectRecord;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new AppError(`${context} must be an object`, "E_CONTRACT_INVALID_INPUT", {
|
|
||||||
cause: value,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function asNonEmptyString(value: unknown, field: string): string {
|
|
||||||
if (typeof value !== "string") {
|
|
||||||
throw new AppError(`${field} must be a string`, "E_CONTRACT_INVALID_INPUT", {
|
|
||||||
cause: value,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalized = value.trim();
|
|
||||||
if (!normalized) {
|
|
||||||
throw new AppError(`${field} must not be empty`, "E_CONTRACT_INVALID_INPUT", {
|
|
||||||
cause: value,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
function asOptionalBoolean(value: unknown, field: string): boolean | undefined {
|
|
||||||
if (value === undefined) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value !== "boolean") {
|
|
||||||
throw new AppError(`${field} must be a boolean when provided`, "E_CONTRACT_INVALID_INPUT", {
|
|
||||||
cause: value,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseExampleEchoToolInput(input: unknown): ExampleEchoToolInput {
|
|
||||||
const record = asRecord(input, EXAMPLE_ECHO_TOOL_NAME);
|
|
||||||
|
|
||||||
return {
|
|
||||||
message: asNonEmptyString(record.message, "message"),
|
|
||||||
uppercase: asOptionalBoolean(record.uppercase, "uppercase"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function runExampleEchoTool(input: unknown): ExampleEchoToolResult {
|
|
||||||
const parsed = parseExampleEchoToolInput(input);
|
|
||||||
return {
|
|
||||||
output: parsed.uppercase ? parsed.message.toUpperCase() : parsed.message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseExampleSummaryPromptInput(input: unknown): ExampleSummaryPromptInput {
|
|
||||||
const record = asRecord(input, EXAMPLE_SUMMARY_PROMPT_NAME);
|
|
||||||
|
|
||||||
return {
|
|
||||||
topic: asNonEmptyString(record.topic, "topic"),
|
|
||||||
audience:
|
|
||||||
record.audience === undefined
|
|
||||||
? undefined
|
|
||||||
: asNonEmptyString(record.audience, "audience"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renderExampleSummaryPrompt(input: unknown): string {
|
|
||||||
const parsed = parseExampleSummaryPromptInput(input);
|
|
||||||
const audienceLabel = parsed.audience ? ` for ${parsed.audience}` : "";
|
|
||||||
return `Write a concise summary about ${parsed.topic}${audienceLabel}.`;
|
|
||||||
}
|
|
||||||
@ -1,63 +0,0 @@
|
|||||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
||||||
import {
|
|
||||||
registerPromptCapabilities,
|
|
||||||
registerPromptHandlers,
|
|
||||||
} from "./prompts.js";
|
|
||||||
import {
|
|
||||||
registerResourceCapabilities,
|
|
||||||
registerResourceHandlers,
|
|
||||||
} from "./resources.js";
|
|
||||||
import { registerToolCapabilities, registerToolHandlers } from "./tools.js";
|
|
||||||
import type { CapabilityRegistration, CapabilityRegistry } from "./types.js";
|
|
||||||
import { createCapabilityRegistry } from "./types.js";
|
|
||||||
|
|
||||||
export { registerPromptCapabilities } from "./prompts.js";
|
|
||||||
export { registerPromptHandlers } from "./prompts.js";
|
|
||||||
export { registerResourceCapabilities } from "./resources.js";
|
|
||||||
export { registerResourceHandlers, TEMPLATE_STATUS_RESOURCE_URI } from "./resources.js";
|
|
||||||
export { registerToolCapabilities } from "./tools.js";
|
|
||||||
export { registerToolHandlers } from "./tools.js";
|
|
||||||
export {
|
|
||||||
EXAMPLE_ECHO_TOOL_NAME,
|
|
||||||
EXAMPLE_SUMMARY_PROMPT_NAME,
|
|
||||||
parseExampleEchoToolInput,
|
|
||||||
parseExampleSummaryPromptInput,
|
|
||||||
renderExampleSummaryPrompt,
|
|
||||||
runExampleEchoTool,
|
|
||||||
} from "./contracts.js";
|
|
||||||
export type {
|
|
||||||
CapabilityDescriptor,
|
|
||||||
CapabilityKind,
|
|
||||||
CapabilityRegistration,
|
|
||||||
CapabilityRegistry,
|
|
||||||
} from "./types.js";
|
|
||||||
export type {
|
|
||||||
ExampleEchoToolInput,
|
|
||||||
ExampleEchoToolResult,
|
|
||||||
ExampleSummaryPromptInput,
|
|
||||||
} from "./contracts.js";
|
|
||||||
export { createCapabilityRegistry } from "./types.js";
|
|
||||||
|
|
||||||
export const registerCoreCapabilities: CapabilityRegistration = (registry) => {
|
|
||||||
registerToolCapabilities(registry);
|
|
||||||
registerResourceCapabilities(registry);
|
|
||||||
registerPromptCapabilities(registry);
|
|
||||||
};
|
|
||||||
|
|
||||||
export function registerCoreMcpCapabilities(server: McpServer): void {
|
|
||||||
registerToolHandlers(server);
|
|
||||||
registerResourceHandlers(server);
|
|
||||||
registerPromptHandlers(server);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerCapabilities(
|
|
||||||
registrations: CapabilityRegistration[] = [registerCoreCapabilities],
|
|
||||||
): CapabilityRegistry {
|
|
||||||
const registry = createCapabilityRegistry();
|
|
||||||
|
|
||||||
for (const register of registrations) {
|
|
||||||
register(registry);
|
|
||||||
}
|
|
||||||
|
|
||||||
return registry;
|
|
||||||
}
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
||||||
import { z } from "zod";
|
|
||||||
import {
|
|
||||||
EXAMPLE_SUMMARY_PROMPT_NAME,
|
|
||||||
renderExampleSummaryPrompt,
|
|
||||||
} from "./contracts.js";
|
|
||||||
import type { CapabilityRegistration } from "./types.js";
|
|
||||||
|
|
||||||
export const registerPromptCapabilities: CapabilityRegistration = (registry) => {
|
|
||||||
registry.prompts.push({
|
|
||||||
name: EXAMPLE_SUMMARY_PROMPT_NAME,
|
|
||||||
kind: "prompt",
|
|
||||||
description: "Summary prompt with a validated topic/audience contract",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const summaryPromptArgsSchema = {
|
|
||||||
topic: z.string(),
|
|
||||||
audience: z.string().optional(),
|
|
||||||
};
|
|
||||||
|
|
||||||
export function registerPromptHandlers(server: McpServer): void {
|
|
||||||
server.registerPrompt(
|
|
||||||
EXAMPLE_SUMMARY_PROMPT_NAME,
|
|
||||||
{
|
|
||||||
description: "Summary prompt with a validated topic/audience contract",
|
|
||||||
argsSchema: summaryPromptArgsSchema,
|
|
||||||
},
|
|
||||||
(input) => ({
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
content: {
|
|
||||||
type: "text",
|
|
||||||
text: renderExampleSummaryPrompt(input),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
import type { CapabilityRegistration } from "./types.js";
|
|
||||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
||||||
|
|
||||||
export const TEMPLATE_STATUS_RESOURCE_URI = "template://status";
|
|
||||||
|
|
||||||
export const registerResourceCapabilities: CapabilityRegistration = (registry) => {
|
|
||||||
registry.resources.push({
|
|
||||||
name: TEMPLATE_STATUS_RESOURCE_URI,
|
|
||||||
kind: "resource",
|
|
||||||
description: "Baseline status resource descriptor",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export function registerResourceHandlers(server: McpServer): void {
|
|
||||||
server.registerResource(
|
|
||||||
"template-status",
|
|
||||||
TEMPLATE_STATUS_RESOURCE_URI,
|
|
||||||
{
|
|
||||||
title: "Template Status",
|
|
||||||
description: "Baseline status resource descriptor",
|
|
||||||
mimeType: "application/json",
|
|
||||||
},
|
|
||||||
async (uri) => ({
|
|
||||||
contents: [
|
|
||||||
{
|
|
||||||
uri: uri.href,
|
|
||||||
mimeType: "application/json",
|
|
||||||
text: JSON.stringify({ status: "ok", template: "ts-mcp-template" }),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
||||||
import { z } from "zod";
|
|
||||||
import {
|
|
||||||
EXAMPLE_ECHO_TOOL_NAME,
|
|
||||||
runExampleEchoTool,
|
|
||||||
type ExampleEchoToolResult,
|
|
||||||
} from "./contracts.js";
|
|
||||||
import type { CapabilityRegistration } from "./types.js";
|
|
||||||
|
|
||||||
export const registerToolCapabilities: CapabilityRegistration = (registry) => {
|
|
||||||
registry.tools.push({
|
|
||||||
name: EXAMPLE_ECHO_TOOL_NAME,
|
|
||||||
kind: "tool",
|
|
||||||
description: "Echo text with a validated input contract",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const exampleEchoInputSchema = z.object({
|
|
||||||
message: z.string(),
|
|
||||||
uppercase: z.boolean().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
function toEchoToolResponse(result: ExampleEchoToolResult) {
|
|
||||||
return {
|
|
||||||
content: [{ type: "text" as const, text: result.output }],
|
|
||||||
structuredContent: result,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerToolHandlers(server: McpServer): void {
|
|
||||||
server.registerTool(
|
|
||||||
EXAMPLE_ECHO_TOOL_NAME,
|
|
||||||
{
|
|
||||||
description: "Echo text with a validated input contract",
|
|
||||||
inputSchema: exampleEchoInputSchema,
|
|
||||||
},
|
|
||||||
async (input) => toEchoToolResponse(runExampleEchoTool(input)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
export type CapabilityKind = "tool" | "resource" | "prompt";
|
|
||||||
|
|
||||||
export type CapabilityDescriptor = {
|
|
||||||
name: string;
|
|
||||||
kind: CapabilityKind;
|
|
||||||
description: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CapabilityRegistry = {
|
|
||||||
tools: CapabilityDescriptor[];
|
|
||||||
resources: CapabilityDescriptor[];
|
|
||||||
prompts: CapabilityDescriptor[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CapabilityRegistration = (registry: CapabilityRegistry) => void;
|
|
||||||
|
|
||||||
export function createCapabilityRegistry(): CapabilityRegistry {
|
|
||||||
return {
|
|
||||||
tools: [],
|
|
||||||
resources: [],
|
|
||||||
prompts: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
export type { RuntimeConfig, RuntimeMode } from "./runtime.js";
|
|
||||||
export { parseRuntimeConfig, RUNTIME_MODES } from "./runtime.js";
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
export const RUNTIME_MODES = ["development", "test", "production"] as const;
|
|
||||||
|
|
||||||
export type RuntimeMode = (typeof RUNTIME_MODES)[number];
|
|
||||||
|
|
||||||
export interface RuntimeConfig {
|
|
||||||
mode: RuntimeMode;
|
|
||||||
port: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_PORT = 3000;
|
|
||||||
|
|
||||||
function parseRuntimeMode(rawMode: string | undefined): RuntimeMode {
|
|
||||||
const normalized = rawMode?.trim().toLowerCase();
|
|
||||||
|
|
||||||
if (normalized === undefined || normalized.length === 0) {
|
|
||||||
return "development";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (RUNTIME_MODES.includes(normalized as RuntimeMode)) {
|
|
||||||
return normalized as RuntimeMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(
|
|
||||||
`Invalid RUNTIME_MODE '${rawMode}'. Expected one of: ${RUNTIME_MODES.join(", ")}.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseHttpPort(rawPort: string | undefined): number {
|
|
||||||
if (rawPort === undefined || rawPort.trim().length === 0) {
|
|
||||||
return DEFAULT_PORT;
|
|
||||||
}
|
|
||||||
|
|
||||||
const value = Number(rawPort);
|
|
||||||
const isInteger = Number.isInteger(value);
|
|
||||||
|
|
||||||
if (!isInteger || value < 1 || value > 65535) {
|
|
||||||
throw new Error(`Invalid HTTP_PORT '${rawPort}'. Expected an integer from 1 to 65535.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseRuntimeConfig(env: NodeJS.ProcessEnv = process.env): RuntimeConfig {
|
|
||||||
return {
|
|
||||||
mode: parseRuntimeMode(env.RUNTIME_MODE),
|
|
||||||
port: parseHttpPort(env.HTTP_PORT)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
export { createMcpCore } from "./mcp-core.js";
|
|
||||||
export type { McpCore, McpCoreFactoryOptions } from "./mcp-core.js";
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
||||||
import type { Implementation } from "@modelcontextprotocol/sdk/types.js";
|
|
||||||
import {
|
|
||||||
registerCapabilities,
|
|
||||||
registerCoreCapabilities,
|
|
||||||
registerCoreMcpCapabilities,
|
|
||||||
type CapabilityRegistration,
|
|
||||||
type CapabilityRegistry,
|
|
||||||
} from "../capabilities/index.js";
|
|
||||||
|
|
||||||
export type McpCore = {
|
|
||||||
server: McpServer;
|
|
||||||
registry: CapabilityRegistry;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type McpCoreFactoryOptions = {
|
|
||||||
serverInfo?: Implementation;
|
|
||||||
capabilityRegistrations?: CapabilityRegistration[];
|
|
||||||
registerServerCapabilities?: (server: McpServer) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_SERVER_INFO: Implementation = {
|
|
||||||
name: "ts-mcp-template",
|
|
||||||
version: "0.1.0",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function createMcpCore(options: McpCoreFactoryOptions = {}): McpCore {
|
|
||||||
const capabilityRegistrations =
|
|
||||||
options.capabilityRegistrations ?? [registerCoreCapabilities];
|
|
||||||
const registerServerCapabilities =
|
|
||||||
options.registerServerCapabilities ?? registerCoreMcpCapabilities;
|
|
||||||
|
|
||||||
const server = new McpServer(options.serverInfo ?? DEFAULT_SERVER_INFO);
|
|
||||||
const registry = registerCapabilities(capabilityRegistrations);
|
|
||||||
|
|
||||||
registerServerCapabilities(server);
|
|
||||||
|
|
||||||
return {
|
|
||||||
server,
|
|
||||||
registry,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
220
src/http.ts
220
src/http.ts
@ -1,220 +0,0 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
import { pathToFileURL } from "node:url";
|
|
||||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
||||||
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
||||||
import { parseRuntimeConfig } from "./config/index.js";
|
|
||||||
import { createMcpCore, type McpCore } from "./core/index.js";
|
|
||||||
import { asError, toErrorPayload } from "./lib/errors.js";
|
|
||||||
import { createLogger } from "./lib/logger.js";
|
|
||||||
|
|
||||||
const logger = createLogger({ name: "mcp-http" });
|
|
||||||
|
|
||||||
type SessionState = {
|
|
||||||
core: McpCore;
|
|
||||||
transport: StreamableHTTPServerTransport;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type HttpServerOptions = {
|
|
||||||
host?: string;
|
|
||||||
port?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const MCP_ROUTE = "/mcp";
|
|
||||||
|
|
||||||
function readSessionIdHeader(req: IncomingMessage): string | undefined {
|
|
||||||
const header = req.headers["mcp-session-id"];
|
|
||||||
if (Array.isArray(header)) {
|
|
||||||
return header[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
return header;
|
|
||||||
}
|
|
||||||
|
|
||||||
function respondJson(
|
|
||||||
res: ServerResponse,
|
|
||||||
statusCode: number,
|
|
||||||
payload: Record<string, unknown>,
|
|
||||||
): void {
|
|
||||||
const body = JSON.stringify(payload);
|
|
||||||
res.statusCode = statusCode;
|
|
||||||
res.setHeader("content-type", "application/json");
|
|
||||||
res.setHeader("content-length", Buffer.byteLength(body));
|
|
||||||
res.end(body);
|
|
||||||
}
|
|
||||||
|
|
||||||
function respondJsonRpcError(
|
|
||||||
res: ServerResponse,
|
|
||||||
statusCode: number,
|
|
||||||
message: string,
|
|
||||||
code: number,
|
|
||||||
): void {
|
|
||||||
respondJson(res, statusCode, {
|
|
||||||
jsonrpc: "2.0",
|
|
||||||
error: {
|
|
||||||
code,
|
|
||||||
message,
|
|
||||||
},
|
|
||||||
id: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readJsonBody(req: IncomingMessage): Promise<unknown> {
|
|
||||||
const chunks: Buffer[] = [];
|
|
||||||
|
|
||||||
for await (const chunk of req) {
|
|
||||||
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chunks.length === 0) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const raw = Buffer.concat(chunks).toString("utf8").trim();
|
|
||||||
if (raw.length === 0) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return JSON.parse(raw);
|
|
||||||
} catch {
|
|
||||||
throw new Error("Invalid JSON body");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleMcpRequest(
|
|
||||||
req: IncomingMessage,
|
|
||||||
res: ServerResponse,
|
|
||||||
sessions: Map<string, SessionState>,
|
|
||||||
): Promise<void> {
|
|
||||||
const method = req.method ?? "GET";
|
|
||||||
const parsedBody = method === "POST" ? await readJsonBody(req) : undefined;
|
|
||||||
const sessionId = readSessionIdHeader(req);
|
|
||||||
|
|
||||||
if (sessionId !== undefined) {
|
|
||||||
const existing = sessions.get(sessionId);
|
|
||||||
if (existing === undefined) {
|
|
||||||
respondJsonRpcError(res, 404, "Session not found", -32001);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await existing.transport.handleRequest(req, res, parsedBody);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (method === "POST" && isInitializeRequest(parsedBody)) {
|
|
||||||
const core = createMcpCore();
|
|
||||||
const state: SessionState = {
|
|
||||||
core,
|
|
||||||
transport: new StreamableHTTPServerTransport({
|
|
||||||
sessionIdGenerator: () => randomUUID(),
|
|
||||||
onsessioninitialized: (newSessionId) => {
|
|
||||||
sessions.set(newSessionId, state);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
state.transport.onclose = () => {
|
|
||||||
if (state.transport.sessionId !== undefined) {
|
|
||||||
sessions.delete(state.transport.sessionId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
await core.server.connect(state.transport);
|
|
||||||
await state.transport.handleRequest(req, res, parsedBody);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJsonRpcError(res, 400, "Bad Request: No valid session ID provided", -32000);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleHttpRequest(
|
|
||||||
req: IncomingMessage,
|
|
||||||
res: ServerResponse,
|
|
||||||
sessions: Map<string, SessionState>,
|
|
||||||
): Promise<void> {
|
|
||||||
const path = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`).pathname;
|
|
||||||
if (path !== MCP_ROUTE) {
|
|
||||||
respondJson(res, 404, {
|
|
||||||
error: "Not Found",
|
|
||||||
message: `Route '${path}' is not available. Use '${MCP_ROUTE}'.`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const method = req.method ?? "GET";
|
|
||||||
if (method !== "GET" && method !== "POST" && method !== "DELETE") {
|
|
||||||
respondJson(res, 405, {
|
|
||||||
error: "Method Not Allowed",
|
|
||||||
message: `Method '${method}' is not supported on '${MCP_ROUTE}'.`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await handleMcpRequest(req, res, sessions);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function startHttpServer(options: HttpServerOptions = {}): Promise<Server> {
|
|
||||||
const runtime = parseRuntimeConfig();
|
|
||||||
const host = options.host ?? "127.0.0.1";
|
|
||||||
const port = options.port ?? runtime.port;
|
|
||||||
const sessions = new Map<string, SessionState>();
|
|
||||||
|
|
||||||
const server = createServer((req, res) => {
|
|
||||||
void handleHttpRequest(req, res, sessions).catch((error: unknown) => {
|
|
||||||
logger.error("Failed to process HTTP request", toErrorPayload(asError(error)));
|
|
||||||
|
|
||||||
if (!res.headersSent) {
|
|
||||||
respondJsonRpcError(res, 500, "Internal server error", -32603);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
server.on("close", () => {
|
|
||||||
for (const state of sessions.values()) {
|
|
||||||
void state.transport.close().catch(() => {
|
|
||||||
logger.warn("Failed to close streamable HTTP transport");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
sessions.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolvePromise, reject) => {
|
|
||||||
server.once("error", reject);
|
|
||||||
server.listen(port, host, () => {
|
|
||||||
server.off("error", reject);
|
|
||||||
resolvePromise();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info("MCP HTTP server started", {
|
|
||||||
host,
|
|
||||||
port,
|
|
||||||
route: MCP_ROUTE,
|
|
||||||
});
|
|
||||||
|
|
||||||
return server;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runHttpEntrypoint(): Promise<void> {
|
|
||||||
try {
|
|
||||||
await startHttpServer();
|
|
||||||
} catch (error: unknown) {
|
|
||||||
logger.error("Fatal HTTP startup error", toErrorPayload(asError(error)));
|
|
||||||
process.exitCode = 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isDirectExecution(): boolean {
|
|
||||||
const entryPath = process.argv[1];
|
|
||||||
if (entryPath === undefined) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return import.meta.url === pathToFileURL(resolve(entryPath)).href;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDirectExecution()) {
|
|
||||||
void runHttpEntrypoint();
|
|
||||||
}
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
export * from "./capabilities/index.js";
|
|
||||||
export * from "./core/index.js";
|
|
||||||
export * from "./http.js";
|
|
||||||
export * from "./stdio.js";
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
export class AppError extends Error {
|
|
||||||
public readonly code: string;
|
|
||||||
public readonly cause?: unknown;
|
|
||||||
|
|
||||||
constructor(message: string, code: string, options?: { cause?: unknown }) {
|
|
||||||
super(message);
|
|
||||||
this.name = "AppError";
|
|
||||||
this.code = code;
|
|
||||||
this.cause = options?.cause;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function asError(value: unknown): Error {
|
|
||||||
if (value instanceof Error) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === "string") {
|
|
||||||
return new Error(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Error("Unknown error");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toErrorPayload(error: unknown): { name: string; message: string; code?: string } {
|
|
||||||
if (error instanceof AppError) {
|
|
||||||
return {
|
|
||||||
name: error.name,
|
|
||||||
message: error.message,
|
|
||||||
code: error.code
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalized = asError(error);
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: normalized.name,
|
|
||||||
message: normalized.message
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isAppError(error: unknown): error is AppError {
|
|
||||||
return error instanceof AppError;
|
|
||||||
}
|
|
||||||
@ -1,69 +0,0 @@
|
|||||||
export type LogLevel = "debug" | "info" | "warn" | "error";
|
|
||||||
|
|
||||||
export interface Logger {
|
|
||||||
debug: (message: string, details?: unknown) => void;
|
|
||||||
info: (message: string, details?: unknown) => void;
|
|
||||||
warn: (message: string, details?: unknown) => void;
|
|
||||||
error: (message: string, details?: unknown) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LoggerOptions {
|
|
||||||
name?: string;
|
|
||||||
sink?: (line: string) => void;
|
|
||||||
now?: () => Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
function serializeDetails(details: unknown): string {
|
|
||||||
if (details === undefined) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (details instanceof Error) {
|
|
||||||
return JSON.stringify({
|
|
||||||
name: details.name,
|
|
||||||
message: details.message,
|
|
||||||
stack: details.stack
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof details === "string") {
|
|
||||||
return JSON.stringify({ details });
|
|
||||||
}
|
|
||||||
|
|
||||||
return JSON.stringify(details);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createLogger(options: LoggerOptions = {}): Logger {
|
|
||||||
const name = options.name ?? "app";
|
|
||||||
// MCP stdio servers must keep stdout reserved for protocol messages.
|
|
||||||
const sink = options.sink ?? ((line: string) => process.stderr.write(`${line}\n`));
|
|
||||||
const now = options.now ?? (() => new Date());
|
|
||||||
|
|
||||||
const write = (level: LogLevel, message: string, details?: unknown): void => {
|
|
||||||
const payload = {
|
|
||||||
timestamp: now().toISOString(),
|
|
||||||
level,
|
|
||||||
logger: name,
|
|
||||||
message,
|
|
||||||
...(details === undefined ? {} : { details })
|
|
||||||
};
|
|
||||||
|
|
||||||
sink(JSON.stringify(payload));
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
debug: (message, details) => write("debug", message, details),
|
|
||||||
info: (message, details) => write("info", message, details),
|
|
||||||
warn: (message, details) => write("warn", message, details),
|
|
||||||
error: (message, details) => write("error", message, details)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatLogLine(level: LogLevel, message: string, details?: unknown): string {
|
|
||||||
const suffix = serializeDetails(details);
|
|
||||||
if (suffix.length === 0) {
|
|
||||||
return `[${level.toUpperCase()}] ${message}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `[${level.toUpperCase()}] ${message} ${suffix}`;
|
|
||||||
}
|
|
||||||
44
src/stdio.ts
44
src/stdio.ts
@ -1,44 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
import { realpathSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
||||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
||||||
import { createMcpCore } from "./core/index.js";
|
|
||||||
import { asError, toErrorPayload } from "./lib/errors.js";
|
|
||||||
import { createLogger } from "./lib/logger.js";
|
|
||||||
|
|
||||||
const logger = createLogger({ name: "mcp-stdio" });
|
|
||||||
|
|
||||||
export async function startStdioServer(): Promise<void> {
|
|
||||||
const { server } = createMcpCore();
|
|
||||||
const transport = new StdioServerTransport();
|
|
||||||
|
|
||||||
await server.connect(transport);
|
|
||||||
logger.info("MCP stdio server started");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runStdioEntrypoint(): Promise<void> {
|
|
||||||
try {
|
|
||||||
await startStdioServer();
|
|
||||||
} catch (error: unknown) {
|
|
||||||
logger.error("Fatal stdio startup error", toErrorPayload(asError(error)));
|
|
||||||
process.exitCode = 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isDirectExecution(): boolean {
|
|
||||||
const entryPath = process.argv[1];
|
|
||||||
if (entryPath === undefined) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const runtimeEntryPath = realpathSync(resolve(entryPath));
|
|
||||||
const modulePath = realpathSync(fileURLToPath(import.meta.url));
|
|
||||||
|
|
||||||
return pathToFileURL(modulePath).href === pathToFileURL(runtimeEntryPath).href;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDirectExecution()) {
|
|
||||||
void runStdioEntrypoint();
|
|
||||||
}
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import {
|
|
||||||
EXAMPLE_ECHO_TOOL_NAME,
|
|
||||||
EXAMPLE_SUMMARY_PROMPT_NAME,
|
|
||||||
type CapabilityRegistration,
|
|
||||||
createCapabilityRegistry,
|
|
||||||
registerCapabilities,
|
|
||||||
registerCoreCapabilities,
|
|
||||||
} from "../src/capabilities/index.js";
|
|
||||||
|
|
||||||
describe("capability registration", () => {
|
|
||||||
it("aggregates core registrations through plain function composition", () => {
|
|
||||||
const registry = registerCapabilities();
|
|
||||||
|
|
||||||
expect(registry.tools.map((entry) => entry.name)).toEqual([
|
|
||||||
EXAMPLE_ECHO_TOOL_NAME,
|
|
||||||
]);
|
|
||||||
expect(registry.resources.map((entry) => entry.name)).toEqual([
|
|
||||||
"template://status",
|
|
||||||
]);
|
|
||||||
expect(registry.prompts.map((entry) => entry.name)).toEqual([
|
|
||||||
EXAMPLE_SUMMARY_PROMPT_NAME,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("supports explicit composition with custom registrations", () => {
|
|
||||||
const registerCustom: CapabilityRegistration = (registry) => {
|
|
||||||
registry.tools.push({
|
|
||||||
name: "custom.tool",
|
|
||||||
kind: "tool",
|
|
||||||
description: "Custom tool descriptor",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const registry = registerCapabilities([
|
|
||||||
registerCoreCapabilities,
|
|
||||||
registerCustom,
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(registry.tools.map((entry) => entry.name)).toEqual([
|
|
||||||
EXAMPLE_ECHO_TOOL_NAME,
|
|
||||||
"custom.tool",
|
|
||||||
]);
|
|
||||||
expect(registry.resources).toHaveLength(1);
|
|
||||||
expect(registry.prompts).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("lets callers register into an existing registry", () => {
|
|
||||||
const registry = createCapabilityRegistry();
|
|
||||||
|
|
||||||
registerCoreCapabilities(registry);
|
|
||||||
|
|
||||||
expect(registry.tools).toHaveLength(1);
|
|
||||||
expect(registry.resources).toHaveLength(1);
|
|
||||||
expect(registry.prompts).toHaveLength(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,64 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { AppError } from "../src/lib/errors.js";
|
|
||||||
import {
|
|
||||||
parseExampleEchoToolInput,
|
|
||||||
parseExampleSummaryPromptInput,
|
|
||||||
renderExampleSummaryPrompt,
|
|
||||||
runExampleEchoTool,
|
|
||||||
} from "../src/capabilities/index.js";
|
|
||||||
|
|
||||||
describe("example capability contracts", () => {
|
|
||||||
it("accepts valid tool input and applies uppercase transform", () => {
|
|
||||||
expect(
|
|
||||||
parseExampleEchoToolInput({ message: " hello team ", uppercase: true }),
|
|
||||||
).toEqual({
|
|
||||||
message: "hello team",
|
|
||||||
uppercase: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(runExampleEchoTool({ message: "hello", uppercase: true })).toEqual({
|
|
||||||
output: "HELLO",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects invalid tool input shapes", () => {
|
|
||||||
expect(() => parseExampleEchoToolInput("bad-input")).toThrow(AppError);
|
|
||||||
expect(() => parseExampleEchoToolInput({ message: "" })).toThrow(
|
|
||||||
"message must not be empty",
|
|
||||||
);
|
|
||||||
expect(() =>
|
|
||||||
parseExampleEchoToolInput({ message: "valid", uppercase: "yes" }),
|
|
||||||
).toThrow("uppercase must be a boolean when provided");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts valid prompt input and renders generic prompt text", () => {
|
|
||||||
expect(
|
|
||||||
parseExampleSummaryPromptInput({
|
|
||||||
topic: "event-driven systems",
|
|
||||||
audience: "new contributors",
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
topic: "event-driven systems",
|
|
||||||
audience: "new contributors",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(
|
|
||||||
renderExampleSummaryPrompt({
|
|
||||||
topic: "API design",
|
|
||||||
}),
|
|
||||||
).toBe("Write a concise summary about API design.");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects invalid prompt input shapes", () => {
|
|
||||||
expect(() => parseExampleSummaryPromptInput(null)).toThrow(AppError);
|
|
||||||
expect(() => parseExampleSummaryPromptInput({ topic: 42 })).toThrow(
|
|
||||||
"topic must be a string",
|
|
||||||
);
|
|
||||||
expect(() =>
|
|
||||||
parseExampleSummaryPromptInput({
|
|
||||||
topic: "valid topic",
|
|
||||||
audience: " ",
|
|
||||||
}),
|
|
||||||
).toThrow("audience must not be empty");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,69 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { parseRuntimeConfig } from "../src/config/index.js";
|
|
||||||
|
|
||||||
describe("parseRuntimeConfig", () => {
|
|
||||||
it("returns defaults when env values are missing", () => {
|
|
||||||
expect(parseRuntimeConfig({})).toEqual({
|
|
||||||
mode: "development",
|
|
||||||
port: 3000,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses valid runtime mode and port", () => {
|
|
||||||
expect(
|
|
||||||
parseRuntimeConfig({
|
|
||||||
RUNTIME_MODE: "production",
|
|
||||||
HTTP_PORT: "8080",
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
mode: "production",
|
|
||||||
port: 8080,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("normalizes mode casing and surrounding whitespace", () => {
|
|
||||||
expect(
|
|
||||||
parseRuntimeConfig({
|
|
||||||
RUNTIME_MODE: " PrOdUcTiOn ",
|
|
||||||
HTTP_PORT: " 8081 ",
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
mode: "production",
|
|
||||||
port: 8081,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects invalid runtime mode", () => {
|
|
||||||
expect(() =>
|
|
||||||
parseRuntimeConfig({
|
|
||||||
RUNTIME_MODE: "staging",
|
|
||||||
}),
|
|
||||||
).toThrow(/Invalid RUNTIME_MODE/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects invalid http port", () => {
|
|
||||||
expect(() =>
|
|
||||||
parseRuntimeConfig({
|
|
||||||
HTTP_PORT: "0",
|
|
||||||
}),
|
|
||||||
).toThrow(/Invalid HTTP_PORT/);
|
|
||||||
|
|
||||||
expect(() =>
|
|
||||||
parseRuntimeConfig({
|
|
||||||
HTTP_PORT: "not-a-number",
|
|
||||||
}),
|
|
||||||
).toThrow(/Invalid HTTP_PORT/);
|
|
||||||
|
|
||||||
expect(() =>
|
|
||||||
parseRuntimeConfig({
|
|
||||||
HTTP_PORT: "65536",
|
|
||||||
}),
|
|
||||||
).toThrow(/Invalid HTTP_PORT/);
|
|
||||||
|
|
||||||
expect(() =>
|
|
||||||
parseRuntimeConfig({
|
|
||||||
HTTP_PORT: "42.5",
|
|
||||||
}),
|
|
||||||
).toThrow(/Invalid HTTP_PORT/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,101 +0,0 @@
|
|||||||
import { readFile } from "node:fs/promises";
|
|
||||||
import type { Server } from "node:http";
|
|
||||||
import type { AddressInfo } from "node:net";
|
|
||||||
import { dirname, resolve } from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
||||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
|
||||||
import { EXAMPLE_ECHO_TOOL_NAME } from "../src/capabilities/index.js";
|
|
||||||
import { startHttpServer } from "../src/http.js";
|
|
||||||
|
|
||||||
const testsDir = dirname(fileURLToPath(import.meta.url));
|
|
||||||
const projectRoot = resolve(testsDir, "..");
|
|
||||||
const packageJsonPath = resolve(projectRoot, "package.json");
|
|
||||||
|
|
||||||
let activeServer: Server | undefined;
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
if (activeServer !== undefined) {
|
|
||||||
await new Promise<void>((resolveClose, rejectClose) => {
|
|
||||||
activeServer?.close((error) => {
|
|
||||||
if (error) {
|
|
||||||
rejectClose(error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
resolveClose();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
activeServer = undefined;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function startTestServer(): Promise<URL> {
|
|
||||||
activeServer = await startHttpServer({ host: "127.0.0.1", port: 0 });
|
|
||||||
const address = activeServer.address() as AddressInfo;
|
|
||||||
return new URL(`http://127.0.0.1:${address.port}/mcp`);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("HTTP entrypoint", () => {
|
|
||||||
it("supports streamable HTTP initialize flow and shared capabilities", async () => {
|
|
||||||
const endpoint = await startTestServer();
|
|
||||||
const transport = new StreamableHTTPClientTransport(endpoint);
|
|
||||||
const client = new Client({ name: "http-test-client", version: "0.0.0" }, { capabilities: {} });
|
|
||||||
|
|
||||||
await client.connect(transport);
|
|
||||||
const tools = await client.listTools();
|
|
||||||
|
|
||||||
expect(tools.tools.map((tool) => tool.name)).toContain(EXAMPLE_ECHO_TOOL_NAME);
|
|
||||||
|
|
||||||
await client.close();
|
|
||||||
await transport.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 404 on unknown routes", async () => {
|
|
||||||
await startTestServer();
|
|
||||||
const address = activeServer?.address() as AddressInfo;
|
|
||||||
const response = await fetch(`http://127.0.0.1:${address.port}/not-mcp`);
|
|
||||||
|
|
||||||
expect(response.status).toBe(404);
|
|
||||||
await expect(response.json()).resolves.toMatchObject({
|
|
||||||
error: "Not Found",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns explicit invalid-session errors", async () => {
|
|
||||||
await startTestServer();
|
|
||||||
const address = activeServer?.address() as AddressInfo;
|
|
||||||
const response = await fetch(`http://127.0.0.1:${address.port}/mcp`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"content-type": "application/json",
|
|
||||||
"mcp-session-id": "missing-session",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
jsonrpc: "2.0",
|
|
||||||
id: "invalid-session",
|
|
||||||
method: "ping",
|
|
||||||
params: {},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status).toBe(404);
|
|
||||||
await expect(response.json()).resolves.toMatchObject({
|
|
||||||
jsonrpc: "2.0",
|
|
||||||
error: {
|
|
||||||
message: "Session not found",
|
|
||||||
},
|
|
||||||
id: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("publishes an HTTP entry command", async () => {
|
|
||||||
const packageJsonRaw = await readFile(packageJsonPath, "utf8");
|
|
||||||
const packageJson = JSON.parse(packageJsonRaw) as {
|
|
||||||
scripts?: Record<string, string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(packageJson.scripts?.["dev:http"]).toBe("tsx src/http.ts");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
import { AppError, asError, isAppError, toErrorPayload } from "../src/lib/errors.js";
|
|
||||||
import { createLogger } from "../src/lib/logger.js";
|
|
||||||
|
|
||||||
describe("logger", () => {
|
|
||||||
it("writes JSON logs using provided sink", () => {
|
|
||||||
const sink = vi.fn<(line: string) => void>();
|
|
||||||
const logger = createLogger({
|
|
||||||
name: "unit",
|
|
||||||
sink,
|
|
||||||
now: () => new Date("2026-01-01T00:00:00.000Z")
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info("ready", { port: 3000 });
|
|
||||||
|
|
||||||
expect(sink).toHaveBeenCalledTimes(1);
|
|
||||||
expect(JSON.parse(sink.mock.calls[0][0] as string)).toEqual({
|
|
||||||
timestamp: "2026-01-01T00:00:00.000Z",
|
|
||||||
level: "info",
|
|
||||||
logger: "unit",
|
|
||||||
message: "ready",
|
|
||||||
details: { port: 3000 }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defaults to stderr and never writes to stdout", () => {
|
|
||||||
const stderrWrite = vi
|
|
||||||
.spyOn(process.stderr, "write")
|
|
||||||
.mockImplementation(() => true);
|
|
||||||
const stdoutWrite = vi
|
|
||||||
.spyOn(process.stdout, "write")
|
|
||||||
.mockImplementation(() => true);
|
|
||||||
|
|
||||||
const logger = createLogger({ name: "unit" });
|
|
||||||
logger.error("failed");
|
|
||||||
|
|
||||||
expect(stderrWrite).toHaveBeenCalledTimes(1);
|
|
||||||
expect(stdoutWrite).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
stderrWrite.mockRestore();
|
|
||||||
stdoutWrite.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("errors", () => {
|
|
||||||
it("normalizes unknown values into Error", () => {
|
|
||||||
expect(asError("boom")).toBeInstanceOf(Error);
|
|
||||||
expect(asError("boom").message).toBe("boom");
|
|
||||||
expect(asError(123).message).toBe("Unknown error");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("exposes consistent payloads", () => {
|
|
||||||
const appError = new AppError("Bad input", "BAD_INPUT");
|
|
||||||
|
|
||||||
expect(isAppError(appError)).toBe(true);
|
|
||||||
expect(toErrorPayload(appError)).toEqual({
|
|
||||||
name: "AppError",
|
|
||||||
message: "Bad input",
|
|
||||||
code: "BAD_INPUT"
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(toErrorPayload(new Error("Oops"))).toEqual({
|
|
||||||
name: "Error",
|
|
||||||
message: "Oops"
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,81 +0,0 @@
|
|||||||
import { readFile } from "node:fs/promises";
|
|
||||||
import { dirname, resolve } from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
import {
|
|
||||||
type CapabilityRegistration,
|
|
||||||
EXAMPLE_ECHO_TOOL_NAME,
|
|
||||||
EXAMPLE_SUMMARY_PROMPT_NAME,
|
|
||||||
TEMPLATE_STATUS_RESOURCE_URI,
|
|
||||||
} from "../src/capabilities/index.js";
|
|
||||||
import { createMcpCore } from "../src/core/index.js";
|
|
||||||
|
|
||||||
type McpServerInternals = {
|
|
||||||
_registeredTools: Record<string, unknown>;
|
|
||||||
_registeredResources: Record<string, unknown>;
|
|
||||||
_registeredPrompts: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("MCP core factory", () => {
|
|
||||||
it("wires example capabilities through the shared core path", () => {
|
|
||||||
const core = createMcpCore();
|
|
||||||
const serverInternals = core.server as unknown as McpServerInternals;
|
|
||||||
|
|
||||||
expect(core.registry.tools.map((entry) => entry.name)).toEqual([
|
|
||||||
EXAMPLE_ECHO_TOOL_NAME,
|
|
||||||
]);
|
|
||||||
expect(core.registry.resources.map((entry) => entry.name)).toEqual([
|
|
||||||
TEMPLATE_STATUS_RESOURCE_URI,
|
|
||||||
]);
|
|
||||||
expect(core.registry.prompts.map((entry) => entry.name)).toEqual([
|
|
||||||
EXAMPLE_SUMMARY_PROMPT_NAME,
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(Object.keys(serverInternals._registeredTools)).toEqual([
|
|
||||||
EXAMPLE_ECHO_TOOL_NAME,
|
|
||||||
]);
|
|
||||||
expect(Object.keys(serverInternals._registeredResources)).toEqual([
|
|
||||||
TEMPLATE_STATUS_RESOURCE_URI,
|
|
||||||
]);
|
|
||||||
expect(Object.keys(serverInternals._registeredPrompts)).toEqual([
|
|
||||||
EXAMPLE_SUMMARY_PROMPT_NAME,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates an unconnected core that does not require transport wiring", () => {
|
|
||||||
const core = createMcpCore();
|
|
||||||
|
|
||||||
expect(core.server.isConnected()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("supports explicit registry composition and server capability wiring", () => {
|
|
||||||
const registerCustom: CapabilityRegistration = (registry) => {
|
|
||||||
registry.tools.push({
|
|
||||||
name: "custom.tool",
|
|
||||||
kind: "tool",
|
|
||||||
description: "Custom test capability",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const registerServerCapabilities = vi.fn<(server: unknown) => void>();
|
|
||||||
|
|
||||||
const core = createMcpCore({
|
|
||||||
capabilityRegistrations: [registerCustom],
|
|
||||||
registerServerCapabilities,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(core.registry.tools.map((entry) => entry.name)).toEqual(["custom.tool"]);
|
|
||||||
expect(core.registry.resources).toEqual([]);
|
|
||||||
expect(core.registry.prompts).toEqual([]);
|
|
||||||
expect(registerServerCapabilities).toHaveBeenCalledTimes(1);
|
|
||||||
expect(registerServerCapabilities).toHaveBeenCalledWith(core.server);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not import stdio/http transport wrappers in shared core", async () => {
|
|
||||||
const here = dirname(fileURLToPath(import.meta.url));
|
|
||||||
const coreModulePath = resolve(here, "../src/core/mcp-core.ts");
|
|
||||||
const source = await readFile(coreModulePath, "utf8");
|
|
||||||
|
|
||||||
expect(source).not.toMatch(/server\/(stdio|sse|streamableHttp)\.js/);
|
|
||||||
expect(source).not.toMatch(/(?:express|hono)/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import * as template from "../src/index.js";
|
|
||||||
|
|
||||||
describe("template package smoke test", () => {
|
|
||||||
it("exposes the MCP core factory from public entrypoint", () => {
|
|
||||||
expect(typeof template.createMcpCore).toBe("function");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,150 +0,0 @@
|
|||||||
import { readFile } from "node:fs/promises";
|
|
||||||
import { dirname, resolve } from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
||||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { EXAMPLE_ECHO_TOOL_NAME } from "../src/capabilities/index.js";
|
|
||||||
|
|
||||||
const testsDir = dirname(fileURLToPath(import.meta.url));
|
|
||||||
const projectRoot = resolve(testsDir, "..");
|
|
||||||
const stdioEntryPath = resolve(projectRoot, "src/stdio.ts");
|
|
||||||
const packageJsonPath = resolve(projectRoot, "package.json");
|
|
||||||
|
|
||||||
type StdioCommandConfig = {
|
|
||||||
name?: string;
|
|
||||||
bin?: Record<string, string>;
|
|
||||||
mcp?: {
|
|
||||||
stdio?: {
|
|
||||||
developmentOnly?: boolean;
|
|
||||||
command?: string;
|
|
||||||
args?: string[];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
scripts?: Record<string, string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function readStdioCommandConfig(): Promise<{ command: string; args: string[] }> {
|
|
||||||
const packageJsonRaw = await readFile(packageJsonPath, "utf8");
|
|
||||||
const packageJson = JSON.parse(packageJsonRaw) as StdioCommandConfig;
|
|
||||||
|
|
||||||
return {
|
|
||||||
command: packageJson.mcp?.stdio?.command ?? "node",
|
|
||||||
args: packageJson.mcp?.stdio?.args ?? [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createConnectedStdioClient(): Promise<{
|
|
||||||
client: Client;
|
|
||||||
transport: StdioClientTransport;
|
|
||||||
}> {
|
|
||||||
const stdioCommand = await readStdioCommandConfig();
|
|
||||||
const transport = new StdioClientTransport({
|
|
||||||
command: stdioCommand.command,
|
|
||||||
args: stdioCommand.args,
|
|
||||||
cwd: projectRoot,
|
|
||||||
stderr: "pipe",
|
|
||||||
});
|
|
||||||
const client = new Client(
|
|
||||||
{ name: "stdio-test-client", version: "0.0.0" },
|
|
||||||
{ capabilities: {} },
|
|
||||||
);
|
|
||||||
|
|
||||||
await client.connect(transport);
|
|
||||||
return { client, transport };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("stdio entrypoint", () => {
|
|
||||||
it("starts over stdio and keeps startup diagnostics on stderr", async () => {
|
|
||||||
const stdioCommand = await readStdioCommandConfig();
|
|
||||||
const transport = new StdioClientTransport({
|
|
||||||
command: stdioCommand.command,
|
|
||||||
args: stdioCommand.args,
|
|
||||||
cwd: projectRoot,
|
|
||||||
stderr: "pipe",
|
|
||||||
});
|
|
||||||
const stderrMessages: string[] = [];
|
|
||||||
const stderr = transport.stderr;
|
|
||||||
stderr?.on("data", (chunk) => {
|
|
||||||
stderrMessages.push(chunk.toString());
|
|
||||||
});
|
|
||||||
const client = new Client(
|
|
||||||
{ name: "stdio-test-client", version: "0.0.0" },
|
|
||||||
{ capabilities: {} },
|
|
||||||
);
|
|
||||||
|
|
||||||
await client.connect(transport);
|
|
||||||
const tools = await client.listTools();
|
|
||||||
|
|
||||||
expect(tools.tools.map((tool) => tool.name)).toContain(EXAMPLE_ECHO_TOOL_NAME);
|
|
||||||
expect(stderrMessages.join("")).toContain("MCP stdio server started");
|
|
||||||
|
|
||||||
await client.close();
|
|
||||||
await transport.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects invalid tool calls over stdio transport", async () => {
|
|
||||||
const { client, transport } = await createConnectedStdioClient();
|
|
||||||
|
|
||||||
const missingToolResult = await client.callTool({
|
|
||||||
name: "template.missing-tool",
|
|
||||||
arguments: {},
|
|
||||||
});
|
|
||||||
const invalidInputResult = await client.callTool({
|
|
||||||
name: EXAMPLE_ECHO_TOOL_NAME,
|
|
||||||
arguments: { uppercase: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(missingToolResult.isError).toBe(true);
|
|
||||||
expect(JSON.stringify(missingToolResult.content)).toMatch(/not found|unknown/i);
|
|
||||||
expect(invalidInputResult.isError).toBe(true);
|
|
||||||
expect(JSON.stringify(invalidInputResult.content)).toMatch(/message|required|invalid/i);
|
|
||||||
|
|
||||||
await client.close();
|
|
||||||
await transport.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("publishes a development-only non-npm stdio launch command for workspace MCP clients", async () => {
|
|
||||||
const packageJsonRaw = await readFile(packageJsonPath, "utf8");
|
|
||||||
const packageJson = JSON.parse(packageJsonRaw) as StdioCommandConfig;
|
|
||||||
|
|
||||||
expect(packageJson.mcp?.stdio?.developmentOnly).toBe(true);
|
|
||||||
expect(packageJson.mcp?.stdio?.command).not.toMatch(/^npm|^npx/);
|
|
||||||
expect(packageJson.scripts?.["start:stdio"]).toBeUndefined();
|
|
||||||
expect(packageJson.mcp?.stdio?.command).toBe("node");
|
|
||||||
expect(packageJson.mcp?.stdio?.args).toEqual([
|
|
||||||
"./node_modules/tsx/dist/cli.mjs",
|
|
||||||
"src/stdio.ts",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("publishes a bin entry for npx-based stdio startup", async () => {
|
|
||||||
const packageJsonRaw = await readFile(packageJsonPath, "utf8");
|
|
||||||
const packageJson = JSON.parse(packageJsonRaw) as StdioCommandConfig;
|
|
||||||
|
|
||||||
expect(packageJson.name).toBeDefined();
|
|
||||||
expect(packageJson.bin).toEqual({
|
|
||||||
[packageJson.name as string]: "./dist/stdio.js",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not write non-protocol logs to stdout from source entry", async () => {
|
|
||||||
const source = await readFile(stdioEntryPath, "utf8");
|
|
||||||
|
|
||||||
expect(source).not.toMatch(/console\.log\(/);
|
|
||||||
expect(source).not.toMatch(/process\.stdout\.write\(/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the published stdio entry executable", async () => {
|
|
||||||
const source = await readFile(stdioEntryPath, "utf8");
|
|
||||||
|
|
||||||
expect(source).toMatch(/^#!\/usr\/bin\/env node/m);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("publishes a dedicated package-level verification script for the bin entry", async () => {
|
|
||||||
const packageJsonRaw = await readFile(packageJsonPath, "utf8");
|
|
||||||
const packageJson = JSON.parse(packageJsonRaw) as StdioCommandConfig;
|
|
||||||
|
|
||||||
expect(packageJson.scripts?.["test:publish"]).toBe("node ./scripts/verify-publish-bin.mjs");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"module": "NodeNext",
|
|
||||||
"moduleResolution": "NodeNext",
|
|
||||||
"strict": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"types": ["node", "vitest/globals"],
|
|
||||||
"rootDir": ".",
|
|
||||||
"outDir": "dist"
|
|
||||||
},
|
|
||||||
"include": ["src", "tests", "vitest.config.ts"],
|
|
||||||
"exclude": ["dist", "node_modules"]
|
|
||||||
}
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
import { defineConfig } from "tsup";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
entry: {
|
|
||||||
index: "src/index.ts",
|
|
||||||
stdio: "src/stdio.ts",
|
|
||||||
http: "src/http.ts",
|
|
||||||
},
|
|
||||||
format: ["esm"],
|
|
||||||
platform: "node",
|
|
||||||
target: "node20",
|
|
||||||
dts: true,
|
|
||||||
sourcemap: true,
|
|
||||||
clean: true,
|
|
||||||
splitting: false,
|
|
||||||
outDir: "dist",
|
|
||||||
});
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
import { defineConfig } from "vitest/config";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
test: {
|
|
||||||
include: ["tests/**/*.test.ts"]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Loading…
x
Reference in New Issue
Block a user