Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
12b8e73
feat(mcp): add native dataset stats
alex-alecu Jun 22, 2026
492e508
fix(mcp): derive native resource
alex-alecu Jun 23, 2026
d370360
fix(mcp): route native refresh
alex-alecu Jun 23, 2026
9e6cfe1
fix(mcp): preserve auth errors
alex-alecu Jun 23, 2026
4e4315c
fix(dataset): parse booleans
alex-alecu Jun 23, 2026
be37a7d
fix(mcp): allow admin native access
alex-alecu Jun 23, 2026
e70d24e
feat(mcp): add dataset query discovery
alex-alecu Jun 23, 2026
2df5ec6
feat(usage): add ask usage
alex-alecu Jun 23, 2026
fabf61b
feat(mcp): add usage cost tool
alex-alecu Jun 23, 2026
f374804
fix(mcp): narrow usage cost tool
alex-alecu Jun 23, 2026
9bdc780
Merge remote-tracking branch 'origin/feat/native-mcp-dataset-stats' i…
alex-alecu Jun 24, 2026
98827c5
fix(usage): start ask usage blank
alex-alecu Jun 24, 2026
f664451
fix(usage): handle string assistant errors
alex-alecu Jun 24, 2026
9de5345
style(usage): format usage ai router test
alex-alecu Jun 24, 2026
ae9e889
feat(cloud-agent): add empty local repository source
alex-alecu Jun 24, 2026
e626975
feat(usage): add ask usage model selection
alex-alecu Jun 24, 2026
8ebfde6
fix(cloud-agent): clarify fresh session progress
alex-alecu Jun 24, 2026
44c4dc6
fix(cloud-agent): isolate kilo import env
alex-alecu Jun 24, 2026
833c17f
fix(dev): stabilize cloud agent local env
alex-alecu Jun 24, 2026
1267665
fix(usage): render ask dataset results
alex-alecu Jun 25, 2026
14abe5f
fix(usage): suppress ask render markup
alex-alecu Jun 25, 2026
133e2e6
fix(usage): improve ask usage query fidelity
alex-alecu Jun 25, 2026
5c63cb7
fix(usage): strengthen ask usage rendering contract
alex-alecu Jun 25, 2026
1651559
fix(usage): render ask raw dataset results
alex-alecu Jun 25, 2026
79d3fa0
fix(usage): render ask usage from structured tool results
alex-alecu Jun 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .specs/mcp-gateway-auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ Kilo v1 is intentionally a two-plane system:
upstream credential injection, streaming proxying, per-instance refresh
coordination, runtime telemetry, and maintenance cleanup.

Root `/mcp` is a separate first-party native MCP resource owned by `apps/web`. It
MUST use native `token_use="native_mcp"` JWTs with the exact native resource as
audience and MUST NOT reuse Gateway scoped-route claims, configs, connection
instances, provider grants, or Worker runtime semantics.

This document supersedes the earlier clean-room baseline/profile split. There is
one in-repo contract for Kilo v1, not a baseline plus override layer.

Expand Down Expand Up @@ -105,6 +110,7 @@ when they appear in all capitals.
| Surface | Owner | Required behavior |
|---|---|---|
| `GET /health` | Worker | Health response only. |
| `POST /mcp` | App | First-party native MCP server for individual read-only stats and query metadata; requires native `mcp:access` OAuth token and rejects Gateway/Kilo API tokens. |
| `GET` or `POST /mcp-connect/user/{user_id}/{config_id}/{route_key}` | Worker | Protected runtime entrypoint. Unauthenticated callers receive an OAuth challenge; authorized callers are proxied. |
| `GET` or `POST /mcp-connect/org/{org_id}/{config_id}/{route_key}` | Worker | Same as personal route, with org eligibility checks. |
| Descendants under scoped connect routes | Worker | Allowed only when config path passthrough is enabled and authorized against the root route. |
Expand All @@ -125,6 +131,33 @@ when they appear in all capitals.
| `GET /api/mcp-gateway/oauth/userinfo` | App | Profile-gated user-info. |
| `GET /api/mcp-gateway/available` | App | Authenticated list of configs usable in the current execution context. |

## Native MCP Dataset Tools

