Skip to main content
Glama
antropovanikolitf

mcp-task-server

README.md
# mcp-task-server

A small, security-focused [Model Context Protocol](https://modelcontextprotocol.io) server that exposes a task manager over stdio, backed by SQLite.

MCP is an open protocol that standardizes how applications provide context and tools to large language models. An MCP server exposes capabilities (here: task management tools) that any MCP-compatible client can discover and call. This server uses the stdio transport, so the client launches it as a subprocess and no network port, API key, or cloud service is involved.

This repository doubles as a reference for building MCP servers with security as a first-class design constraint: strict input validation, parameterized SQL, confirmation for destructive operations, and a full audit trail.

## Quickstart

Requires Node.js 20 or newer.

```bash
git clone https://github.com/antropovanikolitf/mcp-task-server.git
cd mcp-task-server
npm ci
npm run build
npm test
```

Any MCP client with stdio support can use the server: point it at `node dist/index.js`. Most clients take a JSON configuration entry along these lines:

```json
{
  "mcpServers": {
    "tasks": {
      "command": "node",
      "args": ["/absolute/path/to/mcp-task-server/dist/index.js"],
      "env": {
        "TASKS_DB_PATH": "/absolute/path/to/tasks.db"
      }
    }
  }
}
```

`TASKS_DB_PATH` is optional; it defaults to `tasks.db` in the working directory.

## Tools

| Tool | Arguments | Description |
| --- | --- | --- |
| `add_task` | `title` (string, 1 to 200 chars), `due?` (YYYY-MM-DD), `tags?` (up to 10 strings, 40 chars each) | Create a task. |
| `list_tasks` | `filter?` with `status` (`open` or `done`), `tag`, `dueAfter`, `dueBefore` | List tasks, optionally filtered. Due bounds are inclusive. |
| `complete_task` | `id` (positive integer) | Mark a task as done. |
| `delete_task` | `id` (positive integer), `confirm` (must be the boolean literal `true`) | Permanently delete a task. Calls without `confirm: true` are rejected. |
| `search_tasks` | `query` (string, 1 to 200 chars) | Substring search on titles, case-insensitive for ASCII letters (SQLite `LIKE` semantics). `%` and `_` are matched literally. |

Every tool returns a structured JSON result: `{ "ok": true, "data": ... }` on success, or `{ "ok": false, "error": { "code", "message" } }` on failure. Error codes are `invalid_input`, `not_found`, and `internal_error`. This contract holds over the wire: validation failures come back as this structured shape with `isError` set, not as opaque protocol errors. Only calling a tool name that does not exist fails at the protocol level.

Each tool also declares MCP tool annotations (`readOnlyHint` on `list_tasks` and `search_tasks`, `destructiveHint` on `delete_task`), so clients can gate or confirm calls based on protocol metadata rather than description prose.

## Architecture

The code is layered so that each concern is testable in isolation. `src/index.ts` is wiring only: it builds the store, the server, and the stdio transport. `src/server.ts` registers the `tools/list` and `tools/call` handlers directly on the low-level SDK `Server`, advertises each tool's JSON Schema (including `additionalProperties: false`) and annotations for discovery, and converts structured results into MCP tool responses. It deliberately does not use the SDK's high-level tool wrapper: the wrapper validates arguments itself before the handler runs, which would strip unknown properties silently and keep rejected calls out of the audit log. Instead, the raw wire arguments go straight into `src/tools.ts`, where every handler funnels through a single `runTool` helper that validates input with zod, appends an audit entry, and converts any unexpected exception into a structured error. `src/db.ts` is the storage layer, a thin class over better-sqlite3 that uses prepared, parameterized statements exclusively. `src/types.ts` defines the shared domain types. Tests in `test/` cover the storage layer, every tool handler, and the full client-to-server wire path over an in-memory MCP transport, including validation rejections, audit entries for rejected calls, and injection-shaped inputs.

## Security considerations

**Tool arguments are untrusted input, even when they come from an LLM.** A model relays whatever appears in its context, so a prompt-injected document or web page can steer it into calling tools with hostile arguments. This server therefore validates every payload with zod before any logic runs: unknown properties are rejected, strings have length caps, dates must be real calendar dates, and ids must be positive integers. Length limits count UTF-16 code units (JavaScript's `string.length`), not Unicode code points or grapheme clusters. Nothing reaches the database unvalidated.

**Parameterized SQL only.** All queries use prepared statements with bound parameters. User-supplied values are never interpolated into SQL text, and `LIKE` wildcards in search queries are escaped so they match literally.

**Least-privilege tool design.** The server exposes exactly five narrow tools rather than a generic query or exec surface. Each tool can do one thing, which keeps the blast radius of a misdirected call small and makes every call auditable and gateable by the client.

**Confirmation for destructive operations.** `delete_task` requires `confirm` to be the boolean literal `true`. A model cannot delete data as a side effect of loose phrasing; the schema forces a deliberate, separate confirmation step, and the tool description instructs clients to obtain explicit user consent first.

**Audit logging.** Every tool call is appended to an `audit_log` table with the tool name, a size-capped serialization of the arguments, the outcome (`ok`, `rejected`, `not_found`, or `error`), and a timestamp. Rejected calls are logged too, so probing attempts against the registered tools are visible after the fact, and the logged arguments are the raw wire payload, unknown keys included; calls to tool names that do not exist fail at the protocol level, as noted above, and never reach this layer. One known gap: the MCP SDK's JSON-RPC layer strips a top-level `__proto__` key from the arguments before any application code runs, so a call carrying one is accepted and that key is invisible to the audit log, while nested `__proto__` keys do reach the tool layer and are rejected by the strict schemas. Database triggers make the table append-only: `UPDATE` and `DELETE` on `audit_log` are refused at the SQLite level, so history cannot be rewritten through the normal connection (this is tamper resistance, not cryptographic tamper evidence). If the audit write itself fails (for example a read-only database), the failure is reported on stderr and the tool result still reflects what actually happened; the trail is best-effort under storage failure.

**Errors are structured, never stack traces.** Handlers catch unexpected exceptions and return a generic `internal_error` result. Internals, file paths, and stack frames are never sent back to the model.

**No secrets.** The stdio transport needs no API keys, tokens, or network listeners, and the repository contains none.

For a broader treatment of these risks, see the [OWASP Top 10 for LLM Applications](https://owasp.org/www-project-top-10-for-large-language-model-applications/), in particular LLM01 (Prompt Injection) and LLM06 (Excessive Agency).

## Development

```bash
npm run build   # compile TypeScript to dist/
npm test        # run the vitest suite
```

## License

MIT. See [LICENSE](LICENSE).