Skip to content

feat(chat-ai): SSE streaming + IMcpToolProvider + Message.context#1466

Merged
9 commits merged into
developmentfrom
feature/ai-chat-companion-orchestrator
May 15, 2026
Merged

feat(chat-ai): SSE streaming + IMcpToolProvider + Message.context#1466
9 commits merged into
developmentfrom
feature/ai-chat-companion-orchestrator

Conversation

@rubenvdlinde
Copy link
Copy Markdown
Contributor

Summary

Implements the OR-side of the ai-chat-companion 3-spec chain (hydra ADR-034).

  • NEW OCA\OpenRegister\Mcp\IMcpToolProvider PHP interface — per-app MCP tool registration; tool ids namespaced as {appId}.{toolName}, enforced mechanically by McpToolsService.
  • NEW built-in providers (lib/Mcp/BuiltIn/) for registers, schemas, objects — the existing static tools migrated onto the new contract.
  • NEW POST /api/chat/stream SSE controller emitting the six-event envelope (token, tool_call, tool_result, heartbeat, final, error). v1 emits a single `final` after the synchronous LLM call; token-by-token streaming via LLPhant hooks is a follow-up.
  • NEW GET /api/chat/health — lightweight public probe for the nextcloud-vue widget at mount.
  • NEW migration adds Message.context JSON column (default {}).
  • REFACTOR McpToolsService now enumerates IMcpToolProvider implementations in-process per turn (126 added / 493 removed LOC).
  • FIX pre-existing OR bug: ResponseGenerationHandler unconditionally instantiated OpenAIChat($config) which type-errored when $config was OllamaConfig. Wrapped in if ($chatProvider !== 'ollama') — Ollama chat was completely broken before this.

Spec change

Delta MODIFIES the existing chat-ai capability. New requirements:

  1. IMcpToolProvider PHP interface
  2. McpToolsService provider-discovery refactor
  3. SSE streaming endpoint POST /api/chat/stream
  4. Health probe GET /api/chat/health
  5. Message.context JSON column
  6. MCP tool authorization flowthrough (ADR-005 Rule 3)

Browser-verified

