How Project-Level Authorization Works for MCP Tool Access in OpenSEO
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 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").
// 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. This wrapper:
- Accepts the tool-context auth (containing the organization ID) and the client-supplied
projectId - Calls
requireProjectAccessto verify project ownership viaProjectService.getProjectForOrganization - Throws
AppError("FORBIDDEN")if validation fails - Returns a rich context object with
auth,billing, andprojecton success
// 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 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.
// 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 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:
// 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 |
Extract organizationId from token; validate projectId exists for org |
| MCP wrapper | 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— 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— ProvideswithMcpProjectAuthwrapper andrequireProjectAccessvalidation【/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— 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— User-facing documentation of the connection flow
Summary
OpenSEO's project-level authorization for MCP tools operates through three coordinated mechanisms:
ensureUserMiddlewarevalidates the organization-project pair before any handler executeswithMcpProjectAuthwraps every MCP tool to enforce re-validation and provide authorized context- Tool implementations receive a guaranteed-valid
context.projectthat 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.
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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →