Documentation Index Fetch the complete documentation index at: https://docs.agentfront.dev/llms.txt
Use this file to discover all available pages before exploring further.
Multi-tenant applications need to ensure users can only access data for their authorized sites or tenants. This guide shows you how to build a site-scoped authorization plugin that validates access before tool execution.
What You’ll Build
A site authorization plugin that:
Validates users can only access their authorized sites
Supports admin-only tools within sites
Works with tools that have a siteId input parameter
// plugins/site-authorization.types.ts
declare global {
interface ExtendFrontMcpToolMetadata {
site ?: {
/**
* Mark tool as site-scoped. If true, validates site access.
* Defaults to true if the tool input has a siteId field.
*/
siteScoped ?: boolean ;
/**
* Require admin privileges for this tool.
*/
adminRequired ?: boolean ;
/**
* Override the default field name for site ID.
* @ default ' siteId '
*/
siteIdFieldName ?: string ;
};
}
}
export interface SiteAuthorizationPluginOptions {
/**
* In demo mode, allow all requests if user has no site claims.
* @ default true
*/
demoAllowAllIfNoClaims ?: boolean ;
/**
* Default field name for site ID in tool input.
* @ default ' siteId '
*/
siteIdFieldName ?: string ;
}
Step 2: Create the Site Authorization Plugin
// plugins/site-authorization.plugin.ts
import { DynamicPlugin , Plugin , ToolHook , FlowCtxOf } from ' @frontmcp/sdk ' ;
import { SiteAuthorizationPluginOptions } from ' ./site-authorization.types ' ;
@ Plugin ({
name : ' site-authorization ' ,
description : ' Validates site access and admin requirements for site-scoped tools ' ,
})
export default class SiteAuthorizationPlugin extends DynamicPlugin < SiteAuthorizationPluginOptions > {
opts : SiteAuthorizationPluginOptions ;
constructor ( opts : SiteAuthorizationPluginOptions = {}) {
super ();
this . opts = {
demoAllowAllIfNoClaims : opts . demoAllowAllIfNoClaims ?? true ,
siteIdFieldName : opts . siteIdFieldName ?? ' siteId ' ,
};
}
/**
* Hook runs BEFORE tool execution (priority 900 = high).
* Validates site access before any tool logic runs.
*/
@ ToolHook . Will ( ' execute ' , { priority : 900 })
async validateSiteAccess ( flowCtx : FlowCtxOf < ' tools:call-tool ' >) {
const ctx = flowCtx . state . required . toolContext ;
const input : Record < string , unknown > = ctx . input ?? {};
const meta = ctx . metadata ;
// Determine the site ID field name
const siteField = meta . site ?. siteIdFieldName || this . opts . siteIdFieldName || ' siteId ' ;
const siteId = input [ siteField ] as string | undefined ;
// Determine if this is a site-scoped tool
const siteScoped = meta . site ?. siteScoped ?? ( siteId !== undefined );
const adminRequired = meta . site ?. adminRequired === true ;
// If not site-scoped, allow execution
if (! siteScoped ) {
return ;
}
// Validate site ID is provided
if (! siteId || typeof siteId !== ' string ' || siteId . length === 0 ) {
throw new Error ( ` Missing required ${ siteField } for site-scoped operation ` );
}
// Check user has access to this site
const allowedSites = this . getAllowedSites ( ctx . authInfo );
if ( allowedSites !== ' ALL ' && ! allowedSites . includes ( siteId )) {
throw new Error ( ` Not authorized for site ${ siteId } ` );
}
// Check admin requirement
if ( adminRequired && ! this . isAdmin ( ctx . authInfo )) {
throw new Error ( ' Admin privileges required ' );
}
}
/**
* Extract allowed sites from user claims.
* Returns 'ALL' if user has no restrictions.
*/
private getAllowedSites ( authInfo : unknown ): string [] | ' ALL ' {
const user = this . extractUser ( authInfo );
if (! user ) {
return this . opts . demoAllowAllIfNoClaims ? ' ALL ' : [];
}
// Check for sites or tenants claim
const sites = user . sites || user . tenants ;
if (! sites || ( Array . isArray ( sites ) && sites . length === 0 )) {
return this . opts . demoAllowAllIfNoClaims ? ' ALL ' : [];
}
if ( Array . isArray ( sites )) {
return sites . map ( String );
}
if ( typeof sites === ' string ' ) {
return [ sites ];
}
return [];
}
/**
* Check if user has admin privileges.
*/
private isAdmin ( authInfo : unknown ): boolean {
const user = this . extractUser ( authInfo );
if (! user ) {
return !! this . opts . demoAllowAllIfNoClaims ;
}
if ( user . isAdmin === true ) {
return true ;
}
const roles : string [] = Array . isArray ( user . roles ) ? user . roles : [];
return roles . includes ( ' admin ' ) || roles . includes ( ' owner ' ) || roles . includes ( ' superadmin ' );
}
/**
* Safely extract user object from authInfo.
*/
private extractUser ( authInfo : unknown ): Record < string , unknown > | null {
if (! authInfo || typeof authInfo !== ' object ' ) return null ;
const obj = authInfo as Record < string , unknown >;
if (! obj . user || typeof obj . user !== ' object ' ) return null ;
return obj . user as Record < string , unknown >;
}
}
Step 3: Register the Plugin
import { App } from ' @frontmcp/sdk ' ;
import SiteAuthorizationPlugin from ' ./plugins/site-authorization.plugin ' ;
import ListEmployeesTool from ' ./tools/list-employees.tool ' ;
import AdminAddEntryTool from ' ./tools/admin-add-entry.tool ' ;
@ App ({
id : ' employee-time ' ,
name : ' Employee Time Tracking ' ,
plugins : [
SiteAuthorizationPlugin ,
// or with options:
// SiteAuthorizationPlugin.init({
// demoAllowAllIfNoClaims: false,
// siteIdFieldName: 'tenantId',
// }),
],
tools : [ ListEmployeesTool , AdminAddEntryTool ],
})
export default class EmployeeTimeApp {}
Tools with a siteId input are automatically site-scoped:
import { Tool , ToolContext } from ' @frontmcp/sdk ' ;
import { z } from ' @frontmcp/sdk ' ;
@ Tool ({
name : ' list-employees ' ,
description : ' List employees for a site ' ,
inputSchema : {
siteId : z . string (). describe ( ' Site identifier ' ),
department : z . string (). optional (),
},
// site.siteScoped is inferred from siteId being present
})
export default class ListEmployeesTool extends ToolContext {
async execute ( input : { siteId : string ; department ?: string }) {
// Only runs if user has access to input.siteId
return { employees : [], siteId : input . siteId };
}
}
@ Tool ({
name : ' admin-add-entry ' ,
description : ' Admin: Add time entry for any employee ' ,
inputSchema : {
siteId : z . string (),
employeeId : z . string (),
hours : z . number (),
},
site : {
adminRequired : true , // Requires admin role
},
})
export default class AdminAddEntryTool extends ToolContext {
async execute ( input : { siteId : string ; employeeId : string ; hours : number }) {
// Only runs if user is admin AND has access to siteId
return { success : true };
}
}
Custom Site Field Name
@ Tool ({
name : ' list-locations ' ,
description : ' List locations for a tenant ' ,
inputSchema : {
tenantId : z . string (), // Using tenantId instead of siteId
},
site : {
siteIdFieldName : ' tenantId ' , // Override field name
},
})
export default class ListLocationsTool extends ToolContext {
async execute ( input : { tenantId : string }) {
return { locations : [] };
}
}
Explicit Site Scoping
Force site validation even without a siteId field:
@ Tool ({
name : ' get-global-settings ' ,
inputSchema : {
settingKey : z . string (),
},
site : {
siteScoped : false , // Explicitly disable site scoping
},
})
export default class GetGlobalSettingsTool extends ToolContext {
// This tool is NOT site-scoped
}
User Claims Structure
The plugin expects user claims in this format:
// In authInfo
{
user : {
id : ' user-123 ' ,
email : ' user@example.com ' ,
// Site access (either field works)
sites : [ ' site-A ' , ' site-B ' ],
// or
tenants : [ ' tenant-A ' , ' tenant-B ' ],
// Admin check (any of these)
isAdmin : true ,
// or
roles : [ ' admin ' ], // or 'owner', 'superadmin'
}
}
How It Works
Tool execution requested
Client calls a tool with input including siteId.
Will hook intercepts
Before execution, validateSiteAccess hook runs at priority 900.
Extract site ID
Plugin reads siteId from input (or custom field name).
Check user access
Plugin extracts user’s allowed sites from authInfo.user.sites or authInfo.user.tenants.
Validate access
If site isn’t in allowed list, throws error. If admin required, checks roles.
Execute or reject
Tool executes only if validation passes.
Combining with Role-Based Authorization
You can use both plugins together:
import { App } from ' @frontmcp/sdk ' ;
import AuthorizationPlugin from ' ./plugins/authorization.plugin ' ;
import SiteAuthorizationPlugin from ' ./plugins/site-authorization.plugin ' ;
@ App ({
id : ' enterprise-app ' ,
name : ' Enterprise App ' ,
plugins : [
// Role-based: filters tool list
AuthorizationPlugin ,
// Site-based: validates execution
SiteAuthorizationPlugin ,
],
tools : [ /* ... */ ],
})
export default class EnterpriseApp {}
This gives you:
Role-based filtering - Users only see tools for their roles
Site-based validation - Users can only access their authorized sites
Testing Site Authorization
import { test , expect } from ' @frontmcp/testing ' ;
test . use ({ server : ' ./src/main.ts ' });
test ( ' should allow access to authorized site ' , async ({ mcp }) => {
const result = await mcp . tools . call ( ' employee-time:list-employees ' , {
siteId : ' site-A ' ,
});
expect ( result ). toBeSuccessful ();
});
test ( ' should deny access to unauthorized site ' , async ({ mcp }) => {
const result = await mcp . tools . call ( ' employee-time:list-employees ' , {
siteId : ' site-C ' , // Not in user's sites
});
expect ( result ). not . toBeSuccessful ();
});
test ( ' should require admin for admin tools ' , async ({ mcp }) => {
const result = await mcp . tools . call ( ' employee-time:admin-add-entry ' , {
siteId : ' site-A ' ,
employeeId : ' emp-1 ' ,
hours : 8 ,
});
expect ( result ). not . toBeSuccessful ();
});
Best Practices
Always include siteId in tool inputs
The demoAllowAllIfNoClaims option is convenient for development but should be disabled in production: SiteAuthorizationPlugin . init ({
demoAllowAllIfNoClaims : process . env . NODE_ENV !== ' production ' ,
})
Log authorization failures
For security auditing, consider logging failed authorization attempts: if ( allowedSites !== ' ALL ' && ! allowedSites . includes ( siteId )) {
this . logger ?. warn ( ' Site access denied ' , { userId , siteId , allowedSites });
throw new Error ( ` Not authorized for site ${ siteId } ` );
}
Consider caching site access
For high-traffic applications, cache site access checks: private siteAccessCache = new Map < string , Set < string >>();
private getAllowedSites ( authInfo : unknown ) : string [] | ' ALL ' {
const userId = this . getUserId ( authInfo );
if ( this . siteAccessCache . has ( userId )) {
return [... this . siteAccessCache . get ( userId )!];
}
// ... fetch and cache
}
Next Steps
Role-Based Authorization Filter tools by user roles
Create a Plugin Full plugin development guide
Customize Flow Stages Learn more about hooks
Authentication Configure authentication providers