Both endpoints proven live: /api/chat/health returns 200 + {status:\"ok\",capabilities:[\"chat\",\"stream\"]}. /api/chat/stream emits the heartbeat correctly, conversation gets created cleanly. End-to-end Ollama LLM round-trip works in principle but Ollama's /api/chat endpoint specifically hangs on this dev env (Ollama-side issue, not OR-side).

Dependencies

Depends on the hydra ai-chat-companion spec landing first (it defines the contracts this PR implements).

Test plan

  • openspec validate ai-chat-companion-orchestrator --strict passes
  • /api/chat/health returns 200 with the configured provider, 503 without
  • /api/chat/stream opens SSE, emits heartbeat, creates a Conversation row
  • Fireworks streaming spike (task 1, blocker for token-by-token streaming)
  • composer check:strict clean (PHPCS / PHPMD / Psalm / PHPStan / PHPUnit)
  • Unit tests for McpToolsService namespace enforcement
  • Unit tests for ChatStreamController envelope shape

Implements the OR-side of the ai-chat-companion 3-spec chain
(hydra ADR-034). Modifies the existing chat-ai capability:

NEW:
- OCA\OpenRegister\Mcp\IMcpToolProvider PHP interface — per-app
  MCP tool registration contract; tool ids namespaced as
  {appId}.{toolName}, mechanically enforced by McpToolsService.
- Built-in providers (lib/Mcp/BuiltIn/) for registers, schemas,
  objects — the existing static tools migrated onto the new contract.
- POST /api/chat/stream SSE controller (ChatStreamController) emitting
  the six-event envelope per hydra spec: token, tool_call, tool_result,
  heartbeat (15s cadence), final, error. v1 emits a single final after
  the synchronous LLM call; the non-streaming-provider clause of the
  contract covers it. Token-by-token streaming via LLPhant hooks is a
  follow-up.
- GET /api/chat/health (ChatHealthController) — lightweight public
  probe the nextcloud-vue widget hits at mount to decide whether to
  render.
- Migration adds Message.context JSON column (default empty object).

REFACTOR:
- McpToolsService now enumerates IMcpToolProvider implementations
  in-process per turn instead of a hard-coded tool list (126 added
  / 493 removed LOC).

FIX:
- ResponseGenerationHandler: skip OpenAIChat construction for Ollama
  (the old unconditional `new OpenAIChat($config)` type-errored when
  $config was OllamaConfig — Ollama chat was broken on Nextcloud
  instances configured with Ollama as chatProvider).

The openspec change directory ships proposal, design, specs delta
(MODIFIED chat-ai), and tasks (45 tasks across 11 groups; the
Fireworks streaming spike is task 1).

Depends on the hydra ai-chat-companion spec landing first.
@github-actions
Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openregister @ f85808a

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
composer ❌ 1/153 denied
npm ✅ 598/598
PHPUnit ⏭️
Newman ⏭️
Playwright ⏭️

❌ Denied composer licenses

Package Version License
dompdf/dompdf v3.1.5 LGPL-2.1

Quality workflow — 2026-05-11 11:20 UTC

Download the full PDF report from the workflow artifacts.

… messageId

Two follow-up fixes for PR #1466 surfaced during end-to-end testing:

1. ConversationManagementHandler::generateConversationTitle() had the
   SAME LLPhant `OpenAIChat::__construct(OllamaConfig)` type-error as
   ResponseGenerationHandler. Wrapped the OpenAI-default instantiation
   in `if ($chatProvider !== 'ollama')` so the dedicated Ollama branch
   later in the method handles the title call for qwen/ollama models.

2. ChatService::processMessage() now propagates the persisted assistant
   message id to its return array. The historyHandler->storeMessage()
   call returned a Message entity that was being discarded; capture it
   and surface `messageId` via `$assistantStored->getId()`. The
   ChatStreamController already reads `$result['messageId']` to
   populate the SSE `final` event — without this fix it shipped an
   empty string and the nextcloud-vue widget couldn't render the
   assistant bubble (it relies on messageId as a Vue render key).

Verified end-to-end: real LLM round-trip through the SSE endpoint now
returns a populated messageId (e.g. "55") and the widget renders the
assistant bubble in decidesk's chat panel.
…agentUuid

The nextcloud-vue widget opens a fresh chat without an agentUuid — it
has no agent-picker in v1 (per hydra ADR-034 'one global thread per
(user, agent)'). Previous behaviour returned missing_agent and the
chat panel could never start a conversation.

Now: when both conversationUuid and agentUuid are empty, look up the
user's first available Agent via AgentMapper::findAll(limit: 1) and
use that. Falls through to the original missing_agent error if no
agent exists at all (operator needs to configure one in OR settings).

The widget remains a thin client — agent resolution is a server-side
concern that can grow into per-user / per-app preferred-agent logic
without a widget update.
phpcs (auto-fixable via phpcbf + missing @param docblocks):
- lib/Mcp/IMcpToolProvider.php, lib/Service/Mcp/McpToolsService.php,
  lib/Migration/Version1Date20260511100000.php: end-comments + block-
  comment spacing (auto-fix)
- lib/Controller/{Reports,AuditTrail,NotificationHistory}Controller.php,
  lib/Service/Aggregation/AggregationRunner.php,
  lib/Service/Object/PermissionHandler.php: add missing @param docblocks
  for constructor params (mostly pre-existing debt surfaced by the same
  gate)

eslint:
- src/dialogs/avg/EditActivityDialog.vue, src/services/fileMetadata.spec.js:
  vue/max-attributes-per-line + no-multi-spaces (eslint --fix)
- src/views/reports/ReportView.vue: rename unused param `widget` → `_widget`

license:
- dompdf/dompdf reports its license as the non-SPDX `LGPL-2.1`; equivalent
  to the allowlist's `LGPL-2.1-only` / `LGPL-2.1-or-later`. Added to
  .license-overrides.json with justification.
Second pass after the first commit revealed phpcs runs against the whole
tree, not just changed files:

- lib/Controller/RegistersController.php: pre-existing ternary formatting
  (auto-fix via phpcbf)
- lib/Controller/ActionsController.php: pre-existing missing @param
  docblocks for IUserSession/IGroupManager + lowercase inline comment
- lib/Controller/ChatStreamController.php (PR file): named-args for
  emitSseEvent/resolveConversation calls, lowercase=>uppercase booleans,
  comment punctuation, multi-line wrap for long emitSseEvent payload.
  Removed the unreachable JSONResponse return — every code path in
  stream() exits, so the return was dead. psalm still passes.
…t-companion-orchestrator

# Conflicts:
#	.license-overrides.json
#	lib/Controller/ActionsController.php
#	lib/Controller/AuditTrailController.php
#	lib/Controller/NotificationHistoryController.php
#	lib/Controller/ReportsController.php
#	lib/Service/Aggregation/AggregationRunner.php
#	lib/Service/Object/PermissionHandler.php
The development merge (PR #1357) added IUserSession/IGroupManager to
AuditTrailController's constructor and changed two production behaviours
without updating the affected tests. Bring the tests in line:

- AuditTrailControllerTest: pass the two new constructor args; default
  setUp() mocks an authenticated admin so the requireAdmin() gate on
  export()/clearAll() lets the happy-path assertions through. Clears all
  136 ArgumentCountError failures.
- ObjectServiceTest + ObjectServiceDeepTest: setSchema() deliberately
  rethrows DoesNotExistException (so NC's dispatcher returns 404; wrapping
  in ValidationException would be a 500). Update the expected exception.
- PermissionHandlerCacheTest::testConditionalRulesEvaluatePerObjectUuid:
  buildPermissionCacheKey() returns null for schemas with a match rule
  (verdict depends on mutable object data), so repeats are re-evaluated —
  4 hasPermission() calls => 4 ConditionMatcher delegations, not 2.

These three failures pre-date this PR (present on development's own CI);
fixed here because the development merge pulled them into this branch.
…t + add BuiltIn provider tests

The ai-chat-companion-orchestrator change refactored McpToolsService from
an inline registers/schemas/objects CRUD service into a provider
aggregator: it now takes `array $providers` + relocates the CRUD logic to
lib/Mcp/BuiltIn/{Registers,Schemas,Objects}ToolProvider. The old 62-test
McpToolsServiceTest still constructed the pre-refactor signature
(RegisterService as arg #1) → 63 ArgumentCountErrors.

- McpToolsServiceTest: rewritten against the new API — listTools()
  aggregation + `{appId}.` namespace enforcement (drops + warns),
  callTool() routing/success-wrap/InvalidArgumentException-on-unknown/
  exception-to-isError/debug-log/first-match-wins, invokeTool() flat
  envelope + no-throw-on-unknown, addProvider() runtime registration.
- New tests/Unit/Mcp/BuiltIn/{Registers,Schemas,Objects}ToolProviderTest:
  cover the relocated CRUD logic — getAppId(), the single namespaced
  descriptor + inputSchema, each action (list/get/create/update/delete)
  delegating to the mocked service, missing-param + unknown-action
  throwing InvalidArgumentException; the objects provider also covers the
  register+schema requirement and setRegister/setSchema scoping.
ObjectService::saveObject has a wide positional signature (,
, …, , …), so a with(object:, uuid:) named-arg matcher in the
mock was being matched positionally and failed on parameter 1. Capture the
invocation args via willReturnCallback and assert the data payload + the
uuid presence without depending on saveObject's exact parameter order.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[BLOCKER] IDOR: resolveConversation loads conversation by UUID without owner check

When conversationUuid is non-empty, conversationMapper->findByUuid(uuid: $conversationUuid) is called without verifying that the returned conversation's userId matches the authenticated $userId. Any authenticated user can supply another user's conversation UUID and hijack that conversation's context, inject messages, or read its history via the SSE pipeline. Fix: after findByUuid(), assert $conversation->getUserId() === $userId and throw (or emit an SSE error) if they differ.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[BLOCKER] MCP tool provider performs no user-permission check

listObjects() calls $this->objectService->findAll(config: $config) and getObject() calls $this->objectService->find($id) without passing a user context or calling any ACL/permission service. The IMcpToolProvider docblock explicitly states: 'Implementations MUST check Nextcloud auth and per-object IDOR boundaries before returning data', yet none of the three built-in providers do this. An LLM invoked on behalf of user A can retrieve objects from registers user A has no read permission on. Same issue in RegistersToolProvider::listRegisters() and SchemasToolProvider::listSchemas().

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[BLOCKER] Internal exception message leaked verbatim into SSE error event

$e->getMessage() is written directly into the message field of the error SSE event sent to the client. If an internal exception carries a DB error, stack trace fragment, API key, or LLM provider URL, it will be exposed to the browser. Replace with a generic user-facing message and log the raw exception; only expose a sanitised string in the SSE payload.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[BLOCKER] Outer catch only catches Exception, not \Throwable

catch (Exception $e) will not catch \Error, \TypeError, \ParseError, or other \Throwable subclasses. If json_decode returns an unexpected type and a subsequent PHP 8 typed property assignment throws a TypeError, the exception propagates past the catch, no error SSE event is emitted, the client connection drops silently, and Nextcloud's error handler may emit non-SSE output that corrupts the stream. Change to catch (\Throwable $e) to guarantee the error event is always sent before exit.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[BLOCKER] RegistersToolProvider list/create/update/delete exposed with no permission check

listRegisters(), createRegister(), updateRegister(), deleteRegister() call RegisterService methods without passing the current user or consulting ACL. A non-admin user prompted via the AI can delete or overwrite any register in the system. Inject IUserSession and validate against the register's ownership/ACL before mutating state.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[BLOCKER] Migration filename collision with PRs #1478, #1482, #1496

Four concurrent PRs each introduce a migration named Version1Date20260511100000.php. Nextcloud's migration runner identifies migrations by class name; merging more than one of these PRs will cause a fatal Class already declared error or silently skip the second migration depending on the autoloader. Rename this migration to an unused timestamp (e.g. Version1Date20260511110000) before merge and coordinate with the authors of the colliding PRs.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[BLOCKER] SPDX license header missing from new lib/ files (Gate 1)

All five new files under lib/ (IMcpToolProvider.php, ObjectsToolProvider.php, RegistersToolProvider.php, SchemasToolProvider.php, ChatStreamController.php, ChatHealthController.php) use a @license EUPL-1.2 docblock tag but omit the machine-readable SPDX-License-Identifier: EUPL-1.2 and SPDX-FileCopyrightText comment lines required by ADR-014 and the SPDX gate. CI will reject these files.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CONCERN] agentMapper->findAll(limit:1) auto-selects first agent regardless of user permissions

When neither conversationUuid nor agentUuid is provided, the controller falls back to $this->agentMapper->findAll(limit: 1) and picks the first agent in the database. This exposes agents that may be private, admin-only, or tenant-scoped to any authenticated user. The fallback should only return agents visible to $userId.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CONCERN] Client-supplied context echoed verbatim in the final SSE event

$context is read from the request body ($body['context'] ?? null) and then emitted unchanged in the final event payload. If a malicious or buggy client injects a context object containing credentials, session tokens, or PII, those values are reflected back in the SSE stream. Either drop context from the final payload entirely or strip to the known-safe fields (appId, pageKind, objectUuid, registerSlug, schemaSlug, capturedAt).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CONCERN] exit; bypasses Nextcloud shutdown handlers before DB cleanup

The PR's own design.md (Open Question 2) acknowledges that exit; may suppress Nextcloud's register_shutdown_function handlers responsible for DB connection cleanup. The implementation does not address this: no explicit getDatabaseConnection()->close() is called before exit. Under high concurrency this can exhaust the DB connection pool. Add a test and a comment confirming the risk was validated.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CONCERN] getTools() called on every listTools() invocation — no caching

listTools() iterates $this->providers and calls getTools() on each provider on every request turn. If an external IMcpToolProvider performs I/O inside getTools(), this multiplies cost per turn. Tool descriptors are static metadata and should be cached at registration time.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CONCERN] No rate limiting on POST /api/chat/stream

