From 66c52cdcdb2358ae97dc9451e56152232fd047ba Mon Sep 17 00:00:00 2001 From: nicdavidson Date: Fri, 18 Sep 2026 12:30:04 -0600 Subject: [PATCH 1/6] feat(mcp): pure model and admin API client for the MCP server page Catalog shaping (prefixed/merged, lazy threshold, per-identity reach), grid cell states, role grant diffs and PATCH rows, connect snippets and usage attribution live in mcp-model.ts with no Angular dependency. DfMcpApiService wraps /_internal/ai/mcp-{access,catalog,health,usage}, the tools/list JSON-RPC fallback and the role/app/service system calls, returning null when an endpoint is not deployed. --- jest.config.ci.js | 2 + src/app/adf-ai/mcp/df-mcp-api.service.ts | 349 ++++++++++++++ src/app/adf-ai/mcp/mcp-model.spec.ts | 319 +++++++++++++ src/app/adf-ai/mcp/mcp-model.ts | 581 +++++++++++++++++++++++ 4 files changed, 1251 insertions(+) create mode 100644 src/app/adf-ai/mcp/df-mcp-api.service.ts create mode 100644 src/app/adf-ai/mcp/mcp-model.spec.ts create mode 100644 src/app/adf-ai/mcp/mcp-model.ts diff --git a/jest.config.ci.js b/jest.config.ci.js index df1309a9..2f43d405 100644 --- a/jest.config.ci.js +++ b/jest.config.ci.js @@ -48,5 +48,7 @@ module.exports = { '/src/app/adf-ai-usage/utils/task-estimate.spec.ts', '/src/app/shared/components/df-section-landing/df-section-landing.component.spec.ts', '/src/app/adf-home/df-welcome-page/df-generate-api-card/df-generate-api-card.component.spec.ts', + // 2026-09-18 MCP server page (#523): pure catalog / access / snippet model. + '/src/app/adf-ai/mcp/mcp-model.spec.ts', ], }; diff --git a/src/app/adf-ai/mcp/df-mcp-api.service.ts b/src/app/adf-ai/mcp/df-mcp-api.service.ts new file mode 100644 index 00000000..1ef9b7bb --- /dev/null +++ b/src/app/adf-ai/mcp/df-mcp-api.service.ts @@ -0,0 +1,349 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable, catchError, forkJoin, map, of } from 'rxjs'; +import { BASE_URL } from 'src/app/shared/constants/urls'; +import { silent, toastOff } from 'src/app/shared/utilities/http-contexts'; +import { McpBackend, RoleAccessRow } from './mcp-model'; + +/* + * Shapes served by df-mcp-server's admin endpoints. `/_internal` responses + * are NOT camel-cased by the case interceptor (it only rewrites /api), so + * these stay snake_case; `/api/v2/system/*` responses arrive camelCased. + */ + +export interface McpAccessRole { + role_id: number; + name: string; + granted: boolean; + requests: number; + denied: number; + last_seen: string | null; +} + +export interface McpAccess { + service_id: number; + service: string; + require_role_access: boolean; + roles: McpAccessRole[]; +} + +export interface McpCatalogTool { + name: string; + title?: string; + description?: string; + category?: string; + write?: boolean; + service?: string; +} + +export interface McpCatalogBackend { + name: string; + type: string; + category: 'database' | 'file' | string; + /** Union of the role's verbs on this service: ['GET', 'POST', ...]. */ + verbs: string[]; + component_scoped: boolean; + components: string[]; +} + +export interface McpCatalog { + service?: { id: number; name: string; type: string }; + role?: { id: number; name: string; is_active: boolean }; + app_id?: number | null; + lazy_mode?: string; + tool_style?: string; + backends?: McpCatalogBackend[]; + tools: McpCatalogTool[]; + count: number; + bytes: number; + lazy: 'lazy' | 'direct' | 'passthrough'; + facade: string[]; +} + +export interface McpHealth { + status: string; + checks: Array<{ id: string; status: string; message: string }>; + daemons: Array<{ type: string; reachable: boolean }>; +} + +export interface McpUsage { + by_tool: Array<{ tool_name: string; requests: number }>; + by_role: Array<{ role_id: number | null; requests: number }>; + by_app: Array<{ app_id: number | null; requests: number }>; + series: Array<{ date: string; requests: number }>; + total_requests?: number; +} + +export interface McpRole { + id: number; + name: string; + isActive?: boolean; + rows: RoleAccessRow[]; +} + +export interface McpApp { + id: number; + name: string; + roleId: number | null; + apiKey: string | null; +} + +/** Calibration source for catalog bytes: which endpoint answered. */ +export type CatalogSource = 'catalog' | 'rpc' | 'estimate'; + +@Injectable({ providedIn: 'root' }) +export class DfMcpApiService { + constructor(private http: HttpClient) {} + + /** Endpoint an MCP client connects to. */ + endpointUrl(serviceName: string): string { + return `${window.location.origin}/mcp/${serviceName}`; + } + + /** + * Backends an MCP server can attach: the `exposed_services.values` of the + * type schema when the daemon publishes them, else every active database + * and file service. Ids come from the service table so role rows can be + * matched to backends. + */ + attachableBackends( + attachableNames: string[] | null + ): Observable { + return forkJoin({ + types: this.http.get(`${BASE_URL}/system/service_type`, { + params: { fields: 'name,group' }, + context: silent(), + }), + services: this.http.get(`${BASE_URL}/system/service`, { + params: { fields: 'id,name,label,type,is_active' }, + context: silent(), + }), + }).pipe( + map(({ types, services }) => { + const kindOf: Record = {}; + for (const t of types?.resource ?? []) { + if (t.group === 'Database') kindOf[t.name] = 'database'; + if (t.group === 'File') kindOf[t.name] = 'file'; + } + const allowed = attachableNames ? new Set(attachableNames) : null; + return (services?.resource ?? []) + .filter( + (s: any) => + s.isActive !== false && + kindOf[s.type] && + (!allowed || allowed.has(s.name)) + ) + .map( + (s: any): McpBackend => ({ + id: s.id, + name: s.name, + label: s.label || s.name, + kind: kindOf[s.type], + }) + ); + }), + catchError(() => of([])) + ); + } + + /** Every service id -> name, for reach summaries across non-backends. */ + serviceNames(): Observable> { + return this.http + .get(`${BASE_URL}/system/service`, { + params: { fields: 'id,name' }, + context: silent(), + }) + .pipe( + map(res => { + const out: Record = {}; + for (const s of res?.resource ?? []) out[s.id] = s.name; + return out; + }), + catchError(() => of({})) + ); + } + + access(service: string | number): Observable { + return this.http + .get('/_internal/ai/mcp-access', { + params: { service: String(service), period: '30d' }, + context: silent(), + }) + .pipe(catchError(() => of(null))); + } + + /** + * Catalog preview as one role or app key. The endpoint needs one of the + * two (there is no admin view); null when it is missing or errors. + */ + catalog( + service: string | number, + identity: { roleId?: number; appId?: number } + ): Observable { + if (identity.appId == null && identity.roleId == null) return of(null); + const params: Record = { service: String(service) }; + if (identity.appId != null) params['app_id'] = String(identity.appId); + else params['role_id'] = String(identity.roleId); + return this.http + .get('/_internal/ai/mcp-catalog', { + params, + context: silent(), + }) + .pipe(catchError(() => of(null))); + } + + /** + * Fallback catalog: the server's own tools/list over JSON-RPC, as the + * admin. Returns the tool names and the real byte size of the list. + */ + rpcToolsList( + serviceName: string + ): Observable<{ names: string[]; bytes: number } | null> { + return this.http + .post( + `${BASE_URL}/${serviceName}/rpc`, + { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }, + { context: silent() } + ) + .pipe( + map(res => { + const tools: any[] = res?.result?.tools ?? []; + if (!tools.length) return null; + return { + names: tools.map(t => t.name), + bytes: JSON.stringify(tools).length, + }; + }), + catchError(() => of(null)) + ); + } + + health(): Observable { + return this.http + .get('/_internal/ai/mcp-health', { context: silent() }) + .pipe(catchError(() => of(null))); + } + + usage(serviceId: number, period = '7d'): Observable { + return this.http + .get('/_internal/ai/mcp-usage', { + params: { period, service_id: String(serviceId) }, + context: silent(), + }) + .pipe(catchError(() => of(null))); + } + + roles(): Observable { + return this.http + .get(`${BASE_URL}/system/role`, { + params: { + fields: 'id,name,is_active', + related: 'role_service_access_by_role_id', + limit: '1000', + }, + context: silent(), + }) + .pipe( + map(res => + (res?.resource ?? []).map( + (r: any): McpRole => ({ + id: r.id, + name: r.name, + isActive: r.isActive, + rows: (r.roleServiceAccessByRoleId ?? []).map( + (row: any): RoleAccessRow => ({ + id: row.id, + serviceId: row.serviceId, + component: row.component, + verbMask: row.verbMask ?? 0, + requestorMask: row.requestorMask, + }) + ), + }) + ) + ), + catchError(() => of([])) + ); + } + + apps(): Observable { + return this.http + .get(`${BASE_URL}/system/app`, { + params: { fields: 'id,name,role_id,api_key,is_active', limit: '1000' }, + context: silent(), + }) + .pipe( + map(res => + (res?.resource ?? []) + .filter((a: any) => a.isActive !== false && a.roleId != null) + .map( + (a: any): McpApp => ({ + id: a.id, + name: a.name, + roleId: a.roleId, + apiKey: a.apiKey ?? null, + }) + ) + ), + catchError(() => of([])) + ); + } + + /** PATCH role access rows (update by id / create / unlink with role_id null). */ + patchRoleAccess( + roleId: number, + rows: Array> + ): Observable { + return this.http.patch( + `${BASE_URL}/system/role/${roleId}`, + { role_service_access_by_role_id: rows }, + { context: toastOff() } + ); + } + + createRole( + name: string, + description: string, + rows: Array> + ): Observable { + return this.http + .post( + `${BASE_URL}/system/role`, + { + resource: [ + { + name, + description, + is_active: true, + role_service_access_by_role_id: rows, + }, + ], + }, + { params: { fields: 'id' }, context: toastOff() } + ) + .pipe(map(res => res?.resource?.[0]?.id)); + } + + /** Create an app (API key) bound to a role; resolves to the key. */ + createAppKey(name: string, roleId: number): Observable { + return this.http + .post( + `${BASE_URL}/system/app`, + { + resource: [{ name, type: 0, role_id: roleId, is_active: true }], + }, + { params: { fields: 'id,api_key' }, context: toastOff() } + ) + .pipe(map(res => res?.resource?.[0]?.apiKey ?? null)); + } + + createService(payload: Record): Observable { + return this.http + .post( + `${BASE_URL}/system/service`, + { resource: [payload] }, + { params: { fields: 'id' }, context: toastOff() } + ) + .pipe(map(res => res?.resource?.[0]?.id)); + } +} diff --git a/src/app/adf-ai/mcp/mcp-model.spec.ts b/src/app/adf-ai/mcp/mcp-model.spec.ts new file mode 100644 index 00000000..1dca8b66 --- /dev/null +++ b/src/app/adf-ai/mcp/mcp-model.spec.ts @@ -0,0 +1,319 @@ +import { + ADMIN_IDENTITY, + DB_VERBS, + FACADE_TOOLS, + McpBackend, + McpConfig, + accessDiff, + accessRowsForChanges, + callsByBackend, + cellState, + connectSnippet, + grantsByService, + levelFromMask, + maskForLevel, + shapeCatalog, + shapeFixedCatalog, + tokensPerTurn, + toolKey, + verbsByServiceName, +} from './mcp-model'; + +const backends: McpBackend[] = [ + { id: 50, name: 'demo_mysql', label: 'Demo MySQL', kind: 'database' }, + { id: 51, name: 'demo_pgsql', label: 'Demo PostgreSQL', kind: 'database' }, + { id: 52, name: 'demo_sqlsrv', label: 'Demo SQL Server', kind: 'database' }, + { id: 9, name: 'files', label: 'Local Files', kind: 'file' }, +]; + +const base: McpConfig = { + exposedServices: ['demo_mysql', 'demo_pgsql', 'demo_sqlsrv'], + disabledTools: [], + lazyMode: 'auto', + toolStyle: 'prefixed', + allowWrites: true, + allowApiKeyAuth: false, + requireRoleAccess: false, +}; + +const verb = (name: string) => DB_VERBS.find(x => x.name === name)!; + +describe('mcp-model: catalog shaping', () => { + it('prefixed style advertises 5 globals + 5 aggregates + 16 per exposed db', () => { + const shape = shapeCatalog(base, backends); + expect(shape.tools.length).toBe(5 + 5 + 3 * 16); + expect(shape.dbs.map(b => b.name)).toEqual(base.exposedServices); + expect(shape.files).toEqual([]); + expect(shape.lazy).toBe(false); + expect(shape.shown).toBe(shape.tools); + }); + + it('merged style registers each database verb once', () => { + const shape = shapeCatalog({ ...base, toolStyle: 'merged' }, backends); + expect(shape.tools.length).toBe(5 + 5 + 16); + expect(shape.tools.some(t => t.name === 'get_table_data')).toBe(true); + expect(shape.tools.some(t => t.name === 'demo_mysql_get_table_data')).toBe( + false + ); + }); + + it('a single exposed database drops the aggregate tools', () => { + const shape = shapeCatalog( + { ...base, exposedServices: ['demo_mysql'] }, + backends + ); + expect(shape.tools.some(t => t.kind === 'aggregate')).toBe(false); + }); + + it('disabled keys remove the tool in prefixed style and per service in merged', () => { + const key = toolKey('demo_mysql', 'delete_records'); + const prefixed = shapeCatalog({ ...base, disabledTools: [key] }, backends); + expect(prefixed.tools.some(t => t.name === key)).toBe(false); + const merged = shapeCatalog( + { ...base, toolStyle: 'merged', disabledTools: [key] }, + backends + ); + // still advertised because other databases keep it + expect(merged.tools.some(t => t.name === 'delete_records')).toBe(true); + const allOff = shapeCatalog( + { + ...base, + toolStyle: 'merged', + disabledTools: base.exposedServices.map(s => + toolKey(s, 'delete_records') + ), + }, + backends + ); + expect(allOff.tools.some(t => t.name === 'delete_records')).toBe(false); + }); + + it('blocking writes removes every write verb', () => { + const shape = shapeCatalog({ ...base, allowWrites: false }, backends); + expect(shape.writable).toBe(0); + expect(shape.tools.some(t => t.name.endsWith('_create_records'))).toBe( + false + ); + }); + + it('lazy auto flips to the facade above 32 KB, on forces it, off never', () => { + const big = shapeCatalog(base, backends, ADMIN_IDENTITY, [], 700); + expect(big.bytes).toBeGreaterThan(32 * 1024); + expect(big.lazy).toBe(true); + expect(big.shown.map(t => t.name)).toEqual(FACADE_TOOLS); + expect(tokensPerTurn(big)).toBe(Math.round((5 * 700) / 4)); + + expect(shapeCatalog({ ...base, lazyMode: 'on' }, backends).lazy).toBe(true); + expect( + shapeCatalog( + { ...base, lazyMode: 'off' }, + backends, + ADMIN_IDENTITY, + [], + 700 + ).lazy + ).toBe(false); + }); + + it('a role identity only sees backends it holds a verb on', () => { + const shape = shapeCatalog(base, backends, { + kind: 'role', + roleId: 15, + verbs: { demo_mysql: 1 }, + }); + expect(shape.dbs.map(b => b.name)).toEqual(['demo_mysql']); + // write verbs stay advertised (the role denies them at call time) + expect(shape.tools.some(t => t.name === 'demo_mysql_create_records')).toBe( + true + ); + }); + + it('custom tools ride along as their own kind', () => { + const shape = shapeCatalog(base, backends, ADMIN_IDENTITY, ['weather']); + expect(shape.tools.find(t => t.name === 'weather')?.kind).toBe('custom'); + }); + + it('fixed catalog (system_mcp) is the tool list minus disabled', () => { + const shape = shapeFixedCatalog( + { disabledTools: ['delete_service'], lazyMode: 'off' }, + ['list_services', 'get_service', 'delete_service'] + ); + expect(shape.tools.map(t => t.name)).toEqual([ + 'list_services', + 'get_service', + ]); + }); +}); + +describe('mcp-model: grid cell states', () => { + const db = backends[0]; + it('walks the precedence gone > writes-off > off > denied > on', () => { + const readOnly = { kind: 'role' as const, verbs: { demo_mysql: 1 } }; + expect( + cellState(base, backends[3], verb('get_tables'), ADMIN_IDENTITY) + ).toBe('gone'); + expect(cellState(base, backends[1], verb('get_tables'), readOnly)).toBe( + 'gone' + ); + expect( + cellState( + { ...base, allowWrites: false }, + db, + verb('delete_records'), + readOnly + ) + ).toBe('writes-off'); + expect( + cellState( + { ...base, disabledTools: [toolKey('demo_mysql', 'delete_records')] }, + db, + verb('delete_records'), + readOnly + ) + ).toBe('off'); + expect(cellState(base, db, verb('delete_records'), readOnly)).toBe( + 'denied' + ); + expect(cellState(base, db, verb('get_tables'), readOnly)).toBe('on'); + expect(cellState(base, db, verb('delete_records'), ADMIN_IDENTITY)).toBe( + 'on' + ); + }); + + it('update_records is satisfied by PUT or PATCH', () => { + expect( + cellState(base, db, verb('update_records'), { + kind: 'role', + verbs: { demo_mysql: 4 }, + }) + ).toBe('on'); + expect( + cellState(base, db, verb('update_records'), { + kind: 'role', + verbs: { demo_mysql: 8 }, + }) + ).toBe('on'); + expect( + cellState(base, db, verb('update_records'), { + kind: 'role', + verbs: { demo_mysql: 2 }, + }) + ).toBe('denied'); + }); +}); + +describe('mcp-model: access', () => { + it('maps verb masks to levels and back', () => { + expect(levelFromMask(0)).toBe('none'); + expect(levelFromMask(1)).toBe('read'); + expect(levelFromMask(3)).toBe('rw'); + expect(maskForLevel('rw')).toBe(31); + expect(maskForLevel('read')).toBe(1); + expect(maskForLevel('none')).toBe(0); + }); + + it('collapses rows per service and flags table-level limits', () => { + const grants = grantsByService([ + { id: 80, serviceId: 50, component: '_table/orders/*', verbMask: 1 }, + { id: 81, serviceId: 50, component: '_table/customers/*', verbMask: 1 }, + { id: 82, serviceId: 54, component: '*', verbMask: 1 }, + ]); + expect(grants[50]).toEqual({ level: 'read', tableLimited: true, mask: 1 }); + expect(grants[54]).toEqual({ level: 'read', tableLimited: false, mask: 1 }); + expect( + verbsByServiceName([{ serviceId: 50, component: '*', verbMask: 3 }], { + 50: 'demo_mysql', + }) + ).toEqual({ demo_mysql: 3 }); + }); + + it('writes a plain diff and only for changed lines', () => { + const labels = { none: 'no access', read: 'read', rw: 'read and write' }; + expect( + accessDiff( + [ + { serviceId: 50, label: 'Demo MySQL', before: 'none', after: 'read' }, + { serviceId: 51, label: 'Demo PG', before: 'read', after: 'read' }, + { serviceId: 52, label: 'Demo SQL', before: 'rw', after: 'none' }, + ], + labels + ) + ).toEqual([ + 'Demo MySQL: no access → read', + 'Demo SQL: read and write → no access', + ]); + }); + + it('builds PATCH rows: update by id, unlink with role_id null, create new', () => { + const rows = accessRowsForChanges( + [ + { serviceId: 50, label: 'a', before: 'read', after: 'rw' }, + { serviceId: 51, label: 'b', before: 'read', after: 'none' }, + { serviceId: 52, label: 'c', before: 'none', after: 'read' }, + { serviceId: 53, label: 'd', before: 'read', after: 'read' }, + ], + [ + { id: 1, serviceId: 50, component: '*', verbMask: 1 }, + { id: 2, serviceId: 51, component: '*', verbMask: 1 }, + ] + ); + expect(rows).toEqual([ + expect.objectContaining({ id: 1, service_id: 50, verb_mask: 31 }), + { id: 2, role_id: null }, + expect.objectContaining({ service_id: 52, verb_mask: 1, component: '*' }), + ]); + expect(rows[2]['id']).toBeUndefined(); + }); +}); + +describe('mcp-model: connect snippets', () => { + const o = { name: 'demo_mcp', url: 'https://df.example.com/mcp/demo_mcp' }; + it('OAuth snippets carry no key header', () => { + for (const c of [ + 'claude', + 'cursor', + 'vscode', + 'chatgpt', + 'curl', + ] as const) { + expect(connectSnippet(c, o)).not.toContain('X-DreamFactory-API-Key'); + expect(connectSnippet(c, o)).toContain(o.url); + } + }); + it('a previewed key lands in the header of every client', () => { + for (const c of [ + 'claude', + 'cursor', + 'vscode', + 'chatgpt', + 'curl', + ] as const) { + expect(connectSnippet(c, { ...o, apiKey: 'abc' })).toContain( + 'X-DreamFactory-API-Key' + ); + } + expect( + JSON.parse(connectSnippet('cursor', { ...o, apiKey: 'abc' })) + ).toEqual({ + mcpServers: { + demo_mcp: { url: o.url, headers: { 'X-DreamFactory-API-Key': 'abc' } }, + }, + }); + }); +}); + +describe('mcp-model: usage attribution', () => { + it('attributes prefixed tools to backends and bare verbs to the merged bucket', () => { + const r = callsByBackend( + [ + { tool_name: 'demo_mysql_get_table_data', requests: 8 }, + { tool_name: 'demo_mysql_get_tables', requests: 2 }, + { tool_name: 'get_table_data', requests: 2 }, + { tool_name: 'discover_services', requests: 9 }, + ], + backends + ); + expect(r.perBackend).toEqual({ demo_mysql: 10 }); + expect(r.merged).toBe(2); + }); +}); diff --git a/src/app/adf-ai/mcp/mcp-model.ts b/src/app/adf-ai/mcp/mcp-model.ts new file mode 100644 index 00000000..13a610cd --- /dev/null +++ b/src/app/adf-ai/mcp/mcp-model.ts @@ -0,0 +1,581 @@ +/** + * Pure model for the MCP server page: what a server exposes, what one + * identity (admin / role / API key) actually gets, and the text the page + * derives from that (access diffs, connect snippets). + * + * No Angular, no HTTP. Everything the page shows that is not raw data from + * an endpoint is computed here so it can be unit tested in isolation. + * + * Verb lists mirror df-mcp-server's tool registry (see buildToolList in the + * previous synthesised panel). Disabled-tool keys are `_` + * in BOTH tool styles; the daemon drops that service from the merged tool's + * `service` enum when merged. + */ + +export type VerbGroup = 'read' | 'spec' | 'procedures' | 'write'; +export type HttpVerb = 'GET' | 'POST' | 'PATCH' | 'DELETE'; + +export interface McpVerb { + name: string; + group: VerbGroup; + /** Writes data (or runs server-side code). Amber + W badge in the grid. */ + write: boolean; + /** REST verb the role must hold for this tool to succeed at call time. */ + http: HttpVerb; +} + +const v = ( + name: string, + group: VerbGroup, + http: HttpVerb = 'GET', + write = false +): McpVerb => ({ name, group, http, write }); + +export const DB_VERBS: ReadonlyArray = [ + v('get_table_data', 'read'), + v('get_tables', 'read'), + v('get_table_schema', 'read'), + v('get_table_fields', 'read'), + v('get_table_relationships', 'read'), + v('aggregate_data', 'read'), + v('get_api_spec', 'spec'), + v('get_data_model', 'spec'), + v('get_database_resources', 'spec'), + v('get_stored_procedures', 'procedures'), + v('call_stored_procedure', 'procedures', 'POST', true), + v('get_stored_functions', 'procedures'), + v('call_stored_function', 'procedures', 'POST', true), + v('create_records', 'write', 'POST', true), + v('update_records', 'write', 'PATCH', true), + v('delete_records', 'write', 'DELETE', true), +]; + +export const FILE_VERBS: ReadonlyArray = [ + v('list_files', 'read'), + v('get_file', 'read'), + v('get_file_properties', 'read'), + v('create_folder', 'write', 'POST', true), + v('create_file', 'write', 'POST', true), + v('delete_file', 'write', 'DELETE', true), +]; + +export const VERB_GROUPS: ReadonlyArray = [ + 'read', + 'spec', + 'procedures', + 'write', +]; + +/** Always advertised, regardless of exposure. */ +export const GLOBAL_TOOLS = [ + 'discover_services', + 'request_access', + 'list_apis', + 'search', + 'fetch', +]; +/** Advertised once more than one database is visible to the identity. */ +export const AGGREGATE_DB_TOOLS = [ + 'all_get_tables', + 'all_find_table', + 'all_get_stored_procedures', + 'all_get_stored_functions', + 'all_get_resources', +]; +export const AGGREGATE_FILE_TOOLS = ['all_list_files']; +/** What a lazy server advertises instead of the catalog. */ +export const FACADE_TOOLS = [ + 'search_tools', + 'list_tools', + 'describe_tool', + 'call_tool', + 'fetch_more', +]; + +/** lazy_mode=auto flips to the facade above this catalog size. */ +export const LAZY_THRESHOLD_BYTES = 32 * 1024; +/** ~540 B of JSON schema per tool observed on df-dev (58 tools ≈ 31 KB). */ +export const DEFAULT_BYTES_PER_TOOL = 540; + +/** verb_mask bits, as df-core encodes them. */ +export const VERB_BITS: Record = { + GET: 1, + POST: 2, + PUT: 4, + PATCH: 8, + DELETE: 16, +}; +export const READ_MASK = 1; +export const WRITE_MASK = 2 | 4 | 8 | 16; +export const FULL_MASK = 31; + +export type BackendKind = 'database' | 'file'; + +export interface McpBackend { + id: number | null; + name: string; + label: string; + kind: BackendKind; +} + +export interface McpConfig { + exposedServices: string[]; + disabledTools: string[]; + lazyMode: 'auto' | 'on' | 'off'; + toolStyle: 'merged' | 'prefixed'; + allowWrites: boolean; + allowApiKeyAuth: boolean; + requireRoleAccess: boolean; +} + +/** + * Who the page previews as. `verbs` is `service name -> verb_mask` for a + * role; undefined means an admin (nothing filtered). + */ +export interface McpIdentity { + kind: 'admin' | 'role' | 'app'; + roleId?: number; + roleName?: string; + appId?: number; + appName?: string; + apiKey?: string; + verbs?: Record; + /** Backends the role only reaches through specific components (tables). */ + components?: Record; +} + +export const ADMIN_IDENTITY: McpIdentity = { kind: 'admin' }; + +export type CellState = 'on' | 'off' | 'writes-off' | 'denied' | 'gone'; + +export type ToolKind = + | 'global' + | 'aggregate' + | 'tool' + | 'write' + | 'facade' + | 'custom'; + +export interface CatalogTool { + name: string; + kind: ToolKind; + service?: string; +} + +export interface CatalogShape { + /** The full catalog this identity could reach. */ + tools: CatalogTool[]; + /** What the client is actually sent each turn (facade when lazy). */ + shown: CatalogTool[]; + bytes: number; + facadeBytes: number; + lazy: boolean; + dbs: McpBackend[]; + files: McpBackend[]; + writable: number; +} + +export function sanitizeApiName(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]/g, '_') + .replace(/_+/g, '_') + .replace(/^_|_$/g, ''); +} + +export function toolKey(backendName: string, verb: string): string { + return `${sanitizeApiName(backendName)}_${verb}`; +} + +export function verbsFor(kind: BackendKind): ReadonlyArray { + return kind === 'database' ? DB_VERBS : FILE_VERBS; +} + +/** A role allows a tool when it holds the REST verb the tool maps to. */ +export function roleAllows(mask: number | undefined, verb: McpVerb): boolean { + if (mask === undefined) return true; + if (verb.http === 'PATCH') { + return (mask & (VERB_BITS['PATCH'] | VERB_BITS['PUT'])) !== 0; + } + return (mask & VERB_BITS[verb.http]) !== 0; +} + +/** Whether the identity can see this backend at all. */ +export function backendVisible( + backend: McpBackend, + identity: McpIdentity +): boolean { + if (!identity.verbs) return true; + return (identity.verbs[backend.name] ?? 0) > 0; +} + +export function cellState( + cfg: McpConfig, + backend: McpBackend, + verb: McpVerb, + identity: McpIdentity +): CellState { + if (!cfg.exposedServices.includes(backend.name)) return 'gone'; + if (!backendVisible(backend, identity)) return 'gone'; + if (!cfg.allowWrites && verb.write) return 'writes-off'; + if (cfg.disabledTools.includes(toolKey(backend.name, verb.name))) { + return 'off'; + } + if (identity.verbs && !roleAllows(identity.verbs[backend.name], verb)) { + return 'denied'; + } + return 'on'; +} + +/** Tools a backend advertises: exposed, not disabled, not blocked by writes. */ +export function advertisedVerbs( + cfg: McpConfig, + backend: McpBackend +): McpVerb[] { + if (!cfg.exposedServices.includes(backend.name)) return []; + return verbsFor(backend.kind).filter( + verb => + (cfg.allowWrites || !verb.write) && + !cfg.disabledTools.includes(toolKey(backend.name, verb.name)) + ); +} + +/** + * Build the catalog one identity is served. Mirrors the daemon's registry: + * globals, aggregates when 2+ backends of a kind are visible, then either + * one merged verb per tool (database) or `_` per backend. + */ +export function shapeCatalog( + cfg: McpConfig, + backends: McpBackend[], + identity: McpIdentity = ADMIN_IDENTITY, + customTools: string[] = [], + bytesPerTool = DEFAULT_BYTES_PER_TOOL +): CatalogShape { + const visible = backends.filter( + b => cfg.exposedServices.includes(b.name) && backendVisible(b, identity) + ); + const dbs = visible.filter(b => b.kind === 'database'); + const files = visible.filter(b => b.kind === 'file'); + const disabled = new Set(cfg.disabledTools); + const tools: CatalogTool[] = GLOBAL_TOOLS.filter(n => !disabled.has(n)).map( + name => ({ name, kind: 'global' }) + ); + if (dbs.length > 1) { + AGGREGATE_DB_TOOLS.filter(n => !disabled.has(n)).forEach(name => + tools.push({ name, kind: 'aggregate' }) + ); + } + if (files.length > 1) { + AGGREGATE_FILE_TOOLS.filter(n => !disabled.has(n)).forEach(name => + tools.push({ name, kind: 'aggregate' }) + ); + } + const kindOf = (verb: McpVerb): ToolKind => (verb.write ? 'write' : 'tool'); + if (cfg.toolStyle === 'merged') { + for (const verb of DB_VERBS) { + if (dbs.some(b => advertisedVerbs(cfg, b).includes(verb))) { + tools.push({ name: verb.name, kind: kindOf(verb) }); + } + } + } else { + for (const b of dbs) { + for (const verb of advertisedVerbs(cfg, b)) { + tools.push({ + name: toolKey(b.name, verb.name), + kind: kindOf(verb), + service: b.name, + }); + } + } + } + for (const b of files) { + for (const verb of advertisedVerbs(cfg, b)) { + tools.push({ + name: toolKey(b.name, verb.name), + kind: kindOf(verb), + service: b.name, + }); + } + } + customTools + .filter(n => !disabled.has(n)) + .forEach(name => tools.push({ name, kind: 'custom' })); + + const bytes = tools.length * bytesPerTool; + const facadeBytes = FACADE_TOOLS.length * bytesPerTool; + const lazy = + cfg.lazyMode === 'on' || + (cfg.lazyMode === 'auto' && bytes > LAZY_THRESHOLD_BYTES); + const shown = lazy + ? FACADE_TOOLS.map(name => ({ name, kind: 'facade' as ToolKind })) + : tools; + return { + tools, + shown, + bytes, + facadeBytes, + lazy, + dbs, + files, + writable: tools.filter(t => t.kind === 'write').length, + }; +} + +/** Catalog for the fixed-tool System API server: names minus disabled. */ +export function shapeFixedCatalog( + cfg: Pick, + toolNames: ReadonlyArray, + bytesPerTool = DEFAULT_BYTES_PER_TOOL +): CatalogShape { + const disabled = new Set(cfg.disabledTools); + const tools: CatalogTool[] = toolNames + .filter(n => !disabled.has(n)) + .map(name => ({ + name, + kind: /^(get_|list_)/.test(name) ? 'tool' : 'write', + })); + const bytes = tools.length * bytesPerTool; + const lazy = + cfg.lazyMode === 'on' || + (cfg.lazyMode === 'auto' && bytes > LAZY_THRESHOLD_BYTES); + return { + tools, + shown: lazy ? FACADE_TOOLS.map(name => ({ name, kind: 'facade' })) : tools, + bytes, + facadeBytes: FACADE_TOOLS.length * bytesPerTool, + lazy, + dbs: [], + files: [], + writable: tools.filter(t => t.kind === 'write').length, + }; +} + +/** Rough tokens the client carries per turn: bytes / 4. */ +export function tokensPerTurn(shape: CatalogShape): number { + return Math.round((shape.lazy ? shape.facadeBytes : shape.bytes) / 4); +} + +export function formatTokens(n: number): string { + return n >= 1000 ? `~${(n / 1000).toFixed(1)}k` : `~${n}`; +} + +export function formatKb(bytes: number): string { + return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`; +} + +// ---------------------------------------------------------------- access + +export type AccessLevel = 'none' | 'read' | 'rw'; + +export function levelFromMask(mask: number): AccessLevel { + if (mask <= 0) return 'none'; + return (mask & WRITE_MASK) !== 0 ? 'rw' : 'read'; +} + +export function maskForLevel(level: AccessLevel): number { + return level === 'rw' ? FULL_MASK : level === 'read' ? READ_MASK : 0; +} + +export interface RoleAccessRow { + id?: number; + serviceId: number; + component: string; + verbMask: number; + requestorMask?: number; +} + +export interface ServiceGrant { + level: AccessLevel; + /** Rows narrower than `*` exist (table-level limits): edit on the role page. */ + tableLimited: boolean; + mask: number; +} + +/** Collapse a role's access rows into one grant per service id. */ +export function grantsByService( + rows: RoleAccessRow[] +): Record { + const out: Record = {}; + for (const row of rows) { + const g = out[row.serviceId] ?? { + level: 'none', + tableLimited: false, + mask: 0, + }; + g.mask |= row.verbMask; + if (row.component !== '*' && row.verbMask > 0) g.tableLimited = true; + g.level = levelFromMask(g.mask); + out[row.serviceId] = g; + } + return out; +} + +/** `service name -> verb_mask` for the identity filter, from a role's rows. */ +export function verbsByServiceName( + rows: RoleAccessRow[], + serviceNameById: Record +): Record { + const out: Record = {}; + for (const row of rows) { + const name = serviceNameById[row.serviceId]; + if (!name) continue; + out[name] = (out[name] ?? 0) | row.verbMask; + } + return out; +} + +/** `service name -> components` for rows narrower than `*`. */ +export function componentsByServiceName( + rows: RoleAccessRow[], + serviceNameById: Record +): Record { + const out: Record = {}; + for (const row of rows) { + const name = serviceNameById[row.serviceId]; + if (!name || row.component === '*' || row.verbMask <= 0) continue; + (out[name] ??= []).push(row.component); + } + return out; +} + +/** verb_mask from the HTTP verb names mcp-catalog reports per backend. */ +export function maskFromVerbNames(verbs: string[]): number { + return verbs.reduce((m, v) => m | (VERB_BITS[v.toUpperCase()] ?? 0), 0); +} + +export interface AccessChange { + serviceId: number; + label: string; + before: AccessLevel; + after: AccessLevel; +} + +/** Plain-language lines for the diff shown before a role save. */ +export function accessDiff( + changes: AccessChange[], + labels: Record +): string[] { + return changes + .filter(c => c.before !== c.after) + .map(c => `${c.label}: ${labels[c.before]} → ${labels[c.after]}`); +} + +/** + * The role_service_access rows to PATCH for a set of level changes. A row + * that exists is updated by id; `none` on an existing `*` row unlinks it + * (`role_id: null`); a new level creates a `*` row. Table-limited rows are + * never touched here. + */ +export function accessRowsForChanges( + changes: AccessChange[], + existing: RoleAccessRow[] +): Array> { + const out: Array> = []; + for (const c of changes) { + if (c.before === c.after) continue; + const star = existing.find( + r => r.serviceId === c.serviceId && r.component === '*' + ); + if (c.after === 'none') { + if (star?.id != null) out.push({ id: star.id, role_id: null }); + continue; + } + out.push({ + ...(star?.id != null ? { id: star.id } : {}), + service_id: c.serviceId, + component: '*', + verb_mask: maskForLevel(c.after), + requestor_mask: 3, + filters: [], + filter_op: 'AND', + }); + } + return out; +} + +// ------------------------------------------------------------- snippets + +export type ConnectClient = 'claude' | 'cursor' | 'vscode' | 'chatgpt' | 'curl'; +export const CONNECT_CLIENTS: ReadonlyArray = [ + 'claude', + 'cursor', + 'vscode', + 'chatgpt', + 'curl', +]; + +export interface SnippetOptions { + name: string; + url: string; + /** Set when previewing an API key; the header goes in the snippet. */ + apiKey?: string | null; +} + +const KEY_HEADER = 'X-DreamFactory-API-Key'; + +export function connectSnippet( + client: ConnectClient, + o: SnippetOptions +): string { + const key = o.apiKey || null; + const headers = key + ? `,\n "headers": { "${KEY_HEADER}": "${key}" }` + : ''; + switch (client) { + case 'claude': + return ( + `{\n "mcpServers": {\n "${o.name}": {\n "type": "http",\n` + + ` "url": "${o.url}"${headers}\n }\n }\n}` + + (key ? '' : '\n// Claude opens the DreamFactory login on first use.') + ); + case 'cursor': + return `{\n "mcpServers": {\n "${o.name}": {\n "url": "${o.url}"${headers}\n }\n }\n}`; + case 'vscode': + return ( + `// .vscode/mcp.json\n{\n "servers": {\n "${o.name}": {\n "type": "http",\n` + + ` "url": "${o.url}"${headers}\n }\n }\n}` + ); + case 'chatgpt': + return key + ? `Settings → Connectors → Create\n Name: ${o.name}\n MCP server URL: ${o.url}\n Authentication: Custom header\n ${KEY_HEADER}: ${key}` + : `Settings → Connectors → Create\n Name: ${o.name}\n MCP server URL: ${o.url}\n Authentication: OAuth\n// ChatGPT opens the DreamFactory login when you add the connector.`; + case 'curl': + return key + ? `curl -X POST ${o.url} \\\n -H '${KEY_HEADER}: ${key}' \\\n -H 'Content-Type: application/json' \\\n -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'` + : `# OAuth only: discover the authorization server, then use a bearer token\ncurl ${o.url}/.well-known/oauth-authorization-server\n\ncurl -X POST ${o.url} \\\n -H 'Authorization: Bearer ' \\\n -H 'Content-Type: application/json' \\\n -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'`; + } +} + +// ---------------------------------------------------------------- usage + +export interface ToolUsage { + tool_name: string; + requests: number; +} + +/** + * Calls per backend over the usage window. Prefixed tools attribute to their + * backend; merged verbs (no prefix) cannot, so they land on the `merged` + * bucket the caller shows as "all databases". + */ +export function callsByBackend( + byTool: ToolUsage[], + backends: McpBackend[] +): { perBackend: Record; merged: number } { + const perBackend: Record = {}; + let merged = 0; + const prefixes = backends + .map(b => ({ name: b.name, prefix: sanitizeApiName(b.name) + '_' })) + .sort((a, b) => b.prefix.length - a.prefix.length); + const bareVerbs = new Set(DB_VERBS.map(x => x.name)); + for (const row of byTool) { + const hit = prefixes.find(p => row.tool_name.startsWith(p.prefix)); + if (hit) { + perBackend[hit.name] = (perBackend[hit.name] ?? 0) + row.requests; + } else if (bareVerbs.has(row.tool_name)) { + merged += row.requests; + } + } + return { perBackend, merged }; +} From 18dab9377d64260545de98086239e10057820d63 Mon Sep 17 00:00:00 2001 From: nicdavidson Date: Fri, 18 Sep 2026 12:30:04 -0600 Subject: [PATCH 2/6] feat(mcp): exposure page components df-mcp-exposure composes the MCP server page: endpoint + health chips, Preview-as (admin, each role from mcp-access, each API key), summary tiles, modes strip with live consequences, what-the-client-carries bars, the backends x verbs exposure grid (row = exposed_services, cell = disabled_tools, states on/off/writes-off/denied/gone), the previewed identity's catalog chips, connect snippets for Claude/Cursor/VS Code/ ChatGPT/curl and last-7-days calls per backend. df-mcp-access lists who can connect from mcp-access joined with real role rows and keys; add existing role / edit access / create role go through df-mcp-access-dialog, which shows a plain diff before writing role_service_access rows and states the change applies everywhere. Role previews use mcp-catalog (backends[].verbs, component_scoped shown as 'limited to N tables'); the admin view calibrates sizes from tools/list and says so. --- .../df-mcp-access-dialog.component.html | 169 ++++++ .../df-mcp-access-dialog.component.scss | 101 +++ .../df-mcp-access-dialog.component.ts | 224 +++++++ .../df-mcp-access.component.html | 158 +++++ .../df-mcp-access.component.scss | 156 +++++ .../df-mcp-access/df-mcp-access.component.ts | 232 +++++++ .../df-mcp-exposure-grid.component.html | 153 +++++ .../df-mcp-exposure-grid.component.scss | 250 ++++++++ .../df-mcp-exposure-grid.component.ts | 108 ++++ .../df-mcp-exposure.component.html | 421 +++++++++++++ .../df-mcp-exposure.component.scss | 393 ++++++++++++ .../df-mcp-exposure.component.ts | 573 ++++++++++++++++++ src/assets/i18n/en.json | 263 ++++++++ 13 files changed, 3201 insertions(+) create mode 100644 src/app/adf-ai/mcp/df-mcp-access-dialog/df-mcp-access-dialog.component.html create mode 100644 src/app/adf-ai/mcp/df-mcp-access-dialog/df-mcp-access-dialog.component.scss create mode 100644 src/app/adf-ai/mcp/df-mcp-access-dialog/df-mcp-access-dialog.component.ts create mode 100644 src/app/adf-ai/mcp/df-mcp-access/df-mcp-access.component.html create mode 100644 src/app/adf-ai/mcp/df-mcp-access/df-mcp-access.component.scss create mode 100644 src/app/adf-ai/mcp/df-mcp-access/df-mcp-access.component.ts create mode 100644 src/app/adf-ai/mcp/df-mcp-exposure-grid/df-mcp-exposure-grid.component.html create mode 100644 src/app/adf-ai/mcp/df-mcp-exposure-grid/df-mcp-exposure-grid.component.scss create mode 100644 src/app/adf-ai/mcp/df-mcp-exposure-grid/df-mcp-exposure-grid.component.ts create mode 100644 src/app/adf-ai/mcp/df-mcp-exposure/df-mcp-exposure.component.html create mode 100644 src/app/adf-ai/mcp/df-mcp-exposure/df-mcp-exposure.component.scss create mode 100644 src/app/adf-ai/mcp/df-mcp-exposure/df-mcp-exposure.component.ts diff --git a/src/app/adf-ai/mcp/df-mcp-access-dialog/df-mcp-access-dialog.component.html b/src/app/adf-ai/mcp/df-mcp-access-dialog/df-mcp-access-dialog.component.html new file mode 100644 index 00000000..a73e2b6f --- /dev/null +++ b/src/app/adf-ai/mcp/df-mcp-access-dialog/df-mcp-access-dialog.component.html @@ -0,0 +1,169 @@ + +

+ {{ t('dialog.' + data.mode + 'Title', { server: data.serviceLabel }) }} +

+ + + + + + {{ t('dialog.role') }} + + {{ + r.name + }} + + + + + {{ t('dialog.roleName') }} + + + +

+ {{ t('dialog.addHint') }} +

+

+ {{ t('dialog.editHint') }} +

+ +
+
+ +
+ {{ b.label }} + {{ b.name }} +
+ + + {{ t('tableLimited') }} + {{ t('rolePage') }} + + + + + {{ + t('level.none') + }} + {{ + t('level.read') + }} + {{ t('level.rw') }} + + +
+
+

+ {{ t('dialog.noBackends') }} +

+ + + {{ t('dialog.alsoKey') }} + +
+ + + +

{{ t('dialog.reviewHint') }}

+
    +
  • {{ line }}
  • +
  • + {{ t('dialog.noChanges') }} +
  • +
+

+ {{ t('dialog.everywhere') }} +

+

{{ error }}

+
+ + + +

{{ t('dialog.created', { name: name }) }}

+
+ {{ t('dialog.keyOnce') }} + {{ apiKey }} + +
+

{{ t('dialog.keyFailed') }}

+
+
+ + + + + + + + + + + + + + +
diff --git a/src/app/adf-ai/mcp/df-mcp-access-dialog/df-mcp-access-dialog.component.scss b/src/app/adf-ai/mcp/df-mcp-access-dialog/df-mcp-access-dialog.component.scss new file mode 100644 index 00000000..b672a8e4 --- /dev/null +++ b/src/app/adf-ai/mcp/df-mcp-access-dialog/df-mcp-access-dialog.component.scss @@ -0,0 +1,101 @@ +.dlg { + display: flex; + flex-direction: column; + gap: var(--df-space-3); + font-size: var(--df-font-size-sm); + min-width: 0; + + &__full { + width: 100%; + } + + &__hint { + margin: 0; + font-size: var(--df-font-size-xs); + color: var(--df-text-muted); + } + + &__apis { + display: flex; + flex-direction: column; + border-top: 1px solid var(--df-border-2); + } + + &__api { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: var(--df-space-2); + padding: var(--df-space-2) 0; + border-bottom: 1px solid var(--df-border-2); + + mat-checkbox:empty { + display: none; + } + } + + &__api-name { + display: flex; + flex-direction: column; + min-width: 0; + + code { + font-size: var(--df-font-size-xs); + color: var(--df-text-muted); + } + } + + &__limited { + font-size: var(--df-font-size-xs); + color: var(--df-warning); + + a { + color: var(--df-accent); + margin-left: var(--df-space-1); + } + } + + &__key { + margin-top: var(--df-space-1); + } + + &__diff { + margin: 0; + padding-left: 1.4rem; + line-height: 1.7; + } + + &__everywhere { + margin: 0; + padding: var(--df-space-2) var(--df-space-3); + border: 1px solid var(--df-warning-border); + background: var(--df-warning-soft); + border-radius: var(--df-radius-sm); + font-size: var(--df-font-size-xs); + } + + &__error { + margin: 0; + color: var(--df-danger); + font-size: var(--df-font-size-xs); + } + + &__keybox { + display: flex; + flex-direction: column; + gap: var(--df-space-2); + padding: var(--df-space-3); + border: 1px solid var(--df-success-border); + background: var(--df-success-soft); + border-radius: var(--df-radius-sm); + + code { + word-break: break-all; + font-size: var(--df-font-size-xs); + } + + button { + align-self: flex-start; + } + } +} diff --git a/src/app/adf-ai/mcp/df-mcp-access-dialog/df-mcp-access-dialog.component.ts b/src/app/adf-ai/mcp/df-mcp-access-dialog/df-mcp-access-dialog.component.ts new file mode 100644 index 00000000..6ef423bd --- /dev/null +++ b/src/app/adf-ai/mcp/df-mcp-access-dialog/df-mcp-access-dialog.component.ts @@ -0,0 +1,224 @@ +import { CommonModule } from '@angular/common'; +import { Component, Inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatButtonToggleModule } from '@angular/material/button-toggle'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { + MAT_DIALOG_DATA, + MatDialogModule, + MatDialogRef, +} from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { RouterLink } from '@angular/router'; +import { TranslocoModule, TranslocoService } from '@ngneat/transloco'; +import { switchMap, of } from 'rxjs'; +import { normalizeError } from 'src/app/shared/utilities/app-error'; +import { DfMcpApiService, McpRole } from '../df-mcp-api.service'; +import { + AccessChange, + AccessLevel, + McpBackend, + ServiceGrant, + accessDiff, + accessRowsForChanges, + grantsByService, +} from '../mcp-model'; + +export interface McpAccessDialogData { + mode: 'add' | 'edit' | 'create'; + serviceId: number; + serviceLabel: string; + /** Exposed backends (with ids) the role may be granted. */ + backends: McpBackend[]; + roles: McpRole[]; + role?: McpRole; + granted: Set; + allowWrites: boolean; +} + +/** + * df-mcp-access-dialog — add an existing role, edit a role's reach across + * the exposed APIs, or create a role (optionally with an API key shown + * once). Every save shows a plain diff first, because these are real role + * rows: they change what the role can do everywhere, not only through MCP. + */ +@Component({ + selector: 'df-mcp-access-dialog', + standalone: true, + templateUrl: './df-mcp-access-dialog.component.html', + styleUrls: ['./df-mcp-access-dialog.component.scss'], + imports: [ + CommonModule, + FormsModule, + RouterLink, + TranslocoModule, + MatDialogModule, + MatButtonModule, + MatButtonToggleModule, + MatCheckboxModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + MatTooltipModule, + ], +}) +export class DfMcpAccessDialogComponent { + step: 'edit' | 'review' | 'done' = 'edit'; + roleId: number | null = null; + name = ''; + levels: Record = {}; + include: Record = {}; + createKey = true; + diff: string[] = []; + changes: AccessChange[] = []; + apiKey: string | null = null; + saving = false; + error = ''; + + constructor( + public ref: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: McpAccessDialogData, + private api: DfMcpApiService, + private transloco: TranslocoService + ) { + if (data.mode === 'edit' && data.role) { + this.roleId = data.role.id; + this.seedLevels(data.role); + } else if (data.mode === 'create') { + this.name = `${data.serviceLabel}` + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_|_$/g, ''); + this.name = this.name ? `${this.name}_access` : 'mcp_access'; + for (const b of data.backends) this.levels[b.id as number] = 'read'; + } else { + for (const b of data.backends) { + this.levels[b.id as number] = 'read'; + this.include[b.id as number] = false; + } + } + } + + /** Roles an admin can add: active ones without a server grant yet. */ + get addableRoles(): McpRole[] { + return this.data.roles.filter( + r => r.isActive !== false && !this.data.granted.has(r.id) + ); + } + + get role(): McpRole | undefined { + return this.data.role ?? this.data.roles.find(r => r.id === this.roleId); + } + + private currentGrants(): Record { + return this.role ? grantsByService(this.role.rows) : {}; + } + + grant(b: McpBackend): ServiceGrant | undefined { + return this.currentGrants()[b.id as number]; + } + + private seedLevels(role: McpRole): void { + const grants = grantsByService(role.rows); + for (const b of this.data.backends) { + this.levels[b.id as number] = grants[b.id as number]?.level ?? 'none'; + } + } + + onRolePicked(id: number): void { + this.roleId = id; + const role = this.role; + if (role) this.seedLevels(role); + } + + levelLabels(): Record { + return { + none: this.transloco.translate('mcpServer.access.level.none'), + read: this.transloco.translate('mcpServer.access.level.read'), + rw: this.transloco.translate('mcpServer.access.level.rw'), + }; + } + + review(): void { + const grants = this.currentGrants(); + const changes: AccessChange[] = []; + const serverBefore = grants[this.data.serviceId]?.level ?? 'none'; + changes.push({ + serviceId: this.data.serviceId, + label: this.data.serviceLabel, + before: serverBefore, + after: serverBefore === 'none' ? 'read' : serverBefore, + }); + for (const b of this.data.backends) { + const id = b.id as number; + if (this.data.mode === 'add' && !this.include[id]) continue; + if (grants[id]?.tableLimited) continue; + changes.push({ + serviceId: id, + label: b.label, + before: grants[id]?.level ?? 'none', + after: this.levels[id] ?? 'none', + }); + } + this.changes = changes; + this.diff = accessDiff(changes, this.levelLabels()); + this.step = 'review'; + } + + get touchesApis(): boolean { + return this.changes.some( + c => c.serviceId !== this.data.serviceId && c.before !== c.after + ); + } + + save(): void { + this.saving = true; + this.error = ''; + const rows = accessRowsForChanges(this.changes, this.role?.rows ?? []); + const op$ = + this.data.mode === 'create' + ? this.api + .createRole( + this.name, + this.transloco.translate('mcpServer.access.createdDesc', { + server: this.data.serviceLabel, + }), + rows + ) + .pipe( + switchMap(id => + this.createKey + ? this.api.createAppKey(`${this.name}_app`, id) + : of(null) + ) + ) + : this.api.patchRoleAccess(this.roleId as number, rows); + op$.subscribe({ + next: key => { + this.saving = false; + if (this.data.mode === 'create' && this.createKey) { + this.apiKey = (key as string | null) ?? null; + this.step = 'done'; + } else { + this.ref.close(true); + } + }, + error: err => { + this.saving = false; + this.error = normalizeError(err).message; + }, + }); + } + + copyKey(): void { + if (this.apiKey) navigator.clipboard?.writeText(this.apiKey); + } + + trackByBackend(_: number, b: McpBackend): string { + return b.name; + } +} diff --git a/src/app/adf-ai/mcp/df-mcp-access/df-mcp-access.component.html b/src/app/adf-ai/mcp/df-mcp-access/df-mcp-access.component.html new file mode 100644 index 00000000..7f1237da --- /dev/null +++ b/src/app/adf-ai/mcp/df-mcp-access/df-mcp-access.component.html @@ -0,0 +1,158 @@ +
+
+