1. The root `/mcp` native server MUST expose read-only tools only.
2. The native server MUST expose `query_kilo_dataset` for aggregate and
timeseries stats over approved per-user Kilo datasets.
3. The native server MAY expose `get_kilo_usage_cost` as a read-only convenience
tool for total model usage cost over common calendar periods. It MUST derive
the underlying aggregate `query_kilo_dataset` query server-side and MUST NOT
expose conditionally forbidden custom range, grouping, bucket, or limit
properties on this common path.
4. The native server MAY expose `describe_kilo_dataset` to return static query
metadata, field capabilities, mode rules, output aliases, and example payloads.
5. `describe_kilo_dataset` MUST NOT query user data and MUST NOT reveal fields
excluded from the public dataset catalog.
6. All native dataset tools MUST require the same native `mcp:access` OAuth token
and current Kilo org admin eligibility gate.
7. Model-facing schemas for common paths SHOULD avoid optional properties whose
presence is conditionally forbidden by handler logic. Where provider strict
structured-tool compatibility requires an explicit no-value representation,
schemas MAY use required nullable properties, such as `timezone: null` for UTC.
8. Custom timestamp ranges, grouped costs, cost trends, and raw cost metrics MUST
use `query_kilo_dataset` in this version.
9. Dataset tool descriptions, schemas, recipes, and validation errors SHOULD make the
following rules clear to MCP clients: aggregate queries do not use buckets,
timeseries queries require buckets, `count` metrics do not use a field, and
cost metrics use `costMicrodollars` or `costUsd`.

## Scoped Route Contract

1. Every enabled config MUST have exactly one active scoped connect resource in v1.
Expand Down
3 changes: 2 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,9 @@
"@kilocode/event-service": "workspace:*",
"@kilocode/kilo-chat": "workspace:*",
"@kilocode/kilo-chat-hooks": "workspace:*",
"@kilocode/mcp-gateway": "workspace:*",
"@kilocode/kiloclaw-instance-tiers": "workspace:*",
"@kilocode/kiloclaw-secret-catalog": "workspace:*",
"@kilocode/mcp-gateway": "workspace:*",
"@kilocode/organization-entitlement": "workspace:*",
"@kilocode/worker-utils": "workspace:*",
"@linear/sdk": "76.0.0",
Expand All @@ -62,6 +62,7 @@
"@mdx-js/react": "3.1.1",
"@mdxeditor/editor": "3.55.0",
"@mistralai/mistralai": "1.15.1",
"@modelcontextprotocol/sdk": "1.27.1",
"@monaco-editor/react": "4.7.0",
"@next/bundle-analyzer": "16.2.6",
"@next/mdx": "16.2.6",
Expand Down
20 changes: 20 additions & 0 deletions apps/web/src/app/(app)/aks/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { redirect } from 'next/navigation';

type LegacyAskUsagePageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>;
};

export default async function LegacyAskUsagePage({ searchParams }: LegacyAskUsagePageProps) {
const params = await searchParams;
const query = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (Array.isArray(value)) {
value.forEach(item => query.append(key, item));
} else if (value !== undefined) {
query.set(key, value);
}
}

const suffix = query.toString();
redirect(suffix ? `/ask?${suffix}` : '/ask');
}
19 changes: 19 additions & 0 deletions apps/web/src/app/(app)/ask/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Suspense } from 'react';
import { notFound } from 'next/navigation';
import { getUserFromAuth } from '@/lib/user/server';
import { AskUsageContent } from '@/modules/ask-usage/client/AskUsageContent';

export default async function AskUsagePage() {
const { user } = await getUserFromAuth({ adminOnly: true });
if (!user) notFound();

return (
<Suspense
fallback={
<div className="flex h-[calc(100dvh-3.5rem)] items-center justify-center">Loading...</div>
}
>
<AskUsageContent />
</Suspense>
);
}
10 changes: 10 additions & 0 deletions apps/web/src/app/(app)/components/PersonalAppSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
Gift,
ChevronLeft,
ChevronRight,
BarChart3,
} from 'lucide-react';
import HeaderLogo from '@/components/HeaderLogo';
import OrganizationSwitcher from './OrganizationSwitcher';
Expand Down Expand Up @@ -87,6 +88,15 @@ export default function PersonalAppSidebar(props: React.ComponentProps<typeof Si
icon: Code,
url: '/usage',
},
...(user?.is_admin
? [
{
title: 'Ask Usage',
icon: BarChart3,
url: '/ask',
},
]
: []),
];

// KiloClaw group
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,27 @@
import 'server-only';
import { NextResponse } from 'next/server';
import { createGatewayServices } from '@/lib/mcp-gateway/services';
import { GatewaySupportedScopes } from '@kilocode/mcp-gateway';
import { APP_URL } from '@/lib/constants';