The SSE streaming endpoint performs an LLM call (external cost) per request with no throttle. An authenticated user can open unbounded parallel SSE connections to drive up LLM costs or cause resource exhaustion. Add UserRateThrottle attribute or document operator-level mitigation (nginx limit_req).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CONCERN] No heartbeat during synchronous LLM call — 15s heartbeat design not implemented

The design (D3) calls for a heartbeat event every 15 seconds during blocking operations. The v1 implementation emits one heartbeat before the LLM call then blocks synchronously. Reverse proxies with idle-timeout < LLM latency will silently drop the connection. Document this prominently in the code as a known proxy-timeout risk, not just in design.md.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CONCERN] Message.context not validated before persistence — arbitrary JSON stored

$context = $body['context'] ?? null is passed as-is into $ragSettings['__cn_ai_context__']. tasks.md task 8.2 requires validation that context is a JSON object, returning HTTP 400 if not. No validation exists. A malicious client can store arbitrarily large or structurally unexpected JSON in oc_openregister_messages.context.

Copy link
Copy Markdown
Contributor

@WilcoLouwerse WilcoLouwerse left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

🔴 Blockers (7)

🟡 Concerns (8)

🟢 Minor (3)

  • Comment says authenticated but auth is enforced only by framework default (appinfo/routes.php:14)
    // Chat - SSE streaming endpoint (authenticated). — accurate at runtime, but the absence of an explicit #[NoAdminRequired] attribute reference in the comment means a future refactor adding #[PublicPage] would silently remove the auth requirement without the comment becoming obviously wrong.
  • catch (\Exception $e) in invokeTool() — does not catch \Throwable (lib/Service/Mcp/McpToolsService.php:2129)
    invokeTool() catches only \Exception. PHP \Error subclasses (e.g. \TypeError) will propagate uncaught. Change to catch (\Throwable $e) for consistency with the spec requirement that invokeTool always returns a result envelope.
  • Raw php://input read without size limit — potential DoS via large bodies (lib/Controller/ChatStreamController.php:379)
    file_get_contents('php://input') has no size cap. A client can submit a multi-megabyte body (e.g. a large context blob) loaded entirely into memory before validation. Add an explicit size check via $_SERVER['CONTENT_LENGTH'].

