Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Features are defined in [`openspec/specs/`](openspec/specs/). See the [roadmap](
### Supporting
- **OpenRegister Integration** — Pre-wired data layer using OpenRegister objects
- **Quality Pipeline** — PHPCS, PHPMD, Psalm, PHPStan, ESLint, Stylelint
- **AI Chat Companion (MCP Tools)** — 5 governance tools exposed to the OpenRegister AI Chat Companion: list action items, list recent meetings, get meeting details, start a meeting, and add action items. See [docs/features/mcp-tools.md](docs/features/mcp-tools.md).

## Architecture

Expand Down Expand Up @@ -190,6 +191,7 @@ docker exec nextcloud php occ app:enable decidesk
| [`openspec/architecture/`](openspec/architecture/) | App-specific Architectural Decision Records |
| [`openspec/ROADMAP.md`](openspec/ROADMAP.md) | Product roadmap |
| [`openspec/`](openspec/) | Implementation specifications and changes |
| [`docs/features/mcp-tools.md`](docs/features/mcp-tools.md) | AI Chat Companion MCP tools — tool reference, auth, troubleshooting |

## Standards & Compliance

Expand Down
241 changes: 241 additions & 0 deletions docs/features/mcp-tools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
# MCP Tools (AI Chat Companion Integration)

Decidesk exposes 5 governance tools to the AI Chat Companion (hydra ADR-034) via the
`OCA\OpenRegister\Mcp\IMcpToolProvider` interface. The companion can call these tools
when a user asks governance-related questions (e.g. "what action items are due this
week?" or "start the council meeting").

## Overview

The MCP (Model Context Protocol) integration lets an LLM surface Decidesk
capabilities without screen-scraping or custom API clients. Each tool call goes
through:

1. Per-tool argument validation (UUID shape, enum values, numeric ranges).
2. Per-object authorisation check (OWASP A01:2021, ADR-005) — enforced before any
business logic executes.
3. Business logic via the existing service layer.
4. A structured result array with a mandatory `sources[]` array so the companion can
cite which objects it used.

## Enabling the Companion

The integration is registered automatically when Decidesk is loaded alongside
OpenRegister >= the release that publishes `IMcpToolProvider` (PR #1466 in the
openregister repo). No admin configuration is needed.

If OpenRegister is not installed, the tools are simply unavailable; Decidesk
continues to function normally.

## Tool Reference

### `decidesk.listOpenActionItems`

Returns incomplete action items visible to the caller.

**Input fields**

| Field | Type | Required | Default | Constraints |
|----------|--------|----------|---------|-------------------------------|
| `scope` | string | no | `mine` | `mine` or `all` |
| `limit` | int | no | 20 | 1 – 50 |

**Output shape**

```json
{
"count": 3,
"items": [
{
"uuid": "...",
"title": "...",
"dueDate": "2026-06-01",
"meetingTitle": "...",
"meetingUuid": "...",
"assignee": "..."
}
],
"sources": [
{ "type": "decidesk.actionItem", "uuid": "...", "url": "/apps/decidesk/...", "label": "..." }
]
}
```

**Auth requirement:** Any authenticated user. `scope=all` returns items across all
meetings the user can see.

---

### `decidesk.listRecentMeetings`

Returns meetings ordered newest-first.

**Input fields**

| Field | Type | Required | Default | Constraints |
|----------------|--------|----------|-------------|-----------------------------------------|
| `limit` | int | no | 10 | 1 – 20 |
| `statusFilter` | string | no | `any` | `any`, `scheduled`, `in-progress`, `closed` |

**Output shape**

```json
{
"count": 2,
"meetings": [
{
"uuid": "...",
"title": "...",
"scheduledDate": "2026-05-15T14:00:00+02:00",
"status": "scheduled"
}
],
"sources": [ ... ]
}
```

**Auth requirement:** Any authenticated user.

---

### `decidesk.getMeetingDetails`

Fetches a single meeting with inline agenda items, decisions, and action items.

**Input fields**

| Field | Type | Required | Description |
|---------------|--------|----------|------------------------|
| `meetingUuid` | string | yes | UUID of the meeting |

**Output shape**

```json
{
"meeting": { "uuid": "...", "title": "...", "status": "...", "scheduledDate": "..." },
"agendaItems": [ { "uuid": "...", "title": "...", "order": 1 } ],
"decisions": [ { "uuid": "...", "title": "..." } ],
"actionItems": [ { "uuid": "...", "title": "...", "dueDate": "..." } ],
"sources": [ ... ],
"sourcesTruncated": false,
"sourcesTotalCount": 5
}
```

**Auth requirement:** The caller must be a participant in the meeting or a system
admin. Returns `forbidden` otherwise.

---

### `decidesk.startMeeting`

Transitions a meeting from `scheduled` to `opened` (in-progress).

**Input fields**

| Field | Type | Required | Description |
|---------------|--------|----------|------------------------|
| `meetingUuid` | string | yes | UUID of the meeting |

**Output shape**

```json
{
"success": true,
"started": true,
"meetingUuid": "...",
"startedAt": "2026-05-15T14:00:00+00:00",
"sources": [ { "type": "decidesk.meeting", "uuid": "...", "url": "...", "label": "..." } ]
}
```

**Auth requirement:** The caller must be the designated chair of the meeting or a
system admin. Returns `forbidden` otherwise.

**State guard:** If the meeting is not in `scheduled` state, the tool returns
`{ isError: true, error: "invalid_state", message: "Meeting is already <state>." }`.

---

### `decidesk.addActionItem`

Creates a new action item attached to a meeting.

**Input fields**

| Field | Type | Required | Constraints |
|---------------|--------|----------|-----------------------------------------|
| `meetingUuid` | string | yes | UUID of the meeting |
| `title` | string | yes | 3 – 200 characters |
| `assigneeId` | string | no | Nextcloud user ID of the assignee |
| `dueDate` | string | no | ISO 8601 date (`YYYY-MM-DD`) |

**Output shape**

```json
{
"created": true,
"actionItem": {
"uuid": "...",
"title": "...",
"meetingUuid": "...",
"dueDate": "2026-06-01"
},
"sources": [
{ "type": "decidesk.actionItem", "uuid": "...", "url": "...", "label": "..." },
{ "type": "decidesk.meeting", "uuid": "...", "url": "...", "label": "..." }
]
}
```

**Auth requirement:** The caller must be a participant in the meeting or a system
admin. Returns `forbidden` otherwise.

---

## Sources Convention

Every successful tool result contains a `sources` array (REQ-DMCP-006). Each element
has four keys:

| Key | Type | Description |
|---------|--------|------------------------------------------|
| `type` | string | Dot-namespaced type (e.g. `decidesk.meeting`) |
| `uuid` | string | Object UUID |
| `url` | string | Deep link: `/apps/decidesk/<resource>/<uuid>` |
| `label` | string | Human-readable title of the object |

When a result would produce more than 20 source descriptors, the array is capped at 20
and the response includes `sourcesTruncated: true` and `sourcesTotalCount: <n>`.

## Error Envelope

All errors (validation, auth, state, internal) use a consistent envelope:

```json
{
"isError": true,
"error": "unknown_tool | invalid_arguments | forbidden | not_found | invalid_state | internal_error",
"message": "Human-readable explanation."
}
```

## Troubleshooting

**Tool calls return `forbidden` for an admin user**

System admin status is checked via `IGroupManager::isAdmin()`. Confirm the user is in
the Nextcloud `admin` group, not just an app-level administrator.

**Tool calls return `internal_error`**

Check the Nextcloud server log (`data/nextcloud.log`) for entries tagged with
`DecideskToolProvider`. Common causes: OpenRegister `ObjectService` unavailable, or a
corrupted meeting object in the register.

**Tools do not appear in the AI Chat Companion**

The alias `OCA\OpenRegister\Mcp\IMcpToolProvider::decidesk` is registered in
`Application::register()`. If OpenRegister's `McpToolsService` cannot resolve it,
verify that OpenRegister is loaded and that no DI container error appears on app
bootstrap (`occ check`).
11 changes: 11 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

use OCA\Decidesk\BackgroundJob\MailReplyHandler;
use OCA\Decidesk\BackgroundJob\OverdueActionItemsJob;
use OCA\Decidesk\Mcp\DecideskToolProvider;
use OCA\Decidesk\Controller\AnalyticsController;
use OCA\Decidesk\Controller\CommentController;
use OCA\Decidesk\Controller\DecisionController;
Expand Down Expand Up @@ -560,6 +561,16 @@ static function ($c): MotionCoauthorController {
}
);

// Register DecideskToolProvider as the MCP tool provider for the AI Chat Companion.
// The alias key 'OCA\OpenRegister\Mcp\IMcpToolProvider::decidesk' is the format
// that OR's McpToolsService enumerates to discover per-app providers (design D3).
// The interface ships in openregister PR #1466 (ai-chat-companion-orchestrator).
// @spec openspec/changes/decidesk-mcp-tools/specs/mcp-tools/spec.md#REQ-DMCP-001.
$context->registerServiceAlias(
'OCA\\OpenRegister\\Mcp\\IMcpToolProvider::decidesk',
DecideskToolProvider::class
);

}//end register()

