# MCP, OAuth and tool extension

URL: https://ship-and-scale.vercel.app/docs/integrations/mcp

Connect a tool client with workspace credentials, understand consent and add an explicitly granted notes tool.

## The stateless MCP model [#the-stateless-mcp-model]

Model Context Protocol (MCP) lets a compatible client discover and call named tools. The starter's endpoint is `/api/mcp` on your application origin. It exposes the same `get_workspace` and `rename_workspace` operations as the [REST API](/docs/integrations/api), using their shared validation and service layer.

Each request constructs a server for the current credential. It does not retain a shared customer's session, accept JSON-RPC batches or offer a long-lived subscription stream. The supported transport is stateless HTTP with JSON responses. A client must authenticate each request. Read-only credentials never see the rename tool in tool discovery, and the underlying operation independently checks permission.

**MCP authorization diagram**

Client → bearer verification → current workspace grant → allowed tool schema → shared operation → workspace-scoped result. An OAuth login identifies the person; the explicit application connection determines the workspace and permission.

## Local key client [#local-key-client]

Create a workspace key with the smallest permission needed in **Settings → API keys**. Configure a client that supports remote HTTP MCP and an Authorization header:

```json
{
  "url": "http://localhost:3000/api/mcp",
  "headers": { "Authorization": "Bearer REPLACE_WITH_WORKSPACE_KEY" }
}
```

This is the connection data, not a universal configuration-file format. Put it into your client's documented HTTP MCP fields; the nesting/name varies by client. Store the key privately. A cloud-hosted client cannot reach your computer's localhost URL, so use your own deployed HTTPS origin for that client.

The following POSIX shell request checks tool discovery after configuring `MCP_URL` and `WORKSPACE_API_KEY` in the shell. It consumes the credential's request quota but does not rename anything:

```sh
curl --fail-with-body "$MCP_URL" \
  --header "Authorization: Bearer $WORKSPACE_API_KEY" \
  --header "Content-Type: application/json" \
  --header "Accept: application/json, text/event-stream" \
  --data '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
```

Use a compliant MCP client for the full initialize, discovery and invocation lifecycle. To inspect a read invocation at the protocol level, the request body is:

```json
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_workspace","arguments":{}}}
```

The successful tool result contains text JSON and `structuredContent`. Operation failures return an MCP tool error with `isError`; authentication/origin failures can occur before an MCP result and return an HTTP error. Do not interpret HTTP 200 alone as tool success.

## Deployed Claude and OAuth consent [#deployed-claude-and-oauth-consent]

