How OpenSEO Implements Per-Project Authorization for MCP: A Deep Dive

OpenSEO enforces per-project authorization for MCP by wrapping every tool call with withMcpProjectAuth, which validates OAuth tokens or API keys against the specific project's organization before executing any operation.

The every-app/open-seo repository implements a strict authorization layer for its Model Context Protocol (MCP) server, ensuring that every tool invocation is scoped to a specific project and verified against the caller's organizational membership. This design prevents cross-project data leakage while supporting both OAuth tokens and API key authentication methods. Understanding this flow is essential for developers integrating with OpenSEO's MCP tools or building similar authorization systems.

MCP Authentication Flow Overview

OpenSEO's MCP layer operates as a fail-closed security system where every request must pass through a five-stage validation pipeline before touching project data. The architecture separates transport-level authentication from project-level authorization, creating clear boundaries between verifying who is calling and what they can access.

The system supports dual credential models: OAuth tokens carry an orgScope of "pinned" (tied to a specific organization), while API keys use "user" scope requiring dynamic organization resolution. Both paths converge in the same project authorization wrapper, ensuring consistent enforcement regardless of authentication method.

The Five-Step Authorization Process

Step 1: Transport Layer Validation

Every MCP request enters through handleAuthenticatedOpenSeoMcpRequest in src/server/mcp/transport.ts. This function acts as the gatekeeper, verifying that the request carries both a valid OAuth token and the specific MCP scope before allowing further processing.

// transport.ts – entry point for an MCP request
export async function handleAuthenticatedOpenSeoMcpRequest(
  request: Request,
  props: unknown,
) {
  const result = hostedWorkersOAuthMcpPropsSchema.safeParse(props);
  if (!result.success) return new Response("MCP auth context required", {status: 403});
  if (!result.data[MCP_AUTH_CONTEXT_PROP].scopes.includes(MCP_SCOPE))
    return new Response("MCP scope required", {status: 403});
  // ... proceeds to tool handling
}

If the scope check fails, the server returns a 403 Forbidden response immediately, preventing unauthorized requests from consuming resources or reaching project data.

Step 2: Building the MCP Auth Context

Once past transport security, createMcpToolContext in src/server/mcp/context.ts assembles a ToolAuthContext object. This context captures the orgScope ("pinned" for OAuth tokens, "user" for API keys) and prepares the foundation for project-level checks.

The context creation distinguishes between credential types:

  • OAuth tokens: Pre-validated against a specific organization
  • API keys: Require lookup to determine organizational membership

Step 3: Project-Level Authorization Checks

The critical authorization logic resides in src/server/mcp/project-auth.ts. Every MCP tool that accesses project data must be wrapped with withMcpProjectAuth, which calls requireProjectAccess to perform the actual security validation.

// project-auth.ts – core per‑project auth wrapper
export function withMcpProjectAuth<TArgs extends {projectId: string}, TResult>(
  handler: (args: TArgs, ctx: McpProjectAuthContext) => Promise<TResult> | TResult,
) {
  return async (args: TArgs, toolContext: ToolContext) => {
    const context = await requireProjectAccess(toolContext, args.projectId);
    return handler(args, context);
  };
}

The requireProjectAccess function implements branching validation logic:

  • If orgScope = "user" (API key): Fetches the target project, derives its organization, and confirms the caller's membership in that organization
  • If orgScope = "pinned" (OAuth token): Validates that the requested projectId belongs to the token's pre-authorized organization

Both failure paths return a generic FORBIDDEN error (lines 26-28) to prevent organization enumeration attacks.

Step 4: Tool Execution with Resolved Context

Upon successful authorization, the system constructs a McpProjectAuthContext containing resolved auth, billing, and project objects. This context is injected into the tool handler, allowing safe data operations.

// Example tool using the wrapper
export const getProjectContextTool = {
  name: "get_project_context",
  config: { /* ... */ },
  handler: withMcpProjectAuth(async (args, context) => {
    const projectContext = await ProjectContextService.getProjectContext(args.projectId);
    return mcpResponse({
      text: ProjectContextService.renderProjectContextMarkdown(projectContext),
      meta: buildProjectMeta(context, args.projectId, `/p/${args.projectId}/settings/context`),
      structuredContent: projectContext,
    });
  }),
};

Tools like get_project_context in src/server/mcp/tools/project-context.ts demonstrate how the authorization wrapper enables secure data access without duplicating security logic in each handler.

Step 5: Billing Alignment

The final stage ensures financial accountability. The buildBillingCustomer function (called within requireProjectAccess) creates a BillingCustomerContext mirroring the resolved organization and project. This guarantees that all MCP tool usage is correctly attributed to the right billing entity, preventing cross-customer charging errors.

Key Implementation Files

Understanding the file structure helps navigate the authorization stack:

Security Design Principles

OpenSEO's per-project authorization for MCP follows defense-in-depth principles:

  1. Single Source of Truth: All tools delegate to withMcpProjectAuth, eliminating authorization bypass risks from inconsistent checks
  2. Fail-Closed by Default: Any missing project or failed membership test results in immediate rejection
  3. Generic Error Messages: The system returns indistinguishable FORBIDDEN responses for missing projects versus unauthorized access, preventing information leakage about project existence
  4. Context Immutability: Once constructed, the McpProjectAuthContext is passed downstream as a read-only object, preventing tool logic from elevating privileges

Summary

  • Transport validation in transport.ts ensures all MCP requests carry proper OAuth scopes before processing begins
  • Dual credential support handles both API keys (orgScope: "user") and OAuth tokens (orgScope: "pinned") through a unified interface
  • Project-level enforcement via withMcpProjectAuth in project-auth.ts guarantees every tool call is verified against the specific project's organization
  • Billing integration ensures usage attribution aligns with the resolved project context
  • Fail-closed security prevents data access when authorization states are ambiguous or invalid

Frequently Asked Questions

How does OpenSEO prevent users from accessing projects outside their organization?

OpenSEO prevents cross-organization access through the requireProjectAccess function in project-auth.ts. For API key requests, it fetches the requested project and verifies the user belongs to that project's organization. For OAuth tokens, it validates that the project ID exists within the token's pinned organization. Both checks return identical FORBIDDEN errors to prevent project enumeration attacks.

What is the difference between "pinned" and "user" org scopes in MCP authorization?

The orgScope property distinguishes authentication methods: "pinned" indicates an OAuth token pre-authorized for a specific organization, while "user" indicates an API key requiring dynamic organization lookup. The authorization wrapper handles both cases transparently, resolving the correct organization context before allowing project access regardless of the credential type used.

Can MCP tools function without the withMcpProjectAuth wrapper?

While technically possible, bypassing withMcpProjectAuth would violate OpenSEO's security model by allowing unverified project access. The wrapper ensures consistent application of billing context, organizational membership verification, and audit logging. All production tools in the codebase use this wrapper to maintain security boundaries.

Where does the initial MCP scope validation occur?

The initial validation occurs in handleAuthenticatedOpenSeoMcpRequest within src/server/mcp/transport.ts. This function checks that the incoming request includes the MCP scope in its OAuth credentials before any tool routing occurs, providing the first layer of defense against unauthorized MCP access attempts.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →