feat(mcpd): McpToken schema + CRUD routes + introspection

Adds a new McpToken Prisma model (project-scoped, SHA-256 hashed at rest,
optional expiry, revocable) plus backing repository, service, and REST
routes. Tokens are a first-class RBAC subject: new 'McpToken' kind is
added to the subject enum and the service auto-creates an RbacDefinition
with subject McpToken:<sha> when bindings are provided.

Creator-permission ceiling: the service rejects any requested binding
the creator cannot already satisfy themselves (re-uses
rbacService.canAccess / canRunOperation). rbacMode=clone snapshots the
creator's full permissions into the token.

Routes:
  POST   /api/v1/mcptokens              create (returns raw token once)
  GET    /api/v1/mcptokens              list (filter by project)
  GET    /api/v1/mcptokens/:id          describe (no secret in response)
  POST   /api/v1/mcptokens/:id/revoke   soft-delete + remove RbacDef
  DELETE /api/v1/mcptokens/:id          hard-delete
  GET    /api/v1/mcptokens/introspect   validate raw bearer (used by mcplocal)

Extends AuditEvent with optional tokenName/tokenSha fields (indexed) so
token-driven activity can be filtered later. Adds token helpers in
@mcpctl/shared: TOKEN_PREFIX='mcpctl_pat_', generateToken, hashToken,
isMcpToken, timingSafeEqualHex.

Follow-up PRs add the auth-hook dispatch on the prefix, the CLI verbs,
and the HTTP-mode mcplocal that calls /introspect.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michal
2026-04-17 01:00:04 +01:00
parent 3149ea3ae7
commit 2ddb493bb0
17 changed files with 949 additions and 11 deletions

View File

@@ -0,0 +1,78 @@
# mcptoken + HTTP-mode mcplocal — implementation log
Companion to the approved plan at `/home/michal/.claude/plans/lets-discuss-something-i-bright-lovelace.md`.
This file is updated as each milestone lands, so you can review what was actually done vs. what was planned.
## Context (why)
You're running your own vLLM inference outside Claude Code and want it to consume mcpctl over MCP with the same UX Claude gets: project-scoped server discovery, proxy models, the pipeline cache. Today `mcplocal` is systemd-only and serves STDIO — unreachable from off-host and unauthenticated. This work adds:
1. A containerized, network-accessible `mcplocal` serving Streamable HTTP.
2. A new `McpToken` resource (CLI: `mcpctl get/create/delete mcptoken`) — project-scoped bearer tokens with the same RBAC stack as users. Hashed at rest; raw value shown once.
3. Tokens as a first-class RBAC subject kind (`McpToken:<sha>`), with a creator-permission ceiling so non-admins cannot mint escalated tokens.
4. k8s deploy (Service `mcp`, Ingress `mcp.ad.itaz.eu`, PVC-backed `FileCache`).
5. A CLI breaking change: `mcpctl create rbac --binding edit:servers``--roleBindings role:edit,resource:servers`. You explicitly asked for this; only one command uses it.
6. A product-grade `mcpctl test mcp <url>` verb for validating any Streamable-HTTP MCP endpoint, reused by smoke tests.
## Branch
All work lives on `feat/mcptoken` (off `main` at `3149ea3`).
## Pre-work committed to main (outside this branch)
Before starting the feature, we flushed your in-flight changes to main so they wouldn't travel with the branch:
- **`3149ea3 fix: MCP proxy resilience — discovery cache, default liveness probes`** — per-server `tools/list` cache in `McpRouter` with positive+negative TTL so dead upstreams only stall the first call; default liveness probe (tools/list through the real production path) applied to any RUNNING instance without an explicit healthCheck. Already pushed to origin.
## Status legend
- ✅ done
- 🚧 in progress
- ⬜ not started
## PR 1 — Schema + token helpers + mcpd CRUD routes ✅
| # | Step | Status |
|---|---|---|
| 1 | `McpToken` Prisma model + Project/User reverse relations; `AuditEvent.tokenName` / `tokenSha` + index | ✅ |
| 2 | `src/shared/src/tokens/index.ts``generateToken`, `hashToken`, `isMcpToken`, `timingSafeEqualHex`, `TOKEN_PREFIX` | ✅ |
| 3 | `src/mcpd/src/repositories/mcp-token.repository.ts` + new interfaces in `repositories/interfaces.ts` | ✅ |
| 4 | `src/mcpd/src/services/mcp-token.service.ts` — creator-ceiling via `rbacService.canAccess`/`canRunOperation`, raw token returned only once, auto-creates an `RbacDefinition` with subject `McpToken:<sha>` when bindings are non-empty | ✅ |
| 5 | `src/mcpd/src/routes/mcp-tokens.ts` — POST / GET / GET:id / DELETE:id + POST:id/revoke + GET /introspect | ✅ |
| 6 | Wired into `main.ts` — repo/service constructed, routes registered, `mcptokens` added to URL→permission map + name resolver; `/mcptokens/introspect` added to auth-skip list so mcplocal can call it with a raw McpToken bearer | ✅ |
| 7 | RBAC extensions: new subject kind `McpToken` in `rbac-definition.schema.ts`; `mcptokens` added to `RBAC_RESOURCES` and `RESOURCE_ALIASES`; `rbac.service.ts` threads optional `mcpTokenSha` through `canAccess`, `canRunOperation`, `getAllowedScope`, `getPermissions`; resolver matches `{kind:'McpToken', name: sha}` | ✅ |
| 8 | Unit tests — `tests/mcp-token-service.test.ts` covering: empty/clone modes, ceiling rejection, RbacDefinition auto-create with correct `McpToken:<sha>` subject, duplicate-name conflict, introspect valid/revoked/expired/unknown, revoke deletes the RbacDefinition. 11/11 green. Full mcpd suite still 648/648. | ✅ |
### What this PR does NOT do yet (coming in PR 3)
- The mcpd **auth middleware** does not yet dispatch on the token prefix. A raw `mcpctl_pat_…` bearer sent to any `/api/v1/*` endpoint (other than `/introspect`) is still rejected as an invalid session. That's intentional — PR 3 extends `middleware/auth.ts` to recognize both session bearers and McpToken bearers.
- No CLI yet. Tokens can be created only via `POST /api/v1/mcptokens` for now.
## PR 2 — RBAC CLI migration
_(blocked by PR 1 — parser is reused by PR 3)_
## PR 3 — CLI mcptoken verbs + mcpd auth dispatch + audit
_(blocked)_
## PR 4 — HTTP-mode mcplocal + container + `mcpctl test mcp` + smoke
_(blocked)_
## Design decisions recap (so you don't have to re-read the plan)
| Decision | Choice |
|---|---|
| Transport | Streamable HTTP only |
| Binary shape | Same `@mcpctl/mcplocal` package, two entry files (`main.ts` STDIO, `serve.ts` HTTP) |
| Container runtime | Node (not bun-compiled) — mirrors mcpd |
| Cache | PVC at `/var/lib/mcplocal/cache` |
| Hostname | k8s Service `mcp`, Ingress `mcp.ad.itaz.eu` |
| Token format | `mcpctl_pat_<32-byte base62>`, stored as SHA-256, shown-once at create |
| Resource | `McpToken`, CLI noun `mcptoken`, one-project-per-token, FK cascade |
| Subject kind | New `McpToken:<sha>` |
| TTL | No default. Optional `--ttl 30d` / `never` / ISO date |
| Default bindings | `--rbac=empty` (default), `--rbac=clone`, `--bind <kv>` — creator ceiling enforced server-side |
| Binding CLI | `--roleBindings role:view,resource:servers[,name:foo]` or `--roleBindings action:logs` |
| Project enforcement | Endpoint visibility only (no strict create-time check) — same mechanism Claude uses |