{{ t('title') }}

+ {{ t('hint') }} +
+ + +
+
+ +
+ +
+ + +

+ + {{ t('anyUserHint') }} +

+

{{ t('statsUnavailable') }}

+ +
+
+
+ {{ row.name }} + {{ + row.level === 'rw' + ? t('level.rw') + : row.level === 'read' + ? t('level.read') + : t('level.serverOnly') + }} +
+
+ + {{ t('reachNone') }} + + {{ r.label + }}* + +
+
+ {{ row.keyName }} + {{ + t('loginOnly') + }} +
+
+ {{ t('requests', { n: row.requests }) }} + {{ + t('denied', { n: row.denied }) + }} +
+
+ + + {{ t('rolePage') }} +
+
+
+ +

+ {{ requireRoleAccess ? t('nobody') : t('nobodyOpen') }} +

+ + +
+

{{ t('seenTitle') }}

+
+
+ {{ row.name }} + {{ + row.lastSeen ? t('lastSeen', { at: row.lastSeen }) : '' + }} +
+
+ {{ r.label }} +
+
+ {{ row.keyName }} +
+
+ {{ t('requests', { n: row.requests }) }} + {{ + t('denied', { n: row.denied }) + }} +
+
+ + +
+
+
+
+
diff --git a/src/app/adf-ai/mcp/df-mcp-access/df-mcp-access.component.scss b/src/app/adf-ai/mcp/df-mcp-access/df-mcp-access.component.scss new file mode 100644 index 00000000..7d55ca64 --- /dev/null +++ b/src/app/adf-ai/mcp/df-mcp-access/df-mcp-access.component.scss @@ -0,0 +1,156 @@ +:host { + display: block; +} + +.access { + background: var(--df-surface); + border: 1px solid var(--df-border-2); + border-radius: var(--df-radius); + padding: var(--df-space-4); + font-size: var(--df-font-size-sm); + color: var(--df-text); + + &__hd { + display: flex; + align-items: center; + gap: var(--df-space-3); + flex-wrap: wrap; + margin-bottom: var(--df-space-3); + + h3 { + margin: 0; + font-size: var(--df-font-size-lg); + font-weight: var(--df-font-weight-heading); + } + } + + &__actions { + margin-left: auto; + display: flex; + gap: var(--df-space-2); + } + + &__hint { + font-size: var(--df-font-size-xs); + color: var(--df-text-muted); + margin: 0; + } + + &__loading { + padding: var(--df-space-2) 0; + } + + &__warn { + display: flex; + align-items: center; + gap: var(--df-space-2); + flex-wrap: wrap; + margin: 0 0 var(--df-space-3); + padding: var(--df-space-2) var(--df-space-3); + border: 1px solid var(--df-warning-border); + background: var(--df-warning-soft); + border-radius: var(--df-radius-sm); + font-size: var(--df-font-size-xs); + } + + &__rows { + display: flex; + flex-direction: column; + } + + &__row { + display: grid; + grid-template-columns: + minmax(10rem, 1fr) minmax(12rem, 2fr) minmax(8rem, 1fr) + minmax(8rem, auto) auto; + align-items: center; + gap: var(--df-space-3); + padding: var(--df-space-2) 0; + border-top: 1px solid var(--df-border-2); + + @media (max-width: 900px) { + grid-template-columns: 1fr 1fr; + } + } + + &__role { + display: flex; + flex-direction: column; + small { + font-size: var(--df-font-size-xs); + color: var(--df-text-muted); + } + } + + &__name { + font-weight: var(--df-font-weight-medium); + } + + &__reach { + display: flex; + flex-wrap: wrap; + gap: var(--df-space-1); + align-items: center; + + code { + font-size: var(--df-font-size-xs); + padding: 1px 6px; + border-radius: 4px; + background: var(--df-surface-2); + border: 1px solid var(--df-border-2); + } + + &--unexposed { + text-decoration: line-through; + color: var(--df-text-muted); + } + } + + &__lim { + color: var(--df-warning); + font-weight: var(--df-font-weight-heading); + } + + &__key code { + font-size: var(--df-font-size-xs); + } + + &__stats { + display: flex; + flex-direction: column; + font-size: var(--df-font-size-xs); + color: var(--df-text-muted); + font-variant-numeric: tabular-nums; + } + + &__denied { + color: var(--df-danger); + } + + &__btns { + display: flex; + gap: var(--df-space-1); + flex-wrap: wrap; + justify-content: flex-end; + } + + &__small { + font-size: var(--df-font-size-xs) !important; + min-height: 30px !important; + line-height: 30px !important; + padding: 0 var(--df-space-2) !important; + } + + &__seen { + margin-top: var(--df-space-4); + + h4 { + margin: 0 0 var(--df-space-1); + font-size: var(--df-font-size-xs); + font-weight: var(--df-font-weight-heading); + color: var(--df-text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; + } + } +} diff --git a/src/app/adf-ai/mcp/df-mcp-access/df-mcp-access.component.ts b/src/app/adf-ai/mcp/df-mcp-access/df-mcp-access.component.ts new file mode 100644 index 00000000..aae551a7 --- /dev/null +++ b/src/app/adf-ai/mcp/df-mcp-access/df-mcp-access.component.ts @@ -0,0 +1,232 @@ +import { CommonModule } from '@angular/common'; +import { + Component, + EventEmitter, + Input, + OnChanges, + Output, +} from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialog, MatDialogModule } from '@angular/material/dialog'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { RouterLink } from '@angular/router'; +import { TranslocoModule, TranslocoService } from '@ngneat/transloco'; +import { DfBadgeComponent } from 'src/app/shared/components/df-badge/df-badge.component'; +import { DfSkeletonComponent } from 'src/app/shared/components/df-skeleton/df-skeleton.component'; +import { DfSnackbarService } from 'src/app/shared/services/df-snackbar.service'; +import { normalizeError } from 'src/app/shared/utilities/app-error'; +import { + DfMcpApiService, + McpAccess, + McpApp, + McpRole, +} from '../df-mcp-api.service'; +import { + DfMcpAccessDialogComponent, + McpAccessDialogData, +} from '../df-mcp-access-dialog/df-mcp-access-dialog.component'; +import { + AccessLevel, + McpBackend, + accessRowsForChanges, + grantsByService, + levelFromMask, +} from '../mcp-model'; + +export interface ReachItem { + serviceId: number; + label: string; + exposed: boolean; + level: AccessLevel; + tableLimited: boolean; +} + +export interface AccessRow { + roleId: number; + name: string; + granted: boolean; + level: AccessLevel; + reach: ReachItem[]; + keyName: string | null; + requests: number; + denied: number; + lastSeen: string | null; +} + +/** + * df-mcp-access — "Who can connect": roles seen by mcp-access joined with + * their real role_service_access rows and any API key bound to them. + * Grants written here are real role rows, so every save states that it + * changes what the role can do everywhere, not only through MCP. + */ +@Component({ + selector: 'df-mcp-access', + standalone: true, + templateUrl: './df-mcp-access.component.html', + styleUrls: ['./df-mcp-access.component.scss'], + imports: [ + CommonModule, + RouterLink, + TranslocoModule, + MatButtonModule, + MatDialogModule, + MatTooltipModule, + DfBadgeComponent, + DfSkeletonComponent, + ], +}) +export class DfMcpAccessComponent implements OnChanges { + @Input({ required: true }) serviceId!: number; + @Input() serviceLabel = ''; + @Input() access: McpAccess | null = null; + @Input() roles: McpRole[] = []; + @Input() apps: McpApp[] = []; + @Input() backends: McpBackend[] = []; + @Input() exposed: string[] = []; + @Input() requireRoleAccess = false; + @Input() allowWrites = true; + @Input() loading = false; + + @Output() previewAs = new EventEmitter(); + @Output() changed = new EventEmitter(); + + rows: AccessRow[] = []; + granting: number | null = null; + + constructor( + private api: DfMcpApiService, + private dialog: MatDialog, + private snackbar: DfSnackbarService, + private transloco: TranslocoService + ) {} + + ngOnChanges(): void { + this.rows = this.buildRows(); + } + + get grantedRows(): AccessRow[] { + return this.rows.filter(r => r.granted); + } + + get seenRows(): AccessRow[] { + return this.rows.filter(r => !r.granted); + } + + get exposedBackends(): McpBackend[] { + return this.backends.filter( + b => b.id != null && this.exposed.includes(b.name) + ); + } + + private buildRows(): AccessRow[] { + const byId = new Map(this.roles.map(r => [r.id, r])); + const listed = new Map(); + const push = ( + id: number, + name: string, + stats?: { + granted: boolean; + requests: number; + denied: number; + last_seen: string | null; + } + ) => { + const role = byId.get(id); + const grants = role ? grantsByService(role.rows) : {}; + const server = grants[this.serviceId]; + const granted = + stats?.granted ?? (server ? server.level !== 'none' : false); + const reach: ReachItem[] = this.backends + .filter( + b => b.id != null && grants[b.id] && grants[b.id].level !== 'none' + ) + .map(b => ({ + serviceId: b.id as number, + label: b.label, + exposed: this.exposed.includes(b.name), + level: grants[b.id as number].level, + tableLimited: grants[b.id as number].tableLimited, + })); + const mask = reach + .filter(r => r.exposed) + .reduce((m, r) => m | (r.level === 'rw' ? 31 : 1), 0); + listed.set(id, { + roleId: id, + name, + granted, + level: levelFromMask(mask), + reach, + keyName: this.apps.find(a => a.roleId === id)?.name ?? null, + requests: stats?.requests ?? 0, + denied: stats?.denied ?? 0, + lastSeen: stats?.last_seen ?? null, + }); + }; + for (const r of this.access?.roles ?? []) { + push(r.role_id, r.name, r); + } + for (const r of this.roles) { + if (listed.has(r.id)) continue; + if (r.rows.some(x => x.serviceId === this.serviceId && x.verbMask > 0)) { + push(r.id, r.name); + } + } + return Array.from(listed.values()).sort((a, b) => + a.granted === b.granted ? b.requests - a.requests : a.granted ? -1 : 1 + ); + } + + /** Server-only grant for a role seen without one. */ + grant(row: AccessRow): void { + const role = this.roles.find(r => r.id === row.roleId); + if (!role) return; + this.granting = row.roleId; + const rows = accessRowsForChanges( + [{ serviceId: this.serviceId, label: '', before: 'none', after: 'read' }], + role.rows + ); + this.api.patchRoleAccess(role.id, rows).subscribe({ + next: () => { + this.granting = null; + this.snackbar.openSnackBar( + this.transloco.translate('mcpServer.access.granted', { + name: row.name, + }), + 'success' + ); + this.changed.emit(); + }, + error: err => { + this.granting = null; + this.snackbar.openSnackBar(normalizeError(err).message, 'error'); + }, + }); + } + + open(mode: McpAccessDialogData['mode'], row?: AccessRow): void { + const data: McpAccessDialogData = { + mode, + serviceId: this.serviceId, + serviceLabel: this.serviceLabel, + backends: this.exposedBackends, + roles: this.roles, + role: row ? this.roles.find(r => r.id === row.roleId) : undefined, + granted: new Set(this.grantedRows.map(r => r.roleId)), + allowWrites: this.allowWrites, + }; + this.dialog + .open(DfMcpAccessDialogComponent, { data, width: '560px' }) + .afterClosed() + .subscribe(saved => { + if (saved) this.changed.emit(); + }); + } + + hasExposedReach(row: AccessRow): boolean { + return row.reach.some(r => r.exposed); + } + + trackByRole(_: number, r: AccessRow): number { + return r.roleId; + } +} diff --git a/src/app/adf-ai/mcp/df-mcp-exposure-grid/df-mcp-exposure-grid.component.html b/src/app/adf-ai/mcp/df-mcp-exposure-grid/df-mcp-exposure-grid.component.html new file mode 100644 index 00000000..fcafa757 --- /dev/null +++ b/src/app/adf-ai/mcp/df-mcp-exposure-grid/df-mcp-exposure-grid.component.html @@ -0,0 +1,153 @@ + +
+ +
+ +