export async function GET() {
const { config } = createGatewayServices();
return NextResponse.json({
issuer: config.appBaseUrl,
authorization_endpoint: new URL(
'/api/mcp-gateway/oauth/authorize',
config.appBaseUrl
).toString(),
token_endpoint: new URL('/api/mcp-gateway/oauth/token', config.appBaseUrl).toString(),
registration_endpoint: new URL('/api/mcp-gateway/oauth/register', config.appBaseUrl).toString(),
jwks_uri: new URL('/api/mcp-gateway/oauth/jwks.json', config.appBaseUrl).toString(),
userinfo_endpoint: new URL('/api/mcp-gateway/oauth/userinfo', config.appBaseUrl).toString(),
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
token_endpoint_auth_methods_supported: ['none', 'client_secret_post', 'client_secret_basic'],
code_challenge_methods_supported: ['S256'],
scopes_supported: GatewaySupportedScopes,
});
const appBaseUrl = process.env.MCP_GATEWAY_APP_BASE_URL || APP_URL;
return NextResponse.json(
{
issuer: appBaseUrl,
authorization_endpoint: new URL('/api/mcp-gateway/oauth/authorize', appBaseUrl).toString(),
token_endpoint: new URL('/api/mcp-gateway/oauth/token', appBaseUrl).toString(),
registration_endpoint: new URL('/api/mcp-gateway/oauth/register', appBaseUrl).toString(),
jwks_uri: new URL('/api/mcp-gateway/oauth/jwks.json', appBaseUrl).toString(),
userinfo_endpoint: new URL('/api/mcp-gateway/oauth/userinfo', appBaseUrl).toString(),
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
token_endpoint_auth_methods_supported: ['none', 'client_secret_post', 'client_secret_basic'],
code_challenge_methods_supported: ['S256'],
scopes_supported: GatewaySupportedScopes,
resource_indicators_supported: true,
},
{
headers: { 'Cache-Control': 'no-store' },
}
);
}
24 changes: 12 additions & 12 deletions apps/web/src/app/.well-known/oauth-authorization-server/route.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,28 @@
import 'server-only';
import { NextResponse } from 'next/server';
import { createGatewayServices } from '@/lib/mcp-gateway/services';
import { GatewaySupportedScopes } from '@kilocode/mcp-gateway';
import { APP_URL } from '@/lib/constants';

function authorizationServerMetadata() {
const { config } = createGatewayServices();
const appBaseUrl = process.env.MCP_GATEWAY_APP_BASE_URL || APP_URL;
return {
issuer: config.appBaseUrl,
authorization_endpoint: new URL(
'/api/mcp-gateway/oauth/authorize',
config.appBaseUrl
).toString(),
token_endpoint: new URL('/api/mcp-gateway/oauth/token', config.appBaseUrl).toString(),
registration_endpoint: new URL('/api/mcp-gateway/oauth/register', config.appBaseUrl).toString(),
jwks_uri: new URL('/api/mcp-gateway/oauth/jwks.json', config.appBaseUrl).toString(),
userinfo_endpoint: new URL('/api/mcp-gateway/oauth/userinfo', config.appBaseUrl).toString(),
issuer: appBaseUrl,
authorization_endpoint: new URL('/api/mcp-gateway/oauth/authorize', appBaseUrl).toString(),
token_endpoint: new URL('/api/mcp-gateway/oauth/token', appBaseUrl).toString(),
registration_endpoint: new URL('/api/mcp-gateway/oauth/register', appBaseUrl).toString(),
jwks_uri: new URL('/api/mcp-gateway/oauth/jwks.json', appBaseUrl).toString(),
userinfo_endpoint: new URL('/api/mcp-gateway/oauth/userinfo', appBaseUrl).toString(),
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
token_endpoint_auth_methods_supported: ['none', 'client_secret_post', 'client_secret_basic'],
code_challenge_methods_supported: ['S256'],
scopes_supported: GatewaySupportedScopes,
resource_indicators_supported: true,
};
}