Reviewed by WilcoLouwerse via automated batch review.

@WilcoLouwerse WilcoLouwerse closed this pull request by merging all changes into development in f86e83f May 15, 2026
@WilcoLouwerse WilcoLouwerse deleted the feature/ai-chat-companion-orchestrator branch May 15, 2026 09:40
rubenvdlinde added a commit that referenced this pull request May 15, 2026
Brings #1466 (chat-companion orchestrator, /api/chat/health route),
the consolidated #1496 deps work, journeydoc tutorials, and the
LLPhant Ollama think:false+keep_alive patch into the rollup so
CnAiCompanion FAB renders when this branch is deployed.

Conflicts resolved:
- tests/Unit/Controller/AuditTrailControllerTest.php: took dev's
  version (more precise mock with ->with('admin') + useful comment).
- tests/Unit/Service/ObjectServiceTest.php, ObjectServiceDeepTest.php,
  Object/PermissionHandlerCacheTest.php: took dev's versions
  wholesale via 'git checkout --theirs'. Rationale: dev has the
  most recent test fixes (commits 5504377, 7987a97, 8d17464)
  from the consolidation merge that recently passed PHPUnit on dev.
  HEAD's PHPCS-reformatted versions from #1273's quality-fix commit
  diverged structurally enough that the line-based merge produced
  duplicated class bodies.
- package-lock.json: took dev's; will npm install separately if
  needed once we touch JS deps.
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.

2 participants