# How Project-Level Authorization Works for MCP Tool Access in OpenSEO

> Learn how OpenSEO enforces project-level authorization for MCP tool access. Discover the middleware chain that validates organization-project pairs and secures tool operations.

- Repository: [Every App/open-seo](https://github.com/every-app/open-seo)
- Tags: internals
- Published: 2026-08-10

---

**OpenSEO enforces project-level authorization for every MCP tool call through a layered chain: `ensureUserMiddleware` validates the organization-project pair, then `withMcpProjectAuth` wraps each handler to guarantee the tool only operates on authorized projects.**

The OpenSEO platform uses a strict, multi-layer authorization system to ensure that Model Context Protocol (MCP) tools can only access projects belonging to the authenticated organization. This article breaks down exactly how the `every-app/open-seo` repository implements this project-level security model.

## Authentication Flow: From Bearer Token to Validated Project

Every MCP request begins with **user authentication and project extraction** in the middleware layer.

The `ensureUserMiddleware` in [`src/middleware/ensureUser.ts`](https://github.com/every-app/open-seo/blob/main/src/middleware/ensureUser.ts) runs on every incoming request. It extracts the **organization ID** from the Bearer token via `resolveUserContextFromHeaders`, then—if the request includes a `projectId`—looks up that project using `ProjectRepository.getProjectForOrganization`. If the project doesn't exist or doesn't belong to the organization, the middleware throws `AppError("NOT_FOUND")`.

```typescript
// src/middleware/ensureUser.ts (simplified excerpt)
const userContext = await resolveUserContextFromHeaders(headers);
const organizationId = userContext.organizationId;

if (projectId) {
  const project = await ProjectRepository.getProjectForOrganization(
    projectId,
    organizationId
  );
  if (!project) throw new AppError("NOT_FOUND");
}

```

This guarantees that **no server function runs without a validated organization-project pair**.

## The MCP Wrapper: Enforcing Authorization at the Tool Level

While middleware provides initial validation, MCP tools use a dedicated wrapper for stricter enforcement.

### `withMcpProjectAuth` Implementation

Every MCP tool file imports `withMcpProjectAuth` from [`src/server/mcp/project-auth.ts`](https://github.com/every-app/open-seo/blob/main/src/server/mcp/project-auth.ts). This wrapper:

1. Accepts the **tool-context auth** (containing the organization ID) and the client-supplied `projectId`
2. Calls `requireProjectAccess` to verify project ownership via `ProjectService.getProjectForOrganization`
3. Throws `AppError("FORBIDDEN")` if validation fails
4. Returns a rich context object with `auth`, `billing`, and `project` on success

```typescript
// src/server/mcp/project-auth.ts
export function withMcpProjectAuth<TArgs, TReturn>(
  handler: (args: TArgs, context: McpProjectContext) => Promise<TReturn>
) {
  return async (args: TArgs, toolAuth: ToolAuthContext) => {
    const context = await requireProjectAccess(toolAuth, args.projectId);
    // context = { auth, billingCustomerBuilder, project }
    return handler(args, context);
  };
}

async function requireProjectAccess(toolAuth: ToolAuthContext, projectId: string) {
  const project = await ProjectService.getProjectForOrganization(
    projectId,
    toolAuth.organizationId
  );
  if (!project) throw new AppError("FORBIDDEN");
  return { auth: toolAuth, billingCustomerBuilder: ..., project };
}

```

Source: [`src/server/mcp/project-auth.ts`](https://github.com/every-app/open-seo/blob/main/src/server/mcp/project-auth.ts) lines 9-25 and 38-48【/cache/repos/github.com/every-app/open-seo/main/src/server/mcp/project-auth.ts#L9-L25】【/cache/repos/github.com/every-app/open-seo/main/src/server/mcp/project-auth.ts#L38-L48】

## Tool Implementation Pattern

Each MCP tool is implemented as a plain function wrapped by `withMcpProjectAuth`, receiving both tool-specific arguments and the authenticated project context.

```typescript
// src/server/mcp/tools/get-domain-overview.ts
import { withMcpProjectAuth } from "@/server/mcp/project-auth";

export const getDomainOverview = {
  name: "get-domain-overview",
  description: "Retrieve SEO metrics for a domain",
  parameters: { ... },
  handler: withMcpProjectAuth(async (args, context) => {
    // context.project is guaranteed to belong to context.auth.organizationId
    const { domain } = args;
    return await seoService.getDomainMetrics(domain, context.project);
  })
};

```

This pattern ensures **no tool can be invoked without valid project authorization**. The wrapper prevents accidental ID spoofing by binding the validated project record directly to the handler's execution context.

## Client Connection Flow

The authorization chain depends on proper token acquisition during client setup.

When connecting an MCP client (Claude, Cursor, Codex, etc.), users complete OpenSEO's login flow. The resulting access token encodes the **organization ID** and approved scopes. Clients include this token in every request and supply the target `projectId`, which the server validates through the middleware-to-wrapper chain.

> "The first connection sends you through OpenSEO login. After authorization, your MCP client can call OpenSEO tools with the project context and account scopes you approved."

Source: [`web/content/docs/mcp.md`](https://github.com/every-app/open-seo/blob/main/web/content/docs/mcp.md) lines 14-15【/cache/repos/github.com/every-app/open-seo/main/web/content/docs/mcp.md#L14-L15】

## Complete Example: Research Keywords Tool

Here's how a fully implemented tool combines authorization with business logic:

```typescript
// src/server/mcp/tools/research-keywords.ts
import { withMcpProjectAuth } from "@/server/mcp/project-auth";

export const researchKeywords = {
  name: "research-keywords",
  parameters: {
    projectId: { type: "string", required: true },
    keyword: { type: "string", required: true }
  },
  handler: withMcpProjectAuth(async (args, ctx) => {
    // ctx.project is trusted and belongs to ctx.auth.organizationId
    const { keyword } = args;
    
    // Billing check via ctx.billingCustomerBuilder
    await ctx.billingCustomerBuilder.checkCredits("keyword_research");
    
    // Call external API with project-scoped configuration
    return await dataForSeoService.getKeywordMetrics(keyword, ctx.project);
  })
};

```

## Authorization Layers at a Glance

| Layer | File/Function | Responsibility |
|-------|-------------|----------------|
| Request middleware | [`src/middleware/ensureUser.ts`](https://github.com/every-app/open-seo/blob/main/src/middleware/ensureUser.ts) | Extract `organizationId` from token; validate `projectId` exists for org |
| MCP wrapper | [`src/server/mcp/project-auth.ts`](https://github.com/every-app/open-seo/blob/main/src/server/mcp/project-auth.ts) | Re-validate project access; build rich context for handlers |
| Tool handler | `src/server/mcp/tools/*.ts` | Execute business logic using authorized `context.project` |

## Key Files Reference

- **[`src/middleware/ensureUser.ts`](https://github.com/every-app/open-seo/blob/main/src/middleware/ensureUser.ts)** — Validates organization and project on every request【/cache/repos/github.com/every-app/open-seo/main/src/middleware/ensureUser.ts#L28-L34】
- **[`src/server/mcp/project-auth.ts`](https://github.com/every-app/open-seo/blob/main/src/server/mcp/project-auth.ts)** — Provides `withMcpProjectAuth` wrapper and `requireProjectAccess` validation【/cache/repos/github.com/every-app/open-seo/main/src/server/mcp/project-auth.ts#L9-L25】【/cache/repos/github.com/every-app/open-seo/main/src/server/mcp/project-auth.ts#L38-L48】
- **[`src/server/mcp/tools/get-domain-overview.ts`](https://github.com/every-app/open-seo/blob/main/src/server/mcp/tools/get-domain-overview.ts)** — Example tool using the authorization wrapper【/cache/repos/github.com/every-app/open-seo/main/src/server/mcp/tools/get-domain-overview.ts#L6-L12】
- **[`web/content/docs/mcp.md`](https://github.com/every-app/open-seo/blob/main/web/content/docs/mcp.md)** — User-facing documentation of the connection flow

## Summary

OpenSEO's project-level authorization for MCP tools operates through three coordinated mechanisms:

- **`ensureUserMiddleware`** validates the organization-project pair before any handler executes
- **`withMcpProjectAuth`** wraps every MCP tool to enforce re-validation and provide authorized context
- **Tool implementations** receive a guaranteed-valid `context.project` that cannot be spoofed by clients

This design ensures that MCP tools in the `every-app/open-seo` repository operate exclusively on projects the authenticated organization owns, with clear error boundaries at each stage of the request lifecycle.

## Frequently Asked Questions

### What happens if I call an MCP tool with an invalid projectId?

The server throws `AppError("NOT_FOUND")` in the middleware if the project doesn't exist, or `AppError("FORBIDDEN")` from `requireProjectAccess` if the project belongs to a different organization. Both errors prevent the tool handler from executing.

### Can an MCP tool access projects from multiple organizations in one call?

No. Each MCP request carries a single access token encoding one organization ID. The `withMcpProjectAuth` wrapper validates that the supplied `projectId` belongs specifically to that organization, enforcing strict project-level isolation.

### How does billing work with project-level authorization?

The context built by `requireProjectAccess` includes a `billingCustomerBuilder` tied to the authenticated organization. Tool handlers use this to check credits and record usage against the correct billing entity, ensuring costs are attributed to the right account.