Angelo Dini, Switzerland. Head of Product at Divio, co-founder of devguard.

What Makes a Good MCP Server

How to build an MCP server people can trust, with runnable TypeScript and lessons from the devguard and Divio servers: read-only first, guarded writes, auth.

By 16 min read

Article
Article 078 of 78
Category
Teach
Steps
9
Code samples
Bash, TypeScript

A plug, not a portal

An MCP server is easy to write. You can have one answering in Claude Code inside ten minutes, and the official SDK does most of the work. Writing one you would connect to your production account, your compliance data or your colleagues' logs is a different job, and almost none of it is about the protocol.

A wall socket, a plug adapter and a plug with its cable pulled apart along their pins, the plug lifted out of the adapter in orange.
Fig. 078 A wall socket, a plug adapter and a plug with its cable pulled apart along their pins, the plug lifted out of the adapter in orange.

Two companies I work for shipped an MCP server in the last few weeks. I’m Head of Product at Divio and a co-founder of devguard, so take my enthusiasm with the appropriate amount of salt. What I find interesting is that the two servers were built for very different products (a cloud platform and a compliance tool) and still ended up making most of the same decisions. Those decisions are what this post is about.

If you have read my posts on Claude Code and agent skills, this is the third piece of the same puzzle. Skills tell an agent how to work. MCP gives it something to work on.

What an MCP server actually is

The Model Context Protocol is a JSON-RPC protocol between an AI application (the client: Claude Code, Cursor, VS Code, Claude Desktop and so on) and a program that exposes some system to it (the server). A server offers three kinds of things. Tools are functions the model can decide to call, like “list deployments”. Resources are readable things at an address, like one record. Prompts are ready-made instructions a user picks from a menu. Every client that speaks MCP can use every server that speaks MCP, which is the whole point: you write the integration once instead of once per AI tool.

A server runs either as a local process the client starts and talks to over stdio, or as a web service the client reaches over HTTP. The HTTP flavour is called Streamable HTTP. Both servers in this post are the remote kind: you give your client a URL. The current spec revision, 2026-07-28, made the protocol stateless: no more initialize handshake, no more Mcp-Session-Id header, every request carries its own protocol version. That matters for hosting, because a stateless endpoint sits happily behind any load balancer.

The smallest server worth running

The TypeScript SDK split into several packages with v2. You need the server package, the Node adapter and Zod for the input schemas:

Bash
mkdir deploy-board && cd deploy-board
npm init -y && npm pkg set type=module
npm install @modelcontextprotocol/server @modelcontextprotocol/node zod

The example is a toy deployment board, with three deployments held in memory. Swap the array for your own API later.

TypeScript
// server.ts
import { createServer } from 'node:http';
import {
  localhostHostValidation,
  localhostOriginValidation,
  toNodeHandler,
} from '@modelcontextprotocol/node';
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod';

const deployments = [
  { id: 'd-103', environment: 'live', startedAt: '2026-10-08T16:05:00Z', success: true },
  { id: 'd-102', environment: 'test', startedAt: '2026-10-08T15:40:00Z', success: false },
  { id: 'd-101', environment: 'test', startedAt: '2026-10-07T09:12:00Z', success: true },
];

const handler = createMcpHandler(() => {
  const server = new McpServer({ name: 'deploy-board', version: '1.0.0' });

  server.registerTool(
    'list_deployments',
    {
      title: 'List deployments',
      description:
        'Deployments, newest first. Filter by environment and by start time (ISO 8601).',
      inputSchema: z.object({
        environment: z.enum(['test', 'live']).optional(),
        startedAfter: z.iso.datetime().optional(),
        limit: z.number().int().min(1).max(50).default(10),
      }),
      annotations: { readOnlyHint: true },
    },
    async ({ environment, startedAfter, limit }) => {
      const rows = deployments
        .filter(d => !environment || d.environment === environment)
        .filter(d => !startedAfter || d.startedAt > startedAfter)
        .slice(0, limit);
      return { content: [{ type: 'text', text: JSON.stringify(rows) }] };
    }
  );

  return server;
});

const serve = toNodeHandler(handler);
const validHost = localhostHostValidation();
const validOrigin = localhostOriginValidation();

createServer((req, res) => {
  if (!validHost(req, res) || !validOrigin(req, res)) {
    return;
  }
  if (req.url !== '/mcp') {
    res.writeHead(404).end();
    return;
  }
  void serve(req, res);
}).listen(3000, '127.0.0.1', () => {
  console.log('MCP server on http://127.0.0.1:3000/mcp');
});