/**
Expand Down
38 changes: 20 additions & 18 deletions lib/Controller/ApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,6 @@ class ApiController extends Controller
'votes' => 'votes:read',
];


/**
* Constructor.
*
Expand All @@ -120,7 +119,6 @@ public function __construct(

}//end __construct()


/**
* List entities for a public REST resource.
*
Expand Down Expand Up @@ -154,26 +152,31 @@ public function index(string $resource): JSONResponse
$objectService = $this->container->get(id: 'OCA\\OpenRegister\\Service\\ObjectService');
$offset = (($page - 1) * $limit);
$results = $objectService->findAll(register: 'decidesk', schema: $schema, params: ['limit' => $limit, 'offset' => $offset]);
$total = is_array($results) ? count($results) : 0;
$pages = ((int) ceil((float) $total / max(1, $limit)));
$total = 0;
if (is_array($results) === true) {
$total = count($results);
}

$pages = ((int) ceil((float) $total / max(1, $limit)));
} catch (Throwable $e) {
$this->logger->error(message: 'ApiController index failed', context: ['resource' => $resource, 'exception' => $e]);
return $this->errorResponse(message: 'Internal server error', status: Http::STATUS_INTERNAL_SERVER_ERROR);
}

$response = new JSONResponse([
'total' => $total,
'page' => $page,
'pages' => $pages,
'results' => ($results ?? []),
]);
$response = new JSONResponse(
[
'total' => $total,
'page' => $page,
'pages' => $pages,
'results' => ($results ?? []),
]
);
$this->applyCorsHeaders(response: $response);

return $response;

}//end index()


/**
* Retrieve a single entity by id.
*
Expand Down Expand Up @@ -216,7 +219,6 @@ public function show(string $resource, string $id): JSONResponse

}//end show()


/**
* CORS preflight handler for `/api/v1/{resource}`.
*
Expand All @@ -239,7 +241,6 @@ public function preflight(string $resource): JSONResponse

}//end preflight()


/**
* CORS preflight handler for `/api/v1/{resource}/{id}`.
*
Expand All @@ -263,7 +264,6 @@ public function preflightItem(string $resource, string $id): JSONResponse

}//end preflightItem()


/**
* Build a consistent JSON error envelope (REQ-API-003).
*
Expand All @@ -283,7 +283,6 @@ private function errorResponse(string $message, int $status): JSONResponse

}//end errorResponse()


/**
* Apply CORS headers using the configured proxy origin when available.
*
Expand All @@ -298,11 +297,14 @@ private function applyCorsHeaders(JSONResponse $response): void
{
$origin = $this->config->getSystemValueString(key: 'overwrite.cli.url', default: '*');

$response->addHeader(name: 'Access-Control-Allow-Origin', value: ($origin === '' ? '*' : $origin));
$allowedOrigin = '*';
if ($origin !== '') {
$allowedOrigin = $origin;
}

$response->addHeader(name: 'Access-Control-Allow-Origin', value: $allowedOrigin);
$response->addHeader(name: 'Access-Control-Allow-Methods', value: 'GET, OPTIONS');
$response->addHeader(name: 'Access-Control-Allow-Headers', value: 'Authorization, Content-Type, X-Requested-With');

}//end applyCorsHeaders()


}//end class
Loading
Loading