Skip to content
Open
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
369 changes: 369 additions & 0 deletions .github/workflows/kilo-mcp-catalog.yml

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion .oxfmtrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@
"arrowParens": "avoid",
"endOfLine": "lf",
"sortPackageJson": false,
"ignorePatterns": []
"ignorePatterns": ["services/kilo-mcp/catalog.json"]
}
4 changes: 3 additions & 1 deletion apps/web/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ const config: Config = {
'<rootDir>/../../services/kiloclaw/',
'<rootDir>/../../packages/encryption/',
'<rootDir>/../../packages/worker-utils/',
'<rootDir>/src/scripts/',
// Script tests are DB-backed and run via `pnpm script`, not jest — except
// the mcp-catalog unit tests, which only exercise pure library code.
'<rootDir>/src/scripts/(?!mcp-catalog/)',
'<rootDir>/../../.worktrees/',
],
modulePathIgnorePatterns: ['<rootDir>/../../.worktrees/'],
Expand Down
105 changes: 105 additions & 0 deletions apps/web/src/app/api/internal/mcp-catalog/token/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { NextRequest } from 'next/server';
import { generateApiToken } from '@/lib/tokens';
import {
DEFAULT_BENCHMARK_ORG_ID,
DEFAULT_BENCHMARK_USER_ID,
} from '@kilocode/auto-routing-contracts';

jest.mock('@/lib/config.server', () => ({
MCP_CATALOG_TOKEN_SECRET: 'catalog-secret',
}));

const mockRows: unknown[] = [];
const mockMembershipRows: unknown[] = [];
let mockSelectCallCount = 0;
jest.mock('@/lib/drizzle', () => ({
db: {
select: () => {
const callIndex = mockSelectCallCount++;
return {
from: () => ({
where: () => ({
limit: () => Promise.resolve(callIndex === 0 ? mockRows : mockMembershipRows),
}),
}),
};
},
},
}));

jest.mock('@/lib/tokens', () => ({
generateApiToken: jest.fn(() => 'minted-token'),
}));

import { POST } from './route';

const mockGenerateApiToken = jest.mocked(generateApiToken);

function createRequest(headers: Record<string, string> = {}) {
return new NextRequest('http://localhost:3000/api/internal/mcp-catalog/token', {
method: 'POST',
headers: { 'content-type': 'application/json', ...headers },
});
}

describe('POST /api/internal/mcp-catalog/token', () => {
beforeEach(() => {
jest.clearAllMocks();
mockRows.length = 0;
mockMembershipRows.length = 0;
mockSelectCallCount = 0;
});

it('returns 401 without the bearer secret', async () => {
mockRows.push({ id: DEFAULT_BENCHMARK_USER_ID, api_token_pepper: 'pepper' });
const res = await POST(createRequest());
expect(res.status).toBe(401);
expect(mockGenerateApiToken).not.toHaveBeenCalled();
});

it('returns 401 with the wrong bearer secret', async () => {
const res = await POST(createRequest({ authorization: 'Bearer wrong' }));
expect(res.status).toBe(401);
expect(mockGenerateApiToken).not.toHaveBeenCalled();
});

it('returns 404 when the benchmark service account does not exist', async () => {
const res = await POST(createRequest({ authorization: 'Bearer catalog-secret' }));
expect(res.status).toBe(404);
expect(mockGenerateApiToken).not.toHaveBeenCalled();
});

it('returns 404 when the benchmark organization membership is missing', async () => {
mockRows.push({ id: DEFAULT_BENCHMARK_USER_ID, api_token_pepper: 'pepper' });
const res = await POST(createRequest({ authorization: 'Bearer catalog-secret' }));
expect(res.status).toBe(404);
expect(mockGenerateApiToken).not.toHaveBeenCalled();
});

it('mints a 1h benchmarking token scoped to the benchmark organization', async () => {
const user = { id: DEFAULT_BENCHMARK_USER_ID, api_token_pepper: 'pepper' };
mockRows.push(user);
mockMembershipRows.push({ role: 'owner' });

const res = await POST(createRequest({ authorization: 'Bearer catalog-secret' }));

expect(res.status).toBe(200);
const json = (await res.json()) as {
token: string;
organizationId: string;
expiresAt: string;
};
expect(json.token).toBe('minted-token');
expect(json.organizationId).toBe(DEFAULT_BENCHMARK_ORG_ID);
expect(typeof json.expiresAt).toBe('string');
expect(mockGenerateApiToken).toHaveBeenCalledWith(
user,
{
tokenSource: 'mcp-catalog',
organizationId: DEFAULT_BENCHMARK_ORG_ID,
organizationRole: 'owner',
},
{ expiresIn: 60 * 60 }
);
});
});
83 changes: 83 additions & 0 deletions apps/web/src/app/api/internal/mcp-catalog/token/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* Internal API: mint a short-lived Kilo API token for the MCP catalog dump.
*
* Called by `.github/workflows/kilo-mcp-catalog.yml`. The catalog dump shells
* out to `kilo run` to generate missing search summaries, and the CLI
* authenticates against the gateway with a user API token. The workflow holds
* `MCP_CATALOG_TOKEN_SECRET`, a shared secret whose only purpose is this mint,
* and exchanges it for a token that belongs to the benchmarking service
* account instead of a maintainer's personal login.
*
* The minted token is a full user API token (includes `apiTokenPepper`) so the
* gateway accepts it as a real user token. It expires in 1 hour — a single
* catalog dump run — and is scoped to the benchmarking organization.
*
* URL: POST /api/internal/mcp-catalog/token
*/

