diff --git a/apps/web/src/routers/user-router.test.ts b/apps/web/src/routers/user-router.test.ts index 576e056143..d185a2df4b 100644 --- a/apps/web/src/routers/user-router.test.ts +++ b/apps/web/src/routers/user-router.test.ts @@ -82,6 +82,32 @@ let testUser: User; let surveyTestUser: User; let skipTestUser: User; +describe('user router - getMe', () => { + it('getMe returns isAdmin: true for an admin user', async () => { + const admin = await insertTestUser({ is_admin: true }); + const caller = await createCallerForUser(admin.id); + + await expect(caller.user.getMe()).resolves.toEqual({ + success: true, + id: admin.id, + email: admin.google_user_email, + isAdmin: true, + }); + }); + + it('getMe returns isAdmin: false for a non-admin user', async () => { + const user = await insertTestUser(); + const caller = await createCallerForUser(user.id); + + await expect(caller.user.getMe()).resolves.toEqual({ + success: true, + id: user.id, + email: user.google_user_email, + isAdmin: false, + }); + }); +}); + describe('user router - updateProfile', () => { beforeAll(async () => { testUser = await insertTestUser({ diff --git a/apps/web/src/routers/user-router.ts b/apps/web/src/routers/user-router.ts index bb1c53ae31..a83bd682af 100644 --- a/apps/web/src/routers/user-router.ts +++ b/apps/web/src/routers/user-router.ts @@ -474,7 +474,11 @@ async function computeNotificationCapabilities(userId: string): Promise { - return successResult({ id: ctx.user.id, email: ctx.user.google_user_email }); + return successResult({ + id: ctx.user.id, + email: ctx.user.google_user_email, + isAdmin: ctx.user.is_admin, + }); }), getAuthProviders: baseProcedure.query(async ({ ctx }) => { diff --git a/apps/web/src/scripts/mcp-catalog/catalog.test.ts b/apps/web/src/scripts/mcp-catalog/catalog.test.ts index 52a3f63937..48b9115640 100644 --- a/apps/web/src/scripts/mcp-catalog/catalog.test.ts +++ b/apps/web/src/scripts/mcp-catalog/catalog.test.ts @@ -21,6 +21,7 @@ import { generateMissingSummaries, isDenylistedPath, parseKiloCompletion, + procedureRequiresAdmin, readCommittedSummaries, runKiloCompletion, type CatalogLeaf, @@ -99,7 +100,9 @@ describe('mcp-catalog catalog', () => { ); // Every non-internal query and mutation with a summary is a row; a // denylisted segment anywhere in the path and subscriptions are dropped, - // summary or not. `testConnection` stays because `test` is exact-only. + // summary or not. `testConnection` stays because `test` is exact-only, + // and the summary-less `debug.ping` is published and waits for the LLM + // pass because `debug` stays published and guarded. expect(rows.map(row => row.path)).toEqual([ 'organizations.subscription.cancel', 'user.deleteAccount', @@ -114,18 +117,19 @@ describe('mcp-catalog catalog', () => { 'mutation', 'query', ]); - expect(missing).toEqual([]); + expect(missing.map(leaf => leaf.path)).toEqual(['debug.ping']); }); it('treats an internal segment anywhere in the path as internal', () => { expect(isDenylistedPath('admin.users.list')).toBe(true); expect(isDenylistedPath('organizations.admin.grantCredit')).toBe(true); expect(isDenylistedPath('codingPlans.adminInsights')).toBe(true); - expect(isDenylistedPath('debug.ping')).toBe(true); expect(isDenylistedPath('test.echo')).toBe(true); expect(isDenylistedPath('slack.devRemoveDbRowOnly')).toBe(true); expect(isDenylistedPath('user.getProfile')).toBe(false); expect(isDenylistedPath('slack.testConnection')).toBe(false); + // `debug` is published and carries the guard marker, so it is not internal. + expect(isDenylistedPath('debug.ping')).toBe(false); }); it('fails on a zero-row catalog instead of emitting an empty one', () => { @@ -245,6 +249,128 @@ describe('mcp-catalog catalog', () => { ); expect(rows[0]?.summary).toBe(summary); }); + + it('marks a real admin-guarded procedure with the trailing admin marker', () => { + const { rows } = buildCatalogRows( + withMutation([queryLeaf('organizations.seatPurchases')]), + new Map([['organizations.seatPurchases', 'Lists seat purchases.']]) + ); + const row = rows[0]; + expect(row?.admin).toBe(true); + // Key order stays deterministic for the dump's byte-for-byte check. + expect(Object.keys(row ?? {})).toEqual([ + 'path', + 'kind', + 'summary', + 'inputSchema', + 'tags', + 'searchBlob', + 'admin', + ]); + }); + + it('leaves the admin key off a real non-admin procedure', () => { + const { rows } = buildCatalogRows( + withMutation([queryLeaf('user.getBalance')]), + new Map([['user.getBalance', 'Returns the credit balance.']]) + ); + expect(rows[0]).not.toHaveProperty('admin'); + expect(Object.keys(rows[0] ?? {})).toEqual([ + 'path', + 'kind', + 'summary', + 'inputSchema', + 'tags', + 'searchBlob', + ]); + }); + + it('keeps a debug leaf and marks it debug without an admin mark', () => { + const { rows, missing } = buildCatalogRows( + withMutation([queryLeaf('debug.ping')]), + new Map([['debug.ping', 'Pings the debug router.']]) + ); + const row = rows[0]; + // The fixture's own leaves are published; the mutation added to satisfy + // the drift guard carries no summary, so it waits for the LLM pass. + expect(missing.map(leaf => leaf.path)).toEqual(['user.updateProfile']); + expect(row?.debug).toBe(true); + expect(row).not.toHaveProperty('admin'); + // Key order stays deterministic for the dump's byte-for-byte check. + expect(Object.keys(row ?? {})).toEqual([ + 'path', + 'kind', + 'summary', + 'inputSchema', + 'tags', + 'searchBlob', + 'debug', + ]); + }); + + it('marks an admin-guarded debug procedure with both marks', () => { + const { rows } = buildCatalogRows( + withMutation([queryLeaf('debug.badInputError')]), + new Map([['debug.badInputError', 'Echoes a short string back from the debug router.']]) + ); + const row = rows[0]; + expect(row?.debug).toBe(true); + expect(row?.admin).toBe(true); + // Both marks are emitted at the tail in a fixed order, admin first. + expect(Object.keys(row ?? {})).toEqual([ + 'path', + 'kind', + 'summary', + 'inputSchema', + 'tags', + 'searchBlob', + 'admin', + 'debug', + ]); + }); + + it('still drops an admin leaf', () => { + const { rows, missing } = buildCatalogRows( + withMutation([queryLeaf('admin.users.list'), queryLeaf('user.getProfile')]), + new Map([ + ['admin.users.list', 'Lists users.'], + ['user.getProfile', 'Returns the profile of a user.'], + ]) + ); + expect(rows.map(row => row.path)).toEqual(['user.getProfile']); + expect(missing.map(leaf => leaf.path)).toEqual(['user.updateProfile']); + }); + + it('still drops a test leaf', () => { + const { rows, missing } = buildCatalogRows( + withMutation([queryLeaf('test.echo'), queryLeaf('user.getProfile')]), + new Map([ + ['test.echo', 'Echoes a payload.'], + ['user.getProfile', 'Returns the profile of a user.'], + ]) + ); + expect(rows.map(row => row.path)).toEqual(['user.getProfile']); + expect(missing.map(leaf => leaf.path)).toEqual(['user.updateProfile']); + }); + }); + + describe('procedureRequiresAdmin', () => { + const cases: Array<[string | null, boolean]> = [ + ['adminProcedure.input(z.object({})).query(async () => ({}))', true], + ['creditManagerProcedure.query(async () => ({}))', true], + ['superadminProcedure.query(async () => ({}))', true], + ['sessionViewerProcedure.query(async () => ({}))', true], + ['baseProcedure.query(async () => ({}))', false], + ['protectedProcedure.query(async () => ({}))', false], + // A guard elsewhere in the chain does not make the procedure admin-only. + ['baseProcedure.use(adminProcedure).query(async () => ({}))', false], + ['notAdminProcedure.query(async () => ({}))', false], + [null, false], + ]; + + it.each(cases)('treats %s as admin-guarded: %s', (source, expected) => { + expect(procedureRequiresAdmin(source)).toBe(expected); + }); }); describe('CATALOG_JSON_DISPLAY_PATH', () => { diff --git a/apps/web/src/scripts/mcp-catalog/catalog.ts b/apps/web/src/scripts/mcp-catalog/catalog.ts index 905fd75853..9a99397fe1 100644 --- a/apps/web/src/scripts/mcp-catalog/catalog.ts +++ b/apps/web/src/scripts/mcp-catalog/catalog.ts @@ -10,7 +10,8 @@ * entry point. * * Queries and mutations are published wholesale; paths with an internal-only - * segment (`admin`, `debug`, `dev`, `test`) and subscriptions stay internal. + * segment (`admin`, `dev`, `test`) and subscriptions stay internal. `debug` + * paths are published too, marked with `debug: true` for the guard. */ import { spawnSync } from 'node:child_process'; import { readFileSync, statSync } from 'node:fs'; @@ -38,25 +39,33 @@ export const ROOT_ROUTER_PATH = join(__dirname, '..', '..', 'routers', 'root-rou /** * Whether a procedure path is internal-only. A path is internal when any - * segment is `admin`, `debug`, `dev`, or `test`, or starts with `admin`, - * `debug`, or `dev` — the routers name admin and dev-only procedures `adminX` - * and `devX` (for example `organizations.admin.grantCredit` and - * `slack.devRemoveDbRowOnly`). `test` stays exact-only so user-facing calls - * such as `slack.testConnection` remain published. Subscriptions are never - * exported regardless of this check. + * segment is `test` or starts with `admin` or `dev` — the routers name admin + * and dev-only procedures `adminX` and `devX` (for example + * `organizations.admin.grantCredit` and `slack.devRemoveDbRowOnly`). `test` + * stays exact-only so user-facing calls such as `slack.testConnection` remain + * published, and `debug` stays published too: its rows carry a `debug: true` + * marker and are guarded wherever they are offered or called. Subscriptions + * are never exported regardless of this check. */ export function isDenylistedPath(path: string): boolean { return path.split('.').some(segment => { const lower = segment.toLowerCase(); - return ( - lower === 'test' || - lower.startsWith('admin') || - lower.startsWith('debug') || - lower.startsWith('dev') - ); + return lower === 'test' || lower.startsWith('admin') || lower.startsWith('dev'); }); } +/** + * Procedure builders that reject non-admin users. `apps/web/src/lib/trpc/init.ts` + * builds every admin-only procedure from `adminProcedure`, and the other three + * variants chain on it, so a chain whose head is any of these is admin-guarded. + */ +export const ADMIN_GUARD_PROCEDURES = [ + 'adminProcedure', + 'creditManagerProcedure', + 'superadminProcedure', + 'sessionViewerProcedure', +] as const; + /** Instruction every generated summary must follow. */ export const SUMMARY_INSTRUCTION = 'Write a 1-2 sentence search-friendly summary of what this call does, in words an agent would type to find it. No implementation detail. Not a restatement of the path.'; @@ -90,6 +99,17 @@ export type CatalogRow = { inputSchema: Record; tags: string[]; searchBlob: string; + /** + * Emitted only on rows whose procedure sits behind an admin guard; + * absent means every grant may use the row. + */ + admin?: true; + /** + * Emitted only on rows whose procedure's top-level segment is `debug`; + * absent means the row is not a debug endpoint. A debug row behind an admin + * guard carries both marks. + */ + debug?: true; }; /** Failure while generating summaries. `retryable` failures name the failed batch. */ @@ -249,7 +269,7 @@ function deriveTags(segments: string[], schemaKeys: string[]): string[] { return tags; } -function shapeRow(leaf: CatalogLeaf, summary: string): CatalogRow { +function shapeRow(leaf: CatalogLeaf, summary: string, admin: boolean, debug: boolean): CatalogRow { const segments = leaf.path.split('.'); const inputSchema = toInputSchema(leaf.inputs); const schemaKeys = topSchemaKeys(inputSchema); @@ -261,6 +281,10 @@ function shapeRow(leaf: CatalogLeaf, summary: string): CatalogRow { inputSchema, tags, searchBlob: [leaf.path, summary, ...tags, ...schemaKeys].filter(Boolean).join(' '), + // Emitted at the tail and only when true, in a fixed order, so the dump's + // byte-for-byte check stays deterministic for every other row. + ...(admin ? { admin: true } : {}), + ...(debug ? { debug: true } : {}), }; } @@ -283,6 +307,9 @@ export function buildCatalogRows( summaries: Map = new Map() ): { rows: CatalogRow[]; missing: CatalogLeaf[] } { let exposedMutations = 0; + // Resolved once for the whole catalog: the guard marker is decided from each + // procedure's own extracted source, never the whole router file. + const topLevelFiles = extractTopLevelRouterFiles(); const rows: CatalogRow[] = []; const missing: CatalogLeaf[] = []; for (const leaf of leaves) { @@ -291,7 +318,14 @@ export function buildCatalogRows( if (leaf.type === 'mutation') exposedMutations += 1; const summary = summaries.get(leaf.path); if (typeof summary === 'string' && summary !== '') { - rows.push(shapeRow(leaf, summary)); + // An extraction miss counts as non-admin: never hide an endpoint by accident. + const admin = procedureRequiresAdmin( + extractProcedureSource(leaf.path, topLevelFiles)?.source ?? null + ); + // The debug mark comes from the leaf's own top-level segment, so it never + // depends on static extraction succeeding. + const debug = leaf.path.split('.')[0] === 'debug'; + rows.push(shapeRow(leaf, summary, admin, debug)); } else { missing.push(leaf); } @@ -597,6 +631,18 @@ export function extractProcedureSource( return { file: currentFile, source: block }; } +/** + * True when a procedure's extracted value expression is guarded by an admin + * procedure builder. The guard must head the chain (`adminProcedure.input(…)`), + * so a base procedure that merely mentions a guard in its body is not marked; + * a null source (extraction failure) is not admin, because hiding a non-admin + * endpoint would be the worse mistake. + */ +export function procedureRequiresAdmin(source: string | null): boolean { + if (source === null) return false; + return ADMIN_GUARD_PROCEDURES.some(guard => new RegExp(`^\\s*${guard}\\b`).test(source)); +} + function capContext(text: string, limit: number): string { return text.length <= limit ? text : `${text.slice(0, limit)}\n// … truncated`; } diff --git a/services/ai-attribution/drizzle/meta/_journal.json b/services/ai-attribution/drizzle/meta/_journal.json index ad40337466..1a8978a6ef 100644 --- a/services/ai-attribution/drizzle/meta/_journal.json +++ b/services/ai-attribution/drizzle/meta/_journal.json @@ -10,4 +10,4 @@ "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/services/kilo-mcp/catalog.json b/services/kilo-mcp/catalog.json index be84af3f74..43d1cba6b5 100644 --- a/services/kilo-mcp/catalog.json +++ b/services/kilo-mcp/catalog.json @@ -9077,6 +9077,84 @@ ], "searchBlob": "codingPlans.subscribe Start a subscription to a Kilo coding plan. Pay the plan cost in credits and get the managed provider credential installed. codingplans subscribe planid idempotencykey planId idempotencyKey" }, + "debug.badInputError": { + "path": "debug.badInputError", + "kind": "query", + "summary": "Debug endpoint that echoes a short string back, used to exercise input validation errors.", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "string", + "minLength": 2, + "maxLength": 100 + }, + "tags": [ + "debug", + "badinputerror" + ], + "searchBlob": "debug.badInputError Debug endpoint that echoes a short string back, used to exercise input validation errors. debug badinputerror", + "admin": true, + "debug": true + }, + "debug.badInputObjectError": { + "path": "debug.badInputObjectError", + "kind": "query", + "summary": "Debug endpoint that takes a name and age object and echoes them back, used to exercise object input validation errors.", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 2, + "maxLength": 100 + }, + "age": { + "type": "number", + "minimum": 0, + "maximum": 150 + } + }, + "required": [ + "name", + "age" + ] + }, + "tags": [ + "debug", + "badinputobjecterror", + "name", + "age" + ], + "searchBlob": "debug.badInputObjectError Debug endpoint that takes a name and age object and echoes them back, used to exercise object input validation errors. debug badinputobjecterror name age name age", + "admin": true, + "debug": true + }, + "debug.handledTrpcError": { + "path": "debug.handledTrpcError", + "kind": "query", + "summary": "Debug endpoint that raises a handled tRPC conflict error, used to exercise structured error responses.", + "inputSchema": {}, + "tags": [ + "debug", + "handledtrpcerror" + ], + "searchBlob": "debug.handledTrpcError Debug endpoint that raises a handled tRPC conflict error, used to exercise structured error responses. debug handledtrpcerror", + "admin": true, + "debug": true + }, + "debug.unhandledError": { + "path": "debug.unhandledError", + "kind": "query", + "summary": "Debug endpoint that throws an unexpected server error, used to exercise generic error handling.", + "inputSchema": {}, + "tags": [ + "debug", + "unhandlederror" + ], + "searchBlob": "debug.unhandledError Debug endpoint that throws an unexpected server error, used to exercise generic error handling. debug unhandlederror", + "admin": true, + "debug": true + }, "deployments.cancelBuild": { "path": "deployments.cancelBuild", "kind": "mutation", @@ -15092,7 +15170,8 @@ "staticheaders", "pathpassthrough" ], - "searchBlob": "mcpGateway.createPersonal Create a personal MCP gateway connection that proxies a remote MCP server with the chosen auth mode. Returns the new connection config ID. mcpgateway createpersonal name remoteurl authmode providerissuer providerscopes staticproviderclientid staticproviderclientsecret staticheaders pathpassthrough name remoteUrl authMode providerIssuer providerScopes staticProviderClientId staticProviderClientSecret staticHeaders pathPassthrough" + "searchBlob": "mcpGateway.createPersonal Create a personal MCP gateway connection that proxies a remote MCP server with the chosen auth mode. Returns the new connection config ID. mcpgateway createpersonal name remoteurl authmode providerissuer providerscopes staticproviderclientid staticproviderclientsecret staticheaders pathpassthrough name remoteUrl authMode providerIssuer providerScopes staticProviderClientId staticProviderClientSecret staticHeaders pathPassthrough", + "admin": true }, "mcpGateway.delete": { "path": "mcpGateway.delete", @@ -15235,7 +15314,8 @@ "getpersonal", "configid" ], - "searchBlob": "mcpGateway.getPersonal Fetches details of a single personal MCP gateway configuration owned by the current user by its config ID. mcpgateway getpersonal configid configId" + "searchBlob": "mcpGateway.getPersonal Fetches details of a single personal MCP gateway configuration owned by the current user by its config ID. mcpgateway getpersonal configid configId", + "admin": true }, "mcpGateway.listOrganization": { "path": "mcpGateway.listOrganization", @@ -15271,7 +15351,8 @@ "mcpgateway", "listpersonal" ], - "searchBlob": "mcpGateway.listPersonal Lists MCP gateway configurations owned by the current user (personal scope), returning all servers the user has configured. mcpgateway listpersonal" + "searchBlob": "mcpGateway.listPersonal Lists MCP gateway configurations owned by the current user (personal scope), returning all servers the user has configured. mcpgateway listpersonal", + "admin": true }, "mcpGateway.revokeAssignment": { "path": "mcpGateway.revokeAssignment", @@ -27488,7 +27569,8 @@ "seatpurchases", "organizationid" ], - "searchBlob": "organizations.seatPurchases Get the seat purchase records for an organization ordered newest first. organizations seatpurchases organizationid organizationId" + "searchBlob": "organizations.seatPurchases Get the seat purchase records for an organization ordered newest first. organizations seatpurchases organizationid organizationId", + "admin": true }, "organizations.seats": { "path": "organizations.seats", @@ -29072,7 +29154,8 @@ "organizationid", "code_indexing_enabled" ], - "searchBlob": "organizations.settings.updateCodeIndexingFeatureFlag Enable or disable code indexing for an organization. Use as an admin to toggle the code indexing feature. organizations settings updatecodeindexingfeatureflag organizationid code_indexing_enabled organizationId code_indexing_enabled" + "searchBlob": "organizations.settings.updateCodeIndexingFeatureFlag Enable or disable code indexing for an organization. Use as an admin to toggle the code indexing feature. organizations settings updatecodeindexingfeatureflag organizationid code_indexing_enabled organizationId code_indexing_enabled", + "admin": true }, "organizations.settings.updateDataCollection": { "path": "organizations.settings.updateDataCollection", @@ -29285,7 +29368,8 @@ "clearssodomain", "organizationid" ], - "searchBlob": "organizations.sso.clearSsoDomain Remove the SSO domain from an organization. Use to clear the single sign-on domain. organizations sso clearssodomain organizationid organizationId" + "searchBlob": "organizations.sso.clearSsoDomain Remove the SSO domain from an organization. Use to clear the single sign-on domain. organizations sso clearssodomain organizationid organizationId", + "admin": true }, "organizations.sso.createConfig": { "path": "organizations.sso.createConfig", @@ -29311,7 +29395,8 @@ "createconfig", "organizationid" ], - "searchBlob": "organizations.sso.createConfig Set up SSO for an organization. Use to create the organization's single sign-on configuration. organizations sso createconfig organizationid organizationId" + "searchBlob": "organizations.sso.createConfig Set up SSO for an organization. Use to create the organization's single sign-on configuration. organizations sso createconfig organizationid organizationId", + "admin": true }, "organizations.sso.deleteConfig": { "path": "organizations.sso.deleteConfig", @@ -29337,7 +29422,8 @@ "deleteconfig", "organizationid" ], - "searchBlob": "organizations.sso.deleteConfig Delete an organization's SSO configuration. Use to turn off single sign-on. organizations sso deleteconfig organizationid organizationId" + "searchBlob": "organizations.sso.deleteConfig Delete an organization's SSO configuration. Use to turn off single sign-on. organizations sso deleteconfig organizationid organizationId", + "admin": true }, "organizations.sso.generateAdminPortalLink": { "path": "organizations.sso.generateAdminPortalLink", @@ -29372,7 +29458,8 @@ "organizationid", "linktype" ], - "searchBlob": "organizations.sso.generateAdminPortalLink Get a link to the SSO admin portal for configuring SSO or verifying a domain. Use to open the identity provider setup page. organizations sso generateadminportallink organizationid linktype organizationId linkType" + "searchBlob": "organizations.sso.generateAdminPortalLink Get a link to the SSO admin portal for configuring SSO or verifying a domain. Use to open the identity provider setup page. organizations sso generateadminportallink organizationid linktype organizationId linkType", + "admin": true }, "organizations.sso.getConfig": { "path": "organizations.sso.getConfig", @@ -29429,7 +29516,8 @@ "organizationid", "ssodomain" ], - "searchBlob": "organizations.sso.updateSsoDomain Set the email domain that an organization's SSO applies to. Use to assign an SSO domain. organizations sso updatessodomain organizationid ssodomain organizationId ssoDomain" + "searchBlob": "organizations.sso.updateSsoDomain Set the email domain that an organization's SSO applies to. Use to assign an SSO domain. organizations sso updatessodomain organizationid ssodomain organizationId ssoDomain", + "admin": true }, "organizations.subOrganizations.credits": { "path": "organizations.subOrganizations.credits", @@ -30099,7 +30187,8 @@ "organizationid", "plan" ], - "searchBlob": "organizations.updatePlan Change an organization's plan as a platform admin. Use to set the organization plan tier. organizations updateplan organizationid plan organizationId plan" + "searchBlob": "organizations.updatePlan Change an organization's plan as a platform admin. Use to set the organization plan tier. organizations updateplan organizationid plan organizationId plan", + "admin": true }, "organizations.updateSeatsRequired": { "path": "organizations.updateSeatsRequired", @@ -30129,7 +30218,8 @@ "organizationid", "seatsrequired" ], - "searchBlob": "organizations.updateSeatsRequired Require seats for an organization as a platform admin. Use to toggle seat requirements. organizations updateseatsrequired organizationid seatsrequired organizationId seatsRequired" + "searchBlob": "organizations.updateSeatsRequired Require seats for an organization as a platform admin. Use to toggle seat requirements. organizations updateseatsrequired organizationid seatsrequired organizationId seatsRequired", + "admin": true }, "organizations.usageDetails.dismissRecommendation": { "path": "organizations.usageDetails.dismissRecommendation", diff --git a/services/kilo-mcp/drizzle/0005_approval_queue.sql b/services/kilo-mcp/drizzle/0005_approval_queue.sql new file mode 100644 index 0000000000..5b0c36fbd0 --- /dev/null +++ b/services/kilo-mcp/drizzle/0005_approval_queue.sql @@ -0,0 +1,23 @@ +CREATE TABLE `mcp_approval_requests` ( + `id` text PRIMARY KEY NOT NULL, + `kilo_user_id` text NOT NULL, + `organization_id` text, + `client_id` text NOT NULL, + `path` text NOT NULL, + `kind` text NOT NULL, + `input_hash` text NOT NULL, + `input_json` text, + `status` text NOT NULL, + `created_at` text NOT NULL, + `expires_at` text NOT NULL, + `decided_at` text +); +--> statement-breakpoint +CREATE INDEX `idx_mcp_approval_requests_dedupe` ON `mcp_approval_requests` (`kilo_user_id`,`path`,`kind`,`input_hash`,`status`);--> statement-breakpoint +CREATE INDEX `idx_mcp_approval_requests_queue` ON `mcp_approval_requests` (`kilo_user_id`,`status`,`expires_at`);--> statement-breakpoint +CREATE TABLE `mcp_approval_sessions` ( + `id` text PRIMARY KEY NOT NULL, + `kilo_user_id` text NOT NULL, + `created_at` text NOT NULL, + `expires_at` text NOT NULL +); diff --git a/services/kilo-mcp/drizzle/0006_lovely_hammerhead.sql b/services/kilo-mcp/drizzle/0006_lovely_hammerhead.sql new file mode 100644 index 0000000000..5adcfb94d0 --- /dev/null +++ b/services/kilo-mcp/drizzle/0006_lovely_hammerhead.sql @@ -0,0 +1,25 @@ +CREATE TABLE `mcp_admin_authenticators` ( + `kilo_user_id` text PRIMARY KEY NOT NULL, + `secret` text NOT NULL, + `verified_at` text, + `last_used_step` integer, + `created_at` text NOT NULL, + `updated_at` text NOT NULL +); +--> statement-breakpoint +CREATE TABLE `mcp_protected_requests` ( + `id` text PRIMARY KEY NOT NULL, + `session_id` text NOT NULL, + `kilo_user_id` text NOT NULL, + `client_id` text NOT NULL, + `path` text NOT NULL, + `kind` text NOT NULL, + `input_json` text, + `status` text NOT NULL, + `attempts` integer DEFAULT 0 NOT NULL, + `created_at` text NOT NULL, + `expires_at` text NOT NULL +); +--> statement-breakpoint +CREATE INDEX `idx_mcp_protected_requests_session` ON `mcp_protected_requests` (`session_id`,`status`,`expires_at`);--> statement-breakpoint +CREATE INDEX `idx_mcp_protected_requests_owner` ON `mcp_protected_requests` (`kilo_user_id`,`status`,`expires_at`); \ No newline at end of file diff --git a/services/kilo-mcp/drizzle/0007_drop_approval_queue.sql b/services/kilo-mcp/drizzle/0007_drop_approval_queue.sql new file mode 100644 index 0000000000..3b0eb6b5c3 --- /dev/null +++ b/services/kilo-mcp/drizzle/0007_drop_approval_queue.sql @@ -0,0 +1,2 @@ +DROP TABLE `mcp_approval_requests`;--> statement-breakpoint +DROP TABLE `mcp_approval_sessions`; \ No newline at end of file diff --git a/services/kilo-mcp/drizzle/0008_watery_jazinda.sql b/services/kilo-mcp/drizzle/0008_watery_jazinda.sql new file mode 100644 index 0000000000..e299545d64 --- /dev/null +++ b/services/kilo-mcp/drizzle/0008_watery_jazinda.sql @@ -0,0 +1,2 @@ +ALTER TABLE `mcp_admin_authenticators` ADD `failed_attempts` integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE `mcp_admin_authenticators` ADD `locked_until` text; \ No newline at end of file diff --git a/services/kilo-mcp/drizzle/meta/0005_snapshot.json b/services/kilo-mcp/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000000..4d99ef917e --- /dev/null +++ b/services/kilo-mcp/drizzle/meta/0005_snapshot.json @@ -0,0 +1,291 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b9d7ac51-8f63-4b7f-92d7-f2193b9c4cf7", + "prevId": "28887465-becc-45f0-bc1c-ef2b8b0b1fe8", + "tables": { + "mcp_approval_requests": { + "name": "mcp_approval_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_hash": { + "name": "input_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_json": { + "name": "input_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "decided_at": { + "name": "decided_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_mcp_approval_requests_dedupe": { + "name": "idx_mcp_approval_requests_dedupe", + "columns": ["kilo_user_id", "path", "kind", "input_hash", "status"], + "isUnique": false + }, + "idx_mcp_approval_requests_queue": { + "name": "idx_mcp_approval_requests_queue", + "columns": ["kilo_user_id", "status", "expires_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "mcp_approval_sessions": { + "name": "mcp_approval_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_pending_authorizations": { + "name": "oauth_pending_authorizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "auth_request": { + "name": "auth_request", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_auth_code": { + "name": "device_auth_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kilo_token": { + "name": "kilo_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_oauth_pending_authorizations_device_auth_code": { + "name": "uq_oauth_pending_authorizations_device_auth_code", + "columns": ["device_auth_code"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_refresh_token_history": { + "name": "oauth_refresh_token_history", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "grant_id": { + "name": "grant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "current": { + "name": "current", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_oauth_refresh_token_grant": { + "name": "idx_oauth_refresh_token_grant", + "columns": ["user_id", "grant_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/services/kilo-mcp/drizzle/meta/0006_snapshot.json b/services/kilo-mcp/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000000..3de26903f0 --- /dev/null +++ b/services/kilo-mcp/drizzle/meta/0006_snapshot.json @@ -0,0 +1,465 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "c41cde4a-6e38-4dde-a07e-3e2173ad30f8", + "prevId": "b9d7ac51-8f63-4b7f-92d7-f2193b9c4cf7", + "tables": { + "mcp_admin_authenticators": { + "name": "mcp_admin_authenticators", + "columns": { + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_step": { + "name": "last_used_step", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "mcp_approval_requests": { + "name": "mcp_approval_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_hash": { + "name": "input_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_json": { + "name": "input_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "decided_at": { + "name": "decided_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_mcp_approval_requests_dedupe": { + "name": "idx_mcp_approval_requests_dedupe", + "columns": [ + "kilo_user_id", + "path", + "kind", + "input_hash", + "status" + ], + "isUnique": false + }, + "idx_mcp_approval_requests_queue": { + "name": "idx_mcp_approval_requests_queue", + "columns": [ + "kilo_user_id", + "status", + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "mcp_approval_sessions": { + "name": "mcp_approval_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "mcp_protected_requests": { + "name": "mcp_protected_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_json": { + "name": "input_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_mcp_protected_requests_session": { + "name": "idx_mcp_protected_requests_session", + "columns": [ + "session_id", + "status", + "expires_at" + ], + "isUnique": false + }, + "idx_mcp_protected_requests_owner": { + "name": "idx_mcp_protected_requests_owner", + "columns": [ + "kilo_user_id", + "status", + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_pending_authorizations": { + "name": "oauth_pending_authorizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "auth_request": { + "name": "auth_request", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_auth_code": { + "name": "device_auth_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kilo_token": { + "name": "kilo_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_oauth_pending_authorizations_device_auth_code": { + "name": "uq_oauth_pending_authorizations_device_auth_code", + "columns": [ + "device_auth_code" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_refresh_token_history": { + "name": "oauth_refresh_token_history", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "grant_id": { + "name": "grant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "current": { + "name": "current", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_oauth_refresh_token_grant": { + "name": "idx_oauth_refresh_token_grant", + "columns": [ + "user_id", + "grant_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/services/kilo-mcp/drizzle/meta/0007_snapshot.json b/services/kilo-mcp/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000000..954c31e548 --- /dev/null +++ b/services/kilo-mcp/drizzle/meta/0007_snapshot.json @@ -0,0 +1,312 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "3321361d-16b1-48f1-9492-dbbe5c326dd9", + "prevId": "c41cde4a-6e38-4dde-a07e-3e2173ad30f8", + "tables": { + "mcp_admin_authenticators": { + "name": "mcp_admin_authenticators", + "columns": { + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_step": { + "name": "last_used_step", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "mcp_protected_requests": { + "name": "mcp_protected_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_json": { + "name": "input_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_mcp_protected_requests_session": { + "name": "idx_mcp_protected_requests_session", + "columns": [ + "session_id", + "status", + "expires_at" + ], + "isUnique": false + }, + "idx_mcp_protected_requests_owner": { + "name": "idx_mcp_protected_requests_owner", + "columns": [ + "kilo_user_id", + "status", + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_pending_authorizations": { + "name": "oauth_pending_authorizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "auth_request": { + "name": "auth_request", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_auth_code": { + "name": "device_auth_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kilo_token": { + "name": "kilo_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_oauth_pending_authorizations_device_auth_code": { + "name": "uq_oauth_pending_authorizations_device_auth_code", + "columns": [ + "device_auth_code" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_refresh_token_history": { + "name": "oauth_refresh_token_history", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "grant_id": { + "name": "grant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "current": { + "name": "current", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_oauth_refresh_token_grant": { + "name": "idx_oauth_refresh_token_grant", + "columns": [ + "user_id", + "grant_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/services/kilo-mcp/drizzle/meta/0008_snapshot.json b/services/kilo-mcp/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000000..01f84c1b79 --- /dev/null +++ b/services/kilo-mcp/drizzle/meta/0008_snapshot.json @@ -0,0 +1,327 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "a81404eb-59c0-4398-ad5b-49fc6795ee14", + "prevId": "3321361d-16b1-48f1-9492-dbbe5c326dd9", + "tables": { + "mcp_admin_authenticators": { + "name": "mcp_admin_authenticators", + "columns": { + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_step": { + "name": "last_used_step", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "failed_attempts": { + "name": "failed_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "locked_until": { + "name": "locked_until", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "mcp_protected_requests": { + "name": "mcp_protected_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_json": { + "name": "input_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_mcp_protected_requests_session": { + "name": "idx_mcp_protected_requests_session", + "columns": [ + "session_id", + "status", + "expires_at" + ], + "isUnique": false + }, + "idx_mcp_protected_requests_owner": { + "name": "idx_mcp_protected_requests_owner", + "columns": [ + "kilo_user_id", + "status", + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_pending_authorizations": { + "name": "oauth_pending_authorizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "auth_request": { + "name": "auth_request", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_auth_code": { + "name": "device_auth_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kilo_token": { + "name": "kilo_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_oauth_pending_authorizations_device_auth_code": { + "name": "uq_oauth_pending_authorizations_device_auth_code", + "columns": [ + "device_auth_code" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_refresh_token_history": { + "name": "oauth_refresh_token_history", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "grant_id": { + "name": "grant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "current": { + "name": "current", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_oauth_refresh_token_grant": { + "name": "idx_oauth_refresh_token_grant", + "columns": [ + "user_id", + "grant_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/services/kilo-mcp/drizzle/meta/_journal.json b/services/kilo-mcp/drizzle/meta/_journal.json index 980d328401..32b0cf2376 100644 --- a/services/kilo-mcp/drizzle/meta/_journal.json +++ b/services/kilo-mcp/drizzle/meta/_journal.json @@ -36,6 +36,34 @@ "when": 1789210431926, "tag": "0004_refresh_token_history", "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1789567463639, + "tag": "0005_approval_queue", + "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1789578929200, + "tag": "0006_lovely_hammerhead", + "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1789583098804, + "tag": "0007_drop_approval_queue", + "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1789700802365, + "tag": "0008_watery_jazinda", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/services/kilo-mcp/drizzle/migrations.js b/services/kilo-mcp/drizzle/migrations.js index 1a588f0d03..a700a73310 100644 --- a/services/kilo-mcp/drizzle/migrations.js +++ b/services/kilo-mcp/drizzle/migrations.js @@ -4,6 +4,10 @@ import m0001 from './0001_cynical_karen_page.sql'; import m0002 from './0002_square_the_spike.sql'; import m0003 from './0003_wandering_colleen_wing.sql'; import m0004 from './0004_refresh_token_history.sql'; +import m0005 from './0005_approval_queue.sql'; +import m0006 from './0006_lovely_hammerhead.sql'; +import m0007 from './0007_drop_approval_queue.sql'; +import m0008 from './0008_watery_jazinda.sql'; export default { journal, @@ -13,5 +17,9 @@ export default { m0002, m0003, m0004, + m0005, + m0006, + m0007, + m0008, }, }; diff --git a/services/kilo-mcp/src/auth.test.ts b/services/kilo-mcp/src/auth.test.ts index 219fbf59bf..8db7bd5a81 100644 --- a/services/kilo-mcp/src/auth.test.ts +++ b/services/kilo-mcp/src/auth.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { forwardedAuthFromProps, ORGANIZATION_ID_HEADER } from './auth'; +import type { GrantProps } from './types'; describe('auth (props-derived identity)', () => { it('names the organization header apps/web reads', () => { @@ -18,6 +19,8 @@ describe('auth (props-derived identity)', () => { organizationId: 'org-uuid-1', kiloUserId: 'user-1', clientId: 'client-1', + adminEnabled: false, + adminEligible: false, }); }); @@ -31,4 +34,90 @@ describe('auth (props-derived identity)', () => { expect(auth.organizationId).toBeUndefined(); expect(auth.authorization).toBe('Bearer kilo-tok'); }); + + it('forwards the admin opt-in when the grant props carry it', () => { + const auth = forwardedAuthFromProps({ + kiloUserId: 'user-1', + organizationId: null, + kiloToken: 'kilo-tok', + clientId: 'client-1', + adminEnabled: true, + }); + expect(auth.adminEnabled).toBe(true); + }); + + it('fails closed for a grant issued before the admin opt-in existed', () => { + const auth = forwardedAuthFromProps({ + kiloUserId: 'user-1', + organizationId: null, + kiloToken: 'kilo-tok', + clientId: 'client-1', + }); + expect(auth.adminEnabled).toBe(false); + }); + + it('fails closed when the grant opted out', () => { + const auth = forwardedAuthFromProps({ + kiloUserId: 'user-1', + organizationId: null, + kiloToken: 'kilo-tok', + clientId: 'client-1', + adminEnabled: false, + }); + expect(auth.adminEnabled).toBe(false); + }); + + it('forwards the admin eligibility the grant carries', () => { + const auth = forwardedAuthFromProps({ + kiloUserId: 'user-1', + organizationId: null, + kiloToken: 'kilo-tok', + clientId: 'client-1', + adminEligible: true, + }); + expect(auth.adminEligible).toBe(true); + }); + + it('fails closed for admin eligibility: absent and non-true stay false', () => { + for (const props of [ + { adminEligible: undefined }, + { adminEligible: false }, + // A pre-feature grant carries no adminEligible key at all. + {}, + ] as Array>) { + const auth = forwardedAuthFromProps({ + kiloUserId: 'user-1', + organizationId: null, + kiloToken: 'kilo-tok', + clientId: 'client-1', + ...props, + }); + expect(auth.adminEligible).toBe(false); + } + }); + + it('forwards the grant connection id the protected tools bind a request to', () => { + const auth = forwardedAuthFromProps({ + kiloUserId: 'user-1', + organizationId: null, + kiloToken: 'kilo-tok', + clientId: 'client-1', + sessionId: 'sess-1', + }); + expect(auth.sessionId).toBe('sess-1'); + }); + + it('omits the connection id when the grant carries none or an empty one', () => { + for (const sessionId of [undefined, '']) { + const auth = forwardedAuthFromProps({ + kiloUserId: 'user-1', + organizationId: null, + kiloToken: 'kilo-tok', + clientId: 'client-1', + sessionId, + }); + expect(auth.sessionId).toBeUndefined(); + expect('sessionId' in auth).toBe(false); + } + }); }); diff --git a/services/kilo-mcp/src/auth.ts b/services/kilo-mcp/src/auth.ts index e763128b1e..042dd821d0 100644 --- a/services/kilo-mcp/src/auth.ts +++ b/services/kilo-mcp/src/auth.ts @@ -23,5 +23,13 @@ export function forwardedAuthFromProps(props: GrantProps): ForwardedAuth { organizationId: props.organizationId ?? undefined, kiloUserId: props.kiloUserId, clientId: props.clientId, + adminEnabled: props.adminEnabled === true, + adminEligible: props.adminEligible === true, + // The grant's own connection id, forwarded so a protected request binds to + // the session that created it. Absent or empty stays absent: a + // pre-amendment grant (no sessionId) is unusable by the protected tools. + ...(typeof props.sessionId === 'string' && props.sessionId.length > 0 + ? { sessionId: props.sessionId } + : {}), }; } diff --git a/services/kilo-mcp/src/auth/http.ts b/services/kilo-mcp/src/auth/http.ts index c2ce5c2c4d..2a8cd52362 100644 --- a/services/kilo-mcp/src/auth/http.ts +++ b/services/kilo-mcp/src/auth/http.ts @@ -78,9 +78,17 @@ export function oauthErrorResponse( ); } +/** + * An HTML page response. Every page this worker renders belongs to an OAuth + * flow, and the org picker carries the one-time authenticator secret before a + * code verifies, so no cache — browser or intermediary — may retain one. + */ export function htmlResponse(body: string, status = 200): Response { return withAuthCors( - new Response(body, { status, headers: { 'Content-Type': 'text/html; charset=utf-8' } }) + new Response(body, { + status, + headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }, + }) ); } diff --git a/services/kilo-mcp/src/call.test.ts b/services/kilo-mcp/src/call.test.ts index b9f8569fac..a9124873ea 100644 --- a/services/kilo-mcp/src/call.test.ts +++ b/services/kilo-mcp/src/call.test.ts @@ -1,6 +1,20 @@ import { describe, expect, it, vi } from 'vitest'; -import { callCatalogEndpoint, MAX_RESULT_BYTES, serializeWithCap, TRUNCATION_MARKER } from './call'; -import { JsonRpcFailure, type Catalog, type ForwardedAuth } from './types'; +import { + callCatalogEndpoint, + executeProtectedCall, + forwardCatalogCall, + MAX_RESULT_BYTES, + requestProtectedCall, + serializeWithCap, + TRUNCATION_MARKER, +} from './call'; +import { + JsonRpcFailure, + type Catalog, + type ForwardedAuth, + type OtpSubmitOutcome, + type ProtectedRequestsApi, +} from './types'; /** Inline test catalog (no committed fixture; tests never depend on catalog drift). */ const testCatalog: Catalog = { @@ -30,6 +44,29 @@ const testCatalog: Catalog = { searchBlob: 'cliSessions.search Search the user CLI sessions by keyword. clisessions search query limit', }, + 'admin.getMetrics': { + path: 'admin.getMetrics', + kind: 'query', + summary: 'Get admin-only platform metrics.', + inputSchema: {}, + tags: ['admin'], + searchBlob: 'admin.getMetrics Get admin-only platform metrics. admin getmetrics metrics', + admin: true, + }, + 'debug.getState': { + path: 'debug.getState', + kind: 'query', + summary: 'Read the debug platform state.', + inputSchema: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { verbose: { type: 'boolean' } }, + additionalProperties: false, + }, + tags: ['debug'], + searchBlob: 'debug.getState Read the debug platform state. debug getstate state', + debug: true, + }, 'organizations.create': { path: 'organizations.create', kind: 'mutation', @@ -80,6 +117,15 @@ const auth: ForwardedAuth = { kiloUserId: 'user-1', clientId: 'client-1', }; + +/** The same grant after the OTP opt-in: eligible, enabled, with a connection id. */ +const protectedAuth: ForwardedAuth = { + ...auth, + adminEnabled: true, + adminEligible: true, + sessionId: 'session-1', +}; + const WEB_BASE_URL = 'https://app.kilo.ai'; function upstreamResponse(body: unknown, status = 200): Response { @@ -89,6 +135,40 @@ function upstreamResponse(body: unknown, status = 200): Response { }); } +/** A fake pending-request store capturing what `call_protected` recorded. */ +function fakeRequestsStore(options?: { + id?: string; + expiresAt?: string; + peek?: 'pending' | 'gone'; + outcome?: OtpSubmitOutcome; + throws?: boolean; +}): ProtectedRequestsApi & { + created: Array[0]>; +} { + const created: Array[0]> = []; + return { + created, + async createProtectedRequest(input) { + if (options?.throws) throw new Error('Durable Object storage unavailable: tok_123'); + created.push(input); + return { + id: options?.id ?? 'req-1', + expiresAt: options?.expiresAt ?? '2026-09-16T00:05:00.000Z', + }; + }, + async peekProtectedRequest() { + return options?.peek === 'gone' ? { status: 'gone' } : { status: 'pending' }; + }, + async verifyOtpAndClaim() { + return options?.outcome ?? { status: 'not_pending' }; + }, + }; +} + +/** The local-rejection message a guarded path carries when the grant did not opt in. */ +const ADMIN_DENIED_MESSAGE = + '"admin.getMetrics" is an admin or debug endpoint. Reconnect the Kilo MCP server and tick "Enable admin and debug actions" at sign-in to allow admin and debug actions.'; + describe('callCatalogEndpoint', () => { it('rejects a path outside the catalog with a JSON-RPC error and NO upstream request', async () => { const fetchImpl = vi.fn(); @@ -122,6 +202,45 @@ describe('callCatalogEndpoint', () => { expect(fetchImpl).not.toHaveBeenCalled(); }); + it('rejects an admin path locally when the grant did not opt in: the checkbox copy, no upstream', async () => { + for (const authVariant of [{ ...auth, adminEnabled: false }, { ...auth }]) { + const fetchImpl = vi.fn(); + const error = await callCatalogEndpoint({ + catalog: testCatalog, + path: 'admin.getMetrics', + input: undefined, + auth: authVariant, + webBaseUrl: WEB_BASE_URL, + fetchImpl, + }).catch((e: unknown) => e); + expect(error).toBeInstanceOf(JsonRpcFailure); + expect((error as JsonRpcFailure).code).toBe(-32602); + expect((error as Error).message).toBe(ADMIN_DENIED_MESSAGE); + expect((error as JsonRpcFailure).data).toEqual({ path: 'admin.getMetrics' }); + expect(fetchImpl).not.toHaveBeenCalled(); + } + }); + + it('rejects an opted-in admin path locally too, pointing at call_protected, no upstream', async () => { + for (const path of ['admin.getMetrics', 'debug.getState']) { + const fetchImpl = vi.fn(); + const error = await callCatalogEndpoint({ + catalog: testCatalog, + path, + input: undefined, + auth: protectedAuth, + webBaseUrl: WEB_BASE_URL, + fetchImpl, + }).catch((e: unknown) => e); + expect(error).toBeInstanceOf(JsonRpcFailure); + expect((error as JsonRpcFailure).code).toBe(-32602); + expect((error as Error).message).toBe( + `"${path}" is an admin or debug endpoint. Use the call_protected tool, then submit_otp with the code from your authenticator app, to run it.` + ); + expect(fetchImpl).not.toHaveBeenCalled(); + } + }); + it('lists schema violations and skips the upstream when input is invalid', async () => { const fetchImpl = vi.fn(); const error = await callCatalogEndpoint({ @@ -710,6 +829,330 @@ describe('callCatalogEndpoint', () => { }); }); +describe('forwardCatalogCall (the shared upstream path)', () => { + it('is the one GET both tools run: urlencoded input, grant bearer, capped result', async () => { + const fetchImpl = vi.fn(async () => upstreamResponse({ result: { data: { ok: true } } })); + const outcome = await forwardCatalogCall({ + row: testCatalog['debug.getState']!, + input: { verbose: true }, + auth, + webBaseUrl: WEB_BASE_URL, + fetchImpl, + }); + expect(outcome).toEqual({ text: '{"ok":true}', truncated: false }); + const [url] = fetchImpl.mock.calls[0] as unknown as [string]; + expect(url).toBe( + 'https://app.kilo.ai/api/trpc/debug.getState?input=%7B%22verbose%22%3Atrue%7D' + ); + }); +}); + +describe('requestProtectedCall', () => { + it('rejects an unknown path with the shared unknown-path message and records nothing', async () => { + const fetchImpl = vi.fn(); + const requests = fakeRequestsStore(); + const error = await requestProtectedCall({ + catalog: testCatalog, + path: 'secrets.deleteAll', + input: undefined, + auth: protectedAuth, + requests, + webBaseUrl: WEB_BASE_URL, + fetchImpl, + }).catch((e: unknown) => e); + expect(error).toBeInstanceOf(JsonRpcFailure); + expect((error as JsonRpcFailure).code).toBe(-32602); + expect((error as Error).message).toMatch(/Unknown path/); + expect(requests.created).toEqual([]); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('rejects a non-guarded row and points at the call tool', async () => { + const fetchImpl = vi.fn(); + const requests = fakeRequestsStore(); + const error = await requestProtectedCall({ + catalog: testCatalog, + path: 'organizations.list', + input: undefined, + auth: protectedAuth, + requests, + webBaseUrl: WEB_BASE_URL, + fetchImpl, + }).catch((e: unknown) => e); + expect(error).toBeInstanceOf(JsonRpcFailure); + expect((error as JsonRpcFailure).code).toBe(-32602); + expect((error as Error).message).toBe( + '"organizations.list" is not an admin or debug endpoint. Use the call tool for it.' + ); + expect(requests.created).toEqual([]); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('rejects a schema-invalid input for a guarded row and records nothing', async () => { + const fetchImpl = vi.fn(); + const requests = fakeRequestsStore(); + const error = await requestProtectedCall({ + catalog: testCatalog, + path: 'debug.getState', + input: { verbose: 'yes' }, + auth: protectedAuth, + requests, + webBaseUrl: WEB_BASE_URL, + fetchImpl, + }).catch((e: unknown) => e); + expect(error).toBeInstanceOf(JsonRpcFailure); + expect((error as Error).message).toContain('does not match the published schema'); + expect(requests.created).toEqual([]); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('records an admin call with no input and returns otp_required with zero fetches', async () => { + const fetchImpl = vi.fn(); + const requests = fakeRequestsStore({ + id: 'req-42', + expiresAt: '2026-09-16T00:05:00.000Z', + }); + const outcome = await requestProtectedCall({ + catalog: testCatalog, + path: 'admin.getMetrics', + input: undefined, + auth: protectedAuth, + requests, + webBaseUrl: WEB_BASE_URL, + fetchImpl, + }); + expect(outcome.truncated).toBe(false); + expect(JSON.parse(outcome.text)).toEqual({ + status: 'otp_required', + request_id: 'req-42', + expires_at: '2026-09-16T00:05:00.000Z', + message: + 'Approval required: ask the user to read the current code from their authenticator app, then call submit_otp with this request_id and that code. The request expires at 2026-09-16T00:05:00.000Z.', + }); + expect(requests.created).toEqual([ + { + sessionId: 'session-1', + kiloUserId: 'user-1', + clientId: 'client-1', + path: 'admin.getMetrics', + kind: 'admin', + inputJson: null, + nowIso: expect.any(String), + }, + ]); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('records a debug call as kind debug with the reviewed input', async () => { + const fetchImpl = vi.fn(); + const requests = fakeRequestsStore({ id: 'req-dbg' }); + const outcome = await requestProtectedCall({ + catalog: testCatalog, + path: 'debug.getState', + input: { verbose: true }, + auth: protectedAuth, + requests, + webBaseUrl: WEB_BASE_URL, + fetchImpl, + }); + expect(JSON.parse(outcome.text)).toMatchObject({ + status: 'otp_required', + request_id: 'req-dbg', + }); + expect(requests.created).toEqual([ + { + sessionId: 'session-1', + kiloUserId: 'user-1', + clientId: 'client-1', + path: 'debug.getState', + kind: 'debug', + inputJson: '{"verbose":true}', + nowIso: expect.any(String), + }, + ]); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('fails closed with a retryable refusal when the pending-request store is missing', async () => { + const fetchImpl = vi.fn(); + const error = await requestProtectedCall({ + catalog: testCatalog, + path: 'admin.getMetrics', + input: undefined, + auth: protectedAuth, + webBaseUrl: WEB_BASE_URL, + fetchImpl, + }).catch((e: unknown) => e); + expect(error).toBeInstanceOf(JsonRpcFailure); + expect((error as JsonRpcFailure).code).toBe(-32000); + expect((error as JsonRpcFailure).data).toEqual({ + path: 'admin.getMetrics', + retryable: true, + }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('turns a throwing store into the same retryable refusal, never failing open', async () => { + const fetchImpl = vi.fn(); + const requests = fakeRequestsStore({ throws: true }); + const error = await requestProtectedCall({ + catalog: testCatalog, + path: 'admin.getMetrics', + input: undefined, + auth: protectedAuth, + requests, + webBaseUrl: WEB_BASE_URL, + fetchImpl, + }).catch((e: unknown) => e); + expect(error).toBeInstanceOf(JsonRpcFailure); + expect((error as JsonRpcFailure).code).toBe(-32000); + // The store's own failure text is never surfaced: it may embed a token. + expect((error as Error).message).not.toContain('tok_123'); + expect((error as JsonRpcFailure).data).toEqual({ + path: 'admin.getMetrics', + retryable: true, + }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('fails closed when the grant carries no connection id', async () => { + const fetchImpl = vi.fn(); + const requests = fakeRequestsStore(); + const error = await requestProtectedCall({ + catalog: testCatalog, + path: 'admin.getMetrics', + input: undefined, + auth: { ...auth, adminEnabled: true, adminEligible: true }, + requests, + webBaseUrl: WEB_BASE_URL, + fetchImpl, + }).catch((e: unknown) => e); + expect(error).toBeInstanceOf(JsonRpcFailure); + expect((error as JsonRpcFailure).code).toBe(-32000); + expect(requests.created).toEqual([]); + expect(fetchImpl).not.toHaveBeenCalled(); + }); +}); + +describe('executeProtectedCall', () => { + it('runs the recorded guarded path and input exactly once', async () => { + const fetchImpl = vi.fn(async () => upstreamResponse({ result: { data: { ok: true } } })); + const outcome = await executeProtectedCall({ + catalog: testCatalog, + path: 'debug.getState', + inputJson: '{"verbose":true}', + auth: protectedAuth, + webBaseUrl: WEB_BASE_URL, + fetchImpl, + }); + expect(outcome).toEqual({ text: '{"ok":true}', truncated: false }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + const [url] = fetchImpl.mock.calls[0] as unknown as [string]; + expect(url).toBe( + 'https://app.kilo.ai/api/trpc/debug.getState?input=%7B%22verbose%22%3Atrue%7D' + ); + }); + + it('runs a no-input guarded call with no input param', async () => { + const fetchImpl = vi.fn(async () => upstreamResponse({ result: { data: [1] } })); + const outcome = await executeProtectedCall({ + catalog: testCatalog, + path: 'admin.getMetrics', + inputJson: null, + auth: protectedAuth, + webBaseUrl: WEB_BASE_URL, + fetchImpl, + }); + expect(outcome.text).toBe('[1]'); + const [url] = fetchImpl.mock.calls[0] as unknown as [string]; + expect(new URL(url).searchParams.has('input')).toBe(false); + }); + + it('refuses a recorded input that no longer matches the published schema, never rewriting it', async () => { + const fetchImpl = vi.fn(); + const error = await executeProtectedCall({ + catalog: testCatalog, + path: 'debug.getState', + inputJson: '{"verbose":"yes"}', + auth: protectedAuth, + webBaseUrl: WEB_BASE_URL, + fetchImpl, + }).catch((e: unknown) => e); + expect(error).toBeInstanceOf(JsonRpcFailure); + expect((error as Error).message).toContain('does not match the published schema'); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('refuses a recorded input that is not JSON', async () => { + const fetchImpl = vi.fn(); + const error = await executeProtectedCall({ + catalog: testCatalog, + path: 'debug.getState', + inputJson: '{not json', + auth: protectedAuth, + webBaseUrl: WEB_BASE_URL, + fetchImpl, + }).catch((e: unknown) => e); + expect(error).toBeInstanceOf(JsonRpcFailure); + expect((error as Error).message).toMatch(/not valid JSON/); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('refuses when the recorded path is no longer a guarded catalog row', async () => { + const fetchImpl = vi.fn(); + // The row was demoted to an ordinary endpoint. + const demoted: Catalog = { + 'admin.getMetrics': { ...testCatalog['admin.getMetrics']!, admin: undefined }, + }; + const cases: Array<[Catalog, string]> = [ + [demoted, 'admin.getMetrics'], + [testCatalog, 'admin.removed'], + ]; + for (const [catalog, path] of cases) { + const error = await executeProtectedCall({ + catalog, + path, + inputJson: null, + auth: protectedAuth, + webBaseUrl: WEB_BASE_URL, + fetchImpl, + }).catch((e: unknown) => e); + expect(error).toBeInstanceOf(JsonRpcFailure); + expect((error as Error).message).toMatch(/no longer available/); + } + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('surfaces a tRPC failure from the recorded call with its code and status', async () => { + const fetchImpl = vi.fn(async () => + upstreamResponse( + { + error: { + message: 'Forbidden', + code: -32003, + data: { code: 'FORBIDDEN', httpStatus: 403 }, + }, + }, + 403 + ) + ); + const error = await executeProtectedCall({ + catalog: testCatalog, + path: 'admin.getMetrics', + inputJson: null, + auth: protectedAuth, + webBaseUrl: WEB_BASE_URL, + fetchImpl, + }).catch((e: unknown) => e); + expect(error).toBeInstanceOf(JsonRpcFailure); + expect((error as JsonRpcFailure).message).toBe('Forbidden'); + expect((error as JsonRpcFailure).data).toMatchObject({ + trpcCode: 'FORBIDDEN', + httpStatus: 403, + }); + }); +}); + describe('serializeWithCap', () => { it('passes small payloads through untouched', () => { expect(serializeWithCap({ a: 1 })).toEqual({ text: '{"a":1}', truncated: false }); diff --git a/services/kilo-mcp/src/call.ts b/services/kilo-mcp/src/call.ts index 8a433d3ed6..81819639fa 100644 --- a/services/kilo-mcp/src/call.ts +++ b/services/kilo-mcp/src/call.ts @@ -1,6 +1,13 @@ import { Validator, type OutputUnit, type Schema, type SchemaDraft } from '@cfworker/json-schema'; import { ORGANIZATION_ID_HEADER } from './auth'; -import { JsonRpcFailure, type Catalog, type ForwardedAuth } from './types'; +import { isGuardedRow } from './search'; +import { + JsonRpcFailure, + type Catalog, + type CatalogRow, + type ForwardedAuth, + type ProtectedRequestsApi, +} from './types'; /** JSON-RPC error codes (see https://www.jsonrpc.org/specification). */ const INVALID_PARAMS = -32602; @@ -170,58 +177,43 @@ function ambiguousMutationAppError(path: string, status: number): JsonRpcFailure } /** - * Validate `input` against the endpoint's published schema and forward the - * call to apps/web over its public tRPC transport, chosen by the catalog row's - * `kind`: - * - * - `query`: `GET {WEB_BASE_URL}/api/trpc/{path}` with `?input=` only when - * input is sent. A network failure is retryable: a GET changed nothing. - * - `mutation`: `POST` to the same URL with no `input` query parameter, - * `Content-Type: application/json`, and a JSON body (`{}` for a procedure - * that takes no input). tRPC accepts a mutation only on POST and its - * json content-type handler reads `req.json()`, so an empty body is a 400 — - * a body is always sent. A network failure leaves the outcome unknown, so it - * is reported as ambiguous, never as a blind retry. A gateway/function - * failure (a non-2xx with no tRPC error message, like app.kilo.ai's - * FUNCTION_INVOCATION_TIMEOUT 504) and a 2xx whose result cannot be read are - * the same unknown-outcome case: the POST reached the platform, so a - * mutation reports them as ambiguous too. An app-level tRPC error does not - * settle it either: tRPC raises a 5xx-class error after the resolver returned - * (output validation, post-resolver middleware), so a committed write can - * come back as an error envelope — a mutation reports a 5xx-class tRPC error - * as ambiguous as well. A 4xx-class tRPC error is raised before the write - * (validation, authorization, not-found, precondition) and keeps the ordinary - * mapping. A query keeps its plain upstream error: a GET changed nothing. - * - * apps/web resolves identity from the forwarded bearer and the organization - * header — both come from the verified grant props (see - * `forwardedAuthFromProps`): the bearer is the Kilo credential bound to the - * grant, and the organization header is the grant's organization (never a - * caller-supplied header). Either transport keeps the same `Accept`, - * `Authorization`, and organization headers and the same `fetchImpl ?? fetch` - * injection. - * - * Every rejection that can be decided locally (unknown path, schema-invalid - * input) throws a JsonRpcFailure BEFORE any upstream request is made. + * Resolve a catalog row by exact key. `hasOwnProperty` keeps the catalog an + * allowlist: a prototype name (`__proto__`) is never a row. */ -export async function callCatalogEndpoint(options: { - catalog: Catalog; - path: string; - input: unknown; - auth: ForwardedAuth; - webBaseUrl: string; - fetchImpl?: typeof fetch; -}): Promise<{ text: string; truncated: boolean }> { - const { catalog, path, input, auth, webBaseUrl } = options; - const row = Object.prototype.hasOwnProperty.call(catalog, path) ? catalog[path] : undefined; - if (!row) { - throw new JsonRpcFailure( - INVALID_PARAMS, - `Unknown path "${path}". The call tool only accepts paths published in the Kilo catalog — run the search tool first and call one of the paths it returns.`, - { path } - ); - } +function catalogRowFor(catalog: Catalog, path: string): CatalogRow | undefined { + return Object.prototype.hasOwnProperty.call(catalog, path) ? catalog[path] : undefined; +} + +/** The unknown-path refusal shared by `call` and `call_protected`. */ +function unknownPath(path: string): JsonRpcFailure { + return new JsonRpcFailure( + INVALID_PARAMS, + `Unknown path "${path}". The call tool only accepts paths published in the Kilo catalog — run the search tool first and call one of the paths it returns.`, + { path } + ); +} +/** + * Fail-closed refusal for a protected call that cannot be recorded and later + * claimed: the pending-request store is unbound, or the grant carries no + * connection id. A guarded call is never run without a recorded, approved + * request, and no store message reaches the caller. + */ +function protectedRequestsUnavailable(path: string): JsonRpcFailure { + return new JsonRpcFailure( + INTERNAL_ERROR, + `Could not record this admin or debug request for "${path}". Retry the call; if it keeps failing, reconnect the Kilo MCP server.`, + { path, retryable: true } + ); +} + +/** + * Validate `input` against the endpoint's published schema and answer whether it + * is forwarded as the tRPC `input` value. `call`, `call_protected` and + * `submit_otp`'s recorded-payload re-check all run these exact checks, so the + * payload the user approves is exactly the payload the upstream receives. + */ +function assertInputMatchesSchema(row: CatalogRow, path: string, input: unknown): boolean { const schemaIsEmpty = isNoInputSchema(row.inputSchema); const sendInput = input !== undefined && input !== null; if (schemaIsEmpty && sendInput) { @@ -254,7 +246,56 @@ export async function callCatalogEndpoint(options: { ); } } + return sendInput; +} +/** + * The one upstream path `call` and `submit_otp` share: forward an + * already-validated call to apps/web over its public tRPC transport, chosen by + * the catalog row's `kind`: + * + * - `query`: `GET {WEB_BASE_URL}/api/trpc/{path}` with `?input=` only when + * input is sent. A network failure is retryable: a GET changed nothing. + * - `mutation`: `POST` to the same URL with no `input` query parameter, + * `Content-Type: application/json`, and a JSON body (`{}` for a procedure + * that takes no input). tRPC accepts a mutation only on POST and its + * json content-type handler reads `req.json()`, so an empty body is a 400 — + * a body is always sent. A network failure leaves the outcome unknown, so it + * is reported as ambiguous, never as a blind retry. A gateway/function + * failure (a non-2xx with no tRPC error message, like app.kilo.ai's + * FUNCTION_INVOCATION_TIMEOUT 504) and a 2xx whose result cannot be read are + * the same unknown-outcome case: the POST reached the platform, so a + * mutation reports them as ambiguous too. An app-level tRPC error does not + * settle it either: tRPC raises a 5xx-class error after the resolver returned + * (output validation, post-resolver middleware), so a committed write can + * come back as an error envelope — a mutation reports a 5xx-class tRPC error + * as ambiguous as well. A 4xx-class tRPC error is raised before the write + * (validation, authorization, not-found, precondition) and keeps the ordinary + * mapping. A query keeps its plain upstream error: a GET changed nothing. + * + * apps/web resolves identity from the forwarded bearer and the organization + * header — both come from the verified grant props (see + * `forwardedAuthFromProps`): the bearer is the Kilo credential bound to the + * grant, and the organization header is the grant's organization (never a + * caller-supplied header). Either transport keeps the same `Accept`, + * `Authorization`, and organization headers and the same `fetchImpl ?? fetch` + * injection. + * + * The caller has already validated `input` against the published schema and, + * for a guarded row, obtained the user's approval; every rejection decidable + * locally is thrown before any upstream request, and this helper never + * re-decides access. + */ +export async function forwardCatalogCall(options: { + row: CatalogRow; + input: unknown; + auth: ForwardedAuth; + webBaseUrl: string; + fetchImpl?: typeof fetch; +}): Promise<{ text: string; truncated: boolean }> { + const { row, input, auth, webBaseUrl } = options; + const path = row.path; + const sendInput = input !== undefined && input !== null; const url = new URL(`/api/trpc/${row.path}`, webBaseUrl); if (row.kind === 'query' && sendInput) { url.searchParams.set('input', JSON.stringify(input)); @@ -350,3 +391,166 @@ export async function callCatalogEndpoint(options: { const data = Object.prototype.hasOwnProperty.call(result, 'data') ? result.data : null; return serializeWithCap(data); } + +/** + * The `call` tool: validate `input` against the endpoint's published schema and + * forward a non-guarded endpoint to apps/web over its public tRPC GET transport. + * + * A guarded row (admin or debug) is refused locally before any upstream + * request: `call` never runs one. Without the opt-in the refusal names the + * checkbox to tick; with it, the refusal points at the OTP-protected tools. + * apps/web enforces the admin rule again via `adminProcedure`. + */ +export async function callCatalogEndpoint(options: { + catalog: Catalog; + path: string; + input: unknown; + auth: ForwardedAuth; + webBaseUrl: string; + fetchImpl?: typeof fetch; +}): Promise<{ text: string; truncated: boolean }> { + const { catalog, path, input } = options; + const row = catalogRowFor(catalog, path); + if (!row) { + throw unknownPath(path); + } + if (isGuardedRow(row)) { + throw new JsonRpcFailure( + INVALID_PARAMS, + options.auth.adminEnabled === true + ? `"${path}" is an admin or debug endpoint. Use the call_protected tool, then submit_otp with the code from your authenticator app, to run it.` + : `"${path}" is an admin or debug endpoint. Reconnect the Kilo MCP server and tick "Enable admin and debug actions" at sign-in to allow admin and debug actions.`, + { path } + ); + } + assertInputMatchesSchema(row, path, input); + return forwardCatalogCall({ + row, + input, + auth: options.auth, + webBaseUrl: options.webBaseUrl, + ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}), + }); +} + +/** + * The `call_protected` tool: record a reviewed admin or debug call and answer + * the `otp_required` result. No upstream request is made here — the recorded + * request is claimed by `submit_otp` after the user's authenticator code + * verifies, and only then does `executeProtectedCall` run it. + */ +export async function requestProtectedCall(options: { + catalog: Catalog; + path: string; + input: unknown; + auth: ForwardedAuth; + requests?: ProtectedRequestsApi; + webBaseUrl: string; + fetchImpl?: typeof fetch; +}): Promise<{ text: string; truncated: boolean }> { + const { catalog, path, input, auth, requests } = options; + const row = catalogRowFor(catalog, path); + if (!row) { + throw unknownPath(path); + } + if (!isGuardedRow(row)) { + throw new JsonRpcFailure( + INVALID_PARAMS, + `"${path}" is not an admin or debug endpoint. Use the call tool for it.`, + { path } + ); + } + // Validate before recording anything: the payload the user approves is + // exactly the payload the schema describes. + const sendInput = assertInputMatchesSchema(row, path, input); + + const sessionId = auth.sessionId; + if (!requests || typeof sessionId !== 'string' || sessionId.length === 0) { + // Fail-closed: without a store (or a connection id to bind the request to) + // the call can never be approved, and skipping the request would let a + // guarded call run unapproved. + throw protectedRequestsUnavailable(path); + } + + let created: Awaited>; + try { + created = await requests.createProtectedRequest({ + sessionId, + kiloUserId: auth.kiloUserId, + clientId: auth.clientId, + path, + kind: row.debug === true ? 'debug' : 'admin', + inputJson: sendInput ? JSON.stringify(input) : null, + nowIso: new Date().toISOString(), + }); + } catch { + // A store that throws must not surface its own message (it may embed a + // credential) and must never let the call run unapproved. + throw protectedRequestsUnavailable(path); + } + const { id, expiresAt } = created; + + return { + text: JSON.stringify({ + status: 'otp_required', + request_id: id, + expires_at: expiresAt, + message: + 'Approval required: ask the user to read the current code from their authenticator app, then call submit_otp with this request_id and that code. The request expires at ' + + expiresAt + + '.', + }), + truncated: false, + }; +} + +/** + * The `submit_otp` execution step: run the payload a claimed request recorded. + * + * The recorded path must still be a guarded catalog row and the recorded + * `inputJson` must still satisfy that row's published schema — a mismatch is a + * local refusal, never a silent rewrite of what the user approved. Only then is + * the single upstream request made. + */ +export async function executeProtectedCall(options: { + catalog: Catalog; + path: string; + inputJson: string | null; + auth: ForwardedAuth; + webBaseUrl: string; + fetchImpl?: typeof fetch; +}): Promise<{ text: string; truncated: boolean }> { + const { catalog, path, inputJson } = options; + const row = catalogRowFor(catalog, path); + if (!row || !isGuardedRow(row)) { + // The catalog changed under a pending request (the row was demoted or + // removed); the recorded call can no longer run. + throw new JsonRpcFailure( + INVALID_PARAMS, + `The recorded admin or debug call for "${path}" is no longer available. Start a new admin or debug call with call_protected.`, + { path } + ); + } + + let input: unknown; + if (inputJson !== null) { + try { + input = JSON.parse(inputJson); + } catch { + throw new JsonRpcFailure( + INVALID_PARAMS, + `The recorded input for "${path}" is not valid JSON. Start a new admin or debug call with call_protected.`, + { path } + ); + } + } + assertInputMatchesSchema(row, path, input); + + return forwardCatalogCall({ + row, + input, + auth: options.auth, + webBaseUrl: options.webBaseUrl, + ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}), + }); +} diff --git a/services/kilo-mcp/src/db/sqlite-schema.ts b/services/kilo-mcp/src/db/sqlite-schema.ts index f88293437d..20c54c4700 100644 --- a/services/kilo-mcp/src/db/sqlite-schema.ts +++ b/services/kilo-mcp/src/db/sqlite-schema.ts @@ -3,8 +3,9 @@ import { index, integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqli /** * DO SQLite schema for the KiloMcpOAuthStore Durable Object (s6, migration tag * v1). The library (`@cloudflare/workers-oauth-provider`) owns clients, codes, - * refresh tokens, and revoked jtis in OAUTH_KV. Local state bridges consent - * and records issued refresh-token hashes for the strict reuse policy. + * refresh tokens, and revoked jtis in OAUTH_KV. Local state bridges consent, + * records issued refresh-token hashes for the strict reuse policy, and holds + * the admin authenticators and the OTP-gated protected requests (o2). * * All timestamps are ISO-8601 strings (UTC) so they compare correctly with * `>` / `<` in SQLite and clone cleanly over DO RPC. @@ -55,3 +56,82 @@ export const oauthRefreshTokenHistory = sqliteTable( }, table => [index('idx_oauth_refresh_token_grant').on(table.user_id, table.grant_id)] ); + +/** + * One TOTP authenticator per admin (o2). The row is created on first + * enrollment and its `secret` is immutable afterwards: a picker re-render must + * never invalidate the secret the admin already scanned. `verified_at` stays + * null until `confirmAuthenticator` proves possession with a current code; + * `last_used_step` records the RFC 6238 step of the last accepted execution + * code, which is what makes an accepted code single use. + * + * `failed_attempts` and `locked_until` are the account-wide wrong-code limiter: + * a protected request's own `attempts` cap is reset by every new + * `call_protected`, so the authenticator carries the count that a fresh request + * cannot reset. `MAX_OTP_FAILURES` consecutive failures set `locked_until`, and + * an accepted code clears both. + * + * `secret` is base32 and never logged; no code is ever stored. + */ +export const mcpAdminAuthenticators = sqliteTable('mcp_admin_authenticators', { + /** The admin the authenticator belongs to (one authenticator each). */ + kilo_user_id: text('kilo_user_id').primaryKey(), + /** Base32 shared secret; never logged, never echoed outside enrollment. */ + secret: text('secret').notNull(), + /** Null until a current code proved possession; an unverified row is unusable. */ + verified_at: text('verified_at'), + /** RFC 6238 step of the last accepted execution code (single use). */ + last_used_step: integer('last_used_step'), + /** Wrong execution codes since the last accepted code or lockout expiry. */ + failed_attempts: integer('failed_attempts').notNull().default(0), + /** ISO timestamp until which submissions are refused; null when unlocked. */ + locked_until: text('locked_until'), + created_at: text('created_at').notNull(), + updated_at: text('updated_at').notNull(), +}); + +/** + * One guarded MCP call waiting on its owner's OTP (o2). Created by + * `call_protected` with the reviewed payload and claimed exactly once by + * `submit_otp`; `input_json` is the only copy of the payload and disappears with + * the row. `session_id` binds the request to the grant that created it, so no + * other session can submit its code. A request is pending for + * PROTECTED_REQUEST_TTL_SECONDS, accepts at most MAX_OTP_ATTEMPTS submissions, + * and goes to `invalidated` when they are used up. + * + * Never logged: a code, a payload, or an identity. + */ +export const mcpProtectedRequests = sqliteTable( + 'mcp_protected_requests', + { + /** `crypto.randomUUID()`; handed to the caller as `request_id`. */ + id: text('id').primaryKey(), + /** The grant that created the request; only that session may submit its code. */ + session_id: text('session_id').notNull(), + /** The admin who owns the request; the authenticator is keyed by it. */ + kilo_user_id: text('kilo_user_id').notNull(), + client_id: text('client_id').notNull(), + path: text('path').notNull(), + kind: text('kind', { enum: ['admin', 'debug'] }).notNull(), + /** The reviewed JSON payload; never logged, removed with the row. */ + input_json: text('input_json'), + status: text('status', { enum: ['pending', 'used', 'invalidated'] }).notNull(), + /** Wrong submissions so far; MAX_OTP_ATTEMPTS invalidates the row. */ + attempts: integer('attempts').notNull().default(0), + created_at: text('created_at').notNull(), + /** The request is actionable for PROTECTED_REQUEST_TTL_SECONDS after creation. */ + expires_at: text('expires_at').notNull(), + }, + table => [ + index('idx_mcp_protected_requests_session').on( + table.session_id, + table.status, + table.expires_at + ), + index('idx_mcp_protected_requests_owner').on( + table.kilo_user_id, + table.status, + table.expires_at + ), + ] +); diff --git a/services/kilo-mcp/src/deployment-config.test.ts b/services/kilo-mcp/src/deployment-config.test.ts index b98553a3c2..88e67e2d38 100644 --- a/services/kilo-mcp/src/deployment-config.test.ts +++ b/services/kilo-mcp/src/deployment-config.test.ts @@ -17,3 +17,30 @@ it('uses a real or automatically provisioned production OAuth KV namespace', () expect(binding.id).not.toBe('0'.repeat(32)); } }); + +it('marks the dev AI and Vectorize bindings remote so semantic search is reachable locally', () => { + // Workers AI and Vectorize have no local emulation: `wrangler dev` reports + // them "not supported" and every search degrades to token-only, so the + // semantic half of hybrid search can never be exercised locally. The + // supported fix is `remote: true` on the binding, which proxies the local + // dev worker to the real dev resources (src/search-knn.ts + wrangler's + // binding table). Assert it here so a regression is caught in CI instead of + // silently disabling semantic search at sign-in. + const parsed = parseConfigFileTextToJson( + 'wrangler.jsonc', + readFileSync(new URL('../wrangler.jsonc', import.meta.url), 'utf8') + ); + expect(parsed.error).toBeUndefined(); + const config = parsed.config as { + env?: { + dev?: { + ai?: { binding: string; remote?: boolean }; + vectorize?: Array<{ binding: string; index_name: string; remote?: boolean }>; + }; + }; + }; + const dev = config.env?.dev; + expect(dev?.ai).toMatchObject({ binding: 'AI', remote: true }); + const vectorize = dev?.vectorize?.find(item => item.binding === 'VECTORIZE'); + expect(vectorize).toMatchObject({ index_name: 'kilo-mcp-catalog-dev', remote: true }); +}); diff --git a/services/kilo-mcp/src/index.test.ts b/services/kilo-mcp/src/index.test.ts index e8b8fb7c55..b0db961990 100644 --- a/services/kilo-mcp/src/index.test.ts +++ b/services/kilo-mcp/src/index.test.ts @@ -1,8 +1,19 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import worker, { apiHandler, clientRegistrationCallback, createMcpHandler } from './index'; -import { ANONYMOUS_DISTINCT_ID, createMcpAnalytics } from './analytics'; +import worker, { + apiHandler, + canUseProtectedActions, + clientRegistrationCallback, + createMcpHandler, +} from './index'; +import { ANONYMOUS_DISTINCT_ID, createMcpAnalytics, type McpAnalytics } from './analytics'; import { ORGANIZATION_ID_HEADER } from './auth'; -import type { Catalog, ForwardedAuth, GrantProps } from './types'; +import type { + Catalog, + ForwardedAuth, + GrantProps, + OtpSubmitOutcome, + ProtectedRequestsApi, +} from './types'; /** The default fetch handlers take the Worker ExecutionContext. */ const TEST_CTX = { @@ -34,6 +45,38 @@ const testCatalog: Catalog = { searchBlob: 'cliSessions.search Search the user CLI sessions by keyword. clisessions search query', }, + 'admin.getMetrics': { + path: 'admin.getMetrics', + kind: 'query', + summary: 'Get admin-only platform metrics.', + inputSchema: {}, + tags: ['admin'], + searchBlob: 'admin.getMetrics Get admin-only platform metrics. admin getmetrics metrics', + admin: true, + }, + 'debug.getState': { + path: 'debug.getState', + kind: 'query', + summary: 'Read the debug platform state.', + inputSchema: {}, + tags: ['debug'], + searchBlob: 'debug.getState Read the debug platform state. debug getstate state', + debug: true, + }, + 'debug.echoText': { + path: 'debug.echoText', + kind: 'query', + summary: 'Echoes a short string back from the debug router.', + inputSchema: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'string', + minLength: 2, + maxLength: 100, + }, + tags: ['debug'], + searchBlob: 'debug.echoText Echoes a short string back from the debug router. debug echotext', + debug: true, + }, 'cliSessions.revokeAll': { path: 'cliSessions.revokeAll', kind: 'mutation', @@ -64,10 +107,72 @@ const AUTH: ForwardedAuth = { organizationId: 'org-1', kiloUserId: 'user-1', clientId: 'client-1', + adminEnabled: false, +}; + +/** + * The same grant, but opted into admin and debug endpoints at sign-in: enabled, + * eligible, with the per-connection sessionId a pending request binds to. + */ +const AUTH_ADMIN: ForwardedAuth = { + ...AUTH, + adminEnabled: true, + adminEligible: true, + sessionId: 'session-1', }; const JSON_HEADERS = { 'Content-Type': 'application/json' }; +/** The recorded `createProtectedRequest` inputs, for assertions. */ +type CreatedProtectedRequest = Parameters[0]; + +/** + * A fake pending-request store. `created` captures what `call_protected` + * recorded; `peek`/`outcome` script what a `submit_otp` finds and claims. + */ +function fakeRequestsStore(options?: { + id?: string; + expiresAt?: string; + peek?: 'pending' | 'gone' | { status: 'locked'; retryAfterSeconds: number }; + outcome?: OtpSubmitOutcome; +}): ProtectedRequestsApi & { created: CreatedProtectedRequest[] } { + const created: CreatedProtectedRequest[] = []; + return { + created, + async createProtectedRequest(input) { + created.push(input); + return { + id: options?.id ?? 'req-1', + expiresAt: options?.expiresAt ?? '2026-09-16T00:05:00.000Z', + }; + }, + async peekProtectedRequest() { + if (typeof options?.peek === 'object') return options.peek; + return options?.peek === 'gone' ? { status: 'gone' } : { status: 'pending' }; + }, + async verifyOtpAndClaim() { + return options?.outcome ?? { status: 'not_pending' }; + }, + }; +} + +/** A fetch fake whose calls a test can inspect. */ +type FetchMock = typeof fetch & { + mock: { calls: Array<[string, RequestInit | undefined]> }; +}; + +/** A fetch fake that answers the `user.getMe` admin re-check. */ +function adminCheckFetch(isAdmin: boolean): FetchMock { + return vi.fn(async () => + Response.json({ result: { data: { isAdmin } } }) + ) as unknown as FetchMock; +} + +/** The URLs a fetch mock was called with. */ +function calledUrls(fetchImpl: FetchMock): string[] { + return fetchImpl.mock.calls.map(call => call[0]); +} + function makeHandler(fetchImpl?: typeof fetch) { return createMcpHandler({ catalog: testCatalog, @@ -76,6 +181,25 @@ function makeHandler(fetchImpl?: typeof fetch) { }); } +/** + * A handler with the protected-request store (and optionally a fetch fake and a + * catalog). Omitting `requests` is the unbound-store case. + */ +function protectedHandler(options?: { + requests?: ProtectedRequestsApi; + fetchImpl?: typeof fetch; + catalog?: Catalog; + analytics?: McpAnalytics; +}): ReturnType { + return createMcpHandler({ + catalog: options?.catalog ?? testCatalog, + webBaseUrl: 'https://app.kilo.ai', + ...(options?.requests ? { protectedRequests: options.requests } : {}), + ...(options?.fetchImpl ? { fetchImpl: options.fetchImpl } : {}), + ...(options?.analytics ? { analytics: options.analytics } : {}), + }); +} + async function rpc( handler: ReturnType, body: unknown, @@ -171,7 +295,7 @@ describe('JSON-RPC envelope validation (zod)', () => { }); describe('initialize / ping / tools/list', () => { - it('initialize echoes the protocol version and advertises tools', async () => { + it('initialize echoes the protocol version and plain instructions for a non-admin grant', async () => { const { json } = await rpcResult({ jsonrpc: '2.0', id: 1, @@ -182,6 +306,30 @@ describe('initialize / ping / tools/list', () => { expect(result['protocolVersion']).toBe('2025-06-18'); expect(result['serverInfo']).toMatchObject({ name: 'kilo-mcp' }); expect(result['capabilities']).toMatchObject({ tools: {} }); + // A connection without the opt-in must never see an admin or debug word. + const instructions = result['instructions'] as string; + expect(instructions).toContain('search (find catalog endpoints) and call (invoke one by path)'); + expect(instructions.toLowerCase()).not.toContain('admin'); + expect(instructions.toLowerCase()).not.toContain('debug'); + }); + + it('initialize names call_protected and submit_otp for an opted-in admin grant', async () => { + const response = await rpc( + makeHandler(), + { + jsonrpc: '2.0', + id: 2, + method: 'initialize', + params: { protocolVersion: '2025-06-18' }, + }, + AUTH_ADMIN + ); + const instructions = ((await response.json()) as { result: { instructions: string } }).result + .instructions; + expect(instructions).toContain('call_protected'); + expect(instructions).toContain('submit_otp'); + expect(instructions).toContain('authenticator app'); + expect(instructions).toContain('fixed once call_protected returns'); }); it('rejects malformed initialize params with -32602', async () => { @@ -218,9 +366,97 @@ describe('initialize / ping / tools/list', () => { }); expect(tools[1]!.inputSchema).toMatchObject({ type: 'object', - properties: { path: { type: 'string' }, input: { type: 'object' } }, + properties: { path: { type: 'string' } }, required: ['path'], }); + // The input value's shape belongs to the endpoint's published schema, so + // the tool must not constrain it to an object: debug.badInputError takes a + // string, and a record-only argument left that row uncallable. + const inputProperty = ( + tools[1]!.inputSchema.properties as Record> + ).input; + expect(inputProperty).toBeDefined(); + expect(inputProperty).not.toHaveProperty('type'); + }); + + it('tools/list adds call_protected and submit_otp only for an opted-in admin grant', async () => { + const response = await rpc( + makeHandler(), + { jsonrpc: '2.0', id: 5, method: 'tools/list' }, + AUTH_ADMIN + ); + const tools = ( + (await response.json()) as { + result: { + tools: Array<{ name: string; description: string; inputSchema: Record }>; + }; + } + ).result.tools; + expect(tools.map(tool => tool.name)).toEqual([ + 'search', + 'call', + 'call_protected', + 'submit_otp', + ]); + const callProtected = tools[2]!; + expect(callProtected.description).toContain('authenticator app'); + expect(callProtected.description).toContain('fixed once this returns'); + expect(callProtected.inputSchema).toMatchObject({ + type: 'object', + properties: { path: { type: 'string' } }, + required: ['path'], + }); + const submitOtp = tools[3]!; + expect(submitOtp.description).toContain('authenticator app'); + expect(submitOtp.description).toContain('cannot change here'); + expect(submitOtp.inputSchema).toMatchObject({ + type: 'object', + properties: { request_id: { type: 'string' }, otp: { type: 'string' } }, + required: ['request_id', 'otp'], + }); + }); + + it('tools/list gates on the sessionId: an opted-in grant without one sees only search and call', async () => { + for (const auth of [ + { ...AUTH, adminEnabled: true, adminEligible: true }, + { ...AUTH_ADMIN, sessionId: '' }, + { ...AUTH_ADMIN, adminEligible: false }, + { ...AUTH_ADMIN, adminEnabled: false }, + ]) { + const response = await rpc( + makeHandler(), + { jsonrpc: '2.0', id: 6, method: 'tools/list' }, + auth + ); + const tools = ((await response.json()) as { result: { tools: Array<{ name: string }> } }) + .result.tools; + expect(tools.map(tool => tool.name)).toEqual(['search', 'call']); + } + expect(canUseProtectedActions(AUTH_ADMIN)).toBe(true); + expect(canUseProtectedActions(AUTH)).toBe(false); + }); + + it('a connection without the opt-in cannot discover or call the protected tools', async () => { + // Every other connection gets the ordinary unknown-tool answer for either + // name — the tools are unlisted and unreachable, so neither can leak. + for (const auth of [AUTH, { ...AUTH, adminEnabled: true, adminEligible: true }]) { + for (const name of ['call_protected', 'submit_otp']) { + const { json } = await rpcResult( + { + jsonrpc: '2.0', + id: 7, + method: 'tools/call', + params: { name, arguments: {} }, + }, + undefined, + auth + ); + expect((json as { error: { code: number; message: string } }).error.code).toBe(-32602); + expect((json as { error: { code: number; message: string } }).error.message).toBe( + `Unknown tool "${name}". Available tools: search, call.` + ); + } + } }); }); @@ -270,20 +506,78 @@ describe('tools/call params and arguments validation (zod)', () => { expect((noPath.json as { error: { code: number } }).error.code).toBe(-32602); }); - it('rejects a non-object call input', async () => { - const { json } = await rpcResult({ - jsonrpc: '2.0', - id: 25, - method: 'tools/call', - params: { name: 'call', arguments: { path: 'organizations.list', input: 'nope' } }, + it('call accepts the scalar input a published schema describes and forwards exactly it', async () => { + // A published schema may describe a scalar (not an object); the tool + // argument must accept it instead of demanding an object, or the row can + // never be called at all. + const scalarCatalog: Catalog = { + 'reports.echo': { + path: 'reports.echo', + kind: 'query', + summary: 'Echoes a short string back.', + inputSchema: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'string', + minLength: 2, + maxLength: 100, + }, + tags: ['reports'], + searchBlob: 'reports.echo Echoes a short string back. reports echo', + }, + }; + const fetchImpl = vi.fn( + async () => + new Response(JSON.stringify({ result: { data: 'you sent: hello' } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + const response = await rpc( + protectedHandler({ catalog: scalarCatalog, fetchImpl }), + { + jsonrpc: '2.0', + id: 25, + method: 'tools/call', + params: { name: 'call', arguments: { path: 'reports.echo', input: 'hello' } }, + }, + AUTH + ); + const json = (await response.json()) as { result: { content: Array<{ text: string }> } }; + expect(json.result.content[0]!.text).toBe('"you sent: hello"'); + const [url] = fetchImpl.mock.calls[0] as unknown as [string]; + expect(url).toBe('https://app.kilo.ai/api/trpc/reports.echo?input=%22hello%22'); + }); + + it('call_protected accepts the scalar input a published schema describes and records exactly it', async () => { + const fetchImpl = vi.fn(); + const requests = fakeRequestsStore({ id: 'req-scalar' }); + const response = await rpc( + protectedHandler({ requests, fetchImpl }), + { + jsonrpc: '2.0', + id: 26, + method: 'tools/call', + params: { name: 'call_protected', arguments: { path: 'debug.echoText', input: 'hello' } }, + }, + AUTH_ADMIN + ); + const json = (await response.json()) as { result: { content: Array<{ text: string }> } }; + expect(JSON.parse(json.result.content[0]!.text)).toMatchObject({ + status: 'otp_required', + request_id: 'req-scalar', }); - expect((json as { error: { code: number } }).error.code).toBe(-32602); + expect(requests.created[0]).toMatchObject({ + path: 'debug.echoText', + kind: 'debug', + inputJson: '"hello"', + }); + expect(fetchImpl).not.toHaveBeenCalled(); }); it('unknown tool names are rejected', async () => { const { json } = await rpcResult({ jsonrpc: '2.0', - id: 26, + id: 27, method: 'tools/call', params: { name: 'delete', arguments: {} }, }); @@ -403,6 +697,137 @@ describe('tools/call search', () => { } }); + it('omits admin rows for a grant without the admin opt-in and returns them when it opted in', async () => { + const body = { + jsonrpc: '2.0', + id: 27, + method: 'tools/call', + params: { name: 'search', arguments: { query: 'metrics' } }, + }; + const disabled = await rpcResult(body, undefined, AUTH); + const disabledText = (disabled.json as { result: { content: Array<{ text: string }> } }).result + .content[0]!.text; + expect(disabledText).not.toContain('admin.getMetrics'); + + const enabled = await rpcResult(body, undefined, AUTH_ADMIN); + const enabledText = (enabled.json as { result: { content: Array<{ text: string }> } }).result + .content[0]!.text; + expect(enabledText).toContain('admin.getMetrics'); + }); + + it('empty: names the hidden admin and debug endpoints for an admin-eligible grant that did not opt in', async () => { + const hiddenMessage = async (query: string): Promise => { + const { json } = await rpcResult( + { + jsonrpc: '2.0', + id: 30, + method: 'tools/call', + params: { name: 'search', arguments: { query } }, + }, + undefined, + { ...AUTH, adminEligible: true } + ); + return (json as { result: { content: Array<{ text: string }> } }).result.content[0]!.text; + }; + for (const query of ['metrics', 'debug']) { + const text = await hiddenMessage(query); + const payload = JSON.parse(text) as { results: unknown[]; message: string }; + // The catalog does match; the rows are hidden, so the shape stays a + // zero-row result and the message names both hidden families and the + // checkbox to tick, with no approval-queue reference. + expect(payload.results).toEqual([]); + expect(payload.message).toContain(`No endpoints matched "${query}"`); + expect(payload.message).toContain( + 'admin or debug endpoints and are hidden for this connection' + ); + expect(payload.message).toContain('Enable admin and debug actions'); + expect(payload.message).toContain('code from your authenticator app'); + expect(payload.message).not.toContain('queue'); + } + }); + + it('empty: a non-admin or pre-feature grant gets the plain message with no admin or debug trace', async () => { + const hiddenMessage = async (auth: ForwardedAuth, query: string): Promise => { + const { json } = await rpcResult( + { + jsonrpc: '2.0', + id: 31, + method: 'tools/call', + params: { name: 'search', arguments: { query } }, + }, + undefined, + auth + ); + return (json as { result: { content: Array<{ text: string }> } }).result.content[0]!.text; + }; + // AUTH carries no adminEligible key (a pre-feature grant) and the explicit + // false both stay non-eligible: no hidden-endpoint sentence, no queue URL. + for (const auth of [AUTH, { ...AUTH, adminEligible: false }]) { + for (const query of ['metrics', 'debug', 'zzqqx nothing']) { + const text = await hiddenMessage(auth, query); + expect(text).toContain('No endpoints matched'); + expect(text).not.toContain('hidden for this connection'); + expect(text).not.toContain('queue'); + } + } + }); + + it('returns guarded hits marked requiresApproval with no hint when the grant opted in', async () => { + const search = async ( + query: string + ): Promise<{ text: string; results: Array> }> => { + const { json } = await rpcResult( + { + jsonrpc: '2.0', + id: 33, + method: 'tools/call', + params: { name: 'search', arguments: { query } }, + }, + undefined, + AUTH_ADMIN + ); + const text = (json as { result: { content: Array<{ text: string }> } }).result.content[0]! + .text; + return { + text, + results: (JSON.parse(text) as { results: Array> }).results, + }; + }; + const admin = await search('metrics'); + expect(admin.results).toContainEqual( + expect.objectContaining({ path: 'admin.getMetrics', requiresApproval: true }) + ); + expect(admin.text).not.toContain('hidden for this connection'); + + const debug = await search('debug'); + expect(debug.results).toContainEqual( + expect.objectContaining({ path: 'debug.getState', requiresApproval: true }) + ); + }); + + it('runs the semantic hook once for an empty search on a grant without the admin opt-in', async () => { + const semanticCandidates = vi.fn(async () => []); + const handler = createMcpHandler({ + catalog: testCatalog, + webBaseUrl: 'https://app.kilo.ai', + semanticCandidates, + }); + const response = await rpc( + handler, + { + jsonrpc: '2.0', + id: 34, + method: 'tools/call', + params: { name: 'search', arguments: { query: 'metrics' } }, + }, + AUTH + ); + expect(response.status).toBe(200); + // The embedding + Vectorize lookup happen once for the whole search, even + // though the admin gate hides the query's only match. + expect(semanticCandidates).toHaveBeenCalledTimes(1); + }); + it('keeps an over-cap search payload parseable JSON instead of cutting it mid-token', async () => { // Every hit now carries its full input schema, so a result set can exceed // the cap the call tool already enforces. A lone hit whose schema alone @@ -743,6 +1168,168 @@ describe('tools/call call', () => { expect(fetchImpl).not.toHaveBeenCalled(); }); + it('non-retryable: a guarded path is refused locally for a grant that did not opt in, no upstream', async () => { + const fetchImpl = vi.fn(); + for (const path of ['admin.getMetrics', 'debug.getState']) { + const { json } = await rpcResult( + { + jsonrpc: '2.0', + id: 28, + method: 'tools/call', + params: { name: 'call', arguments: { path } }, + }, + fetchImpl + ); + const error = (json as { error: { code: number; message: string } }).error; + expect(error.code).toBe(-32602); + expect(error.message).toBe( + `"${path}" is an admin or debug endpoint. Reconnect the Kilo MCP server and tick "Enable admin and debug actions" at sign-in to allow admin and debug actions.` + ); + expect(error.message).not.toContain('queue'); + } + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('non-retryable: an opted-in grant is refused by call too, pointing at call_protected, no upstream', async () => { + const fetchImpl = vi.fn(); + for (const path of ['admin.getMetrics', 'debug.getState']) { + const response = await rpc( + makeHandler(fetchImpl), + { + jsonrpc: '2.0', + id: 28, + method: 'tools/call', + params: { name: 'call', arguments: { path } }, + }, + AUTH_ADMIN + ); + const error = ((await response.json()) as { error: { code: number; message: string } }).error; + expect(error.code).toBe(-32602); + expect(error.message).toBe( + `"${path}" is an admin or debug endpoint. Use the call_protected tool, then submit_otp with the code from your authenticator app, to run it.` + ); + } + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('call_protected refuses an unknown path, an unguarded row and a schema-invalid input locally', async () => { + const fetchImpl = vi.fn(); + const requests = fakeRequestsStore(); + const handler = protectedHandler({ requests, fetchImpl }); + const refusal = async ( + path: string, + input?: unknown + ): Promise<{ code: number; message: string }> => { + const response = await rpc( + handler, + { + jsonrpc: '2.0', + id: 29, + method: 'tools/call', + params: { + name: 'call_protected', + arguments: input === undefined ? { path } : { path, input }, + }, + }, + AUTH_ADMIN + ); + return ((await response.json()) as { error: { code: number; message: string } }).error; + }; + + const unknown = await refusal('nope.goes.here'); + expect(unknown.code).toBe(-32602); + expect(unknown.message).toMatch(/Unknown path/); + + const unguarded = await refusal('organizations.list'); + expect(unguarded.code).toBe(-32602); + expect(unguarded.message).toBe( + '"organizations.list" is not an admin or debug endpoint. Use the call tool for it.' + ); + + const invalid = await refusal('debug.echoText', 5); + expect(invalid.code).toBe(-32602); + expect(invalid.message).toContain('does not match the published schema'); + + expect(requests.created).toEqual([]); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('call_protected records an admin call and returns otp_required with zero fetches', async () => { + const fetchImpl = vi.fn(); + const requests = fakeRequestsStore({ + id: 'req-42', + expiresAt: '2026-09-16T00:05:00.000Z', + }); + const response = await rpc( + protectedHandler({ requests, fetchImpl }), + { + jsonrpc: '2.0', + id: 30, + method: 'tools/call', + params: { name: 'call_protected', arguments: { path: 'admin.getMetrics' } }, + }, + AUTH_ADMIN + ); + const text = ((await response.json()) as { result: { content: Array<{ text: string }> } }) + .result.content[0]!.text; + expect(JSON.parse(text)).toEqual({ + status: 'otp_required', + request_id: 'req-42', + expires_at: '2026-09-16T00:05:00.000Z', + message: + 'Approval required: ask the user to read the current code from their authenticator app, then call submit_otp with this request_id and that code. The request expires at 2026-09-16T00:05:00.000Z.', + }); + expect(requests.created[0]).toMatchObject({ + sessionId: 'session-1', + path: 'admin.getMetrics', + kind: 'admin', + inputJson: null, + }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('call_protected records a debug call as kind debug with the reviewed input and no upstream', async () => { + const fetchImpl = vi.fn(); + const requests = fakeRequestsStore({ id: 'req-dbg' }); + const response = await rpc( + protectedHandler({ requests, fetchImpl }), + { + jsonrpc: '2.0', + id: 31, + method: 'tools/call', + params: { name: 'call_protected', arguments: { path: 'debug.getState' } }, + }, + AUTH_ADMIN + ); + const text = ((await response.json()) as { result: { content: Array<{ text: string }> } }) + .result.content[0]!.text; + expect(JSON.parse(text)).toMatchObject({ status: 'otp_required', request_id: 'req-dbg' }); + expect(requests.created[0]).toMatchObject({ + path: 'debug.getState', + kind: 'debug', + inputJson: null, + }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('non-retryable: an opted-in guarded call without the request store never fails open', async () => { + const fetchImpl = vi.fn(); + const response = await rpc( + protectedHandler({ fetchImpl }), + { + jsonrpc: '2.0', + id: 32, + method: 'tools/call', + params: { name: 'call_protected', arguments: { path: 'admin.getMetrics' } }, + }, + AUTH_ADMIN + ); + const json = (await response.json()) as { error: { code: number; data: unknown } }; + expect(json.error.code).toBe(-32000); + expect(json.error.data).toMatchObject({ path: 'admin.getMetrics', retryable: true }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + it('retryable: an upstream tRPC error becomes a JSON-RPC error carrying code and httpStatus, and a corrected retry succeeds', async () => { const failing = vi.fn( async () => @@ -820,6 +1407,233 @@ describe('tools/call call', () => { }); }); +describe('tools/call submit_otp', () => { + async function submit( + handler: ReturnType, + args: Record, + auth: ForwardedAuth = AUTH_ADMIN + ): Promise<{ response: Response; json: Record }> { + const response = await rpc( + handler, + { + jsonrpc: '2.0', + id: 50, + method: 'tools/call', + params: { name: 'submit_otp', arguments: args }, + }, + auth + ); + return { response, json: (await response.json()) as Record }; + } + + it('maps every OtpSubmitOutcome to its own refusal copy', async () => { + const cases: Array<[OtpSubmitOutcome, string]> = [ + [ + { status: 'not_pending' }, + 'This request is no longer pending. Start a new admin or debug call with call_protected.', + ], + [ + { status: 'expired' }, + 'This request expired. Start a new admin or debug call with call_protected.', + ], + [ + { status: 'invalidated' }, + 'This request was cancelled after too many incorrect codes. Start a new admin or debug call with call_protected.', + ], + [ + { status: 'bad_code', attemptsRemaining: 4 }, + 'That code is not valid. Check your authenticator app and try again. 4 attempts remaining.', + ], + [ + { status: 'reused_code' }, + 'That code was already used. Ask the user for the next code from their authenticator app, then submit it again.', + ], + [ + { status: 'no_authenticator' }, + 'No authenticator is registered for this Kilo account. Reconnect the Kilo MCP server and add your authenticator at sign-in.', + ], + [ + { status: 'locked', retryAfterSeconds: 900 }, + 'Too many incorrect codes were submitted for this Kilo account. Try again in 15 minutes, then start a new admin or debug call with call_protected.', + ], + [ + { status: 'locked', retryAfterSeconds: 20 }, + 'Too many incorrect codes were submitted for this Kilo account. Try again in 1 minute, then start a new admin or debug call with call_protected.', + ], + ]; + for (const [outcome, message] of cases) { + const requests = fakeRequestsStore({ outcome }); + const fetchImpl = adminCheckFetch(true); + const { json } = await submit(protectedHandler({ requests, fetchImpl }), { + request_id: 'req-1', + otp: '123456', + }); + const error = json['error'] as { code: number; message: string }; + expect(error.code).toBe(-32602); + expect(error.message).toBe(message); + // The wrong-code refusal states the attempts without the code. + expect(JSON.stringify(json)).not.toContain('123456'); + // No outcome other than `ok` runs the recorded call. + expect(calledUrls(fetchImpl).filter(url => url.includes('/api/trpc/debug'))).toHaveLength(0); + expect(calledUrls(fetchImpl).filter(url => url.includes('/api/trpc/admin'))).toHaveLength(0); + } + }); + + it('answers the uniform no-longer-pending refusal for a gone request and checks nothing else', async () => { + const requests = fakeRequestsStore({ peek: 'gone' }); + const fetchImpl = adminCheckFetch(true); + const { json } = await submit(protectedHandler({ requests, fetchImpl }), { + request_id: 'never-issued', + otp: '123456', + }); + expect((json['error'] as { message: string }).message).toBe( + 'This request is no longer pending. Start a new admin or debug call with call_protected.' + ); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('refuses a locked account before spending an upstream admin re-check', async () => { + const requests = fakeRequestsStore({ + peek: { status: 'locked', retryAfterSeconds: 900 }, + }); + const fetchImpl = adminCheckFetch(true); + const { json } = await submit(protectedHandler({ requests, fetchImpl }), { + request_id: 'req-1', + otp: '123456', + }); + const error = json['error'] as { code: number; message: string }; + expect(error.code).toBe(-32602); + expect(error.message).toBe( + 'Too many incorrect codes were submitted for this Kilo account. Try again in 15 minutes, then start a new admin or debug call with call_protected.' + ); + // The account-wide lock is consulted at peek, before the live admin check, + // so a locked account spends no upstream request on a guess. + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('non-retryable: an admin who lost the role cannot run an already-pending request', async () => { + const requests = fakeRequestsStore({ + outcome: { status: 'ok', path: 'admin.getMetrics', inputJson: null }, + }); + const fetchImpl = adminCheckFetch(false); + const { json } = await submit(protectedHandler({ requests, fetchImpl }), { + request_id: 'req-1', + otp: '123456', + }); + const error = json['error'] as { code: number; message: string }; + expect(error.code).toBe(-32602); + expect(error.message).toBe( + 'Your Kilo account does not have admin access, so this admin or debug call cannot run. Reconnect the Kilo MCP server if that is unexpected.' + ); + expect(calledUrls(fetchImpl).filter(url => url.includes('/api/trpc/admin'))).toHaveLength(0); + }); + + it('retryable: a thrown admin check refuses without running the call', async () => { + const requests = fakeRequestsStore({ + outcome: { status: 'ok', path: 'admin.getMetrics', inputJson: null }, + }); + const fetchImpl = vi.fn(async () => { + throw new Error('user.getMe upstream unreachable'); + }) as unknown as FetchMock; + const { json } = await submit(protectedHandler({ requests, fetchImpl }), { + request_id: 'req-1', + otp: '123456', + }); + const error = json['error'] as { code: number; message: string }; + expect(error.code).toBe(-32000); + expect(error.message).toBe( + 'Could not check admin access for this admin or debug call. Retry; if it keeps failing, reconnect the Kilo MCP server.' + ); + expect(calledUrls(fetchImpl).filter(url => url.includes('/api/trpc/admin'))).toHaveLength(0); + }); + + it('fails closed when the pending-request store is missing', async () => { + const fetchImpl = adminCheckFetch(true); + const { json } = await submit(protectedHandler({ fetchImpl }), { + request_id: 'req-1', + otp: '123456', + }); + const error = json['error'] as { code: number; data: unknown }; + expect(error.code).toBe(-32000); + expect(error.data).toMatchObject({ retryable: true }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('rejects a submit that tries to change the reviewed payload', async () => { + for (const args of [ + { request_id: 'req-1', otp: '123456', path: 'admin.getMetrics' }, + { request_id: 'req-1', otp: '123456', input: { verbose: true } }, + ]) { + const { json } = await submit(protectedHandler({ requests: fakeRequestsStore() }), args); + const error = json['error'] as { code: number; message: string }; + expect(error.code).toBe(-32602); + expect(error.message).toContain('Invalid submit_otp arguments'); + } + }); + + it('on ok runs the recorded path exactly once and returns the upstream result', async () => { + const requests = fakeRequestsStore({ + outcome: { status: 'ok', path: 'debug.getState', inputJson: null }, + }); + const fetchImpl = vi.fn(async (input: string | URL) => { + if (String(input).includes('/api/trpc/user.getMe')) { + return Response.json({ result: { data: { isAdmin: true } } }); + } + return Response.json({ result: { data: { ok: true } } }); + }) as unknown as FetchMock; + const { json } = await submit(protectedHandler({ requests, fetchImpl }), { + request_id: 'req-1', + otp: '123456', + }); + const text = (json['result'] as { content: Array<{ text: string }> }).content[0]!.text; + expect(text).toBe('{"ok":true}'); + + const urls = calledUrls(fetchImpl); + expect(urls.filter(url => url.includes('/api/trpc/debug.getState'))).toHaveLength(1); + expect(urls.filter(url => url.includes('/api/trpc/user.getMe'))).toHaveLength(1); + // The admin re-check forwards the grant's Kilo bearer exactly once. + const meCall = fetchImpl.mock.calls.find(call => call[0].includes('user.getMe')); + const meHeaders = (meCall?.[1]?.headers ?? {}) as Record; + expect(meHeaders['Authorization']).toBe('Bearer kilo-token'); + }); + + it('never puts the submitted code in a response body, an analytics event or a log line', async () => { + const code = '919191'; + const events: unknown[] = []; + const analytics: McpAnalytics = { + sessionStarted: () => {}, + toolCalled: input => { + events.push(input); + }, + searchPerformed: () => {}, + callRejected: input => { + events.push(input); + }, + oauthSignIn: () => {}, + }; + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + const errorLog = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + const requests = fakeRequestsStore({ outcome: { status: 'bad_code', attemptsRemaining: 4 } }); + const { json } = await submit( + protectedHandler({ requests, fetchImpl: adminCheckFetch(true), analytics }), + { request_id: 'req-1', otp: code } + ); + expect(JSON.stringify(json)).not.toContain(code); + expect(events.length).toBeGreaterThan(0); + expect(JSON.stringify(events)).not.toContain(code); + for (const spy of [log, errorLog]) { + for (const call of spy.mock.calls) { + expect(JSON.stringify(call)).not.toContain(code); + } + } + } finally { + log.mockRestore(); + errorLog.mockRestore(); + } + }); +}); + /** * The library transport: `@cloudflare/workers-oauth-provider` owns /mcp auth. * A request without a bearer never reaches the API handler. @@ -963,6 +1777,34 @@ describe('api handler (props-based auth)', () => { }; } + /** + * An env whose KILO_MCP_OAUTH_STORE namespace records created protected + * requests and scripts what `submit_otp` finds. `getByName` returns the DO + * stub the worker asks for. + */ + function envWithProtectedStore( + created: Array>, + options?: { peek?: 'pending' | 'gone'; outcome?: OtpSubmitOutcome } + ): Env { + return { + WEB_BASE_URL: 'https://app.kilo.ai', + KILO_MCP_OAUTH_STORE: { + getByName: () => ({ + async createProtectedRequest(input: Record) { + created.push(input); + return { id: 'req-api', expiresAt: '2026-09-16T00:05:00.000Z' }; + }, + async peekProtectedRequest() { + return options?.peek === 'gone' ? { status: 'gone' } : { status: 'pending' }; + }, + async verifyOtpAndClaim() { + return options?.outcome ?? { status: 'not_pending' }; + }, + }), + }, + } as unknown as Env; + } + it('forwards the grant Kilo token and organization to apps/web', async () => { const calls: Array<{ url: string; headers: Record }> = []; const upstream = vi.fn(async (input: string | URL, init?: RequestInit) => { @@ -996,6 +1838,160 @@ describe('api handler (props-based auth)', () => { expect(trpc?.headers[ORGANIZATION_ID_HEADER]).toBe('org-9'); }); + /** + * A real admin-guarded row in the bundled catalog that takes no input. + * `organizations.admin.*` paths are excluded from the dump (the generator + * publishes admin-guarded procedures only under non-internal paths), so an + * actual catalog row is used to exercise the guard end to end. + */ + const REAL_ADMIN_PATH = 'mcpGateway.listPersonal'; + + it('refuses a bundled admin path for a grant without the admin opt-in, before any upstream request', async () => { + const upstream = vi.fn(); + vi.stubGlobal('fetch', upstream); + const { ctx } = apiContext({ + kiloUserId: 'user-1', + organizationId: 'org-1', + kiloToken: 'kilo-1', + clientId: 'client-1', + }); + const response = await apiHandler.fetch!( + new Request('https://kilo-mcp.test/mcp', { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'call', arguments: { path: REAL_ADMIN_PATH } }, + }), + }), + { WEB_BASE_URL: 'https://app.kilo.ai' } as Env, + ctx + ); + expect(response.status).toBe(200); + const json = (await response.json()) as { error: { code: number; message: string } }; + expect(json.error.code).toBe(-32602); + expect(json.error.message).toContain('is an admin or debug endpoint'); + expect(json.error.message).toContain('Enable admin and debug actions'); + expect(upstream).not.toHaveBeenCalled(); + }); + + it('records an opted-in bundled admin path as a protected request through the DO stub, no upstream', async () => { + const upstream = vi.fn(); + vi.stubGlobal('fetch', upstream); + const created: Array> = []; + const { ctx } = apiContext({ + kiloUserId: 'user-1', + organizationId: 'org-1', + kiloToken: 'kilo-1', + clientId: 'client-1', + adminEnabled: true, + adminEligible: true, + sessionId: 'sess-1', + }); + const response = await apiHandler.fetch!( + new Request('https://kilo-mcp.test/mcp', { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'call_protected', arguments: { path: REAL_ADMIN_PATH } }, + }), + }), + envWithProtectedStore(created), + ctx + ); + expect(response.status).toBe(200); + const json = (await response.json()) as { result: { content: Array<{ text: string }> } }; + expect(JSON.parse(json.result.content[0]!.text)).toMatchObject({ + status: 'otp_required', + request_id: 'req-api', + }); + expect(created[0]).toMatchObject({ + sessionId: 'sess-1', + path: REAL_ADMIN_PATH, + kind: 'admin', + }); + expect(upstream).not.toHaveBeenCalled(); + }); + + it('runs an opted-in bundled admin path exactly once through submit_otp', async () => { + const calls: string[] = []; + const upstream = vi.fn(async (input: string | URL) => { + const url = String(input); + calls.push(url); + if (url.includes('user.getMe')) return Response.json({ result: { data: { isAdmin: true } } }); + return Response.json({ result: { data: { ok: true } } }); + }); + vi.stubGlobal('fetch', upstream); + const created: Array> = []; + const { ctx } = apiContext({ + kiloUserId: 'user-1', + organizationId: 'org-1', + kiloToken: 'kilo-1', + clientId: 'client-1', + adminEnabled: true, + adminEligible: true, + sessionId: 'sess-1', + }); + const response = await apiHandler.fetch!( + new Request('https://kilo-mcp.test/mcp', { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'submit_otp', arguments: { request_id: 'req-api', otp: '123456' } }, + }), + }), + envWithProtectedStore(created, { + outcome: { status: 'ok', path: REAL_ADMIN_PATH, inputJson: null }, + }), + ctx + ); + const json = (await response.json()) as { result: { content: Array<{ text: string }> } }; + expect(json.result.content[0]!.text).toBe('{"ok":true}'); + expect(calls.filter(url => url.includes(`/api/trpc/${REAL_ADMIN_PATH}`))).toHaveLength(1); + }); + + it('search omits bundled admin rows for a grant without the opt-in and returns them when it opted in', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => Response.json({})) + ); + const search = async (adminEnabled?: boolean): Promise => { + const { ctx } = apiContext({ + kiloUserId: 'user-1', + organizationId: 'org-1', + kiloToken: 'kilo-1', + clientId: 'client-1', + ...(adminEnabled ? { adminEnabled } : {}), + }); + const response = await apiHandler.fetch!( + new Request('https://kilo-mcp.test/mcp', { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'search', arguments: { query: REAL_ADMIN_PATH } }, + }), + }), + { WEB_BASE_URL: 'https://app.kilo.ai' } as Env, + ctx + ); + return ((await response.json()) as { result: { content: Array<{ text: string }> } }).result + .content[0]!.text; + }; + expect(await search()).not.toContain(REAL_ADMIN_PATH); + expect(await search(true)).toContain(REAL_ADMIN_PATH); + }); + it('keeps the MCP CORS contract for a direct OPTIONS request', async () => { const { ctx } = apiContext({ kiloUserId: 'user-1', diff --git a/services/kilo-mcp/src/index.ts b/services/kilo-mcp/src/index.ts index a9ecc1f1b8..4f0895e4f3 100644 --- a/services/kilo-mcp/src/index.ts +++ b/services/kilo-mcp/src/index.ts @@ -9,8 +9,14 @@ import { } from './analytics'; import { forwardedAuthFromProps } from './auth'; import { MCP_SCOPE, scopeTokens } from './auth/http'; -import { callCatalogEndpoint, MAX_RESULT_BYTES } from './call'; +import { + callCatalogEndpoint, + executeProtectedCall, + MAX_RESULT_BYTES, + requestProtectedCall, +} from './call'; import { createDefaultHandler } from './oauth/consent'; +import { fetchUserIsAdmin } from './oauth/kilo-pairing'; import { onError, tokenExchangeCallback } from './oauth/provider-hooks'; import { createRefreshReuseHandler, isTokenRequest } from './oauth/refresh-reuse'; import { @@ -19,6 +25,7 @@ import { initializeParamsSchema, jsonRpcEnvelopeSchema, searchArgsSchema, + submitOtpArgsSchema, toolsCallParamsSchema, type JsonRpcEnvelope, } from './schemas'; @@ -26,7 +33,7 @@ import { DEFAULT_SEARCH_LIMIT, MAX_SEARCH_LIMIT, noSemanticCandidates, - searchCatalog, + searchCatalogDetailed, } from './search'; import { createSemanticCandidates } from './search-knn'; import { getKiloMcpOAuthStoreStub, KiloMcpOAuthStore as OAuthStore } from './store/oauth-store'; @@ -35,6 +42,8 @@ import { type Catalog, type ForwardedAuth, type GrantProps, + type OtpSubmitOutcome, + type ProtectedRequestsApi, type SearchResult, type SemanticCandidates, } from './types'; @@ -54,6 +63,8 @@ const PARSE_ERROR = -32700; const INVALID_REQUEST = -32600; const METHOD_NOT_FOUND = -32601; const INVALID_PARAMS = -32602; +/** A local or upstream failure that is not the caller's fault (retryable when marked so). */ +const INTERNAL_ERROR = -32000; /** The library already authenticated the request; this is the post-auth "no Kilo token bound" case. */ const UNAUTHORIZED = -32001; @@ -66,7 +77,7 @@ const SERVER_INFO = { name: 'kilo-mcp', version: '1.0.0' } as const; * these and reports everything else as `unknown`, so arbitrary caller text can * never reach PostHog. */ -const PUBLISHED_TOOL_NAMES = new Set(['search', 'call']); +const PUBLISHED_TOOL_NAMES = new Set(['search', 'call', 'call_protected', 'submit_otp']); const CORS_HEADERS: Record = { 'Access-Control-Allow-Origin': '*', @@ -149,10 +160,8 @@ const TOOLS = [ description: 'A catalog endpoint path returned by search, e.g. "organizations.list".', }, input: { - type: 'object', description: - 'Arguments matching the endpoint input schema from search. Omit for endpoints that take no input.', - additionalProperties: true, + 'Arguments matching the endpoint input schema from search. Usually an object, but some endpoints take a scalar or array — pass exactly the value the schema describes. Omit for endpoints that take no input.', }, }, required: ['path'], @@ -161,10 +170,89 @@ const TOOLS = [ }, ] as const; +/** + * The two tools published only to an opted-in admin connection. `call_protected` + * records a reviewed admin or debug call; `submit_otp` runs it once the user's + * authenticator code is accepted. The payload is fixed by `call_protected`, so + * `submit_otp` carries no path and no input. + */ +const PROTECTED_TOOLS = [ + { + name: 'call_protected', + description: + 'Call an admin or debug Kilo API endpoint with OTP approval. This does NOT run the endpoint: it validates the input, records the call exactly as submitted, and returns a request_id and an expiry. Ask the user to read the current code from their authenticator app, then call submit_otp with that request_id and that code. The endpoint and payload are fixed once this returns, and the call runs at most once, when the code is accepted.', + inputSchema: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'An admin or debug catalog endpoint path returned by search, e.g. "organizations.admin.getMetrics". The call tool refuses these paths.', + }, + input: { + description: + 'Arguments matching the endpoint input schema from search. Usually an object, but some endpoints take a scalar or array — pass exactly the value the schema describes. Omit for endpoints that take no input.', + }, + }, + required: ['path'], + additionalProperties: false, + }, + }, + { + name: 'submit_otp', + description: + "Approve and run the admin or debug call that call_protected recorded. The code comes from the user's authenticator app, never from the model or the transcript. This tool takes only the request_id from call_protected and the current otp: the endpoint and payload were fixed when call_protected returned and cannot change here.", + inputSchema: { + type: 'object', + properties: { + request_id: { + type: 'string', + description: 'The request_id that call_protected returned.', + }, + otp: { + type: 'string', + description: + 'The current one-time code the user reads from their authenticator app for this Kilo account.', + }, + }, + required: ['request_id', 'otp'], + additionalProperties: false, + }, + }, +] as const; + +/** + * Whether this grant may use the OTP-protected admin and debug tools. Fail-closed + * on every leg: the admin opt-in, the live admin eligibility recorded on the + * grant, and a non-empty per-connection id the pending request binds to. A + * pre-amendment grant (no `sessionId`) is never eligible. + */ +export function canUseProtectedActions(auth: ForwardedAuth): boolean { + return ( + auth.adminEnabled === true && + auth.adminEligible === true && + typeof auth.sessionId === 'string' && + auth.sessionId.length > 0 + ); +} + +/** The ordinary unknown-tool refusal; an unpublished name is never disclosed. */ +function unknownTool(name: string): JsonRpcFailure { + return new JsonRpcFailure( + INVALID_PARAMS, + `Unknown tool "${name}". Available tools: search, call.` + ); +} + type McpHandlerDeps = { catalog: Catalog; webBaseUrl: string; fetchImpl?: typeof fetch; + /** + * The pending protected-request store (o2). A guarded call without it is + * refused before anything runs: never fail open. + */ + protectedRequests?: ProtectedRequestsApi; /** Vectorize kNN hook; token-only search when omitted. */ semanticCandidates?: SemanticCandidates; /** @@ -196,6 +284,16 @@ function identity(auth: ForwardedAuth): AnalyticsIdentity { return { kiloUserId: auth.kiloUserId, organizationId: auth.organizationId ?? null }; } +/** + * The raw Kilo token inside the grant's `Authorization` header value. The + * admin re-check calls `fetchUserIsAdmin`, which forms its own `Bearer …` + * header, so the grant's already-prefixed value is unwrapped here. Never log or + * echo the result. + */ +function grantKiloToken(authorization: string): string { + return authorization.replace(/^Bearer\s+/i, ''); +} + /** An MCP tools/call success payload. */ type ToolResult = { content: Array<{ type: 'text'; text: string }>; @@ -267,6 +365,57 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +/** + * The refusal a `submit_otp` caller sees for a request it can no longer submit. + * `gone` is the single uniform answer for an unknown id, another session's id + * and a used request — it names no path, kind, client or owner, so enumerating + * ids learns nothing. The owning connection, which already holds the id, gets + * the specific `expired` or `invalidated` reason instead. + */ +function staleProtectedRequest(status: 'gone' | 'expired' | 'invalidated'): JsonRpcFailure { + switch (status) { + case 'expired': + return new JsonRpcFailure( + INVALID_PARAMS, + 'This request expired. Start a new admin or debug call with call_protected.' + ); + case 'invalidated': + return new JsonRpcFailure( + INVALID_PARAMS, + 'This request was cancelled after too many incorrect codes. Start a new admin or debug call with call_protected.' + ); + case 'gone': + return new JsonRpcFailure( + INVALID_PARAMS, + 'This request is no longer pending. Start a new admin or debug call with call_protected.' + ); + } +} + +/** + * The wait an account-wide wrong-code lockout names, rounded up to whole + * minutes so the refusal always reads as a duration. Safe to surface: it + * describes the caller's own account, not a secret. + */ +function formatLockoutWait(retryAfterSeconds: number): string { + const minutes = Math.max(1, Math.ceil(retryAfterSeconds / 60)); + return minutes === 1 ? '1 minute' : `${minutes} minutes`; +} + +/** + * The account-wide wrong-code lockout refusal. The gate is scoped to the + * authenticator, so minting a fresh `call_protected` request cannot reset it; + * the same copy serves the peek pre-check and the claim outcome. + */ +function lockedRefusal(retryAfterSeconds: number): JsonRpcFailure { + return new JsonRpcFailure( + INVALID_PARAMS, + `Too many incorrect codes were submitted for this Kilo account. Try again in ${formatLockoutWait( + retryAfterSeconds + )}, then start a new admin or debug call with call_protected.` + ); +} + async function runTool( name: string, args: Record, @@ -284,10 +433,16 @@ async function runTool( ); } const { query, limit } = parsed.data; - const results = await searchCatalog(query, { + // One pass yields the rows and whether the guarded gate withheld a match, so + // the empty-state answer below needs no second search: an opted-in grant + // withholds nothing, and a query with no match anywhere reports `false`. + const { results, hiddenGuardedMatches } = await searchCatalogDetailed(query, { catalog: deps.catalog, limit, semanticCandidates: deps.semanticCandidates ?? noSemanticCandidates, + // Fail-closed: only a grant that ticked the admin opt-in sees admin or + // debug rows. + includeGuarded: auth.adminEnabled === true, }); // The query's shape only — never the raw text (it can carry personal data). analytics.searchPerformed({ @@ -298,16 +453,20 @@ async function runTool( limit: Math.max(1, Math.floor(limit ?? DEFAULT_SEARCH_LIMIT)), }); if (results.length === 0) { - // Empty state, not an error: tell the agent how to recover. The echoed - // query is bounded before serialization — never passed to the byte cap, - // which would cut the structured payload into invalid JSON. - return toolResult({ - text: JSON.stringify({ - results: [], - message: `No endpoints matched "${boundedQueryEcho(query)}". Refine your query: use fewer or different keywords, or describe the task in plain language.`, - }), - truncated: false, - }); + // Empty state, not an error: tell the agent how to recover. A query whose + // only matches are guarded rows is NOT a bad query: the catalog matches, + // the guarded gate hid the rows for this connection. That signal came from + // the single search pass above, so no second embedding or vector lookup + // runs while the user is already waiting on an empty answer. Only an admin + // is told the endpoints exist — a non-admin gets no admin/debug trace. + // The echoed query is bounded before serialization — never passed to the + // byte cap, which would cut the structured payload into invalid JSON. + const message = `No endpoints matched "${boundedQueryEcho(query)}". Refine your query: use fewer or different keywords, or describe the task in plain language.${ + hiddenGuardedMatches && auth.adminEligible === true + ? ' Some endpoints matching this query are admin or debug endpoints and are hidden for this connection. If you are a Kilo admin, reconnect the Kilo MCP server and tick "Enable admin and debug actions" at sign-in to allow them; admin and debug calls then need a code from your authenticator app.' + : '' + }`; + return toolResult({ text: JSON.stringify({ results: [], message }), truncated: false }); } // Every hit carries its published input schema, and a 50-row result can // exceed the tool-result cap: drop the lowest-ranked hits so the payload @@ -333,10 +492,160 @@ async function runTool( }); return toolResult(outcome); } - throw new JsonRpcFailure( - INVALID_PARAMS, - `Unknown tool "${name}". Available tools: search, call.` - ); + if (name === 'call_protected') { + // A connection without the opt-in, the live admin eligibility and a + // sessionId never learns these tools exist: the same unknown-tool answer as + // any unpublished name. + if (!canUseProtectedActions(auth)) { + throw unknownTool(name); + } + const parsed = callArgsSchema.safeParse(args); + if (!parsed.success) { + throw new JsonRpcFailure( + INVALID_PARAMS, + `Invalid call_protected arguments: ${describeZodIssues(parsed.error)}` + ); + } + const { path, input } = parsed.data; + const outcome = await requestProtectedCall({ + catalog: deps.catalog, + path, + input, + auth, + ...(deps.protectedRequests ? { requests: deps.protectedRequests } : {}), + webBaseUrl: deps.webBaseUrl, + ...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}), + }); + return { + content: [{ type: 'text', text: outcome.text }], + ...(outcome.truncated ? { truncated: true as const } : {}), + }; + } + if (name === 'submit_otp') { + if (!canUseProtectedActions(auth)) { + throw unknownTool(name); + } + const parsed = submitOtpArgsSchema.safeParse(args); + if (!parsed.success) { + throw new JsonRpcFailure( + INVALID_PARAMS, + `Invalid submit_otp arguments: ${describeZodIssues(parsed.error)}` + ); + } + const { request_id, otp } = parsed.data; + const requests = deps.protectedRequests; + const sessionId = auth.sessionId; + if (!requests || typeof sessionId !== 'string' || sessionId.length === 0) { + // Fail-closed: without the store there is no pending request to check or + // claim, so an admin or debug call can never run. + throw new JsonRpcFailure( + INTERNAL_ERROR, + `Could not check this admin or debug request for "${request_id}". Retry; if it keeps failing, reconnect the Kilo MCP server.`, + { retryable: true } + ); + } + + const nowIso = new Date().toISOString(); + let outcome: OtpSubmitOutcome; + try { + // `gone` covers an unknown id, another session's id and a used row with + // one uniform answer that names no path, kind or owner; the owning + // connection's expired/cancelled request gets its own refusal here, + // before the live admin re-check below (a stale request never reaches it). + const peek = await requests.peekProtectedRequest(request_id, sessionId, nowIso); + if (peek.status === 'locked') { + throw lockedRefusal(peek.retryAfterSeconds); + } + if (peek.status !== 'pending') { + throw staleProtectedRequest(peek.status); + } + // Re-derive admin from the live user.getMe with the grant's Kilo bearer: + // an admin who lost the role cannot execute an already-pending request. + // A check that cannot be reached is retryable, never a pass. + let isAdmin: boolean; + try { + isAdmin = await fetchUserIsAdmin( + { + webBaseUrl: deps.webBaseUrl, + ...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}), + }, + grantKiloToken(auth.authorization) + ); + } catch { + throw new JsonRpcFailure( + INTERNAL_ERROR, + 'Could not check admin access for this admin or debug call. Retry; if it keeps failing, reconnect the Kilo MCP server.', + { retryable: true } + ); + } + if (!isAdmin) { + throw new JsonRpcFailure( + INVALID_PARAMS, + 'Your Kilo account does not have admin access, so this admin or debug call cannot run. Reconnect the Kilo MCP server if that is unexpected.' + ); + } + outcome = await requests.verifyOtpAndClaim({ + id: request_id, + sessionId, + kiloUserId: auth.kiloUserId, + code: otp, + nowIso, + }); + } catch (error) { + if (error instanceof JsonRpcFailure) throw error; + // A store failure is a local refusal: the code never reaches a message. + throw new JsonRpcFailure( + INTERNAL_ERROR, + 'Could not use this admin or debug request. Retry; if it keeps failing, reconnect the Kilo MCP server.', + { retryable: true } + ); + } + + switch (outcome.status) { + case 'not_pending': + throw staleProtectedRequest('gone'); + case 'expired': + throw staleProtectedRequest('expired'); + case 'invalidated': + throw staleProtectedRequest('invalidated'); + case 'bad_code': + throw new JsonRpcFailure( + INVALID_PARAMS, + `That code is not valid. Check your authenticator app and try again. ${outcome.attemptsRemaining} attempts remaining.` + ); + case 'reused_code': + throw new JsonRpcFailure( + INVALID_PARAMS, + 'That code was already used. Ask the user for the next code from their authenticator app, then submit it again.' + ); + case 'no_authenticator': + throw new JsonRpcFailure( + INVALID_PARAMS, + 'No authenticator is registered for this Kilo account. Reconnect the Kilo MCP server and add your authenticator at sign-in.' + ); + case 'locked': + // The gate is account-wide, so a fresh call_protected cannot reset it. + // Naming the wait lets the agent schedule the retry instead of guessing. + throw lockedRefusal(outcome.retryAfterSeconds); + case 'ok': { + // The payload was fixed by call_protected and re-validated against the + // published schema; this is the single upstream request for it. + const result = await executeProtectedCall({ + catalog: deps.catalog, + path: outcome.path, + inputJson: outcome.inputJson, + auth, + webBaseUrl: deps.webBaseUrl, + ...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}), + }); + return { + content: [{ type: 'text', text: result.text }], + ...(result.truncated ? { truncated: true as const } : {}), + }; + } + } + } + throw unknownTool(name); } async function handleRpcMessage( @@ -376,14 +685,17 @@ async function handleRpcMessage( protocolVersion, capabilities: { tools: {} }, serverInfo: SERVER_INFO, - instructions: - 'This server exposes the Kilo API through two tools: search (find catalog endpoints) and call (invoke one by path). Search before every call. Each result carries a kind: "query" reads data, "mutation" changes it. Call a mutation path only when the user asked for that change, and if it fails with an ambiguous transport error, check the current state before retrying.', + instructions: canUseProtectedActions(auth) + ? 'This server exposes the Kilo API through two tools: search (find catalog endpoints) and call (invoke one by path). Search before every call. Each result carries a kind: "query" reads data, "mutation" changes it. Call a mutation path only when the user asked for that change, and if it fails with an ambiguous transport error, check the current state before retrying. This connection may also run admin and debug endpoints: use call_protected to submit one, then submit_otp with the code the user reads from their authenticator app to approve it. The endpoint and payload are fixed once call_protected returns.' + : 'This server exposes the Kilo API through two tools: search (find catalog endpoints) and call (invoke one by path). Search before every call. Each result carries a kind: "query" reads data, "mutation" changes it. Call a mutation path only when the user asked for that change, and if it fails with an ambiguous transport error, check the current state before retrying.', }); } case 'ping': return jsonRpcResult(id ?? 0, {}); case 'tools/list': - return jsonRpcResult(id ?? 0, { tools: TOOLS }); + return jsonRpcResult(id ?? 0, { + tools: canUseProtectedActions(auth) ? [...TOOLS, ...PROTECTED_TOOLS] : TOOLS, + }); case 'tools/call': { const parsed = toolsCallParamsSchema.safeParse(message.params); if (!parsed.success) { @@ -548,11 +860,17 @@ export const apiHandler = { analytics.callRejected({ identity: null, reason: 'auth_failure' }); return unauthorizedResponse(request); } + // The pending protected requests live in the worker's existing DO (o2). + // Resolve it only when the binding is present so a handler built without one + // still answers every non-guarded call; the protected tools then fail closed + // rather than running a guarded call unapproved. + const protectedRequests = env.KILO_MCP_OAUTH_STORE ? getKiloMcpOAuthStoreStub(env) : undefined; const handler = createMcpHandler({ catalog, webBaseUrl: env.WEB_BASE_URL, semanticCandidates: createSemanticCandidates(env), analytics, + ...(protectedRequests ? { protectedRequests } : {}), }); return handler(request, forwardedAuthFromProps(props)); }, diff --git a/services/kilo-mcp/src/oauth/consent.test.ts b/services/kilo-mcp/src/oauth/consent.test.ts index 2dfaa64334..1ea1a429fa 100644 --- a/services/kilo-mcp/src/oauth/consent.test.ts +++ b/services/kilo-mcp/src/oauth/consent.test.ts @@ -5,13 +5,15 @@ import type { CompleteAuthorizationOptions, OAuthHelpers, } from '@cloudflare/workers-oauth-provider'; -import { createDefaultHandler } from './consent'; +import { createDefaultHandler, type ConsentDeps } from './consent'; import type { McpAnalytics, OAuthSignInInput } from '../analytics'; import type { NewPendingAuthorization, OAuthStoreApi, PendingAuthorization, } from '../store/oauth-store'; +import type { AuthenticatorEnrollmentApi } from '../types'; +import { totpCode } from '../otp/totp'; /** * The library throws this at /authorize for an invalid request; reproduce its @@ -36,6 +38,19 @@ const REDIRECT = 'https://client.test/cb'; const STATE = 'st-1'; const NOW = new Date('2026-09-11T00:00:00.000Z'); +/** The RFC 6238 Appendix B shared secret, so a test code is deterministic. */ +const SECRET = 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ'; + +/** The code the seeded secret produces at NOW. */ +function currentCode(): Promise { + return totpCode(SECRET, NOW.getTime()); +} + +/** A six-digit code that is provably not the current one. */ +async function wrongCode(): Promise { + return (await currentCode()) === '000000' ? '111111' : '000000'; +} + function iso(offsetMs: number): string { return new Date(NOW.getTime() + offsetMs).toISOString(); } @@ -64,16 +79,32 @@ function clientInfo(): ClientInfo { } /** - * In-memory OAuthStoreApi. The methods the consent routes reach are real; the - * rest throw so a new dependency fails loudly. + * In-memory OAuthStoreApi plus the authenticator enrolment the picker's opt-in + * reads. The methods the consent routes reach are real; the rest throw so a new + * dependency fails loudly. */ -function createFakeStore(): OAuthStoreApi & { pending: Map } { +function createFakeStore(): OAuthStoreApi & + AuthenticatorEnrollmentApi & { + pending: Map; + authenticators: Map; + ensureCalls: number; + confirmCalls: number; + } { const pending = new Map(); + const authenticators = new Map(); + const counters = { ensureCalls: 0, confirmCalls: 0 }; const unused = (): never => { throw new Error('not reachable from these tests'); }; return { pending, + authenticators, + get ensureCalls() { + return counters.ensureCalls; + }, + get confirmCalls() { + return counters.confirmCalls; + }, recordPairingApproval: async (deviceAuthCode, identity, nowIso) => { for (const [id, record] of pending) { if ( @@ -166,9 +197,37 @@ function createFakeStore(): OAuthStoreApi & { pending: Map { + counters.ensureCalls += 1; + let row = authenticators.get(kiloUserId); + if (!row) { + row = { secret: SECRET, verified: false }; + authenticators.set(kiloUserId, row); + } + return { secret: row.secret, verified: row.verified }; + }, + confirmAuthenticator: async (kiloUserId, code, nowIso) => { + counters.confirmCalls += 1; + const row = authenticators.get(kiloUserId); + if (!row || code !== (await totpCode(row.secret, Date.parse(nowIso)))) { + return false; + } + row.verified = true; + return true; + }, }; } +/** Seed a known authenticator for the paired identity (a prior GET enrolled it). */ +function seedAuthenticator( + store: ReturnType, + kiloUserId: string, + options: { verified?: boolean } = {} +): string { + store.authenticators.set(kiloUserId, { secret: SECRET, verified: options.verified === true }); + return SECRET; +} + async function seedPending( store: ReturnType, overrides: { id?: string; deviceAuthCode?: string; expiresAt?: string } = {} @@ -238,7 +297,7 @@ function fakeAnalytics(): { analytics: McpAnalytics; calls: OAuthSignInInput[] } } function deps( - store: OAuthStoreApi, + store: ConsentDeps['store'], fetchImpl: typeof fetch | undefined, extra: { analytics?: McpAnalytics } = {} ) { @@ -257,8 +316,14 @@ async function run(handler: ExportedHandler, request: Request, env: Env): P return handler.fetch!(request as unknown as CfRequest, env, {} as ExecutionContext); } -/** A fetch fake routed by URL for the whole device-auth + org-list flow. */ -function flowFetch(overrides: Record Response | Promise> = {}) { +/** + * A fetch fake routed by URL for the whole device-auth + org-list + admin + * flow. `isAdmin` answers the paired identity's `user.getMe` read. + */ +function flowFetch( + overrides: Record Response | Promise> = {}, + options: { isAdmin?: boolean } = {} +) { return vi.fn(async (input: string | URL) => { const url = String(input); for (const [suffix, response] of Object.entries(overrides)) { @@ -268,6 +333,9 @@ function flowFetch(overrides: Record Response | Promise> if (url.endsWith('/api/device-auth/codes/PAIR-1')) { return Response.json({ status: 'approved', token: 'kilo-tok-1', userId: 'u-1' }); } + if (url.includes('/api/trpc/user.getMe')) { + return Response.json({ result: { data: { isAdmin: options.isAdmin ?? false } } }); + } if (url.includes('/api/trpc/organizations.list')) { return Response.json({ result: { data: [{ organizationId: 'org-2', organizationName: 'Nova' }] }, @@ -890,6 +958,532 @@ describe('GET/POST /authorize/org', () => { run(handler, new Request(`${ISSUER}/authorize/org?id=`), env).then(r => r.status) ).resolves.toBe(400); }); + + it('shows the admin opt-in unchecked, with the hidden authenticator subsection', async () => { + const store = createFakeStore(); + const id = await seedPaired(store); + const handler = createDefaultHandler(deps(store, flowFetch({}, { isAdmin: true }))); + const response = await run( + handler, + new Request(`${ISSUER}/authorize/org?id=${id}`), + envWith(fakeHelpers({ client: clientInfo() }).helpers) + ); + expect(response.status).toBe(200); + const html = await response.text(); + expect(html).toContain('name="admin_enabled"'); + expect(html).toContain('Enable admin and debug actions'); + expect(html).toContain( + 'Off by default. Admin and debug actions stay hidden until enabled, and each one needs a code from your authenticator app.' + ); + // Off by default, in every render: the option never arrives pre-checked. + expect(html).not.toMatch(/]*name="admin_enabled"[^>]*checked/); + // The subsection is hidden by default and carries the enrolment material + // (the secret the store just minted) plus the code field. + expect(html).toContain('