For a deployed remote connector, the current starter uses hosted Supabase's OAuth server, PKCE and explicit app consent. Check the OAuth-server settings available to your hosted project and your client's current connector requirements before relying on it in production. See [Supabase OAuth server](https://supabase.com/docs/guides/auth/oauth-server) and [Claude custom connectors](https://support.claude.com/en/articles/11175166-getting-started-with-custom-connectors-using-remote-mcp). The current official overview describes an OAuth 2.1 server; availability in your selected project still needs the installer's checks and a real consent test.

1. Complete [deployment kickstart](/docs/start/deployment-kickstart). The canonical HTTPS origin must match the installed Auth redirects and MCP audience.
2. In the client's custom connector settings, use your deployed `/api/mcp` URL. Use its OAuth connection flow rather than pasting a dashboard session cookie.
3. Sign in to your application with a verified account. Review the requesting client and redirect destination on the application's consent page.
4. Select the intended workspace and read or read/write permission. Only an eligible owner/admin can approve the connection.
5. Return to the client and list the tools. Invoke `get_workspace` first and compare its ID/name with your chosen workspace.
6. Test a rename only in an authorized demonstration workspace with read/write permission. Confirm it preserves the slug and unrelated settings.

The access token's audience must be the exact application MCP URL. A token for another origin, a general dashboard login or another OAuth client/session cannot be substituted. A workspace selected later in a dashboard tab does not retarget an already approved connection.

This source includes the OAuth flow and automated boundaries; this guide's local verification did not exercise a deployed Claude authorization or invocation. A real client round trip and revocation test remain required for your release.

## Current grants and origins [#current-grants-and-origins]

`lib/integrations/auth.ts` validates token issuer, audience, user, session, client and connection before accepting OAuth. The database rechecks that the account is verified, the client/session is current, and the user still holds an eligible workspace role. OAuth-issued tokens do not gain ordinary app database privileges.

`lib/integrations/http.ts` checks browser Origin headers against `APP_URL`, with a development-only localhost/127.0.0.1 equivalence. A headerless server client can connect with a valid bearer. Do not allow arbitrary browser origins to fix a misconfigured client. Fix the configured origin, deployment callbacks and audience together.

The endpoint accepts POST JSON up to 16,384 bytes, rejects batches, and returns authenticated GET/DELETE requests with 405. It is not an SSE subscription endpoint. A credential permits 60 requests per minute. Retry 429 according to the response; avoid connection retry loops that exhaust the same quota.

## Add the notes tool [#add-the-notes-tool]

First install [the explicit notes grant extension](/docs/integrations/api#extend-with-workspace-notes). It creates a separate permission to read the latest 50 workspace notes, leaving older credentials unchanged. The complete optional MCP server replacement below retains the existing two operations and adds `get_workspace_notes` only for a currently granted credential.

**lib/mcp/server.ts — optional notes extension**

```
import 'server-only';
import { z } from 'zod';
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
import { site } from '@/config/site';
import config from '@/config/integrations.json';
import type { Principal } from '@/lib/integrations/auth';
import { IntegrationError } from '@/lib/integrations/http';
import { operations, type Operation } from '@/lib/integrations/schema';
import { executeOperation } from '@/lib/integrations/workspace';
import { canReadNotes, readNotes, notesOutput } from '@/lib/integrations/notes';
import { integrationActivity } from '@/lib/admin/integration-activity';

export async function mcpHandler(auth: Principal) {
  const notesAllowed = await canReadNotes(auth);
  return createMcpHandler(() => {
    const server = new McpServer({ name: site.name, version: '1.0.0' });
    for (const name of Object.keys(operations) as Operation[]) {
      const op = operations[name];
      if (!config.operations.includes(name) || (op.permission === 'write' && auth.permission !== 'read_write')) continue;
      server.registerTool(name, { title: op.title, description: op.description, inputSchema: op.input, outputSchema: op.output, annotations: { readOnlyHint: op.permission === 'read', destructiveHint: false, idempotentHint: true, openWorldHint: false } }, async (input: unknown) => {
        try {
          const result = await executeOperation(name, auth, input);
          await integrationActivity(auth.org_id, 'mcp');
          return { content: [{ type: 'text' as const, text: JSON.stringify(result) }], structuredContent: result };
        } catch (error) { return failure(error); }
      });
    }
    if (notesAllowed) server.registerTool('get_workspace_notes', { title: 'Read workspace notes', description: 'Read the latest 50 notes in the explicitly authorized workspace.', inputSchema: z.strictObject({}), outputSchema: notesOutput, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false } }, async () => {
      try {
        const result = await readNotes(auth);
        await integrationActivity(auth.org_id, 'mcp');
        return { content: [{ type: 'text' as const, text: JSON.stringify(result) }], structuredContent: result };
      } catch (error) { return failure(error); }
    });
    return server;
  }, { legacy: 'stateless', responseMode: 'json', maxSubscriptions: 0 });
}
function failure(error: unknown) {
  const e = error instanceof IntegrationError ? error : new IntegrationError(503, 'service_unavailable', 'Service temporarily unavailable.');
  return { isError: true as const, content: [{ type: 'text' as const, text: JSON.stringify({ error: e.message, code: e.code }) }] };
}
```

Because grant lookup is asynchronous, change the handler call in `app/api/mcp/route.ts` from `mcpHandler(auth).fetch(...)` to these two complete statements, in the same location after body validation:

```ts
const handler = await mcpHandler(auth);
const response = await handler.fetch(request, { parsedBody: body });
```

Keep the remainder of that route, including bearer/origin checks, bounded input, unsupported-method behavior, `no-store` and error mapping. Use the original endpoint so the OAuth audience remains correct. Do not add a second unregistered MCP audience just to expose the example.

The tool input is an empty strict object. A caller cannot inject `org_id` or another filter. The shared `readNotes` service checks the grant again during invocation; discovery is not authorization. Removing the grant between discovery and invocation must fail safely. The output has a bounded notes array and never includes memberships, emails or billing data.

In `/dashboard/notes-access`, an owner/admin can approve an API key or their own current OAuth connection for that workspace. Explain this additional permission to the person approving it: they are allowing the connected service to read note bodies. Original workspace-only consent does not silently expand. Deploying the sample code alone grants no one this capability.

## Disconnect and retry [#disconnect-and-retry]

Open **Settings → Connections** and disconnect the relevant client. The application first revokes local access, then attempts provider-grant cleanup. If provider cleanup fails, access remains denied locally and the UI offers cleanup retry. Do not restore the local grant to make the row disappear.

An expired or revoked key needs a newly authorized credential. A disconnected OAuth integration needs a new consent flow. Removing or demoting a workspace member revokes their connection and does not revive it if membership returns. After a domain change, reconnect clients whose audience still references the previous origin.

## Verification checklist [#verification-checklist]

Test read-only discovery/invocation, invalid audience, wrong Origin, missing bearer, expired key, revoked session and membership loss. Then test the notes grant with both key and OAuth paths: absent grant hides the tool; explicit grant reads only the correct workspace; removing it blocks the next invocation. Confirm a tool error is displayed meaningfully in the real client. The [testing guide](/docs/operations/testing) distinguishes fixture checks from deployed-client acceptance.