Skip to content

Commit 15019d0

Browse files
Merge branch 'main' into NicolaCimitile-patch-6
2 parents 1712c77 + 9622e85 commit 15019d0

38 files changed

Lines changed: 1664 additions & 489 deletions

File tree

ai/gen-ai-agents/oci-enterprise-ai-chat/README.md

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,18 @@ LANGFUSE_BASE_URL=https://cloud.langfuse.com
7373
LOG_LEVEL=info
7474
```
7575

76+
### Text-to-SQL via MCP, optional
77+
78+
```env
79+
NEXT_PUBLIC_NL2SQL_MCP_URL=https://your-nl2sql-mcp.example.com/mcp
80+
```
81+
82+
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.
83+
84+
### OCI Generative AI Hosted Deployments, optional
85+
86+
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.
87+
7688
### Models
7789

7890
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.
@@ -113,7 +125,7 @@ Toggle per-tool from Settings → Tools → Native:
113125
- **Web Search** *(coming soon)*, real-time web lookups
114126
- **File Search (RAG)**: vector retrieval over Knowledge Bases
115127
- **Code Interpreter**: Python sandbox with 420+ libraries
116-
- **Text-to-SQL** *(coming soon)*, natural language SQL against your semantic stores
128+
- **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))
117129

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

@@ -245,7 +257,7 @@ src/
245257

246258
### MCP tool invocation
247259
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).
248-
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
260+
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
249261
3. Custom servers use the more generic `/api/mcp` JSON-RPC proxy for discovery and direct invocation (outside OCI)
250262

251263
### Settings persistence
@@ -355,7 +367,7 @@ oci container-instances container-instance restart \
355367

356368
### Notes
357369
- A `/ready` healthcheck endpoint is exposed for the LB.
358-
- If you mount the app under a subpath, set `BASE_PATH=/your-path`.
370+
- 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).
359371
- 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.
360372
- All secrets (Client Secret, Session Secret, Langfuse keys) should be set as **environment variables on the Container Instance**, never baked into the image.
361373

ai/gen-ai-agents/oci-enterprise-ai-chat/files/entrypoint.sh

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
set -e
33

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

ai/gen-ai-agents/oci-enterprise-ai-chat/files/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
2-
"name": "oci-agent-light-demo-creator",
3-
"version": "0.4.0",
2+
"name": "oci-enterprise-ai-chat",
3+
"version": "0.9.9",
44
"private": true,
55
"scripts": {
66
"dev": "next dev --turbopack",

ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/api/generate-title/route.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export async function POST(request) {
1919
"${userMessage.substring(0, 150)}" → "`;
2020

2121
const requestBody = {
22-
model: 'openai.gpt-4o-mini',
22+
model: 'google.gemini-2.5-flash-lite',
2323
input: [{ role: 'user', content: prompt }],
2424
stream: false
2525
};

ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/api/mcp/oauth/authorize/route.js

Lines changed: 76 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
generateCodeVerifier,
66
generateCodeChallenge,
77
signPayload,
8+
basePrefix,
89
PENDING_COOKIE,
910
} from '../../../../lib/mcp-oauth';
1011
import { createLogger } from '../../../../lib/logger';
@@ -27,20 +28,72 @@ export async function GET(request) {
2728
return NextResponse.json({ error: 'endpoint query param is required' }, { status: 400 });
2829
}
2930

31+
// Static-client mode (authType `oauth2-user`): the caller supplies the
32+
// pre-registered OAuth credentials in the query string. We skip discovery
33+
// and dynamic client registration entirely and just run PKCE.
34+
const staticClientId = params.get('clientId');
35+
const staticClientSecret = params.get('clientSecret') || '';
36+
const staticAuthorizeUrl = params.get('authorizeUrl');
37+
const staticTokenUrl = params.get('tokenUrl');
38+
const staticScope = params.get('scope') || '';
39+
const useStatic = !!(staticClientId && staticAuthorizeUrl && staticTokenUrl);
40+
3041
const baseUrl = getBaseUrl(request);
31-
const redirectUri = `${baseUrl}/api/mcp/oauth/callback`;
42+
// basePrefix() re-adds the OCI Hosted Deployment prefix OCI stripped, so the
43+
// redirect_uri is routable when the IdP sends the browser back. It's stored in
44+
// the PENDING cookie and reused verbatim for the token exchange (callback).
45+
const redirectUri = `${baseUrl}${basePrefix()}/api/mcp/oauth/callback`;
3246

