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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions ai/gen-ai-agents/oci-enterprise-ai-chat/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,18 @@ LANGFUSE_BASE_URL=https://cloud.langfuse.com
LOG_LEVEL=info
```

### Text-to-SQL via MCP, optional

```env
NEXT_PUBLIC_NL2SQL_MCP_URL=https://your-nl2sql-mcp.example.com/mcp
```

When set, points the in-chat Text-to-SQL flow at a hosted DBTools MCP server that exposes `generate_sql`. When unset, the Text-to-SQL toggle stays hidden.

### OCI Generative AI Hosted Deployments, optional

When the app is deployed via OCI Generative AI Hosted Applications, the platform injects `APPLICATION_BASE_URL` (the full invocation prefix) into the container. `entrypoint.sh` honors it automatically and falls back to a manual `BASE_PATH=/your-path` for non-hosted deployments behind a subpath. Nothing to set if you do not use either.

### Models

Models are defined as a static list in `src/app/page.js` (`STATIC_MODELS`). To add or remove a model available in your tenancy, edit that array. No env var needed.
Expand Down Expand Up @@ -113,7 +125,7 @@ Toggle per-tool from Settings → Tools → Native:
- **Web Search** *(coming soon)*, real-time web lookups
- **File Search (RAG)**: vector retrieval over Knowledge Bases
- **Code Interpreter**: Python sandbox with 420+ libraries
- **Text-to-SQL** *(coming soon)*, natural language SQL against your semantic stores
- **Text-to-SQL**: natural language to SQL against your semantic stores, fronted by a hosted DBTools MCP server (set `NEXT_PUBLIC_NL2SQL_MCP_URL` in [Configuration](#text-to-sql-via-mcp-optional))

![Tools settings](images/03-settings-tools.png)

Expand Down Expand Up @@ -245,7 +257,7 @@ src/

### MCP tool invocation
1. OCI executes MCP tools natively via the Responses API. The app **does not run tools itself** during chat (only for Test Connection / Refresh in Settings).
2. For OAuth 2.1 MCP servers (e.g. SDD Generator), the client obtains an access token via `/api/mcp/oauth/*` and passes it to OCI in the tool's `authorization` field
2. For OAuth 2.1 MCP servers, the client obtains an access token via `/api/mcp/oauth/*` and passes it to OCI in the tool's `authorization` field
3. Custom servers use the more generic `/api/mcp` JSON-RPC proxy for discovery and direct invocation (outside OCI)

### Settings persistence
Expand Down Expand Up @@ -355,7 +367,7 @@ oci container-instances container-instance restart \

### Notes
- A `/ready` healthcheck endpoint is exposed for the LB.
- If you mount the app under a subpath, set `BASE_PATH=/your-path`.
- For subpath deployments, set `BASE_PATH=/your-path`. On OCI Generative AI Hosted Deployments the platform injects `APPLICATION_BASE_URL` automatically and `entrypoint.sh` honors it (falling back to `BASE_PATH` otherwise).
- When the LB-to-backend connection is HTTP (not HTTPS end-to-end), cookies must **not** carry the `Secure` flag. `mcp-oauth.js` and the IDCS auth code already handle this automatically when running over HTTP.
- All secrets (Client Secret, Session Secret, Langfuse keys) should be set as **environment variables on the Container Instance**, never baked into the image.

Expand Down
4 changes: 3 additions & 1 deletion ai/gen-ai-agents/oci-enterprise-ai-chat/files/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
set -e

PLACEHOLDER="/__BASE_PATH_PLACEHOLDER__"
PREFIX="${BASE_PATH:-}"
# OCI Hosted Deployments auto-inject the reserved APPLICATION_BASE_URL (the full
# /.../actions/invoke path). Fall back to BASE_PATH for the Container Instance deploy.
PREFIX="${APPLICATION_BASE_URL:-${BASE_PATH:-}}"
PREFIX="${PREFIX%/}"
ESCAPED=$(printf '%s' "$PREFIX" | sed 's/[\/&|]/\\&/g')

Expand Down
4 changes: 2 additions & 2 deletions ai/gen-ai-agents/oci-enterprise-ai-chat/files/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "oci-agent-light-demo-creator",
"version": "0.4.0",
"name": "oci-enterprise-ai-chat",
"version": "0.9.9",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export async function POST(request) {
"${userMessage.substring(0, 150)}" → "`;

const requestBody = {
model: 'openai.gpt-4o-mini',
model: 'google.gemini-2.5-flash-lite',
input: [{ role: 'user', content: prompt }],
stream: false
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
generateCodeVerifier,
generateCodeChallenge,
signPayload,
basePrefix,
PENDING_COOKIE,
} from '../../../../lib/mcp-oauth';
import { createLogger } from '../../../../lib/logger';
Expand All @@ -27,20 +28,72 @@ export async function GET(request) {
return NextResponse.json({ error: 'endpoint query param is required' }, { status: 400 });
}

// Static-client mode (authType `oauth2-user`): the caller supplies the
// pre-registered OAuth credentials in the query string. We skip discovery
// and dynamic client registration entirely and just run PKCE.
const staticClientId = params.get('clientId');
const staticClientSecret = params.get('clientSecret') || '';
const staticAuthorizeUrl = params.get('authorizeUrl');
const staticTokenUrl = params.get('tokenUrl');
const staticScope = params.get('scope') || '';
const useStatic = !!(staticClientId && staticAuthorizeUrl && staticTokenUrl);

const baseUrl = getBaseUrl(request);
const redirectUri = `${baseUrl}/api/mcp/oauth/callback`;
// basePrefix() re-adds the OCI Hosted Deployment prefix OCI stripped, so the
// redirect_uri is routable when the IdP sends the browser back. It's stored in
// the PENDING cookie and reused verbatim for the token exchange (callback).
const redirectUri = `${baseUrl}${basePrefix()}/api/mcp/oauth/callback`;

// 1. Discover OAuth metadata from MCP server
const metadata = await fetchOAuthMetadata(endpoint);
if (!metadata) {
return NextResponse.json({ error: 'MCP server does not support OAuth 2.1' }, { status: 502 });
}
log.info('OAuth metadata fetched', { endpoint });
let clientId, clientSecret, authorizationEndpoint, tokenEndpoint, scopeList;

if (useStatic) {
clientId = staticClientId;
clientSecret = staticClientSecret;
authorizationEndpoint = staticAuthorizeUrl;
tokenEndpoint = staticTokenUrl;
scopeList = staticScope.split(/\s+/).filter(Boolean);
log.info('OAuth (static client) authorize', { endpoint, authorizationEndpoint });
} else {
// 1. Discover OAuth metadata from MCP server (oauth2.1 flow)
const metadata = await fetchOAuthMetadata(endpoint);
if (!metadata) {
return NextResponse.json({ error: 'MCP server does not support OAuth 2.1' }, { status: 502 });
}
log.info('OAuth metadata fetched', { endpoint });

// 2. Register this app as an OAuth client (dynamic registration)
const scopes = metadata.scopes_supported || ['read', 'write', 'generate'];
const registration = await registerClient(metadata.registration_endpoint, redirectUri, scopes);
log.info('Client registered', { clientId: registration.client_id });
// 2. Determine the OAuth client. The discovery above already yielded the
// authorize/token endpoints (works for both one-level and RFC 9728).
scopeList = metadata.scopes_supported || ['read', 'write', 'generate'];
authorizationEndpoint = metadata.authorization_endpoint;
tokenEndpoint = metadata.token_endpoint;

if (metadata.registration_endpoint) {
// 2a. Dynamic client registration (RFC 7591)
const registration = await registerClient(metadata.registration_endpoint, redirectUri, scopeList);
log.info('Client registered', { clientId: registration.client_id });
clientId = registration.client_id;
clientSecret = registration.client_secret;
} else {
// 2b. No dynamic registration (OCI IAM Identity Domains don't expose it).
// Use a pre-registered confidential client supplied via env. Wired for the
// NL2SQL MCP endpoint; the secret stays server-side (never hits the browser).
const nl2sqlUrl = process.env.NEXT_PUBLIC_NL2SQL_MCP_URL || process.env.NL2SQL_MCP_URL || '';
const envClientId = process.env.NL2SQL_OAUTH_CLIENT_ID;
const envClientSecret = process.env.NL2SQL_OAUTH_CLIENT_SECRET || '';
if (envClientId && nl2sqlUrl && endpoint === nl2sqlUrl) {
clientId = envClientId;
clientSecret = envClientSecret;
// IDCS only returns a refresh_token when offline_access is requested.
if (!scopeList.includes('offline_access')) scopeList = [...scopeList, 'offline_access'];
log.info('OAuth (pre-registered confidential client) authorize', { endpoint });
} else {
return NextResponse.json({
error: "This authorization server has no dynamic client registration. Set NL2SQL_OAUTH_CLIENT_ID / NL2SQL_OAUTH_CLIENT_SECRET (a pre-registered confidential app) to enable it.",
code: 'no_dynamic_registration',
}, { status: 502 });
}
}
}

// 3. Generate PKCE pair
const codeVerifier = generateCodeVerifier();
Expand All @@ -50,25 +103,31 @@ export async function GET(request) {
// 4. Store pending state in a signed cookie
const pending = await signPayload({
endpoint,
clientId: registration.client_id,
clientSecret: registration.client_secret,
clientId,
clientSecret,
codeVerifier,
state,
tokenEndpoint: metadata.token_endpoint,
tokenEndpoint,
redirectUri,
returnTo,
});

// 5. First set the cookie, then redirect via an HTML page
// (direct 307 redirect may not persist cookies in all browsers)
const authUrl = new URL(metadata.authorization_endpoint);
authUrl.searchParams.set('client_id', registration.client_id);
const authUrl = new URL(authorizationEndpoint);
authUrl.searchParams.set('client_id', clientId);
authUrl.searchParams.set('redirect_uri', redirectUri);
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('scope', scopes.join(' '));
if (scopeList.length > 0) authUrl.searchParams.set('scope', scopeList.join(' '));
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
authUrl.searchParams.set('state', state);
// Google requires these to issue a refresh_token + force the consent prompt
// the first time. Harmless for other providers (they'll just ignore unknown params).
if (useStatic) {
authUrl.searchParams.set('access_type', 'offline');
authUrl.searchParams.set('prompt', 'consent');
}

const html = `<!DOCTYPE html><html><head><meta http-equiv="refresh" content="0;url=${authUrl.toString()}"></head><body>Redirecting...</body></html>`;
const response = new Response(html, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,44 +4,51 @@ import {
signPayload,
exchangeCode,
tokenCookieName,
basePrefix,
PENDING_COOKIE,
} from '../../../../lib/mcp-oauth';
import { createLogger } from '../../../../lib/logger';

function returnUrl(request, result, pending) {
const dest = pending?.returnTo || '/settings';
function returnUrl(request, result, pending, detail) {
// returnTo from the client already carries the deployment prefix (it's the
// browser's window.location.pathname); only the fallback needs basePrefix().
const dest = pending?.returnTo || `${basePrefix()}/settings`;
let host = request.headers.get('x-forwarded-host') || request.headers.get('host');
const proto = request.headers.get('x-forwarded-proto') || 'http';
host = host.replace(/:80$/, '').replace(/:443$/, '');
const base = `${proto}://${host}`;
const url = new URL(dest, base);
url.searchParams.set('mcp_auth', result);
if (detail) url.searchParams.set('mcp_error', String(detail).slice(0, 300));
return url.toString();
}

export async function GET(request) {
const log = createLogger('mcp-oauth-callback');

// Declared at function scope so the catch block (and the early error paths)
// can use it to build returnTo without a ReferenceError.
let pending = null;
try {
const params = new URL(request.url).searchParams;
const code = params.get('code');
const state = params.get('state');
const error = params.get('error');

// 1. Read pending state from cookie early — so returnTo works even when the
// IdP redirects back with ?error=... (e.g. unauthorized_client / bad scope).
const pendingCookie = request.cookies.get(PENDING_COOKIE)?.value;
pending = await verifyPayload(pendingCookie);

if (error) {
log.error('Authorization denied', { error, description: params.get('error_description') });
return NextResponse.redirect(returnUrl(request, 'error', pending));
return NextResponse.redirect(returnUrl(request, 'error', pending, params.get('error_description') || error));
}

if (!code) {
log.error('Missing code');
return NextResponse.redirect(returnUrl(request, 'error', null));
return NextResponse.redirect(returnUrl(request, 'error', pending, 'No authorization code returned'));
}

// 1. Read pending state from cookie
const pendingCookie = request.cookies.get(PENDING_COOKIE)?.value;
const pending = await verifyPayload(pendingCookie);

if (!pending) {
log.error('Missing or invalid pending cookie', {
hasCookie: !!pendingCookie,
Expand All @@ -67,7 +74,10 @@ export async function GET(request) {
clientId: pending.clientId,
clientSecret: pending.clientSecret,
tokenEndpoint: pending.tokenEndpoint,
accessToken: tokenData.access_token,
// Access JWT (~3KB for IDCS) is NOT stored — it would push the signed cookie
// past the browser's ~4KB limit and the cookie gets dropped (hasToken=false).
// Store only the small refresh token; /api/mcp/oauth/token mints a fresh
// access token on demand. This also fixes access-token expiry.
refreshToken: tokenData.refresh_token,
expiresAt: Date.now() + (tokenData.expires_in || 3600) * 1000,
});
Expand All @@ -89,6 +99,6 @@ export async function GET(request) {
return response;
} catch (error) {
log.error('OAuth callback failed', { error: error.message });
return NextResponse.redirect(returnUrl(request, 'error', pending));
return NextResponse.redirect(returnUrl(request, 'error', pending, error.message));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,35 +16,37 @@ export async function GET(request) {
const tokenCookie = request.cookies.get(cookieName)?.value;
const tokens = await verifyPayload(tokenCookie);

if (!tokens) {
if (!tokens || !tokens.refreshToken) {
return NextResponse.json({ hasToken: false });
}

// Force refresh if requested, or if expiring within 30s
const forceRefresh = new URL(request.url).searchParams.get('refresh') === 'true';
if (forceRefresh || tokens.expiresAt < Date.now() + 30000) {
try {
const refreshed = await refreshAccessToken(
tokens.tokenEndpoint, tokens.refreshToken, tokens.clientId, tokens.clientSecret
);
tokens.accessToken = refreshed.access_token;
tokens.refreshToken = refreshed.refresh_token || tokens.refreshToken;
tokens.expiresAt = Date.now() + (refreshed.expires_in || 3600) * 1000;

const response = NextResponse.json({ hasToken: true, accessToken: tokens.accessToken });
const isHttps = (request.headers.get('x-forwarded-proto') || 'http') === 'https';
response.cookies.set(cookieName, await signPayload(tokens), {
httpOnly: true,
secure: isHttps,
sameSite: 'lax',
maxAge: 30 * 24 * 60 * 60,
path: '/',
});
return response;
} catch {
return NextResponse.json({ hasToken: false });
}
// Lightweight presence check (used by Settings to show authorized/needs_auth).
// Does NOT consume the refresh token.
if (new URL(request.url).searchParams.get('probe') === 'true') {
return NextResponse.json({ hasToken: true });
}

return NextResponse.json({ hasToken: true, accessToken: tokens.accessToken });
// The cookie stores ONLY the refresh token (the IDCS access JWT is ~3KB and would
// blow past the browser's ~4KB cookie limit). Always mint a fresh access token
// from the refresh token, and persist the (possibly rotated) refresh token.
try {
const refreshed = await refreshAccessToken(
tokens.tokenEndpoint, tokens.refreshToken, tokens.clientId, tokens.clientSecret
);
tokens.refreshToken = refreshed.refresh_token || tokens.refreshToken;
tokens.expiresAt = Date.now() + (refreshed.expires_in || 3600) * 1000;

const response = NextResponse.json({ hasToken: true, accessToken: refreshed.access_token });
const isHttps = (request.headers.get('x-forwarded-proto') || 'http') === 'https';
response.cookies.set(cookieName, await signPayload(tokens), {
httpOnly: true,
secure: isHttps,
sameSite: 'lax',
maxAge: 30 * 24 * 60 * 60,
path: '/',
});
return response;
} catch {
return NextResponse.json({ hasToken: false });
}
}
Loading