+ {{ t('empty') }} +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ {{ t('group.' + g) }} +
{{ t('backend') }} + {{ verb.name }} + {{ t('tools') }}
+
+ + + {{ b.label }} + {{ b.name }} + {{ t('kind.database') }} + {{ t('limitedTo', { n: comps.length }) }} +
+
+ + + — + {{ t('hiddenShort') }} + {{ toolCount(b) }} +
+
+ + + {{ b.label }} + {{ b.name }} + {{ t('kind.file') }} + {{ t('limitedTo', { n: comps.length }) }} +
+
+ + + {{ t('fileVerbs') }} + + — + {{ t('hiddenShort') }} + {{ toolCount(b) }} +
+
+ +
+ {{ t('legend.' + s) }} + {{ t('legend.write') }} +
+
diff --git a/src/app/adf-ai/mcp/df-mcp-exposure-grid/df-mcp-exposure-grid.component.scss b/src/app/adf-ai/mcp/df-mcp-exposure-grid/df-mcp-exposure-grid.component.scss new file mode 100644 index 00000000..4da96918 --- /dev/null +++ b/src/app/adf-ai/mcp/df-mcp-exposure-grid/df-mcp-exposure-grid.component.scss @@ -0,0 +1,250 @@ +// df-mcp-exposure-grid — backends x verbs. Tokens only. Cell fill: +// on = success, write = warning, off = muted outline, writes-off = hatched, +// denied = danger outline, gone = transparent. +:host { + display: block; +} + +.grid__wrap { + overflow-x: auto; + padding-bottom: var(--df-space-1); +} + +.grid__loading, +.grid__empty { + padding: var(--df-space-2) 0; + font-size: var(--df-font-size-sm); + color: var(--df-text-muted); +} + +.grid { + border-collapse: separate; + border-spacing: 0; + min-width: 56rem; + width: 100%; + font-size: var(--df-font-size-sm); + + th, + td { + padding: 2px 3px; + text-align: center; + border-bottom: 1px solid var(--df-border-2); + white-space: nowrap; + } + + &__svc { + text-align: left !important; + position: sticky; + left: 0; + background: var(--df-surface); + z-index: 1; + min-width: 15rem; + } + + &__group { + font-size: var(--df-font-size-xs); + font-weight: var(--df-font-weight-heading); + color: var(--df-text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; + border-left: 1px solid var(--df-border-2); + + &--write { + color: var(--df-warning); + } + } + + &__verb { + height: 7.5rem; + vertical-align: bottom; + padding-bottom: var(--df-space-1); + + span { + display: inline-block; + writing-mode: vertical-rl; + transform: rotate(180deg); + font-family: monospace; + font-size: 11px; + font-weight: var(--df-font-weight-body); + color: var(--df-text-2); + } + } + + &__count { + font-size: var(--df-font-size-xs); + color: var(--df-text-muted); + min-width: 3rem; + font-variant-numeric: tabular-nums; + } + + &__name { + display: flex; + align-items: center; + gap: var(--df-space-2); + min-height: var(--df-row-height); + } + + &__lbl { + font-weight: var(--df-font-weight-medium); + color: var(--df-text); + } + + &__n { + font-size: var(--df-font-size-xs); + color: var(--df-text-muted); + } + + &__ty { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--df-text-faint); + border: 1px solid var(--df-border-2); + border-radius: 999px; + padding: 0 6px; + } + + &__limited { + font-size: 10px; + color: var(--df-warning); + border: 1px solid var(--df-warning-border); + background: var(--df-warning-soft); + border-radius: 999px; + padding: 0 6px; + white-space: nowrap; + } + + &__row--off &__lbl { + color: var(--df-text-muted); + } + + &__row--hidden &__lbl { + text-decoration: line-through; + color: var(--df-text-muted); + } + + &__row--file td { + border-top: 1px solid var(--df-border); + } + + &__pad { + text-align: left !important; + } + + &__fileverbs { + font-size: var(--df-font-size-xs); + color: var(--df-text-faint); + font-style: italic; + } + + // ---- cells + &__cell { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.2rem; + height: 2.2rem; + padding: 0; + border: 1px solid transparent; + border-radius: var(--df-radius-sm); + background: transparent; + cursor: pointer; + + &:hover:not(:disabled) { + background: var(--df-hover); + border-color: var(--df-border); + } + &:focus-visible { + outline: 2px solid var(--df-accent); + outline-offset: 1px; + } + &:disabled { + cursor: default; + } + + i { + display: block; + width: 1.1rem; + height: 1.1rem; + border-radius: 3px; + background: var(--df-success); + box-sizing: border-box; + } + + &--write i { + background: var(--df-warning); + } + + &[data-state='off'] i { + background: transparent; + border: 1.5px solid var(--df-text-faint); + } + &[data-state='writes-off'] i { + background: repeating-linear-gradient( + 45deg, + var(--df-border) 0 2px, + transparent 2px 4px + ); + border: 1px solid var(--df-border); + } + &[data-state='denied'] i { + background: transparent; + border: 1.5px dashed var(--df-danger); + } + &[data-state='gone'] i { + background: transparent; + border: 1px solid var(--df-border-2); + opacity: 0.5; + } + } + + // ---- legend + &__legend { + display: flex; + flex-wrap: wrap; + gap: var(--df-space-3); + margin-top: var(--df-space-3); + font-size: var(--df-font-size-xs); + color: var(--df-text-muted); + + span { + display: inline-flex; + align-items: center; + gap: var(--df-space-1); + } + } + + &__dot { + display: inline-block; + width: 1rem; + height: 1rem; + border-radius: 3px; + background: var(--df-success); + box-sizing: border-box; + + &--write { + background: var(--df-warning); + } + &[data-state='off'] { + background: transparent; + border: 1.5px solid var(--df-text-faint); + } + &[data-state='writes-off'] { + background: repeating-linear-gradient( + 45deg, + var(--df-border) 0 2px, + transparent 2px 4px + ); + border: 1px solid var(--df-border); + } + &[data-state='denied'] { + background: transparent; + border: 1.5px dashed var(--df-danger); + } + &[data-state='gone'] { + background: transparent; + border: 1px solid var(--df-border-2); + opacity: 0.5; + } + } +} diff --git a/src/app/adf-ai/mcp/df-mcp-exposure-grid/df-mcp-exposure-grid.component.ts b/src/app/adf-ai/mcp/df-mcp-exposure-grid/df-mcp-exposure-grid.component.ts new file mode 100644 index 00000000..2fe35e5a --- /dev/null +++ b/src/app/adf-ai/mcp/df-mcp-exposure-grid/df-mcp-exposure-grid.component.ts @@ -0,0 +1,108 @@ +import { CommonModule } from '@angular/common'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { MatSlideToggleModule } from '@angular/material/slide-toggle'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { TranslocoModule } from '@ngneat/transloco'; +import { DfSkeletonComponent } from 'src/app/shared/components/df-skeleton/df-skeleton.component'; +import { + ADMIN_IDENTITY, + CellState, + DB_VERBS, + FILE_VERBS, + McpBackend, + McpConfig, + McpIdentity, + McpVerb, + VERB_GROUPS, + VerbGroup, + advertisedVerbs, + backendVisible, + cellState, + toolKey, +} from '../mcp-model'; + +/** + * df-mcp-exposure-grid — rows are attachable backends, columns the verbs + * each backend becomes. The row switch is `exposed_services`; a cell is one + * `disabled_tools` key. Cell state also reflects the previewed identity + * (denied / gone) and the server-wide writes switch. + */ +@Component({ + selector: 'df-mcp-exposure-grid', + standalone: true, + templateUrl: './df-mcp-exposure-grid.component.html', + styleUrls: ['./df-mcp-exposure-grid.component.scss'], + imports: [ + CommonModule, + TranslocoModule, + MatSlideToggleModule, + MatTooltipModule, + DfSkeletonComponent, + ], +}) +export class DfMcpExposureGridComponent { + @Input() backends: McpBackend[] = []; + @Input({ required: true }) cfg!: McpConfig; + @Input() identity: McpIdentity = ADMIN_IDENTITY; + @Input() loading = false; + + @Output() toggleExposed = new EventEmitter(); + @Output() toggleCell = new EventEmitter(); + + readonly dbVerbs = DB_VERBS; + readonly fileVerbs = FILE_VERBS; + readonly groups = VERB_GROUPS; + readonly states: CellState[] = ['on', 'off', 'writes-off', 'denied', 'gone']; + + get databases(): McpBackend[] { + return this.backends.filter(b => b.kind === 'database'); + } + + get files(): McpBackend[] { + return this.backends.filter(b => b.kind === 'file'); + } + + groupSpan(group: VerbGroup): number { + return DB_VERBS.filter(v => v.group === group).length; + } + + isExposed(b: McpBackend): boolean { + return this.cfg.exposedServices.includes(b.name); + } + + isHidden(b: McpBackend): boolean { + return this.isExposed(b) && !backendVisible(b, this.identity); + } + + state(b: McpBackend, verb: McpVerb): CellState { + return cellState(this.cfg, b, verb, this.identity); + } + + key(b: McpBackend, verb: McpVerb): string { + return toolKey(b.name, verb.name); + } + + interactive(b: McpBackend, verb: McpVerb): boolean { + const s = this.state(b, verb); + return s !== 'gone' && s !== 'writes-off'; + } + + /** Components the previewed role is limited to on this backend, if any. */ + limitedTo(b: McpBackend): string[] | null { + const comps = this.identity.components?.[b.name]; + return this.isExposed(b) && comps?.length ? comps : null; + } + + toolCount(b: McpBackend): number { + return advertisedVerbs(this.cfg, b).length; + } + + onCell(b: McpBackend, verb: McpVerb): void { + if (!this.interactive(b, verb)) return; + this.toggleCell.emit(this.key(b, verb)); + } + + trackByName(_: number, o: { name: string }): string { + return o.name; + } +} diff --git a/src/app/adf-ai/mcp/df-mcp-exposure/df-mcp-exposure.component.html b/src/app/adf-ai/mcp/df-mcp-exposure/df-mcp-exposure.component.html new file mode 100644 index 00000000..b6bc22f6 --- /dev/null +++ b/src/app/adf-ai/mcp/df-mcp-exposure/df-mcp-exposure.component.html @@ -0,0 +1,421 @@ +
+ +
+ {{ + endpointUrl + }} + +
+ + +
+
+ + {{ t('previewAs') }} + + + {{ + t('identity.admin') + }} + {{ + t('identity.role', { name: o.label }) + }} + {{ + t('identity.key', { name: o.label, role: o.identity.roleName }) + }} + + + +