33-
// 1. Discover OAuth metadata from MCP server
34-
const metadata = await fetchOAuthMetadata(endpoint);
35-
if (!metadata) {
36-
return NextResponse.json({ error: 'MCP server does not support OAuth 2.1' }, { status: 502 });
37-
}
38-
log.info('OAuth metadata fetched', { endpoint });
47+
let clientId, clientSecret, authorizationEndpoint, tokenEndpoint, scopeList;
48+
49+
if (useStatic) {
50+
clientId = staticClientId;
51+
clientSecret = staticClientSecret;
52+
authorizationEndpoint = staticAuthorizeUrl;
53+
tokenEndpoint = staticTokenUrl;
54+
scopeList = staticScope.split(/\s+/).filter(Boolean);
55+
log.info('OAuth (static client) authorize', { endpoint, authorizationEndpoint });
56+
} else {
57+
// 1. Discover OAuth metadata from MCP server (oauth2.1 flow)
58+
const metadata = await fetchOAuthMetadata(endpoint);
59+
if (!metadata) {
60+
return NextResponse.json({ error: 'MCP server does not support OAuth 2.1' }, { status: 502 });
61+
}
62+
log.info('OAuth metadata fetched', { endpoint });
3963

40-
// 2. Register this app as an OAuth client (dynamic registration)
41-
const scopes = metadata.scopes_supported || ['read', 'write', 'generate'];
42-
const registration = await registerClient(metadata.registration_endpoint, redirectUri, scopes);
43-
log.info('Client registered', { clientId: registration.client_id });
64+
// 2. Determine the OAuth client. The discovery above already yielded the
65+
// authorize/token endpoints (works for both one-level and RFC 9728).
66+
scopeList = metadata.scopes_supported || ['read', 'write', 'generate'];
67+
authorizationEndpoint = metadata.authorization_endpoint;
68+
tokenEndpoint = metadata.token_endpoint;
69+
70+
if (metadata.registration_endpoint) {
71+
// 2a. Dynamic client registration (RFC 7591)
72+
const registration = await registerClient(metadata.registration_endpoint, redirectUri, scopeList);
73+
log.info('Client registered', { clientId: registration.client_id });
74+
clientId = registration.client_id;
75+
clientSecret = registration.client_secret;
76+
} else {
77+
// 2b. No dynamic registration (OCI IAM Identity Domains don't expose it).
78+
// Use a pre-registered confidential client supplied via env. Wired for the
79+
// NL2SQL MCP endpoint; the secret stays server-side (never hits the browser).
80+
const nl2sqlUrl = process.env.NEXT_PUBLIC_NL2SQL_MCP_URL || process.env.NL2SQL_MCP_URL || '';
81+
const envClientId = process.env.NL2SQL_OAUTH_CLIENT_ID;
82+
const envClientSecret = process.env.NL2SQL_OAUTH_CLIENT_SECRET || '';
83+
if (envClientId && nl2sqlUrl && endpoint === nl2sqlUrl) {
84+
clientId = envClientId;
85+
clientSecret = envClientSecret;
86+
// IDCS only returns a refresh_token when offline_access is requested.
87+
if (!scopeList.includes('offline_access')) scopeList = [...scopeList, 'offline_access'];
88+
log.info('OAuth (pre-registered confidential client) authorize', { endpoint });
89+
} else {
90+
return NextResponse.json({
91+
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.",
92+
code: 'no_dynamic_registration',
93+
}, { status: 502 });
94+
}
95+
}
96+
}
4497