import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { timingSafeEqual } from '@kilocode/encryption';
import { extractBearerToken } from '@kilocode/worker-utils/extract-bearer-token';
import { and, eq } from 'drizzle-orm';
import {
DEFAULT_BENCHMARK_ORG_ID,
DEFAULT_BENCHMARK_USER_ID,
} from '@kilocode/auto-routing-contracts';
import { kilocode_users, organization_memberships } from '@kilocode/db/schema';
import { db } from '@/lib/drizzle';
import { generateApiToken } from '@/lib/tokens';
import { MCP_CATALOG_TOKEN_SECRET } from '@/lib/config.server';

const ONE_HOUR_IN_SECONDS = 60 * 60;

export async function POST(req: NextRequest) {
const secret = extractBearerToken(req.headers.get('authorization'));
if (!MCP_CATALOG_TOKEN_SECRET || !secret || !timingSafeEqual(secret, MCP_CATALOG_TOKEN_SECRET)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

const [user] = await db
.select()
.from(kilocode_users)
.where(eq(kilocode_users.id, DEFAULT_BENCHMARK_USER_ID))
.limit(1);

if (!user) {
return NextResponse.json({ error: 'Benchmark service account not found' }, { status: 404 });
}

const [membership] = await db
.select({ role: organization_memberships.role })
.from(organization_memberships)
.where(
and(
eq(organization_memberships.kilo_user_id, DEFAULT_BENCHMARK_USER_ID),
eq(organization_memberships.organization_id, DEFAULT_BENCHMARK_ORG_ID)
)
)
.limit(1);

if (!membership) {
return NextResponse.json(
{ error: 'Benchmark organization membership not found' },
{ status: 404 }
);
}

const apiToken = generateApiToken(
user,
{
tokenSource: 'mcp-catalog',
organizationId: DEFAULT_BENCHMARK_ORG_ID,
organizationRole: membership.role,
},
{ expiresIn: ONE_HOUR_IN_SECONDS }
);

return NextResponse.json({
token: apiToken,
organizationId: DEFAULT_BENCHMARK_ORG_ID,
expiresAt: new Date(Date.now() + ONE_HOUR_IN_SECONDS * 1000).toISOString(),
});
}
4 changes: 4 additions & 0 deletions apps/web/src/lib/config.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ export const MISTRAL_API_KEY = getEnvVariable('MISTRAL_API_KEY');
export const INCEPTION_API_KEY = getEnvVariable('INCEPTION_API_KEY');
export const EXA_API_KEY = getEnvVariable('EXA_API_KEY');
export const INTERNAL_API_SECRET = getEnvVariable('INTERNAL_API_SECRET');
// Shared secret with the MCP catalog CI job
// (.github/workflows/kilo-mcp-catalog.yml). It authenticates only the mint in
// app/api/internal/mcp-catalog/token; it is never accepted as a Kilo credential.
export const MCP_CATALOG_TOKEN_SECRET = getEnvVariable('MCP_CATALOG_TOKEN_SECRET');
export function isBoundedInternalServiceTokenIssuanceEnabled(): boolean {
return getEnvVariable('BOUNDED_INTERNAL_SERVICE_TOKENS_ENABLED') === 'true';
}
Expand Down
Loading
Loading