Skip to content

Commit 64242ae

Browse files
committed
update
1 parent 0a51cb8 commit 64242ae

111 files changed

Lines changed: 11669 additions & 9114 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,17 @@
55
**/__pycache__
66
.vscode
77

8+
9+
/backend/public/webapp
10+
811
# OpenAPI Generator Cache
912
.openapi-generator/
1013

1114
# OpenAPI Generator auto-generated files we don't want
1215
**/generated
1316
ui/src/lib/api-client
14-
**/backend_mcp_server_generated.py
17+
**/generated_mcp
18+
**/openapi.json
1519

1620
# AI Tools Cache
1721
.ai_tools_cache/

backend/.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,7 @@ PORT=8445
1616
# MCP AI Assistant server configuration
1717
MCP_SERVER_URL=http://localhost:8081
1818
MCP_SERVER_TIMEOUT_MS=10000
19+
20+
# OpenAI API Key (required for AI assistant functionality)
21+
# Get your key from: https://platform.openai.com/api-keys
22+
OPENAI_API_KEY=sk-proj-...

backend/eslint.config.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import js from "@eslint/js";
22
import globals from "globals";
33
import tseslint from "typescript-eslint";
4+
import path from "path";
5+
import { fileURLToPath } from "url";
6+
7+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
48

