3. Project Architecture & Directory Roles

Top-Level Directory Structure

mcp-kit/
├── src/
│   ├── index.ts        # Library entry point
│   ├── server.ts       # MCP server entry point (stdio)
│   ├── cli.ts          # CLI entry point
│   ├── agent.test.ts   # Real-LLM test (excluded from the default test run)
│   ├── tools/          # MCP tool definitions
│   ├── exchange/       # Exchange-rate scraping domain
│   ├── tmdb/           # TMDB movie lookup domain
│   ├── system/         # Host metrics domain (battery / memory / CPU / disk)
│   └── common/         # Shared kit (never published separately, inlined into the bundle)
├── docs/               # Rspress documentation site content
├── scripts/            # Rspress plugin
├── skills/             # Hand-written SKILL.md
├── .github/workflows/  # CI/CD pipelines
├── package.json        # Single package config (@julong/mcp-kit)
├── tsconfig.json       # TypeScript config (@/* → src/*)
├── tsup.config.ts      # Bundling config
├── rstest.config.ts    # Test config
├── rslint.config.ts    # Lint config
├── rspress.config.ts   # Documentation site config
├── netlify.toml        # Netlify deployment config
├── release-please-config.json      # release-please config (single package)
├── .release-please-manifest.json   # Tracks the current version
└── CLAUDE.md           # Project context for AI assistants

Layer Responsibilities

src/common/ — Shared Kit (Internal Only, Never Published Separately)

Not a separate package — just a module in the same source tree. tsup inlines it via noExternal, so it only exists inside the build output.

src/common/
├── kit/
│   ├── tool.ts     # Tool definitions: toolDef(), defineTool(), AnyToolDef type, text() helper
│   ├── server.ts   # MCP server: createMcpServer(), startServer(), installProcessGuards()
│   └── cli.ts      # CLI execution: runCli(), handleCliError()
├── agent/
│   ├── llm.ts      # createChatModel() — OpenAI-compatible chat model from env vars
│   ├── log.ts      # FileLogCallback, appendLog() — one .log file per taskId
│   ├── runner.ts   # runMcpAgent(), nodeMcpServer() — spawn MCP servers, run a deep agent
│   └── index.ts    # Agent-kit entry point (imported as `@/common/agent`, NOT via `@/common`)
├── .log/           # Agent run logs, one file per taskId (gitignored)
├── types.ts        # Common types (Nullable, Optional, MaybePromise)
├── constants.ts    # Common constants (VERSION)
└── index.ts        # Public entry point (re-exports all kit/*)

agent/ is deliberately not re-exported from index.ts. The build inlines every dependency via noExternal, so re-exporting it would drag langchain and deepagents into the published MCP server bundle. Consumers reach it through the separate @/common/agent path, and it stays out of dist/.

This keeps the layering one-way: src/common/agent is the only place that assembles an agent — everything else ships MCP tools and nothing else. Proving the tools work under an LLM happens from a test that hands @/common/agent an MCP server and a prompt (see src/agent.test.ts).

src/tools/ — MCP Tool Definitions

src/tools/
├── index.ts        # tools re-export + UTILS_ENV_KEYS (env block in the generated README)
├── exchange.ts     # exchangeRatesTool, exchangeRateTool definitions
├── exchange.test.ts
├── movie.ts        # nowPlayingMoviesTool, upcomingMoviesTool, movieRecommendationsTool definitions
├── movie.test.ts
├── system.ts       # systemBatteryTool, systemMemoryTool, systemCpuTool, systemDiskTool, systemInfoTool definitions
└── system.test.ts

src/exchange/ — Exchange-Rate Scraping Domain

Opens Naver, Google, and Daum with Playwright and reads the rates directly. playwright resolves browser drivers from its own package directory at runtime, so it is left in dependencies and kept out of the bundle (marked external in tsup.config.ts).

src/exchange/
├── index.ts        # Parallel portal orchestration + timeout handling
├── types.ts        # Provider / CurrencyCode / ExchangeQuote definitions
├── parse.ts        # Number parsing, quoted-unit normalization, ExchangeQuote construction
├── wait.ts         # Waits until placeholder text is replaced by a real rate
├── browser.ts      # Chromium launch and browser context creation
└── providers/      # naver.ts · google.ts · daum.ts scrapers

src/tmdb/ — TMDB Movie Lookup Domain

Calls the TMDB v3 REST API over fetch. No browser and no extra dependency, so the whole domain is bundled like the rest of the source.

