Changelog
- 1.0.0, 22 October 2026: first release
kits / connector-starter
A minimal, tested Model Context Protocol server in TypeScript and Python, with one example tool and security notes.
MIT License
Copyright (c) 2026 Snowkit
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.Version 1.0.0
A small, working Model Context Protocol (MCP) server in two languages: TypeScript and Python. Each starter has one useful example tool, tests that run without a network, and a README. Copy the folder for your language, rename it and start adding your own tools.
MCP is an open protocol that lets an assistant or agent call tools, read resources and use prompts that live outside the app it runs in. You write a server once, and any client that speaks MCP can connect to it.
You should be comfortable with a terminal and with either Node.js or Python.
typescript/ or python/ to where your project lives.npm install, npm run build, npm test. Python: create a virtual environment, pip install -e ".[dev]", pytest.text_stats by hand.| File | What it is |
|---|---|
README.md | This guide: running, connecting, extending, testing and securing the starters |
LICENSE.txt | MIT licence |
CHANGELOG.md | Release history |
typescript/README.md | Setup, scripts, tool reference and an example second tool for the TypeScript starter |
typescript/package.json | Dependencies and the build, start, dev, typecheck and test scripts |
typescript/tsconfig.json | Compiler settings (ES2022, NodeNext modules, strict mode) |
typescript/src/server.ts | createServer() with the text_stats tool and its zod schemas |
typescript/src/index.ts | Entry point that connects the server to stdio |
typescript/test/server.test.ts | Tests using the SDK client over an in-memory transport |
python/README.md | Setup, tool reference and an example second tool for the Python starter |
python/pyproject.toml | Package metadata, dependencies, dev extras and the console script |
python/src/connector_starter/__init__.py | Package exports |
python/src/connector_starter/__main__.py | Enables python -m connector_starter |
python/src/connector_starter/server.py | create_server() with the text_stats tool and main() |
python/tests/test_server.py | Tests using mcp.Client connected in-process |
Both starters register the same tool, so a client sees identical behaviour whichever language you run.
text_stats counts the words, characters and lines in a piece of text and estimates the reading time.
| Argument | Type | Rules |
|---|---|---|
text | string | Required. Up to 100,000 characters. |
words_per_minute | integer | Optional. 50 to 1000. Defaults to 200. |
Example result for "Hello world\nSecond line here":
{ "words": 5, "characters": 28, "lines": 2, "reading_time_seconds": 2 }
It is deliberately simple: deterministic, no network, no secrets. That makes it a good template for testing and a safe thing to leave in while you build.
TypeScript (Node.js 20 or newer):
cd typescript
npm install
npm run build
npm start
Python (3.10 or newer), macOS and Linux:
cd python
python3 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
connector-starter
On Windows, activate with .\.venv\Scripts\Activate.ps1 instead.
Both servers speak MCP over stdio. Started by hand, they wait silently for input. That is normal: a client normally starts the process for you.
Most desktop and editor clients read a JSON config with an mcpServers object. The file name and location differ by client, so check its documentation for where the file lives. The shape is the same. Always use absolute paths, because the client may start the server from a different working directory.
TypeScript (after npm run build):
{
"mcpServers": {
"connector-starter": {
"command": "node",
"args": ["/absolute/path/to/connector-starter/typescript/dist/index.js"]
}
}
}
Python, macOS and Linux (point at the virtual environment so the right packages load):
{
"mcpServers": {
"connector-starter": {
"command": "/absolute/path/to/connector-starter/python/.venv/bin/connector-starter",
"args": []
}
}
}
Python on Windows (backslashes must be doubled in JSON):
{
"mcpServers": {
"connector-starter": {
"command": "C:\\projects\\connector-starter\\python\\.venv\\Scripts\\python.exe",
"args": ["-m", "connector_starter"]
}
}
}
If your server needs settings such as an API key, most clients accept an env object next to args. Keep real values in that local config or in your secret store, never in the repository.
After editing the config, restart the client or reload its MCP servers. If the tool does not appear, check the client's MCP log first. The usual causes are a relative path, a missing build, or a virtual environment that was never installed into.
Each language README shows a complete second tool (slugify). The pattern is the same in both:
createServer() or create_server() with a clear name, a one-sentence description and a strict input schema.readOnlyHint for tools that change nothing, destructiveHint for tools that delete or overwrite, openWorldHint for tools that reach the internet.Naming tips: use short snake_case verbs or nouns (search_orders, get_invoice). Write the description for the model that will read it: say what the tool does, what it returns and when not to use it. Keep the number of tools small; many similar tools make selection worse.
The MCP Inspector is the official tool for poking at a server by hand. It runs with npx, so nothing needs to be installed globally.
Open the browser interface:
cd typescript
npx @modelcontextprotocol/inspector node dist/index.js
cd python
npx @modelcontextprotocol/inspector .venv/bin/connector-starter
On Windows, use .venv\Scripts\connector-starter.exe for the Python command. The Inspector prints a local URL. Open it, press Connect, go to Tools, list the tools and run text_stats with your own text. Try a words_per_minute of 5 to see how a validation error is reported.
For scripts and CI, the Inspector also has a command-line mode:
npx @modelcontextprotocol/inspector --cli node dist/index.js --method tools/list
npx @modelcontextprotocol/inspector --cli node dist/index.js --method tools/call --tool-name text_stats --tool-arg "text=one two three"
The automated tests in each starter cover the same ground without starting a process, so run npm test or pytest on every change and use the Inspector for exploration.
A tool is code that an agent can run on your behalf, often with your permissions. Treat every argument as untrusted input, even when you trust the person using the client: the arguments are chosen by a model, and the model can be steered by content it reads.
destructiveHint so clients can ask for confirmation.eval or similar.This release was built and tested against:
| Starter | Package | Version range |
|---|---|---|
| TypeScript | @modelcontextprotocol/sdk | ^1.32.1 |
| TypeScript | zod | ^4.6.7 |
| TypeScript | typescript (dev) | ^7.0.2 |
| Python | mcp | >=2.3.0,<3 |
No lockfile is included, so npm install and pip install resolve the newest matching versions. Commit the lockfile your install creates so your builds stay repeatable.
These are starters, not frameworks. They use stdio only; remote hosting over Streamable HTTP, authentication, rate limiting and deployment are out of scope. The security notes reduce risk but cannot make a tool safe on their own; review any tool that writes data or touches the network before you connect it to an agent. MCP and its SDKs change quickly, so check the release notes when you upgrade.
MIT License. See LICENSE.txt.
A minimal Model Context Protocol server written in Python with the official mcp package. It exposes one tool, text_stats, over stdio.
mcp package, version 2.3 or newer within the 2.x line (installed for you below)macOS and Linux:
python3 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
Windows (PowerShell):
python -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install -e ".[dev]"
connector-starter
python -m connector_starter does the same thing. The server reads JSON-RPC messages on stdin and writes replies on stdout, so it will sit quietly in the terminal. That is expected. Stop it with Ctrl+C. To talk to it, connect an MCP client or the MCP Inspector, as described in the kit README.
pytest
The tests connect an in-process mcp.Client to create_server(), so no subprocess or network is involved. They list the tools, call text_stats with valid input and check that invalid input comes back as a tool error.
| File | What it is |
|---|---|
pyproject.toml | Package metadata, the mcp dependency, dev extras and the connector-starter console script |
src/connector_starter/server.py | create_server(), the text_stats tool, the pure compute_text_stats() function and main() |
src/connector_starter/__init__.py | Public exports for the package |
src/connector_starter/__main__.py | Lets you start the server with python -m connector_starter |
tests/test_server.py | Unit tests for the counting logic and in-memory tests for the server |
text_stats takes:
| Argument | Type | Rules |
|---|---|---|
text | string | Required. Up to 100,000 characters. |
words_per_minute | integer | Optional. 50 to 1000. Defaults to 200. |
It returns words, characters, lines and reading_time_seconds as structured content. The return type is a Pydantic model, so MCPServer publishes an output schema for it automatically.
Counting rules:
\n, \r\n or \r. A trailing line break does not add an empty line.Add another decorated function inside create_server():
@server.tool(
name="slugify",
description="Turn a title into a lowercase, hyphenated URL slug.",
annotations=ToolAnnotations(readOnlyHint=True, idempotentHint=True, openWorldHint=False),
)
def slugify(
title: Annotated[str, Field(min_length=1, max_length=200, description="The title to convert.")],
) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
return slug or "untitled"
Type hints and Field constraints become the tool's JSON Schema, and MCPServer validates arguments before your function runs. Keep the logic in a plain function when it is more than a few lines, so it can be unit tested without a client. Then add a test in tests/test_server.py that calls the new tool through Client(create_server()).
Version 2 of the mcp package renamed the high-level server class to MCPServer (in mcp.server.mcpserver) and added mcp.Client, which can connect straight to a server object for tests. If you need to stay on 1.x, the equivalent class is FastMCP in mcp.server.fastmcp, and the tests would use create_connected_server_and_client_session from mcp.shared.memory instead.
[build-system]
requires = ["hatchling>=1.25"]
build-backend = "hatchling.build"
[project]
name = "connector-starter"
version = "1.0.0"
description = "A minimal Model Context Protocol server with one example tool."
readme = "README.md"
license = "MIT"
requires-python = ">=3.10"
dependencies = [
"mcp>=2.3.0,<3",
]
[project.optional-dependencies]
dev = [
"pytest>=8.3",
"anyio>=4.5",
]
[project.scripts]
connector-starter = "connector_starter.server:main"
[tool.hatch.build.targets.wheel]
packages = ["src/connector_starter"]
[tool.pytest.ini_options]
testpaths = ["tests"]from connector_starter.server import TextStats, compute_text_stats, create_server, main
__all__ = ["TextStats", "compute_text_stats", "create_server", "main"]
__version__ = "1.0.0"from connector_starter.server import main
if __name__ == "__main__":
main()import math
import re
from typing import Annotated
from mcp.server.mcpserver import MCPServer
from mcp.types import ToolAnnotations
from pydantic import BaseModel, Field
SERVER_NAME = "connector-starter"
SERVER_VERSION = "1.0.0"
MAX_TEXT_LENGTH = 100_000
_LINE_BREAK = re.compile(r"\r\n|\r|\n")
class TextStats(BaseModel):
words: int
characters: int
lines: int
reading_time_seconds: int
def compute_text_stats(text: str, words_per_minute: int = 200) -> TextStats:
words = len(text.split())
characters = len(text)
lines = 0
if text:
parts = _LINE_BREAK.split(text)
lines = len(parts) - 1 if parts[-1] == "" else len(parts)
reading_time_seconds = 0 if words == 0 else math.ceil(words / words_per_minute * 60)
return TextStats(
words=words,
characters=characters,
lines=lines,
reading_time_seconds=reading_time_seconds,
)
def create_server() -> MCPServer:
server = MCPServer(SERVER_NAME, version=SERVER_VERSION)
@server.tool(
name="text_stats",
title="Text statistics",
description=(
"Count the words, characters and lines in a piece of text "
"and estimate how long it takes to read."
),
annotations=ToolAnnotations(
readOnlyHint=True,
idempotentHint=True,
openWorldHint=False,
),
)
def text_stats(
text: Annotated[str, Field(max_length=MAX_TEXT_LENGTH, description="The text to analyse.")],
words_per_minute: Annotated[
int,
Field(
ge=50,
le=1000,
description="Reading speed used for the time estimate. Defaults to 200.",
),
] = 200,
) -> TextStats:
return compute_text_stats(text, words_per_minute)
return server
def main() -> None:
create_server().run("stdio")import pytest
from mcp import Client
from connector_starter import compute_text_stats, create_server
pytestmark = pytest.mark.anyio
@pytest.fixture
def anyio_backend():
return "asyncio"
def test_empty_text_returns_zeros():
stats = compute_text_stats("")
assert stats.model_dump() == {
"words": 0,
"characters": 0,
"lines": 0,
"reading_time_seconds": 0,
}
def test_trailing_newline_is_not_an_extra_line():
assert compute_text_stats("one\ntwo\n").lines == 2
assert compute_text_stats("one\r\n\r\nthree").lines == 3
def test_characters_are_code_points():
text = "caf" + chr(0xE9) + " " + chr(0x1F600)
assert compute_text_stats(text).characters == 6
async def test_lists_text_stats_tool():
async with Client(create_server()) as client:
result = await client.list_tools()
tool = next(t for t in result.tools if t.name == "text_stats")
assert tool.description
assert tool.input_schema["required"] == ["text"]
async def test_returns_structured_stats():
async with Client(create_server()) as client:
result = await client.call_tool("text_stats", {"text": "Hello world\nSecond line here"})
assert not result.is_error
assert result.structured_content == {
"words": 5,
"characters": 28,
"lines": 2,
"reading_time_seconds": 2,
}
async def test_honours_custom_reading_speed():
text = " ".join(["word"] * 300)
async with Client(create_server()) as client:
result = await client.call_tool("text_stats", {"text": text, "words_per_minute": 100})
assert result.structured_content["reading_time_seconds"] == 180
async def test_rejects_invalid_input():
async with Client(create_server()) as client:
result = await client.call_tool("text_stats", {"text": "hello", "words_per_minute": 5})
assert result.is_errorA minimal Model Context Protocol server written in TypeScript with the official @modelcontextprotocol/sdk package. It exposes one tool, text_stats, over stdio.
npm install
npm run build
npm run build compiles src/ into dist/.
npm start
This runs node dist/index.js. During development, npm run dev runs the TypeScript source directly with tsx, with no build step.
The server reads JSON-RPC messages on stdin and writes replies on stdout, so it will sit quietly in the terminal. That is expected. Stop it with Ctrl+C. To talk to it, connect an MCP client or the MCP Inspector, as described in the kit README.
Never write to stdout from a stdio server (for example with console.log). Stdout carries the protocol, and stray output corrupts it. Use console.error for logs; it goes to stderr.
npm test
The tests use the Node.js built-in test runner. They connect the SDK Client to createServer() through InMemoryTransport.createLinkedPair(), so no subprocess or network is involved. They list the tools, call text_stats with valid input and check that invalid input comes back as a tool error.
| Script | What it does |
|---|---|
npm run build | Compiles src/ to dist/ with tsc |
npm start | Runs the compiled server on stdio |
npm run dev | Runs src/index.ts directly with tsx |
npm run typecheck | Type checks without writing files |
npm test | Runs test/server.test.ts |
| File | What it is |
|---|---|
package.json | Dependencies and scripts |
tsconfig.json | Compiler settings (ES2022, NodeNext modules, strict mode) |
src/server.ts | createServer(), the text_stats tool and the pure computeTextStats() function |
src/index.ts | Entry point that connects createServer() to the stdio transport |
test/server.test.ts | Unit tests for the counting logic and in-memory tests for the server |
Keeping createServer() separate from the transport is deliberate. Tests and other transports (such as Streamable HTTP) can reuse the same server without touching stdio.
text_stats takes:
| Argument | Type | Rules |
|---|---|---|
text | string | Required. Up to 100,000 characters. |
words_per_minute | integer | Optional. 50 to 1000. Defaults to 200. |
It returns words, characters, lines and reading_time_seconds, both as structured content (matching the declared output schema) and as a JSON text block for clients that only read text.
Counting rules:
\n, \r\n or \r. A trailing line break does not add an empty line.The tool is marked read-only, idempotent and closed-world through annotations. Clients may use these hints when deciding whether to ask the user before a call.
Register another tool inside createServer(), before return server:
server.registerTool(
"slugify",
{
title: "Slugify",
description: "Turn a title into a lowercase, hyphenated URL slug.",
inputSchema: {
title: z.string().min(1).max(200).describe("The title to convert."),
},
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
},
async ({ title }) => {
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
return { content: [{ type: "text", text: slug || "untitled" }] };
},
);
The zod shape becomes the tool's JSON Schema, and the SDK validates arguments before your handler runs. Keep the logic in a plain exported function when it is more than a few lines, so it can be unit tested without a client. Then add a test in test/server.test.ts that calls the new tool through the in-memory client.
package.json declares a connector-starter bin that points at dist/index.js, and the entry file starts with a Node shebang. After npm run build, npm link makes connector-starter available on your PATH. If you publish the package, remove "private": true and pick a unique package name first.
{
"name": "connector-starter",
"version": "1.0.0",
"description": "A minimal Model Context Protocol server with one example tool.",
"license": "MIT",
"private": true,
"type": "module",
"main": "dist/index.js",
"bin": {
"connector-starter": "dist/index.js"
},
"files": [
"dist"
],
"engines": {
"node": ">=20"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"dev": "tsx src/index.ts",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "tsx --test test/server.test.ts"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.32.1",
"zod": "^4.6.7"
},
"devDependencies": {
"@types/node": "^26.6.5",
"tsx": "^4.23.15",
"typescript": "^7.0.2"
}
}{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": false,
"sourceMap": false,
"types": ["node"]
},
"include": ["src/**/*.ts"]
}#!/usr/bin/env node
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { createServer } from "./server.js";
async function main(): Promise<void> {
const server = createServer();
const transport = new StdioServerTransport();
await server.connect(transport);
const shutdown = async (): Promise<void> => {
await server.close();
process.exit(0);
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
}
main().catch((error: unknown) => {
console.error(error);
process.exit(1);
});import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
export const SERVER_NAME = "connector-starter";
export const SERVER_VERSION = "1.0.0";
export const MAX_TEXT_LENGTH = 100_000;
export interface TextStats {
words: number;
characters: number;
lines: number;
reading_time_seconds: number;
}
export function computeTextStats(text: string, wordsPerMinute = 200): TextStats {
const trimmed = text.trim();
const words = trimmed === "" ? 0 : trimmed.split(/\s+/).length;
const characters = Array.from(text).length;
let lines = 0;
if (text !== "") {
const parts = text.split(/\r\n|\r|\n/);
lines = parts[parts.length - 1] === "" ? parts.length - 1 : parts.length;
}
const readingTimeSeconds = words === 0 ? 0 : Math.ceil((words / wordsPerMinute) * 60);
return { words, characters, lines, reading_time_seconds: readingTimeSeconds };
}
export function createServer(): McpServer {
const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION });
server.registerTool(
"text_stats",
{
title: "Text statistics",
description:
"Count the words, characters and lines in a piece of text and estimate how long it takes to read.",
inputSchema: {
text: z
.string()
.max(MAX_TEXT_LENGTH)
.describe("The text to analyse."),
words_per_minute: z
.number()
.int()
.min(50)
.max(1000)
.default(200)
.describe("Reading speed used for the time estimate. Defaults to 200."),
},
outputSchema: {
words: z.number().int(),
characters: z.number().int(),
lines: z.number().int(),
reading_time_seconds: z.number().int(),
},
annotations: {
readOnlyHint: true,
idempotentHint: true,
openWorldHint: false,
},
},
async ({ text, words_per_minute }) => {
const stats = computeTextStats(text, words_per_minute);
return {
content: [{ type: "text", text: JSON.stringify(stats) }],
structuredContent: { ...stats },
};
},
);
return server;
}import assert from "node:assert/strict";
import { after, before, describe, it } from "node:test";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { computeTextStats, createServer } from "../src/server.js";
describe("computeTextStats", () => {
it("returns zeros for empty text", () => {
assert.deepEqual(computeTextStats(""), {
words: 0,
characters: 0,
lines: 0,
reading_time_seconds: 0,
});
});
it("does not count a trailing newline as an extra line", () => {
assert.equal(computeTextStats("one\ntwo\n").lines, 2);
assert.equal(computeTextStats("one\r\n\r\nthree").lines, 3);
});
it("counts characters as code points", () => {
const text = `caf${String.fromCodePoint(0xe9)} ${String.fromCodePoint(0x1f600)}`;
assert.equal(computeTextStats(text).characters, 6);
});
});
describe("connector-starter server", () => {
const server = createServer();
const client = new Client({ name: "test-client", version: "1.0.0" });
before(async () => {
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
});
after(async () => {
await client.close();
await server.close();
});
it("lists the text_stats tool with an input schema", async () => {
const { tools } = await client.listTools();
const tool = tools.find((t) => t.name === "text_stats");
assert.ok(tool, "text_stats is listed");
assert.ok(tool.description && tool.description.length > 0);
assert.deepEqual(tool.inputSchema.required, ["text"]);
});
it("returns structured stats for valid input", async () => {
const result = await client.callTool({
name: "text_stats",
arguments: { text: "Hello world\nSecond line here" },
});
assert.notEqual(result.isError, true);
assert.deepEqual(result.structuredContent, {
words: 5,
characters: 28,
lines: 2,
reading_time_seconds: 2,
});
});
it("honours a custom reading speed", async () => {
const text = Array.from({ length: 300 }, () => "word").join(" ");
const result = await client.callTool({
name: "text_stats",
arguments: { text, words_per_minute: 100 },
});
assert.equal((result.structuredContent as { reading_time_seconds: number }).reading_time_seconds, 180);
});
it("rejects invalid input with a tool error", async () => {
const result = await client.callTool({
name: "text_stats",
arguments: { text: "hello", words_per_minute: 5 },
});
assert.equal(result.isError, true);
});
});