59
export default tseslint.config(
610
{ ignores: ["dist/**", 'node_modules/**, "**/lib/api-client/**'] },
@@ -10,6 +14,11 @@ export default tseslint.config(
1014
languageOptions: {
1115
ecmaVersion: 2022,
1216
globals: globals.node,
17+
parserOptions: {
18+
tsconfigRootDir: __dirname,
19+
// Don't enforce project-based type checking to avoid include issues
20+
// ESLint will still do basic TS parsing without full type information
21+
}
1322
},
1423
rules: {
1524
"no-console": "off", // Allow console.log in backend

backend/package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,23 @@
88
"test:watch": "bun test --watch",
99
"dev": "bun run --watch src/index.ts",
1010
"start": "bun run src/index.ts",
11+
"mcp": "bun run src/mcp-server.ts",
1112
"build": "tsc --noEmit && bun build src/index.ts --outdir dist --target node",
1213
"export-openapi": "mkdir -p dist && bun run src/export-openapi.ts && bun run scripts/sanitize-openapi.ts",
1314
"lint": "eslint .",
1415
"lint:fix": "eslint . --fix"
1516
},
1617
"dependencies": {
18+
"@ai-sdk/openai": "^2.0.52",
19+
"ai": "^5.0.70",
20+
"@modelcontextprotocol/sdk": "^1.4.0",
21+
"zod": "^3.23.8",
1722
"@elysiajs/cors": "^1.4.0",
1823
"@elysiajs/eden": "^1.4.3",
1924
"@elysiajs/openapi": "^1.4.11",
2025
"@elysiajs/static": "^1.4.0",
2126
"@keycloak/keycloak-admin-client": "^26.4.0",
27+
"@sinclair/typebox": "^0.34.41",
2228
"cross-fetch": "^4.1.0",
2329
"elysia": "^1.4.11",
2430
"jsonwebtoken": "^9.0.2",

backend/src/app-factory.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { Elysia } from 'elysia'
2+
import { openapi, fromTypes } from '@elysiajs/openapi'
3+
import { cors } from '@elysiajs/cors'
4+
import staticPlugin from '@elysiajs/static'
5+
import { join } from 'path'
6+
import { keycloakPlugin } from './lib/keycloak-plugin'
7+
import { fhirRoutes } from './routes/fhir'
8+
import { statusRoutes } from './routes/status'
9+
import { serverDiscoveryRoutes } from './routes/fhir-servers'
10+
import { oauthMonitoringRoutes } from './routes/oauth-monitoring'
11+
import { oauthWebSocket } from './routes/oauth-websocket'
12+
import { config } from './config'
13+
import { adminRoutes } from './routes/admin'
14+
import { authRoutes } from './routes/auth'
15+
import { mcpMetadataRoutes } from './routes/auth/mcp-metadata'
16+
import { docsRoutes } from './routes/docs'
17+
import { mcpHttpRoutes } from './routes/mcp-http'
18+
19+
export function createApp() {
20+
const app = new Elysia({
21+
name: config.name,
22+
serve: {
23+
idleTimeout: 120
24+
},
25+
websocket: {
26+
idleTimeout: 120
27+
},
28+
aot: true,
29+
sanitize: (value) => Bun.escapeHTML(value)
30+
})
31+
.use(cors({
32+
origin: config.cors.origins,
33+
credentials: true,
34+
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
35+
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'Accept', 'Origin']
36+
}))
37+
.use(openapi({
38+
references: fromTypes(
39+
process.env.NODE_ENV === 'production' ? 'dist/index.d.ts' : 'src/index.ts',
40+
{ projectRoot: join(import.meta.dir, '..') }
41+
),
42+
documentation: {
43+
info: {
44+
title: config.displayName,
45+
version: config.version,
46+
description: 'SMART on FHIR Proxy + Healthcare Administration API using Keycloak and Elysia',
47+
},
48+
tags: [
49+
{ name: 'authentication', description: 'Authentication and authorization endpoints' },
50+
{ name: 'users', description: 'Healthcare user management' },
51+
{ name: 'admin', description: 'Administrative operations' },
52+
{ name: 'fhir', description: 'FHIR resource proxy endpoints' },
53+
{ name: 'servers', description: 'FHIR server discovery endpoints' },
54+
{ name: 'identity-providers', description: 'Identity provider management' },
55+
{ name: 'smart-apps', description: 'SMART on FHIR configuration endpoints' },
56+
{ name: 'oauth-ws-monitoring', description: 'OAuth monitoring via WebSocket' },
57+
{ name: 'oauth-sse-monitoring', description: 'OAuth monitoring via Server-Sent Events' },
58+
{ name: 'ai', description: 'AI assistant endpoints proxied to MCP server' },
59+
],
60+
servers: [
61+
{ url: config.baseUrl, description: 'Development server' }
62+
]
63+
}
64+
}))
65+
.use(staticPlugin({ assets: 'public', prefix: '/' }))
66+
.use(keycloakPlugin)
67+
.use(docsRoutes)
68+
.use(mcpMetadataRoutes)
69+
// Optional: HTTP MCP endpoints (legacy/alt transport) behind a flag
70+
.use(process.env.MCP_HTTP_ENABLED === 'true' ? mcpHttpRoutes : (new Elysia()))
71+
.use(statusRoutes)
72+
.use(serverDiscoveryRoutes)
73+
.use(authRoutes)
74+
.use(adminRoutes)
75+
.use(oauthMonitoringRoutes)
76+
.use(oauthWebSocket)
77+
.use(fhirRoutes)
78+
79+
return app
80+
}

backend/src/config.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,14 @@ export const config = {
9292
get baseUrl() {
9393
return process.env.MCP_SERVER_URL || 'http://localhost:8081';
9494
},
95+
// AI routing flags
96+
// In MONO_MODE, we use the internal Node AI; otherwise we proxy to the remote (Python MCP) AI
97+
get useInternalAI() {
98+
return (process.env.MONO_MODE || '').toLowerCase() === 'true'
99+
},
100+
get useRemoteAI() {
101+
return !this.useInternalAI
102+
},
95103
get chatEndpoint() {
96104
return `${this.baseUrl.replace(/\/$/, '')}/ai/chat`;
97105
},
@@ -106,6 +114,64 @@ export const config = {
106114
}
107115
},
108116

117+
mcp: {
118+
// Whether to expose MCP HTTP discovery metadata
119+
get httpEnabled() {
120+
return (process.env.MCP_HTTP_ENABLED || 'true').toLowerCase() !== 'false'
121+
},
122+
// Base URI for the resource identifier (scheme+host[:port])
123+
get resourceBase() {
124+
return (process.env.MCP_HTTP_RESOURCE_URI || config.baseUrl).replace(/\/$/, '')
125+
},
126+
// Path that identifies the MCP HTTP endpoint (can be empty)
127+
get resourcePath() {
128+
// Default to '/mcp' as the canonical path if using HTTP transport later
129+
const p = process.env.MCP_HTTP_RESOURCE_PATH ?? '/mcp'
130+
return p.startsWith('/') ? p : `/${p}`
131+
},
132+
// Canonical Resource URI per RFC 8707 (no fragment, prefer no trailing slash)
133+
get canonicalResource() {
134+
const base = this.resourceBase
135+
const path = this.resourcePath
136+
const full = `${base}${path}`
137+
// Normalize to no trailing slash unless path is root
138+
return full.endsWith('/') && path !== '/' ? full.slice(0, -1) : full
139+
},
140+
// Authorization servers (issuer URLs) for this resource
141+
get authorizationServers() {
142+
const envList = process.env.MCP_AUTHORIZATION_SERVERS
143+
? process.env.MCP_AUTHORIZATION_SERVERS.split(',').map(s => s.trim()).filter(Boolean)
144+
: []
145+
if (envList.length) return envList
146+
// Fallback to Keycloak issuer if configured
147+
if (config.keycloak.isConfigured) {
148+
const base = config.keycloak.publicUrl || config.keycloak.baseUrl
149+
const realm = config.keycloak.realm
150+
if (base && realm) return [`${base.replace(/\/$/, '')}/realms/${realm}`]
151+
}
152+
return [] as string[]
153+
},
154+
// Scopes supported by this resource (authorization guidance for clients)
155+
get scopesSupported() {
156+
const env = process.env.MCP_SCOPES_SUPPORTED
157+
if (env) {
158+
// Support comma or space separated
159+
const parts = env.split(/[,\s]+/).map(s => s.trim()).filter(Boolean)
160+
if (parts.length) return parts
161+
}
162+
return ['tools:use']
163+
},
164+
// Optional scope value to send in WWW-Authenticate challenges
165+
get scopeChallenge() {
166+
return process.env.MCP_SCOPE_CHALLENGE || this.scopesSupported[0] || undefined
167+
},
168+
// JWT audience claim - what audience should tokens have to be accepted?
169+
// Defaults to the canonical resource URI (e.g., "http://localhost:8445/mcp")
170+
get audience() {
171+
return process.env.JWT_AUDIENCE || this.canonicalResource
172+
}
173+
},
174+
109175
cors: {
110176
// Support multiple origins - can be a single URL or comma-separated list
111177
// Defaults to common development origins

backend/src/export-openapi.ts

Lines changed: 87 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,36 +10,31 @@ import { oauthMonitoringRoutes } from './routes/oauth-monitoring'
1010
import { oauthWebSocket } from './routes/oauth-websocket'
1111
import { adminRoutes } from './routes/admin'
1212
import { authRoutes } from './routes/auth'
13-
import { aiRoutes, aiPublicRoutes } from './routes/admin/ai'
13+
import { aiRoutes, aiPublicRoutes } from './routes/admin/ai-external'
1414
import { writeFileSync, mkdirSync } from 'fs'
1515
import { join } from 'path'
1616

17-
// Minimal config for OpenAPI export - provides defaults for required environment variables
17+
/**
18+
* Export configuration - uses values from config module (which reads from .env and package.json)
19+
* No hardcoded values - everything comes from environment or package.json
20+
*/
1821
const exportConfig = {
19-
name: 'proxy-smart',
20-
displayName: 'Proxy Smart',
21-
version: process.env.npm_package_version || '1.0.0',
22-
baseUrl: 'http://localhost:3001',
23-
port: 3001,
22+
name: config.name,
23+
displayName: config.displayName,
24+
version: config.version,
25+
baseUrl: config.baseUrl,
26+
port: config.port,
2427
keycloak: {
25-
serverUrl: process.env.KEYCLOAK_SERVER_URL || 'http://localhost:8080',
26-
realm: process.env.KEYCLOAK_REALM || 'proxy-smart',
27-
clientId: process.env.KEYCLOAK_CLIENT_ID || 'proxy-smart-admin',
28-
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET || 'mock-secret',
28+
serverUrl: config.keycloak.publicUrl || config.keycloak.baseUrl || 'http://localhost:8080',
29+
realm: config.keycloak.realm || 'proxy-smart',
30+
jwksUri: config.keycloak.jwksUri,
2931
},
3032
fhir: {
31-
serverBases: (process.env.FHIR_SERVER_BASE ?? 'http://localhost:8081/fhir')
32-
.split(',')
33-
.map(s => s.trim()),
34-
},
35-
logging: {
36-
level: 'info' as const,
37-
oauthMetrics: false,
33+
serverBases: config.fhir.serverBases,
3834
},
3935
cors: {
40-
allowedOrigins: ['http://localhost:5173', 'http://localhost:3000'],
36+
allowedOrigins: config.cors.origins,
4137
},
42-
enableMutualTLS: false,
4338
}
4439

4540
// Create the same app configuration as the main server
@@ -95,13 +90,57 @@ const app = new Elysia({
9590
bearerFormat: 'JWT',
9691
description: 'JWT Bearer token from OAuth2 flow'
9792
},
93+
OAuth2: {
94+
type: 'oauth2',
95+
description: 'OAuth2 authentication via Keycloak with SMART on FHIR support',
96+
flows: {
97+
authorizationCode: {
98+
authorizationUrl: `${exportConfig.baseUrl}/auth/authorize`,
99+
tokenUrl: `${exportConfig.baseUrl}/auth/token`,
100+
refreshUrl: `${exportConfig.baseUrl}/auth/token`,
101+
scopes: {
102+
'openid': 'OpenID Connect authentication',
103+
'profile': 'User profile information',
104+
'email': 'User email address',
105+
'patient/*.read': 'Read all patient data',
106+
'patient/*.write': 'Write all patient data',
107+
'user/*.read': 'Read all data for current user',
108+
'user/*.write': 'Write all data for current user',
109+
'launch': 'SMART launch context',
110+
'launch/patient': 'SMART launch with patient context',
111+
'launch/encounter': 'SMART launch with encounter context',
112+
'offline_access': 'Offline access via refresh token'
113+
}
114+
},
115+
password: {
116+
tokenUrl: `${exportConfig.baseUrl}/auth/token`,
117+
refreshUrl: `${exportConfig.baseUrl}/auth/token`,
118+
scopes: {
119+
'openid': 'OpenID Connect authentication',
120+
'profile': 'User profile information',
121+
'email': 'User email address'
122+
}
123+
},
124+
clientCredentials: {
125+
tokenUrl: `${exportConfig.baseUrl}/auth/token`,
126+
scopes: {
127+
'system/*.read': 'System-level read access to FHIR resources',
128+
'system/*.write': 'System-level write access to FHIR resources'
129+
}
130+
}
131+
}
132+
},
98133
MutualTLS: {
99134
type: 'http',
100135
scheme: 'mutual-tls',
101136
description: 'Mutual TLS authentication for secure API communication between proxy and FHIR servers. Submit a request to the infrastructure team with full information about your application to obtain a client certificate.'
102137
}
103138
}
104139
},
140+
security: [
141+
{ OAuth2: ['openid', 'profile', 'email'] },
142+
{ BearerAuth: [] }
143+
],
105144
servers: [
106145
{
107146
url: exportConfig.baseUrl,
@@ -148,15 +187,40 @@ const exportSpec = async () => {
148187

149188
const spec = await response.json()
150189

190+
// Add custom OpenAPI extensions for MCP server generation
191+
// These help the MCP generator extract authentication configuration
192+
spec['x-jwks-uri'] = exportConfig.keycloak.jwksUri || `${exportConfig.baseUrl}/.well-known/jwks.json`
193+
spec['x-issuer'] = exportConfig.keycloak.serverUrl ?
194+
`${exportConfig.keycloak.serverUrl}/realms/${exportConfig.keycloak.realm}` :
195+
exportConfig.baseUrl
196+
spec['x-audience'] = config.mcp.audience
197+
spec['x-token-endpoint'] = `${exportConfig.baseUrl}/auth/token`
198+
spec['x-authorization-endpoint'] = `${exportConfig.baseUrl}/auth/authorize`
199+
spec['x-userinfo-endpoint'] = `${exportConfig.baseUrl}/auth/userinfo`
200+
201+
console.log('📝 Added custom OpenAPI extensions:')
202+
console.log(` x-jwks-uri: ${spec['x-jwks-uri']}`)
203+
console.log(` x-issuer: ${spec['x-issuer']}`)
204+
console.log(` x-audience: ${spec['x-audience']}`)
205+
151206
// Ensure dist directory exists
152207
const distDir = join(process.cwd(), 'dist')
153208
mkdirSync(distDir, { recursive: true })
154209

155-
// Write to file
210+
// Write to backend dist
156211
const outputPath = join(distDir, 'openapi.json')
157-
writeFileSync(outputPath, JSON.stringify(spec, null, 2))
158-
212+
const specJson = JSON.stringify(spec, null, 2)
213+
writeFileSync(outputPath, specJson)
159214
console.log(`✅ OpenAPI spec exported to: ${outputPath}`)
215+
216+
// Also copy to mcp-server folder for the Python generator
217+
const mcpServerPath = join(process.cwd(), '..', 'mcp-server', 'openapi.json')
218+
try {
219+
writeFileSync(mcpServerPath, specJson)
220+
console.log(`✅ OpenAPI spec copied to: ${mcpServerPath}`)
221+
} catch (copyError) {
222+
console.warn(`⚠️ Failed to copy to mcp-server folder: ${copyError}`)
223+
}
160224

161225
serverInstance?.stop()
162226
process.exit(0)

0 commit comments

Comments
 (0)