Node 24 runs TypeScript files directly, so there is no build step:

Bash
node server.ts
claude mcp add --transport http deploy-board http://127.0.0.1:3000/mcp

Start a new claude session and ask it which test deployments failed. It finds the tool on its own.

A few things in there are worth a second look. createMcpHandler takes a factory and builds a fresh McpServer for every request, which is the stateless model from the spec made concrete; it also answers older 2025-era clients in a stateless fallback mode, so you don’t have to care which revision a client speaks. The two validation guards protect a localhost server from DNS rebinding, where a web page you happen to have open tries to talk to a port on your machine. And the description says “newest first” and “ISO 8601” because the description is the only documentation the model reads. If the model has to guess the date format, it will guess wrong at least once.

For poking at a server without an AI in the loop, the MCP Inspector (npx @modelcontextprotocol/inspector) lists the tools and lets you call them by hand.

That’s the protocol part done. Everything after this is design.

Read-only is where you start

Both servers launched read-only, and I think that is the single most important decision in either of them.

devguard’s server is read-only, full stop. Its announcement says it reads your risks, controls, policies, evidence, audits, vendors, assets and deadlines as they stand right now, and that it cannot change or delete anything. It also only ever sees the organisation you connected it to. For a compliance tool that is the right call: the value is in asking questions across all of that data, and a model that could quietly edit a policy would be a model nobody’s auditor would let near the account.

Divio’s server reads applications, environments, deployments, logs, metrics, domains, backups and organisations. It is read-only unless the credential you connect with explicitly allows writing, which I’ll come back to.

The reasoning is boring and correct. A read tool that misfires wastes a few seconds. A write tool that misfires deploys something, and the person who asked for it may not have read the tool call before approving it. People will connect your server to an agent that runs in a loop. Plan for that person.

The readOnlyHint annotation in the example tells clients a tool has no side effects, which a client may take into account when deciding whether to ask you first. Treat it as a courtesy. The spec is explicit that clients must consider annotations untrusted unless they come from a trusted server. The actual guarantee is that a write tool does not exist on a read-only connection, which is the next lesson.

A short write list, with rails

Divio’s server can do exactly three things that change anything, and each one is narrower than it sounds:

  • It can create an application, but only on the free trial and from a blank template. It never chooses a paid plan.
  • It can deploy, but only to the test environment. Live is promoted by a person, in the Control Panel.
  • It can take a backup.

Look at what is missing: deleting, restoring, deploying to live, anything that costs money. Each of those is a decision a human should make while looking at a screen. The three that made it are things you would happily let a colleague on their first day do.

The other half of the rail is that the write tools are opt-in per connection. They only exist when the person connecting has explicitly granted write access; without that, the server is exactly the read-only surface. The spec explicitly allows this: the tool list may vary with the authorisation presented on the request, so a read-only caller never sees a write tool in the first place. The model can’t call what it can’t see.

With a per-request factory this is a plain if. Here is the deployment board with a guarded write tool. The factory receives the request’s verified auth info (how it gets there is the next section):

TypeScript
const handler = createMcpHandler(({ authInfo }) => {
  const server = new McpServer({ name: 'deploy-board', version: '1.0.0' });

  // ... list_deployments as before ...

  if (authInfo?.scopes.includes('deploy:write')) {
    server.registerTool(
      'deploy_test',
      {
        title: 'Deploy the test environment',
        description: 'Deploys test. Live is promoted by a human, elsewhere.',
        inputSchema: z.object({}),
        annotations: { readOnlyHint: false, destructiveHint: false },
      },
      async () => {
        const row = {
          id: `d-${100 + deployments.length + 1}`,
          environment: 'test',
          startedAt: new Date().toISOString(),
          success: true,
        };
        deployments.unshift(row);
        return { content: [{ type: 'text', text: JSON.stringify(row) }] };
      }
    );
  }

  return server;
});

Notice there is no environment argument. A tool that takes "test" | "live" and checks it is one bug away from deploying live. A tool that has no way to say “live” is not.

Two ways in

Both servers support two kinds of credential, for two kinds of user.

OAuth is for people. You add the server URL to your client, the client opens a browser, you sign in as yourself, and the server acts with your permissions. No tokens copied into config files. The MCP authorisation spec makes the server an OAuth resource server and requires it to publish a Protected Resource Metadata document (RFC 9728). The flow goes like this:

  1. The client calls the server without a token.
  2. The server answers 401 with a WWW-Authenticate header pointing at its metadata document, ideally with the scope it needs.
  3. The client reads the document, finds the authorisation server, and runs an authorisation-code flow with PKCE.
  4. The client comes back with a token issued for this server, and the server checks that it was.

