Skip to content

RFC: Expose Horde application APIs to AI clients via MCP (endpoint wiring + per-app tool providers) #6

Description

@TDannhauer

Scope: Expose Horde application APIs to AI agents via the Model Context
Protocol (MCP), starting with Wicked (wiki) as the pilot application.

This RFC proposes wiring up the existing Horde\Rpc\Mcp server
implementation into a usable endpoint, plus the per-app provider work needed
to make it useful. Feedback from maintainers is requested on the work
packages and the open questions at the end; most of the changes live in
other repositories (Core, base, wicked), but the MCP layer itself is
here, so this seems the right place for the design discussion.

Related: horde/Core#199 (descriptor schema preservation in ApiRegistry,
found while preparing this plan).


1. Goal

MCP-capable AI clients should be able to discover and invoke Horde
application functionality — read and edit wiki pages, later query calendars,
contacts, tasks, and mail — through a single authenticated MCP endpoint on a
Horde installation.

Target user story for phase 1:

"Update the FooBar wiki page with the new deployment steps" — an AI agent
calls wiki_get_page, edits the markup, calls wiki_edit_page with a
changelog, and the change appears in Wicked's normal page history under
the bot's (or user's) identity, subject to normal Horde permissions.

2. Starting point: the MCP protocol layer already exists

The FRAMEWORK_6_0 branch of horde/rpc already contains a complete, tested
MCP server implementation in src/Mcp/:

Component Purpose
Horde\Rpc\Mcp\McpServer Facade wiring all components
Horde\Rpc\Mcp\McpRouter MCP lifecycle: initialize, ping, tools/list, tools/call, resources/list, resources/read
Horde\Rpc\Mcp\Transport\HttpHandler PSR-15 handler/middleware, MCP Streamable HTTP (POST + JSON), protocol versions 2025-11-25 / 2025-03-26
Horde\Rpc\Mcp\AuthContext Permissions from PSR-7 request attributes (auth_permissions, authenticated)
Horde\Rpc\Mcp\Protocol\ToolDescriptor Maps MethodDescriptor → MCP tool JSON (auto-generates inputSchema from parameter metadata)
Horde\Rpc\Mcp\ResourceProviderInterface Read-only MCP resources

Unit tests exist under test/Unit/Mcp/, and doc/MCP-USAGE.md documents the
design. The MCP layer deliberately shares one dispatch abstraction with the
modern JSON-RPC 2.0 stack (see horde/core/doc/INTERAPP_RPC.md):

JSON-RPC client        MCP client (LLM)
      |                      |
JsonRpcHandler           McpServer
      +----------+----------+
                 |
      Shared dispatch layer (horde/rpc)
      ApiProvider / MethodInvoker / MethodDescriptor
                 |
      ApiRegistry (horde/core) — aggregates per-app providers
                 |
      Horde\{App}\Api providers (per application)

