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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 166 additions & 0 deletions backend/src/mcp-groups/__tests__/mcp-groups-seeding.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { describe, it, expect, beforeEach, jest, mock } from 'bun:test';
import { McpGroupsSeedingService } from '../mcp-groups-seeding.service';
import { McpGroupsRepository } from '../mcp-groups.repository';

// ---------------------------------------------------------------------------
// Minimal mock template registry (avoids loading real JSON fixtures)
// ---------------------------------------------------------------------------
const MOCK_TEMPLATE = {
slug: 'test-group',
name: 'Test Group',
description: 'A test group',
credentialContractName: 'test-cred',
credentialMapping: null,
defaultDockerImage: 'test-image:latest',
version: { major: 1, minor: 0, patch: 0 },
servers: [
{
name: 'test-server',
description: 'A test server',
transportType: 'http' as const,
endpoint: 'http://localhost:8080',
recommended: true,
defaultSelected: true,
},
],
};

mock.module('../mcp-group-templates', () => ({
MCP_GROUP_TEMPLATES: { 'test-group': MOCK_TEMPLATE },
computeTemplateHash: (_t: unknown) => 'mock-hash-abc',
}));

// ---------------------------------------------------------------------------
// DB / transaction helpers
// ---------------------------------------------------------------------------
function makeTx(insertedServerId = 'server-uuid-1', insertedGroupId = 'group-uuid-1') {
const returning = jest.fn();
const values = jest.fn().mockReturnValue({ returning });
const set = jest.fn().mockReturnValue({ where: jest.fn().mockReturnValue({ returning }) });
// where() must be both awaitable (resolves to []) AND have .limit() for different call-sites
const makeWhereResult = () => {
const p = Promise.resolve([]) as any;
p.limit = jest.fn().mockResolvedValue([]);
return p;
};
const where = jest.fn().mockImplementation(makeWhereResult);
const select = jest.fn().mockReturnValue({ from: jest.fn().mockReturnValue({ where }) });
const insert = jest.fn().mockReturnValue({ values });
const update = jest.fn().mockReturnValue({ set });
const deleteFrom = jest.fn().mockReturnValue({ where: jest.fn().mockResolvedValue([]) });

// Default returning sequences: first call → group, subsequent → server
let callCount = 0;
returning.mockImplementation(() => {
callCount++;
if (callCount === 1) {
return Promise.resolve([{ id: insertedGroupId, slug: 'test-group', templateHash: null }]);
}
return Promise.resolve([{ id: insertedServerId }]);
});

return { insert, select, update, delete: deleteFrom, returning, values };
}

function makeDb(tx: ReturnType<typeof makeTx>) {
return {
transaction: jest.fn().mockImplementation((fn: (tx: any) => Promise<any>) => fn(tx)),
execute: jest.fn().mockResolvedValue({ rows: [] }),
...tx,
};
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('McpGroupsSeedingService – multi-tenant isolation', () => {
let service: McpGroupsSeedingService;
let groupsRepository: McpGroupsRepository;
let tx: ReturnType<typeof makeTx>;
let db: ReturnType<typeof makeDb>;

beforeEach(() => {
tx = makeTx();
db = makeDb(tx);

groupsRepository = {
findBySlug: jest.fn().mockResolvedValue(null), // group does not yet exist → create path
} as unknown as McpGroupsRepository;

service = new McpGroupsSeedingService(db as any, groupsRepository);
});

describe('syncTemplate – create path (group does not exist yet)', () => {
it('stamps created servers with the provided organizationId', async () => {
const orgId = 'org-abc-123';
await service.syncTemplate('test-group', false, orgId);

// Find the insert call for mcp_servers
const insertCalls = tx.insert.mock.calls;
// tx.insert is called once for mcpGroups, once for mcpServers
expect(insertCalls.length).toBeGreaterThanOrEqual(2);

// The values() arg for the server insert should include organizationId
const serverValuesCall = tx.values.mock.calls.find(
([vals]: any[]) => vals && 'transportType' in vals,
);
expect(serverValuesCall).toBeDefined();
expect(serverValuesCall![0].organizationId).toBe(orgId);
});

it('stamps created servers with null organizationId when called from syncAllTemplates (bootstrap)', async () => {
await service.syncAllTemplates();

const serverValuesCall = tx.values.mock.calls.find(
([vals]: any[]) => vals && 'transportType' in vals,
);
expect(serverValuesCall).toBeDefined();
expect(serverValuesCall![0].organizationId).toBeNull();
});
});

describe('syncTemplate – update path (group already exists)', () => {
beforeEach(() => {
(groupsRepository.findBySlug as ReturnType<typeof jest.fn>).mockResolvedValue({
id: 'group-uuid-existing',
slug: 'test-group',
templateHash: 'old-hash', // different → triggers update
});
});

it('stamps newly created servers with the provided organizationId on update', async () => {
const orgId = 'org-xyz-456';

// select().from().where().limit() returns [] → no existing server → insert branch
await service.syncTemplate('test-group', false, orgId);

const serverValuesCall = tx.values.mock.calls.find(
([vals]: any[]) => vals && 'transportType' in vals,
);
expect(serverValuesCall).toBeDefined();
expect(serverValuesCall![0].organizationId).toBe(orgId);
});
});

describe('syncTemplate – skipped path (template hash matches)', () => {
it('returns skipped action and does not insert any servers', async () => {
(groupsRepository.findBySlug as ReturnType<typeof jest.fn>).mockResolvedValue({
id: 'group-uuid-existing',
slug: 'test-group',
templateHash: 'mock-hash-abc', // same hash → skip
});

const result = await service.syncTemplate('test-group', false, 'org-any');
expect(result.action).toBe('skipped');
expect(tx.insert).not.toHaveBeenCalled();
});
});

describe('syncTemplate – unknown slug', () => {
it('throws when template slug is not found', async () => {
await expect(service.syncTemplate('nonexistent-slug')).rejects.toThrow(
"Template 'nonexistent-slug' not found",
);
});
});
});
72 changes: 72 additions & 0 deletions backend/src/mcp-groups/__tests__/mcp-groups.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, it, expect, beforeEach, jest } from 'bun:test';
import { UnauthorizedException } from '@nestjs/common';
import { McpGroupsController } from '../mcp-groups.controller';
import { McpGroupsService } from '../mcp-groups.service';
import type { AuthContext } from '../../auth/types';
import type { ImportTemplateRequestDto } from '../dto/mcp-groups.dto';

