Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/lazy-hornets-hide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@mastra/mcp': minor
'@mastra/core': minor
---

Added MCP server Fine-Grained Authorization mapping overrides for tool authorization.

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.

```ts
const server = new MCPServer({
name: 'My Server',
version: '1.0.0',
tools: { getData },
fga: {
resourceMapping: {
tool: {
fgaResourceType: 'user',
deriveId: ({ user }) => user.id,
},
},
permissionMapping: {
'tools:execute': 'read',
},
},
});
```
4 changes: 2 additions & 2 deletions docs/src/content/en/docs/server/auth/fga.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -219,12 +219,12 @@ When an FGA provider is configured, Mastra automatically checks authorization at
| Built-in workflow HTTP execution routes and `Workflow.execute()` | `workflows:execute` | `workflow` | `workflowId` |
| Standalone tool execution | `tools:execute` | `tool` | `toolName` |
| Agent tool execution | `tools:execute` | `tool` | `${agentId}:${toolName}` |
| MCP tool execution | `tools:execute` | `tool` | `JSON.stringify([serverName, toolName])` |
| 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 |
| Thread and memory access | `memory:read`, `memory:write`, `memory:delete` | `thread` | `threadId` |
| Stored resource routes | Stored resource permission for the route action | Stored resource type | Route record ID, or the stored-resource scope for collection routes |
| HTTP resource routes | Configured per route | Configured per route | Configured per route |

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).
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).

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.