That last check is not optional: the spec says a server must only accept tokens issued for it as the audience, and must not pass any other token through to an upstream API. That discovery is why claude mcp add needs nothing but the URL.

A token is for scripts and CI, where there is no browser and nobody to click “allow”. Divio accepts a personal access token for that. devguard’s Claude Desktop setup goes through the mcp-remote bridge with an API key from Account → Tokens.

Here is a toy version of the gate, as the request handler in front of the factory. The TOKENS map stands in for real verification, which for OAuth means checking the token’s signature, issuer, audience and expiry against your authorisation server:

TypeScript
import type { AuthInfo } from '@modelcontextprotocol/server';

const BASE = 'http://127.0.0.1:3000';
const METADATA = '/.well-known/oauth-protected-resource/mcp';

// stand-in for real verification: one token may only read, the other was granted writing
const TOKENS: Record<string, string[]> = {
  'Bearer reader-jwt': ['deploy:read'],
  'Bearer writer-jwt': ['deploy:read', 'deploy:write'],
};

function authenticate(header: string | undefined): AuthInfo | undefined {
  const scopes = header ? TOKENS[header] : undefined;
  if (!scopes) {
    return undefined;
  }
  return {
    token: header?.split(' ')[1] ?? '',
    clientId: 'demo',
    scopes,
    expiresAt: Math.floor(Date.now() / 1000) + 3600,
  };
}

createServer((req, res) => {
  if (!validHost(req, res) || !validOrigin(req, res)) {
    return;
  }
  if (req.url === METADATA) {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(
      JSON.stringify({
        resource: `${BASE}/mcp`,
        authorization_servers: ['https://auth.example.com'],
        scopes_supported: ['deploy:read', 'deploy:write'],
        bearer_methods_supported: ['header'],
      })
    );
    return;
  }
  if (req.url !== '/mcp') {
    res.writeHead(404).end();
    return;
  }
  const auth = authenticate(req.headers.authorization);
  if (!auth) {
    res.writeHead(401, {
      'WWW-Authenticate': `Bearer resource_metadata="${BASE}${METADATA}", scope="deploy:read"`,
    });
    res.end();
    return;
  }
  void serve(Object.assign(req, { auth }), res);
}).listen(3000, '127.0.0.1');

toNodeHandler picks up req.auth and hands it to the factory as authInfo, which is how the if in the previous section sees the scopes. Grant the write scope deliberately, per connection: a token that can do everything its owner can is exactly what you don’t want an agent holding by default. It’s the same reason rm has -i.

devguard’s announcement puts the people side plainly: Claude Code, Cursor, VS Code, Windsurf and the Gemini CLI open a browser the first time, so you sign in as yourself and no key goes into a config file. Separately, its Auth 1.5.0 release on 14 September rebuilt sign-in on OAuth 2.1 and lets you control which organisation a connected app can see.

Answer the questions people actually ask

A server that mirrors the REST API one endpoint per tool is technically complete and practically annoying. The model has to work out which of thirty tools to chain, in what order, with what filters. The servers I like are built backwards from the questions.

Ship the prompts

devguard’s announcement opens with a question that doesn’t fit on any one screen: which ISO 27001 controls have no policy behind them? That’s a join across controls and policies. So the server ships five prompts for the work that keeps coming back: a gap analysis against a framework, a risk posture summary, a treatment plan for one risk, audit preparation, and a policy review.

A prompt is cheap to write and it encodes something the model can’t know: what a good answer to that question looks like in your domain. In the toy server:

TypeScript
server.registerPrompt(
  'failed-deploys',
  {
    title: 'What failed, and why?',
    description: 'Find failed deployments since a point in time and explain them.',
    argsSchema: z.object({ since: z.string() }),
  },
  ({ since }) => ({
    messages: [
      {
        role: 'user',
        content: {
          type: 'text',
          text: `Call list_deployments with startedAfter="${since}". For every row with success: false, read deploy://deployments/<id> and say what failed. End with one line: how many failed, out of how many.`,
        },
      },
    ],
  })
);

Clients show prompts as a menu (in Claude Code they turn up as slash commands), so a user who has never read your docs finds them anyway.

Give records an address

