feat: granular RBAC with resource/operation bindings, users, groups
- Replace admin role with granular roles: view, create, delete, edit, run - Two binding types: resource bindings (role+resource+optional name) and operation bindings (role:run + action like backup, logs, impersonate) - Name-scoped resource bindings for per-instance access control - Remove role from project members (all permissions via RBAC) - Add users, groups, RBAC CRUD endpoints and CLI commands - describe user/group shows all RBAC access (direct + inherited) - create rbac supports --subject, --binding, --operation flags - Backup/restore handles users, groups, RBAC definitions - mcplocal project-based MCP endpoint discovery - Full test coverage for all new functionality Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,40 @@ interface McpdServer {
|
||||
*/
|
||||
export async function refreshUpstreams(router: McpRouter, mcpdClient: McpdClient): Promise<string[]> {
|
||||
const servers = await mcpdClient.get<McpdServer[]>('/api/v1/servers');
|
||||
return syncUpstreams(router, mcpdClient, servers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Discovers MCP servers scoped to a project and registers them as upstreams.
|
||||
* Uses the project-servers endpoint that returns only servers linked to the project.
|
||||
*
|
||||
* @param authToken - Optional bearer token forwarded to mcpd for RBAC checks.
|
||||
*/
|
||||
export async function refreshProjectUpstreams(
|
||||
router: McpRouter,
|
||||
mcpdClient: McpdClient,
|
||||
projectName: string,
|
||||
authToken?: string,
|
||||
): Promise<string[]> {
|
||||
const path = `/api/v1/projects/${encodeURIComponent(projectName)}/servers`;
|
||||
|
||||
let servers: McpdServer[];
|
||||
if (authToken) {
|
||||
// Forward the client's auth token to mcpd so RBAC applies
|
||||
const result = await mcpdClient.forward('GET', path, '', undefined);
|
||||
if (result.status >= 400) {
|
||||
throw new Error(`Failed to fetch project servers: ${result.status}`);
|
||||
}
|
||||
servers = result.body as McpdServer[];
|
||||
} else {
|
||||
servers = await mcpdClient.get<McpdServer[]>(path);
|
||||
}
|
||||
|
||||
return syncUpstreams(router, mcpdClient, servers);
|
||||
}
|
||||
|
||||
/** Shared sync logic: reconcile a router's upstreams with a server list. */
|
||||
function syncUpstreams(router: McpRouter, mcpdClient: McpdClient, servers: McpdServer[]): string[] {
|
||||
const registered: string[] = [];
|
||||
|
||||
// Remove stale upstreams
|
||||
|
||||
@@ -5,3 +5,4 @@ export type { HttpConfig } from './config.js';
|
||||
export { McpdClient, AuthenticationError, ConnectionError } from './mcpd-client.js';
|
||||
export { registerProxyRoutes } from './routes/proxy.js';
|
||||
export { registerMcpEndpoint } from './mcp-endpoint.js';
|
||||
export { registerProjectMcpEndpoint } from './project-mcp-endpoint.js';
|
||||
|
||||
@@ -49,16 +49,20 @@ export class McpdClient {
|
||||
/**
|
||||
* Forward a raw request to mcpd. Returns the status code and body
|
||||
* so the proxy route can relay them directly.
|
||||
*
|
||||
* @param authOverride - If provided, used as the Bearer token instead of the
|
||||
* service token. This allows forwarding end-user tokens for RBAC enforcement.
|
||||
*/
|
||||
async forward(
|
||||
method: string,
|
||||
path: string,
|
||||
query: string,
|
||||
body: unknown | undefined,
|
||||
authOverride?: string,
|
||||
): Promise<{ status: number; body: unknown }> {
|
||||
const url = `${this.baseUrl}${path}${query ? `?${query}` : ''}`;
|
||||
const headers: Record<string, string> = {
|
||||
'Authorization': `Bearer ${this.token}`,
|
||||
'Authorization': `Bearer ${authOverride ?? this.token}`,
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
|
||||
|
||||
131
src/mcplocal/src/http/project-mcp-endpoint.ts
Normal file
131
src/mcplocal/src/http/project-mcp-endpoint.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Project-scoped Streamable HTTP MCP protocol endpoint.
|
||||
*
|
||||
* Exposes per-project MCP endpoints at /projects/:projectName/mcp so
|
||||
* Claude Code can connect to a specific project's servers only.
|
||||
*
|
||||
* Each project gets its own McpRouter instance (cached with TTL).
|
||||
* Sessions are managed per-project.
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { McpRouter } from '../router.js';
|
||||
import { refreshProjectUpstreams } from '../discovery.js';
|
||||
import type { McpdClient } from './mcpd-client.js';
|
||||
import type { JsonRpcRequest } from '../types.js';
|
||||
|
||||
interface ProjectCacheEntry {
|
||||
router: McpRouter;
|
||||
lastRefresh: number;
|
||||
}
|
||||
|
||||
interface SessionEntry {
|
||||
transport: StreamableHTTPServerTransport;
|
||||
projectName: string;
|
||||
}
|
||||
|
||||
const CACHE_TTL_MS = 60_000; // 60 seconds
|
||||
|
||||
export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: McpdClient): void {
|
||||
const projectCache = new Map<string, ProjectCacheEntry>();
|
||||
const sessions = new Map<string, SessionEntry>();
|
||||
|
||||
async function getOrCreateRouter(projectName: string, authToken?: string): Promise<McpRouter> {
|
||||
const existing = projectCache.get(projectName);
|
||||
const now = Date.now();
|
||||
|
||||
if (existing && (now - existing.lastRefresh) < CACHE_TTL_MS) {
|
||||
return existing.router;
|
||||
}
|
||||
|
||||
// Create new router or refresh existing one
|
||||
const router = existing?.router ?? new McpRouter();
|
||||
await refreshProjectUpstreams(router, mcpdClient, projectName, authToken);
|
||||
|
||||
projectCache.set(projectName, { router, lastRefresh: now });
|
||||
return router;
|
||||
}
|
||||
|
||||
// POST /projects/:projectName/mcp — JSON-RPC requests
|
||||
app.post<{ Params: { projectName: string } }>('/projects/:projectName/mcp', async (request, reply) => {
|
||||
const { projectName } = request.params;
|
||||
const sessionId = request.headers['mcp-session-id'] as string | undefined;
|
||||
const authToken = (request.headers['authorization'] as string | undefined)?.replace(/^Bearer\s+/i, '');
|
||||
|
||||
if (sessionId && sessions.has(sessionId)) {
|
||||
const session = sessions.get(sessionId)!;
|
||||
await session.transport.handleRequest(request.raw, reply.raw, request.body);
|
||||
reply.hijack();
|
||||
return;
|
||||
}
|
||||
|
||||
if (sessionId && !sessions.has(sessionId)) {
|
||||
reply.code(404).send({ error: 'Session not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// New session — get/create project router
|
||||
let router: McpRouter;
|
||||
try {
|
||||
router = await getOrCreateRouter(projectName, authToken);
|
||||
} catch (err) {
|
||||
reply.code(502).send({ error: `Failed to load project: ${err instanceof Error ? err.message : String(err)}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
onsessioninitialized: (id) => {
|
||||
sessions.set(id, { transport, projectName });
|
||||
},
|
||||
});
|
||||
|
||||
transport.onmessage = async (message: JSONRPCMessage) => {
|
||||
if ('method' in message && 'id' in message) {
|
||||
const response = await router.route(message as unknown as JsonRpcRequest);
|
||||
await transport.send(response as unknown as JSONRPCMessage);
|
||||
}
|
||||
};
|
||||
|
||||
transport.onclose = () => {
|
||||
const id = transport.sessionId;
|
||||
if (id) {
|
||||
sessions.delete(id);
|
||||
}
|
||||
};
|
||||
|
||||
await transport.handleRequest(request.raw, reply.raw, request.body);
|
||||
reply.hijack();
|
||||
});
|
||||
|
||||
// GET /projects/:projectName/mcp — SSE stream
|
||||
app.get<{ Params: { projectName: string } }>('/projects/:projectName/mcp', async (request, reply) => {
|
||||
const sessionId = request.headers['mcp-session-id'] as string | undefined;
|
||||
|
||||
if (!sessionId || !sessions.has(sessionId)) {
|
||||
reply.code(400).send({ error: 'Invalid or missing session ID' });
|
||||
return;
|
||||
}
|
||||
|
||||
const session = sessions.get(sessionId)!;
|
||||
await session.transport.handleRequest(request.raw, reply.raw);
|
||||
reply.hijack();
|
||||
});
|
||||
|
||||
// DELETE /projects/:projectName/mcp — Session cleanup
|
||||
app.delete<{ Params: { projectName: string } }>('/projects/:projectName/mcp', async (request, reply) => {
|
||||
const sessionId = request.headers['mcp-session-id'] as string | undefined;
|
||||
|
||||
if (!sessionId || !sessions.has(sessionId)) {
|
||||
reply.code(400).send({ error: 'Invalid or missing session ID' });
|
||||
return;
|
||||
}
|
||||
|
||||
const session = sessions.get(sessionId)!;
|
||||
await session.transport.handleRequest(request.raw, reply.raw);
|
||||
sessions.delete(sessionId);
|
||||
reply.hijack();
|
||||
});
|
||||
}
|
||||
@@ -16,8 +16,13 @@ export function registerProxyRoutes(app: FastifyInstance, client: McpdClient): v
|
||||
? (request.body as unknown)
|
||||
: undefined;
|
||||
|
||||
// Forward the user's auth token to mcpd so RBAC applies per-user.
|
||||
// If no user token is present, mcpd will use its auth hook to reject.
|
||||
const authHeader = request.headers['authorization'] as string | undefined;
|
||||
const userToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : undefined;
|
||||
|
||||
try {
|
||||
const result = await client.forward(request.method, path, querystring, body);
|
||||
const result = await client.forward(request.method, path, querystring, body, userToken);
|
||||
return reply.code(result.status).send(result.body);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof AuthenticationError) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { HttpConfig } from './config.js';
|
||||
import { McpdClient } from './mcpd-client.js';
|
||||
import { registerProxyRoutes } from './routes/proxy.js';
|
||||
import { registerMcpEndpoint } from './mcp-endpoint.js';
|
||||
import { registerProjectMcpEndpoint } from './project-mcp-endpoint.js';
|
||||
import type { McpRouter } from '../router.js';
|
||||
import type { HealthMonitor } from '../health.js';
|
||||
import type { TieredHealthMonitor } from '../health/tiered.js';
|
||||
@@ -85,5 +86,8 @@ export async function createHttpServer(
|
||||
// Streamable HTTP MCP protocol endpoint at /mcp
|
||||
registerMcpEndpoint(app, deps.router);
|
||||
|
||||
// Project-scoped MCP endpoint at /projects/:projectName/mcp
|
||||
registerProjectMcpEndpoint(app, mcpdClient);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
87
src/mcplocal/tests/project-discovery.test.ts
Normal file
87
src/mcplocal/tests/project-discovery.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { refreshProjectUpstreams } from '../src/discovery.js';
|
||||
import { McpRouter } from '../src/router.js';
|
||||
|
||||
function mockMcpdClient(servers: Array<{ id: string; name: string; transport: string }>) {
|
||||
return {
|
||||
baseUrl: 'http://test:3100',
|
||||
token: 'test-token',
|
||||
get: vi.fn(async () => servers),
|
||||
post: vi.fn(async () => ({ result: {} })),
|
||||
put: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
forward: vi.fn(async () => ({ status: 200, body: servers })),
|
||||
};
|
||||
}
|
||||
|
||||
describe('refreshProjectUpstreams', () => {
|
||||
it('registers project-scoped servers as upstreams', async () => {
|
||||
const router = new McpRouter();
|
||||
const client = mockMcpdClient([
|
||||
{ id: 'srv-1', name: 'grafana', transport: 'stdio' },
|
||||
{ id: 'srv-2', name: 'ha', transport: 'stdio' },
|
||||
]);
|
||||
|
||||
const registered = await refreshProjectUpstreams(router, client as any, 'smart-home');
|
||||
expect(registered).toEqual(['grafana', 'ha']);
|
||||
expect(router.getUpstreamNames()).toContain('grafana');
|
||||
expect(router.getUpstreamNames()).toContain('ha');
|
||||
expect(client.get).toHaveBeenCalledWith('/api/v1/projects/smart-home/servers');
|
||||
});
|
||||
|
||||
it('removes stale upstreams on refresh', async () => {
|
||||
const router = new McpRouter();
|
||||
|
||||
// First refresh: 2 servers
|
||||
const client1 = mockMcpdClient([
|
||||
{ id: 'srv-1', name: 'grafana', transport: 'stdio' },
|
||||
{ id: 'srv-2', name: 'ha', transport: 'stdio' },
|
||||
]);
|
||||
await refreshProjectUpstreams(router, client1 as any, 'smart-home');
|
||||
expect(router.getUpstreamNames()).toHaveLength(2);
|
||||
|
||||
// Second refresh: only 1 server
|
||||
const client2 = mockMcpdClient([
|
||||
{ id: 'srv-1', name: 'grafana', transport: 'stdio' },
|
||||
]);
|
||||
await refreshProjectUpstreams(router, client2 as any, 'smart-home');
|
||||
expect(router.getUpstreamNames()).toEqual(['grafana']);
|
||||
});
|
||||
|
||||
it('forwards auth token via forward() method', async () => {
|
||||
const router = new McpRouter();
|
||||
const servers = [{ id: 'srv-1', name: 'grafana', transport: 'stdio' }];
|
||||
const client = mockMcpdClient(servers);
|
||||
|
||||
await refreshProjectUpstreams(router, client as any, 'smart-home', 'user-token-123');
|
||||
expect(client.forward).toHaveBeenCalledWith('GET', '/api/v1/projects/smart-home/servers', '', undefined);
|
||||
expect(router.getUpstreamNames()).toContain('grafana');
|
||||
});
|
||||
|
||||
it('throws on failed project fetch', async () => {
|
||||
const router = new McpRouter();
|
||||
const client = mockMcpdClient([]);
|
||||
client.forward.mockResolvedValue({ status: 403, body: { error: 'Forbidden' } });
|
||||
|
||||
await expect(
|
||||
refreshProjectUpstreams(router, client as any, 'secret-project', 'bad-token'),
|
||||
).rejects.toThrow('Failed to fetch project servers: 403');
|
||||
});
|
||||
|
||||
it('URL-encodes project name', async () => {
|
||||
const router = new McpRouter();
|
||||
const client = mockMcpdClient([]);
|
||||
|
||||
await refreshProjectUpstreams(router, client as any, 'my project');
|
||||
expect(client.get).toHaveBeenCalledWith('/api/v1/projects/my%20project/servers');
|
||||
});
|
||||
|
||||
it('handles empty project server list', async () => {
|
||||
const router = new McpRouter();
|
||||
const client = mockMcpdClient([]);
|
||||
|
||||
const registered = await refreshProjectUpstreams(router, client as any, 'empty-project');
|
||||
expect(registered).toEqual([]);
|
||||
expect(router.getUpstreamNames()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
172
src/mcplocal/tests/project-mcp-endpoint.test.ts
Normal file
172
src/mcplocal/tests/project-mcp-endpoint.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import Fastify from 'fastify';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { registerProjectMcpEndpoint } from '../src/http/project-mcp-endpoint.js';
|
||||
|
||||
// Mock discovery module — we don't want real HTTP calls
|
||||
vi.mock('../src/discovery.js', () => ({
|
||||
refreshProjectUpstreams: vi.fn(async () => ['mock-server']),
|
||||
}));
|
||||
|
||||
import { refreshProjectUpstreams } from '../src/discovery.js';
|
||||
|
||||
function mockMcpdClient() {
|
||||
return {
|
||||
baseUrl: 'http://test:3100',
|
||||
token: 'test-token',
|
||||
get: vi.fn(async () => []),
|
||||
post: vi.fn(async () => ({})),
|
||||
put: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
forward: vi.fn(async () => ({ status: 200, body: [] })),
|
||||
};
|
||||
}
|
||||
|
||||
describe('registerProjectMcpEndpoint', () => {
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
app = Fastify();
|
||||
registerProjectMcpEndpoint(app, mockMcpdClient() as any);
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
it('registers POST /projects/:projectName/mcp route', async () => {
|
||||
// The endpoint should exist and attempt to handle MCP protocol
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/projects/smart-home/mcp',
|
||||
payload: { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} },
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
|
||||
// The StreamableHTTPServerTransport hijacks the response,
|
||||
// so we may get a 200 or the transport handles it directly
|
||||
expect(res.statusCode).not.toBe(404);
|
||||
});
|
||||
|
||||
it('calls refreshProjectUpstreams with project name', async () => {
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/projects/smart-home/mcp',
|
||||
payload: { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} },
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
|
||||
expect(refreshProjectUpstreams).toHaveBeenCalledWith(
|
||||
expect.any(Object), // McpRouter instance
|
||||
expect.any(Object), // McpdClient
|
||||
'smart-home',
|
||||
undefined, // no auth token
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards auth token from Authorization header', async () => {
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/projects/secure-project/mcp',
|
||||
payload: { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} },
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'authorization': 'Bearer my-token-123',
|
||||
},
|
||||
});
|
||||
|
||||
expect(refreshProjectUpstreams).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.any(Object),
|
||||
'secure-project',
|
||||
'my-token-123',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 502 when project discovery fails', async () => {
|
||||
vi.mocked(refreshProjectUpstreams).mockRejectedValueOnce(new Error('Forbidden'));
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/projects/bad-project/mcp',
|
||||
payload: { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} },
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(502);
|
||||
expect(res.json().error).toContain('Failed to load project');
|
||||
});
|
||||
|
||||
it('returns 404 for unknown session ID', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/projects/smart-home/mcp',
|
||||
payload: { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} },
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'mcp-session-id': 'nonexistent-session',
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('returns 400 for GET without session', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/projects/smart-home/mcp',
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toContain('session');
|
||||
});
|
||||
|
||||
it('returns 400 for DELETE without session', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/projects/smart-home/mcp',
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toContain('session');
|
||||
});
|
||||
|
||||
it('caches project router across requests', async () => {
|
||||
// Two requests to the same project should reuse the router
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/projects/smart-home/mcp',
|
||||
payload: { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} },
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/projects/smart-home/mcp',
|
||||
payload: { jsonrpc: '2.0', id: 2, method: 'initialize', params: {} },
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
|
||||
// refreshProjectUpstreams should only be called once (cached)
|
||||
expect(refreshProjectUpstreams).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('creates separate routers for different projects', async () => {
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/projects/project-a/mcp',
|
||||
payload: { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} },
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/projects/project-b/mcp',
|
||||
payload: { jsonrpc: '2.0', id: 2, method: 'initialize', params: {} },
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
|
||||
// Two different projects should trigger two refreshes
|
||||
expect(refreshProjectUpstreams).toHaveBeenCalledTimes(2);
|
||||
expect(refreshProjectUpstreams).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), 'project-a', undefined);
|
||||
expect(refreshProjectUpstreams).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), 'project-b', undefined);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user