View File

@@ -25,6 +25,7 @@ model User {
auditLogs AuditLog[] auditLogs AuditLog[]
ownedProjects Project[] ownedProjects Project[]
groupMemberships GroupMember[] groupMemberships GroupMember[]
mcpTokens McpToken[]
@@index([email]) @@index([email])
} }
@@ -187,6 +188,7 @@ model Project {
servers ProjectServer[] servers ProjectServer[]
prompts Prompt[] prompts Prompt[]
promptRequests PromptRequest[] promptRequests PromptRequest[]
mcpTokens McpToken[]
@@index([name]) @@index([name])
@@index([ownerId]) @@index([ownerId])
@@ -204,6 +206,36 @@ model ProjectServer {
@@unique([projectId, serverId]) @@unique([projectId, serverId])
} }
// ── MCP Tokens (bearer credentials for HTTP-mode mcplocal) ──
//
// Raw value format: `mcpctl_pat_<32 base62 chars>`. The raw value is shown
// exactly once at create time; only the SHA-256 hash is persisted. Tokens are
// scoped to exactly one project — they're only valid at
// `/projects/<that-project>/mcp`. Creator's RBAC is the ceiling; the service
// rejects bindings that exceed what the creator themselves can do.
model McpToken {
id String @id @default(cuid())
name String
projectId String
tokenHash String @unique
tokenPrefix String
ownerId String
description String @default("")
createdAt DateTime @default(now())
expiresAt DateTime?
lastUsedAt DateTime?
revokedAt DateTime?
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
@@unique([name, projectId])
@@index([tokenHash])
@@index([projectId])
@@index([ownerId])
}
// ── MCP Instances (running containers) ── // ── MCP Instances (running containers) ──
model McpInstance { model McpInstance {
@@ -288,6 +320,8 @@ model AuditEvent {
correlationId String? correlationId String?
parentEventId String? parentEventId String?
userName String? userName String?
tokenName String?
tokenSha String?
payload Json payload Json
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -297,6 +331,7 @@ model AuditEvent {
@@index([timestamp]) @@index([timestamp])
@@index([eventKind]) @@index([eventKind])
@@index([userName]) @@index([userName])
@@index([tokenSha])
} }
// ── Backup Pending Queue ── // ── Backup Pending Queue ──

View File

@@ -18,6 +18,7 @@ import {
UserRepository, UserRepository,
GroupRepository, GroupRepository,
AuditEventRepository, AuditEventRepository,
McpTokenRepository,
} from './repositories/index.js'; } from './repositories/index.js';
import { PromptRepository } from './repositories/prompt.repository.js'; import { PromptRepository } from './repositories/prompt.repository.js';
import { PromptRequestRepository } from './repositories/prompt-request.repository.js'; import { PromptRequestRepository } from './repositories/prompt-request.repository.js';
@@ -43,6 +44,7 @@ import {
UserService, UserService,
GroupService, GroupService,
AuditEventService, AuditEventService,
McpTokenService,
} from './services/index.js'; } from './services/index.js';
import type { RbacAction } from './services/index.js'; import type { RbacAction } from './services/index.js';
import type { UpdateRbacDefinitionInput } from './validation/rbac-definition.schema.js'; import type { UpdateRbacDefinitionInput } from './validation/rbac-definition.schema.js';
@@ -62,6 +64,7 @@ import {
registerUserRoutes, registerUserRoutes,
registerGroupRoutes, registerGroupRoutes,
registerAuditEventRoutes, registerAuditEventRoutes,
registerMcpTokenRoutes,
} from './routes/index.js'; } from './routes/index.js';
import { registerPromptRoutes } from './routes/prompts.js'; import { registerPromptRoutes } from './routes/prompts.js';
import { registerGitBackupRoutes } from './routes/git-backup.js'; import { registerGitBackupRoutes } from './routes/git-backup.js';
@@ -104,6 +107,7 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck {
'mcp': 'servers', 'mcp': 'servers',
'prompts': 'prompts', 'prompts': 'prompts',
'promptrequests': 'promptrequests', 'promptrequests': 'promptrequests',
'mcptokens': 'mcptokens',
}; };
const resource = resourceMap[segment]; const resource = resourceMap[segment];
@@ -116,6 +120,12 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck {
return { kind: 'resource', resource: 'promptrequests', action: 'delete', resourceName: approveMatch[1] }; return { kind: 'resource', resource: 'promptrequests', action: 'delete', resourceName: approveMatch[1] };
} }
// Special case: /api/v1/mcptokens/:id/revoke → treated as 'delete' on the token.
const revokeMatch = url.match(/^\/api\/v1\/mcptokens\/([^/?]+)\/revoke/);
if (revokeMatch?.[1]) {
return { kind: 'resource', resource: 'mcptokens', action: 'delete', resourceName: revokeMatch[1] };
}
// Special case: /api/v1/projects/:name/prompts/visible → view prompts // Special case: /api/v1/projects/:name/prompts/visible → view prompts
const visiblePromptsMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/prompts\/visible/); const visiblePromptsMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/prompts\/visible/);
if (visiblePromptsMatch?.[1]) { if (visiblePromptsMatch?.[1]) {
@@ -259,6 +269,7 @@ async function main(): Promise<void> {
const rbacDefinitionRepo = new RbacDefinitionRepository(prisma); const rbacDefinitionRepo = new RbacDefinitionRepository(prisma);
const userRepo = new UserRepository(prisma); const userRepo = new UserRepository(prisma);
const groupRepo = new GroupRepository(prisma); const groupRepo = new GroupRepository(prisma);
const mcpTokenRepo = new McpTokenRepository(prisma);
// CUID detection for RBAC name resolution // CUID detection for RBAC name resolution
const CUID_RE = /^c[^\s-]{8,}$/i; const CUID_RE = /^c[^\s-]{8,}$/i;
@@ -267,6 +278,7 @@ async function main(): Promise<void> {
secrets: secretRepo, secrets: secretRepo,
projects: projectRepo, projects: projectRepo,
groups: groupRepo, groups: groupRepo,
mcptokens: mcpTokenRepo,
}; };
// Migrate legacy 'admin' role → granular roles // Migrate legacy 'admin' role → granular roles
@@ -292,6 +304,7 @@ async function main(): Promise<void> {
const mcpProxyService = new McpProxyService(instanceRepo, serverRepo, orchestrator); const mcpProxyService = new McpProxyService(instanceRepo, serverRepo, orchestrator);
const rbacDefinitionService = new RbacDefinitionService(rbacDefinitionRepo); const rbacDefinitionService = new RbacDefinitionService(rbacDefinitionRepo);
const rbacService = new RbacService(rbacDefinitionRepo, prisma); const rbacService = new RbacService(rbacDefinitionRepo, prisma);
const mcpTokenService = new McpTokenService(mcpTokenRepo, projectRepo, rbacDefinitionRepo, rbacService);
const userService = new UserService(userRepo); const userService = new UserService(userRepo);
const groupService = new GroupService(groupRepo, userRepo); const groupService = new GroupService(groupRepo, userRepo);
const promptRepo = new PromptRepository(prisma); const promptRepo = new PromptRepository(prisma);
@@ -329,6 +342,8 @@ async function main(): Promise<void> {
const url = request.url; const url = request.url;
// Skip auth for health, auth, and root // Skip auth for health, auth, and root
if (url.startsWith('/api/v1/auth/') || url === '/healthz' || url === '/health') return; if (url.startsWith('/api/v1/auth/') || url === '/healthz' || url === '/health') return;
// Introspection authenticates via the McpToken bearer itself — route handles its own auth.
if (url.startsWith('/api/v1/mcptokens/introspect')) return;
if (!url.startsWith('/api/v1/')) return; if (!url.startsWith('/api/v1/')) return;
// Run auth middleware // Run auth middleware
@@ -393,6 +408,7 @@ async function main(): Promise<void> {
registerRbacRoutes(app, rbacDefinitionService); registerRbacRoutes(app, rbacDefinitionService);
registerUserRoutes(app, userService); registerUserRoutes(app, userService);
registerGroupRoutes(app, groupService); registerGroupRoutes(app, groupService);
registerMcpTokenRoutes(app, { tokenService: mcpTokenService, projectRepo });
registerPromptRoutes(app, promptService, projectRepo); registerPromptRoutes(app, promptService, projectRepo);
// ── Git-based backup ── // ── Git-based backup ──

View File

@@ -30,6 +30,8 @@ export class AuditEventRepository implements IAuditEventRepository {
correlationId: e.correlationId ?? null, correlationId: e.correlationId ?? null,
parentEventId: e.parentEventId ?? null, parentEventId: e.parentEventId ?? null,
userName: e.userName ?? null, userName: e.userName ?? null,
tokenName: e.tokenName ?? null,
tokenSha: e.tokenSha ?? null,
payload: e.payload as Prisma.InputJsonValue, payload: e.payload as Prisma.InputJsonValue,
})); }));
const result = await this.prisma.auditEvent.createMany({ data }); const result = await this.prisma.auditEvent.createMany({ data });
@@ -132,6 +134,8 @@ function buildWhere(filter?: AuditEventFilter): Prisma.AuditEventWhereInput {
if (filter.serverName !== undefined) where.serverName = filter.serverName; if (filter.serverName !== undefined) where.serverName = filter.serverName;
if (filter.correlationId !== undefined) where.correlationId = filter.correlationId; if (filter.correlationId !== undefined) where.correlationId = filter.correlationId;
if (filter.userName !== undefined) where.userName = filter.userName; if (filter.userName !== undefined) where.userName = filter.userName;
if (filter.tokenName !== undefined) where.tokenName = filter.tokenName;
if (filter.tokenSha !== undefined) where.tokenSha = filter.tokenSha;
if (filter.from !== undefined || filter.to !== undefined) { if (filter.from !== undefined || filter.to !== undefined) {
const timestamp: Prisma.DateTimeFilter = {}; const timestamp: Prisma.DateTimeFilter = {};

View File

@@ -15,3 +15,5 @@ export type { IGroupRepository, GroupWithMembers } from './group.repository.js';
export { GroupRepository } from './group.repository.js'; export { GroupRepository } from './group.repository.js';
export type { IAuditEventRepository, AuditEventFilter, AuditEventCreateInput } from './interfaces.js'; export type { IAuditEventRepository, AuditEventFilter, AuditEventCreateInput } from './interfaces.js';
export { AuditEventRepository } from './audit-event.repository.js'; export { AuditEventRepository } from './audit-event.repository.js';
export type { IMcpTokenRepository, McpTokenFilter, McpTokenWithRelations, CreateMcpTokenRepoInput } from './interfaces.js';
export { McpTokenRepository } from './mcp-token.repository.js';

View File

@@ -1,4 +1,4 @@
import type { McpServer, McpInstance, AuditLog, AuditEvent, Secret, InstanceStatus } from '@prisma/client'; import type { McpServer, McpInstance, AuditLog, AuditEvent, McpToken, Secret, InstanceStatus } from '@prisma/client';
import type { CreateMcpServerInput, UpdateMcpServerInput } from '../validation/mcp-server.schema.js'; import type { CreateMcpServerInput, UpdateMcpServerInput } from '../validation/mcp-server.schema.js';
import type { CreateSecretInput, UpdateSecretInput } from '../validation/secret.schema.js'; import type { CreateSecretInput, UpdateSecretInput } from '../validation/secret.schema.js';
@@ -57,6 +57,8 @@ export interface AuditEventFilter {
serverName?: string; serverName?: string;
correlationId?: string; correlationId?: string;
userName?: string; userName?: string;
tokenName?: string;
tokenSha?: string;
from?: Date; from?: Date;
to?: Date; to?: Date;
limit?: number; limit?: number;
@@ -74,6 +76,8 @@ export interface AuditEventCreateInput {
correlationId?: string; correlationId?: string;
parentEventId?: string; parentEventId?: string;
userName?: string; userName?: string;
tokenName?: string;
tokenSha?: string;
payload: Record<string, unknown>; payload: Record<string, unknown>;
} }
@@ -95,3 +99,37 @@ export interface IAuditEventRepository {
listSessions(filter?: { projectName?: string; userName?: string; from?: Date; to?: Date; limit?: number; offset?: number }): Promise<AuditSessionSummary[]>; listSessions(filter?: { projectName?: string; userName?: string; from?: Date; to?: Date; limit?: number; offset?: number }): Promise<AuditSessionSummary[]>;
countSessions(filter?: { projectName?: string; userName?: string; from?: Date; to?: Date }): Promise<number>; countSessions(filter?: { projectName?: string; userName?: string; from?: Date; to?: Date }): Promise<number>;
} }
// ── MCP Tokens ──
export interface McpTokenFilter {
projectId?: string;
ownerId?: string;
includeRevoked?: boolean;
}
export interface CreateMcpTokenRepoInput {
name: string;
projectId: string;
ownerId: string;
tokenHash: string;
tokenPrefix: string;
description?: string;
expiresAt?: Date | null;
}
export type McpTokenWithRelations = McpToken & {
project: { id: string; name: string };
owner: { id: string; email: string };
};
export interface IMcpTokenRepository {
findAll(filter?: McpTokenFilter): Promise<McpTokenWithRelations[]>;
findById(id: string): Promise<McpTokenWithRelations | null>;
findByHash(tokenHash: string): Promise<McpTokenWithRelations | null>;
findByNameAndProject(name: string, projectId: string): Promise<McpTokenWithRelations | null>;
create(data: CreateMcpTokenRepoInput): Promise<McpTokenWithRelations>;
revoke(id: string): Promise<McpTokenWithRelations>;
touchLastUsed(id: string): Promise<void>;
delete(id: string): Promise<void>;
}

View File

@@ -0,0 +1,83 @@
import type { PrismaClient } from '@prisma/client';
import type {
IMcpTokenRepository,
McpTokenFilter,
McpTokenWithRelations,
CreateMcpTokenRepoInput,
} from './interfaces.js';
const INCLUDE_RELATIONS = {
project: { select: { id: true, name: true } },
owner: { select: { id: true, email: true } },
} as const;
export class McpTokenRepository implements IMcpTokenRepository {
constructor(private readonly prisma: PrismaClient) {}
async findAll(filter?: McpTokenFilter): Promise<McpTokenWithRelations[]> {
const where: Record<string, unknown> = {};
if (filter?.projectId !== undefined) where['projectId'] = filter.projectId;
if (filter?.ownerId !== undefined) where['ownerId'] = filter.ownerId;
if (!filter?.includeRevoked) where['revokedAt'] = null;
return this.prisma.mcpToken.findMany({
where,
include: INCLUDE_RELATIONS,
orderBy: { createdAt: 'desc' },
}) as Promise<McpTokenWithRelations[]>;
}
async findById(id: string): Promise<McpTokenWithRelations | null> {
return this.prisma.mcpToken.findUnique({
where: { id },
include: INCLUDE_RELATIONS,
}) as Promise<McpTokenWithRelations | null>;
}
async findByHash(tokenHash: string): Promise<McpTokenWithRelations | null> {
return this.prisma.mcpToken.findUnique({
where: { tokenHash },
include: INCLUDE_RELATIONS,
}) as Promise<McpTokenWithRelations | null>;
}
async findByNameAndProject(name: string, projectId: string): Promise<McpTokenWithRelations | null> {
return this.prisma.mcpToken.findUnique({
where: { name_projectId: { name, projectId } },
include: INCLUDE_RELATIONS,
}) as Promise<McpTokenWithRelations | null>;
}
async create(data: CreateMcpTokenRepoInput): Promise<McpTokenWithRelations> {
return this.prisma.mcpToken.create({
data: {
name: data.name,
projectId: data.projectId,
ownerId: data.ownerId,
tokenHash: data.tokenHash,
tokenPrefix: data.tokenPrefix,
description: data.description ?? '',
expiresAt: data.expiresAt ?? null,
},
include: INCLUDE_RELATIONS,
}) as Promise<McpTokenWithRelations>;
}
async revoke(id: string): Promise<McpTokenWithRelations> {
return this.prisma.mcpToken.update({
where: { id },
data: { revokedAt: new Date() },
include: INCLUDE_RELATIONS,
}) as Promise<McpTokenWithRelations>;
}
async touchLastUsed(id: string): Promise<void> {
await this.prisma.mcpToken.update({
where: { id },
data: { lastUsedAt: new Date() },
});
}
async delete(id: string): Promise<void> {
await this.prisma.mcpToken.delete({ where: { id } });
}
}

View File

@@ -18,3 +18,5 @@ export { registerRbacRoutes } from './rbac-definitions.js';
export { registerUserRoutes } from './users.js'; export { registerUserRoutes } from './users.js';
export { registerGroupRoutes } from './groups.js'; export { registerGroupRoutes } from './groups.js';
export { registerAuditEventRoutes } from './audit-events.js'; export { registerAuditEventRoutes } from './audit-events.js';
export { registerMcpTokenRoutes } from './mcp-tokens.js';
export type { McpTokenRouteDeps } from './mcp-tokens.js';

View File

@@ -0,0 +1,142 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { isMcpToken } from '@mcpctl/shared';
import type { McpTokenService } from '../services/mcp-token.service.js';
import { PermissionCeilingError } from '../services/mcp-token.service.js';
import { NotFoundError, ConflictError } from '../services/mcp-server.service.js';
import type { IProjectRepository } from '../repositories/project.repository.js';
export interface McpTokenRouteDeps {
tokenService: McpTokenService;
projectRepo: IProjectRepository;
}
export function registerMcpTokenRoutes(app: FastifyInstance, deps: McpTokenRouteDeps): void {
const { tokenService, projectRepo } = deps;
// ── List ─────────────────────────────────────────────────────────────
app.get<{ Querystring: { projectId?: string; projectName?: string; includeRevoked?: string } }>(
'/api/v1/mcptokens',
async (request) => {
const { projectId, projectName, includeRevoked } = request.query;
// Allow filtering by project name for CLI ergonomics.
let resolvedProjectId = projectId;
if (resolvedProjectId === undefined && projectName !== undefined) {
const project = await projectRepo.findByName(projectName);
if (project === null) throw new NotFoundError(`Project not found: ${projectName}`);
resolvedProjectId = project.id;
}
const filter: { projectId?: string; includeRevoked?: boolean } = {};
if (resolvedProjectId !== undefined) filter.projectId = resolvedProjectId;
if (includeRevoked === 'true') filter.includeRevoked = true;
const rows = await tokenService.list(filter);
return rows.map(toListResponse);
},
);
// ── Describe ─────────────────────────────────────────────────────────
app.get<{ Params: { id: string } }>('/api/v1/mcptokens/:id', async (request) => {
const row = await tokenService.getById(request.params.id);
return toListResponse(row);
});
// ── Create ───────────────────────────────────────────────────────────
app.post('/api/v1/mcptokens', async (request, reply) => {
const userId = request.userId;
if (userId === undefined) {
reply.code(401);
return { error: 'Not authenticated' };
}
try {
// Accept projectName OR projectId for CLI ergonomics.
const body = (request.body ?? {}) as Record<string, unknown>;
if (typeof body['projectName'] === 'string' && typeof body['projectId'] !== 'string') {
const project = await projectRepo.findByName(body['projectName']);
if (project === null) throw new NotFoundError(`Project not found: ${body['projectName']}`);
body['projectId'] = project.id;
}
const result = await tokenService.create(userId, body);
reply.code(201);
return {
...toListResponse(result.mcpToken),
token: result.raw,
};
} catch (err) {
if (err instanceof NotFoundError) {
reply.code(404);
return { error: err.message };
}
if (err instanceof ConflictError) {
reply.code(409);
return { error: err.message };
}
if (err instanceof PermissionCeilingError) {
reply.code(403);
return { error: err.message };
}
throw err;
}
});
// ── Revoke (soft-delete) ────────────────────────────────────────────
app.post<{ Params: { id: string } }>('/api/v1/mcptokens/:id/revoke', async (request) => {
const row = await tokenService.revoke(request.params.id);
return toListResponse(row);
});
// ── Delete (hard) ────────────────────────────────────────────────────
app.delete<{ Params: { id: string } }>('/api/v1/mcptokens/:id', async (request, reply) => {
await tokenService.delete(request.params.id);
reply.code(204);
});
// ── Introspect ───────────────────────────────────────────────────────
// Called by mcplocal's HTTP-mode auth preHandler to resolve a raw bearer
// to principal info. Accepts a McpToken bearer directly — bypasses the
// session-auth path.
app.get('/api/v1/mcptokens/introspect', async (request: FastifyRequest, reply: FastifyReply) => {
const header = request.headers.authorization;
if (header === undefined || !header.startsWith('Bearer ')) {
reply.code(401);
return { ok: false, error: 'Missing Authorization' };
}
const token = header.slice(7);
if (!isMcpToken(token)) {
reply.code(401);
return { ok: false, error: 'Not a mcptoken bearer' };
}
const result = await tokenService.introspectRaw(token);
if (!result.ok) {
reply.code(401);
}
return result;
});
}
function toListResponse(row: import('../repositories/interfaces.js').McpTokenWithRelations): Record<string, unknown> {
return {
id: row.id,
name: row.name,
projectId: row.projectId,
projectName: row.project.name,
tokenPrefix: row.tokenPrefix,
ownerId: row.ownerId,
ownerEmail: row.owner.email,
description: row.description,
createdAt: row.createdAt,
expiresAt: row.expiresAt,
lastUsedAt: row.lastUsedAt,
revokedAt: row.revokedAt,
status: statusOf(row),
};
}
function statusOf(row: import('../repositories/interfaces.js').McpTokenWithRelations): 'active' | 'revoked' | 'expired' {
if (row.revokedAt !== null) return 'revoked';
if (row.expiresAt !== null && row.expiresAt < new Date()) return 'expired';
return 'active';
}

View File

@@ -34,3 +34,5 @@ export { UserService } from './user.service.js';
export { GroupService } from './group.service.js'; export { GroupService } from './group.service.js';
export { AuditEventService } from './audit-event.service.js'; export { AuditEventService } from './audit-event.service.js';
export type { AuditEventQueryParams } from './audit-event.service.js'; export type { AuditEventQueryParams } from './audit-event.service.js';
export { McpTokenService, PermissionCeilingError } from './mcp-token.service.js';
export type { CreateMcpTokenResult, IntrospectResult } from './mcp-token.service.js';

View File

@@ -0,0 +1,222 @@
import { generateToken, hashToken } from '@mcpctl/shared';
import type { McpToken } from '@prisma/client';
import type { IMcpTokenRepository, McpTokenWithRelations, McpTokenFilter } from '../repositories/interfaces.js';
import type { IRbacDefinitionRepository } from '../repositories/rbac-definition.repository.js';
import type { IProjectRepository } from '../repositories/project.repository.js';
import { CreateMcpTokenSchema } from '../validation/mcp-token.schema.js';
import { isResourceBinding, type RbacRoleBinding, type RbacSubject } from '../validation/rbac-definition.schema.js';
import type { RbacService, Permission } from './rbac.service.js';
import { ROLE_ACTIONS_FOR_CEILING } from './rbac.service.js';
import { NotFoundError, ConflictError } from './mcp-server.service.js';
/** Thrown when the requesting user tries to mint a token with bindings they cannot grant themselves. */
export class PermissionCeilingError extends Error {
constructor(message: string) {
super(message);
this.name = 'PermissionCeilingError';
}
}
export interface CreateMcpTokenResult {
/** The database row (with project/owner relations). */
mcpToken: McpTokenWithRelations;
/** The raw bearer token — shown exactly once. */
raw: string;
}
export interface IntrospectResult {
ok: boolean;
tokenId?: string;
tokenName?: string;
tokenSha?: string;
projectId?: string;
projectName?: string;
ownerId?: string;
expired?: boolean;
revoked?: boolean;
}
export class McpTokenService {
constructor(
private readonly tokenRepo: IMcpTokenRepository,
private readonly projectRepo: IProjectRepository,
private readonly rbacRepo: IRbacDefinitionRepository,
private readonly rbacService: RbacService,
) {}
async list(filter?: McpTokenFilter): Promise<McpTokenWithRelations[]> {
return this.tokenRepo.findAll(filter);
}
async getById(id: string): Promise<McpTokenWithRelations> {
const row = await this.tokenRepo.findById(id);
if (row === null) throw new NotFoundError(`McpToken not found: ${id}`);
return row;
}
/** Hash + lookup a raw bearer. Returns the row if valid and active; null if missing, revoked, or expired. */
async introspectRaw(raw: string): Promise<IntrospectResult> {
const hash = hashToken(raw);
const row = await this.tokenRepo.findByHash(hash);
if (row === null) return { ok: false };
const now = new Date();
const revoked = row.revokedAt !== null;
const expired = row.expiresAt !== null && row.expiresAt < now;
if (revoked || expired) {
return {
ok: false,
tokenId: row.id,
tokenName: row.name,
tokenSha: row.tokenHash,
revoked,
expired,
};
}
// Best-effort last-used tracking (don't block on this).
this.tokenRepo.touchLastUsed(row.id).catch(() => { /* ignore */ });
return {
ok: true,
tokenId: row.id,
tokenName: row.name,
tokenSha: row.tokenHash,
projectId: row.projectId,
projectName: row.project.name,
ownerId: row.ownerId,
expired: false,
revoked: false,
};
}
async create(creatorUserId: string, input: unknown): Promise<CreateMcpTokenResult> {
const data = CreateMcpTokenSchema.parse(input);
const project = await this.projectRepo.findById(data.projectId);
if (project === null) throw new NotFoundError(`Project not found: ${data.projectId}`);
const existing = await this.tokenRepo.findByNameAndProject(data.name, data.projectId);
if (existing !== null && existing.revokedAt === null) {
throw new ConflictError(`McpToken already exists: ${data.name} in project ${project.name}`);
}
// Resolve the effective bindings:
// base = rbacMode === 'clone' ? snapshot(creator) : []
// effective = base + explicit bindings
const basePerms = data.rbacMode === 'clone'
? await this.rbacService.getPermissions(creatorUserId)
: [];
const baseBindings = basePerms.map(permissionToBinding);
const effectiveBindings: RbacRoleBinding[] = [...baseBindings, ...data.bindings];
// Creator ceiling: every effective binding must be within what creator can do.
// Cloned bindings are trivially satisfied; explicit ones may not be.
for (const binding of data.bindings) {
const violation = await this.checkCeiling(creatorUserId, binding);
if (violation !== null) throw new PermissionCeilingError(violation);
}
// Generate the token
const { raw, hash, prefix } = generateToken();
// Normalize expiresAt
let expiresAt: Date | null = null;
if (data.expiresAt !== undefined && data.expiresAt !== null) {
expiresAt = typeof data.expiresAt === 'string' ? new Date(data.expiresAt) : data.expiresAt;
}
const createArgs: {
name: string;
projectId: string;
ownerId: string;
tokenHash: string;
tokenPrefix: string;
description?: string;
expiresAt: Date | null;
} = {
name: data.name,
projectId: data.projectId,
ownerId: creatorUserId,
tokenHash: hash,
tokenPrefix: prefix,
expiresAt,
};
if (data.description !== undefined) createArgs.description = data.description;
const row = await this.tokenRepo.create(createArgs);
// If the token has bindings, auto-create an RbacDefinition so the token is a real RBAC principal.
if (effectiveBindings.length > 0) {
const subject: RbacSubject = { kind: 'McpToken', name: hash };
await this.rbacRepo.create({
name: rbacDefNameFor(row),
subjects: [subject],
roleBindings: effectiveBindings,
});
}
return { mcpToken: row, raw };
}
async revoke(id: string): Promise<McpTokenWithRelations> {
const existing = await this.getById(id);
const row = await this.tokenRepo.revoke(id);
// Remove the RBAC definition so the token's bindings stop resolving immediately.
await this.deleteRbacDefinitionFor(existing).catch(() => { /* ignore */ });
return row;
}
async delete(id: string): Promise<void> {
const existing = await this.getById(id);
await this.deleteRbacDefinitionFor(existing).catch(() => { /* ignore */ });
await this.tokenRepo.delete(id);
}
private async deleteRbacDefinitionFor(row: McpToken): Promise<void> {
const name = rbacDefNameFor(row);
const existing = await this.rbacRepo.findByName(name);
if (existing === null) return;
await this.rbacRepo.delete(existing.id);
}
/**
* For a single requested binding, return null if the creator can grant it,
* or a human-readable reason string if they cannot.
*/
private async checkCeiling(creatorUserId: string, binding: RbacRoleBinding): Promise<string | null> {
if (isResourceBinding(binding)) {
const grantedActions = ROLE_ACTIONS_FOR_CEILING[binding.role] ?? [];
for (const action of grantedActions) {
const ok = await this.rbacService.canAccess(
creatorUserId,
action,
binding.resource,
binding.name,
);
if (!ok) {
return `Ceiling violation: you do not have permission '${action}' on ${binding.resource}${binding.name !== undefined ? `/${binding.name}` : ''}`;
}
}
return null;
}
// Operation binding
const ok = await this.rbacService.canRunOperation(creatorUserId, binding.action);
if (!ok) return `Ceiling violation: you cannot run operation '${binding.action}'`;
return null;
}
}
function permissionToBinding(p: Permission): RbacRoleBinding {
if ('resource' in p) {
return p.name !== undefined
? { role: p.role as RbacRoleBinding extends { role: infer R } ? R : never, resource: p.resource, name: p.name } as RbacRoleBinding
: { role: p.role, resource: p.resource } as RbacRoleBinding;
}
return { role: 'run', action: p.action };
}
function rbacDefNameFor(row: { id: string }): string {
// Must match the regex in CreateRbacDefinitionSchema (lowercase alphanumeric with hyphens).
return `mcptoken-${row.id.toLowerCase()}`;
}

View File

@@ -38,6 +38,9 @@ const ROLE_ACTIONS: Record<string, readonly RbacAction[]> = {
expose: ['expose', 'view'], expose: ['expose', 'view'],
}; };
/** Exported alias for permission-ceiling checks elsewhere (e.g. McpTokenService). */
export const ROLE_ACTIONS_FOR_CEILING = ROLE_ACTIONS;
export class RbacService { export class RbacService {
constructor( constructor(
private readonly rbacRepo: IRbacDefinitionRepository, private readonly rbacRepo: IRbacDefinitionRepository,
@@ -50,8 +53,8 @@ export class RbacService {
* If provided, name-scoped bindings only match when their name equals this. * If provided, name-scoped bindings only match when their name equals this.
* If omitted (listing), name-scoped bindings still grant access. * If omitted (listing), name-scoped bindings still grant access.
*/ */
async canAccess(userId: string, action: RbacAction, resource: string, resourceName?: string, serviceAccountName?: string): Promise<boolean> { async canAccess(userId: string, action: RbacAction, resource: string, resourceName?: string, serviceAccountName?: string, mcpTokenSha?: string): Promise<boolean> {
const permissions = await this.getPermissions(userId, serviceAccountName); const permissions = await this.getPermissions(userId, serviceAccountName, mcpTokenSha);
const normalized = normalizeResource(resource); const normalized = normalizeResource(resource);
for (const perm of permissions) { for (const perm of permissions) {
@@ -73,8 +76,8 @@ export class RbacService {
* Check whether a user is allowed to perform a named operation. * Check whether a user is allowed to perform a named operation.
* Operations require an explicit 'run' role binding with a matching action. * Operations require an explicit 'run' role binding with a matching action.
*/ */
async canRunOperation(userId: string, operation: string, serviceAccountName?: string): Promise<boolean> { async canRunOperation(userId: string, operation: string, serviceAccountName?: string, mcpTokenSha?: string): Promise<boolean> {
const permissions = await this.getPermissions(userId, serviceAccountName); const permissions = await this.getPermissions(userId, serviceAccountName, mcpTokenSha);
for (const perm of permissions) { for (const perm of permissions) {
if ('action' in perm && perm.role === 'run' && perm.action === operation) { if ('action' in perm && perm.role === 'run' && perm.action === operation) {
@@ -90,8 +93,8 @@ export class RbacService {
* Returns wildcard:true if any matching binding is unscoped (no name constraint). * Returns wildcard:true if any matching binding is unscoped (no name constraint).
* Returns wildcard:false with a set of allowed names if all bindings are name-scoped. * Returns wildcard:false with a set of allowed names if all bindings are name-scoped.
*/ */
async getAllowedScope(userId: string, action: RbacAction, resource: string, serviceAccountName?: string): Promise<AllowedScope> { async getAllowedScope(userId: string, action: RbacAction, resource: string, serviceAccountName?: string, mcpTokenSha?: string): Promise<AllowedScope> {
const permissions = await this.getPermissions(userId, serviceAccountName); const permissions = await this.getPermissions(userId, serviceAccountName, mcpTokenSha);
const normalized = normalizeResource(resource); const normalized = normalizeResource(resource);
const names = new Set<string>(); const names = new Set<string>();
@@ -113,13 +116,13 @@ export class RbacService {
/** /**
* Collect all permissions for a user across all matching RbacDefinitions. * Collect all permissions for a user across all matching RbacDefinitions.
*/ */
async getPermissions(userId: string, serviceAccountName?: string): Promise<Permission[]> { async getPermissions(userId: string, serviceAccountName?: string, mcpTokenSha?: string): Promise<Permission[]> {
// 1. Resolve user email // 1. Resolve user email
const user = await this.prisma.user.findUnique({ const user = await this.prisma.user.findUnique({
where: { id: userId }, where: { id: userId },
select: { email: true }, select: { email: true },
}); });
if (user === null && serviceAccountName === undefined) return []; if (user === null && serviceAccountName === undefined && mcpTokenSha === undefined) return [];
// 2. Resolve group names the user belongs to // 2. Resolve group names the user belongs to
let groupNames: string[] = []; let groupNames: string[] = [];
@@ -142,6 +145,7 @@ export class RbacService {
if (s.kind === 'User') return user !== null && s.name === user.email; if (s.kind === 'User') return user !== null && s.name === user.email;
if (s.kind === 'Group') return groupNames.includes(s.name); if (s.kind === 'Group') return groupNames.includes(s.name);
if (s.kind === 'ServiceAccount') return serviceAccountName !== undefined && s.name === serviceAccountName; if (s.kind === 'ServiceAccount') return serviceAccountName !== undefined && s.name === serviceAccountName;
if (s.kind === 'McpToken') return mcpTokenSha !== undefined && s.name === mcpTokenSha;
return false; return false;
}); });

View File

@@ -0,0 +1,21 @@
import { z } from 'zod';
import { RbacRoleBindingSchema } from './rbac-definition.schema.js';
export const McpTokenRbacMode = z.enum(['empty', 'clone']);
export type McpTokenRbacMode = z.infer<typeof McpTokenRbacMode>;
export const CreateMcpTokenSchema = z.object({
name: z
.string()
.min(1)
.max(100)
.regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'),
projectId: z.string().min(1),
description: z.string().optional(),
expiresAt: z.union([z.string().datetime(), z.date(), z.null()]).optional(),
rbacMode: McpTokenRbacMode.default('empty'),
/** Explicit bindings, added on top of the `rbacMode` base (empty or clone). */
bindings: z.array(RbacRoleBindingSchema).default([]),
});
export type CreateMcpTokenInput = z.infer<typeof CreateMcpTokenSchema>;

View File

@@ -1,7 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
export const RBAC_ROLES = ['edit', 'view', 'create', 'delete', 'run', 'expose'] as const; export const RBAC_ROLES = ['edit', 'view', 'create', 'delete', 'run', 'expose'] as const;
export const RBAC_RESOURCES = ['*', 'servers', 'instances', 'secrets', 'projects', 'templates', 'users', 'groups', 'rbac', 'prompts', 'promptrequests'] as const; export const RBAC_RESOURCES = ['*', 'servers', 'instances', 'secrets', 'projects', 'templates', 'users', 'groups', 'rbac', 'prompts', 'promptrequests', 'mcptokens'] as const;
/** Singular→plural map for resource names. */ /** Singular→plural map for resource names. */
const RESOURCE_ALIASES: Record<string, string> = { const RESOURCE_ALIASES: Record<string, string> = {
@@ -14,6 +14,7 @@ const RESOURCE_ALIASES: Record<string, string> = {
group: 'groups', group: 'groups',
prompt: 'prompts', prompt: 'prompts',
promptrequest: 'promptrequests', promptrequest: 'promptrequests',
mcptoken: 'mcptokens',
}; };
/** Normalize a resource name to its canonical plural form. */ /** Normalize a resource name to its canonical plural form. */
@@ -22,7 +23,7 @@ export function normalizeResource(resource: string): string {
} }
export const RbacSubjectSchema = z.object({ export const RbacSubjectSchema = z.object({
kind: z.enum(['User', 'Group', 'ServiceAccount']), kind: z.enum(['User', 'Group', 'ServiceAccount', 'McpToken']),
name: z.string().min(1), name: z.string().min(1),
}); });

View File

@@ -0,0 +1,246 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { McpTokenService, PermissionCeilingError } from '../src/services/mcp-token.service.js';
import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js';
import type { IMcpTokenRepository, McpTokenWithRelations } from '../src/repositories/interfaces.js';
import type { IProjectRepository } from '../src/repositories/project.repository.js';
import type { IRbacDefinitionRepository } from '../src/repositories/rbac-definition.repository.js';
import type { RbacService } from '../src/services/rbac.service.js';
import { hashToken, isMcpToken, TOKEN_PREFIX } from '@mcpctl/shared';
const PROJECT = { id: 'cproj1', name: 'myproj' };
function makeRow(overrides: Partial<McpTokenWithRelations> = {}): McpTokenWithRelations {
return {
id: 'ctok1',
name: 'mytok',
projectId: PROJECT.id,
tokenHash: 'deadbeef',
tokenPrefix: 'mcpctl_pat_abcd',
ownerId: 'cuser1',
description: '',
createdAt: new Date(),
expiresAt: null,
lastUsedAt: null,
revokedAt: null,
project: PROJECT,
owner: { id: 'cuser1', email: 'alice@example.com' },
...overrides,
};
}
function mockTokenRepo(): IMcpTokenRepository {
return {
findAll: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByHash: vi.fn(async () => null),
findByNameAndProject: vi.fn(async () => null),
create: vi.fn(async (input) => makeRow({
name: input.name,
projectId: input.projectId,
tokenHash: input.tokenHash,
tokenPrefix: input.tokenPrefix,
ownerId: input.ownerId,
description: input.description ?? '',
expiresAt: input.expiresAt ?? null,
})),
revoke: vi.fn(async (id) => makeRow({ id, revokedAt: new Date() })),
touchLastUsed: vi.fn(async () => {}),
delete: vi.fn(async () => {}),
};
}
function mockProjectRepo(): IProjectRepository {
return {
findById: vi.fn(async (id) => (id === PROJECT.id ? PROJECT : null)),
findByName: vi.fn(async (name) => (name === PROJECT.name ? PROJECT : null)),
// minimal stubs for the rest — not exercised in these tests
findAll: vi.fn(async () => []),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
attachServer: vi.fn(),
detachServer: vi.fn(),
listServers: vi.fn(async () => []),
} as unknown as IProjectRepository;
}
function mockRbacRepo(): IRbacDefinitionRepository {
return {
findAll: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByName: vi.fn(async () => null),
create: vi.fn(async () => ({ id: 'rbac-1', name: 'x', subjects: [], roleBindings: [], version: 1, createdAt: new Date(), updatedAt: new Date() })),
update: vi.fn(),
delete: vi.fn(async () => {}),
};
}
function mockRbacService(overrides: Partial<RbacService> = {}): RbacService {
return {
canAccess: vi.fn(async () => true),
canRunOperation: vi.fn(async () => true),
getAllowedScope: vi.fn(async () => ({ wildcard: true, names: new Set() })),
getPermissions: vi.fn(async () => []),
...overrides,
} as unknown as RbacService;
}
describe('McpTokenService.create', () => {
let tokenRepo: ReturnType<typeof mockTokenRepo>;
let projectRepo: IProjectRepository;
let rbacRepo: ReturnType<typeof mockRbacRepo>;
let rbacService: RbacService;
let service: McpTokenService;
beforeEach(() => {
tokenRepo = mockTokenRepo();
projectRepo = mockProjectRepo();
rbacRepo = mockRbacRepo();
rbacService = mockRbacService();
service = new McpTokenService(tokenRepo, projectRepo, rbacRepo, rbacService);
});
it('creates a token with no bindings (rbacMode=empty, default)', async () => {
const result = await service.create('cuser1', {
name: 'mytok',
projectId: PROJECT.id,
});
expect(result.raw).toMatch(new RegExp(`^${TOKEN_PREFIX}`));
expect(isMcpToken(result.raw)).toBe(true);
expect(tokenRepo.create).toHaveBeenCalledTimes(1);
// Hash must be persisted, never raw
const args = vi.mocked(tokenRepo.create).mock.calls[0]![0];
expect(args.tokenHash).toBe(hashToken(result.raw));
expect(args.tokenPrefix).toBe(result.raw.slice(0, 16));
// No RBAC definition should be created when there are no bindings
expect(rbacRepo.create).not.toHaveBeenCalled();
});
it('creates an RbacDefinition with subject McpToken:<sha> when bindings are given', async () => {
const result = await service.create('cuser1', {
name: 'mytok',
projectId: PROJECT.id,
bindings: [{ role: 'view', resource: 'servers' }],
});
expect(rbacRepo.create).toHaveBeenCalledTimes(1);
const defArgs = vi.mocked(rbacRepo.create).mock.calls[0]![0];
const subjects = defArgs.subjects as Array<{ kind: string; name: string }>;
expect(subjects).toEqual([{ kind: 'McpToken', name: hashToken(result.raw) }]);
expect(defArgs.roleBindings).toEqual([{ role: 'view', resource: 'servers' }]);
});
it('rejects bindings the creator does not have (ceiling violation)', async () => {
rbacService = mockRbacService({
canAccess: vi.fn(async () => false),
} as Partial<RbacService>);
service = new McpTokenService(tokenRepo, projectRepo, rbacRepo, rbacService);
await expect(
service.create('cuser1', {
name: 'mytok',
projectId: PROJECT.id,
bindings: [{ role: 'edit', resource: 'servers' }],
}),
).rejects.toThrow(PermissionCeilingError);
expect(tokenRepo.create).not.toHaveBeenCalled();
});
it('clones the creator\'s permissions when rbacMode=clone', async () => {
rbacService = mockRbacService({
getPermissions: vi.fn(async () => [
{ role: 'view', resource: 'servers' },
{ role: 'run', action: 'logs' },
]),
} as Partial<RbacService>);
service = new McpTokenService(tokenRepo, projectRepo, rbacRepo, rbacService);
await service.create('cuser1', {
name: 'mytok',
projectId: PROJECT.id,
rbacMode: 'clone',
});
expect(rbacRepo.create).toHaveBeenCalledTimes(1);
const defArgs = vi.mocked(rbacRepo.create).mock.calls[0]![0];
expect(defArgs.roleBindings).toEqual([
{ role: 'view', resource: 'servers' },
{ role: 'run', action: 'logs' },
]);
});
it('throws NotFoundError if project does not exist', async () => {
await expect(
service.create('cuser1', { name: 'mytok', projectId: 'nope' }),
).rejects.toThrow(NotFoundError);
});
it('throws ConflictError if active token with same name in same project exists', async () => {
vi.mocked(tokenRepo.findByNameAndProject).mockResolvedValueOnce(makeRow());
await expect(
service.create('cuser1', { name: 'mytok', projectId: PROJECT.id }),
).rejects.toThrow(ConflictError);
});
});
describe('McpTokenService.introspectRaw', () => {
let tokenRepo: ReturnType<typeof mockTokenRepo>;
let service: McpTokenService;
beforeEach(() => {
tokenRepo = mockTokenRepo();
service = new McpTokenService(tokenRepo, mockProjectRepo(), mockRbacRepo(), mockRbacService());
});
it('returns ok=false for unknown tokens', async () => {
const result = await service.introspectRaw(`${TOKEN_PREFIX}unknown`);
expect(result.ok).toBe(false);
expect(result.tokenName).toBeUndefined();
});
it('returns ok=true and principal info for active tokens, and updates lastUsedAt', async () => {
const raw = `${TOKEN_PREFIX}aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa`;
const hash = hashToken(raw);
vi.mocked(tokenRepo.findByHash).mockResolvedValueOnce(makeRow({ tokenHash: hash }));
const result = await service.introspectRaw(raw);
expect(result.ok).toBe(true);
expect(result.projectName).toBe(PROJECT.name);
expect(result.tokenName).toBe('mytok');
expect(tokenRepo.touchLastUsed).toHaveBeenCalled();
});
it('rejects revoked tokens', async () => {
const raw = `${TOKEN_PREFIX}bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb`;
vi.mocked(tokenRepo.findByHash).mockResolvedValueOnce(makeRow({ tokenHash: hashToken(raw), revokedAt: new Date() }));
const result = await service.introspectRaw(raw);
expect(result.ok).toBe(false);
expect(result.revoked).toBe(true);
});
it('rejects expired tokens', async () => {
const raw = `${TOKEN_PREFIX}cccccccccccccccccccccccccccccccc`;
const past = new Date(Date.now() - 60_000);
vi.mocked(tokenRepo.findByHash).mockResolvedValueOnce(makeRow({ tokenHash: hashToken(raw), expiresAt: past }));
const result = await service.introspectRaw(raw);
expect(result.ok).toBe(false);
expect(result.expired).toBe(true);
});
});
describe('McpTokenService.revoke', () => {
it('marks revokedAt and removes the auto-created RbacDefinition', async () => {
const tokenRepo = mockTokenRepo();
const rbacRepo = mockRbacRepo();
const service = new McpTokenService(tokenRepo, mockProjectRepo(), rbacRepo, mockRbacService());
const row = makeRow();
vi.mocked(tokenRepo.findById).mockResolvedValue(row);
vi.mocked(rbacRepo.findByName).mockResolvedValue({
id: 'rbac-ctok1', name: 'mcptoken-ctok1', subjects: [], roleBindings: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
});
await service.revoke('ctok1');
expect(tokenRepo.revoke).toHaveBeenCalledWith('ctok1');
expect(rbacRepo.findByName).toHaveBeenCalledWith('mcptoken-ctok1');
expect(rbacRepo.delete).toHaveBeenCalledWith('rbac-ctok1');
});
});

View File

@@ -3,3 +3,4 @@ export * from './validation/index.js';
export * from './constants/index.js'; export * from './constants/index.js';
export * from './utils/index.js'; export * from './utils/index.js';
export * from './secrets/index.js'; export * from './secrets/index.js';
export * from './tokens/index.js';

View File

@@ -0,0 +1,41 @@
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
export const TOKEN_PREFIX = 'mcpctl_pat_';
// base62 alphabet (URL/header safe, no ambiguous chars across all positions)
const BASE62 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
export interface GeneratedToken {
/** The raw token — `mcpctl_pat_<32 base62 chars>`. Shown once at create time; never stored. */
raw: string;
/** SHA-256 hex digest of the raw value. Persist this, not the raw value. */
hash: string;
/** First 16 chars of the raw token, safe to display (e.g. in `mcpctl get mcptoken`). */
prefix: string;
}
export function generateToken(): GeneratedToken {
const bytes = randomBytes(24);
let body = '';
for (const b of bytes) body += BASE62[b % 62];
const raw = TOKEN_PREFIX + body;
return { raw, hash: hashToken(raw), prefix: raw.slice(0, 16) };
}
export function hashToken(raw: string): string {
return createHash('sha256').update(raw).digest('hex');
}
export function isMcpToken(bearer: string): boolean {
return bearer.startsWith(TOKEN_PREFIX);
}
/** Constant-time compare two equal-length hex strings. Returns false on length mismatch. */
export function timingSafeEqualHex(a: string, b: string): boolean {
if (a.length !== b.length) return false;
try {
return timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex'));
} catch {
return false;
}
}