Tutorial: Implementing Basic Governance (Identity & RBAC) with @ithena-one/mcp-governance
This tutorial guides you through incrementally adding Identity Resolution and Role-Based Access Control (RBAC) to a Model Context Protocol (MCP) server using the @ithena-one/mcp-governance
SDK. We’ll start with a basic setup and progressively enhance a single application file (src/governed-app.ts
).
Prerequisites:
- Node.js (v18 or later recommended)
- npm, yarn, or pnpm
- Basic understanding of TypeScript
- Familiarity with the base
@modelcontextprotocol/sdk
@ithena-one/mcp-governance
SDK installed (e.g.,npm install @ithena-one/mcp-governance
)
To create a governed MCP server that identifies callers and enforces basic permissions using RBAC, demonstrating the core workflow of the SDK.
Step 0: Project Setup & Initial Governed Server
We need a starting point: a working MCP server using the governance SDK, but with most governance features off or using defaults. This ensures the basic SDK setup is correct before adding complexity.
Create Project & Install Dependencies
mkdir my-governed-mcp-app
cd my-governed-mcp-app
npm init -y
npm install @modelcontextprotocol/sdk @ithena-one/mcp-governance zod
npm install --save-dev typescript @types/node
npx tsc --init --rootDir src --outDir dist --esModuleInterop --resolveJsonModule --lib esnext --module nodenext --moduleResolution nodenext --strict
mkdir src
Create src/governed-app.ts
This initial version uses GovernedServer
but only configures the default console logger and auditor. No identity or RBAC yet.
// src/governed-app.ts
import { Server as BaseServer } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import process from 'node:process';
// --- Import Governance SDK ---
import {
GovernedServer,
GovernedServerOptions,
ConsoleLogger, // Default logger
ConsoleAuditLogStore, // Default auditor
GovernedRequestHandlerExtra, // Type for handler context
// We'll import interfaces as needed in later steps
} from '@ithena-one/mcp-governance';
console.log('Starting Governed MCP Server...');
// --- 1. Create Base MCP Server ---
const baseServer = new BaseServer(
{ name: "MyGovernedMCPServer", version: "1.0.0" },
{ capabilities: { tools: {} } } // Enable tools capability
);
// --- 2. Governance Components (Initial Defaults) ---
const logger = new ConsoleLogger({}, 'debug'); // Log debug and above
const auditStore = new ConsoleAuditLogStore(); // Log audits to console (NOT FOR PRODUCTION)
// --- 3. GovernedServer Configuration (Initial) ---
const governedServerOptions: GovernedServerOptions = {
logger: logger,
auditStore: auditStore,
serviceIdentifier: "governed-app-instance",
enableRbac: false, // Explicitly OFF for Step 0
};
// --- 4. Create GovernedServer instance ---
const governedServer = new GovernedServer(baseServer, governedServerOptions);
logger.info('GovernedServer created');
// --- 5. Define Tool Schema ---
const helloToolSchema = z.object({
jsonrpc: z.literal("2.0"), id: z.union([z.string(), z.number()]),
method: z.literal('tools/callHello'), // Unique method name
params: z.object({
arguments: z.object({ greeting: z.string().optional().default('Hello') }).optional().default({ greeting: 'Hello' }),
testUserId: z.string().optional(), // We'll use this later for stdio identity testing
_meta: z.any().optional() }) });
// Add schema for sensitive tool here to avoid errors later
const sensitiveToolSchema = z.object({ jsonrpc: z.literal("2.0"), id: z.union([z.string(), z.number()]), method: z.literal('tools/callSensitive'), params: z.object({ arguments: z.any().optional(), testUserId: z.string().optional(), _meta: z.any().optional() }) });
// --- 6. Register Handler ---
governedServer.setRequestHandler(helloToolSchema,
async (request, extra: GovernedRequestHandlerExtra) => {
const scopedLogger = extra.logger || logger;
// Identity will be null here
scopedLogger.info(`[Handler] Executing callHello. EventID: ${extra.eventId}`);
const greeting = request.params?.arguments?.greeting || 'DefaultGreeting';
const responseText = `${greeting} World from governed server!`; // No identity yet
return { content: [{ type: 'text', text: responseText }] };
}
);
logger.info('Handler registered.');
// --- 7. Connect and Shutdown ---
const transport = new StdioServerTransport();
async function startServer() {
try { await governedServer.connect(transport); logger.info("Governed MCP server (Step 0) started on stdio."); logger.info("Ready for requests..."); } catch (error) { logger.error("Failed to start server", error); process.exit(1); }
}
const shutdown = async () => {
logger.info("Shutting down...");
try { await governedServer.close(); logger.info("Shutdown complete."); process.exit(0); } catch (err) { logger.error("Error during shutdown:", err); process.exit(1); }
};
process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown);
startServer(); // Call startServer at the end
Build and Run
npm run build
npm run start
Test
Send a request via stdin:
{"jsonrpc":"2.0","id":1,"method":"tools/callHello","params":{"arguments":{"greeting":"Initial"}}}
Request succeeds. Logs show handler execution. Audit record shows identity: null
and authorization: {decision: 'not_applicable'}
. Stop server (Ctrl+C).
Step 1: Implement Identity Resolution
The first step in governance is knowing who is making the request. We need to implement the IdentityResolver
interface and configure the GovernedServer
to use it.
Modify src/governed-app.ts
:
Add Imports
Add necessary types/interfaces near the top:
// Add near other governance imports
import {
IdentityResolver,
OperationContext,
UserIdentity
} from '@ithena-one/mcp-governance';
Implement Resolver
Define the logic before governedServerOptions
. We use a parameter for stdio testing.
// --- ADDED FOR STEP 1: IdentityResolver ---
const testIdentityResolver: IdentityResolver = {
async resolveIdentity(opCtx: OperationContext): Promise<UserIdentity | null> {
const scopedLogger = opCtx.logger || logger; // Use context logger
scopedLogger.debug('Entering IdentityResolver', { eventId: opCtx.eventId });
// Check param first for stdio testing
const paramsObj = opCtx.mcpMessage.params as any;
const testIdParam = paramsObj?.testUserId;
if (testIdParam) {
scopedLogger.info(`Identity resolved via param: ${testIdParam}`, { eventId: opCtx.eventId });
return { id: testIdParam, source: 'param' }; // Return structured identity
}
// Fallback to header (for potential future SSE/HTTP testing)
const userHeader = opCtx.transportContext.headers?.['x-test-user-id'];
const userIdFromHeader = Array.isArray(userHeader) ? userHeader[0] : userHeader;
if (userIdFromHeader) {
scopedLogger.info(`Identity resolved via header: ${userIdFromHeader}`, { eventId: opCtx.eventId });
return { id: userIdFromHeader, source: 'header' };
}
scopedLogger.info('No test identity found', { eventId: opCtx.eventId });
return null;
}
};
// --- END STEP 1 ---
Update governedServerOptions
Add the identityResolver
.
// --- 3. GovernedServer Configuration (Initial) ---
const governedServerOptions: GovernedServerOptions = {
logger: logger,
auditStore: auditStore,
identityResolver: testIdentityResolver, // <-- ADDED
serviceIdentifier: "governed-app-instance",
enableRbac: false, // Still OFF for this step
};
Update Handler
Modify the helloToolSchema
handler to use the resolved identity from extra.identity
.
// Find the setRequestHandler call for helloToolSchema
governedServer.setRequestHandler(helloToolSchema,
async (request, extra: GovernedRequestHandlerExtra) => {
const scopedLogger = extra.logger || logger;
const identityId = typeof extra.identity === 'string' ? extra.identity : extra.identity?.id; // Get ID
scopedLogger.info(`[Handler] Executing callHello for identity: ${identityId || 'anonymous'}. EventID: ${extra.eventId}`);
const greeting = request.params?.arguments?.greeting || 'DefaultGreeting';
const responseText = `${greeting} ${identityId || 'World'} from governed server!`; // Use identity in response
return { content: [{ type: 'text', text: responseText }] };
}
);
Rebuild and Run
npm run build
npm run start
Test
- Without Identity: Send
{"jsonrpc":"2.0","id":2,"method":"tools/callHello"}
Succeeds, response includes “World”. Logs: “No test identity found”. Audit:
identity: null
. - With Identity: Send
{"jsonrpc":"2.0","id":3,"method":"tools/callHello","params":{"testUserId": "tester"}}
Succeeds, response includes “tester”. Logs: “Identity resolved via param”. Audit: shows identity object. Stop server.
Step 2: Implement Basic RBAC
Now that we know who the user is, we need to check what they are allowed to do. We implement RoleStore
and PermissionStore
and enable the RBAC check in the pipeline.
Modify src/governed-app.ts
:
Add Imports
// Add near other governance imports
import {
RoleStore,
PermissionStore,
InMemoryRoleStore,
InMemoryPermissionStore,
} from '@ithena-one/mcp-governance';
Implement RBAC Stores
Define roles/permissions before governedServerOptions
.
// --- ADDED FOR STEP 2: RBAC Stores ---
const testRoleStore: RoleStore = new InMemoryRoleStore({
'admin-007': ['admin'], // User 'admin-007' has 'admin' role
'user-123': ['user'], // User 'user-123' has 'user' role
});
const testPermissionStore: PermissionStore = new InMemoryPermissionStore({
// Define permissions granted by each role
'admin': ['tool:callHello', 'tool:callSensitive'], // Use derived permission strings
'user': ['tool:callHello'],
});
// --- END STEP 2 ---
Update governedServerOptions
Add stores and set enableRbac: true
.
// --- 3. GovernedServer Configuration ---
const governedServerOptions: GovernedServerOptions = {
logger: logger,
auditStore: auditStore,
identityResolver: testIdentityResolver,
roleStore: testRoleStore, // <-- ADDED
permissionStore: testPermissionStore, // <-- ADDED
enableRbac: true, // <-- ENABLED
auditDeniedRequests: true, // <-- Good practice
serviceIdentifier: "governed-app-instance",
};
Add Sensitive Tool Handler
Add the handler for sensitiveToolSchema
near the helloToolSchema
handler.
// --- ADDED FOR STEP 2 ---
governedServer.setRequestHandler(sensitiveToolSchema,
async (request, extra: GovernedRequestHandlerExtra) => {
const identityId = typeof extra.identity === 'string' ? extra.identity : extra.identity?.id;
const scopedLogger = extra.logger || logger;
// Log roles received by the handler
scopedLogger.info(`[Handler] Executing callSensitive for identity: ${identityId}`, { roles: extra.roles });
// RBAC check 'tool:callSensitive' must have passed to reach here
return { content: [{ type: 'text', text: `Sensitive data accessed by ${identityId}` }] };
}
);
// --- END STEP 2 ---
Update Hello Handler Log (Optional)
Add role logging.
// Modify helloTool handler log
scopedLogger.info(`[Handler] Executing callHello for identity: ${identityId || 'anonymous'} with roles: ${JSON.stringify(extra.roles)}. EventID: ${extra.eventId}`);
Rebuild and Run
npm run build
npm run start
Test RBAC Scenarios
- Hello (User): Send
{"jsonrpc":"2.0","id":4,"method":"tools/callHello","params":{"testUserId": "user-123"}}
Success. Logs: ID resolved, roles
['user']
, permissiontool:callHello
granted. - Sensitive (User): Send
{"jsonrpc":"2.0","id":5,"method":"tools/callSensitive","params":{"testUserId": "user-123"}}
Error
-32001
. Logs: ID resolved, roles['user']
, permissiontool:callSensitive
derived, but check fails. Audit:denied
,reason: 'permission'
. - Sensitive (Admin): Send
{"jsonrpc":"2.0","id":6,"method":"tools/callSensitive","params":{"testUserId": "admin-007"}}
Success. Logs: ID resolved, roles
['admin']
, permissiontool:callSensitive
granted. Handler runs. - Hello (No ID): Send
{"jsonrpc":"2.0","id":7,"method":"tools/callHello"}}
Error
-32001
. Logs: ID resolved asnull
. RBAC fails immediately (reason: 'identity'
). Stop server.
Final Code (src/governed-app.ts
- Identity & RBAC)
After completing Step 2, your src/governed-app.ts
should look similar to this:
// src/governed-app.ts
import { Server as BaseServer } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import process from 'node:process';
// --- Import Governance SDK ---
import {
GovernedServer,
GovernedServerOptions,
ConsoleLogger,
ConsoleAuditLogStore,
InMemoryRoleStore,
InMemoryPermissionStore,
IdentityResolver,
RoleStore,
PermissionStore,
OperationContext,
UserIdentity,
GovernedRequestHandlerExtra,
} from '@ithena-one/mcp-governance';
console.log('Starting Governed MCP Server (Identity & RBAC)...');
// --- 1. Create Base MCP Server ---
const baseServer = new BaseServer(
{ name: "MyGovernedMCPServer-RBAC", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
// --- 2. Governance Components Implementations ---
const logger = new ConsoleLogger({}, 'debug');
const auditStore = new ConsoleAuditLogStore(); // Not for Production
const testIdentityResolver: IdentityResolver = {
async resolveIdentity(opCtx: OperationContext): Promise<UserIdentity | null> {
const scopedLogger = opCtx.logger || logger;
scopedLogger.debug('Entering IdentityResolver', { eventId: opCtx.eventId });
const paramsObj = opCtx.mcpMessage.params as any; const testIdParam = paramsObj?.testUserId;
if (testIdParam) { scopedLogger.info(`Identity resolved via param: ${testIdParam}`, { eventId: opCtx.eventId }); return { id: testIdParam, source: 'param' }; }
const userHeader = opCtx.transportContext.headers?.['x-test-user-id']; const userIdFromHeader = Array.isArray(userHeader) ? userHeader[0] : userHeader;
if (userIdFromHeader) { scopedLogger.info(`Identity resolved via header: ${userIdFromHeader}`, { eventId: opCtx.eventId }); return { id: userIdFromHeader, source: 'header' }; }
scopedLogger.info('No test identity found', { eventId: opCtx.eventId }); return null;
}
};
const testRoleStore: RoleStore = new InMemoryRoleStore({
'admin-007': ['admin'], 'user-123': ['user'],
});
const testPermissionStore: PermissionStore = new InMemoryPermissionStore({
'admin': ['tool:callHello', 'tool:callSensitive'],
'user': ['tool:callHello'],
});
// --- 3. Final GovernedServer Configuration ---
const governedServerOptions: GovernedServerOptions = {
logger: logger,
auditStore: auditStore,
identityResolver: testIdentityResolver,
roleStore: testRoleStore,
permissionStore: testPermissionStore,
enableRbac: true, // RBAC is ON
auditDeniedRequests: true,
serviceIdentifier: "governed-app-rbac-instance",
};
// --- 4. Create Final GovernedServer instance ---
const governedServer = new GovernedServer(baseServer, governedServerOptions);
logger.info('GovernedServer created with Identity & RBAC options');
// --- 5. Define Tool Schemas ---
const helloToolSchema = z.object({ jsonrpc: z.literal("2.0"), id: z.union([z.string(), z.number()]), method: z.literal('tools/callHello'), params: z.object({ arguments: z.object({ greeting: z.string().optional().default('Hello') }).optional().default({ greeting: 'Hello' }), testUserId: z.string().optional(), _meta: z.any().optional() }) });
const sensitiveToolSchema = z.object({ jsonrpc: z.literal("2.0"), id: z.union([z.string(), z.number()]), method: z.literal('tools/callSensitive'), params: z.object({ arguments: z.any().optional(), testUserId: z.string().optional(), _meta: z.any().optional() }) });
// --- 6. Register Handlers ---
governedServer.setRequestHandler(helloToolSchema,
async (request, extra: GovernedRequestHandlerExtra) => {
const scopedLogger = extra.logger || logger; const identityId = typeof extra.identity === 'string' ? extra.identity : extra.identity?.id;
scopedLogger.info(`[Handler] Executing callHello for identity: ${identityId || 'anonymous'} with roles: ${JSON.stringify(extra.roles)}. EventID: ${extra.eventId}`);
const greeting = request.params?.arguments?.greeting || 'DefaultGreeting'; const responseText = `${greeting} ${identityId || 'World'} from governed server!`;
return { content: [{ type: 'text', text: responseText }] }; });
governedServer.setRequestHandler(sensitiveToolSchema,
async (request, extra: GovernedRequestHandlerExtra) => {
const scopedLogger = extra.logger || logger; const identityId = typeof extra.identity === 'string' ? extra.identity : extra.identity?.id;
scopedLogger.info(`[Handler] Executing callSensitive for identity: ${identityId}`, { roles: extra.roles });
return { content: [{ type: 'text', text: `Sensitive data accessed by ${identityId}` }] }; });
logger.info('Handlers registered.');
// --- 7. Connect and Shutdown ---
const transport = new StdioServerTransport();
async function startServer() {
try { await governedServer.connect(transport); logger.info("Governed MCP server (Identity & RBAC) started on stdio."); logger.info("Ready for requests..."); } catch (error) { logger.error("Failed to start server", error as Error); process.exit(1); } }
const shutdown = async () => { logger.info("Shutting down..."); try { await governedServer.close(); logger.info("Shutdown complete."); process.exit(0); } catch (err) { logger.error("Error during shutdown:", err); process.exit(1); } };
process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown);
startServer();
Next Steps & Production Considerations
This tutorial covered the core of Identity and RBAC.
The @ithena-one/mcp-governance
SDK also supports:
- Credential Resolution: Securely injecting secrets (
CredentialResolver
). - Custom Audit Sanitization: Preventing data leaks (
sanitizeForAudit
). - Hooks: Running custom logic (
postAuthorizationHook
).
This tutorial used simple, non-production-ready components.
- Replace default resolvers and stores with implementations integrated with your actual Auth systems.
- Replace default logger/auditor with production-ready solutions.
- Implement and thoroughly test a custom
sanitizeForAudit
function. - Consider using
CredentialResolver
for secrets. - Secure your transport (TLS).
Building robust, secure governance requires careful implementation. If managing these components becomes complex, exploring managed platforms designed for MCP governance might be beneficial.
Alternatively, consider the Ithena managed platform. Instead of building and maintaining your own governance components and logic, integrate our managed solution via a simple configuration pointing to the Ithena endpoint. Gain robust governance with significantly less effort. Visit https://ithena.one to learn more.