4598
// 3. Generate PKCE pair
4699
const codeVerifier = generateCodeVerifier();
@@ -50,25 +103,31 @@ export async function GET(request) {
50103
// 4. Store pending state in a signed cookie
51104
const pending = await signPayload({
52105
endpoint,
53-
clientId: registration.client_id,
54-
clientSecret: registration.client_secret,
106+
clientId,
107+
clientSecret,
55108
codeVerifier,
56109
state,
57-
tokenEndpoint: metadata.token_endpoint,
110+
tokenEndpoint,
58111
redirectUri,
59112
returnTo,
60113
});
61114

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

73132
const html = `<!DOCTYPE html><html><head><meta http-equiv="refresh" content="0;url=${authUrl.toString()}"></head><body>Redirecting...</body></html>`;
74133
const response = new Response(html, {

ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/api/mcp/oauth/callback/route.js

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,44 +4,51 @@ import {
44
signPayload,
55
exchangeCode,
66
tokenCookieName,
7+
basePrefix,
78
PENDING_COOKIE,
89
} from '../../../../lib/mcp-oauth';
910
import { createLogger } from '../../../../lib/logger';
1011

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

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

29+
// Declared at function scope so the catch block (and the early error paths)
30+
// can use it to build returnTo without a ReferenceError.
31+
let pending = null;
2532
try {
2633
const params = new URL(request.url).searchParams;
2734
const code = params.get('code');
28-
const state = params.get('state');
2935
const error = params.get('error');
3036

37+
// 1. Read pending state from cookie early — so returnTo works even when the
38+
// IdP redirects back with ?error=... (e.g. unauthorized_client / bad scope).
39+
const pendingCookie = request.cookies.get(PENDING_COOKIE)?.value;
40+
pending = await verifyPayload(pendingCookie);
41+
3142
if (error) {
3243
log.error('Authorization denied', { error, description: params.get('error_description') });
33-
return NextResponse.redirect(returnUrl(request, 'error', pending));
44+
return NextResponse.redirect(returnUrl(request, 'error', pending, params.get('error_description') || error));
3445
}
3546

3647
if (!code) {
3748
log.error('Missing code');
38-
return NextResponse.redirect(returnUrl(request, 'error', null));
49+
return NextResponse.redirect(returnUrl(request, 'error', pending, 'No authorization code returned'));
3950
}
4051

41-
// 1. Read pending state from cookie
42-
const pendingCookie = request.cookies.get(PENDING_COOKIE)?.value;
43-
const pending = await verifyPayload(pendingCookie);
44-
4552
if (!pending) {
4653
log.error('Missing or invalid pending cookie', {
4754
hasCookie: !!pendingCookie,
@@ -67,7 +74,10 @@ export async function GET(request) {
6774
clientId: pending.clientId,
6875
clientSecret: pending.clientSecret,
6976
tokenEndpoint: pending.tokenEndpoint,
70-
accessToken: tokenData.access_token,
77+
// Access JWT (~3KB for IDCS) is NOT stored — it would push the signed cookie
78+
// past the browser's ~4KB limit and the cookie gets dropped (hasToken=false).
79+
// Store only the small refresh token; /api/mcp/oauth/token mints a fresh
80+
// access token on demand. This also fixes access-token expiry.
7181
refreshToken: tokenData.refresh_token,
7282
expiresAt: Date.now() + (tokenData.expires_in || 3600) * 1000,
7383
});
@@ -89,6 +99,6 @@ export async function GET(request) {
8999
return response;
90100
} catch (error) {
91101
log.error('OAuth callback failed', { error: error.message });
92-
return NextResponse.redirect(returnUrl(request, 'error', pending));
102+
return NextResponse.redirect(returnUrl(request, 'error', pending, error.message));
93103
}
94104
}

ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/api/mcp/oauth/token/route.js

Lines changed: 28 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -16,35 +16,37 @@ export async function GET(request) {
1616
const tokenCookie = request.cookies.get(cookieName)?.value;
1717
const tokens = await verifyPayload(tokenCookie);
1818

19-
if (!tokens) {
19+
if (!tokens || !tokens.refreshToken) {
2020
return NextResponse.json({ hasToken: false });
2121
}
2222

23-
// Force refresh if requested, or if expiring within 30s
24-
const forceRefresh = new URL(request.url).searchParams.get('refresh') === 'true';
25-
if (forceRefresh || tokens.expiresAt < Date.now() + 30000) {
26-
try {
27-
const refreshed = await refreshAccessToken(
28-
tokens.tokenEndpoint, tokens.refreshToken, tokens.clientId, tokens.clientSecret
29-
);
30-
tokens.accessToken = refreshed.access_token;
31-
tokens.refreshToken = refreshed.refresh_token || tokens.refreshToken;
32-
tokens.expiresAt = Date.now() + (refreshed.expires_in || 3600) * 1000;
33-
34-
const response = NextResponse.json({ hasToken: true, accessToken: tokens.accessToken });
35-
const isHttps = (request.headers.get('x-forwarded-proto') || 'http') === 'https';
36-
response.cookies.set(cookieName, await signPayload(tokens), {
37-
httpOnly: true,
38-
secure: isHttps,
39-
sameSite: 'lax',
40-
maxAge: 30 * 24 * 60 * 60,
41-
path: '/',
42-
});
43-
return response;
44-
} catch {
45-
return NextResponse.json({ hasToken: false });
46-
}
23+
// Lightweight presence check (used by Settings to show authorized/needs_auth).
24+
// Does NOT consume the refresh token.
25+
if (new URL(request.url).searchParams.get('probe') === 'true') {
26+
return NextResponse.json({ hasToken: true });
4727
}
4828

49-
return NextResponse.json({ hasToken: true, accessToken: tokens.accessToken });
29+
// The cookie stores ONLY the refresh token (the IDCS access JWT is ~3KB and would
30+
// blow past the browser's ~4KB cookie limit). Always mint a fresh access token
31+
// from the refresh token, and persist the (possibly rotated) refresh token.
32+
try {
33+
const refreshed = await refreshAccessToken(
34+
tokens.tokenEndpoint, tokens.refreshToken, tokens.clientId, tokens.clientSecret
35+
);
36+
tokens.refreshToken = refreshed.refresh_token || tokens.refreshToken;
37+
tokens.expiresAt = Date.now() + (refreshed.expires_in || 3600) * 1000;
38+
39+
const response = NextResponse.json({ hasToken: true, accessToken: refreshed.access_token });
40+
const isHttps = (request.headers.get('x-forwarded-proto') || 'http') === 'https';
41+
response.cookies.set(cookieName, await signPayload(tokens), {
42+
httpOnly: true,
43+
secure: isHttps,
44+
sameSite: 'lax',
45+
maxAge: 30 * 24 * 60 * 60,
46+
path: '/',
47+
});
48+
return response;
49+
} catch {
50+
return NextResponse.json({ hasToken: false });
51+
}
5052
}

0 commit comments

Comments
 (0)