Shipping an MCP Server for a B2B SaaS: Auth, Scopes and Tool Design

November 3, 2026 (2d ago)

TimeTackle now has an MCP server. It’s listed in the ChatGPT and Claude connector directories, and it powers the AI assistant inside the TimeTackle web app as well. Users can ask things like “how much time did my team spend in customer meetings last month?” from whichever assistant they already use.

The Model Context Protocol is simple to get running locally. Getting it to a state where a security reviewer and two connector directories are happy took most of the work. This post is about that part.

MCP clients discover the protected resource metadata, authorize against an OAuth server with PKCE and dynamic client registration, and receive MCP-scoped tokens. Tool calls over Streamable HTTP pass through token checks, a tool registry and guards in the MCP server, which calls the TimeTackle API using the same authorization as the web app.
The MCP server is a thin, well-guarded layer over the existing API. It never gets more access than the user has in the web app.

Decision 1: use the official SDK

We built on the official MCP TypeScript SDK rather than implementing the protocol ourselves. The protocol is still evolving: transports, auth discovery and capability negotiation have all changed in the last year. The SDK absorbs those changes, and we can spend our time on tools and security instead of framing.

The server runs over Streamable HTTP, which is what remote connectors use, as a separate service from the main API so it can be scaled, rate-limited and monitored on its own.

Decision 2: tokens that only work for MCP

The tempting shortcut is to let MCP clients use the same tokens as the web app. We didn’t, for two reasons. An MCP token sits in a third-party client’s storage, so if it leaks, the damage should be limited to what MCP can do. And users should be able to see and revoke “ChatGPT’s access” separately from their own logins.

So MCP has its own OAuth flow and its own tokens:

  • Discovery follows the MCP authorization spec: the server publishes protected-resource metadata that points clients to our authorization server.
  • Authorization uses OAuth 2.1 with PKCE. Clients register dynamically, since we can’t pre-register every possible MCP client.
  • Tokens are MCP-scoped: issued with an audience that only the MCP server accepts, and scopes that map to tool groups. A token minted for MCP is rejected by the main API, and a web-app session token is rejected by the MCP server.
  • Consent is explicit. The user sees which client is asking and what it will be able to read.
mcp-auth.ts
export async function authenticateMcpRequest(req: Request): Promise<McpPrincipal> {
  const token = bearer(req);
  const claims = await verifyAccessToken(token, {
    audience: env.MCP_RESOURCE_URL,      // tokens for other audiences are rejected
    issuer: env.AUTH_ISSUER,
  });
 
  return {
    userId: claims.sub,
    accountId: claims.account_id,
    scopes: new Set(claims.scope.split(" ")),
    clientId: claims.client_id,          // which MCP client, for audit and revocation
  };
}

Decision 3: the API’s authorization, not a new one

Once a request is authenticated, the MCP server calls the existing TimeTackle API as that user, through the same authorization checks the web app uses. The MCP layer adds restrictions (scopes, rate limits, row limits) but never grants anything. If a user can’t see a team’s calendar in the web app, no tool call will show it to them.

This is the single most important property of the design, and it’s what made the security review straightforward.

Decision 4: tools shaped for a model, not mirrored from REST

The obvious first design exposes endpoints one-to-one: list_events, get_event, list_tags, list_users, and so on. Models use tools like that badly. To answer “how much time did my team spend in customer meetings last month?” a model will page through thousands of events and try to sum them itself.

The tools that worked are task-shaped:

  • A time_report tool that takes a date range, a grouping (by tag, person or category) and filters, and returns aggregated totals. The server does the arithmetic.
  • Search tools that return summaries with ids, so the model can drill in only when it needs detail.
  • Small, typed inputs with descriptions written for the model: what each field means, valid values, and an example.
tools/time-report.ts
server.registerTool(
  "time_report",
  {
    description:
      "Total tracked time for a date range, grouped by tag, person or category. " +
      "Use this for any question about how much time was spent on something.",
    inputSchema: {
      from: z.string().describe("Start date, YYYY-MM-DD"),
      to: z.string().describe("End date, YYYY-MM-DD, inclusive"),
      groupBy: z.enum(["tag", "person", "category"]),
      tagIds: z.array(z.string()).optional().describe("Only include these tags"),
    },
  },
  async (args, { authInfo }) => {
    requireScope(authInfo, "reports:read");
    const report = await api.as(authInfo).reports.timeByGroup(args);
    return { content: [{ type: "text", text: formatReport(report) }], structuredContent: report };
  },
);

Decision 5: guards for a new kind of client

MCP clients behave differently from people. They retry aggressively, run tools in loops and sometimes ask for far more data than anyone would read. Every tool call goes through guards:

  • Rate limits per user and per client.
  • Row and range limits, with an explicit message telling the model to narrow its query instead of a silent truncation.
  • An audit log of tool calls with client, user and parameters, which is also what we review when something looks wrong.

Getting into the directories

Both connector directories review submissions. What they cared about lined up closely with what a SOC 2 auditor cares about: a clear auth flow, least-privilege scopes, documented tools, a privacy policy that covers the data the tools return, and a support contact. Having designed for those from the start meant the reviews were about details rather than architecture.

What I’d do again

  • Give MCP its own token audience from day one. Retrofitting it is painful.
  • Reuse your API’s authorization. The MCP server should be the least privileged client you have.
  • Design tools around the questions users ask, and make the server do the computation.
  • Treat directory review like a security review. It mostly is one.