{{ t('identityNote.' + identityNoteKey) }}

+
+
+ {{ t('tiles.tools') }} + {{ shape.shown.length }} + + {{ + t('tiles.facadeOver', { n: shape.tools.length }) + }} + {{ + t('tiles.serverReports', { n: serverToolCount }) + }} + {{ + t('tiles.estimated') + }} + +
+
+ {{ t('tiles.tokens') }} + {{ tokens }} + {{ t('tiles.tokensHint') }} +
+
+ {{ t('tiles.mode') }} + {{ + shape.lazy ? t('mode.lazy') : t('mode.direct') + }} + {{ + t('modeReason.' + modeReasonKey, { kb: fullKb, threshold: thresholdKb }) + }} +
+
+ {{ t('tiles.who') }} + {{ + cfg.allowApiKeyAuth ? t('auth.oauthKey') : t('auth.oauthOnly') + }} + + {{ + t('auth.anyLogin') + }} + {{ + t('auth.rolesAndKeys', { roles: grantedRoleCount, keys: keyCount }) + }} + {{ + t('auth.rolesOnly', { roles: grantedRoleCount }) + }} + +
+
+ + +
+
+ {{ t('modes.lazy') }} + + {{ + t('modes.auto') + }} + {{ t('modes.on') }} + {{ t('modes.off') }} + + {{ + t('modes.lazyWhy.' + cfg.lazyMode, { threshold: thresholdKb }) + }} +
+ +
+ {{ t('modes.style') }} + + {{ + t('modes.merged') + }} + {{ + t('modes.prefixed') + }} + + {{ + cfg.toolStyle === 'merged' + ? t('modes.styleWhy.merged') + : t('modes.styleWhy.prefixed', { n: shape.dbs.length }) + }} +
+ +
+ {{ t('modes.writes') }} + + {{ + t('modes.allowed') + }} + {{ + t('modes.blocked') + }} + + + {{ + t('modes.needsUpgrade') + }} + {{ + cfg.allowWrites + ? t('modes.writesWhy.on', { n: shape.writable }) + : t('modes.writesWhy.off') + }} + +
+ +
+ {{ t('modes.auth') }} + + {{ + t('auth.oauthOnly') + }} + {{ + t('auth.oauthKeyLong') + }} + + {{ + cfg.allowApiKeyAuth ? t('modes.authWhy.key') : t('modes.authWhy.oauth') + }} +
+ +
+ {{ t('modes.requireRole') }} + + {{ + t('modes.on') + }} + {{ + t('modes.off') + }} + + + {{ + t('modes.needsUpgrade') + }} + {{ + cfg.requireRoleAccess + ? t('modes.requireWhy.on') + : t('modes.requireWhy.off') + }} + +
+
+ + +
+
+