devguard can also read a single record at its own devguard:// address. That’s what resources are for: a stable URI the model can quote back and the client can fetch. The toy server’s prompt above refers to one, and registering it takes a URI template:

TypeScript
import { ResourceTemplate } from '@modelcontextprotocol/server';

server.registerResource(
  'deployment',
  new ResourceTemplate('deploy://deployments/{id}', { list: undefined }),
  { title: 'One deployment', mimeType: 'application/json' },
  async (uri, { id }) => {
    const row = deployments.find(d => d.id === id);
    return { contents: [{ uri: uri.href, text: JSON.stringify(row ?? null) }] };
  }
);

Do the paging in the server

Some questions the upstream API simply can’t answer. Divio’s API has no date filter and no ordering on deployments and backups, so “which environments deployed today, and did any fail?” would mean the model paging through everything and filtering in its head. Models are bad at that, and it burns context on rows nobody wanted.

So the Divio server does it itself: it walks the pages, filters, and returns the answer. And because a scan has to stop somewhere, it says when it stopped early. Its answers carry a flag that says whether the scan was complete or older rows may be missing. That one flag is the difference between “no backups this week” and “I didn’t look far enough to know”, and the model will happily say the first when it means the second unless you tell it.

The pattern fits in twenty lines:

TypeScript
type Page<T> = { results: T[]; next: string | null };

export async function scanSince<T extends { startedAt: string }>(
  fetchPage: (cursor: string | null) => Promise<Page<T>>,
  after: string,
  maxPages = 20
) {
  const results: T[] = [];
  let cursor: string | null = null;
  let complete = false;
  for (let page = 0; page < maxPages; page++) {
    const { results: rows, next } = await fetchPage(cursor);
    results.push(...rows.filter(row => row.startedAt > after));
    if (!next) {
      complete = true;
      break;
    }
    cursor = next;
  }
  results.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
  return { count: results.length, complete, results };
}

Without ordering there is no early exit; you can’t know the next page has nothing newer. Hence the cap, and hence being honest about it.

Don’t hand the model your secrets

Anything a tool returns goes into the model’s context, and from there into a provider’s logs, a chat transcript, maybe a screenshot in a support ticket. Decide what never leaves.

Divio drew the line at environment variable values: they never leave the platform. The tools return names and metadata only, so “which variables are set on production?” works and “what’s the database password?” doesn’t. Logs are the honest exception. They come back as your application emitted them, and the README says so in as many words: if your app logs secrets, the server will return them. I like that it’s written down rather than papered over with a regex that catches half of them.

The same thinking covers scope. devguard’s server only ever sees the organisation you connected it to, so someone with access to several organisations doesn’t get them mixed into one answer.

Question Divio devguard
Can it write? Three narrow tools, only when write access is granted No
People sign in with OAuth in the browser A browser sign-in, no key in a config file
Scripts and CI use A personal access token An API key from Account → Tokens (via mcp-remote for Claude Desktop)
Curated questions Paging and filtering in the server Five compliance prompts
Never returned Environment variable values Anything outside the connected organisation

Plugging it in

For your own server, the commands from earlier are all you need. For the two in this post:

Bash
# Divio: sign in with your account when prompted
claude mcp add --transport http divio https://mcp.divio.com/mcp

Claude Desktop and claude.ai take the same URL as a custom connector. For devguard, Settings → Integrations has a tab per client with a snippet to copy; Claude Code, Cursor, VS Code, Windsurf and the Gemini CLI need the URL and nothing else, and Claude Desktop connects through the mcp-remote bridge with an API key from Account → Tokens.

What still bugs me

Annotations. A server can say readOnlyHint: true on a tool that deletes things, and the spec’s answer is that clients shouldn’t trust it unless they trust the server. Fair enough, but it means the one bit of metadata that could make confirmations smarter is mostly decoration. Until that changes, the only rail that holds is on the server: don’t register the tool.

The other thing is churn. The July revision removed sessions and the handshake, the SDK moved to v2 with new package names, and half the tutorials you’ll find still import @modelcontextprotocol/sdk and set up session ids. The code above runs against @modelcontextprotocol/server 2.3.1 today. Pin your versions, and check the date on whatever you copy from, including this.

‘Till next time!

Steps in this article

  1. 01What an MCP server actually is
  2. 02The smallest server worth running
  3. 03Read-only is where you start
  4. 04A short write list, with rails
  5. 05Two ways in
  6. 06Answer the questions people actually ask
  7. 07Don’t hand the model your secrets
  8. 08Plugging it in
  9. 09What still bugs me