All projects

Employer work

Internal MCP Server

A Model Context Protocol server exposing a contract lifecycle platform as 62 typed tools any LLM client can call, with delegated OAuth 2.1 and tenant isolation enforced by the input schema.

Role
Design and implementation
Period
2026
Stack
  • TypeScript
  • NestJS
  • MCP SDK
  • Zod
  • OAuth 2.1
  • jose
  • pnpm workspaces
  • Vitest

This is employer work. Names of companies, clients, products and repositories are withheld; the architecture and the reasoning are not.

The problem

A large SaaS platform accumulates capability faster than it accumulates interfaces to that capability. Everything the product can do is reachable over HTTP, but reaching it from an AI assistant, an agentic IDE, or any third-party LLM client means each of those clients writing its own integration against a versioned REST API — and each of them getting authentication, tenant scoping and error handling subtly wrong in its own way.

The Model Context Protocol turns that from N integrations into one. But it moves the risk: now a model, not a developer, decides which operation to call.

The approach

One NestJS module per business domain — cards, approval, review, signature, contacts, forms, document management, uploads, areas, workflows, folders, auth — each declaring its tools through a wrapper class. Transport is streamable HTTP. Every tool’s input is a Zod schema, which is also what produces the JSON Schema the model sees, so the contract the model is shown and the contract the server enforces cannot drift apart.

Authentication is OAuth 2.1 with dynamic client registration, delegated to the platform’s own provider and validated against its JWKS. The full handshake is the specified one: a 401 carrying WWW-Authenticate, protected-resource metadata discovery, registration, authorization code in the browser, then a bearer token and reconnect.

Decisions worth defending

Every tool declares all four MCP annotations, and a test enforces it by reflection. The annotations tell a client whether a tool is read-only, destructive, idempotent, and whether it touches the outside world. They are advisory metadata — nothing breaks if you omit them, which is exactly the problem. A tool that deletes a contract and forgets to say so is indistinguishable, to a planning model, from one that reads it.

So a test walks every wrapper by reflection and fails the build if any tool is missing any annotation. This costs a test that has to understand the decorator layout, and it means adding a tool takes thirty seconds longer. In exchange, a destructive tool cannot reach a model unlabelled, because the build will not produce one. Code review would have caught most of these; the build catches all of them.

Tenant isolation is a required schema field, not a middleware check. Nearly every tool requires an area identifier as part of its input. A middleware guard reading tenant context from the session would have been less repetitive — and would have made cross-tenant access a question of whether the guard was wired up on that route. Putting it in the schema means the model cannot construct a call that omits scope, and the omission is a validation error before any handler runs.

Tool descriptions are written for the planner, not for a human reader. They chain explicitly — a description states that a given tool does not itself trigger extraction and names the tool to call next. Descriptions are the only lever over a model’s plan, and vague ones produce a client that calls three tools where one would do, or stops one call short of the outcome the user asked for.

A gateway mode exists purely to make local development possible. Seven OAuth endpoints are proxied with URL rewriting so the whole handshake completes through a single tunnel. This is scaffolding, not architecture, and it is behind a flag — but without it the OAuth flow could only be exercised in a deployed environment, and a flow that can only be tested in staging is a flow that gets tested rarely.

Scale

  • ~3,500 lines across 15 tool wrappers in 12 domain modules
  • 62 tools, 100% with Zod-validated input and complete MCP annotations
  • 7 OAuth endpoints proxied in gateway mode; granular scope enforcement per tool
  • 8 test files, including the reflection sweep that gates the build