Skip to content

feat(mcp): catalog preview endpoint — tools/list for a chosen role without a session (#64) - #75

Closed
nicdavidson wants to merge 1 commit into
developfrom
feat-catalog-preview
Closed

nicdavidson wants to merge 1 commit into
developfrom
feat-catalog-preview

Conversation

@nicdavidson

@nicdavidson nicdavidson commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Summary

The admin UI needs to show what a role or API key will get from tools/list on an MCP service before anyone connects. The verb lists, prefix rules, aggregator thresholds, facade names and the lazy decision live in the daemon, so the daemon computes the list and PHP supplies what the proxy would have sent for that role.

DaemonPOST /mcp/catalog/preview (internal-key gated like /mcp/cache/clear). Body: { serviceName, _mcpConfig, _mcpAvailableServices, clientName?, lazyMode? }, the same envelope fields the proxy sends. Runs the real registration path (createServer) in a throwaway in-memory server, lists tools with the given client name, and returns { tools: [{name,title,description,category,write,service?}], count, bytes, lazy: 'lazy'|'direct'|'passthrough', facade: [...] }. tools is the full callable catalog (facade excluded), bytes its serialized tools/list size (the auto threshold input), facade what the client actually sees instead when the decision is lazy (facade + hot tools). No DreamFactory calls, no session, nothing executed. The envelope config parsing was lifted out of the request handler into parseMcpConfig() and shared, so the preview cannot drift from a session.

PHP — admin-only GET /_internal/ai/mcp-catalog?service=<name|id>&role_id=<id>|app_id=<id>[&client=<name>][&lazy_mode=auto|on|off] (registered under df.auth_check next to mcp-usage). It seeds the DF session as that role through Support\RoleSession (no user so isSysAdmin() is false, app id, Role::getCachedInfo() — the same composition the API-key path establishes via setSessionData()), resolves the catalog through the shared AvailableServices::resolve(), reads Session::getServicePermissions() per backend, calls the daemon preview via McpDaemonClient::catalogPreview(), and restores the admin's session in finally. The response adds service, role, client, lazy_mode, tool_style and backends: [{name,type,category,verbs,component_scoped,components}] so the UI can mark tools that are advertised but denied at call time. verbs is the union of verb masks over every role row for that backend at any component (follow-up commit: the service-wide lookup alone reported [] for roles granted only at table level); component_scoped is true when the role has no service-wide row for the backend, and components lists the component patterns the grant is limited to, e.g. ["_table/orders/*", "_table/customers/*"]. app_id previews the app's default role (what an API-key-only client runs as). Only data-plane mcp services are accepted; system_mcp returns 400. No mcp_request_log row is written.

README (both) document the endpoint and response.

Behaviour changes

None for MCP clients: the proxied /mcp/{service} path is unchanged (the config parse was moved into a function, byte-for-byte the same logic), existing daemon tests pass unchanged.

  • Before: the only way to see a service's catalog was a live tools/list as the calling admin, which needed a daemon session and wrote an audit row.
  • After: a new admin-only read endpoint GET /_internal/ai/mcp-catalog and a new internal daemon route POST /mcp/catalog/preview. Neither opens a session, runs a tool or logs to mcp_request_log. The preview mutates the admin's session only for the duration of the call and restores it.
  • Upgrade impact: none. No config, migration or env change. If MCP_INTERNAL_KEY is set, the new daemon route requires it like the other internal routes.
  • How verified: daemon unit tests + a scratch daemon on port 8016 with MCP_INTERNAL_KEY (403 without the key, JSON preview with it, X-Mcp-Ledger stripped); PHP functional test of the role session on the real df-core Session; full PHP suite.

Testing

  • daemon/src/services/catalog-preview.test.ts (6 tests): prefixed classification (category / write / service, longest-prefix binding, aggregators), merged style, lazy on, auto small vs large, lazyMode override, passthrough client, disabled_tools + custom tools (api GET/POST, function, disabled), empty / missing exposure stays empty.
  • tests/Unit/CatalogPreviewWiringTest.php: route under df.auth_check, admin gate first, exactly one AvailableServices::resolve() inside RoleSession::run(), verb masks read as the role, no proxyRequest / rpcStateless / RequestLogger / _mcpPayload, daemon route key-gated and built on createServer + parseMcpConfig, no DreamFactoryService and no callTool in the preview.
  • tests/Unit/RoleSessionTest.php (functional, real df-core Session): a component-only role (orders_analyst: GET on _table/orders/*, GET+POST on _table/customers/*) reports verbs: [GET, POST], component_scoped: true, components: [...] while df-core's service-wide lookup returns 0; script-only rows ignored; inactive role reports nothing; inside the callback isSysAdmin() is false, the catalog is role ∩ exposure, per-backend verb masks are the role's; afterwards the admin session is byte-identical, also when the callback throws.
  • PHP suite: OK (188 tests, 671 assertions) (was 180).
  • Daemon: npm test 13 pass / 4 fail — the 4 failures are pre-existing on develop (the list_tools facade name missing from two expected arrays) and are fixed by test(daemon): expect list_tools in the lazy facade catalog #62; with test(daemon): expect list_tools in the lazy facade catalog #62's two-line diff applied locally the suite is 17/17. tsc --noEmit clean.

Closes #64

…ession (#64)

Daemon: POST /mcp/catalog/preview (internal-key gated) takes the same
_mcpConfig / _mcpAvailableServices the proxy envelopes, plus clientName and
lazyMode overrides, and runs the real registration path (createServer) in a
throwaway in-memory server. Returns { tools[{name,title,description,category,
write,service?}], count, bytes, lazy, facade }. No DreamFactory calls, no
session, nothing executed. The envelope config parser is shared with the
proxied request (parseMcpConfig) so the preview cannot drift from a session.

PHP: admin-only GET /_internal/ai/mcp-catalog?service=<name|id>&role_id=<id>
|app_id=<id>[&client=][&lazy_mode=]. Seeds the session as that role
(RoleSession: no user, app id, Role::getCachedInfo — the key-auth
composition), resolves the catalog through the shared
AvailableServices::resolve(), reads the role's verb mask per backend, asks the
daemon for the preview, and restores the admin's session. No audit row.

Tests: daemon unit test for the preview (prefixed vs merged, lazy on/auto/off
+ override, passthrough client, disabled_tools, custom tools, empty
exposure); PHP wiring test (route, admin gate, shared resolver, no proxy /
rpc / audit) and a functional RoleSession test on the real df-core Session.
@nicdavidson

Copy link
Copy Markdown
Contributor Author

Integrated into the team branch feat/mcp-exposure (merge commit history preserved; see the draft PR from that branch to develop). Closing so develop stays untouched until the Tuesday comparison; the review notes above still apply.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant