diff --git a/apps/api/package.json b/apps/api/package.json index 695fa36..4def577 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -11,11 +11,13 @@ }, "dependencies": { "@fieldlens/contracts": "1.0.0", + "@modelcontextprotocol/sdk": "^1.30.1", "cors": "^2.8.5", "express": "^5.1.0", "helmet": "^8.1.0", "jose": "^6.1.0", - "pg": "^8.16.3" + "pg": "^8.16.3", + "zod": "^4.6.5" }, "devDependencies": { "@types/cors": "^2.8.19", diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 0d8abd4..453f5e6 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -29,6 +29,7 @@ export const config = { environment: process.env.FIELDLENS_ENV?.trim() || 'development', logLevel: process.env.LOG_LEVEL?.trim() || 'info', 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() || '', 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', diff --git a/apps/api/src/mcp.test.ts b/apps/api/src/mcp.test.ts new file mode 100644 index 0000000..bb08963 --- /dev/null +++ b/apps/api/src/mcp.test.ts @@ -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) + }) +}) diff --git a/apps/api/src/mcp.ts b/apps/api/src/mcp.ts new file mode 100644 index 0000000..7283d2a --- /dev/null +++ b/apps/api/src/mcp.ts @@ -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 }) + } +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index ecc1360..becb546 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -9,6 +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 { runMigrations } from './migrations.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.get('/api/v1/access/me', async (req, res) => {