Expand Down
50 changes: 50 additions & 0 deletions docs/src/content/en/reference/tools/mcp-server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,13 @@ The constructor accepts an `MCPServerConfig` object with the following propertie
description:
'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.',
},
{
name: 'fga',
type: "{ resourceMapping?: Partial<Record<'tool' | 'tools', { fgaResourceType: string; deriveId?: ({ user, resourceId, requestContext }) => string | undefined }>>; permissionMapping?: Record<string, string> }",
isOptional: true,
description:
"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.",
},
{
name: 'repository',
type: 'Repository', // { url: string; source: string; id: string; }
Expand Down Expand Up @@ -1362,6 +1369,49 @@ const server = new MCPServer({
})
```

### Scope MCP tool FGA separately

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.

```typescript
import { MastraFGAPermissions } from '@mastra/core/auth/ee'

const server = new MCPServer({
id: 'my-server',
name: 'My Server',
version: '1.0.0',
tools: { getUserData },
mapAuthInfoToUser: ({ authInfo }) => {
const user = authInfo as {
extra?: {
userId?: string
organizationMembershipId?: string
}
}

if (!user.extra?.userId) {
return null
}

return {
id: user.extra.userId,
organizationMembershipId: user.extra.organizationMembershipId,
}
},
fga: {
resourceMapping: {
tool: {
fgaResourceType: 'user',
deriveId: ({ user }) => (user as { id: string }).id,
},
},
permissionMapping: {
[MastraFGAPermissions.TOOLS_EXECUTE]: 'read',
},
},
})
```

### Setting Up Authentication Middleware

To pass data to your tools, populate `req.auth` on the Node.js request object in your HTTP server middleware before calling `server.startHTTP()`.
Expand Down
33 changes: 33 additions & 0 deletions packages/core/src/mcp/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type * as http from 'node:http';
import type { ToolsInput, Agent } from '../agent';
import type { MastraFGAPermissionInput } from '../auth/ee/interfaces/permissions.generated';
import type { RequestContext } from '../request-context';
import type { Workflow } from '../workflows';

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

export interface MCPServerFGAResourceMappingEntry {
/** The FGA resource type to authorize MCP tool checks against. */
fgaResourceType: string;
/**
* Derive the FGA resource ID from the MCP request/user context.
* Return `undefined` to fall back to the MCP tool resource ID.
*/
deriveId?: (ctx: { user: unknown; resourceId?: string; requestContext?: RequestContext }) => string | undefined;
}

export type MCPServerFGAPermissionMapping = Partial<Record<MastraFGAPermissionInput, string>> & Record<string, string>;

export interface MCPServerFGAConfig {
/**
* Map MCP server tool authorization resources independently from the Mastra
* instance's global `tool` resource mapping.
*/
resourceMapping?: Partial<Record<'tool' | 'tools', MCPServerFGAResourceMappingEntry>>;
/**
* Map MCP server tool authorization permissions independently from the Mastra
* instance's global permission mapping.
*/
permissionMapping?: MCPServerFGAPermissionMapping;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// +++ Authoritative MCPServerConfig +++
/** Configuration options for creating an MCPServer instance. */
export interface MCPServerConfig<TId extends string = string> {
Expand Down Expand Up @@ -253,6 +279,13 @@ export interface MCPServerConfig<TId extends string = string> {
* already put a `user` value in the request context.
*/
mapAuthInfoToUser?: MCPAuthInfoToUserMapper;
/**
* Optional FGA mapping overrides for this MCP server's `tools/list` and
* `tools/call` checks. These mappings are applied before delegating to the
* Mastra instance FGA provider and do not affect internal agent/workflow tool
* execution.
*/
fga?: MCPServerFGAConfig;
/** Optional repository information for the server's source code. */
repository?: Repository;
/**
Expand Down
102 changes: 102 additions & 0 deletions packages/mcp/src/server/__tests__/server-fga.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,4 +363,106 @@ describe('MCP Server FGA checks', () => {
}),
);
});

it('should use server FGA mapping overrides when filtering tools/list', async () => {
const deriveId = vi.fn(({ user }) => user.id);
mcpServer = new MCPServer({
name: 'test-server',
version: '1.0.0',
tools: {
'test-tool': createTool({
id: 'test-tool',
description: 'A test tool',
inputSchema: z.object({}),
execute: vi.fn(),
}),
},
fga: {
resourceMapping: {
tool: {
fgaResourceType: 'mcp-user',
deriveId,
},
},
permissionMapping: {
[MastraFGAPermissions.TOOLS_EXECUTE]: 'read',
},
},
});
const mockFGAProvider = {
check: vi.fn(),
require: vi.fn(),
filterAccessible: vi.fn(),
};
mcpServer.__registerMastra(createMockMastra(mockFGAProvider) as any);

const requestContext = createRequestContext({ id: 'user-1' });
const result = await mcpServer.getToolListInfo(requestContext as any);

expect(result.tools.map(tool => tool.name)).toEqual(['test-tool']);
expect(deriveId).toHaveBeenCalledWith({
user: { id: 'user-1' },
resourceId: JSON.stringify([mcpServer.getServerInfo().id, 'test-tool']),
requestContext,
});
expect(mockFGAProvider.require).toHaveBeenCalledWith(
{ id: 'user-1' },
expect.objectContaining({
resource: { type: 'mcp-user', id: 'user-1' },
permission: 'read',
}),
);
});

it('should use server FGA mapping overrides when enforcing tools/call', async () => {
const execute = vi.fn().mockResolvedValue({ output: 'success' });
const deriveId = vi.fn(({ user }) => user.id);
mcpServer = new MCPServer({
name: 'test-server',
version: '1.0.0',
tools: {
'test-tool': createTool({
id: 'test-tool',
description: 'A test tool',
inputSchema: z.object({ input: z.string() }),
execute,
}),
},
fga: {
resourceMapping: {
tool: {
fgaResourceType: 'mcp-user',
deriveId,
},
},
permissionMapping: {
[MastraFGAPermissions.TOOLS_EXECUTE]: 'read',
},
},
});
const mockFGAProvider = {
check: vi.fn(),
require: vi.fn(),
filterAccessible: vi.fn(),
};
mcpServer.__registerMastra(createMockMastra(mockFGAProvider) as any);

const requestContext = createRequestContext({ id: 'user-1' });

await mcpServer.executeTool('test-tool', { input: 'hello' }, { requestContext: requestContext as any });

expect(execute).toHaveBeenCalledTimes(1);
expect(deriveId).toHaveBeenCalledWith({
user: { id: 'user-1' },
resourceId: JSON.stringify([mcpServer.getServerInfo().id, 'test-tool']),
requestContext,
});
expect(mockFGAProvider.require).toHaveBeenCalledWith(
{ id: 'user-1' },
expect.objectContaining({
resource: { type: 'mcp-user', id: 'user-1' },
permission: 'read',
}),
);
});
});
43 changes: 41 additions & 2 deletions packages/mcp/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ErrorCategory, ErrorDomain, MastraError } from '@mastra/core/error';
import { MCPServerBase } from '@mastra/core/mcp';
import type {
MCPAuthInfoToUserMapper,
MCPServerFGAConfig,
MCPServerConfig,
ServerInfo,
ServerDetailInfo,
Expand Down Expand Up @@ -103,6 +104,7 @@ export class MCPServer extends MCPServerBase {
private promptOptions?: MCPServerPrompts;
private jsonSchemaValidator?: jsonSchemaValidator;
private mapAuthInfoToUser?: MCPAuthInfoToUserMapper;
private fga?: MCPServerFGAConfig;
private subscriptions: Set<string> = new Set();
private currentLoggingLevel: LoggingLevel | undefined;

Expand Down Expand Up @@ -307,6 +309,7 @@ export class MCPServer extends MCPServerBase {
this.promptOptions = opts.prompts;
this.jsonSchemaValidator = opts.jsonSchemaValidator;
this.mapAuthInfoToUser = opts.mapAuthInfoToUser;
this.fga = opts.fga;

const capabilities: ServerCapabilities = {
tools: {},
Expand Down Expand Up @@ -2274,12 +2277,18 @@ export class MCPServer extends MCPServerBase {
if (!user) {
throw new FGADeniedError({ id: 'unknown' }, { type: 'tool', id: resourceId }, MastraFGAPermissions.TOOLS_EXECUTE);
}
const { resource, permission } = this.resolveToolFGAParams({
user,
resourceId,
requestContext,
permission: MastraFGAPermissions.TOOLS_EXECUTE,
});

await requireFGA({
fgaProvider,
user,
resource: { type: 'tool', id: resourceId },
permission: MastraFGAPermissions.TOOLS_EXECUTE,
resource,
permission,
requestContext,
context: {
resourceId,
Expand All @@ -2292,6 +2301,36 @@ export class MCPServer extends MCPServerBase {
});
}

private resolveToolFGAParams({
user,
resourceId,
requestContext,
permission,
}: {
user: unknown;
resourceId: string;
requestContext?: RequestContext;
permission: string;
}): { resource: { type: string; id: string }; permission: string } {
const mappedPermission = this.fga?.permissionMapping?.[permission] ?? permission;
const resourceMapping = this.fga?.resourceMapping?.tool ?? this.fga?.resourceMapping?.tools;

if (!resourceMapping) {
return {
resource: { type: 'tool', id: resourceId },
permission: mappedPermission,
};
}

return {
resource: {
type: resourceMapping.fgaResourceType,
id: resourceMapping.deriveId?.({ user, resourceId, requestContext }) ?? resourceId,
},
permission: mappedPermission,
};
}

/**
* Executes a specific tool provided by this MCP server.
*
Expand Down
Loading