A provider written once serves JSON-RPC, MCP, and SOAP. Horde also already
ships the auth building blocks MCP prescribes for remote HTTP servers: a
full OAuth2/OIDC server (/oauth2/*, /.well-known/* routes in
horde/horde/config/routes.php) and JwtAuthMiddleware /
AuthHttpBasic middleware in horde/core.

The implementation is homegrown, not the official mcp/sdk. That is a
deliberate trade-off already made here: no external pre-1.0 dependency,
protocol surface limited to what Horde needs, one dispatch layer for all
transports. This plan builds on that decision (see Open Questions §10.1).

3. Gap analysis — what is actually missing

3.1 No HTTP endpoint

Nothing in horde/horde (or any app) instantiates McpServer. The MCP
layer is dormant library code. A route + controller/factory wiring is needed.

3.2 No per-app tool providers (except Content)

ApiRegistryFactory discovers providers by convention (Horde\{App}\Api
implementing ApiInterfaceListProvider). Today only horde/content ships
modern providers (TaggerProvider, TaggerAdminProvider). Wicked, Kronolith,
Turba, Nag, Mnemo, IMP only have legacy lib/Api.php classes, which carry no
structured metadata (no schemas, no permission declarations).

3.3 User identity does not reach the tools

McpRouter::handleToolsCall() invokes with:

$result = $this->invoker->invoke($name, (array) $arguments);

— no ApiCallContext. The provider cannot know who is calling. Wicked
permissions are per-user and per-page; a wiki edit must be attributed to the
authenticated user. The router must build an ApiCallContext (user id,
permissions, auth state) from the transport's AuthContext and pass it to
both invoke() and listMethods() (the latter so tools/list can hide
tools the caller may not use — the visibility-filtering pattern
TaggerAdminProvider already implements).

3.4 Tool naming

ApiRegistry produces dot-notation names (wiki.editPage). Several MCP
clients restrict tool names to [a-zA-Z0-9_-]. The MCP layer needs a
reversible name mapping (proposal: dots → underscores at the
ToolDescriptor boundary, reverse-mapped in tools/call).

3.5 Parameter style

Legacy registry methods take positional parameters. MCP sends named
arguments (JSON object). Modern providers must accept named parameters —
already the stated convention for the AJAX transport, so this aligns.

3.6 Configuration and operational controls

No switch exists to enable/disable the MCP endpoint or select which
interfaces are exposed. Deployments must be able to run without any MCP
surface (default off).

4. Architecture

4.1 Request flow (target state)

MCP client
  | POST /horde/api/v1/mcp   (Bearer JWT or Basic auth)
  v
Route (horde/horde config/routes.php)
  middleware: ErrorFilter
            → JwtAuthMiddleware        (Bearer token → authenticated user)
            → AuthHttpBasic            (fallback for simple deployments)
            → DemandAuthenticatedUser  (reject anonymous)
            → McpPermissionAttributes  (NEW: map Horde perms of the
                                        authenticated user into the
                                        `auth_permissions` request attribute,
                                        set `authenticated`, `user_id`)
  v
McpHandler (thin PSR-15 controller, horde/horde or horde/core)
  builds McpServer{ServerInfo, ApiRegistry, ApiRegistry, ResponseFactory,
                   StreamFactory, WikiResourceProvider?}
  v
HttpHandler (horde/rpc, existing) → McpRouter (existing, + ApiCallContext fix)
  v
ApiRegistry (horde/core, existing)
  v
Horde\Wicked\Api\WikiProvider (NEW) → legacy Wicked driver / Wicked_Page

4.2 Authentication strategy

Phased, reusing what exists:

  1. Phase 1 (pilot): JwtAuthMiddleware (Bearer token obtained via the
    existing /api/v1/auth/login JWT flow) and HTTP Basic as a
    fallback for self-hosted setups. Both terminate in
    DemandAuthenticatedUser. This matches how rpc.php handles external
    clients today, upgraded to token auth.
  2. Phase 2: wire the existing OAuth2 authorization-code flow +
    /.well-known/oauth-protected-resource metadata so spec-compliant MCP
    clients can do the standard OAuth dance against Horde's own OAuth server.
    Horde already ships authorize/token/introspection endpoints and a consent
    middleware — this is configuration and glue, not new infrastructure.

Anonymous MCP access is not offered. tools/list is filtered by the
caller's context; tools/call re-checks per-method permissions (already
implemented in McpRouter) and the application enforces its own
object-level permissions (e.g. Wicked_Page::allows()), so authorization is
defense-in-depth: transport → descriptor → application.

4.3 Tool design principles

  • Curated, not blanket. Apps declare an explicit MCP-suitable tool set;
    we do not auto-expose every registry method. Tool descriptions are written
    for LLM consumption (imperative, self-contained, parameter docs).
  • Named parameters with JSON schemas, authored explicitly per tool
    (not relying on auto-generation) including property descriptions.
  • Mutating tools demand a changelog parameter; Wicked already supports
    require_change_log, giving every AI edit an auditable trail.
  • Results are compact JSON (the MCP layer wraps them as text content).
    Avoid returning rendered HTML unless requested; wiki source + metadata is
    what agents work with.

4.4 Wicked pilot tool set

Interface name wiki (matches its legacy provides), exposed as MCP tools:

Tool (wire name) Maps to Notes
wiki_list_pages driver getPages() names only; special flag
wiki_get_page Wicked_Page::getText() + metadata source, version, author, changelog
wiki_render_page processor transform(..., 'Plain'|'Xhtml') for read-only context
wiki_edit_page update/create semantics of legacy edit() required changelog; optional expected_version for optimistic locking
wiki_page_history driver getHistory()
wiki_recent_changes driver getRecentChanges($days)
wiki_search_pages driver getMatchingPages() / searchTitles() important for agents; legacy API lacks it

Optionally (phase 1.5): a ResourceProviderInterface exposing pages as MCP
resources (wiki://PageName), so clients can attach wiki pages as context
without a tool call. The MCP layer supports this already.

The provider wraps the same driver calls the legacy Wicked_Api uses, but
does not call the legacy API class (which reads $GLOBALS). Dependencies
(driver, registry, logger) are constructor-injected.

5. Work packages by repository

WP1 — horde/rpc: context propagation + naming (small)

  1. McpRouter: accept/construct an ApiCallContext from AuthContext
    (user id, permissions, authenticated) and pass it to
    hasMethod, getMethodDescriptor, listMethods, invoke.
  2. HttpHandler::extractAuthContext(): also read a user_id attribute.
  3. Tool-name mapping dots↔underscores at the ToolDescriptor /
    tools/call boundary.
  4. Tests for all three.

WP2 — horde/core: endpoint plumbing (small)

  1. New middleware McpPermissionAttributes (working title): translate the
    authenticated Horde user's permissions into auth_permissions /
    user_id request attributes consumed by the MCP transport.
  2. McpServerFactory (injector binding): builds McpServer with
    ServerInfo from Horde version/config, ApiRegistry as
    provider+invoker, HTTP factories from DI.

WP3 — horde/horde: endpoint + configuration (small)

  1. Route POST /api/v1/mcp in config/routes.php with the middleware stack
    from §4.1, dispatching to a thin McpController.
  2. conf.xml switches: $conf['api']['mcp']['enabled'] (default off),
    optional interface allowlist.
  3. Admin visibility: the existing /admin/apis/ screen already lists modern
    providers; add an "exposed via MCP" indicator (nice-to-have).

WP4 — horde/wicked: modern API provider (medium — the pilot)

  1. src/Api.php implementing ApiInterfaceListProvider
    (['wiki' => WikiProvider::class], naming convention
    Horde\Wicked\Api).
  2. src/Api/WikiProvider.php implementing ApiProvider + MethodInvoker:
    descriptors with hand-written JSON schemas, named params, object-level
    permission enforcement, changelog required on edit.
  3. PHPUnit tests with a stubbed driver (test/.../Stub/).
  4. Legacy lib/Api.php remains untouched — both systems coexist by design.

WP5 — Documentation

  1. Extend horde/rpc/doc/MCP-USAGE.md (context propagation, naming).
  2. New horde/horde doc: "Connecting AI clients to Horde via MCP" —
    endpoint enablement, token issuance, client configuration examples,
    security guidance.
  3. Update horde/core/doc/INTERAPP_RPC.md (MCP transport section).

Later phases (out of scope for the pilot, listed for the roadmap)

  • Kronolith (calendar_*), Turba (contacts_*), Nag (tasks_*),
    Mnemo (notes_*) providers — each an independent, parallelizable WP
    following the Wicked template.
  • OAuth resource-server metadata for spec-compliant client onboarding (§4.2
    phase 2).
  • MCP prompts capability (e.g. reusable "summarize my week" prompt) — the
    current implementation supports tools + resources only.

6. Testing strategy

  • Unit: new/changed classes in rpc, core, wicked.
  • Integration: end-to-end MCP handshake against a deployed endpoint
    (initialize → tools/list → tools/call) with a scripted client; assert a
    wiki edit lands in page history with correct author + changelog.
  • Client smoke test: register the endpoint in an MCP-capable AI client
    against a test installation; exercise read + edit on a scratch wiki page.
  • Negative tests: anonymous request rejected; user without page EDIT
    permission gets isError result; missing changelog rejected when
    require_change_log is on.

7. Security considerations

  • Endpoint off by default; explicit admin opt-in.
  • No anonymous tool surface; three-layer authorization (see §4.2).
  • Mutations are attributable: Horde user identity + mandatory changelog.
  • The MCP layer never exposes credentials; token issuance uses existing
    Horde auth flows. Recommend dedicated bot users with scoped permissions
    for autonomous agents; interactive use runs under the person's own account.
  • Rate limiting is left to the webserver/reverse proxy for the pilot
    (documented); revisit if abuse surfaces.
  • Prompt-injection realism: wiki content read by an agent may contain
    hostile instructions. Mitigation is client-side (agent policy), but the
    docs must state it, and destructive operations beyond page edits are
    deliberately not exposed (no page deletion/rename in phase 1).

8. Rollout plan

Phase Content Exit criterion
0 Discussion of this RFC Maintainer buy-in on WP1/WP2 changes
1 WP1 + WP2 + WP3 + WP4 (Wicked pilot), PRs per repository AI client edits a wiki page end-to-end on a test installation
1.5 Wiki resources; /admin/apis/ MCP indicator
2 OAuth client onboarding; second app (proposal: Kronolith read-only first) External MCP client connects without manual token handling

9. Effort estimate (coding, excl. review cycles)

WP Estimate
WP1 rpc 1–2 days incl. tests
WP2 core 0.5–1 day incl. tests
WP3 horde 0.5–1 day
WP4 wicked 2–3 days incl. tests
WP5 docs 1 day

10. Open questions

  1. Homegrown MCP vs official mcp/sdk: the in-tree implementation
    avoids a pre-1.0 dependency and reuses the shared dispatch layer, but it
    must track the MCP spec ourselves (currently 2025-11-25). Do we commit
    to maintaining it, or plan a later migration path to mcp/sdk behind the
    same dispatch interfaces? (Recommendation: keep homegrown; the surface is
    small and the dispatch abstraction isolates a future swap.)
  2. Endpoint path and ownership: /api/v1/mcp under the horde app
    (proposed) vs a per-app endpoint. Single endpoint + interface prefixes is
    simpler for clients and matches ApiRegistry's design.
  3. Identity model for ApiCallContext: minimal (user_id,
    permissions, authenticated) attribute-bag entries, or a typed
    identity object? Affects WP1 API.
  4. GET/SSE support: the current transport is POST-only (valid for
    Streamable HTTP without server-initiated streams). Some clients probe
    GET; do we return 405 (current behavior) or add an SSE stub later?
  5. Interface allowlist granularity: global config list (proposed) vs
    per-user/per-permission exposure of whole interfaces.
  6. Tool naming convention: wiki_edit_page style (snake, proposed) vs
    wiki_editPage (mixed). Cosmetic but should be uniform across apps from
    day one.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions