Skip to content

Commit 029a414

Browse files
authored
feat(mcp): add server FGA authorization overrides (#17529)
1 parent 983aa20 commit 029a414

6 files changed

Lines changed: 255 additions & 4 deletions

File tree

.changeset/lazy-hornets-hide.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@mastra/mcp': minor
3+
'@mastra/core': minor
4+
---
5+
6+
Added MCP server Fine-Grained Authorization mapping overrides for tool authorization.
7+
8+
Use the new `fga` option on `MCPServer` to customize the resource and permission mappings used for `tools/list` and `tools/call` checks without changing the Mastra instance-level `tool` mapping used by internal agent and workflow tool execution.
9+
10+
```ts
11+
const server = new MCPServer({
12+
name: 'My Server',
13+
version: '1.0.0',
14+
tools: { getData },
15+
fga: {
16+
resourceMapping: {
17+
tool: {
18+
fgaResourceType: 'user',
19+
deriveId: ({ user }) => user.id,
20+
},
21+
},
22+
permissionMapping: {
23+
'tools:execute': 'read',
24+
},
25+
},
26+
});
27+
```

docs/src/content/en/docs/server/auth/fga.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,12 +219,12 @@ When an FGA provider is configured, Mastra automatically checks authorization at
219219
| Built-in workflow HTTP execution routes and `Workflow.execute()` | `workflows:execute` | `workflow` | `workflowId` |
220220
| Standalone tool execution | `tools:execute` | `tool` | `toolName` |
221221
| Agent tool execution | `tools:execute` | `tool` | `${agentId}:${toolName}` |
222-
| MCP tool execution | `tools:execute` | `tool` | `JSON.stringify([serverName, toolName])` |
222+
| MCP tool execution | `tools:execute` | `tool` by default, or the server-level `fga.resourceMapping` override | `JSON.stringify([serverName, toolName])` by default, or the server-level derived ID |
223223
| Thread and memory access | `memory:read`, `memory:write`, `memory:delete` | `thread` | `threadId` |
224224
| Stored resource routes | Stored resource permission for the route action | Stored resource type | Route record ID, or the stored-resource scope for collection routes |
225225
| HTTP resource routes | Configured per route | Configured per route | Configured per route |
226226

227-
For OAuth-protected MCP servers, HTTP MCP transports pass authenticated data as `extra.authInfo`. If an `MCPServer` is registered on an FGA-enabled Mastra instance, configure `mapAuthInfoToUser` so Mastra can set `requestContext.get('user')` before checking `tools/list` and `tools/call`. See [MCPServer authentication context](/reference/tools/mcp-server#map-auth-data-for-fga).
227+
For OAuth-protected MCP servers, HTTP MCP transports pass authenticated data as `extra.authInfo`. If an `MCPServer` is registered on an FGA-enabled Mastra instance, configure `mapAuthInfoToUser` so Mastra can set `requestContext.get('user')` before checking `tools/list` and `tools/call`. Use the server-level `fga` option when MCP tool checks need different resource or permission mapping than internal agent and workflow tool checks. See [MCPServer authentication context](/reference/tools/mcp-server#map-auth-data-for-fga).
228228

229229
Direct SDK calls to `createRun().start()`, `resume()`, or `restart()` are not independently checked by core FGA in this release. Make those calls from a protected route or guard them in application code. Pass a `requestContext` with an authenticated user when invoking protected entry points directly.
230230

docs/src/content/en/reference/tools/mcp-server.mdx

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,13 @@ The constructor accepts an `MCPServerConfig` object with the following propertie
120120
description:
121121
'Maps MCP transport auth data from `extra.authInfo` into the `user` value used by Mastra FGA checks. Use this when an OAuth-protected MCP server is registered on a Mastra instance with an FGA provider.',
122122
},
123+
{
124+
name: 'fga',
125+
type: "{ resourceMapping?: Partial<Record<'tool' | 'tools', { fgaResourceType: string; deriveId?: ({ user, resourceId, requestContext }) => string | undefined }>>; permissionMapping?: Record<string, string> }",
126+
isOptional: true,
127+
description:
128+
"Overrides resource and permission mappings for this MCP server's `tools/list` and `tools/call` FGA checks. Use this when MCP authorization should be scoped differently from internal agent or workflow tool execution.",
129+
},
123130
{
124131
name: 'repository',
125132
type: 'Repository', // { url: string; source: string; id: string; }
@@ -1362,6 +1369,49 @@ const server = new MCPServer({
13621369
})
13631370
```
13641371

1372+
### Scope MCP tool FGA separately
1373+
1374+
Use `fga.resourceMapping` and `fga.permissionMapping` when MCP clients need a different authorization scope than internal agent or workflow tool execution. The override applies only to `tools/list` and `tools/call` checks for this MCP server.
1375+
1376+
```typescript
1377+
import { MastraFGAPermissions } from '@mastra/core/auth/ee'
1378+
1379+
const server = new MCPServer({
1380+
id: 'my-server',
1381+
name: 'My Server',
1382+
version: '1.0.0',
1383+
tools: { getUserData },
1384+
mapAuthInfoToUser: ({ authInfo }) => {
1385+
const user = authInfo as {
1386+
extra?: {
1387+
userId?: string
1388+
organizationMembershipId?: string
1389+
}
1390+
}
1391+
1392+
if (!user.extra?.userId) {
1393+
return null
1394+
}
1395+
1396+
return {
1397+
id: user.extra.userId,
1398+
organizationMembershipId: user.extra.organizationMembershipId,
1399+
}
1400+
},
1401+
fga: {
1402+
resourceMapping: {
1403+
tool: {
1404+
fgaResourceType: 'user',
1405+
deriveId: ({ user }) => (user as { id: string }).id,
1406+
},
1407+
},
1408+
permissionMapping: {
1409+
[MastraFGAPermissions.TOOLS_EXECUTE]: 'read',
1410+
},
1411+
},
1412+
})
1413+
```
1414+
13651415
### Setting Up Authentication Middleware
13661416

13671417
To pass data to your tools, populate `req.auth` on the Node.js request object in your HTTP server middleware before calling `server.startHTTP()`.

packages/core/src/mcp/types.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type * as http from 'node:http';
22
import type { ToolsInput, Agent } from '../agent';
3+
import type { MastraFGAPermissionInput } from '../auth/ee/interfaces/permissions.generated';
34
import type { RequestContext } from '../request-context';
45
import type { Workflow } from '../workflows';
56

@@ -214,6 +215,31 @@ export type MCPAuthInfoToUserMapper<TUser = unknown> = (args: {
214215
requestContext: RequestContext;
215216
}) => TUser | null | undefined | Promise<TUser | null | undefined>;
216217

218+
export interface MCPServerFGAResourceMappingEntry {
219+
/** The FGA resource type to authorize MCP tool checks against. */
220+
fgaResourceType: string;
221+
/**
222+
* Derive the FGA resource ID from the MCP request/user context.
223+
* Return `undefined` to fall back to the MCP tool resource ID.
224+
*/
225+
deriveId?: (ctx: { user: unknown; resourceId?: string; requestContext?: RequestContext }) => string | undefined;
226+
}
227+
228+
export type MCPServerFGAPermissionMapping = Partial<Record<MastraFGAPermissionInput, string>> & Record<string, string>;
229+
230+
export interface MCPServerFGAConfig {
231+
/**
232+
* Map MCP server tool authorization resources independently from the Mastra
233+
* instance's global `tool` resource mapping.
234+
*/
235+
resourceMapping?: Partial<Record<'tool' | 'tools', MCPServerFGAResourceMappingEntry>>;
236+
/**
237+
* Map MCP server tool authorization permissions independently from the Mastra
238+
* instance's global permission mapping.
239+
*/
240+
permissionMapping?: MCPServerFGAPermissionMapping;
241+
}
242+
217243
// +++ Authoritative MCPServerConfig +++
218244
/** Configuration options for creating an MCPServer instance. */
219245
export interface MCPServerConfig<TId extends string = string> {
@@ -253,6 +279,13 @@ export interface MCPServerConfig<TId extends string = string> {
253279
* already put a `user` value in the request context.
254280
*/
255281
mapAuthInfoToUser?: MCPAuthInfoToUserMapper;
282+
/**
283+
* Optional FGA mapping overrides for this MCP server's `tools/list` and
284+
* `tools/call` checks. These mappings are applied before delegating to the
285+
* Mastra instance FGA provider and do not affect internal agent/workflow tool
286+
* execution.
287+
*/
288+
fga?: MCPServerFGAConfig;
256289
/** Optional repository information for the server's source code. */
257290
repository?: Repository;
258291
/**

packages/mcp/src/server/__tests__/server-fga.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,4 +363,106 @@ describe('MCP Server FGA checks', () => {
363363
}),
364364
);
365365
});
366+
367+
it('should use server FGA mapping overrides when filtering tools/list', async () => {
368+
const deriveId = vi.fn(({ user }) => user.id);
369+
mcpServer = new MCPServer({
370+
name: 'test-server',
371+
version: '1.0.0',
372+
tools: {
373+
'test-tool': createTool({
374+
id: 'test-tool',
375+
description: 'A test tool',
376+
inputSchema: z.object({}),
377+
execute: vi.fn(),
378+
}),
379+
},
380+
fga: {
381+
resourceMapping: {
382+
tool: {
383+
fgaResourceType: 'mcp-user',
384+
deriveId,
385+
},
386+
},
387+
permissionMapping: {
388+
[MastraFGAPermissions.TOOLS_EXECUTE]: 'read',
389+
},
390+
},
391+
});
392+
const mockFGAProvider = {
393+
check: vi.fn(),
394+
require: vi.fn(),
395+
filterAccessible: vi.fn(),
396+
};
397+
mcpServer.__registerMastra(createMockMastra(mockFGAProvider) as any);
398+
399+
const requestContext = createRequestContext({ id: 'user-1' });
400+
const result = await mcpServer.getToolListInfo(requestContext as any);
401+
402+
expect(result.tools.map(tool => tool.name)).toEqual(['test-tool']);
403+
expect(deriveId).toHaveBeenCalledWith({
404+
user: { id: 'user-1' },
405+
resourceId: JSON.stringify([mcpServer.getServerInfo().id, 'test-tool']),
406+
requestContext,
407+
});
408+
expect(mockFGAProvider.require).toHaveBeenCalledWith(
409+
{ id: 'user-1' },
410+
expect.objectContaining({
411+
resource: { type: 'mcp-user', id: 'user-1' },
412+
permission: 'read',
413+
}),
414+
);
415+
});
416+
417+
it('should use server FGA mapping overrides when enforcing tools/call', async () => {
418+
const execute = vi.fn().mockResolvedValue({ output: 'success' });
419+
const deriveId = vi.fn(({ user }) => user.id);
420+
mcpServer = new MCPServer({
421+
name: 'test-server',
422+
version: '1.0.0',
423+
tools: {
424+
'test-tool': createTool({
425+
id: 'test-tool',
426+
description: 'A test tool',
427+
inputSchema: z.object({ input: z.string() }),
428+
execute,
429+
}),
430+
},
431+
fga: {
432+
resourceMapping: {
433+
tool: {
434+
fgaResourceType: 'mcp-user',
435+
deriveId,
436+
},
437+
},
438+
permissionMapping: {
439+
[MastraFGAPermissions.TOOLS_EXECUTE]: 'read',
440+
},
441+
},
442+
});
443+
const mockFGAProvider = {
444+
check: vi.fn(),
445+
require: vi.fn(),
446+
filterAccessible: vi.fn(),
447+
};
448+
mcpServer.__registerMastra(createMockMastra(mockFGAProvider) as any);
449+
450+
const requestContext = createRequestContext({ id: 'user-1' });
451+
452+
await mcpServer.executeTool('test-tool', { input: 'hello' }, { requestContext: requestContext as any });
453+
454+
expect(execute).toHaveBeenCalledTimes(1);
455+
expect(deriveId).toHaveBeenCalledWith({
456+
user: { id: 'user-1' },
457+
resourceId: JSON.stringify([mcpServer.getServerInfo().id, 'test-tool']),
458+
requestContext,
459+
});
460+
expect(mockFGAProvider.require).toHaveBeenCalledWith(
461+
{ id: 'user-1' },
462+
expect.objectContaining({
463+
resource: { type: 'mcp-user', id: 'user-1' },
464+
permission: 'read',
465+
}),
466+
);
467+
});
366468
});

packages/mcp/src/server/server.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { ErrorCategory, ErrorDomain, MastraError } from '@mastra/core/error';
66
import { MCPServerBase } from '@mastra/core/mcp';
77
import type {
88
MCPAuthInfoToUserMapper,
9+
MCPServerFGAConfig,
910
MCPServerConfig,
1011
ServerInfo,
1112
ServerDetailInfo,
@@ -104,6 +105,7 @@ export class MCPServer extends MCPServerBase {
104105
private promptOptions?: MCPServerPrompts;
105106
private jsonSchemaValidator?: jsonSchemaValidator;
106107
private mapAuthInfoToUser?: MCPAuthInfoToUserMapper;
108+
private fga?: MCPServerFGAConfig;
107109
private subscriptions: Set<string> = new Set();
108110
private currentLoggingLevel: LoggingLevel | undefined;
109111

@@ -312,6 +314,7 @@ export class MCPServer extends MCPServerBase {
312314
this.promptOptions = opts.prompts;
313315
this.jsonSchemaValidator = opts.jsonSchemaValidator;
314316
this.mapAuthInfoToUser = opts.mapAuthInfoToUser;
317+
this.fga = opts.fga;
315318

316319
const capabilities: ServerCapabilities = {
317320
tools: {},
@@ -2266,12 +2269,18 @@ export class MCPServer extends MCPServerBase {
22662269
if (!user) {
22672270
throw new FGADeniedError({ id: 'unknown' }, { type: 'tool', id: resourceId }, MastraFGAPermissions.TOOLS_EXECUTE);
22682271
}
2272+
const { resource, permission } = this.resolveToolFGAParams({
2273+
user,
2274+
resourceId,
2275+
requestContext,
2276+
permission: MastraFGAPermissions.TOOLS_EXECUTE,
2277+
});
22692278

22702279
await requireFGA({
22712280
fgaProvider,
22722281
user,
2273-
resource: { type: 'tool', id: resourceId },
2274-
permission: MastraFGAPermissions.TOOLS_EXECUTE,
2282+
resource,
2283+
permission,
22752284
requestContext,
22762285
context: {
22772286
resourceId,
@@ -2284,6 +2293,36 @@ export class MCPServer extends MCPServerBase {
22842293
});
22852294
}
22862295

2296+
private resolveToolFGAParams({
2297+
user,
2298+
resourceId,
2299+
requestContext,
2300+
permission,
2301+
}: {
2302+
user: unknown;
2303+
resourceId: string;
2304+
requestContext?: RequestContext;
2305+
permission: string;
2306+
}): { resource: { type: string; id: string }; permission: string } {
2307+
const mappedPermission = this.fga?.permissionMapping?.[permission] ?? permission;
2308+
const resourceMapping = this.fga?.resourceMapping?.tool ?? this.fga?.resourceMapping?.tools;
2309+
2310+
if (!resourceMapping) {
2311+
return {
2312+
resource: { type: 'tool', id: resourceId },
2313+
permission: mappedPermission,
2314+
};
2315+
}
2316+
2317+
return {
2318+
resource: {
2319+
type: resourceMapping.fgaResourceType,
2320+
id: resourceMapping.deriveId?.({ user, resourceId, requestContext }) ?? resourceId,
2321+
},
2322+
permission: mappedPermission,
2323+
};
2324+
}
2325+
22872326
/**
22882327
* Executes a specific tool provided by this MCP server.
22892328
*

0 commit comments

Comments
 (0)