{{ t('carry.title') }}

+ {{ t('carry.hint') }} +
+
+
+
+ {{ t('carry.full') }} +
+ +
+ {{ fullKb }} +
+
+ {{ t('carry.facade') }} +
+ +
+ {{ facadeKb }} +
+

+ {{ t('carry.barHint', { threshold: thresholdKb }) }} +

+

+ {{ + shape.lazy + ? t('carry.isLazy', { + lite: facadeKb, + full: fullKb, + n: shape.tools.length, + }) + : t('carry.isDirect', { + full: fullKb, + n: shape.tools.length, + threshold: thresholdKb, + }) + }} +

+
+
+
+ search_tools("shipped orders by customer") + {{ t('carry.step1', { tool: exampleTool }) }} +
+
+ describe_tool("{{ exampleTool }}") + {{ t('carry.step2') }} +
+
+ call_tool("{{ exampleTool }}", {{ '{' }} … {{ '}' }}) + {{ t('carry.step3') }} +
+
+ fetch_more(handle, 6000) + {{ t('carry.step4') }} +
+

{{ t('carry.stepsNote') }}

+
+
+
+ + + + +
+ +
+
+

{{ t('grid.title') }}

+ {{ t('grid.hint') }} +
+ +
+ +
+ +
+
+

{{ t('catalog.title') }}

+
+
+ {{ shape.shown.length }} {{ t('catalog.advertised') }} + {{ shape.writable }} {{ t('catalog.canWrite') }} + {{ shape.dbs.length }} {{ t('catalog.databases') }} + {{ shape.files.length }} {{ t('catalog.files') }} +
+
+ + {{ tool.name }}W + +
+

+ {{ t('catalog.lazyNote', { n: shape.tools.length }) }} +

+

+ {{ t('catalog.source.' + catalogSource) }} +

+
+ + +
+
+

{{ t('connect.title') }}

+
+ + {{ + t('connect.client.' + c) + }} + +

+ {{ t('connect.keyOff') }} +

+
{{ snippet }}
+ +
+ + +
+
+

{{ t('usage.title') }}