function makeService() {
return {
importTemplate: jest.fn().mockResolvedValue({ action: 'created', group: { id: 'g1' } }),
listGroups: jest.fn().mockResolvedValue([]),
listGroupsWithServers: jest.fn().mockResolvedValue([]),
listTemplates: jest.fn().mockReturnValue([]),
getGroup: jest.fn(),
getGroupBySlug: jest.fn(),
createGroup: jest.fn(),
updateGroup: jest.fn(),
deleteGroup: jest.fn(),
getServersInGroup: jest.fn().mockResolvedValue([]),
addServerToGroup: jest.fn(),
removeServerFromGroup: jest.fn(),
updateServerInGroup: jest.fn(),
syncTemplates: jest.fn(),
} as unknown as McpGroupsService;
}

const AUTH_WITH_ORG: AuthContext = {
userId: 'user-1',
organizationId: 'org-123',
roles: ['ADMIN'],
isAuthenticated: true,
provider: 'test',
};

const AUTH_NO_ORG: AuthContext = {
userId: 'user-1',
organizationId: null,
roles: ['ADMIN'],
isAuthenticated: true,
provider: 'test',
};

describe('McpGroupsController.importTemplate', () => {
let controller: McpGroupsController;
let service: McpGroupsService;

beforeEach(() => {
service = makeService();
controller = new McpGroupsController(service);
});

it('throws UnauthorizedException when auth is null', async () => {
await expect(
controller.importTemplate(null, 'some-slug', {} as ImportTemplateRequestDto),
).rejects.toBeInstanceOf(UnauthorizedException);
expect(service.importTemplate).not.toHaveBeenCalled();
});

it('throws UnauthorizedException when organizationId is null', async () => {
await expect(
controller.importTemplate(AUTH_NO_ORG, 'some-slug', {} as ImportTemplateRequestDto),
).rejects.toBeInstanceOf(UnauthorizedException);
expect(service.importTemplate).not.toHaveBeenCalled();
});

it('passes organizationId to the service when auth context is valid', async () => {
const body: ImportTemplateRequestDto = { serverCacheTokens: {} };
await controller.importTemplate(AUTH_WITH_ORG, 'my-template', body);

expect(service.importTemplate).toHaveBeenCalledWith('my-template', 'org-123', body);
});
});
25 changes: 20 additions & 5 deletions backend/src/mcp-groups/mcp-groups-seeding.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ export class McpGroupsSeedingService {
/**
* Sync all templates to the database
*
* This is a platform-level bootstrap operation; servers are created with
* organizationId = null so they are visible to all orgs as shared defaults.
*
* @param force - Force update even if template hash matches
* @returns Summary of sync operation
*/
Expand All @@ -88,7 +91,7 @@ export class McpGroupsSeedingService {
const results: TemplateSyncResult[] = [];

for (const slug of slugs) {
const result = await this.syncTemplate(slug, force);
const result = await this.syncTemplate(slug, force, null);
results.push(result);
}

Expand All @@ -113,9 +116,14 @@ export class McpGroupsSeedingService {
*
* @param slug - Template slug to sync
* @param force - Force update even if template hash matches
* @param organizationId - The org that owns the created servers, or null for platform-level bootstrap
* @returns Sync result for the template
*/
async syncTemplate(slug: string, force = false): Promise<TemplateSyncResult> {
async syncTemplate(
slug: string,
force = false,
organizationId: string | null = null,
): Promise<TemplateSyncResult> {
const template = MCP_GROUP_TEMPLATES[slug];
if (!template) {
throw new Error(`Template '${slug}' not found`);
Expand All @@ -126,7 +134,7 @@ export class McpGroupsSeedingService {

if (!existingGroup) {
// Create new group from template
return this.createGroupFromTemplate(template, templateHash);
return this.createGroupFromTemplate(template, templateHash, organizationId);
}

// Check if update is needed
Expand All @@ -143,7 +151,7 @@ export class McpGroupsSeedingService {
}

// Update existing group
return this.updateGroupFromTemplate(existingGroup.id, template, templateHash);
return this.updateGroupFromTemplate(existingGroup.id, template, templateHash, organizationId);
}

/**
Expand All @@ -152,6 +160,7 @@ export class McpGroupsSeedingService {
private async createGroupFromTemplate(
template: McpGroupTemplate,
templateHash: string,
organizationId: string | null,
): Promise<TemplateSyncResult> {
this.logger.log(`Creating group '${template.slug}' from template...`);

Expand All @@ -174,7 +183,7 @@ export class McpGroupsSeedingService {
// Create servers and relationships
let serversSynced = 0;
for (const serverTemplate of template.servers) {
const server = await this.createServer(tx, group.id, serverTemplate);
const server = await this.createServer(tx, group.id, serverTemplate, organizationId);
await this.createGroupServerRelation(tx, group.id, server.id, serverTemplate);
serversSynced++;
}
Expand All @@ -198,6 +207,7 @@ export class McpGroupsSeedingService {
groupId: string,
template: McpGroupTemplate,
templateHash: string,
organizationId: string | null,
): Promise<TemplateSyncResult> {
this.logger.log(`Updating group '${template.slug}' from template...`);

Expand Down Expand Up @@ -279,6 +289,7 @@ export class McpGroupsSeedingService {
command: serverTemplate.command ?? null,
args: serverTemplate.args ?? null,
groupId,
organizationId,
enabled: true,
Comment on lines +292 to 293

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve tenant isolation when updating existing template servers

Only the insert branch now writes organizationId; when updateGroupFromTemplate finds an existing server by name, it updates that row without setting/scoping organizationId, so shared (NULL) or other-org rows remain reused and mutable during org imports. This means a template update/import path can still operate on non-tenant-scoped server records, undermining the isolation this change is intended to enforce.

Useful? React with 👍 / 👎.

})
.returning();
Expand Down Expand Up @@ -321,11 +332,14 @@ export class McpGroupsSeedingService {

/**
* Create a server from a template
*
* @param organizationId - Org that owns this server instance, or null for platform-level servers
*/
private async createServer(
tx: NodePgDatabase,
groupId: string,
serverTemplate: any,
organizationId: string | null,
): Promise<{ id: string }> {
const [server] = await tx
.insert(mcpServers)
Expand All @@ -337,6 +351,7 @@ export class McpGroupsSeedingService {
command: serverTemplate.command ?? null,
args: serverTemplate.args ?? null,
groupId,
organizationId,
enabled: true,
})
.returning();
Expand Down
8 changes: 6 additions & 2 deletions backend/src/mcp-groups/mcp-groups.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
Post,
ParseUUIDPipe,
Query,
UnauthorizedException,
} from '@nestjs/common';
import {
ApiCreatedResponse,
Expand Down Expand Up @@ -177,10 +178,13 @@ export class McpGroupsController {
@ApiOperation({ summary: 'Import a group template (admin only)' })
@ApiOkResponse({ type: ImportGroupTemplateResponse })
async importTemplate(
@CurrentAuth() _auth: AuthContext | null,
@CurrentAuth() auth: AuthContext | null,
@Param('slug') slug: string,
@Body() body: ImportTemplateRequestDto,
): Promise<ImportGroupTemplateResponse> {
return this.mcpGroupsService.importTemplate(slug, body);
if (!auth?.organizationId) {
throw new UnauthorizedException('Organization context is required to import a template');
}
return this.mcpGroupsService.importTemplate(slug, auth.organizationId, body);
}
}
7 changes: 6 additions & 1 deletion backend/src/mcp-groups/mcp-groups.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,9 +138,14 @@ export class McpGroupsService implements OnModuleInit {

async importTemplate(
slug: string,
organizationId: string,
input?: ImportTemplateRequestDto,
): Promise<ImportGroupTemplateResponse> {
const result: TemplateSyncResult = await this.seedingService.syncTemplate(slug);
const result: TemplateSyncResult = await this.seedingService.syncTemplate(
slug,
false,
organizationId,
);
Comment on lines +144 to +148

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Seed per-org servers even when template hash is unchanged

importTemplate now passes organizationId into syncTemplate, but syncTemplate still short-circuits on the global mcp_groups.templateHash and does not verify that the calling org already has server rows; once one org has imported a slug, another org importing the same version can get skipped and no org-scoped servers are created for it. Because /mcp-servers queries are organization-filtered (org_id = current OR org_id IS NULL), that org can end up with no usable servers despite a successful import response.

Useful? React with 👍 / 👎.

const group = await this.getGroupBySlug(slug);

// If cache tokens were provided, create tools for each server
Expand Down