diff --git a/apps/api/.env.example b/apps/api/.env.example index cc7d281..77da523 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -5,12 +5,14 @@ KEYCLOAK_JWKS_URI=https://auth.example.invalid/realms/example/protocol/openid-co KEYCLOAK_AUDIENCE=fieldlens-api KEYCLOAK_BROWSER_CLIENT_ID=fieldlens KEYCLOAK_DESKTOP_CLIENT_ID=fieldlens-print +KEYCLOAK_MCP_CLIENT_ID=fieldlens-mcp KEYCLOAK_MACHINE_CLIENT_ID=fieldlens-integration FIELDLENS_TENANT_ID=fieldlens-production FIELDLENS_ENV=development LOG_LEVEL=info WEB_ORIGIN=http://localhost:5173 STATIC_DIR= +FIELDLENS_MCP_RESOURCE_URL=http://localhost:5173/api/mcp FIELDLENS_PRINT_RELEASE_TAG=fieldlens-print-v0.1.0 FIELDLENS_PRINT_RELEASE_BASE_URL=https://git.example.invalid/owner/fieldlens/releases/download FIELDLENS_PRINT_DOWNLOAD_DIR=downloads diff --git a/apps/api/src/mcp.test.ts b/apps/api/src/mcp.test.ts index bb08963..d8080bb 100644 --- a/apps/api/src/mcp.test.ts +++ b/apps/api/src/mcp.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' -import { captureLimit, createFieldLensMcpServer } from './mcp.js' +import { FIELDLENS_MCP_OAUTH_SCOPES, captureLimit, createFieldLensMcpServer } from './mcp.js' describe('FieldLens MCP telemetry', () => { it('exposes only a bounded read-only diagnostic tool', async () => { @@ -14,6 +14,10 @@ describe('FieldLens MCP telemetry', () => { expect(tools.map((tool) => tool.name)).toEqual(['list_print_captures']) expect(tools[0]?.annotations?.readOnlyHint).toBe(true) expect(tools[0]?.inputSchema.properties?.limit).toBeDefined() + expect(tools[0]?.securitySchemes).toEqual([ + { type: 'oauth2', scopes: [...FIELDLENS_MCP_OAUTH_SCOPES] }, + ]) + expect(tools[0]?._meta?.securitySchemes).toEqual(tools[0]?.securitySchemes) } finally { await client.close() await server.close() diff --git a/apps/api/src/mcp.ts b/apps/api/src/mcp.ts index 7283d2a..233bc3f 100644 --- a/apps/api/src/mcp.ts +++ b/apps/api/src/mcp.ts @@ -6,6 +6,8 @@ import { config } from './config.js' import { pool } from './db.js' import { log } from './log.js' +export const FIELDLENS_MCP_OAUTH_SCOPES = ['openid', 'offline_access'] as const + export function captureLimit(value: number) { return Number.isFinite(value) ? Math.max(1, Math.min(200, Math.trunc(value))) : 50 } @@ -16,6 +18,10 @@ export function createFieldLensMcpServer() { description: 'Read recent FieldLens printer capture diagnostics for the authorized tenant. No print document or barcode values are included.', inputSchema: { limit: z.number().int().min(1).max(200).optional() }, annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, + securitySchemes: [{ type: 'oauth2', scopes: [...FIELDLENS_MCP_OAUTH_SCOPES] }], + _meta: { + securitySchemes: [{ type: 'oauth2', scopes: [...FIELDLENS_MCP_OAUTH_SCOPES] }], + }, }, async ({ limit }) => { const result = await pool.query(` SELECT id, device_id AS "deviceId", source, stream_format AS "streamFormat", diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index becb546..2aa122b 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -9,7 +9,7 @@ import { config } from './config.js' import { loadDeviceContext } from './context.js' import { databaseReady, pool } from './db.js' import { log } from './log.js' -import { handleMcpRequest } from './mcp.js' +import { FIELDLENS_MCP_OAUTH_SCOPES, handleMcpRequest } from './mcp.js' import { runMigrations } from './migrations.js' import { processSyncEvent } from './sync.js' @@ -103,11 +103,11 @@ app.get('/downloads/fieldlens-print-windows-x64-setup.exe.sig', (_req, res) => { }) app.get('/downloads/fieldlens-print-linux-x64.AppImage', (_req, res) => { - redirectDesktopDownload(res, 'fieldlens-print-linux-x64.AppImage', config.linuxDesktopDownloadBaseUrl) + redirectDesktopDownload(res, 'fieldlens-print-linux-x64.AppImage') }) app.get('/downloads/fieldlens-print-linux-x64.AppImage.sig', (_req, res) => { - redirectDesktopDownload(res, 'fieldlens-print-linux-x64.AppImage.sig', config.linuxDesktopDownloadBaseUrl) + redirectDesktopDownload(res, 'fieldlens-print-linux-x64.AppImage.sig') }) // Backward-compatible aliases used by the first downloads page. @@ -132,11 +132,19 @@ app.get('/api/health/ready', async (_req, res) => { const mcpResourceMetadataUrl = new URL('/.well-known/oauth-protected-resource/api/mcp', config.mcpResourceUrl).href app.get('/.well-known/oauth-protected-resource/api/mcp', (_req, res) => { res.setHeader('Cache-Control', 'public, max-age=300') - res.json({ resource: config.mcpResourceUrl, authorization_servers: [config.issuer], bearer_methods_supported: ['header'] }) + res.json({ + resource: config.mcpResourceUrl, + authorization_servers: [config.issuer], + scopes_supported: [...FIELDLENS_MCP_OAUTH_SCOPES], + bearer_methods_supported: ['header'], + }) }) app.use('/api/mcp', (_req, res, next) => { - res.setHeader('WWW-Authenticate', `Bearer resource_metadata="${mcpResourceMetadataUrl}"`) + res.setHeader( + 'WWW-Authenticate', + `Bearer resource_metadata="${mcpResourceMetadataUrl}", scope="${FIELDLENS_MCP_OAUTH_SCOPES.join(' ')}"`, + ) res.setHeader('Cache-Control', 'no-store') next() }, authenticate, (_req, res, next) => {