+ {{ + t('usage.unavailable') + }} +
+ + + + + + + + + + + + + + + + + + +
{{ systemMcp ? t('usage.tool') : t('usage.backend') }}{{ t('usage.calls') }}{{ t('usage.lastUsed') }}
{{ row.label || t('usage.merged') }}{{ row.calls }}{{ row.lastUsed || t('usage.never') }}
{{ t('usage.nothing') }}
+
+
+
+
diff --git a/src/app/adf-ai/mcp/df-mcp-exposure/df-mcp-exposure.component.scss b/src/app/adf-ai/mcp/df-mcp-exposure/df-mcp-exposure.component.scss new file mode 100644 index 00000000..752ff842 --- /dev/null +++ b/src/app/adf-ai/mcp/df-mcp-exposure/df-mcp-exposure.component.scss @@ -0,0 +1,393 @@ +// df-mcp-exposure — tokens only (light / dark / phosphor come free). +:host { + display: block; + width: 100%; +} + +.mcp { + display: flex; + flex-direction: column; + gap: var(--df-space-4); + font-size: var(--df-font-size-sm); + color: var(--df-text); + + &__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--df-space-3); + flex-wrap: wrap; + } + + &__endpoint { + font-size: var(--df-font-size-xs); + color: var(--df-text-muted); + word-break: break-all; + } + + &__health { + display: flex; + align-items: center; + gap: var(--df-space-2); + flex-wrap: wrap; + min-width: 0; + max-width: 100%; + + // health messages can be a sentence; let the badge wrap instead of + // pushing the row off the card + ::ng-deep .df-badge { + white-space: normal; + max-width: 100%; + height: auto; + line-height: 1.35; + } + } + + &__link { + color: var(--df-accent); + font-size: var(--df-font-size-xs); + text-decoration: none; + &:hover { + text-decoration: underline; + } + } + + &__card { + background: var(--df-surface); + border: 1px solid var(--df-border-2); + border-radius: var(--df-radius); + padding: var(--df-space-4); + min-width: 0; + } + + &__hd { + display: flex; + align-items: baseline; + gap: var(--df-space-3); + flex-wrap: wrap; + margin-bottom: var(--df-space-3); + + h3 { + margin: 0; + font-size: var(--df-font-size-lg); + font-weight: var(--df-font-weight-heading); + } + } + + &__hint, + &__note { + margin: var(--df-space-1) 0 0; + font-size: var(--df-font-size-xs); + color: var(--df-text-muted); + line-height: 1.45; + } + + // ---- top row: preview-as + tiles + &__top { + display: grid; + grid-template-columns: minmax(16rem, 1.6fr) repeat(4, minmax(9rem, 1fr)); + gap: var(--df-space-3); + + @media (max-width: 1100px) { + grid-template-columns: 1fr 1fr; + } + @media (max-width: 600px) { + grid-template-columns: 1fr; + } + } + + &__who { + grid-column: span 1; + mat-form-field { + width: 100%; + } + @media (max-width: 1100px) { + grid-column: 1 / -1; + } + } + + &__tile { + display: flex; + flex-direction: column; + gap: var(--df-space-1); + } + + &__k { + font-size: var(--df-font-size-xs); + font-weight: var(--df-font-weight-heading); + color: var(--df-text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; + } + + &__v { + font-size: var(--df-font-size-2xl); + font-weight: var(--df-font-weight-heading); + font-variant-numeric: tabular-nums; + line-height: 1.1; + + &--small { + font-size: var(--df-font-size-lg); + } + } + + &__d, + &__c { + font-size: var(--df-font-size-xs); + color: var(--df-text-muted); + line-height: 1.4; + } + + // ---- modes strip + &__modes { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); + gap: var(--df-space-4); + } + + &__mode { + display: flex; + flex-direction: column; + gap: var(--df-space-2); + min-width: 0; + + mat-button-toggle-group { + align-self: flex-start; + max-width: 100%; + } + } + + &__writes ::ng-deep .mat-button-toggle-checked[value='false'], + &__writes ::ng-deep .mat-button-toggle-checked:last-child { + color: var(--df-warning); + } + + // ---- what the client carries + &__carry { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: var(--df-space-5); + + @media (max-width: 900px) { + grid-template-columns: 1fr; + } + } + + &__bars { + display: flex; + flex-direction: column; + gap: var(--df-space-2); + } + + &__bar-row { + display: grid; + grid-template-columns: 7rem minmax(0, 1fr) 4.5rem; + align-items: center; + gap: var(--df-space-2); + } + + &__bar-lbl { + font-size: var(--df-font-size-xs); + color: var(--df-text-2); + } + + &__bar-n { + font-size: var(--df-font-size-xs); + text-align: right; + font-variant-numeric: tabular-nums; + } + + &__bar { + height: 1rem; + background: var(--df-surface-2); + border-radius: 999px; + overflow: hidden; + + i { + display: block; + height: 100%; + background: var(--df-accent); + border-radius: 999px; + transition: width var(--df-duration-standard) var(--df-ease-standard); + } + + &--lite i { + background: var(--df-success); + } + } + + &__steps { + display: flex; + flex-direction: column; + gap: var(--df-space-2); + transition: opacity var(--df-duration-standard); + + &--dim { + opacity: 0.45; + } + } + + &__step { + display: flex; + flex-direction: column; + gap: 2px; + padding-left: var(--df-space-3); + border-left: 2px solid var(--df-accent-soft); + + code { + font-size: var(--df-font-size-xs); + color: var(--df-text); + } + span { + font-size: var(--df-font-size-xs); + color: var(--df-text-muted); + } + } + + // ---- split: grid + rail + // the 16-verb grid needs ~60rem; only pair it with the rail on wide + // screens, otherwise the rail stacks underneath + &__split { + display: grid; + grid-template-columns: 1fr; + gap: var(--df-space-4); + align-items: start; + + @media (min-width: 1700px) { + grid-template-columns: minmax(0, 1.7fr) minmax(22rem, 1fr); + } + + &--rail-only { + grid-template-columns: 1fr; + } + } + + &__rail { + display: flex; + flex-direction: column; + gap: var(--df-space-4); + min-width: 0; + } + + @media (max-width: 1699px) { + &__rail { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); + align-items: start; + } + } + + .mcp__split--rail-only &__rail { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); + align-items: start; + } + + // ---- catalog chips + &__cat-sum { + display: flex; + gap: var(--df-space-3); + flex-wrap: wrap; + font-size: var(--df-font-size-xs); + color: var(--df-text-muted); + margin-bottom: var(--df-space-2); + + b { + color: var(--df-text); + font-weight: var(--df-font-weight-heading); + } + } + + &__chips { + display: flex; + flex-wrap: wrap; + gap: var(--df-space-1); + max-height: 18rem; + overflow: auto; + } + + &__chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 999px; + font-family: monospace; + font-size: var(--df-font-size-xs); + background: var(--df-surface-2); + border: 1px solid var(--df-border-2); + color: var(--df-text-2); + + &--global { + color: var(--df-text-muted); + } + &--write { + background: var(--df-warning-soft); + border-color: var(--df-warning-border); + color: var(--df-warning); + } + &--facade { + background: var(--df-accent-soft); + border-color: var(--df-accent); + color: var(--df-accent); + } + &--custom { + border-style: dashed; + } + } + + &__w { + font-size: 9px; + font-weight: var(--df-font-weight-heading); + line-height: 1; + padding: 1px 3px; + border-radius: 3px; + background: var(--df-warning); + color: var(--df-accent-contrast); + } + + // ---- connect + &__clients { + margin-bottom: var(--df-space-2); + flex-wrap: wrap; + } + + &__snip { + margin: 0 0 var(--df-space-2); + padding: var(--df-space-3); + background: var(--df-code-bg); + color: var(--df-code-text); + border-radius: var(--df-radius-sm); + font-size: var(--df-font-size-xs); + line-height: 1.5; + overflow-x: auto; + white-space: pre; + } + + // ---- usage table + &__ev { + width: 100%; + border-collapse: collapse; + font-size: var(--df-font-size-sm); + + th, + td { + text-align: left; + padding: var(--df-space-1) var(--df-space-2); + border-bottom: 1px solid var(--df-border-2); + } + th { + font-size: var(--df-font-size-xs); + color: var(--df-text-muted); + font-weight: var(--df-font-weight-heading); + } + td:nth-child(2) { + font-variant-numeric: tabular-nums; + } + } + + &__zero { + color: var(--df-warning); + font-weight: var(--df-font-weight-heading); + } +} diff --git a/src/app/adf-ai/mcp/df-mcp-exposure/df-mcp-exposure.component.ts b/src/app/adf-ai/mcp/df-mcp-exposure/df-mcp-exposure.component.ts new file mode 100644 index 00000000..f085bac5 --- /dev/null +++ b/src/app/adf-ai/mcp/df-mcp-exposure/df-mcp-exposure.component.ts @@ -0,0 +1,573 @@ +import { CommonModule } from '@angular/common'; +import { + Component, + EventEmitter, + Input, + OnChanges, + OnDestroy, + OnInit, + Output, + SimpleChanges, +} from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatButtonToggleModule } from '@angular/material/button-toggle'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatSelectModule } from '@angular/material/select'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { RouterLink } from '@angular/router'; +import { TranslocoModule } from '@ngneat/transloco'; +import { Subject, forkJoin, of, switchMap, takeUntil } from 'rxjs'; +import { DfBadgeComponent } from 'src/app/shared/components/df-badge/df-badge.component'; +import { DfMcpAccessComponent } from '../df-mcp-access/df-mcp-access.component'; +import { DfMcpExposureGridComponent } from '../df-mcp-exposure-grid/df-mcp-exposure-grid.component'; +import { + CatalogSource, + DfMcpApiService, + McpAccess, + McpApp, + McpHealth, + McpRole, + McpUsage, +} from '../df-mcp-api.service'; +import { + ADMIN_IDENTITY, + CONNECT_CLIENTS, + CatalogShape, + ConnectClient, + DEFAULT_BYTES_PER_TOOL, + FACADE_TOOLS, + LAZY_THRESHOLD_BYTES, + McpBackend, + McpConfig, + McpIdentity, + callsByBackend, + componentsByServiceName, + connectSnippet, + formatKb, + formatTokens, + grantsByService, + maskFromVerbNames, + shapeCatalog, + shapeFixedCatalog, + tokensPerTurn, + verbsByServiceName, +} from '../mcp-model'; + +export interface IdentityOption { + key: string; + label: string; + identity: McpIdentity; +} + +interface UsageRow { + label: string; + calls: number; + lastUsed: string | null; +} + +/** + * df-mcp-exposure — the MCP server page body: what the server exposes, + * previewed as one identity, who can connect, modes, connect snippets. + * + * Composed into df-service-details behind its `isMcp` branch. The host + * owns the form: this component binds the config controls it is given + * (exposedServices, lazyMode, toolStyle, allowApiKeyAuth, requireRoleAccess, + * allowWrites) and mutates the shared `disabledTools` set, so the host's + * existing save path persists everything unchanged. + * + * `preview` (the create-flow review step) renders from inputs only: no + * access, health or usage calls, and backends come from the host. + */ +@Component({ + selector: 'df-mcp-exposure', + standalone: true, + templateUrl: './df-mcp-exposure.component.html', + styleUrls: ['./df-mcp-exposure.component.scss'], + imports: [ + CommonModule, + ReactiveFormsModule, + RouterLink, + TranslocoModule, + MatButtonModule, + MatButtonToggleModule, + MatFormFieldModule, + MatSelectModule, + MatTooltipModule, + DfBadgeComponent, + DfMcpExposureGridComponent, + DfMcpAccessComponent, + ], +}) +export class DfMcpExposureComponent implements OnInit, OnChanges, OnDestroy { + @Input() serviceId: number | null = null; + @Input({ required: true }) serviceName = ''; + @Input() serviceLabel = ''; + @Input() systemMcp = false; + @Input({ required: true }) config!: FormGroup; + @Input({ required: true }) disabledTools = new Set(); + @Input() customTools: string[] = []; + @Input() fixedTools: string[] = []; + /** exposed_services.values from the type schema; null = all DB/file. */ + @Input() attachableNames: string[] | null = null; + @Input() preview = false; + /** Preview mode only: the host supplies the backends. */ + @Input() backends: McpBackend[] = []; + /** Preview mode only: roles the create flow will grant. */ + @Input() previewRoles: McpRole[] = []; + + @Output() identityChange = new EventEmitter(); + + readonly clients = CONNECT_CLIENTS; + readonly facadeTools = FACADE_TOOLS; + readonly thresholdKb = formatKb(LAZY_THRESHOLD_BYTES); + + loading = false; + roles: McpRole[] = []; + apps: McpApp[] = []; + access: McpAccess | null = null; + health: McpHealth | null = null; + healthChecked = false; + usage: McpUsage | null = null; + serviceNameById: Record = {}; + + identities: IdentityOption[] = []; + identityKey = 'admin'; + identity: McpIdentity = ADMIN_IDENTITY; + + shape!: CatalogShape; + bytesPerTool = DEFAULT_BYTES_PER_TOOL; + catalogSource: CatalogSource = 'estimate'; + private rpcCalibrated = false; + private rpcToolCount: number | null = null; + /** Tool count the server itself reported (rpc/catalog), for reconciliation. */ + serverToolCount: number | null = null; + + client: ConnectClient = 'claude'; + copied = false; + + private destroy$ = new Subject(); + private formSub$ = new Subject(); + + constructor(private api: DfMcpApiService) {} + + ngOnInit(): void { + this.bindForm(); + this.recompute(); + if (!this.preview) this.load(); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes['config'] && !changes['config'].firstChange) this.bindForm(); + if (changes['backends'] || changes['previewRoles']) { + if (this.preview) { + this.roles = this.previewRoles; + this.buildIdentities(); + } + this.recompute(); + } + } + + ngOnDestroy(): void { + this.formSub$.next(); + this.formSub$.complete(); + this.destroy$.next(); + this.destroy$.complete(); + } + + private bindForm(): void { + this.formSub$.next(); + this.config.valueChanges + .pipe(takeUntil(this.formSub$), takeUntil(this.destroy$)) + .subscribe(() => this.recompute()); + } + + // ----------------------------------------------------------- config + + control(name: string): FormControl | null { + return (this.config.get(name) as FormControl | null) ?? null; + } + + has(name: string): boolean { + return !!this.config.get(name); + } + + get cfg(): McpConfig { + const val = (name: string, fallback: unknown) => { + const c = this.config.get(name); + return c && c.value != null ? c.value : fallback; + }; + return { + exposedServices: (val('exposedServices', []) as string[]) ?? [], + disabledTools: Array.from(this.disabledTools), + lazyMode: val('lazyMode', 'auto') as McpConfig['lazyMode'], + toolStyle: + val('toolStyle', 'prefixed') === 'merged' ? 'merged' : 'prefixed', + allowWrites: val('allowWrites', true) !== false, + allowApiKeyAuth: val('allowApiKeyAuth', false) === true, + requireRoleAccess: val('requireRoleAccess', false) === true, + }; + } + + setMode(name: string, value: unknown): void { + const c = this.control(name); + if (!c) return; + c.setValue(value); + c.markAsDirty(); + } + + toggleExposed(name: string): void { + const c = this.control('exposedServices'); + if (!c) return; + const cur: string[] = Array.isArray(c.value) ? c.value : []; + c.setValue( + cur.includes(name) ? cur.filter(n => n !== name) : [...cur, name] + ); + c.markAsDirty(); + } + + toggleCell(key: string): void { + if (this.disabledTools.has(key)) this.disabledTools.delete(key); + else this.disabledTools.add(key); + this.recompute(); + } + + recompute(): void { + const cfg = this.cfg; + this.shape = this.systemMcp + ? shapeFixedCatalog(cfg, this.fixedTools, this.bytesPerTool) + : shapeCatalog( + cfg, + this.backends, + this.identity, + this.customTools, + this.bytesPerTool + ); + } + + // ------------------------------------------------------------- load + + private load(): void { + if (this.serviceId == null) return; + this.loading = true; + forkJoin({ + backends: this.systemMcp + ? of([] as McpBackend[]) + : this.api.attachableBackends(this.attachableNames), + roles: this.api.roles(), + apps: this.api.apps(), + access: this.api.access(this.serviceName), + names: this.api.serviceNames(), + usage: this.api.usage(this.serviceId), + health: this.api.health(), + rpc: this.systemMcp ? of(null) : this.api.rpcToolsList(this.serviceName), + }) + .pipe(takeUntil(this.destroy$)) + .subscribe(r => { + this.backends = r.backends; + this.roles = r.roles; + this.apps = r.apps; + this.access = r.access; + this.serviceNameById = r.names; + this.usage = r.usage; + this.health = r.health; + this.healthChecked = true; + if (r.rpc) { + this.rpcCalibrated = true; + this.rpcToolCount = r.rpc.names.length; + this.serverToolCount = r.rpc.names.length; + this.bytesPerTool = Math.round(r.rpc.bytes / r.rpc.names.length); + this.catalogSource = 'rpc'; + } + this.loading = false; + this.buildIdentities(); + this.recompute(); + this.calibrateFromCatalog(); + }); + } + + /** Reload access + roles after the access panel writes a role. */ + reloadAccess(): void { + if (this.serviceId == null) return; + forkJoin({ + roles: this.api.roles(), + apps: this.api.apps(), + access: this.api.access(this.serviceName), + }) + .pipe(takeUntil(this.destroy$)) + .subscribe(r => { + this.roles = r.roles; + this.apps = r.apps; + this.access = r.access; + this.buildIdentities(); + this.selectIdentity(this.identityKey); + }); + } + + /** + * mcp-catalog: the server's own preview for a role or key. Real bytes and + * count calibrate the estimate; `backends[].verbs` replaces the reach we + * derived from role rows, and component-scoped backends are labelled + * "limited to " rather than marking every tool denied. + * Admins have no preview (the endpoint needs a role), so they keep the + * tools/list calibration. + */ + private calibrateFromCatalog(): void { + if (this.serviceId == null) return; + if (this.identity.kind === 'admin') { + this.catalogSource = this.rpcCalibrated ? 'rpc' : 'estimate'; + this.serverToolCount = this.rpcToolCount; + this.recompute(); + return; + } + const asked = this.identity; + of(asked) + .pipe( + switchMap(id => + this.api.catalog(this.serviceName, { + roleId: id.roleId, + appId: id.appId, + }) + ), + takeUntil(this.destroy$) + ) + .subscribe(cat => { + if (this.identity !== asked) return; + if (!cat || !cat.count) { + this.catalogSource = this.rpcCalibrated ? 'rpc' : 'estimate'; + this.serverToolCount = null; + return; + } + this.catalogSource = 'catalog'; + this.serverToolCount = cat.count; + if (cat.bytes) this.bytesPerTool = Math.round(cat.bytes / cat.count); + if (cat.backends) { + const verbs: Record = {}; + const components: Record = {}; + for (const b of cat.backends) { + verbs[b.name] = maskFromVerbNames(b.verbs ?? []); + if (b.component_scoped && b.components?.length) { + components[b.name] = b.components; + } + } + this.identity = { ...asked, verbs, components }; + } + this.recompute(); + }); + } + + // --------------------------------------------------------- identity + + private buildIdentities(): void { + const opts: IdentityOption[] = [ + { key: 'admin', label: '', identity: ADMIN_IDENTITY }, + ]; + const rolesById = new Map(this.roles.map(r => [r.id, r])); + const seen = new Set(); + const listed: Array<{ id: number; name: string }> = []; + for (const r of this.access?.roles ?? []) { + listed.push({ id: r.role_id, name: r.name }); + } + if (this.serviceId != null) { + for (const r of this.roles) { + if ( + r.rows.some(x => x.serviceId === this.serviceId && x.verbMask > 0) + ) { + listed.push({ id: r.id, name: r.name }); + } + } + } + for (const r of this.previewRoles) listed.push({ id: r.id, name: r.name }); + for (const r of listed) { + if (seen.has(r.id)) continue; + seen.add(r.id); + const role = rolesById.get(r.id); + const verbs = role + ? verbsByServiceName(role.rows, this.serviceNameById) + : {}; + const components = role + ? componentsByServiceName(role.rows, this.serviceNameById) + : {}; + opts.push({ + key: `role:${r.id}`, + label: r.name, + identity: { + kind: 'role', + roleId: r.id, + roleName: r.name, + verbs, + components, + }, + }); + for (const app of this.apps.filter(a => a.roleId === r.id)) { + opts.push({ + key: `app:${app.id}`, + label: app.name, + identity: { + kind: 'app', + roleId: r.id, + roleName: r.name, + appId: app.id, + appName: app.name, + apiKey: app.apiKey ?? undefined, + verbs, + components, + }, + }); + } + } + this.identities = opts; + if (!opts.some(o => o.key === this.identityKey)) this.identityKey = 'admin'; + } + + selectIdentity(key: string): void { + this.identityKey = key; + this.identity = + this.identities.find(o => o.key === key)?.identity ?? ADMIN_IDENTITY; + this.identityChange.emit(this.identity); + this.recompute(); + this.calibrateFromCatalog(); + } + + previewRole(roleId: number): void { + this.selectIdentity(`role:${roleId}`); + } + + /** Backends the previewed role reaches nowhere (its note text). */ + get identityNoteKey(): string { + if (this.identity.kind === 'admin') return 'admin'; + if (this.systemMcp) return 'roleSystem'; + return this.shape.dbs.length + this.shape.files.length === 0 + ? 'roleNoData' + : 'role'; + } + + // ------------------------------------------------------------ tiles + + get endpointUrl(): string { + return this.api.endpointUrl(this.serviceName); + } + + get tokens(): string { + return formatTokens(tokensPerTurn(this.shape)); + } + + get fullKb(): string { + return formatKb(this.shape.bytes); + } + + get facadeKb(): string { + return formatKb(this.shape.facadeBytes); + } + + barWidth(bytes: number): number { + const max = Math.max(this.shape.bytes, LAZY_THRESHOLD_BYTES); + return Math.max(2, Math.round((bytes / max) * 100)); + } + + get modeReasonKey(): string { + const cfg = this.cfg; + if (cfg.lazyMode === 'on') return 'forcedOn'; + if (cfg.lazyMode === 'off') return 'forcedOff'; + return this.shape.lazy ? 'autoOver' : 'autoUnder'; + } + + get grantedRoleCount(): number { + return (this.access?.roles ?? []).filter(r => r.granted).length; + } + + get keyCount(): number { + const granted = new Set( + (this.access?.roles ?? []).filter(r => r.granted).map(r => r.role_id) + ); + return this.apps.filter(a => a.roleId != null && granted.has(a.roleId)) + .length; + } + + get healthVariant(): 'success' | 'warning' | 'danger' | 'neutral' { + if (!this.health) return 'neutral'; + const s = (this.health.status || '').toLowerCase(); + if (s === 'ok' || s === 'healthy' || s === 'pass') return 'success'; + if (s === 'warn' || s === 'warning' || s === 'degraded') return 'warning'; + return 'danger'; + } + + get healthMessage(): string { + if (!this.health) return ''; + const bad = this.health.checks?.find( + c => (c.status || '').toLowerCase() !== 'ok' && c.message + ); + return bad?.message ?? ''; + } + + get exampleTool(): string { + return this.cfg.toolStyle === 'merged' || this.systemMcp + ? 'get_table_data' + : `${this.shape.dbs[0]?.name ?? 'db'}_get_table_data`; + } + + // ---------------------------------------------------------- connect + + get snippet(): string { + return connectSnippet(this.client, { + name: this.serviceName, + url: this.endpointUrl, + apiKey: + this.identity.kind === 'app' && this.cfg.allowApiKeyAuth + ? (this.identity.apiKey ?? `<${this.identity.appName} key>`) + : null, + }); + } + + copySnippet(): void { + navigator.clipboard?.writeText(this.snippet).then(() => { + this.copied = true; + setTimeout(() => (this.copied = false), 1500); + }); + } + + // ------------------------------------------------------------ usage + + get usageRows(): UsageRow[] { + const byTool = this.usage?.by_tool ?? []; + const lastUsed = + [...(this.usage?.series ?? [])] + .filter(s => s.requests > 0) + .map(s => s.date) + .sort() + .pop() ?? null; + if (this.systemMcp) { + return byTool + .slice() + .sort((a, b) => b.requests - a.requests) + .slice(0, 8) + .map(t => ({ label: t.tool_name, calls: t.requests, lastUsed })); + } + const exposed = this.backends.filter(b => + this.cfg.exposedServices.includes(b.name) + ); + const { perBackend, merged } = callsByBackend(byTool, exposed); + const rows = exposed.map(b => ({ + label: b.label, + calls: perBackend[b.name] ?? 0, + lastUsed: perBackend[b.name] ? lastUsed : null, + })); + if (merged > 0) { + rows.push({ label: '', calls: merged, lastUsed }); + } + return rows; + } + + /** Roles the access panel needs: grants collapsed per service. */ + grantsFor(role: McpRole) { + return grantsByService(role.rows); + } + + trackByKey(_: number, o: { key: string }): string { + return o.key; + } + + trackByName(_: number, o: { name: string }): string { + return o.name; + } +} diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 9c28ce32..7da76c4d 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1162,5 +1162,268 @@ "empty": "REST API, generated from the schema." } } + }, + "mcpServer": { + "health": { + "success": "Healthy", + "warning": "Degraded", + "danger": "Unhealthy", + "neutral": "Health unknown", + "unavailable": "Health checks unavailable (needs a newer df-mcp-server)", + "anyUser": "Any logged-in user can connect", + "rolesGranted": "{{n}} roles granted", + "noRoles": "No role granted yet", + "systemPage": "System page" + }, + "previewAs": "Preview as", + "identity": { + "admin": "Any admin (OAuth login)", + "role": "Role: {{name}}", + "key": "Key: {{name}} (role {{role}})" + }, + "identityNote": { + "admin": "Admins see every exposed backend. Nothing is filtered by role.", + "role": "Backends this role holds no verb on are removed from its catalog. Write verbs stay advertised and fail at call time unless the role allows them.", + "roleNoData": "This role reaches none of the exposed backends: it connects and gets no data tools.", + "roleSystem": "The System API server advertises the same tools to every identity; the role decides what each call may touch." + }, + "tiles": { + "tools": "Tools advertised", + "facadeOver": "facade over {{n}} tools", + "serverReports": "server reports {{n}} right now", + "estimated": "estimated from this config", + "tokens": "Sent every turn", + "tokensHint": "tokens of tool schema (bytes ÷ 4)", + "mode": "Catalog mode", + "who": "Who can connect" + }, + "mode": { + "lazy": "Lazy", + "direct": "Direct" + }, + "modeReason": { + "forcedOn": "lazy loading forced on", + "forcedOff": "lazy loading off", + "autoOver": "{{kb}} catalog, over the {{threshold}} threshold", + "autoUnder": "{{kb}} catalog, under the {{threshold}} threshold" + }, + "auth": { + "oauthOnly": "OAuth only", + "oauthKey": "OAuth + key", + "oauthKeyLong": "OAuth + key", + "anyLogin": "any user with a login", + "rolesAndKeys": "{{roles}} roles and {{keys}} keys can connect", + "rolesOnly": "{{roles}} roles can connect" + }, + "modes": { + "lazy": "Lazy tool loading", + "auto": "Auto", + "on": "On", + "off": "Off", + "lazyWhy": { + "auto": "Facade only when the catalog passes {{threshold}}", + "on": "Clients get 5 facade tools, every turn", + "off": "Clients get the full catalog, every turn" + }, + "style": "Database tool style", + "merged": "Merged", + "prefixed": "Prefixed", + "styleWhy": { + "merged": "16 tools once; each takes a service argument", + "prefixed": "16 tools × {{n}} databases, more context per turn" + }, + "writes": "Writes", + "allowed": "Allowed", + "blocked": "Blocked", + "needsUpgrade": "Needs a newer df-mcp-server; the server default applies until then", + "writesWhy": { + "on": "{{n}} write tools advertised where the role allows", + "off": "No client can create, update or delete through this server" + }, + "auth": "Authentication", + "authWhy": { + "key": "X-DreamFactory-API-Key accepted; the key's role scopes the tools", + "oauth": "Bearer token from the DreamFactory login" + }, + "requireRole": "Require role access", + "requireWhy": { + "on": "Only roles granted this server can connect", + "off": "Any logged-in user can connect; roles only limit data calls" + } + }, + "carry": { + "title": "What the client carries each turn", + "hint": "Lazy loading swaps the full menu for a search box. The tools still exist; the client finds them on demand.", + "full": "Full catalog", + "facade": "Lazy facade", + "barHint": "bar length = share of the {{threshold}} auto threshold", + "isLazy": "This server is lazy right now: the client carries {{lite}} per turn instead of {{full}}, and reaches all {{n}} tools through the steps on the right.", + "isDirect": "This server is direct right now: every turn carries all {{n}} tool schemas ({{full}}). Auto switches to lazy above {{threshold}}.", + "step1": "→ 3 matches: {{tool}}, aggregate_data, get_table_schema", + "step2": "→ full input schema, only now", + "step3": "→ first 6,000 characters + a handle. Role checks happen here, exactly as on the REST API.", + "step4": "→ the rest, page by page", + "stepsNote": "Clients that defer tool schemas themselves always get the full list. Results over 6,000 characters are paged through fetch_more." + }, + "grid": { + "title": "Exposure", + "hint": "Rows are backends, columns are the verbs each backend becomes. Switch a row on to expose it. Click a cell to disable one tool.", + "empty": "No database or file services to expose. Create one under API Connections first.", + "group": { + "read": "Read", + "spec": "Spec", + "procedures": "Procedures", + "write": "Write" + }, + "backend": "Backend", + "tools": "Tools", + "expose": "Expose {{name}}", + "kind": { + "database": "database", + "file": "file" + }, + "state": { + "on": "advertised", + "off": "disabled by you", + "writes-off": "blocked by the server-wide writes switch", + "denied": "advertised, but this role has no matching verb, so calls fail", + "gone": "not in this catalog" + }, + "hiddenShort": "hidden", + "fileVerbs": "file stores have 6 verbs", + "legend": { + "on": "advertised, read", + "write": "advertised, writes data", + "off": "disabled by you", + "writes-off": "blocked by the server-wide writes switch", + "denied": "advertised but the role denies it at call time", + "gone": "hidden from this identity entirely" + }, + "limitedTo": "limited to {{n}} tables" + }, + "catalog": { + "title": "What this identity sees", + "advertised": "advertised", + "canWrite": "can write", + "databases": "databases", + "files": "file stores", + "lazyNote": "Clients search the {{n}}-tool catalog through these five instead of carrying it.", + "source": { + "catalog": "Sizes and reach come from the server's catalog preview for this identity.", + "rpc": "Admin view: sizes calibrated from the server's current tools/list (saved config). Pick a role or key to preview its real catalog.", + "estimated": "Sizes are estimates (~540 bytes per tool); the server did not answer tools/list." + } + }, + "connect": { + "title": "Connect", + "client": { + "claude": "Claude", + "cursor": "Cursor", + "vscode": "VS Code", + "chatgpt": "ChatGPT", + "curl": "curl" + }, + "keyOff": "API key authentication is off for this server; this key can only be used once you allow it above.", + "copy": "Copy", + "copied": "Copied" + }, + "usage": { + "title": "Last 7 days", + "unavailable": "usage unavailable", + "tool": "Tool", + "backend": "Backend", + "calls": "Calls", + "lastUsed": "Last used", + "merged": "All databases (merged tools)", + "never": "never", + "nothing": "Nothing exposed" + }, + "access": { + "title": "Who can connect", + "hint": "Roles with access to this server. A role also needs access to each API it should reach.", + "addExisting": "Add existing role", + "createRole": "Create role", + "anyUser": "Any logged-in user can connect", + "anyUserHint": "Require role access is off. Roles below only limit what data calls may touch; turn it on to restrict who connects.", + "statsUnavailable": "Request counts unavailable (mcp-access endpoint did not answer); roles shown from their grants.", + "level": { + "rw": "read and write", + "read": "read only", + "none": "no access", + "serverOnly": "server only, no data APIs" + }, + "reachNone": "reaches no exposed API", + "tableLimited": "limited to specific tables (edit on the role page)", + "notExposed": "not exposed here", + "loginOnly": "login only", + "requests": "{{n}} requests", + "denied": "{{n}} denied", + "previewAs": "Preview as", + "editAccess": "Edit access", + "rolePage": "Role page", + "nobody": "Nobody can connect yet. Add a role or create one.", + "nobodyOpen": "No role holds a grant on this server; every logged-in user connects with their own role.", + "seenTitle": "Seen in the last 30 days without a grant", + "lastSeen": "last seen {{at}}", + "grant": "Grant", + "granted": "{{name}} can now connect to this server.", + "createdDesc": "Created from the {{server}} MCP page", + "dialog": { + "addTitle": "Add a role to {{server}}", + "editTitle": "Edit access to {{server}}", + "createTitle": "Create a role for {{server}}", + "role": "Role", + "roleName": "Role name", + "addHint": "The role is granted this server only. Tick an API to also grant it there.", + "editHint": "Per exposed API. None removes the role's blanket row for that API.", + "writesBlocked": "Writes are blocked on this server", + "noBackends": "Nothing is exposed yet; the role will only be granted the server.", + "alsoKey": "Also create an API key, for bots and scripts that can't log in", + "reviewHint": "Review before saving:", + "noChanges": "No changes.", + "everywhere": "This changes what the role can do everywhere, not only through MCP.", + "created": "Role {{name}} created and granted this server.", + "keyOnce": "Key created. Copy it now, it is shown once.", + "copyKey": "Copy key", + "keyFailed": "The API key could not be created; add one under API Keys.", + "cancel": "Cancel", + "review": "Review changes", + "back": "Back", + "save": "Save", + "createSave": "Create role", + "done": "Done" + } + }, + "create": { + "steps": { + "name": "Name", + "apis": "Attach APIs", + "who": "Who can connect", + "client": "Client settings", + "review": "Review", + "connect": "Connect" + }, + "nameHint": "The name becomes the endpoint: /mcp/.", + "apisHint": "Pick the database and file APIs this server exposes. You can change this later from the exposure grid.", + "noApis": "No database or file APIs exist yet. Create one under API Connections first.", + "whoHint": "Who may connect, and how.", + "requireRole": "Require role access", + "requireRoleHint": "On: only roles granted this server connect. Off: any logged-in user.", + "allowKey": "Allow API key authentication", + "allowKeyHint": "Bots and scripts that can't log in send X-DreamFactory-API-Key.", + "grantRoles": "Grant these existing roles", + "grantRolesHint": "Server access only; each role keeps its own API grants.", + "clientHint": "How much the client carries each turn.", + "reviewHint": "This is the page you will land on. Everything is editable after creation.", + "next": "Next", + "back": "Back", + "createServer": "Create server", + "creating": "Creating…", + "waiting": "Waiting for the first call…", + "waitingHint": "Add the server to a client with the snippet above. This page updates when the first request arrives.", + "firstCall": "First call received. Your server is live.", + "openPage": "Open server page", + "createFailed": "Could not create the server: {{error}}" + } } } From aadbd0de00ca9432f72660fc7c84d73577e5ceba Mon Sep 17 00:00:00 2001 From: nicdavidson Date: Fri, 18 Sep 2026 12:30:04 -0600 Subject: [PATCH 3/6] feat(mcp): create-flow stepper for new MCP servers name -> attach APIs -> who can connect -> client settings -> review (the exposure page in preview mode) -> connect snippet that waits for the first call. Defaults for new servers: merged tool style, require role access on, writes allowed. Grants selected roles server access after the POST. --- .../df-mcp-create.component.html | 262 ++++++++++++++++++ .../df-mcp-create.component.scss | 127 +++++++++ .../df-mcp-create/df-mcp-create.component.ts | 262 ++++++++++++++++++ 3 files changed, 651 insertions(+) create mode 100644 src/app/adf-ai/mcp/df-mcp-create/df-mcp-create.component.html create mode 100644 src/app/adf-ai/mcp/df-mcp-create/df-mcp-create.component.scss create mode 100644 src/app/adf-ai/mcp/df-mcp-create/df-mcp-create.component.ts diff --git a/src/app/adf-ai/mcp/df-mcp-create/df-mcp-create.component.html b/src/app/adf-ai/mcp/df-mcp-create/df-mcp-create.component.html new file mode 100644 index 00000000..b48d1d25 --- /dev/null +++ b/src/app/adf-ai/mcp/df-mcp-create/df-mcp-create.component.html @@ -0,0 +1,262 @@ +
+ + + +
+ + {{ + 'services.controls.namespace.label' | transloco + }} + + +