src/tmdb/
├── index.ts        # Domain entry point (re-exports)
├── types.ts        # TmdbMovie / MovieSummary / MovieListResult definitions and constants
├── client.ts       # Auth resolution, URL building, GET with a timeout
├── genres.ts       # Genre id → name table (cached per language), genre name resolution
├── normalize.ts    # Raw TMDB movie → MovieSummary
└── movies.ts       # now playing / upcoming / recommendation orchestration

Authentication comes from the environment: TMDB_API_KEY (v3 API key, sent as a query parameter) or TMDB_ACCESS_TOKEN (read access token, sent as a bearer header). When both are set the token wins, so the secret never lands in a URL.

src/system/ — Host Metrics Domain

Reads the machine the server runs on through systeminformation. No network, no credential, and no browser — the package shells out to platform commands (ioreg, vm_stat, df, …), so every read is wrapped in a timeout.

src/system/
├── index.ts        # Domain entry point (re-exports)
├── types.ts        # SECTIONS, BatteryInfo / MemoryInfo / CpuLoadInfo / DiskUsage / SystemSnapshot, defaults
├── normalize.ts    # Raw systeminformation payload → the types above, plus pickPrimaryDisk()
└── collect.ts      # readBattery / readMemory / readCpu / readDisks / readSnapshot, timeouts

Three decisions in this domain exist because the raw numbers answer the wrong question:

  • CPU load is sampled twice. currentLoad() reports the delta since the previous call, so the first call in a process is the average since boot. readCpu() primes it, waits sampleMs (at least 200ms — below that systeminformation returns its cached reading), then reads again.
  • Memory counts active, not used. On macOS and Linux used includes cache and buffers, which puts an idle machine in the 90s. The reclaimable part is reported separately as cachedBytes.
  • One disk is chosen, not all of them. macOS splits a single APFS container into several volumes and mounts a read-only system snapshot at /, which reports about 3% usage. pickPrimaryDisk() prefers /System/Volumes/Data on darwin, the working directory's drive on Windows, and / elsewhere. allDisks returns every mount instead.

readSnapshot() reads the requested sections concurrently, so asking for all four costs about as much as asking for CPU alone. A section that fails becomes null and the reason lands in errors, which is what separates "could not read the battery" from "this machine has no battery".

docs/ + rspress.config.ts — Rspress Documentation Site

docs/                          # Static markdown docs (01-*.md ~ 10-*.md)
├── index.md                   # Home page (Rspress hero layout)
├── 01-project-overview.md
├── ...
├── 10-commands.md
└── ko/                        # Korean locale
scripts/readme-docs-plugin.ts  # README → /api page plugin
rspress.config.ts              # Rspress config (sidebar, nav, plugins)
netlify.toml                   # Netlify deployment config

readme-docs-plugin renders the hand-written root README.md at the /api (and /ko/api) routes.

End-to-End Data Flow

Tool definitions (src/tools/*.ts)
  │
  ├──→ src/index.ts        ─→ tsup build ─→ dist/index.js   (library)
  ├──→ src/server.ts       ─→ tsup build ─→ dist/server.js  (MCP server)
  └──→ src/cli.ts          ─→ tsup build ─→ dist/cli.js     (CLI)

Documentation is not generated from the tools — README.md and
skills/<bin>/SKILL.md are hand-written and must be edited alongside src/tools/*.ts.

tsconfig path alias: @/* → ./src/* (root tsconfig.json; rstest.config.ts mirrors the same alias)

Architecture Diagrams

Module & Dependency Structure

src/common is never published as its own package — it is inlined into the bundle at build time.

Internal Source Structure

One tools definition is consumed three ways: the MCP server (stdio), the CLI runner, and the library entry. Documentation is a fourth consumer, written by hand.

Build Flow

Release Pipeline (.github/workflows/release.yml)

Separation of Concerns

  1. Tool definitions live in src/tools/ — the MCP-facing interface belongs only here
  2. Domain logic lives in src/exchange/, src/tmdb/, src/system/ — scraping, parsing, timeout handling
  3. Shared MCP server/CLI logic lives in src/common/kit/ — server creation, CLI parsing, error handling
  4. src/server.ts only passes the tool objects to createMcpServer() (a very thin layer)
  5. src/cli.ts only passes the tool objects to runCli() (a very thin layer)

Where to Add New Features

  • New tool: add a file under src/tools/ (or extend an existing one) and aggregate it in src/tools/index.ts
  • New portal/currency: add a scraper under src/exchange/providers/ and the constant in src/exchange/types.ts
  • New TMDB endpoint: add the call in src/tmdb/movies.ts and the response type in src/tmdb/types.ts
  • New host metric: add the section name to SECTIONS in src/system/types.ts, the conversion in src/system/normalize.ts, and the reader in src/system/collect.ts
  • New shared capability: add a module under src/common/kit/
  • Build config changes: edit the root tsup.config.ts