Merge development into main: OAuth-protected remote MCP server (#111)
Lint / PHP (phpcs PSR-12) (push) Successful in 19s
Lint / JS (eslint) (push) Successful in 8s
Lint / PHP requirements (version + extensions) (push) Successful in 22s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m34s
Lint / Deploy (push) Successful in 3s

Adds /mcp, a remote MCP server (official MCP PHP SDK, pinned 0.8.1)
protected by Authelia-issued OAuth access tokens, so Claude Code can work
with Tinker Tickets as the signed-in user: search_tickets, get_ticket,
create_ticket, add_comment, update_status, assign_ticket. Tools run the
same code paths as the web UI (ApiTicketController moved to its own
file; CommentService, AssignmentService and TicketCreationService
extracted from their web endpoints). Composer is introduced for the MCP
endpoint only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
This commit is contained in:
2026-09-24 19:21:30 -04:00
co-authored by Claude Opus 5.5
22 changed files with 4029 additions and 597 deletions
+3 -1
View File
@@ -6,4 +6,6 @@ settings.local.json
# Upload files (keep folder structure, ignore actual uploads)
uploads/*
!uploads/.gitkeep
!uploads/.htaccess
!uploads/.htaccess
# Composer dependencies (used only by the MCP endpoint; installed at deploy time)
vendor/
+1
View File
@@ -6,6 +6,7 @@
<exclude-pattern>*/uploads/*</exclude-pattern>
<exclude-pattern>*/migrations/*</exclude-pattern>
<exclude-pattern>*/.gitea/*</exclude-pattern>
<exclude-pattern>*/vendor/*</exclude-pattern>
<arg name="extensions" value="php"/>
<arg name="colors"/>
+11 -144
View File
@@ -80,155 +80,22 @@ try {
exit;
}
$ticketId = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : '';
if (!ctype_digit($ticketId) || (int)$ticketId <= 0) {
http_response_code(400);
// Validation, access check, mentions, audit log and notifications live in
// CommentService so the MCP add_comment tool runs the same code path.
require_once dirname(__DIR__) . '/services/CommentService.php';
$result = CommentService::addComment($conn, $currentUser, $data);
if (!empty($result['http_status'])) {
http_response_code($result['http_status']);
unset($result['http_status']);
ob_end_clean();
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Invalid ticket ID']);
echo json_encode($result);
exit;
}
// Reject empty/whitespace-only comments
$commentTextRaw = isset($data['comment_text']) ? trim((string)$data['comment_text']) : '';
if ($commentTextRaw === '') {
http_response_code(400);
ob_end_clean();
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Comment text cannot be empty']);
exit;
}
// Persist the trimmed text (not the raw client value) — matches update_comment.php
// and keeps stored comment_text free of leading whitespace that could shift a
// markdown-enabled comment's first line out of column 0 on reload.
$data['comment_text'] = $commentTextRaw;
// Never trust a client-supplied display name — always attribute the comment to
// the authenticated session user.
$data['user_name'] = $currentUser['display_name'] ?? $currentUser['username'] ?? 'User';
// Verify user can access the ticket before allowing a comment
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket) {
http_response_code(404);
ob_end_clean();
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit;
}
if (!$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
http_response_code(403);
ob_end_clean();
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Access denied']);
exit;
}
// Initialize models
$commentModel = new CommentModel($conn);
$auditLog = new AuditLogModel($conn);
// If replying, the parent comment must belong to this same (accessible) ticket.
if (isset($data['parent_comment_id']) && $data['parent_comment_id'] !== null && $data['parent_comment_id'] !== '') {
$parentComment = $commentModel->getCommentById((int)$data['parent_comment_id']);
if (!$parentComment || (string)$parentComment['ticket_id'] !== (string)$ticketId) {
http_response_code(400);
ob_end_clean();
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Invalid parent comment']);
exit;
}
}
// Extract @mentions from comment text
$mentions = $commentModel->extractMentions($data['comment_text'] ?? '');
$mentionedUsers = [];
if (!empty($mentions)) {
$mentionedUsers = $commentModel->getMentionedUsers($mentions);
}
// Add comment with user tracking
$result = $commentModel->addComment($ticketId, $data, $userId);
// Log comment creation to audit log
if ($result['success'] && isset($result['comment_id'])) {
$auditLog->logCommentCreate($userId, $result['comment_id'], $ticketId);
// Log mentions to audit log
foreach ($mentionedUsers as $mentionedUser) {
$auditLog->log(
$userId,
'mention',
'user',
(string)$mentionedUser['user_id'],
[
'ticket_id' => $ticketId,
'comment_id' => $result['comment_id'],
'mentioned_username' => $mentionedUser['username']
]
);
}
// Matrix notifications
$authorDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null;
$commentText = $data['comment_text'] ?? '';
$ticketTitle = $ticket['title'] ?? "Ticket #{$ticketId}";
$ticketVisibility = $ticket['visibility'] ?? 'public';
// @mention notifications — resolve usernames → Matrix IDs via Synapse Admin API.
// Only notify mentioned users who actually have access to this ticket;
// otherwise a mention would DM them the ticket's title and comment text
// even though canUserAccessTicket() would deny them the ticket itself.
$accessibleMentionedUsers = array_filter(
$mentionedUsers,
fn($u) => $ticketModel->canUserAccessTicket($ticket, $u)
);
if (!empty($accessibleMentionedUsers)) {
$mentionedUsernames = array_column($accessibleMentionedUsers, 'username');
$mentionedMatrixIds = SynapseHelper::resolveUsernames($mentionedUsernames);
if (!empty($mentionedMatrixIds)) {
NotificationHelper::sendMentionNotification($ticketId, $ticketTitle, $commentText, $authorDisplay, $mentionedMatrixIds);
}
}
// General comment notification (opt-in via MATRIX_NOTIFY_COMMENTS)
if (!empty($GLOBALS['config']['MATRIX_NOTIFY_COMMENTS'])) {
NotificationHelper::sendCommentNotification(
$ticketId,
$ticketTitle,
$commentText,
$authorDisplay,
$ticketVisibility !== 'public',
$ticketVisibility
);
}
// Notify watchers of the new comment
NotificationHelper::notifyWatchers(
$conn,
$ticketId,
$ticketTitle,
'comment_added',
['author' => $authorDisplay, 'preview' => mb_strimwidth($commentText, 0, 200, '…')],
(int)$userId,
$ticketVisibility
);
// Add mentioned users to result for frontend
$result['mentions'] = array_map(function ($u) {
return $u['username'];
}, $mentionedUsers);
}
// Add user info to result for frontend avatar rendering
if ($result['success']) {
$result['user_name'] = $currentUser['display_name'] ?? $currentUser['username'];
$result['user_id'] = $userId;
if (isset($newCsrfToken)) {
$result['csrf_token'] = $newCsrfToken;
}
if ($result['success'] && isset($newCsrfToken)) {
$result['csrf_token'] = $newCsrfToken;
}
// Discard any unexpected output
+9 -79
View File
@@ -1,11 +1,7 @@
<?php
require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/UserModel.php';
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
require_once dirname(__DIR__) . '/helpers/SynapseHelper.php';
require_once dirname(__DIR__) . '/services/AssignmentService.php';
// Get request data
$data = json_decode(file_get_contents('php://input'), true);
@@ -15,79 +11,13 @@ if (!is_array($data)) {
exit;
}
$ticketIdRaw = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : '';
$assignedTo = $data['assigned_to'] ?? null;
// Validation, permission check, audit log, notification and stats-cache
// invalidation live in AssignmentService so the MCP assign_ticket tool runs
// the same code path.
$result = AssignmentService::assign($conn, $currentUser, $data);
if (!ctype_digit($ticketIdRaw) || (int)$ticketIdRaw <= 0) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Ticket ID required']);
exit;
}
$ticketId = $ticketIdRaw;
$ticketModel = new TicketModel($conn);
$auditLogModel = new AuditLogModel($conn);
$userModel = new UserModel($conn);
// Verify ticket exists and user can access it
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit;
}
// Authorization: only admins or the ticket creator/assignee can reassign
if (!$isAdmin && (int)$ticket['created_by'] !== (int)$userId && (int)$ticket['assigned_to'] !== (int)$userId) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Permission denied']);
exit;
}
if ($assignedTo === null || $assignedTo === '') {
// Unassign ticket
$success = $ticketModel->unassignTicket($ticketId, $userId);
if ($success) {
$auditLogModel->log($userId, 'unassign', 'ticket', $ticketId);
}
} else {
// Validate assigned_to is a valid user ID
$assignedTo = (int)$assignedTo;
$targetUser = $userModel->getUserById($assignedTo);
if (!$targetUser) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid user ID']);
exit;
}
// Assign ticket
$success = $ticketModel->assignTicket($ticketId, $assignedTo, $userId);
if ($success) {
$auditLogModel->log($userId, 'assign', 'ticket', $ticketId, ['assigned_to' => $assignedTo]);
if (!empty($GLOBALS['config']['MATRIX_NOTIFY_ASSIGNMENTS'])) {
$changedByDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null;
$assigneeName = $targetUser['display_name'] ?? $targetUser['username'] ?? null;
$assigneeMatrix = isset($targetUser['username'])
? SynapseHelper::resolveUsername($targetUser['username'])
: null;
NotificationHelper::sendAssignmentNotification(
$ticketId,
$ticket['title'] ?? "Ticket #{$ticketId}",
$assigneeName,
$assigneeMatrix,
$changedByDisplay,
$ticket['visibility'] ?? 'public'
);
}
}
}
if (!$success) {
http_response_code(500);
apiRespond(['success' => false, 'error' => 'Failed to update ticket assignment']);
} else {
require_once dirname(__DIR__) . '/models/StatsModel.php';
(new StatsModel($conn))->invalidateCache();
apiRespond(['success' => true]);
if (!empty($result['http_status'])) {
http_response_code($result['http_status']);
unset($result['http_status']);
}
apiRespond($result);
+1 -256
View File
@@ -62,262 +62,7 @@ try {
$userId = $currentUser['user_id'];
$isAdmin = $currentUser['is_admin'] ?? false;
// Updated controller class that handles partial updates
class ApiTicketController
{
private $conn;
private $ticketModel;
private $commentModel;
private $auditLog;
private $workflowModel;
private $userId;
private $isAdmin;
private $currentUser;
public function __construct($conn, $userId = null, $isAdmin = false, $currentUser = [])
{
$this->conn = $conn;
$this->ticketModel = new TicketModel($conn);
$this->commentModel = new CommentModel($conn);
$this->auditLog = new AuditLogModel($conn);
$this->workflowModel = new WorkflowModel($conn);
$this->userId = $userId;
$this->isAdmin = $isAdmin;
$this->currentUser = $currentUser;
}
public function update($id, $data)
{
// First, get the current ticket data to fill in missing fields
$currentTicket = $this->ticketModel->getTicketById($id);
if (!$currentTicket) {
return [
'success' => false,
'error' => 'Ticket not found'
];
}
// Visibility check: return 404 for tickets the user cannot access
if (!$this->ticketModel->canUserAccessTicket($currentTicket, $this->currentUser)) {
return [
'success' => false,
'error' => 'Ticket not found',
'http_status' => 404
];
}
// Any authenticated team member can update tickets.
// Admin-only operations (delete, bulk actions) are enforced separately.
// Merge current data with updates, keeping existing values for missing fields
$updateData = [
'ticket_id' => $id,
'title' => $data['title'] ?? $currentTicket['title'],
'description' => $data['description'] ?? $currentTicket['description'],
'category' => $data['category'] ?? $currentTicket['category'],
'type' => $data['type'] ?? $currentTicket['type'],
'status' => $data['status'] ?? $currentTicket['status'],
'priority' => isset($data['priority']) ? (int)$data['priority'] : (int)$currentTicket['priority']
];
// Validate required fields
if (empty($updateData['title'])) {
return [
'success' => false,
'error' => 'Title cannot be empty',
'http_status' => 400
];
}
// Validate priority range
if ($updateData['priority'] < 1 || $updateData['priority'] > 5) {
return [
'success' => false,
'error' => 'Priority must be between 1 and 5',
'http_status' => 400
];
}
// Validate visibility BEFORE any DB write so a bad payload can't leave the
// ticket half-updated (core fields committed but request reported as failed).
$visibilityGroups = null;
if (isset($data['visibility'])) {
$visibilityGroups = $data['visibility_groups'] ?? null;
// Convert array to comma-separated string if needed
if (is_array($visibilityGroups)) {
$visibilityGroups = implode(',', array_map('trim', $visibilityGroups));
}
// Authorization: only an admin or the ticket's creator may change
// visibility. Enforce only when the requested visibility actually
// differs so ordinary edits that re-send the same value aren't blocked.
$currentVisibility = $currentTicket['visibility'] ?? 'public';
$currentGroups = $currentTicket['visibility_groups'] ?? null;
$groupsProvided = array_key_exists('visibility_groups', $data);
$visibilityChanged = ($data['visibility'] !== $currentVisibility)
|| ($groupsProvided && (string)$visibilityGroups !== (string)$currentGroups);
if ($visibilityChanged) {
$isCreator = $this->userId !== null
&& (int)($currentTicket['created_by'] ?? 0) === (int)$this->userId;
if (!$this->isAdmin && !$isCreator) {
return [
'success' => false,
'error' => 'You do not have permission to change ticket visibility',
'http_status' => 403
];
}
}
// Internal visibility requires at least one group
if ($data['visibility'] === 'internal' && (empty($visibilityGroups) || trim($visibilityGroups) === '')) {
return [
'success' => false,
'error' => 'Internal visibility requires at least one group to be specified',
'http_status' => 400
];
}
}
// Validate status transition using workflow model
if ($currentTicket['status'] !== $updateData['status']) {
$allowed = $this->workflowModel->isTransitionAllowed(
$currentTicket['status'],
$updateData['status'],
$this->isAdmin
);
if (!$allowed) {
return [
'success' => false,
'error' => 'Status transition not allowed: ' . $currentTicket['status'] . ' → ' . $updateData['status']
];
}
// Enforce requires_comment transitions server-side.
if ($this->workflowModel->transitionRequiresComment($currentTicket['status'], $updateData['status'])) {
$statusChangeComment = trim((string)($data['comment'] ?? $data['comment_text'] ?? ''));
if ($statusChangeComment === '') {
return [
'success' => false,
'error' => 'A comment is required for this status change',
'requires_comment' => true,
'http_status' => 400
];
}
}
}
// A comment accompanying a status change (required or optional) is
// persisted in the SAME transaction as the status update below, so
// a failure partway through can't leave an orphaned "reason"
// comment attached with no matching status change — the two
// previously ran as separate, non-transactional HTTP calls from
// the client (add_comment.php then update_ticket.php).
$statusChangeComment = $statusChangeComment ?? trim((string)($data['comment'] ?? $data['comment_text'] ?? ''));
$result = null;
$this->conn->begin_transaction();
try {
if ($statusChangeComment !== '' && $currentTicket['status'] !== $updateData['status']) {
$commentResult = $this->commentModel->addComment($id, [
'user_name' => $this->currentUser['display_name'] ?? $this->currentUser['username'] ?? 'User',
'comment_text' => $statusChangeComment,
'markdown_enabled' => !empty($data['markdown_enabled']),
], $this->userId);
if (empty($commentResult['success'])) {
throw new Exception($commentResult['error'] ?? 'Failed to add comment');
}
}
// Update ticket with user tracking and optional optimistic locking
$expectedUpdatedAt = $data['expected_updated_at'] ?? null;
$result = $this->ticketModel->updateTicket($updateData, $this->userId, $expectedUpdatedAt);
if (!$result['success']) {
throw new Exception($result['error'] ?? 'Failed to update ticket in database');
}
// Handle visibility update if provided (already validated above)
if (isset($data['visibility'])) {
$visResult = $this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
if (!$visResult) {
throw new Exception('Failed to update ticket visibility');
}
}
$this->conn->commit();
} catch (Exception $e) {
$this->conn->rollback();
$response = ['success' => false, 'error' => $e->getMessage()];
if (is_array($result) && !empty($result['conflict'])) {
$response['conflict'] = true;
$response['current_updated_at'] = $result['current_updated_at'] ?? null;
}
return $response;
}
if (isset($data['visibility']) && $this->userId) {
$this->auditLog->log(
$this->userId,
'update',
'ticket',
(string)$id,
[
'field' => 'visibility',
'from' => $currentTicket['visibility'] ?? 'public',
'to' => $data['visibility'],
'groups' => $visibilityGroups
]
);
}
// Log ticket update to audit log — only the changed fields (delta)
if ($this->userId) {
$trackFields = ['title', 'priority', 'status', 'description', 'category', 'type'];
$delta = [];
foreach ($trackFields as $field) {
$oldVal = (string)($currentTicket[$field] ?? '');
$newVal = (string)($updateData[$field] ?? '');
if ($oldVal !== $newVal) {
$delta[$field] = ['from' => $oldVal, 'to' => $newVal];
}
}
if (!empty($delta)) {
$this->auditLog->logTicketUpdate($this->userId, $id, $delta);
}
}
// Notify on status change (global notify list + watchers)
if ($currentTicket['status'] !== $updateData['status']) {
$changedBy = $this->currentUser['display_name'] ?? $this->currentUser['username'] ?? null;
NotificationHelper::sendStatusChangeNotification(
$id,
$currentTicket['status'],
$updateData['status'],
$updateData['title'],
$changedBy,
$currentTicket['visibility'] ?? 'public'
);
NotificationHelper::notifyWatchers(
$this->conn,
$id,
$updateData['title'],
'status_changed',
['old_status' => $currentTicket['status'], 'new_status' => $updateData['status'], 'changed_by' => $changedBy],
(int)$this->userId,
$currentTicket['visibility'] ?? 'public'
);
}
return [
'success' => true,
'status' => $updateData['status'],
'priority' => $updateData['priority'],
'updated_at' => date('Y-m-d H:i:s'),
'message' => 'Ticket updated successfully',
'csrf_token' => $GLOBALS['newCsrfToken'] ?? null
];
}
}
require_once dirname(__DIR__) . '/controllers/ApiTicketController.php';
// Use centralized database connection
$conn = Database::getConnection();
+33
View File
@@ -0,0 +1,33 @@
{
"name": "lotusguild/tinker-tickets",
"description": "Tinker Tickets. Composer is used ONLY by the MCP endpoint (mcp/); nothing else may require vendor/autoload.php.",
"type": "project",
"license": "proprietary",
"require": {
"php": ">=8.2",
"ext-openssl": "*",
"firebase/php-jwt": "^7.0",
"laminas/laminas-httphandlerrunner": "^2.12",
"mcp/sdk": "0.8.1",
"nyholm/psr7": "^1.8",
"nyholm/psr7-server": "^1.1",
"psr/simple-cache": "^3.0",
"symfony/cache": "^7.3",
"symfony/http-client": "^7.3"
},
"autoload": {
"psr-4": {
"TinkerTickets\\Mcp\\": "mcp/src/"
}
},
"config": {
"platform": {
"php": "8.2.0"
},
"sort-packages": true,
"optimize-autoloader": true,
"allow-plugins": {
"php-http/discovery": false
}
}
}
Generated
+2421
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -102,6 +102,14 @@ $GLOBALS['config'] = [
explode(',', $envVars['ALLOWED_HOSTS'] ?? 'localhost,127.0.0.1')
)),
// MCP endpoint (mcp/server.php). MCP_RESOURCE_URL is this server's
// canonical URL: access tokens must carry it as their audience, so a
// beta token can't be replayed against prod. MCP_OAUTH_ISSUER is the
// Authelia issuer that signs those tokens.
'MCP_RESOURCE_URL' => $envVars['MCP_RESOURCE_URL']
?? (!empty($envVars['APP_DOMAIN']) ? 'https://' . $envVars['APP_DOMAIN'] . '/mcp' : null),
'MCP_OAUTH_ISSUER' => $envVars['MCP_OAUTH_ISSUER'] ?? 'https://auth.lotusguild.org',
// Session settings
'SESSION_TIMEOUT' => 18000, // 5 hours in seconds
'SESSION_REGENERATE_INTERVAL' => 300, // Regenerate session ID every 5 minutes
+275
View File
@@ -0,0 +1,275 @@
<?php
/**
* Partial ticket updates for the web UI (api/update_ticket.php) and the MCP
* update_status tool: workflow-validated status transitions (including
* requires_comment, with the comment persisted in the same transaction),
* priority/field edits, visibility changes (admin or creator only), audit
* log delta, and status-change notifications. Returns result arrays; callers
* own HTTP concerns (sessions, CSRF, response codes) and must invalidate the
* stats cache on success.
*
* Moved verbatim out of api/update_ticket.php so the MCP endpoint runs the
* exact same code path as the web UI (tinker_tickets#111).
*/
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/CommentModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/WorkflowModel.php';
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
class ApiTicketController
{
private $conn;
private $ticketModel;
private $commentModel;
private $auditLog;
private $workflowModel;
private $userId;
private $isAdmin;
private $currentUser;
public function __construct($conn, $userId = null, $isAdmin = false, $currentUser = [])
{
$this->conn = $conn;
$this->ticketModel = new TicketModel($conn);
$this->commentModel = new CommentModel($conn);
$this->auditLog = new AuditLogModel($conn);
$this->workflowModel = new WorkflowModel($conn);
$this->userId = $userId;
$this->isAdmin = $isAdmin;
$this->currentUser = $currentUser;
}
public function update($id, $data)
{
// First, get the current ticket data to fill in missing fields
$currentTicket = $this->ticketModel->getTicketById($id);
if (!$currentTicket) {
return [
'success' => false,
'error' => 'Ticket not found'
];
}
// Visibility check: return 404 for tickets the user cannot access
if (!$this->ticketModel->canUserAccessTicket($currentTicket, $this->currentUser)) {
return [
'success' => false,
'error' => 'Ticket not found',
'http_status' => 404
];
}
// Any authenticated team member can update tickets.
// Admin-only operations (delete, bulk actions) are enforced separately.
// Merge current data with updates, keeping existing values for missing fields
$updateData = [
'ticket_id' => $id,
'title' => $data['title'] ?? $currentTicket['title'],
'description' => $data['description'] ?? $currentTicket['description'],
'category' => $data['category'] ?? $currentTicket['category'],
'type' => $data['type'] ?? $currentTicket['type'],
'status' => $data['status'] ?? $currentTicket['status'],
'priority' => isset($data['priority']) ? (int)$data['priority'] : (int)$currentTicket['priority']
];
// Validate required fields
if (empty($updateData['title'])) {
return [
'success' => false,
'error' => 'Title cannot be empty',
'http_status' => 400
];
}
// Validate priority range
if ($updateData['priority'] < 1 || $updateData['priority'] > 5) {
return [
'success' => false,
'error' => 'Priority must be between 1 and 5',
'http_status' => 400
];
}
// Validate visibility BEFORE any DB write so a bad payload can't leave the
// ticket half-updated (core fields committed but request reported as failed).
$visibilityGroups = null;
if (isset($data['visibility'])) {
$visibilityGroups = $data['visibility_groups'] ?? null;
// Convert array to comma-separated string if needed
if (is_array($visibilityGroups)) {
$visibilityGroups = implode(',', array_map('trim', $visibilityGroups));
}
// Authorization: only an admin or the ticket's creator may change
// visibility. Enforce only when the requested visibility actually
// differs so ordinary edits that re-send the same value aren't blocked.
$currentVisibility = $currentTicket['visibility'] ?? 'public';
$currentGroups = $currentTicket['visibility_groups'] ?? null;
$groupsProvided = array_key_exists('visibility_groups', $data);
$visibilityChanged = ($data['visibility'] !== $currentVisibility)
|| ($groupsProvided && (string)$visibilityGroups !== (string)$currentGroups);
if ($visibilityChanged) {
$isCreator = $this->userId !== null
&& (int)($currentTicket['created_by'] ?? 0) === (int)$this->userId;
if (!$this->isAdmin && !$isCreator) {
return [
'success' => false,
'error' => 'You do not have permission to change ticket visibility',
'http_status' => 403
];
}
}
// Internal visibility requires at least one group
if ($data['visibility'] === 'internal' && (empty($visibilityGroups) || trim($visibilityGroups) === '')) {
return [
'success' => false,
'error' => 'Internal visibility requires at least one group to be specified',
'http_status' => 400
];
}
}
// Validate status transition using workflow model
if ($currentTicket['status'] !== $updateData['status']) {
$allowed = $this->workflowModel->isTransitionAllowed(
$currentTicket['status'],
$updateData['status'],
$this->isAdmin
);
if (!$allowed) {
return [
'success' => false,
'error' => 'Status transition not allowed: ' . $currentTicket['status'] . ' → ' . $updateData['status']
];
}
// Enforce requires_comment transitions server-side.
if ($this->workflowModel->transitionRequiresComment($currentTicket['status'], $updateData['status'])) {
$statusChangeComment = trim((string)($data['comment'] ?? $data['comment_text'] ?? ''));
if ($statusChangeComment === '') {
return [
'success' => false,
'error' => 'A comment is required for this status change',
'requires_comment' => true,
'http_status' => 400
];
}
}
}
// A comment accompanying a status change (required or optional) is
// persisted in the SAME transaction as the status update below, so
// a failure partway through can't leave an orphaned "reason"
// comment attached with no matching status change — the two
// previously ran as separate, non-transactional HTTP calls from
// the client (add_comment.php then update_ticket.php).
$statusChangeComment = $statusChangeComment ?? trim((string)($data['comment'] ?? $data['comment_text'] ?? ''));
$result = null;
$this->conn->begin_transaction();
try {
if ($statusChangeComment !== '' && $currentTicket['status'] !== $updateData['status']) {
$commentResult = $this->commentModel->addComment($id, [
'user_name' => $this->currentUser['display_name'] ?? $this->currentUser['username'] ?? 'User',
'comment_text' => $statusChangeComment,
'markdown_enabled' => !empty($data['markdown_enabled']),
], $this->userId);
if (empty($commentResult['success'])) {
throw new Exception($commentResult['error'] ?? 'Failed to add comment');
}
}
// Update ticket with user tracking and optional optimistic locking
$expectedUpdatedAt = $data['expected_updated_at'] ?? null;
$result = $this->ticketModel->updateTicket($updateData, $this->userId, $expectedUpdatedAt);
if (!$result['success']) {
throw new Exception($result['error'] ?? 'Failed to update ticket in database');
}
// Handle visibility update if provided (already validated above)
if (isset($data['visibility'])) {
$visResult = $this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
if (!$visResult) {
throw new Exception('Failed to update ticket visibility');
}
}
$this->conn->commit();
} catch (Exception $e) {
$this->conn->rollback();
$response = ['success' => false, 'error' => $e->getMessage()];
if (is_array($result) && !empty($result['conflict'])) {
$response['conflict'] = true;
$response['current_updated_at'] = $result['current_updated_at'] ?? null;
}
return $response;
}
if (isset($data['visibility']) && $this->userId) {
$this->auditLog->log(
$this->userId,
'update',
'ticket',
(string)$id,
[
'field' => 'visibility',
'from' => $currentTicket['visibility'] ?? 'public',
'to' => $data['visibility'],
'groups' => $visibilityGroups
]
);
}
// Log ticket update to audit log — only the changed fields (delta)
if ($this->userId) {
$trackFields = ['title', 'priority', 'status', 'description', 'category', 'type'];
$delta = [];
foreach ($trackFields as $field) {
$oldVal = (string)($currentTicket[$field] ?? '');
$newVal = (string)($updateData[$field] ?? '');
if ($oldVal !== $newVal) {
$delta[$field] = ['from' => $oldVal, 'to' => $newVal];
}
}
if (!empty($delta)) {
$this->auditLog->logTicketUpdate($this->userId, $id, $delta);
}
}
// Notify on status change (global notify list + watchers)
if ($currentTicket['status'] !== $updateData['status']) {
$changedBy = $this->currentUser['display_name'] ?? $this->currentUser['username'] ?? null;
NotificationHelper::sendStatusChangeNotification(
$id,
$currentTicket['status'],
$updateData['status'],
$updateData['title'],
$changedBy,
$currentTicket['visibility'] ?? 'public'
);
NotificationHelper::notifyWatchers(
$this->conn,
$id,
$updateData['title'],
'status_changed',
['old_status' => $currentTicket['status'], 'new_status' => $updateData['status'], 'changed_by' => $changedBy],
(int)$this->userId,
$currentTicket['visibility'] ?? 'public'
);
}
return [
'success' => true,
'status' => $updateData['status'],
'priority' => $updateData['priority'],
'updated_at' => date('Y-m-d H:i:s'),
'message' => 'Ticket updated successfully',
'csrf_token' => $GLOBALS['newCsrfToken'] ?? null
];
}
}
+14 -102
View File
@@ -101,115 +101,27 @@ class TicketController
return;
}
// Handle visibility groups (comes as array from checkboxes)
$visibilityGroups = null;
if (isset($_POST['visibility_groups']) && is_array($_POST['visibility_groups'])) {
$visibilityGroups = implode(',', array_map('trim', $_POST['visibility_groups']));
}
// Honor the posted status, validated against the app's canonical list
$validStatuses = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed'];
$status = $_POST['status'] ?? 'Open';
if (!in_array($status, $validStatuses, true)) {
$status = 'Open';
}
$ticketData = [
'title' => trim($_POST['title'] ?? ''),
// Validation (incl. required custom fields), creation, audit log,
// stats cache, custom field values, duplicate link and notification
// live in TicketCreationService so the MCP create_ticket tool runs
// the same code path.
require_once dirname(__DIR__) . '/services/TicketCreationService.php';
$result = TicketCreationService::create($this->conn, $currentUser ?? [], [
'title' => $_POST['title'] ?? '',
'description' => $_POST['description'] ?? '',
'priority' => $_POST['priority'] ?? '4',
'category' => $_POST['category'] ?? 'General',
'type' => $_POST['type'] ?? 'Issue',
'status' => $status,
'status' => $_POST['status'] ?? 'Open',
'visibility' => $_POST['visibility'] ?? 'public',
'visibility_groups' => $visibilityGroups,
'assigned_to' => !empty($_POST['assigned_to']) ? $_POST['assigned_to'] : null
];
// Validate input (server-side; form is novalidate)
if ($ticketData['title'] === '') {
$error = "Title is required";
$templates = $this->templateModel->getAllTemplates();
$allUsers = $this->userModel->getAllUsers();
$conn = $this->conn; // Make $conn available to view
include dirname(__DIR__) . '/views/CreateTicketView.php';
return;
}
if (trim($ticketData['description']) === '') {
$error = "Description is required";
$templates = $this->templateModel->getAllTemplates();
$allUsers = $this->userModel->getAllUsers();
$conn = $this->conn; // Make $conn available to view
include dirname(__DIR__) . '/views/CreateTicketView.php';
return;
}
// Custom fields applicable to the submitted category — validate
// is_required server-side (the form is novalidate, and a field
// hidden by the client-side category toggle must not silently
// bypass a requirement that applies to the category actually
// submitted).
$submittedCustomFields = is_array($_POST['custom_fields'] ?? null) ? $_POST['custom_fields'] : [];
$applicableFieldDefs = array_filter(
$allCustomFieldDefs,
fn($def) => $def['category'] === null || $def['category'] === $ticketData['category']
);
$customFieldsToSave = [];
foreach ($applicableFieldDefs as $def) {
$fieldId = (int)$def['field_id'];
$raw = $submittedCustomFields[$fieldId] ?? null;
$normalized = $def['field_type'] === 'checkbox'
? (!empty($raw) ? '1' : '0')
: (is_scalar($raw) ? trim((string)$raw) : '');
if (!empty($def['is_required']) && $def['field_type'] !== 'checkbox' && $normalized === '') {
$error = $def['field_label'] . ' is required';
$templates = $this->templateModel->getAllTemplates();
$allUsers = $this->userModel->getAllUsers();
$conn = $this->conn;
include dirname(__DIR__) . '/views/CreateTicketView.php';
return;
}
if ($normalized !== '') {
$customFieldsToSave[$fieldId] = $normalized;
}
}
// Create ticket with user tracking
$result = $this->ticketModel->createTicket($ticketData, $userId);
'visibility_groups' => (isset($_POST['visibility_groups']) && is_array($_POST['visibility_groups']))
? $_POST['visibility_groups'] : null,
'assigned_to' => $_POST['assigned_to'] ?? null,
'custom_fields' => $_POST['custom_fields'] ?? [],
'link_duplicate_of' => $_POST['link_duplicate_of'] ?? '',
]);
if ($result['success']) {
// Log ticket creation to audit log
if (isset($GLOBALS['auditLog']) && $userId) {
$GLOBALS['auditLog']->logTicketCreate($userId, $result['ticket_id'], $ticketData);
}
// Ticket counts changed — invalidate the cached dashboard stats
require_once dirname(__DIR__) . '/models/StatsModel.php';
(new StatsModel($this->conn))->invalidateCache();
// Persist custom field values for the fields applicable to
// this ticket's category
if (!empty($customFieldsToSave)) {
$this->customFieldModel->setValues($result['ticket_id'], $customFieldsToSave);
}
// Auto-link as duplicate if requested from create form
$linkDupOfRaw = trim($_POST['link_duplicate_of'] ?? '');
if ($linkDupOfRaw !== '' && ctype_digit($linkDupOfRaw)) {
$depSql = "INSERT IGNORE INTO ticket_dependencies (ticket_id, depends_on_id, dependency_type, created_by)
VALUES (?, ?, 'duplicates', ?)";
$depStmt = $this->conn->prepare($depSql);
$depStmt->bind_param("ssi", $result['ticket_id'], $linkDupOfRaw, $userId);
$depStmt->execute();
$depStmt->close();
}
// Send Matrix notification for new ticket
NotificationHelper::sendTicketNotification($result['ticket_id'], $ticketData, 'manual');
// Redirect to the new ticket
header("Location: " . $GLOBALS['config']['BASE_URL'] . "/ticket/" . $result['ticket_id']);
exit;
+31
View File
@@ -0,0 +1,31 @@
<?php
/**
* Who may use Tinker Tickets at all. Shared by the web login
* (AuthMiddleware, from Authelia's Remote-Groups header) and the MCP endpoint
* (from the OAuth access token's groups claim), so both entry points enforce
* one rule instead of two copies that can drift apart.
*/
class AccessPolicy
{
/** Membership in any of these grants access. */
private const REQUIRED_GROUPS = ['admin', 'employee'];
/**
* @param string $groups Comma-separated group names (Remote-Groups format)
*/
public static function hasAppAccess(string $groups): bool
{
if ($groups === '') {
return false;
}
// Filter to safe characters only to prevent header injection attacks
$userGroups = array_filter(
array_map('trim', explode(',', strtolower($groups))),
fn($g) => preg_match('/^[a-z0-9_\-]+$/', $g)
);
return !empty(array_intersect($userGroups, self::REQUIRED_GROUPS));
}
}
+159
View File
@@ -0,0 +1,159 @@
<?php
/**
* MCP endpoint (Streamable HTTP), OAuth-protected by Authelia. See issue #111.
*
* Serves /mcp and the RFC 9728 Protected Resource Metadata paths (nginx routes
* all of them here). This is the ONLY file allowed to load vendor/autoload.php.
*
* Identity comes exclusively from the validated access token. Never read
* Remote-User / Remote-* headers or $_SESSION for identity here: this location
* is exempt from Authelia forward-auth at the proxy, so those headers are
* client-controlled on this path.
*/
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api', false);
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/vendor/autoload.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/helpers/AccessPolicy.php';
require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
require_once dirname(__DIR__) . '/models/UserModel.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/CommentModel.php';
require_once dirname(__DIR__) . '/models/StatsModel.php';
require_once dirname(__DIR__) . '/controllers/ApiTicketController.php';
require_once dirname(__DIR__) . '/services/TicketCreationService.php';
require_once dirname(__DIR__) . '/services/CommentService.php';
require_once dirname(__DIR__) . '/services/AssignmentService.php';
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
use Mcp\Server;
use Mcp\Server\Session\FileSessionStore;
use Mcp\Server\Transport\Http\Middleware\AuthorizationMiddleware;
use Mcp\Server\Transport\Http\Middleware\CorsMiddleware;
use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware;
use Mcp\Server\Transport\Http\Middleware\ProtectedResourceMetadataMiddleware;
use Mcp\Server\Transport\Http\OAuth\JwksProvider;
use Mcp\Server\Transport\Http\OAuth\JwtTokenValidator;
use Mcp\Server\Transport\Http\OAuth\OidcDiscovery;
use Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadata;
use Mcp\Server\Transport\StreamableHttpTransport;
use Nyholm\Psr7\Factory\Psr17Factory;
use Nyholm\Psr7Server\ServerRequestCreator;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Symfony\Component\Cache\Psr16Cache;
use TinkerTickets\Mcp\Auth\IdentityMiddleware;
use TinkerTickets\Mcp\Auth\ToolScopeMiddleware;
use TinkerTickets\Mcp\ToolCatalog;
$resourceUrl = $GLOBALS['config']['MCP_RESOURCE_URL'] ?? null;
$issuer = $GLOBALS['config']['MCP_OAUTH_ISSUER'] ?? null;
if (empty($resourceUrl) || empty($issuer)) {
http_response_code(503);
header('Content-Type: application/json');
echo json_encode(['error' => 'MCP endpoint is not configured (MCP_RESOURCE_URL / MCP_OAUTH_ISSUER)']);
exit;
}
$psr17 = new Psr17Factory();
$request = (new ServerRequestCreator($psr17, $psr17, $psr17, $psr17))->fromGlobals();
// ServerRequestCreator adds Host both from the URI and from the request
// headers, so getHeaderLine('Host') comes back as "h, h" under PHP-FPM, which
// the DNS-rebinding check below then rejects. Collapse to the single value the
// client actually sent.
$clientHost = $request->getHeader('Host')[0] ?? '';
if ($clientHost !== '') {
$request = $request->withHeader('Host', $clientHost);
}
// TLS terminates at the reverse proxy, so PHP sees plain http and a Host header
// the client controls. The SDK derives the resource_metadata URL in its 401
// challenge from the request URI, so pin scheme/host/port to the configured
// canonical URL instead of anything the request claims. preserveHost keeps
// the client's real Host header for the DNS-rebinding check below; without
// it withUri() would overwrite Host and make that check a no-op.
$canonical = parse_url($resourceUrl);
$request = $request->withUri(
$request->getUri()
->withScheme($canonical['scheme'])
->withHost($canonical['host'])
->withPort($canonical['port'] ?? null),
true
);
// Cache OIDC discovery + JWKS so every MCP call isn't two extra round trips to
// Authelia. Outside the webroot on purpose.
$cache = new Psr16Cache(new FilesystemAdapter('tinker_mcp', 3600, sys_get_temp_dir() . '/tinker_mcp_cache'));
$validator = new JwtTokenValidator(
issuer: $issuer,
audience: $resourceUrl,
jwksProvider: new JwksProvider(new OidcDiscovery(cache: $cache), cache: $cache),
// Authelia puts scopes in an `scp` array, not the standard `scope` string
// (verified in #111 phase 1). With the default, every scope check fails.
scopeClaim: 'scp',
);
$resourcePath = $canonical['path'] ?? '';
$metadata = new ProtectedResourceMetadata(
authorizationServers: [$issuer],
scopesSupported: ['tickets:read', 'tickets:write'],
resource: $resourceUrl,
resourceName: 'Tinker Tickets',
// RFC 9728 path-suffixed form first (used in the WWW-Authenticate
// challenge), plus the root form some clients probe.
metadataPaths: array_values(array_unique([
'/.well-known/oauth-protected-resource' . $resourcePath,
'/.well-known/oauth-protected-resource',
])),
);
$conn = Database::getConnection();
$server = ToolCatalog::register(
Server::builder()
->setServerInfo('Tinker Tickets', '1.0.0')
// Handshake-era clients (pre-2026-07-28) still use protocol sessions.
->setSession(new FileSessionStore(sys_get_temp_dir() . '/tinker_mcp_sessions')),
$conn
)->build();
$transport = new StreamableHttpTransport(
$request,
middleware: [
// CORS: SDK default (no Access-Control-Allow-Origin, so cross-origin
// browser calls are refused). Host allowlist: only the canonical
// hostname, which also refuses direct-by-IP access.
new CorsMiddleware(),
new DnsRebindingProtectionMiddleware([$canonical['host']]),
new ProtectedResourceMetadataMiddleware($metadata),
new AuthorizationMiddleware($validator, $metadata),
// Identity comes from the validated token's server-side request
// attributes, never from client-writable JSON-RPC `_meta` (so the
// SDK's OAuthRequestMetaMiddleware is intentionally not used).
new IdentityMiddleware($conn, $psr17, $psr17),
new ToolScopeMiddleware(
$psr17,
$canonical['scheme'] . '://' . $canonical['host'] . $metadata->getPrimaryMetadataPath()
),
],
);
try {
$response = $server->run($transport);
} catch (\Throwable $e) {
error_log('mcp/server.php: ' . $e::class . ': ' . $e->getMessage());
$response = $psr17->createResponse(500)
->withHeader('Content-Type', 'application/json')
->withBody($psr17->createStream(json_encode([
'jsonrpc' => '2.0',
'id' => null,
'error' => ['code' => -32603, 'message' => 'Internal error'],
])));
}
(new SapiEmitter())->emit($response);
+78
View File
@@ -0,0 +1,78 @@
<?php
namespace TinkerTickets\Mcp\Auth;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
/**
* Maps a validated access token to a Tinker Tickets user, applying exactly
* the same rules as the web login (AuthMiddleware): the shared
* AccessPolicy::hasAppAccess() group check, then
* UserModel::syncUserFromAuthelia() to create/update the user row and derive
* is_admin from groups.
*
* Must run after the SDK's AuthorizationMiddleware, which has already
* verified the token's signature, issuer, audience and expiry and attached
* its claims as `oauth.claims` / `oauth.scopes` request attributes.
*/
final class IdentityMiddleware implements MiddlewareInterface
{
public function __construct(
private readonly \mysqli $conn,
private readonly ResponseFactoryInterface $responseFactory,
private readonly StreamFactoryInterface $streamFactory,
) {
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$claims = $request->getAttribute('oauth.claims');
$scopes = $request->getAttribute('oauth.scopes') ?? [];
if (!is_array($claims)) {
// Only reachable if the stack is misordered; never serve anonymously.
return $this->deny(500, 'Identity unavailable');
}
$username = is_string($claims['preferred_username'] ?? null) ? trim($claims['preferred_username']) : '';
if ($username === '') {
return $this->deny(403, 'Access token has no preferred_username claim');
}
$groupsClaim = $claims['groups'] ?? [];
$groups = implode(',', array_filter(
is_array($groupsClaim) ? $groupsClaim : [$groupsClaim],
'is_string'
));
if (!\AccessPolicy::hasAppAccess($groups)) {
return $this->deny(403, 'Your account is not permitted to use Tinker Tickets');
}
$user = (new \UserModel($this->conn))->syncUserFromAuthelia(
$username,
is_string($claims['name'] ?? null) ? $claims['name'] : '',
is_string($claims['email'] ?? null) ? $claims['email'] : '',
$groups
);
McpIdentity::set($user, array_values(array_filter($scopes, 'is_string')));
return $handler->handle($request);
}
private function deny(int $status, string $message): ResponseInterface
{
return $this->responseFactory->createResponse($status)
->withHeader('Content-Type', 'application/json')
->withBody($this->streamFactory->createStream(json_encode([
'jsonrpc' => '2.0',
'id' => null,
'error' => ['code' => -32001, 'message' => $message],
])));
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace TinkerTickets\Mcp\Auth;
/**
* The Tinker Tickets user an MCP request runs as, plus the token's scopes.
*
* Set once per HTTP request by IdentityMiddleware from the *validated* access
* token, which is held as server-side PSR-7 request attributes. It is
* deliberately NOT read from the JSON-RPC `_meta` field: clients can write
* to `_meta`, and the SDK's OAuthRequestMetaMiddleware only overwrites the
* keys the validator happens to set.
*
* Static state is request-scoped here: PHP-FPM serves one HTTP request per
* process lifecycle, and the whole MCP call (including SDK fibers) runs
* inside it.
*/
final class McpIdentity
{
public const SCOPE_READ = 'tickets:read';
public const SCOPE_WRITE = 'tickets:write';
private static ?array $user = null;
/** @var list<string> */
private static array $scopes = [];
/**
* @param array<string, mixed> $user
* @param list<string> $scopes
*/
public static function set(array $user, array $scopes): void
{
self::$user = $user;
self::$scopes = $scopes;
}
/**
* @return array<string, mixed>
*/
public static function user(): array
{
if (self::$user === null) {
// Unreachable if the middleware stack is intact; fail closed.
throw new \LogicException('MCP identity requested before IdentityMiddleware ran');
}
return self::$user;
}
/** tickets:write implies tickets:read (scope hierarchy, MCP spec §Scope Challenge Handling). */
public static function canRead(array $scopes): bool
{
return in_array(self::SCOPE_READ, $scopes, true) || in_array(self::SCOPE_WRITE, $scopes, true);
}
public static function canWrite(array $scopes): bool
{
return in_array(self::SCOPE_WRITE, $scopes, true);
}
}
+91
View File
@@ -0,0 +1,91 @@
<?php
namespace TinkerTickets\Mcp\Auth;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use TinkerTickets\Mcp\ToolCatalog;
/**
* Per-operation scope enforcement, before anything is dispatched.
*
* - Lifecycle messages (initialize, ping, notifications/*, server/discover)
* need only a valid token.
* - tools/call on a write tool needs tickets:write.
* - Everything else (tools/list, read tools, ...) needs tickets:read, which
* tickets:write implies.
*
* Denials use the MCP spec's step-up challenge: HTTP 403 +
* WWW-Authenticate: Bearer error="insufficient_scope", listing every scope
* the request needs in one go.
*/
final class ToolScopeMiddleware implements MiddlewareInterface
{
private const LIFECYCLE_METHODS = ['initialize', 'ping', 'server/discover'];
public function __construct(
private readonly ResponseFactoryInterface $responseFactory,
private readonly string $resourceMetadataUrl,
) {
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
if ($request->getMethod() !== 'POST') {
return $handler->handle($request);
}
$payload = json_decode((string)$request->getBody(), true);
$request->getBody()->rewind();
if (!is_array($payload)) {
return $handler->handle($request); // the SDK answers malformed JSON-RPC itself
}
$messages = array_is_list($payload) ? $payload : [$payload];
$needWrite = false;
$needRead = false;
foreach ($messages as $message) {
if (!is_array($message) || !is_string($message['method'] ?? null)) {
continue; // responses to server->client requests carry no method
}
$method = $message['method'];
if (in_array($method, self::LIFECYCLE_METHODS, true) || str_starts_with($method, 'notifications/')) {
continue;
}
if ($method === 'tools/call' && ToolCatalog::isWriteTool((string)($message['params']['name'] ?? ''))) {
$needWrite = true;
} else {
$needRead = true;
}
}
$scopes = $request->getAttribute('oauth.scopes') ?? [];
if ($needWrite && !McpIdentity::canWrite($scopes)) {
return $this->insufficient(McpIdentity::SCOPE_WRITE, 'This operation requires the tickets:write scope.');
}
if ($needRead && !McpIdentity::canRead($scopes)) {
return $this->insufficient(
$needWrite ? McpIdentity::SCOPE_WRITE : McpIdentity::SCOPE_READ,
'This operation requires the tickets:read scope.'
);
}
return $handler->handle($request);
}
private function insufficient(string $scope, string $description): ResponseInterface
{
return $this->responseFactory->createResponse(403)->withHeader(
'WWW-Authenticate',
sprintf(
'Bearer error="insufficient_scope", scope="%s", resource_metadata="%s", error_description="%s"',
$scope,
$this->resourceMetadataUrl,
$description
)
);
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace TinkerTickets\Mcp;
use Mcp\Schema\ToolAnnotations;
use Mcp\Server\Builder;
use TinkerTickets\Mcp\Tools\TicketReadTools;
use TinkerTickets\Mcp\Tools\TicketWriteTools;
/**
* The single list of MCP tools: registration, plus which ones need the
* tickets:write scope (read by ToolScopeMiddleware). Keeping both here means
* a new write tool can't be registered without also being scope-gated.
*/
final class ToolCatalog
{
/** Tool names that mutate data and require tickets:write. */
private const WRITE_TOOLS = ['create_ticket', 'add_comment', 'update_status', 'assign_ticket'];
public static function isWriteTool(string $name): bool
{
return in_array($name, self::WRITE_TOOLS, true);
}
public static function register(Builder $builder, \mysqli $conn): Builder
{
$read = new TicketReadTools($conn);
$write = new TicketWriteTools($conn);
$readOnly = new ToolAnnotations(readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false);
// Writes change tickets (and notify people) but never delete anything.
$additive = new ToolAnnotations(readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false);
$update = new ToolAnnotations(readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false);
return $builder
->addTool([$read, 'searchTickets'], 'search_tickets', 'Search tickets', null, $readOnly)
->addTool([$read, 'getTicket'], 'get_ticket', 'Get ticket', null, $readOnly)
->addTool([$write, 'createTicket'], 'create_ticket', 'Create ticket', null, $additive)
->addTool([$write, 'addComment'], 'add_comment', 'Add comment', null, $additive)
->addTool([$write, 'updateStatus'], 'update_status', 'Update ticket status', null, $update)
->addTool([$write, 'assignTicket'], 'assign_ticket', 'Assign ticket', null, $update);
}
}
+183
View File
@@ -0,0 +1,183 @@
<?php
namespace TinkerTickets\Mcp\Tools;
use Mcp\Exception\ToolCallException;
use TinkerTickets\Mcp\Auth\McpIdentity;
/**
* Read-only tools. Everything goes through the same model methods and
* visibility checks the web UI uses, as the signed-in user, so MCP can never
* show more than that user could see in their browser.
*/
final class TicketReadTools
{
private const MAX_COMMENTS = 200;
public function __construct(private readonly \mysqli $conn)
{
}
/**
* Search and list tickets you can see (same visibility rules as the web UI).
*
* @param string|null $query Free-text search over ticket titles and descriptions.
* @param string|null $status Comma-separated statuses, e.g. "Open,In Progress". Omit for every status except Closed; use "all" for every status.
* @param int|null $priority Exact priority: 1 (critical) to 5 (minimal).
* @param string|null $category Exact category name.
* @param string|null $assignee "me", "unassigned", or a username.
* @param int $page Page number, starting at 1.
* @param int $limit Tickets per page, 1-50.
*
* @return array<string, mixed>
*/
public function searchTickets(
?string $query = null,
?string $status = null,
?int $priority = null,
?string $category = null,
?string $assignee = null,
int $page = 1,
int $limit = 20,
): array {
$user = McpIdentity::user();
$allStatuses = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed'];
if ($status === null || trim($status) === '') {
$statusFilter = implode(',', array_filter($allStatuses, fn($s) => $s !== 'Closed'));
} elseif (strtolower(trim($status)) === 'all') {
$statusFilter = null;
} else {
$requested = array_map('trim', explode(',', $status));
$unknown = array_diff($requested, $allStatuses);
if ($unknown) {
throw new ToolCallException('Unknown status: ' . implode(', ', $unknown)
. '. Valid statuses: ' . implode(', ', $allStatuses));
}
$statusFilter = implode(',', $requested);
}
$filters = [];
if ($priority !== null) {
if ($priority < 1 || $priority > 5) {
throw new ToolCallException('priority must be between 1 and 5');
}
$filters['priority_min'] = $priority;
$filters['priority_max'] = $priority;
}
if ($assignee !== null && trim($assignee) !== '') {
$filters['assigned_to'] = $this->resolveAssigneeFilter(trim($assignee), $user);
}
$page = max(1, $page);
$limit = min(50, max(1, $limit));
$result = (new \TicketModel($this->conn))->getAllTickets(
$page,
$limit,
$statusFilter,
'updated_at',
'desc',
($category !== null && trim($category) !== '') ? trim($category) : null,
null,
($query !== null && trim($query) !== '') ? trim($query) : null,
$filters,
$user
);
return [
'tickets' => array_map([$this, 'summarize'], $result['tickets']),
'page' => $result['current_page'],
'pages' => $result['pages'],
'total' => $result['total'],
];
}
/**
* Get one ticket's full details and its comments.
*
* @param string $ticket_id The ticket ID (digits only, e.g. "123456789").
* @param bool $include_comments Include the ticket's comments, newest first.
*
* @return array<string, mixed>
*/
public function getTicket(string $ticket_id, bool $include_comments = true): array
{
$user = McpIdentity::user();
$ticketModel = new \TicketModel($this->conn);
$ticketId = trim($ticket_id);
$ticket = preg_match('/^\d+$/', $ticketId) ? $ticketModel->getTicketById($ticketId) : null;
// Same "not found" for missing and not-visible, so a restricted
// ticket's existence isn't revealed.
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $user)) {
throw new ToolCallException("Ticket {$ticketId} not found");
}
$details = $this->summarize($ticket) + [
'description' => $ticket['description'] ?? '',
'visibility_groups' => $ticket['visibility_groups'] ?? null,
'closed_at' => $ticket['closed_at'] ?? null,
'updated_by' => $ticket['updater_display_name'] ?? $ticket['updater_username'] ?? null,
];
if ($include_comments) {
$comments = (new \CommentModel($this->conn))->getCommentsByTicketId($ticketId, false);
$details['comment_count'] = count($comments);
$details['comments'] = array_map(fn(array $c) => [
'comment_id' => (int)$c['comment_id'],
'author' => $c['display_name'] ?? $c['username'] ?? $c['user_name'] ?? null,
'created_at' => $c['created_at'] ?? null,
'reply_to' => isset($c['parent_comment_id']) ? (int)$c['parent_comment_id'] : null,
'text' => $c['comment_text'] ?? '',
], array_slice($comments, 0, self::MAX_COMMENTS));
if (count($comments) > self::MAX_COMMENTS) {
$details['comments_truncated'] = true;
}
}
return $details;
}
/**
* @param array<string, mixed> $user
*/
private function resolveAssigneeFilter(string $assignee, array $user): int|string
{
$lower = strtolower($assignee);
if ($lower === 'me') {
return (int)$user['user_id'];
}
if ($lower === 'unassigned') {
return 'unassigned';
}
$match = (new \UserModel($this->conn))->getUserByUsername($assignee);
if (!$match) {
throw new ToolCallException("Unknown user: {$assignee}");
}
return (int)$match['user_id'];
}
/**
* @param array<string, mixed> $t
*
* @return array<string, mixed>
*/
private function summarize(array $t): array
{
return [
'ticket_id' => (string)$t['ticket_id'],
'title' => $t['title'] ?? '',
'status' => $t['status'] ?? null,
'priority' => isset($t['priority']) ? (int)$t['priority'] : null,
'category' => $t['category'] ?? null,
'type' => $t['type'] ?? null,
'visibility' => $t['visibility'] ?? 'public',
'assigned_to' => $t['assigned_display_name'] ?? $t['assigned_username'] ?? null,
'created_by' => $t['creator_display_name'] ?? $t['creator_username'] ?? null,
'created_at' => $t['created_at'] ?? null,
'updated_at' => $t['updated_at'] ?? null,
'url' => \UrlHelper::ticketUrl((string)$t['ticket_id']),
];
}
}
+216
View File
@@ -0,0 +1,216 @@
<?php
namespace TinkerTickets\Mcp\Tools;
use Mcp\Exception\ToolCallException;
use TinkerTickets\Mcp\Auth\McpIdentity;
/**
* Write tools. Each one is a thin adapter over the exact code path the web UI
* uses (TicketCreationService, CommentService, ApiTicketController,
* AssignmentService), run as the signed-in user, so permissions, workflow
* rules, audit entries, notifications and stats-cache invalidation are
* identical. Gated by tickets:write in ToolScopeMiddleware (see ToolCatalog).
*/
final class TicketWriteTools
{
private const VISIBILITIES = ['public', 'internal', 'confidential'];
public function __construct(private readonly \mysqli $conn)
{
}
/**
* Create a new ticket as you. It starts in the Open status.
*
* @param string $title Short summary of the issue.
* @param string $description Full description (markdown supported).
* @param int $priority 1 (critical) to 5 (minimal). Default 4.
* @param string $category Ticket category, e.g. "General", "Hardware", "Network".
* @param string $type Ticket type, e.g. "Issue", "Task", "Request".
* @param string $visibility "public" (everyone), "internal" (only the listed groups), or "confidential" (only you, the assignee and admins).
* @param string|null $visibility_groups Comma-separated group names; required when visibility is "internal".
* @param string|null $assignee Username to assign to, or "me". Omit to leave unassigned.
*
* @return array<string, mixed>
*/
public function createTicket(
string $title,
string $description,
int $priority = 4,
string $category = 'General',
string $type = 'Issue',
string $visibility = 'public',
?string $visibility_groups = null,
?string $assignee = null,
): array {
$user = McpIdentity::user();
// The web form constrains these with dropdowns; an API caller can send
// anything, so validate them here before handing off.
if ($priority < 1 || $priority > 5) {
throw new ToolCallException('priority must be between 1 and 5');
}
if (!in_array($visibility, self::VISIBILITIES, true)) {
throw new ToolCallException('visibility must be one of: ' . implode(', ', self::VISIBILITIES));
}
$assignedTo = null;
if ($assignee !== null && trim($assignee) !== '') {
$assignedTo = $this->resolveUserId(trim($assignee), $user);
}
$result = \TicketCreationService::create($this->conn, $user, [
'title' => $title,
'description' => $description,
'priority' => (string)$priority,
'category' => $category,
'type' => $type,
'status' => 'Open',
'visibility' => $visibility,
'visibility_groups' => $visibility_groups,
'assigned_to' => $assignedTo,
]);
if (!$result['success']) {
throw new ToolCallException($result['error']);
}
return [
'ticket_id' => (string)$result['ticket_id'],
'url' => \UrlHelper::ticketUrl((string)$result['ticket_id']),
];
}
/**
* Post a comment on a ticket as you. @username mentions notify that user.
*
* @param string $ticket_id The ticket ID.
* @param string $text The comment text.
* @param bool $markdown Render the comment as markdown.
* @param int|null $reply_to comment_id of the comment you are replying to, if any.
*
* @return array<string, mixed>
*/
public function addComment(string $ticket_id, string $text, bool $markdown = true, ?int $reply_to = null): array
{
$user = McpIdentity::user();
$result = \CommentService::addComment($this->conn, $user, [
'ticket_id' => $ticket_id,
'comment_text' => $text,
'markdown_enabled' => $markdown,
'parent_comment_id' => $reply_to,
]);
if (empty($result['success'])) {
throw new ToolCallException($this->errorMessage($result, $ticket_id));
}
return [
'comment_id' => (int)$result['comment_id'],
'ticket_id' => trim($ticket_id),
'mentions' => $result['mentions'] ?? [],
];
}
/**
* Change a ticket's status. Transitions follow the Workflow Designer rules;
* some transitions (e.g. closing) require a comment, which is posted as the reason.
*
* @param string $ticket_id The ticket ID.
* @param string $status New status, e.g. "Open", "Pending", "In Progress", "Closed".
* @param string|null $comment Reason for the change; required for transitions that need one.
*
* @return array<string, mixed>
*/
public function updateStatus(string $ticket_id, string $status, ?string $comment = null): array
{
$user = McpIdentity::user();
$validStatuses = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed'];
if (!in_array($status, $validStatuses, true)) {
throw new ToolCallException('Unknown status. Valid statuses: ' . implode(', ', $validStatuses));
}
$ticketId = trim($ticket_id);
if (!ctype_digit($ticketId)) {
throw new ToolCallException("Ticket {$ticketId} not found");
}
$controller = new \ApiTicketController($this->conn, (int)$user['user_id'], !empty($user['is_admin']), $user);
$data = ['status' => $status];
if ($comment !== null && trim($comment) !== '') {
$data['comment'] = $comment;
$data['markdown_enabled'] = true;
}
$result = $controller->update($ticketId, $data);
if (empty($result['success'])) {
if (!empty($result['requires_comment'])) {
throw new ToolCallException('This status change requires a comment explaining why. Call update_status again with a comment.');
}
throw new ToolCallException($this->errorMessage($result, $ticketId));
}
// api/update_ticket.php invalidates the dashboard stats cache after a
// successful update (outside the controller); do the same here.
(new \StatsModel($this->conn))->invalidateCache();
return ['ticket_id' => $ticketId, 'status' => $result['status']];
}
/**
* Assign a ticket to someone, or unassign it. Only admins, the ticket's
* creator, or its current assignee may do this.
*
* @param string $ticket_id The ticket ID.
* @param string $assignee Username to assign to, "me", or "unassigned".
*
* @return array<string, mixed>
*/
public function assignTicket(string $ticket_id, string $assignee): array
{
$user = McpIdentity::user();
$target = trim($assignee);
$assignedTo = strtolower($target) === 'unassigned' || $target === ''
? null
: $this->resolveUserId($target, $user);
$result = \AssignmentService::assign($this->conn, $user, [
'ticket_id' => $ticket_id,
'assigned_to' => $assignedTo,
]);
if (empty($result['success'])) {
throw new ToolCallException($this->errorMessage($result, $ticket_id));
}
return ['ticket_id' => trim($ticket_id), 'assigned_to' => $assignedTo === null ? null : $target];
}
/**
* @param array<string, mixed> $user
*/
private function resolveUserId(string $username, array $user): int
{
if (strtolower($username) === 'me') {
return (int)$user['user_id'];
}
$match = (new \UserModel($this->conn))->getUserByUsername($username);
if (!$match) {
throw new ToolCallException("Unknown user: {$username}");
}
return (int)$match['user_id'];
}
/**
* Map a shared-service failure to a tool error. A ticket the user can't
* see reads as "not found" (like get_ticket), never "access denied", so a
* restricted ticket's existence isn't revealed.
*
* @param array<string, mixed> $result
*/
private function errorMessage(array $result, string $ticketId): string
{
$error = (string)($result['error'] ?? 'Request failed');
if (in_array($error, ['Access denied', 'Ticket not found', 'Invalid ticket ID', 'Ticket ID required'], true)) {
return 'Ticket ' . trim($ticketId) . ' not found';
}
return $error;
}
}
+2 -15
View File
@@ -263,21 +263,8 @@ class AuthMiddleware
*/
private function checkGroupAccess($groups)
{
if (empty($groups)) {
return false;
}
// Check for admin or employee group membership
// Filter to safe characters only to prevent header injection attacks
$userGroups = array_filter(
array_map('trim', explode(',', strtolower($groups))),
function ($g) {
return preg_match('/^[a-z0-9_\-]+$/', $g);
}
);
$requiredGroups = ['admin', 'employee'];
return !empty(array_intersect($userGroups, $requiredGroups));
require_once dirname(__DIR__) . '/helpers/AccessPolicy.php';
return AccessPolicy::hasAppAccess((string)($groups ?? ''));
}
/**
+98
View File
@@ -0,0 +1,98 @@
<?php
/**
* Assigning / unassigning a ticket: access check, permission (admin, creator,
* or current assignee), audit log, optional Matrix assignment notification,
* stats-cache invalidation.
*
* Shared by the web UI (api/assign_ticket.php) and the MCP assign_ticket tool
* so both run one code path (tinker_tickets#111). Extracted verbatim from
* assign_ticket.php. Returns result arrays; failures carry 'http_status'.
*/
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/UserModel.php';
require_once dirname(__DIR__) . '/models/StatsModel.php';
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
require_once dirname(__DIR__) . '/helpers/SynapseHelper.php';
class AssignmentService
{
/**
* @param array $currentUser Authenticated user row (user_id, username, display_name, is_admin, ...)
* @param array $data ticket_id, assigned_to (user_id; null/'' to unassign)
*/
public static function assign(mysqli $conn, array $currentUser, array $data): array
{
$userId = $currentUser['user_id'];
$isAdmin = $currentUser['is_admin'] ?? false;
$ticketIdRaw = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : '';
$assignedTo = $data['assigned_to'] ?? null;
if (!ctype_digit($ticketIdRaw) || (int)$ticketIdRaw <= 0) {
return ['success' => false, 'error' => 'Ticket ID required', 'http_status' => 400];
}
$ticketId = $ticketIdRaw;
$ticketModel = new TicketModel($conn);
$auditLogModel = new AuditLogModel($conn);
$userModel = new UserModel($conn);
// Verify ticket exists and user can access it
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
return ['success' => false, 'error' => 'Ticket not found', 'http_status' => 404];
}
// Authorization: only admins or the ticket creator/assignee can reassign
if (!$isAdmin && (int)$ticket['created_by'] !== (int)$userId && (int)$ticket['assigned_to'] !== (int)$userId) {
return ['success' => false, 'error' => 'Permission denied', 'http_status' => 403];
}
if ($assignedTo === null || $assignedTo === '') {
// Unassign ticket
$success = $ticketModel->unassignTicket($ticketId, $userId);
if ($success) {
$auditLogModel->log($userId, 'unassign', 'ticket', $ticketId);
}
} else {
// Validate assigned_to is a valid user ID
$assignedTo = (int)$assignedTo;
$targetUser = $userModel->getUserById($assignedTo);
if (!$targetUser) {
return ['success' => false, 'error' => 'Invalid user ID', 'http_status' => 400];
}
// Assign ticket
$success = $ticketModel->assignTicket($ticketId, $assignedTo, $userId);
if ($success) {
$auditLogModel->log($userId, 'assign', 'ticket', $ticketId, ['assigned_to' => $assignedTo]);
if (!empty($GLOBALS['config']['MATRIX_NOTIFY_ASSIGNMENTS'])) {
$changedByDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null;
$assigneeName = $targetUser['display_name'] ?? $targetUser['username'] ?? null;
$assigneeMatrix = isset($targetUser['username'])
? SynapseHelper::resolveUsername($targetUser['username'])
: null;
NotificationHelper::sendAssignmentNotification(
$ticketId,
$ticket['title'] ?? "Ticket #{$ticketId}",
$assigneeName,
$assigneeMatrix,
$changedByDisplay,
$ticket['visibility'] ?? 'public'
);
}
}
}
if (!$success) {
return ['success' => false, 'error' => 'Failed to update ticket assignment', 'http_status' => 500];
}
(new StatsModel($conn))->invalidateCache();
return ['success' => true];
}
}
+160
View File
@@ -0,0 +1,160 @@
<?php
/**
* Adding a comment to a ticket: validation, access check, reply-parent check,
* @mention extraction (audit-logged, and notified only to mentioned users who
* can see the ticket), comment + watcher notifications.
*
* Shared by the web UI (api/add_comment.php) and the MCP add_comment tool so
* both run one code path (tinker_tickets#111). Extracted verbatim from
* add_comment.php. Returns result arrays; on validation failure the array
* carries 'http_status' for HTTP callers. Callers own sessions/CSRF/responses.
*/
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/CommentModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
require_once dirname(__DIR__) . '/helpers/SynapseHelper.php';
class CommentService
{
/**
* @param array $currentUser Authenticated user row (user_id, username, display_name, groups, is_admin)
* @param array $data ticket_id, comment_text, optional markdown_enabled / parent_comment_id
*/
public static function addComment(mysqli $conn, array $currentUser, array $data): array
{
$userId = $currentUser['user_id'];
$ticketId = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : '';
if (!ctype_digit($ticketId) || (int)$ticketId <= 0) {
return ['success' => false, 'error' => 'Invalid ticket ID', 'http_status' => 400];
}
// Reject empty/whitespace-only comments
$commentTextRaw = isset($data['comment_text']) ? trim((string)$data['comment_text']) : '';
if ($commentTextRaw === '') {
return ['success' => false, 'error' => 'Comment text cannot be empty', 'http_status' => 400];
}
// Persist the trimmed text (not the raw client value) — matches update_comment.php
// and keeps stored comment_text free of leading whitespace that could shift a
// markdown-enabled comment's first line out of column 0 on reload.
$data['comment_text'] = $commentTextRaw;
// Never trust a client-supplied display name — always attribute the comment to
// the authenticated user.
$data['user_name'] = $currentUser['display_name'] ?? $currentUser['username'] ?? 'User';
// Verify user can access the ticket before allowing a comment
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket) {
return ['success' => false, 'error' => 'Ticket not found', 'http_status' => 404];
}
if (!$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
return ['success' => false, 'error' => 'Access denied', 'http_status' => 403];
}
// Initialize models
$commentModel = new CommentModel($conn);
$auditLog = new AuditLogModel($conn);
// If replying, the parent comment must belong to this same (accessible) ticket.
if (isset($data['parent_comment_id']) && $data['parent_comment_id'] !== null && $data['parent_comment_id'] !== '') {
$parentComment = $commentModel->getCommentById((int)$data['parent_comment_id']);
if (!$parentComment || (string)$parentComment['ticket_id'] !== (string)$ticketId) {
return ['success' => false, 'error' => 'Invalid parent comment', 'http_status' => 400];
}
}
// Extract @mentions from comment text
$mentions = $commentModel->extractMentions($data['comment_text'] ?? '');
$mentionedUsers = [];
if (!empty($mentions)) {
$mentionedUsers = $commentModel->getMentionedUsers($mentions);
}
// Add comment with user tracking
$result = $commentModel->addComment($ticketId, $data, $userId);
// Log comment creation to audit log
if ($result['success'] && isset($result['comment_id'])) {
$auditLog->logCommentCreate($userId, $result['comment_id'], $ticketId);
// Log mentions to audit log
foreach ($mentionedUsers as $mentionedUser) {
$auditLog->log(
$userId,
'mention',
'user',
(string)$mentionedUser['user_id'],
[
'ticket_id' => $ticketId,
'comment_id' => $result['comment_id'],
'mentioned_username' => $mentionedUser['username']
]
);
}
// Matrix notifications
$authorDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null;
$commentText = $data['comment_text'] ?? '';
$ticketTitle = $ticket['title'] ?? "Ticket #{$ticketId}";
$ticketVisibility = $ticket['visibility'] ?? 'public';
// @mention notifications — resolve usernames → Matrix IDs via Synapse Admin API.
// Only notify mentioned users who actually have access to this ticket;
// otherwise a mention would DM them the ticket's title and comment text
// even though canUserAccessTicket() would deny them the ticket itself.
$accessibleMentionedUsers = array_filter(
$mentionedUsers,
fn($u) => $ticketModel->canUserAccessTicket($ticket, $u)
);
if (!empty($accessibleMentionedUsers)) {
$mentionedUsernames = array_column($accessibleMentionedUsers, 'username');
$mentionedMatrixIds = SynapseHelper::resolveUsernames($mentionedUsernames);
if (!empty($mentionedMatrixIds)) {
NotificationHelper::sendMentionNotification($ticketId, $ticketTitle, $commentText, $authorDisplay, $mentionedMatrixIds);
}
}
// General comment notification (opt-in via MATRIX_NOTIFY_COMMENTS)
if (!empty($GLOBALS['config']['MATRIX_NOTIFY_COMMENTS'])) {
NotificationHelper::sendCommentNotification(
$ticketId,
$ticketTitle,
$commentText,
$authorDisplay,
$ticketVisibility !== 'public',
$ticketVisibility
);
}
// Notify watchers of the new comment
NotificationHelper::notifyWatchers(
$conn,
$ticketId,
$ticketTitle,
'comment_added',
['author' => $authorDisplay, 'preview' => mb_strimwidth($commentText, 0, 200, '…')],
(int)$userId,
$ticketVisibility
);
// Add mentioned users to result for frontend
$result['mentions'] = array_map(function ($u) {
return $u['username'];
}, $mentionedUsers);
}
// Add user info to result for frontend avatar rendering
if ($result['success']) {
$result['user_name'] = $currentUser['display_name'] ?? $currentUser['username'];
$result['user_id'] = $userId;
}
return $result;
}
}
+134
View File
@@ -0,0 +1,134 @@
<?php
/**
* Creating a ticket as a user: required-field and required-custom-field
* validation, TicketModel::createTicket (which enforces visibility rules),
* audit log, stats-cache invalidation, custom field values, optional
* "duplicates" link, and the new-ticket Matrix notification.
*
* Shared by the web UI (TicketController::create) and the MCP create_ticket
* tool so both run one code path (tinker_tickets#111). Extracted from
* TicketController::create with the same checks, order and error messages.
* Callers own request parsing, CSRF, and rendering.
*/
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/CustomFieldModel.php';
require_once dirname(__DIR__) . '/models/StatsModel.php';
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
class TicketCreationService
{
/**
* @param array $currentUser Authenticated user row
* @param array $input title, description, priority, category, type, status,
* visibility, visibility_groups (string or list), assigned_to,
* custom_fields (field_id => value), link_duplicate_of
*
* @return array ['success' => true, 'ticket_id' => ...] or ['success' => false, 'error' => ...]
*/
public static function create(mysqli $conn, array $currentUser, array $input): array
{
$userId = $currentUser['user_id'] ?? null;
// Handle visibility groups (a list from the web form's checkboxes, or a string)
$visibilityGroups = null;
if (isset($input['visibility_groups']) && is_array($input['visibility_groups'])) {
$visibilityGroups = implode(',', array_map('trim', $input['visibility_groups']));
} elseif (isset($input['visibility_groups']) && is_string($input['visibility_groups']) && trim($input['visibility_groups']) !== '') {
$visibilityGroups = trim($input['visibility_groups']);
}
// Honor the posted status, validated against the app's canonical list
$validStatuses = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed'];
$status = $input['status'] ?? 'Open';
if (!in_array($status, $validStatuses, true)) {
$status = 'Open';
}
$ticketData = [
'title' => trim($input['title'] ?? ''),
'description' => $input['description'] ?? '',
'priority' => $input['priority'] ?? '4',
'category' => $input['category'] ?? 'General',
'type' => $input['type'] ?? 'Issue',
'status' => $status,
'visibility' => $input['visibility'] ?? 'public',
'visibility_groups' => $visibilityGroups,
'assigned_to' => !empty($input['assigned_to']) ? $input['assigned_to'] : null
];
if ($ticketData['title'] === '') {
return ['success' => false, 'error' => 'Title is required'];
}
if (trim($ticketData['description']) === '') {
return ['success' => false, 'error' => 'Description is required'];
}
// Custom fields applicable to the submitted category — validate
// is_required server-side (the form is novalidate, and a field
// hidden by the client-side category toggle must not silently
// bypass a requirement that applies to the category actually
// submitted).
$customFieldModel = new CustomFieldModel($conn);
$allCustomFieldDefs = $customFieldModel->getAllDefinitions(null, true);
$submittedCustomFields = is_array($input['custom_fields'] ?? null) ? $input['custom_fields'] : [];
$applicableFieldDefs = array_filter(
$allCustomFieldDefs,
fn($def) => $def['category'] === null || $def['category'] === $ticketData['category']
);
$customFieldsToSave = [];
foreach ($applicableFieldDefs as $def) {
$fieldId = (int)$def['field_id'];
$raw = $submittedCustomFields[$fieldId] ?? null;
$normalized = $def['field_type'] === 'checkbox'
? (!empty($raw) ? '1' : '0')
: (is_scalar($raw) ? trim((string)$raw) : '');
if (!empty($def['is_required']) && $def['field_type'] !== 'checkbox' && $normalized === '') {
return ['success' => false, 'error' => $def['field_label'] . ' is required'];
}
if ($normalized !== '') {
$customFieldsToSave[$fieldId] = $normalized;
}
}
// Create ticket with user tracking
$result = (new TicketModel($conn))->createTicket($ticketData, $userId);
if (!$result['success']) {
return ['success' => false, 'error' => $result['error']];
}
// Log ticket creation to audit log
if ($userId) {
(new AuditLogModel($conn))->logTicketCreate($userId, $result['ticket_id'], $ticketData);
}
// Ticket counts changed — invalidate the cached dashboard stats
(new StatsModel($conn))->invalidateCache();
// Persist custom field values for the fields applicable to this ticket's category
if (!empty($customFieldsToSave)) {
$customFieldModel->setValues($result['ticket_id'], $customFieldsToSave);
}
// Auto-link as duplicate if requested
$linkDupOfRaw = trim((string)($input['link_duplicate_of'] ?? ''));
if ($linkDupOfRaw !== '' && ctype_digit($linkDupOfRaw)) {
$depSql = "INSERT IGNORE INTO ticket_dependencies (ticket_id, depends_on_id, dependency_type, created_by)
VALUES (?, ?, 'duplicates', ?)";
$depStmt = $conn->prepare($depSql);
$depStmt->bind_param("ssi", $result['ticket_id'], $linkDupOfRaw, $userId);
$depStmt->execute();
$depStmt->close();
}
// Send Matrix notification for new ticket
NotificationHelper::sendTicketNotification($result['ticket_id'], $ticketData, 'manual');
return ['success' => true, 'ticket_id' => $result['ticket_id']];
}
}