feat(api): serve Keycloak-protected read-only FieldLens MCP telemetry
This commit is contained in:
parent
e22fe58138
commit
c41d242667
@ -11,11 +11,13 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fieldlens/contracts": "1.0.0",
|
"@fieldlens/contracts": "1.0.0",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.30.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
"jose": "^6.1.0",
|
"jose": "^6.1.0",
|
||||||
"pg": "^8.16.3"
|
"pg": "^8.16.3",
|
||||||
|
"zod": "^4.6.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
|
|||||||
@ -29,6 +29,7 @@ export const config = {
|
|||||||
environment: process.env.FIELDLENS_ENV?.trim() || 'development',
|
environment: process.env.FIELDLENS_ENV?.trim() || 'development',
|
||||||
logLevel: process.env.LOG_LEVEL?.trim() || 'info',
|
logLevel: process.env.LOG_LEVEL?.trim() || 'info',
|
||||||
webOrigin: process.env.WEB_ORIGIN?.trim() || 'http://localhost:5173',
|
webOrigin: process.env.WEB_ORIGIN?.trim() || 'http://localhost:5173',
|
||||||
|
mcpResourceUrl: process.env.FIELDLENS_MCP_RESOURCE_URL?.trim() || new URL('/api/mcp', process.env.WEB_ORIGIN?.trim() || 'http://localhost:5173').href,
|
||||||
staticDir: process.env.STATIC_DIR?.trim() || '',
|
staticDir: process.env.STATIC_DIR?.trim() || '',
|
||||||
desktopDownloadBaseUrl: process.env.FIELDLENS_DESKTOP_DOWNLOAD_BASE_URL?.trim() || 'https://fieldlens-desktop-build.arrowstack.dev',
|
desktopDownloadBaseUrl: process.env.FIELDLENS_DESKTOP_DOWNLOAD_BASE_URL?.trim() || 'https://fieldlens-desktop-build.arrowstack.dev',
|
||||||
linuxDesktopDownloadBaseUrl: process.env.FIELDLENS_LINUX_DESKTOP_DOWNLOAD_BASE_URL?.trim() || process.env.FIELDLENS_DESKTOP_DOWNLOAD_BASE_URL?.trim() || 'https://fieldlens-desktop-build.arrowstack.dev',
|
linuxDesktopDownloadBaseUrl: process.env.FIELDLENS_LINUX_DESKTOP_DOWNLOAD_BASE_URL?.trim() || process.env.FIELDLENS_DESKTOP_DOWNLOAD_BASE_URL?.trim() || 'https://fieldlens-desktop-build.arrowstack.dev',
|
||||||
|
|||||||
28
apps/api/src/mcp.test.ts
Normal file
28
apps/api/src/mcp.test.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
describe('FieldLens MCP telemetry', () => {
|
||||||
|
it('exposes only a bounded read-only diagnostic tool', async () => {
|
||||||
|
const server = createFieldLensMcpServer()
|
||||||
|
const client = new Client({ name: 'fieldlens-test', version: '1.0.0' })
|
||||||
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
|
||||||
|
try {
|
||||||
|
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)])
|
||||||
|
const { tools } = await client.listTools()
|
||||||
|
expect(tools.map((tool) => tool.name)).toEqual(['list_print_captures'])
|
||||||
|
expect(tools[0]?.annotations?.readOnlyHint).toBe(true)
|
||||||
|
expect(tools[0]?.inputSchema.properties?.limit).toBeDefined()
|
||||||
|
} finally {
|
||||||
|
await client.close()
|
||||||
|
await server.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clamps row count to a hard limit', () => {
|
||||||
|
expect(captureLimit(0)).toBe(1)
|
||||||
|
expect(captureLimit(500)).toBe(200)
|
||||||
|
expect(captureLimit(Number.NaN)).toBe(50)
|
||||||
|
})
|
||||||
|
})
|
||||||
47
apps/api/src/mcp.ts
Normal file
47
apps/api/src/mcp.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import type { Request, Response } from 'express'
|
||||||
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||||
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
|
||||||
|
import { z } from 'zod/v4'
|
||||||
|
import { config } from './config.js'
|
||||||
|
import { pool } from './db.js'
|
||||||
|
import { log } from './log.js'
|
||||||
|
|
||||||
|
export function captureLimit(value: number) {
|
||||||
|
return Number.isFinite(value) ? Math.max(1, Math.min(200, Math.trunc(value))) : 50
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFieldLensMcpServer() {
|
||||||
|
const server = new McpServer({ name: 'fieldlens', version: '1.0.0' })
|
||||||
|
server.registerTool('list_print_captures', {
|
||||||
|
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 },
|
||||||
|
}, async ({ limit }) => {
|
||||||
|
const result = await pool.query(`
|
||||||
|
SELECT id, device_id AS "deviceId", source, stream_format AS "streamFormat",
|
||||||
|
byte_count AS "byteCount", barcode_count AS "barcodeCount", forwarded,
|
||||||
|
failure_kind AS "failureKind", app_version AS "appVersion",
|
||||||
|
captured_at AS "capturedAt", received_at AS "receivedAt"
|
||||||
|
FROM print_capture_diagnostics WHERE tenant_id = $1
|
||||||
|
ORDER BY captured_at DESC LIMIT $2
|
||||||
|
`, [config.tenantId, captureLimit(limit ?? 50)])
|
||||||
|
return { content: [{ type: 'text', text: JSON.stringify({ captures: result.rows }) }] }
|
||||||
|
})
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleMcpRequest(req: Request, res: Response) {
|
||||||
|
const server = createFieldLensMcpServer()
|
||||||
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true })
|
||||||
|
res.once('close', () => {
|
||||||
|
void transport.close()
|
||||||
|
void server.close()
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
await server.connect(transport)
|
||||||
|
await transport.handleRequest(req, res, req.body)
|
||||||
|
} catch {
|
||||||
|
log('error', 'mcp_request_failed')
|
||||||
|
if (!res.headersSent) res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal server error' }, id: null })
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@ import { config } from './config.js'
|
|||||||
import { loadDeviceContext } from './context.js'
|
import { loadDeviceContext } from './context.js'
|
||||||
import { databaseReady, pool } from './db.js'
|
import { databaseReady, pool } from './db.js'
|
||||||
import { log } from './log.js'
|
import { log } from './log.js'
|
||||||
|
import { handleMcpRequest } from './mcp.js'
|
||||||
import { runMigrations } from './migrations.js'
|
import { runMigrations } from './migrations.js'
|
||||||
import { processSyncEvent } from './sync.js'
|
import { processSyncEvent } from './sync.js'
|
||||||
|
|
||||||
@ -128,6 +129,24 @@ 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'] })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.use('/api/mcp', (_req, res, next) => {
|
||||||
|
res.setHeader('WWW-Authenticate', `Bearer resource_metadata="${mcpResourceMetadataUrl}"`)
|
||||||
|
res.setHeader('Cache-Control', 'no-store')
|
||||||
|
next()
|
||||||
|
}, authenticate, (_req, res, next) => {
|
||||||
|
res.removeHeader('WWW-Authenticate')
|
||||||
|
next()
|
||||||
|
}, requireAccess('operator'))
|
||||||
|
app.post('/api/mcp', handleMcpRequest)
|
||||||
|
app.get('/api/mcp', (_req, res) => { res.status(405).end() })
|
||||||
|
app.delete('/api/mcp', (_req, res) => { res.status(405).end() })
|
||||||
|
|
||||||
app.use('/api/v1', authenticate)
|
app.use('/api/v1', authenticate)
|
||||||
|
|
||||||
app.get('/api/v1/access/me', async (req, res) => {
|
app.get('/api/v1/access/me', async (req, res) => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user