Stacklane is now Vantion LabsLearn more

MCP server starter: give AI agents secure access to internal systems

We use this starter when an agent needs access to internal systems such as a CRM, an ERP or a document store. It gives you a Model Context Protocol server in TypeScript, built on Effect, with sign-in, scoped permissions and a record of every call from the first commit.

What's inside

How it fits together

  1. 01

    Connect

    An MCP client, such as Claude, Cursor or an internal agent, connects over streamable HTTP. Without a token it is pointed to your identity provider and completes the OAuth flow there.

  2. 02

    Authorise

    The server verifies the token against the provider's published keys, or looks up the API key, and checks the scope the tool requires.

  3. 03

    Execute

    The tool calls the internal system through a small client that passes on who the caller is, with rate limits and timeouts applied.

  4. 04

    Record

    The call is written to the audit log, and a typed result goes back to the agent.

A look at the code

A read-only tool with a typed result, a scope check and an audit entry.

TypeScript
import { Effect, Schema } from "effect";
import { Tool, Toolkit } from "effect/unstable/ai";
import { CrmClient, Customer, CustomerNotFound } from "./CrmClient.ts";
import { GuardFailures, ToolGuard } from "../tools/ToolGuard.ts";

const GetCustomer = Tool.make("get_customer", {
  description: "Look up a customer by id.",
  parameters: Schema.Struct({ customerId: Schema.String }),
  success: Customer,
  failure: Schema.Union([CustomerNotFound, ...GuardFailures]),
}).annotate(Tool.Readonly, true);

export const CrmToolkit = Toolkit.make(GetCustomer);

export const CrmToolkitLive = CrmToolkit.toLayer(
  Effect.gen(function*() {
    const guard = yield* ToolGuard;
    const crm = yield* CrmClient;
    return CrmToolkit.of({
      // Checks crm:read, applies the rate and time limits, writes the audit entry.
      get_customer: (params) =>
        guard.run(
          { tool: "get_customer", scope: "crm:read", arguments: params },
          () => crm.getCustomer(params.customerId),
        ),
    });
  }),
);

Talk through one process with the founder