export async function GET() {
return NextResponse.json(authorizationServerMetadata());
return NextResponse.json(authorizationServerMetadata(), {
headers: { 'Cache-Control': 'no-store' },
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { GET } from '../route';
11 changes: 11 additions & 0 deletions apps/web/src/app/.well-known/oauth-protected-resource/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import 'server-only';
import { NextResponse } from 'next/server';
import { nativeMcpProtectedResourceMetadata } from '@kilocode/mcp-gateway';
import { APP_URL } from '@/lib/constants';

export async function GET() {
const appBaseUrl = process.env.MCP_GATEWAY_APP_BASE_URL || APP_URL;
return NextResponse.json(nativeMcpProtectedResourceMetadata(appBaseUrl), {
headers: { 'Cache-Control': 'no-store' },
});
}
64 changes: 63 additions & 1 deletion apps/web/src/app/api/mcp-gateway/oauth/authorize/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,21 @@ const mockPreviewAuthorization = jest.fn<
>();
const mockAuthorize =
jest.fn<(params: unknown) => Promise<{ kind: 'provider_redirect'; authorizationUrl: string }>>();
const mockNativePreviewAuthorization = jest.fn<
(params: unknown) => Promise<{
clientId: string;
clientName: string;
redirectUri: string;
resource: string;
connectionName: string;
endpointHost: string;
contextName: string;
ownerScope: 'personal';
scopes: string[];
}>
>();
const mockNativeAuthorize =
jest.fn<(params: unknown) => Promise<{ kind: 'redirect'; redirectUrl: string }>>();
const mockRouteAuthorize = jest.fn();

jest.mock('@/lib/user/server', () => ({
Expand All @@ -34,7 +49,7 @@ jest.mock('@/lib/user/server', () => ({

jest.mock('@/lib/mcp-gateway/services', () => ({
createGatewayServices: () => ({
config: { rateLimitSecret: 'test-rate-limit-secret' },
config: { appBaseUrl: 'http://localhost:3000', rateLimitSecret: 'test-rate-limit-secret' },
routeService: {
parseResource: () => ({
ownerScope: 'organization',
Expand All @@ -58,6 +73,10 @@ jest.mock('@/lib/mcp-gateway/services', () => ({
previewAuthorization: mockPreviewAuthorization,
authorize: mockAuthorize,
},
nativeMcpAuthorizationService: {
previewAuthorization: mockNativePreviewAuthorization,
authorize: mockNativeAuthorize,
},
}),
}));

Expand Down Expand Up @@ -121,6 +140,20 @@ function authorizationUrl(redirectUri = 'http://127.0.0.1:60424/callback') {
return `http://localhost:3000/api/mcp-gateway/oauth/authorize?${query}`;
}

function nativeAuthorizationUrl(redirectUri = 'http://127.0.0.1:60424/callback') {
const query = new URLSearchParams({
client_id: 'mcp:client',
redirect_uri: redirectUri,
response_type: 'code',
resource: 'http://localhost:3000/mcp',
scope: 'mcp:access',
state: 'client-state',
code_challenge: 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~abcdefghijk',
code_challenge_method: 'S256',
});
return `http://localhost:3000/api/mcp-gateway/oauth/authorize?${query}`;
}

function approvalRequest(
approvalState: string,
cookie: string,
Expand Down Expand Up @@ -228,6 +261,35 @@ describe('POST /api/mcp-gateway/oauth/authorize', () => {
});

describe('GET /api/mcp-gateway/oauth/authorize', () => {
test('routes native MCP consent through the admin-only native branch', async () => {
mockGetUserFromAuth.mockResolvedValue({ user: mockUser, organizationId: undefined });
mockNativePreviewAuthorization.mockResolvedValue({
clientId: 'mcp:client',
clientName: 'Codex',
redirectUri: 'http://127.0.0.1:60424/callback',
resource: 'http://localhost:3000/mcp',
connectionName: 'Kilo usage stats',
endpointHost: 'localhost:3000',
contextName: 'Kilo admin preview',
ownerScope: 'personal',
scopes: ['mcp:access'],
});

const response = await loadedRoute().GET(new NextRequest(nativeAuthorizationUrl()));
if (!response) throw new Error('Expected native authorization response');
const document = await response.text();

expect(response.status).toBe(200);
expect(mockGetUserFromAuth).toHaveBeenCalledWith({ adminOnly: true });
expect(mockNativePreviewAuthorization).toHaveBeenCalledWith(
expect.objectContaining({ userId: mockUser.id, redirectErrors: true })
);
expect(mockPreviewAuthorization).not.toHaveBeenCalled();
expect(document).toContain('Allow access to your Kilo usage stats?');
expect(document).toContain('last 60 days per query');
expect(document).toContain('Read your Kilo stats');
});

test('shows unverified identity, effective access, callback, connection, context, and account', async () => {
const redirectUri = 'https://client.example/callback?source=mcp&mode=desktop';
mockGetUserFromAuth.mockResolvedValue({ user: mockUser, organizationId: undefined });
Expand Down
Loading