Skip to content
Documentation
AI and integrations

MCP, OAuth and tool extension

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

View Markdown

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, 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.

Diagram · 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

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:

{
  "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:

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:

{"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.

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 and Claude custom connectors. 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. 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

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

First install the explicit notes grant extension. 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:

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

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

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 distinguishes fixture checks from deployed-client acceptance.

Template 0.0.1 · September 2026 source snapshot