server-and-tool-architecture

Server entry point and tool-registration architecture

This is the shape of the MCP server that lets an AI client — Claude Code, Claude Desktop, and the like — call into YouTeacher over the MCP stdio transport. It is a small, deliberately flat program: one entry point boots the server, loads credentials, wires a fixed set of tool groups to one shared HTTP client, and connects the transport. There is no plugin loader and no dynamic discovery — the tool set is a hand-written list.

It ships as a private npm package (@youteacher/mcp), an ES-module TypeScript project targeting Node 20 or newer. It exposes a single binary, youteacher-mcp, and leans on just two runtime dependencies: the official @modelcontextprotocol/sdk and zod for schema validation. The build is a plain tsc; development runs the entry point directly through tsx.

Boot sequence

The entry point is an executable script (#!/usr/bin/env node) whose main does four things in order:

  1. Load credentials. Admin API-key credentials are read from a file whose location comes from an environment variable. This happens inside a try/catch: if the file is missing or invalid, the server writes the reason to stderr and exits with a non-zero code.
  2. Build one auth client. A single AuthClient is constructed from those credentials and shared by every tool group — the tools never hold their own credentials or open their own connections.
  3. Register the tool groups. An McpServer is created (named youteacher-mcp, carrying its own version string), then each register*Tool(s) function is called with (server, auth): the me tool, the API-key listing tool, and the post, category, export, and update-self tool groups.
  4. Connect the transport. A StdioServerTransport is attached and server.connect awaited — from here the process talks MCP over stdin/stdout.

A top-level .catch on main handles any failure that escapes the boot: it prints the stack (or message) to stderr and exits non-zero.

Fail fast, not fail quiet

The credential check is the load-bearing design decision here, and the reason is written into the code as a comment: a server that started up without valid credentials would silently return a 401 on every single call. That is a confusing failure — the client looks connected but nothing works. So the server refuses to start at all when credentials are absent or malformed, turning a diffuse per-call authentication error into one clear startup failure the operator sees immediately. The fact "these credentials are wrong" gets one home — the boot — instead of being rediscovered on every request.

One shared client, many thin tool groups

The wiring is a star with the AuthClient at the centre. Every tool group is a register* function that takes the server and the one auth client; the group's job is to translate MCP tool calls into HTTP requests through that client and hand the response back. This keeps authentication and transport concerns in one place and leaves each tool group free to be a thin wrapper over a backend endpoint. Adding a tool group means writing one more register* function and one more line in main — the pattern does not change.

Standardizing backend responses

The content-service tools share two small helpers so that every wrapper turns a backend reply into a well-formed MCP result the same way.

toToolResult converts the auth client's ApiResponse into the MCP CallToolResult shape. A 2xx status becomes a single text block holding the pretty-printed JSON body (or the raw string, if the body is already a string). Any non-2xx status becomes a text block prefixed with the HTTP status line and carries isError: true, which is what makes MCP clients render the call as a failure rather than a success with odd contents. The helper's return type mirrors the SDK's open-ended index signature, so a narrower local interface still type-checks against CallToolResult.

withQuery builds a path?k=v&… string from a params object, skipping any key whose value is null or undefined. This lets each tool pass a partial filter object straight through — the caller never writes the if (x != null) params.set(...) ceremony by hand, and absent filters simply do not appear in the query string. If nothing survives the skip, the bare path is returned with no trailing ?.

Why it is shaped this way

The whole server is small on purpose. Credentials load once and fail loudly; one client carries auth for everyone; tool groups are uniform, register-and-forget wrappers; and two helpers guarantee that "success" and "failure" look the same across every tool a client sees. There is no machinery for problems this server does not have.

about this entry

One of sijie's wiki entries. The AI on this site is grounded in the same corpus and answers in sijie's voice, with citations back to entries like this one — answering costs sijie money, so it waits behind a code: enter an access code →