{{ t('nameHint') }}

+ + {{ + 'services.controls.label.label' | transloco + }} + + + + {{ + 'services.controls.description.label' | transloco + }} + + +
+ +
+
+
+ + + +
+

{{ t('apisHint') }}

+

+ {{ t('noApis') }} +

+
+ + {{ b.label }} + {{ b.name }} + {{ + 'mcpServer.grid.kind.' + b.kind | transloco + }} + +
+
+ + +
+
+
+ + + +
+

{{ t('whoHint') }}

+ + {{ t('requireRole') }} + +

{{ t('requireRoleHint') }}

+ + {{ t('allowKey') }} + +

{{ t('allowKeyHint') }}

+ +

{{ t('grantRoles') }}

+

{{ t('grantRolesHint') }}

+
+ + {{ r.name }} + +
+
+ + +
+
+
+ + + +
+

{{ t('clientHint') }}

+
+ {{ m('lazy') }} + + {{ m('auto') }} + {{ m('on') }} + {{ m('off') }} + +
+
+ {{ m('style') }} + + {{ + m('merged') + }} + {{ + m('prefixed') + }} + + {{ + ctl('toolStyle').value === 'merged' + ? m('styleWhy.merged') + : m('styleWhy.prefixed', { n: exposed.length }) + }} +
+
+ {{ m('writes') }} + + {{ + m('allowed') + }} + {{ + m('blocked') + }} + +
+
+ + +
+
+
+ + + +
+

{{ t('reviewHint') }}

+ +

+ {{ t('createFailed', { error: error }) }} +

+
+ + +
+
+
+ + + +
+ + {{ endpointUrl }} + + {{ + c('client.' + cl) + }} + +
{{ snippet }}
+ +
+ {{ firstCall ? t('firstCall') : t('waiting') }} + {{ t('waitingHint') }} +
+
+ +
+
+

+ {{ t('createFailed', { error: error }) }} +

+

+ {{ creating ? t('creating') : t('reviewHint') }} +

+
+
+
+
diff --git a/src/app/adf-ai/mcp/df-mcp-create/df-mcp-create.component.scss b/src/app/adf-ai/mcp/df-mcp-create/df-mcp-create.component.scss new file mode 100644 index 00000000..593cb901 --- /dev/null +++ b/src/app/adf-ai/mcp/df-mcp-create/df-mcp-create.component.scss @@ -0,0 +1,127 @@ +:host { + display: block; + width: 100%; +} + +.create { + font-size: var(--df-font-size-sm); + color: var(--df-text); + + &__step { + display: flex; + flex-direction: column; + gap: var(--df-space-2); + padding: var(--df-space-3) 0; + } + + &__field { + max-width: 32rem; + width: 100%; + } + + &__hint { + margin: 0; + font-size: var(--df-font-size-xs); + color: var(--df-text-muted); + } + + &__error { + margin: 0; + color: var(--df-danger); + font-size: var(--df-font-size-xs); + } + + &__h4 { + margin: var(--df-space-3) 0 0; + font-size: var(--df-font-size-md); + font-weight: var(--df-font-weight-heading); + } + + &__list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr)); + gap: var(--df-space-1) var(--df-space-3); + + &--roles { + grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); + } + } + + &__lbl { + margin-right: var(--df-space-1); + } + + &__code { + font-size: var(--df-font-size-xs); + color: var(--df-text-muted); + margin-right: var(--df-space-1); + } + + &__kind { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--df-text-faint); + } + + &__mode { + display: flex; + flex-direction: column; + gap: var(--df-space-1); + margin-top: var(--df-space-2); + } + + &__k { + font-size: var(--df-font-size-xs); + font-weight: var(--df-font-weight-heading); + color: var(--df-text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; + } + + &__actions { + display: flex; + gap: var(--df-space-2); + margin-top: var(--df-space-3); + } + + &__endpoint { + font-size: var(--df-font-size-xs); + color: var(--df-text-muted); + word-break: break-all; + } + + &__snip { + margin: 0; + padding: var(--df-space-3); + background: var(--df-code-bg); + color: var(--df-code-text); + border-radius: var(--df-radius-sm); + font-size: var(--df-font-size-xs); + line-height: 1.5; + overflow-x: auto; + max-width: 48rem; + } + + &__wait { + display: flex; + flex-direction: column; + gap: 2px; + margin-top: var(--df-space-3); + padding: var(--df-space-3); + border: 1px dashed var(--df-border); + border-radius: var(--df-radius-sm); + max-width: 48rem; + + span { + font-size: var(--df-font-size-xs); + color: var(--df-text-muted); + } + + &--live { + border-style: solid; + border-color: var(--df-success-border); + background: var(--df-success-soft); + } + } +} diff --git a/src/app/adf-ai/mcp/df-mcp-create/df-mcp-create.component.ts b/src/app/adf-ai/mcp/df-mcp-create/df-mcp-create.component.ts new file mode 100644 index 00000000..b612ff62 --- /dev/null +++ b/src/app/adf-ai/mcp/df-mcp-create/df-mcp-create.component.ts @@ -0,0 +1,262 @@ +import { CommonModule } from '@angular/common'; +import { Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { + FormBuilder, + FormControl, + FormGroup, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatButtonToggleModule } from '@angular/material/button-toggle'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSlideToggleModule } from '@angular/material/slide-toggle'; +import { MatStepperModule } from '@angular/material/stepper'; +import { ActivatedRoute, Router } from '@angular/router'; +import { TranslocoModule } from '@ngneat/transloco'; +import { Subject, forkJoin, interval, of, switchMap, takeUntil } from 'rxjs'; +import { ConfigSchema } from 'src/app/shared/types/service'; +import { normalizeError } from 'src/app/shared/utilities/app-error'; +import { DfMcpApiService, McpRole } from '../df-mcp-api.service'; +import { DfMcpExposureComponent } from '../df-mcp-exposure/df-mcp-exposure.component'; +import { + CONNECT_CLIENTS, + ConnectClient, + McpBackend, + accessRowsForChanges, + connectSnippet, +} from '../mcp-model'; + +/** + * df-mcp-create — the short stepper for a new `mcp` service: + * name → attach APIs → who can connect → client settings → review (the + * server page in preview mode) → connect snippet, waiting for the first call. + * + * Owns its own form (built from the type's config schema) and its own POST, + * so df-service-details only has to render it when type === 'mcp'. + */ +@Component({ + selector: 'df-mcp-create', + standalone: true, + templateUrl: './df-mcp-create.component.html', + styleUrls: ['./df-mcp-create.component.scss'], + imports: [ + CommonModule, + ReactiveFormsModule, + TranslocoModule, + MatStepperModule, + MatButtonModule, + MatButtonToggleModule, + MatCheckboxModule, + MatFormFieldModule, + MatInputModule, + MatSlideToggleModule, + DfMcpExposureComponent, + ], +}) +export class DfMcpCreateComponent implements OnInit, OnDestroy { + /** Config schema for type `mcp`, camelCased by the host. */ + @Input() configSchema: ConfigSchema[] = []; + + form: FormGroup; + config: FormGroup; + disabledTools = new Set(); + backends: McpBackend[] = []; + roles: McpRole[] = []; + grantRoleIds = new Set(); + loading = true; + creating = false; + error = ''; + createdId: number | null = null; + firstCall = false; + client: ConnectClient = 'claude'; + readonly clients = CONNECT_CLIENTS; + copied = false; + + private destroy$ = new Subject(); + private stopPoll$ = new Subject(); + + constructor( + private fb: FormBuilder, + private api: DfMcpApiService, + private router: Router, + private route: ActivatedRoute + ) { + this.form = this.fb.group({ + name: ['', [Validators.required, Validators.pattern(/^[a-zA-Z0-9_-]+$/)]], + label: [''], + description: [''], + }); + this.config = this.fb.group({}); + } + + ngOnInit(): void { + const names = new Set(this.configSchema.map(c => c.name)); + const add = (name: string, value: unknown) => { + if (names.has(name) || name === 'exposedServices') { + this.config.addControl(name, new FormControl(value)); + } + }; + add('exposedServices', []); + add('lazyMode', 'auto'); + add('toolStyle', 'merged'); + add('allowApiKeyAuth', false); + add('requireRoleAccess', true); + add('allowWrites', true); + + const exposed = this.configSchema.find(c => c.name === 'exposedServices'); + const attachable = Array.isArray(exposed?.values) + ? (exposed?.values as Array<{ name: string }>).map(v => v.name) + : null; + forkJoin({ + backends: this.api.attachableBackends(attachable), + roles: this.api.roles(), + }) + .pipe(takeUntil(this.destroy$)) + .subscribe(r => { + this.backends = r.backends; + this.roles = r.roles.filter(x => x.isActive !== false); + this.loading = false; + }); + } + + ngOnDestroy(): void { + this.stopPoll$.next(); + this.destroy$.next(); + this.destroy$.complete(); + } + + get name(): string { + return (this.form.get('name')?.value ?? '') as string; + } + + get exposed(): string[] { + return (this.config.get('exposedServices')?.value ?? []) as string[]; + } + + get previewRoles(): McpRole[] { + return this.roles.filter(r => this.grantRoleIds.has(r.id)); + } + + ctl(name: string): FormControl { + return this.config.get(name) as FormControl; + } + + has(name: string): boolean { + return !!this.config.get(name); + } + + isExposed(b: McpBackend): boolean { + return this.exposed.includes(b.name); + } + + toggleBackend(b: McpBackend): void { + const cur = this.exposed; + this.ctl('exposedServices').setValue( + cur.includes(b.name) ? cur.filter(n => n !== b.name) : [...cur, b.name] + ); + } + + toggleRole(id: number): void { + if (this.grantRoleIds.has(id)) this.grantRoleIds.delete(id); + else this.grantRoleIds.add(id); + // new Set reference so the preview picks the change up + this.grantRoleIds = new Set(this.grantRoleIds); + } + + get endpointUrl(): string { + return this.api.endpointUrl(this.name); + } + + get snippet(): string { + return connectSnippet(this.client, { + name: this.name, + url: this.endpointUrl, + }); + } + + copySnippet(): void { + navigator.clipboard?.writeText(this.snippet).then(() => { + this.copied = true; + setTimeout(() => (this.copied = false), 1500); + }); + } + + create(): void { + if (this.form.invalid || this.creating) return; + this.creating = true; + this.error = ''; + const payload = { + name: this.name, + label: this.form.get('label')?.value || this.name, + description: this.form.get('description')?.value || '', + type: 'mcp', + isActive: true, + config: { + ...this.config.getRawValue(), + disabledTools: [], + customTools: [], + }, + }; + this.api + .createService(payload) + .pipe( + switchMap(id => { + this.createdId = id; + const grants = this.previewRoles.map(role => + this.api.patchRoleAccess( + role.id, + accessRowsForChanges( + [{ serviceId: id, label: '', before: 'none', after: 'read' }], + role.rows + ) + ) + ); + return grants.length ? forkJoin(grants) : of(null); + }), + takeUntil(this.destroy$) + ) + .subscribe({ + next: () => { + this.creating = false; + this.pollFirstCall(); + }, + error: err => { + this.creating = false; + this.error = normalizeError(err).message; + }, + }); + } + + private pollFirstCall(): void { + const id = this.createdId; + if (id == null) return; + interval(5000) + .pipe( + switchMap(() => this.api.usage(id, '7d')), + takeUntil(this.stopPoll$), + takeUntil(this.destroy$) + ) + .subscribe(u => { + if ((u?.total_requests ?? 0) > 0) { + this.firstCall = true; + this.stopPoll$.next(); + } + }); + } + + openPage(): void { + if (this.createdId == null) return; + this.router.navigate(['../', this.createdId], { relativeTo: this.route }); + } + + trackByName(_: number, o: { name: string }): string { + return o.name; + } + + trackById(_: number, o: { id: number }): number { + return o.id; + } +} From 25b81d184c1ab4c72932b3ba52f54abcd60e38e2 Mon Sep 17 00:00:00 2001 From: nicdavidson Date: Fri, 18 Sep 2026 12:30:04 -0600 Subject: [PATCH 4/6] feat(services): compose the MCP page into df-service-details Replace the synthesised Built-in Tools panels (every active DB/file service, ignoring exposed_services) and the merged matrix with df-mcp-exposure for mcp and system_mcp; system_mcp keeps its fixed tool toggles and never shows Tool style. The MCP-owned config fields are hidden from the generic Options loop; the health panel is replaced by the page's own health/reach chips. New mcp services render df-mcp-create. Fix the df-ai-mcp-servers create link to /ai/mcp/create. --- .../df-service-details.component.html | 222 ++-------- .../df-service-details.component.ts | 390 ++---------------- .../df-ai-mcp-servers.component.ts | 2 +- 3 files changed, 80 insertions(+), 534 deletions(-) diff --git a/src/app/adf-services/df-service-details/df-service-details.component.html b/src/app/adf-services/df-service-details/df-service-details.component.html index c7ad8200..1ccb5e44 100644 --- a/src/app/adf-services/df-service-details/df-service-details.component.html +++ b/src/app/adf-services/df-service-details/df-service-details.component.html @@ -487,7 +487,7 @@

sharply below this point. Edit only (nothing to score during create) and never for the read-only DreamFactory Platform APIs. --> }} @@ -580,11 +581,18 @@

[matTooltip]="'services.controls.serviceType.tooltip' | transloco" /> + + + + *ngIf="!subscriptionRequired && !mcpCreateMode"> {{ 'services.controls.namespace.label' | transloco }} @@ -613,7 +621,7 @@

subscriptSizing="dynamic" appearance="outline" class="full-width" - *ngIf="!subscriptionRequired"> + *ngIf="!subscriptionRequired && !mcpCreateMode"> {{ 'services.controls.label.label' | transloco }} subscriptSizing="dynamic" class="full-width" appearance="outline" - *ngIf="!subscriptionRequired"> + *ngIf="!subscriptionRequired && !mcpCreateMode"> {{ 'services.controls.description.label' | transloco }} @@ -640,7 +648,7 @@

{{ 'active' | transloco }}
@@ -665,7 +673,7 @@

- + @@ -994,6 +1002,7 @@

(df-service-details camelCases the schema names) --> - + + + + + @@ -1135,181 +1162,6 @@

- - - - - - Built-in Tools - -
-

Loading services...

-
-
-

No database or file services found.

-
-
- - - - - - - - Global Tools - - - Cross-service · {{ mcpGlobalTools.length }} tools - - - - - - - - - - - - - - - - - -
Tool NameDescription
- - - - {{ tool.name }} - {{ tool.description }}
-
- - - - - - Database Tools (merged) - - - {{ mergedDbVerbs.length }} tools · - {{ dbServices.length }} databases - - -

- Each verb is registered once and takes a - service argument. Turning a verb off for one - database removes that database from the tool's allowed - services; turning it off everywhere removes the tool. -

- - - - - - - - - - - - - - - -
Tool Name - {{ svc.name }} -
- - - - {{ verb.name }} - - - -
-
- - - - - - - - {{ svc.label }} - - - {{ svc.category }} · {{ svc.tools.length }} tools - - - - - - - - - - - - - - - - - -
Tool NameDescription
- - - - {{ tool.name }} - {{ tool.description }}
-
-
-
-
-
-
- @@ -1780,7 +1632,9 @@
Parameters
-
+