diff --git a/contrib/plugins/kanban-mcp/Chart.yaml b/contrib/plugins/kanban-mcp/Chart.yaml new file mode 100644 index 0000000000..48e1a9b442 --- /dev/null +++ b/contrib/plugins/kanban-mcp/Chart.yaml @@ -0,0 +1,8 @@ +apiVersion: v2 +name: kanban-mcp +description: A Helm chart for the MCP Kanban server (Postgres-backed board with MCP, REST, SSE and an embedded UI) +type: application +version: 0.1.0 +appVersion: "1.0.0" +sources: + - https://github.com/kagent-dev/kagent diff --git a/contrib/plugins/kanban-mcp/templates/_helpers.tpl b/contrib/plugins/kanban-mcp/templates/_helpers.tpl new file mode 100644 index 0000000000..e7901cfd86 --- /dev/null +++ b/contrib/plugins/kanban-mcp/templates/_helpers.tpl @@ -0,0 +1,81 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "kanban-mcp.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +*/}} +{{- define "kanban-mcp.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "kanban-mcp.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "kanban-mcp.labels" -}} +helm.sh/chart: {{ include "kanban-mcp.chart" . }} +{{ include "kanban-mcp.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "kanban-mcp.selectorLabels" -}} +app.kubernetes.io/name: {{ include "kanban-mcp.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Name of the Secret that holds the database URL. +Uses database.existingSecret when set, otherwise a generated "-db" Secret. +*/}} +{{- define "kanban-mcp.dbSecretName" -}} +{{- if .Values.database.existingSecret -}} +{{- .Values.database.existingSecret -}} +{{- else -}} +{{- printf "%s-db" (include "kanban-mcp.fullname" .) -}} +{{- end -}} +{{- end }} + +{{/* +In-cluster URL of the kanban MCP endpoint, used as RemoteMCPServer spec.url. +The kanban server serves MCP over Streamable HTTP at /mcp, with the web UI at /. +*/}} +{{- define "kanban-mcp.serverUrl" -}} +{{- printf "http://%s.%s:%d/mcp" (include "kanban-mcp.fullname" .) .Release.Namespace (.Values.service.port | int) }} +{{- end }} + +{{/* +Key within the database Secret that holds the URL. +*/}} +{{- define "kanban-mcp.dbSecretKey" -}} +{{- if .Values.database.existingSecret -}} +{{- default "url" .Values.database.existingSecretKey -}} +{{- else -}} +url +{{- end -}} +{{- end }} diff --git a/contrib/plugins/kanban-mcp/templates/agent.yaml b/contrib/plugins/kanban-mcp/templates/agent.yaml new file mode 100644 index 0000000000..7790a9f5fd --- /dev/null +++ b/contrib/plugins/kanban-mcp/templates/agent.yaml @@ -0,0 +1,153 @@ +{{- if .Values.agent.enabled }} +apiVersion: kagent.dev/v1alpha2 +kind: Agent +metadata: + name: {{ include "kanban-mcp.fullname" . }}-agent + namespace: {{ .Release.Namespace }} + labels: + {{- include "kanban-mcp.labels" . | nindent 4 }} +spec: + description: {{ .Values.agent.description | quote }} + type: Declarative + declarative: + runtime: {{ .Values.agent.runtime | default "python" }} + systemMessage: | + # Kanban Board AI Agent System Prompt + + You are KanbanAssist, an AI agent that manages a Kanban board through the kanban MCP server. You help users plan, track, and move work across the board's workflow stages while keeping task state accurate and up to date. + + ## Core Capabilities + + - **Board Awareness**: You can read the full board and individual cards to understand the current state of work. + - **Work Hierarchy**: You organize work as Features → Tasks → Subtasks (see below) and keep that structure clean. + - **Task Management**: You can create Features and Tasks, break a Feature into child Tasks, update their fields, assign owners, and move them through workflow stages. + - **Checklists**: You can add checklist subtasks (title + done flag) to a Task and tick them off as work progresses. + - **Workflow Discipline**: You respect each board's own columns and move cards only into columns that belong to the card's board. + - **Attachments & Attributes**: You can attach files (md, html, txt, yaml, csv, pdf, docx, xlsx — base64-encoded) or links to any card (Feature or Task), and set simple key/value attributes on a card, to capture supporting context and metadata. + - **Clear Communication**: You explain what you changed and why, and surface tasks that are blocked on human input. + + ## Work Hierarchy + + Work is organized in three levels: + + - **Feature**: a top-level card (an epic / unit of work). Created with `create_task` without a `parent_id`. + - **Task**: a child card under a Feature — the unit a worker owns. Both Features and Tasks are full kanban cards with their own status, assignee, labels, and board column, and both move independently across columns. Create a Task with `create_task` and the Feature's id as `parent_id`. + - **Subtask**: a lightweight **checklist item** (just a title and a done flag) attached to a **Task** (not a Feature). Use `create_subtask`, `toggle_subtask`, `update_subtask`, and `delete_subtask` to manage the checklist. + + ## Boards + + The server hosts multiple boards. Each board has its own ordered set of + columns, and a task can only be placed in a column that belongs to its board. + {{- if .Values.agent.board }} + Unless told otherwise, operate on the board with key `{{ .Values.agent.board }}`: + pass `board: "{{ .Values.agent.board }}"` to `list_tasks`, `create_task`, and + `get_board`. + {{- else }} + Board-scoped tools (`list_tasks`, `create_task`, `get_board`) take an optional + `board` key and default to the `default` board. Use `list_boards` to discover + available boards and their columns before creating or moving tasks. + {{- end }} + + ## Available Tools + + You have access to the following kanban tools: + + ### Board Tools + - `list_boards`: List all boards with their columns, scope, and owner. + - `create_board`: Create a new board with its own ordered column set. + + ### Read Tools + - `get_board`: Get a board's full state (its columns + cards grouped by column); Features and Tasks appear as flat cards, each with its checklist subtasks and attachments. + - `list_tasks`: List cards on a board, optionally filtered by status, assignee, or label; set `parent_id` to list a Feature's child Tasks. + - `get_task`: Get a single card by ID, including its checklist subtasks, attachments, and (for a Feature) its child Tasks. + - `show_task_progress`: Render an interactive progress widget for a card (Feature or Task) inline in the chat — completion percent, plus per-column child-task counts and an individual progress bar per child Task (Feature) or checklist progress (Task). + + ### Write Tools + - `create_task`: Create a Feature (no `parent_id`) or a child Task under a Feature (`parent_id` set). Status defaults to the board's first column. + - `create_subtask`: Add a checklist subtask (title) to a Task. Subtasks attach to Tasks only, not Features. + - `toggle_subtask`: Set or clear the done flag on a checklist subtask. + - `update_subtask`: Rename a checklist subtask. + - `delete_subtask`: Delete a checklist subtask. + - `update_task`: Update one or more fields of a card; unset fields are left unchanged. + - `assign_task`: Assign a card to someone; an empty assignee clears it. + - `move_task`: Move a card to a different workflow status. + - `set_user_input_needed`: Set or clear the human-in-the-loop flag on a card. + - `delete_task`: Delete a card; its checklist subtasks and attachments are removed with it, and deleting a Feature also deletes its child Tasks. + - `add_attachment`: Add a file (base64-encoded content; allowed types: md, markdown, html, htm, txt, yaml, yml, csv, pdf, docx, xlsx) or link attachment to a card (Feature or Task). + - `delete_attachment`: Delete a file or link attachment by ID. + - `set_attribute`: Set (upsert) a key/value attribute on a card; setting an existing key replaces its value. + - `delete_attribute`: Remove a key/value attribute from a card by its key. + + ## Operating Guidelines + + 1. **Read before you write**: Inspect the board or task before making changes so updates are accurate. + 2. **Least surprise**: Confirm destructive actions (delete_task, delete_attachment) with the user before running them. + 3. **Keep status honest**: Move tasks to reflect real progress; flag blocked tasks with set_user_input_needed. + 4. **Be concise**: Summarize what you did, referencing task IDs and the resulting status. + 5. **Status/progress → widget only**: When the user asks for the **status or progress** of a specific Feature or Task, respond with the progress widget **only** — call `show_task_progress` with that card's id (it renders both Features and Tasks) — and nothing else. Do not add a separate textual status summary or call other read tools; the rendered widget is the complete answer. + + ## Response Format + + When responding to user queries: + + 1. **Assessment**: Briefly restate what the user wants. + 2. **Action**: State which tools you will use and on which tasks. + 3. **Result**: Summarize the changes, including task IDs and new statuses. + + Always start with the least intrusive approach, and ask for clarification when a request is ambiguous. + modelConfig: {{ .Values.agent.modelConfig | default "default-model-config" }} + tools: + - type: McpServer + mcpServer: + name: {{ include "kanban-mcp.fullname" . }} + kind: RemoteMCPServer + apiGroup: kagent.dev + toolNames: + - list_boards + - create_board + - get_board + - list_tasks + - get_task + - show_task_progress + - create_task + - create_subtask + - toggle_subtask + - update_subtask + - delete_subtask + - update_task + - assign_task + - move_task + - set_user_input_needed + - delete_task + - add_attachment + - delete_attachment + - set_attribute + - delete_attribute + a2aConfig: + skills: + - id: task-management + name: Task Management + description: Create, update, assign, and move tasks across the board. + tags: + - kanban + - tasks + examples: + - "Create a task to investigate the failing CI pipeline." + - "Move task 12 to Testing and assign it to Alice." + - "What is currently in the Develop column?" + - id: board-reporting + name: Board Reporting + description: Summarize board state and surface blocked work. + tags: + - kanban + - reporting + examples: + - "Give me a summary of the board." + - "Which tasks are blocked waiting on human input?" + - "List all tasks assigned to Bob." + {{- with .Values.agent.resources }} + deployment: + resources: + {{- toYaml . | nindent 8 }} + {{- end }} +{{- end }} diff --git a/contrib/plugins/kanban-mcp/templates/configmap-boards.yaml b/contrib/plugins/kanban-mcp/templates/configmap-boards.yaml new file mode 100644 index 0000000000..dc88ba64ca --- /dev/null +++ b/contrib/plugins/kanban-mcp/templates/configmap-boards.yaml @@ -0,0 +1,14 @@ +{{- if .Values.boards }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "kanban-mcp.fullname" . }}-boards + namespace: {{ .Release.Namespace }} + labels: + {{- include "kanban-mcp.labels" . | nindent 4 }} +data: + # Board definitions seeded by the server at startup via KANBAN_BOARDS_FILE. + # The server upserts each entry, so redeploys reconcile board names/columns. + boards.json: | + {{ .Values.boards | toJson }} +{{- end }} diff --git a/contrib/plugins/kanban-mcp/templates/deployment.yaml b/contrib/plugins/kanban-mcp/templates/deployment.yaml new file mode 100644 index 0000000000..8f87a8068b --- /dev/null +++ b/contrib/plugins/kanban-mcp/templates/deployment.yaml @@ -0,0 +1,124 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "kanban-mcp.fullname" . }} + labels: + {{- include "kanban-mcp.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "kanban-mcp.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "kanban-mcp.selectorLabels" . | nindent 8 }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: kanban-mcp + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- with .Values.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + args: + - "--addr={{ .Values.config.addr }}" + - "--transport={{ .Values.config.transport }}" + - "--log-level={{ .Values.config.logLevel }}" + - "--readonly={{ .Values.config.readonly }}" + env: + - name: KANBAN_ADDR + value: {{ .Values.config.addr | quote }} + - name: KANBAN_TRANSPORT + value: {{ .Values.config.transport | quote }} + - name: KANBAN_LOG_LEVEL + value: {{ .Values.config.logLevel | quote }} + - name: KANBAN_READONLY + value: {{ .Values.config.readonly | quote }} + {{- if or .Values.database.url .Values.database.existingSecret }} + # The connection URL is mounted as a file from a Secret and read via + # KANBAN_DB_URL_FILE so the URL never appears in the pod env / spec. + - name: KANBAN_DB_URL_FILE + value: /etc/kanban/db/{{ include "kanban-mcp.dbSecretKey" . }} + {{- end }} + {{- if .Values.boards }} + # Board definitions are mounted from a ConfigMap and seeded at startup. + - name: KANBAN_BOARDS_FILE + value: /etc/kanban/boards/boards.json + {{- end }} + ports: + - name: http + containerPort: 8080 + protocol: TCP + {{- if eq .Values.config.transport "http" }} + readinessProbe: + httpGet: + path: /api/board + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /api/board + port: http + initialDelaySeconds: 10 + periodSeconds: 20 + {{- end }} + {{- with .Values.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- if or .Values.database.url .Values.database.existingSecret .Values.boards }} + volumeMounts: + {{- if or .Values.database.url .Values.database.existingSecret }} + - name: db-url + mountPath: /etc/kanban/db + readOnly: true + {{- end }} + {{- if .Values.boards }} + - name: boards + mountPath: /etc/kanban/boards + readOnly: true + {{- end }} + {{- end }} + {{- if or .Values.database.url .Values.database.existingSecret .Values.boards }} + volumes: + {{- if or .Values.database.url .Values.database.existingSecret }} + - name: db-url + secret: + secretName: {{ include "kanban-mcp.dbSecretName" . }} + items: + - key: {{ include "kanban-mcp.dbSecretKey" . }} + path: {{ include "kanban-mcp.dbSecretKey" . }} + {{- end }} + {{- if .Values.boards }} + - name: boards + configMap: + name: {{ include "kanban-mcp.fullname" . }}-boards + {{- end }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/contrib/plugins/kanban-mcp/templates/remotemcpserver.yaml b/contrib/plugins/kanban-mcp/templates/remotemcpserver.yaml new file mode 100644 index 0000000000..70772f4dac --- /dev/null +++ b/contrib/plugins/kanban-mcp/templates/remotemcpserver.yaml @@ -0,0 +1,38 @@ +{{- if .Values.remoteMCPServer.enabled }} +apiVersion: kagent.dev/v1alpha2 +kind: RemoteMCPServer +metadata: + name: {{ include "kanban-mcp.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "kanban-mcp.labels" . | nindent 4 }} +spec: + description: {{ .Values.remoteMCPServer.description | quote }} + protocol: STREAMABLE_HTTP + url: {{ include "kanban-mcp.serverUrl" . }} + timeout: {{ .Values.remoteMCPServer.timeout }} + sseReadTimeout: {{ .Values.remoteMCPServer.sseReadTimeout }} + terminateOnClose: {{ .Values.remoteMCPServer.terminateOnClose }} + {{- with .Values.remoteMCPServer.ui }} + ui: + enabled: {{ .enabled }} + {{- with .pathPrefix }} + pathPrefix: {{ . }} + {{- end }} + {{- with .displayName }} + displayName: {{ . | quote }} + {{- end }} + {{- with .icon }} + icon: {{ . }} + {{- end }} + {{- with .section }} + section: {{ . }} + {{- end }} + {{- with .defaultPath }} + defaultPath: {{ . | quote }} + {{- end }} + {{- with .injectCSS }} + injectCSS: {{ . | quote }} + {{- end }} + {{- end }} +{{- end }} diff --git a/contrib/plugins/kanban-mcp/templates/secret.yaml b/contrib/plugins/kanban-mcp/templates/secret.yaml new file mode 100644 index 0000000000..7955f0d228 --- /dev/null +++ b/contrib/plugins/kanban-mcp/templates/secret.yaml @@ -0,0 +1,15 @@ +{{- /* +Only render a Secret when a direct URL is provided and no existing Secret is +referenced. When database.existingSecret is set, that Secret is used as-is. +*/ -}} +{{- if and .Values.database.url (not .Values.database.existingSecret) }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "kanban-mcp.dbSecretName" . }} + labels: + {{- include "kanban-mcp.labels" . | nindent 4 }} +type: Opaque +stringData: + url: {{ .Values.database.url | quote }} +{{- end }} diff --git a/contrib/plugins/kanban-mcp/templates/service.yaml b/contrib/plugins/kanban-mcp/templates/service.yaml new file mode 100644 index 0000000000..d538678fef --- /dev/null +++ b/contrib/plugins/kanban-mcp/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "kanban-mcp.fullname" . }} + labels: + {{- include "kanban-mcp.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + selector: + {{- include "kanban-mcp.selectorLabels" . | nindent 4 }} diff --git a/contrib/plugins/kanban-mcp/values.yaml b/contrib/plugins/kanban-mcp/values.yaml new file mode 100644 index 0000000000..f15085e4b6 --- /dev/null +++ b/contrib/plugins/kanban-mcp/values.yaml @@ -0,0 +1,189 @@ +# Default values for the kanban-mcp chart. +# State lives in Postgres, so there is no persistence / PVC block. + +# -- Number of replicas. The server is stateless (all state is in Postgres), +# but note that SSE clients connect to a single replica; a multi-replica setup +# needs sticky sessions or a shared pub/sub (out of scope for v1). +replicaCount: 1 + +image: + # -- Container image repository. + repository: ghcr.io/kagent-dev/kanban-mcp + # -- Image pull policy. + pullPolicy: IfNotPresent + # -- Image tag. Defaults to the chart appVersion when empty. + tag: "" + +# -- Image pull secrets for private registries. +imagePullSecrets: [] +# -- Override the chart name. +nameOverride: "" +# -- Override the fully qualified app name. +fullnameOverride: "" + +# Server configuration. These map to the kanban-mcp flags / env vars. +config: + # -- Listen address (KANBAN_ADDR). + addr: ":8080" + # -- Transport: "http" (REST + SSE + MCP over HTTP) or "stdio". Use "http" in k8s. + transport: "http" + # -- Log level (KANBAN_LOG_LEVEL): debug | info | warn | error. + logLevel: "info" + # -- Serve the board UI in read-only mode (KANBAN_READONLY). When true the + # dashboard hides the "New Task" button. + readonly: true + +# Postgres connection. Provide exactly one of: +# - database.url -> injected as KANBAN_DB_URL +# - database.existingSecret -> mounted as a file, path injected as KANBAN_DB_URL_FILE +database: + # -- Direct Postgres connection URL, e.g. + # postgres://user:pass@host:5432/db?sslmode=disable + # Stored verbatim in a generated Secret. Prefer existingSecret in production. + url: "" + # -- Name of an existing Secret holding the connection URL. When set it takes + # precedence over database.url and is mounted as a file (KANBAN_DB_URL_FILE). + existingSecret: "" + # -- Key within existingSecret that holds the URL. + existingSecretKey: "url" + +service: + # -- Kubernetes Service type. + type: ClusterIP + # -- Service port. + port: 8080 + +# -- Board definitions seeded into the database at startup (idempotent upsert via +# KANBAN_BOARDS_FILE, rendered into a ConfigMap). Each board has its own ordered +# column set; tasks on a board can only use that board's columns. A built-in +# "default" board (the 7-stage workflow) always exists from the schema migration, +# so this list is only for additional / per-agent boards. Leave empty to keep +# just the default board. +# +# Each entry: +# key: unique slug used to address the board (required) +# name: display name (defaults to key) +# description: optional description +# scope: "general" (shared) or "agent" (bound to an agent) +# owner: owning agent name when scope is "agent" +# columns: ordered list of column names (required, non-empty) +# subtasks: optional ordered list of checklist subtask titles. When a Task is +# created on this board (via the dashboard "New Task" button or the +# MCP/REST API), these subtasks are auto-added to it. Leave empty +# for no template. Features are not affected (only Tasks have +# checklist subtasks). +# +# Example: +# boards: +# - key: team +# name: Team Board +# scope: general +# columns: [Backlog, Todo, Doing, Review, Done] +# subtasks: [Write tests, Implement, Update docs] +# - key: my-agent +# name: My Agent +# scope: agent +# owner: my-agent +# columns: [Inbox, Working, Blocked, Done] +boards: + - key: kubex + name: KubeX + description: KubeX board + scope: general + columns: [Backlog, Todo, Doing, Review, Done] + # - key: d1 + # name: D1 + # description: D1 board + # scope: general + # columns: [Backlog, Todo, Doing, Review, Done] + # - key: ms360-sdk + # name: MS360 SDK Upgrade + # description: MS360 SDK Upgrade board + # scope: general + # columns: [Inbox, Validate, Planning, Working, Blocked, Approved, Done] + # subtasks: [Analyze requirements, Implement change, Open pull request, Monitor CI] + # - key: adoption + # name: MS360 Adoption + # description: MS360 Adoption board + # scope: general + # columns: [Inbox, Validate, Planning, Working, Blocked, Approved, Done] + + - key: kagent + name: KAgent + description: KAgent Features board + scope: general + columns: [Inbox, Plan, Implement, Review, Done, Rejected] + - key: ai-sre + name: AI SRE + description: AI SRE incident response board + scope: general + columns: [Triage, Investigating, Identified, Mitigating, Monitoring, Resolved, Postmortem] + +# RemoteMCPServer registration. Creates a kagent.dev/v1alpha2 RemoteMCPServer so the +# kagent controller discovers this server's MCP tools and the UI lists it as a plugin. +# Requires the kagent CRDs to be installed in the cluster. +remoteMCPServer: + # -- Create the RemoteMCPServer resource. Disable for standalone deployments. + enabled: true + # -- Human-readable description shown in kagent. + description: "Kanban board MCP server (tasks, board, attachments)" + # -- Client timeout the controller uses when connecting to spec.url. + timeout: 30s + # -- SSE read timeout. + sseReadTimeout: 5m0s + # -- Terminate the MCP session when the connection closes. + terminateOnClose: true + # spec.ui drives sidebar registration and the /_p/{pathPrefix}/ UI reverse proxy. + ui: + # -- Expose the embedded board UI as a kagent UI plugin. + enabled: true + # -- URL path segment for routing (/_p/{pathPrefix}/). Defaults to the resource name. + pathPrefix: "kanban" + # -- Sidebar label. Defaults to the resource name. + displayName: "Kanban Boards" + # -- lucide-react icon name. + icon: "kanban" + # -- Sidebar section: OVERVIEW | AGENTS | WORKFLOWS | KNOWLEDGE | EVALUATIONS | RESOURCES | ADMIN | PLUGINS. + section: "WORKFLOWS" + # -- Initial sub-path opened at the plugin root. + defaultPath: "/" + # -- Optional CSS injected into proxied HTML (e.g. to hide the board's own nav). + injectCSS: "" + +# Declarative kagent Agent that drives the kanban board via this server's MCP tools. +# Requires the kagent CRDs, a ModelConfig, and remoteMCPServer.enabled=true so the +# agent can reach the RemoteMCPServer registered by this chart. +agent: + # -- Create the Agent resource. + enabled: true + # -- Human-readable description shown in kagent. + description: "A Kanban board agent that plans, tracks, and moves tasks across the board." + # -- Agent runtime: "python" or "go". Defaults to "python" when empty. + runtime: "go" + # -- ModelConfig resource the agent uses. Defaults to "default-model-config". + modelConfig: "" + # -- Board key this agent operates on. When set, the system prompt instructs the + # agent to pass this board to board-scoped tools (realizing a per-agent board). + # Empty means the agent uses the "default" board unless told otherwise. + board: "" + # -- Resource requests/limits for the agent deployment. + resources: {} + +# -- Resource requests/limits for the container. +resources: {} + +# -- Pod-level securityContext. +podSecurityContext: {} + +# -- Container-level securityContext. +securityContext: {} + +# -- Extra pod annotations. +podAnnotations: {} + +# -- Node selector. +nodeSelector: {} +# -- Tolerations. +tolerations: [] +# -- Affinity. +affinity: {} diff --git a/design/EP-2048-kanban-mcp-plugin.md b/design/EP-2048-kanban-mcp-plugin.md new file mode 100644 index 0000000000..488872a96a --- /dev/null +++ b/design/EP-2048-kanban-mcp-plugin.md @@ -0,0 +1,143 @@ +# EP-2048: Kanban MCP server shipped as a kagent UI plugin + +* Issue: [#2048](https://github.com/kagent-dev/kagent/issues/2048) + +## Background + +Agents frequently need a place to track work — tasks, boards, subtasks, progress — +both for their own multi-step plans and for human collaborators to observe what an +agent is doing. Today there is no first-class task/board primitive in kagent. + +This EP introduces `kanban-mcp` (`go/plugins/kanban-mcp`): a self-contained MCP +server that + +1. exposes **MCP tools** for task/board/subtask/attachment management that agents + call as tools, and +2. ships its **own embedded web UI** (a board view + a real-time task-progress + widget), surfaced inside the kagent console as a UI plugin via + `RemoteMCPServer.spec.ui` (see EP-2047). + +It is the first reference implementation of the "MCP server + embedded UI as a +kagent plugin" pattern, exercising both the plugin registration mechanism +(EP-2047) and the in-chat MCP UI widget mechanism (EP-2046). + +## Motivation + +- Give agents a durable, structured task/board store usable as tools. +- Give users a live board UI and per-task progress, embedded in the kagent console + with the same theme/namespace chrome as the rest of the app. +- Validate the plugin and MCP-app extension points end-to-end with a real plugin. + +### Goals + +- A standalone Go MCP server (`go/plugins/kanban-mcp`) with: + - SQLite **and** Postgres support (via a shared query layer). + - Schema migrations and optional board seeding. + - MCP tools for tasks, boards, subtasks, and attachments. + - A REST API + SSE stream for the embedded UI's live updates. + - An embedded SPA board UI and a `task-progress` MCP-app HTML resource. +- A Helm chart (`contrib/plugins/kanban-mcp`) that deploys the server and registers + it as a kagent UI plugin via a `RemoteMCPServer` with `spec.ui.enabled`. + +### Non-Goals + +- Modifying the kagent controller, core HTTP server, or CRDs (handled by EP-2047). +- A general-purpose project-management product; scope is task/board primitives. +- Authn/authz beyond what kagent's plugin proxy and `RemoteMCPServer` provide. + +## Implementation Details + +### Layout (`go/plugins/kanban-mcp`) + +``` +main.go, server.go entrypoint + HTTP/MCP server wiring +internal/config/ flags/env config (addr, transport, db-url, readonly, …) +internal/db/ connect + sqlc-generated queries (gen/) + queries/*.sql +internal/migrations/ embedded SQL migrations (000001…000007) +internal/seed/ optional board seeding from config +internal/service/ task/board/attachment/progress domain services +internal/mcp/tools.go MCP tool definitions (tasks, boards, subtasks, attachments) +internal/api/handlers.go REST handlers for the embedded UI +internal/sse/hub.go SSE hub for live UI updates +internal/ui/ embedded SPA (index.html) + task_progress.html (MCP app) +docs/mcp-app-task-progress.md task-progress MCP app contract +``` + +- **Dual database support:** queries are authored once (`internal/db/queries/*.sql`), + generated with sqlc (`internal/db/gen`), and run against SQLite or Postgres + selected at runtime from the configured DB URL. +- **Migrations** are embedded and applied on startup; `internal/seed` can + pre-populate boards from configuration. +- **Transports:** the MCP endpoint is served over Streamable HTTP (`/mcp`) and + optionally stdio; the web UI, REST API, and SSE share the same listener. +- **Real-time:** `internal/sse/hub.go` fans out task/board changes so the board UI + and the `task-progress` widget update live. + +### Embedded UI and the task-progress MCP app + +- `internal/ui/index.html` is the board SPA served at the server's web root `/`, + surfaced in the kagent sidebar through `RemoteMCPServer.spec.ui` (EP-2047). +- `internal/ui/task_progress.html` is an **MCP app** resource rendered inline in the + kagent chat (EP-2046): when an agent calls a kanban tool, the chat can render a + live progress widget for the affected board/task. + +### Deployment (`contrib/plugins/kanban-mcp`) + +Helm chart deploying the server `Deployment`/`Service`, optional board `ConfigMap` +and `Secret`, an optional `Agent`, and a `RemoteMCPServer` that both points agents +at the `/mcp` endpoint (`spec.url`) and registers the web UI as a plugin: + +```yaml +apiVersion: kagent.dev/v1alpha2 +kind: RemoteMCPServer +metadata: + name: kanban-mcp +spec: + protocol: STREAMABLE_HTTP + url: http://kanban-mcp.kagent:8080/mcp + ui: + enabled: true + pathPrefix: kanban + displayName: Kanban + icon: kanban + section: PLUGINS +``` + +### Dependencies + +- **EP-2047** (UI Plugins via `RemoteMCPServer.spec.ui`) — required to surface the + board UI in the sidebar and reverse-proxy `/_p/kanban/`. +- **EP-2046** (Chat MCP UI widgets) — required to render the `task-progress` MCP + app inline in chat. The server is usable without it (tools + standalone UI), but + the in-chat widget needs it. + +These are separate PRs; this PR is self-contained (all files live under +`go/plugins/kanban-mcp` and `contrib/plugins/kanban-mcp`) and builds +independently — it does not modify the kagent module's `go.mod` or any shared +file. + +## Test Plan + +- **Unit:** services (task/board/attachment/progress), config, migrations, seed, + SSE hub, MCP tools, and REST handlers — all shipped with `*_test.go` coverage. +- **Integration:** `internal/integration/integration_test.go` exercises the server + end-to-end against the embedded DB. +- **Build:** `go build ./plugins/kanban-mcp/...` passes within the `go/` module + with no new module dependencies. +- **Manual / e2e:** `helm install` the chart, confirm the `RemoteMCPServer` is + discovered, the board appears under "Plugins" in the sidebar, tools are callable + by an agent, and the task-progress widget updates live in chat. + +## Alternatives + +- **External standalone service (not an MCP plugin):** loses agent-as-tools access + and the embedded console integration. +- **New dedicated CRD instead of `RemoteMCPServer.spec.ui`:** rejected in EP-2047 in + favor of reusing the existing CRD. +- **Postgres-only:** SQLite support keeps single-binary/local and lightweight + deployments trivial. + +## Open Questions + +- Should board/task retention and archival be configurable? +- Should the task-progress widget support multiple concurrent boards in one chat? diff --git a/go/plugins/kanban-mcp/Dockerfile b/go/plugins/kanban-mcp/Dockerfile new file mode 100644 index 0000000000..7340b9f6e0 --- /dev/null +++ b/go/plugins/kanban-mcp/Dockerfile @@ -0,0 +1,39 @@ +# Multi-stage build for the kanban-mcp server. +# +# The build context is expected to be the repository root so the entire `go/` +# workspace (a single module, github.com/kagent-dev/kagent/go) is available: +# +# docker build -f go/plugins/kanban-mcp/Dockerfile -t kanban-mcp . +FROM golang:1.26-alpine AS builder + +# GOPROXY is passed in by the build so module downloads go through the configured +# (e.g. internal/proxied) module mirror instead of the public proxy.golang.org, +# which may be unreachable in restricted build environments. +ARG GOPROXY +ENV GOPROXY=${GOPROXY} + +WORKDIR /app + +# Copy the Go module/workspace and build the kanban-mcp binary. The plugin lives +# under the single go/ module, so copying go/ is sufficient. +COPY go/ ./go/ + +WORKDIR /app/go + +# CGO is not required: pgx is pure Go and the UI is embedded via //go:embed. +RUN CGO_ENABLED=0 go build -o /out/kanban-mcp ./plugins/kanban-mcp + +FROM alpine:3.20 + +# ca-certificates lets the server dial TLS Postgres endpoints (e.g. managed PG). +# Copy the bundle from the builder (golang:alpine ships ca-certificates) instead +# of `apk add`, which can't reach the public Alpine CDN in restricted/proxied +# build environments. +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt + +COPY --from=builder /out/kanban-mcp /usr/local/bin/kanban-mcp + +# Default HTTP transport listens on :8080 (see internal/config defaults). +EXPOSE 8080 + +ENTRYPOINT ["kanban-mcp"] diff --git a/go/plugins/kanban-mcp/docker-compose.yml b/go/plugins/kanban-mcp/docker-compose.yml new file mode 100644 index 0000000000..ea00dd611f --- /dev/null +++ b/go/plugins/kanban-mcp/docker-compose.yml @@ -0,0 +1,57 @@ +# Docker Compose for local testing of the kanban-mcp server. +# +# Brings up Postgres + the kanban-mcp server (HTTP transport) so you can exercise +# all four surfaces on http://localhost:8080 : +# / embedded Kanban UI +# /mcp MCP Streamable HTTP endpoint (for AI agents / MCP clients) +# /api/... REST API (tasks, subtasks, attachments, board) +# /events SSE stream of board updates +# +# Run from this directory: +# docker compose up --build +# +# The server applies its own `kanban` schema migrations on startup (track +# kanban_schema_migrations), so it coexists safely if pointed at a database that +# already holds the kagent core schema. +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: kanban + POSTGRES_PASSWORD: kanban + POSTGRES_DB: kanban + ports: + - "5432:5432" + volumes: + - kanban-pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U kanban -d kanban"] + interval: 5s + timeout: 5s + retries: 10 + + kanban-mcp: + build: + # Build context is the repo root so the whole go/ module is available. + context: ../../.. + dockerfile: go/plugins/kanban-mcp/Dockerfile + environment: + KANBAN_TRANSPORT: http + KANBAN_ADDR: ":8080" + KANBAN_DB_URL: "postgres://kanban:kanban@postgres:5432/kanban?sslmode=disable" + KANBAN_LOG_LEVEL: info + ports: + - "8080:8080" + depends_on: + postgres: + condition: service_healthy + healthcheck: + # busybox wget ships in the alpine runtime image. + test: ["CMD-SHELL", "wget -q -O- http://localhost:8080/api/board >/dev/null 2>&1 || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + +volumes: + kanban-pgdata: diff --git a/go/plugins/kanban-mcp/docs/mcp-app-task-progress.md b/go/plugins/kanban-mcp/docs/mcp-app-task-progress.md new file mode 100644 index 0000000000..a4b1d95fa4 --- /dev/null +++ b/go/plugins/kanban-mcp/docs/mcp-app-task-progress.md @@ -0,0 +1,171 @@ +# Kanban MCP App: Task Progress widget + +This document specifies the MCP App (SEP-1865) support added to the `kanban-mcp` +server: an interactive **task progress** widget that renders inline in Kagent +Chat instead of plain text. + +It covers the server contract (tools + `ui://` resource), the View ↔ host +protocol, and how this maps onto the generic in-chat App Bridge that Kagent's UI +implements. + +## 1. Goals + +- Let an agent surface the live progress of a *specific* card (a Feature or a + Task) as a rich, self-updating widget in the chat, not a wall of JSON. +- Stay **backward compatible**: the same tools return a useful text fallback, so + non-UI hosts, logs, and the model still work. +- Be **standards-compliant** with MCP Apps (`io.modelcontextprotocol/ui`, + `text/html;profile=mcp-app`, `ui://` resources) so the widget renders in any + compliant host, not just Kagent. + +## 2. Architecture + +``` +[Kagent Chat UI] ── host, generic MCP Apps App Bridge (@mcp-ui/client AppRenderer) + │ proxies tools/call + resources/read over /api/mcp-apps/{ns}/{name} + ▼ +[kagent core HTTP server] ── MCPAppsHandler: list tools / read ui:// resource / call tool + │ MCP streamable-HTTP (JSON-RPC) + ▼ +[kanban-mcp server] <-- this plugin + ├─ resource ui://kanban/task-progress (single-file HTML, text/html;profile=mcp-app) + ├─ tool show_task_progress (_meta.ui.resourceUri -> renders the View) + └─ tool refresh_task_progress (_meta.ui.visibility: ["app"] -> in-iframe refresh) + │ + ▼ +[Sandboxed iframe View] ── self-contained vanilla JS, speaks MCP Apps postMessage protocol +``` + +The View never talks to the kanban server directly. Every call flows +View → host → core proxy → kanban-mcp. The host is the security boundary. + +## 3. Server contract + +### 3.1 UI resource + +| Field | Value | +| --- | --- | +| URI | `ui://kanban/task-progress` | +| MIME | `text/html;profile=mcp-app` | +| Body | A single self-contained HTML document (inline CSS + JS, no external fetches) | + +Registered via `server.AddResource`. The host fetches it through +`resources/read` (proxied by the core `MCPAppsHandler.HandleReadResource`, which +validates the `ui://` scheme and the exact MIME type). + +### 3.2 Tools + +Both tools share one input and one output shape and one handler; only their +`_meta.ui` differs. + +**`show_task_progress`** — model- and app-visible (default visibility). + +```jsonc +{ + "name": "show_task_progress", + "inputSchema": { "id": "integer (card id)" }, + "_meta": { "ui": { "resourceUri": "ui://kanban/task-progress" } } +} +``` + +The presence of `_meta.ui.resourceUri` is what tells the host "render this tool's +result as an App". When the agent calls it, the host renders the View and pushes +the result into the iframe. + +**`refresh_task_progress`** — app-only (`_meta.ui.visibility: ["app"]`). + +```jsonc +{ + "name": "refresh_task_progress", + "inputSchema": { "id": "integer (card id)" }, + "_meta": { + "ui": { "resourceUri": "ui://kanban/task-progress", "visibility": ["app"] } + } +} +``` + +The model never sees this tool (the ADK toolset filters it out). It exists only +so the View can re-fetch fresh progress data from inside the iframe (the +"Refresh" button / poll) without spawning a new chat turn. + +### 3.3 Result shape + +Every call returns: + +- `content[0].text` — a one-line human summary (the **required** text fallback), + e.g. `Feature "Checkout v2" is 60% complete — 3 of 5 child tasks done (in "Done").` +- `structuredContent` — the `TaskProgress` object the View renders: + +```jsonc +{ + "task_id": 12, "title": "...", "kind": "feature" | "task", + "status": "Develop", "assignee": "...", "labels": ["..."], + "user_input_needed": false, + "percent": 60, "done_count": 3, "total_count": 5, + "summary": "...", + "board": { "key": "default", "name": "Default", "columns": ["Inbox", ...], "done_column": "Done" }, + "columns": [ { "status": "Inbox", "count": 1 }, ... ], // feature only: child counts per column + "children": [ { "id": 3, "title": "...", "status": "Done", "percent": 100, "done": true }, ... ], // feature + "subtasks": [ { "id": 7, "title": "...", "percent": 0, "done": false }, ... ], // task + "updated_at": "2026-..." +} +``` + +### 3.4 Progress computation + +`done_column` is the board's last column. + +- **Task with checklist subtasks:** `percent = round(done/total * 100)`. +- **Task without subtasks:** `percent = column position` of its status in the + board's ordered columns (`index / (len-1) * 100`). +- **Feature child (`children[].percent`):** each child Task's own completion — + its checklist ratio when it has checklist subtasks, else its column position. + The View renders one progress bar per child from this value. +- **Feature (`percent`):** `mean(children[].percent)`; + `done_count = children whose status == done_column`. With no children, falls + back to the Feature's own column position. + +## 4. View ↔ host protocol (MCP Apps / SEP-1865) + +The View is a single self-contained HTML file that implements the MCP Apps +postMessage contract directly (no bundler, no runtime imports — the iframe has no +network access). Messages are JSON-RPC 2.0 over `window.parent.postMessage`. + +Handshake and data flow: + +1. View → host **request** `ui/initialize` `{ appCapabilities, appInfo, protocolVersion: "2026-01-26" }`. +2. Host → View **result** `{ hostCapabilities, hostInfo, hostContext }` (theme, locale, ...). +3. View → host **notification** `ui/notifications/initialized`. +4. Host → View **notification** `ui/notifications/tool-input` `{ arguments: { id } }`. +5. Host → View **notification** `ui/notifications/tool-result` `{ ...CallToolResult }` — + the View renders `structuredContent`. +6. Refresh: View → host **request** `tools/call` `{ name: "refresh_task_progress", arguments: { id } }` + → host returns a fresh `CallToolResult`; the View re-renders. +7. View → host **notification** `ui/notifications/size-changed` `{ width, height }` + so the host can size the iframe to content. + +The View also responds to host `ping` requests and tolerates unknown +host→View requests (replies with an empty result) for forward compatibility. + +## 5. Host-side genericness (App Bridge) + +The in-chat host is **not** kanban-specific. It is implemented once in the Kagent +UI/core and works for any MCP server that follows the contract above: + +- **Discovery:** the UI lists each configured `RemoteMCPServer`'s tools via + `/api/mcp-apps/{ns}/{name}/tools` and detects App tools purely by + `_meta.ui.resourceUri`. Visibility (`app` / `model`) is read from + `_meta.ui.visibility` — no tool names or payload keys are hard-coded. +- **Rendering:** `@mcp-ui/client`'s `AppRenderer` mounts the `ui://` HTML in a + sandboxed iframe via a local `sandbox_proxy.html`, and proxies + `resources/read` / `tools/call` back through the core `MCPAppsHandler`. +- **Routing:** app-only tools (`visibility: ["app"]`) are proxied in-iframe; + model-visible tools invoked from the iframe are promoted to a normal chat + tool-call turn. This is protocol-based and identical for every server. +- **Model loop guard:** the ADK wraps App-tool results the *model* sees with a + terminal "already rendered" notice so the agent doesn't re-invoke the render + tool on every refresh. + +This means adding the task-progress widget required **no host changes** — only +the kanban server-side tools + resource defined here. Any other MCP server can +add a widget the same way. diff --git a/go/plugins/kanban-mcp/internal/api/handlers.go b/go/plugins/kanban-mcp/internal/api/handlers.go new file mode 100644 index 0000000000..cd41ade7cf --- /dev/null +++ b/go/plugins/kanban-mcp/internal/api/handlers.go @@ -0,0 +1,629 @@ +// Package api implements the kanban REST surface. Handlers are plain +// net/http.HandlerFunc factories — no external router — that dispatch on method +// and on simple URL-path inspection. Every handler delegates to the +// service.TaskService (which auto-broadcasts mutations to SSE clients) and maps +// service errors onto HTTP status codes: +// +// - pgx.ErrNoRows (via service.IsNotFound) -> 404 Not Found +// - validation errors (invalid status, nesting, attachment type) -> 400 Bad Request +// - everything else -> 500 Internal Server Error +// +// The route shapes are: +// +// GET,POST /api/tasks +// GET,PUT,DELETE /api/tasks/{id} +// GET,POST /api/tasks/{id}/subtasks +// PUT,DELETE /api/subtasks/{id} +// POST /api/tasks/{id}/attachments +// DELETE /api/attachments/{id} +// GET /api/attachments/{id}/download +// POST,DELETE /api/tasks/{id}/attributes (DELETE ?key={key}) +// GET /api/board (?board={key}) +// GET,POST /api/boards +package api + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "mime" + "net/http" + "path/filepath" + "strconv" + "strings" + + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/db" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/service" +) + +// errorResponse is the JSON body returned by writeError. +type errorResponse struct { + Error string `json:"error"` +} + +// writeJSON encodes v as JSON with the given status code. A non-nil v is +// required for any 2xx body; pass nil for empty bodies (e.g. 204). +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + if v == nil { + return + } + // Encoding errors at this point cannot be reported to the client (headers + // are already flushed); they are intentionally ignored. + _ = json.NewEncoder(w).Encode(v) +} + +// writeError writes a JSON {"error": msg} body with the given status code. +func writeError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, errorResponse{Error: msg}) +} + +// writeServiceError maps a service-layer error onto an HTTP status. Not-found +// errors become 404; the remaining errors are treated as validation (400) when +// they are not pgx.ErrNoRows. Callers that can distinguish validation from +// internal failures should use writeError directly; this helper is the default +// for read/get paths where the only expected failure is not-found. +func writeServiceError(w http.ResponseWriter, err error) { + if service.IsNotFound(err) { + writeError(w, http.StatusNotFound, err.Error()) + return + } + writeError(w, http.StatusInternalServerError, err.Error()) +} + +// writeMutationError maps a mutation error. Not-found -> 404, otherwise the +// error is assumed to be a validation failure (invalid status, nesting, +// attachment type, missing fields) and mapped to 400. Genuine DB/internal +// failures from the service are wrapped pgx errors that are not ErrNoRows; to +// avoid surfacing those as 400 they are detected separately by the caller when +// needed. For this server's surface, mutation failures that are not not-found +// are overwhelmingly validation errors, so 400 is the correct default. +func writeMutationError(w http.ResponseWriter, err error) { + if service.IsNotFound(err) { + writeError(w, http.StatusNotFound, err.Error()) + return + } + writeError(w, http.StatusBadRequest, err.Error()) +} + +// --------------------------------------------------------------------------- +// Request bodies +// --------------------------------------------------------------------------- + +// createTaskBody is the JSON body for POST /api/tasks. Board selects the target +// board for top-level creation; empty means the default board. ParentID, when set, +// creates a child Task under that Feature (board inherited from the Feature). Kind +// selects "feature" (default) or "task" for a top-level card; it is ignored when +// ParentID is set (child cards are always Tasks). +type createTaskBody struct { + Board string `json:"board,omitempty"` + ParentID *int64 `json:"parent_id,omitempty"` + Kind string `json:"kind,omitempty"` // "feature" (default) | "task" + Title string `json:"title"` + Description string `json:"description,omitempty"` + Status string `json:"status,omitempty"` + Labels []string `json:"labels,omitempty"` +} + +// createSubtaskBody is the JSON body for POST /api/tasks/{id}/subtasks. +type createSubtaskBody struct { + Title string `json:"title"` +} + +// updateSubtaskBody is the JSON body for PUT /api/subtasks/{id}. Pointer fields +// are nil when omitted, so only the provided fields are changed. +type updateSubtaskBody struct { + Title *string `json:"title,omitempty"` + Done *bool `json:"done,omitempty"` +} + +// createBoardBody is the JSON body for POST /api/boards. +type createBoardBody struct { + Key string `json:"key"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Scope string `json:"scope,omitempty"` + Owner string `json:"owner,omitempty"` + Columns []string `json:"columns"` + Subtasks []string `json:"subtasks,omitempty"` +} + +// updateTaskBody is the JSON body for PUT /api/tasks/{id}. Pointer fields are +// nil when the caller omits them, so only the provided fields are changed. +type updateTaskBody struct { + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + Status *string `json:"status,omitempty"` + Assignee *string `json:"assignee,omitempty"` + Labels *[]string `json:"labels,omitempty"` + UserInputNeeded *bool `json:"user_input_needed,omitempty"` +} + +// createAttachmentBody is the JSON body for POST /api/tasks/{id}/attachments. +// For type=file, Content is base64-encoded file bytes. +type createAttachmentBody struct { + Type string `json:"type"` + Filename string `json:"filename,omitempty"` + Content string `json:"content,omitempty"` + URL string `json:"url,omitempty"` + Title string `json:"title,omitempty"` +} + +// setAttributeBody is the JSON body for POST /api/tasks/{id}/attributes. +type setAttributeBody struct { + Key string `json:"key"` + Value string `json:"value"` +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +// TasksHandler handles /api/tasks: GET (list top-level tasks with optional +// ?status=, ?assignee=, ?label= filters) and POST (create a top-level task). +func TasksHandler(svc *service.TaskService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + listTasks(w, r, svc) + case http.MethodPost: + createTask(w, r, svc) + default: + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + } + } +} + +func listTasks(w http.ResponseWriter, r *http.Request, svc *service.TaskService) { + var filter service.TaskFilter + q := r.URL.Query() + if s := q.Get("status"); s != "" { + st := db.TaskStatus(s) + filter.Status = &st + } + if a := q.Get("assignee"); a != "" { + filter.Assignee = &a + } + if l := q.Get("label"); l != "" { + filter.Label = &l + } + + tasks, err := svc.ListTasks(r.Context(), q.Get("board"), filter) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, tasks) +} + +func createTask(w http.ResponseWriter, r *http.Request, svc *service.TaskService) { + var body createTaskBody + if err := decodeJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + task, err := svc.CreateTask(r.Context(), body.Board, service.CreateTaskRequest{ + Title: body.Title, + Description: body.Description, + Status: db.TaskStatus(body.Status), + Labels: body.Labels, + ParentID: body.ParentID, + Kind: body.Kind, + }) + if err != nil { + writeMutationError(w, err) + return + } + writeJSON(w, http.StatusCreated, task) +} + +// TaskHandler handles the /api/tasks/{id} family: +// +// GET,PUT,DELETE /api/tasks/{id} +// GET,POST /api/tasks/{id}/subtasks (checklist items) +// POST /api/tasks/{id}/attachments +// POST,DELETE /api/tasks/{id}/attributes (DELETE ?key={key}) +func TaskHandler(svc *service.TaskService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id, sub, ok := parseTaskPath(r.URL.Path) + if !ok { + writeError(w, http.StatusNotFound, "not found") + return + } + + switch sub { + case "": + taskByID(w, r, svc, id) + case "subtasks": + subtasks(w, r, svc, id) + case "attachments": + taskAttachments(w, r, svc, id) + case "attributes": + taskAttributes(w, r, svc, id) + default: + writeError(w, http.StatusNotFound, "not found") + } + } +} + +func taskByID(w http.ResponseWriter, r *http.Request, svc *service.TaskService, id int64) { + switch r.Method { + case http.MethodGet: + task, err := svc.GetTask(r.Context(), id) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, task) + case http.MethodPut: + var body updateTaskBody + if err := decodeJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + req := service.UpdateTaskRequest{ + Title: body.Title, + Description: body.Description, + Assignee: body.Assignee, + Labels: body.Labels, + UserInputNeeded: body.UserInputNeeded, + } + if body.Status != nil { + st := db.TaskStatus(*body.Status) + req.Status = &st + } + task, err := svc.UpdateTask(r.Context(), id, req) + if err != nil { + writeMutationError(w, err) + return + } + writeJSON(w, http.StatusOK, task) + case http.MethodDelete: + if err := svc.DeleteTask(r.Context(), id); err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusNoContent, nil) + default: + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + } +} + +func subtasks(w http.ResponseWriter, r *http.Request, svc *service.TaskService, taskID int64) { + switch r.Method { + case http.MethodGet: + subs, err := svc.ListSubtasks(r.Context(), taskID) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, subs) + case http.MethodPost: + var body createSubtaskBody + if err := decodeJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + sub, err := svc.CreateSubtask(r.Context(), taskID, body.Title) + if err != nil { + writeMutationError(w, err) + return + } + writeJSON(w, http.StatusCreated, sub) + default: + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + } +} + +// SubtaskHandler handles PUT and DELETE /api/subtasks/{id} for checklist items. +// PUT applies the provided fields (title and/or done); DELETE removes the item. +func SubtaskHandler(svc *service.TaskService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id, ok := parseSubtaskID(r.URL.Path) + if !ok { + writeError(w, http.StatusNotFound, "not found") + return + } + switch r.Method { + case http.MethodPut: + var body updateSubtaskBody + if err := decodeJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if body.Title == nil && body.Done == nil { + writeError(w, http.StatusBadRequest, "nothing to update: provide title and/or done") + return + } + var sub *service.Subtask + var err error + if body.Done != nil { + sub, err = svc.ToggleSubtask(r.Context(), id, *body.Done) + if err != nil { + writeMutationError(w, err) + return + } + } + if body.Title != nil { + sub, err = svc.UpdateSubtask(r.Context(), id, *body.Title) + if err != nil { + writeMutationError(w, err) + return + } + } + writeJSON(w, http.StatusOK, sub) + case http.MethodDelete: + if err := svc.DeleteSubtask(r.Context(), id); err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusNoContent, nil) + default: + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + } + } +} + +func taskAttachments(w http.ResponseWriter, r *http.Request, svc *service.TaskService, taskID int64) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + var body createAttachmentBody + if err := decodeJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + att, err := svc.AddAttachment(r.Context(), taskID, service.CreateAttachmentRequest{ + Type: db.AttachmentType(body.Type), + Filename: body.Filename, + Content: body.Content, + URL: body.URL, + Title: body.Title, + }) + if err != nil { + writeMutationError(w, err) + return + } + writeJSON(w, http.StatusCreated, att) +} + +// taskAttributes handles /api/tasks/{id}/attributes: POST upserts an attribute +// ({key, value}); DELETE removes the attribute named by the ?key= query param. +func taskAttributes(w http.ResponseWriter, r *http.Request, svc *service.TaskService, taskID int64) { + switch r.Method { + case http.MethodPost: + var body setAttributeBody + if err := decodeJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + attr, err := svc.SetAttribute(r.Context(), taskID, body.Key, body.Value) + if err != nil { + writeMutationError(w, err) + return + } + writeJSON(w, http.StatusOK, attr) + case http.MethodDelete: + key := r.URL.Query().Get("key") + if key == "" { + writeError(w, http.StatusBadRequest, "missing required query parameter: key") + return + } + if err := svc.DeleteAttribute(r.Context(), taskID, key); err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusNoContent, nil) + default: + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + } +} + +// AttachmentHandler handles the /api/attachments/{id} family: +// +// DELETE /api/attachments/{id} +// GET /api/attachments/{id}/download (streams the decoded file bytes) +func AttachmentHandler(svc *service.TaskService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if id, ok := parseAttachmentDownloadID(r.URL.Path); ok { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + serveAttachmentDownload(w, r, svc, id) + return + } + + id, ok := parseAttachmentID(r.URL.Path) + if !ok { + writeError(w, http.StatusNotFound, "not found") + return + } + if r.Method != http.MethodDelete { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + if err := svc.DeleteAttachment(r.Context(), id); err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusNoContent, nil) + } +} + +// serveAttachmentDownload streams a file attachment's decoded bytes with a +// Content-Disposition header that prompts the browser to download it. +func serveAttachmentDownload(w http.ResponseWriter, r *http.Request, svc *service.TaskService, id int64) { + att, err := svc.GetAttachment(r.Context(), id) + if err != nil { + writeServiceError(w, err) + return + } + if att.Type != db.AttachmentTypeFile { + writeError(w, http.StatusBadRequest, "attachment is not a file") + return + } + data, err := base64.StdEncoding.DecodeString(att.Content) + if err != nil { + writeError(w, http.StatusInternalServerError, "decoding attachment content") + return + } + + filename := att.Filename + if filename == "" { + filename = "download" + } + ctype := mime.TypeByExtension(filepath.Ext(filename)) + if ctype == "" { + ctype = "application/octet-stream" + } + w.Header().Set("Content-Type", ctype) + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) + w.Header().Set("Content-Length", strconv.Itoa(len(data))) + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(data) +} + +// BoardHandler handles GET /api/board?board={key}, returning the full state of a +// single board (default board when ?board= is omitted). +func BoardHandler(svc *service.TaskService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + board, err := svc.GetBoard(r.Context(), r.URL.Query().Get("board")) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, board) + } +} + +// BoardsHandler handles /api/boards: GET (list all boards' metadata) and POST +// (create a new board). +func BoardsHandler(svc *service.TaskService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + boards, err := svc.ListBoards(r.Context()) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, boards) + case http.MethodPost: + var body createBoardBody + if err := decodeJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + board, err := svc.CreateBoard(r.Context(), service.CreateBoardRequest{ + Key: body.Key, + Name: body.Name, + Description: body.Description, + Scope: body.Scope, + Owner: body.Owner, + Columns: body.Columns, + Subtasks: body.Subtasks, + }) + if err != nil { + writeMutationError(w, err) + return + } + writeJSON(w, http.StatusCreated, board) + default: + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + } + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// decodeJSON decodes the request body into v, rejecting unknown fields and +// returning a descriptive error on malformed input. +func decodeJSON(r *http.Request, v any) error { + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + if err := dec.Decode(v); err != nil { + return errors.New("invalid request body: " + err.Error()) + } + return nil +} + +// parseTaskPath splits /api/tasks/{id}[/{sub}] into its numeric id and an +// optional sub-route ("subtasks", "attachments", or "attributes"). It returns +// ok=false for a missing/invalid id or a path with more than one trailing segment. +func parseTaskPath(path string) (id int64, sub string, ok bool) { + rest := strings.TrimPrefix(path, "/api/tasks/") + if rest == "" || rest == path { + return 0, "", false + } + rest = strings.TrimSuffix(rest, "/") + parts := strings.Split(rest, "/") + if len(parts) == 0 || len(parts) > 2 { + return 0, "", false + } + parsed, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + return 0, "", false + } + if len(parts) == 2 { + sub = parts[1] + } + return parsed, sub, true +} + +// parseAttachmentID extracts the numeric id from /api/attachments/{id}. +func parseAttachmentID(path string) (int64, bool) { + return parseSingleID(path, "/api/attachments/") +} + +// parseAttachmentDownloadID extracts {id} from /api/attachments/{id}/download. +func parseAttachmentDownloadID(path string) (int64, bool) { + rest := strings.TrimPrefix(path, "/api/attachments/") + if rest == path { + return 0, false + } + rest = strings.TrimSuffix(rest, "/") + idStr, ok := strings.CutSuffix(rest, "/download") + if !ok || strings.Contains(idStr, "/") { + return 0, false + } + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + return 0, false + } + return id, true +} + +// parseSubtaskID extracts the numeric id from /api/subtasks/{id}. +func parseSubtaskID(path string) (int64, bool) { + return parseSingleID(path, "/api/subtasks/") +} + +// parseSingleID extracts a single numeric id segment after the given prefix, +// rejecting empty values and any nested sub-path. +func parseSingleID(path, prefix string) (int64, bool) { + rest := strings.TrimPrefix(path, prefix) + if rest == "" || rest == path { + return 0, false + } + rest = strings.TrimSuffix(rest, "/") + if strings.Contains(rest, "/") { + return 0, false + } + id, err := strconv.ParseInt(rest, 10, 64) + if err != nil { + return 0, false + } + return id, true +} diff --git a/go/plugins/kanban-mcp/internal/api/handlers_test.go b/go/plugins/kanban-mcp/internal/api/handlers_test.go new file mode 100644 index 0000000000..fd2ae8de7b --- /dev/null +++ b/go/plugins/kanban-mcp/internal/api/handlers_test.go @@ -0,0 +1,581 @@ +package api + +import ( + "bufio" + "bytes" + "context" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os/exec" + "strconv" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + testcontainers "github.com/testcontainers/testcontainers-go" + tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres" + "github.com/testcontainers/testcontainers-go/wait" + + dbgen "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/db/gen" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/migrations" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/service" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/sse" +) + +// startPostgres starts a Postgres container, runs the kanban migrations, and +// returns a connection string. Tests skip when Docker is not available. This is +// a thin local copy of go/core/internal/dbtest (which cannot be imported across +// the internal/ boundary), mirroring the other package test helpers. +func startPostgres(ctx context.Context, t *testing.T) string { + t.Helper() + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available, skipping container test") + } + + pgContainer, err := tcpostgres.Run(ctx, + "postgres:16-alpine", + tcpostgres.WithDatabase("kanban_test"), + tcpostgres.WithUsername("postgres"), + tcpostgres.WithPassword("kanban"), + testcontainers.WithWaitStrategy( + wait.ForLog("database system is ready to accept connections"). + WithOccurrence(2). + WithStartupTimeout(60*time.Second), + ), + ) + if err != nil { + t.Fatalf("starting postgres container: %v", err) + } + t.Cleanup(func() { + if err := pgContainer.Terminate(context.Background()); err != nil { + t.Logf("warning: failed to terminate postgres container: %v", err) + } + }) + + connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable") + if err != nil { + t.Fatalf("getting connection string: %v", err) + } + + if err := migrations.RunUp(connStr); err != nil { + t.Fatalf("running migrations: %v", err) + } + return connStr +} + +// newTestServer starts Postgres, wires the REST handlers onto a mux, and returns +// an httptest.Server plus the live TaskService for direct verification. +func newTestServer(ctx context.Context, t *testing.T) (*httptest.Server, *service.TaskService) { + t.Helper() + url := startPostgres(ctx, t) + + pool, err := pgxpool.New(ctx, url) + if err != nil { + t.Fatalf("creating pool: %v", err) + } + t.Cleanup(pool.Close) + + var svc *service.TaskService + hub := sse.NewHub(func(board string) any { + state, berr := svc.GetBoard(context.Background(), board) + if berr != nil { + return &service.BoardState{Columns: []service.Column{}} + } + return state + }) + svc = service.NewTaskService(dbgen.New(pool), pool, hub) + + mux := http.NewServeMux() + mux.HandleFunc("/events", hub.ServeSSE) + mux.HandleFunc("/api/tasks", TasksHandler(svc)) + mux.HandleFunc("/api/tasks/", TaskHandler(svc)) + mux.HandleFunc("/api/subtasks/", SubtaskHandler(svc)) + mux.HandleFunc("/api/attachments/", AttachmentHandler(svc)) + mux.HandleFunc("/api/board", BoardHandler(svc)) + mux.HandleFunc("/api/boards", BoardsHandler(svc)) + + ts := httptest.NewServer(mux) + t.Cleanup(ts.Close) + return ts, svc +} + +// doReq performs an HTTP request with an optional JSON body and returns the +// status code and raw body. +func doReq(t *testing.T, method, url string, body any) (int, []byte) { + t.Helper() + var rdr io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshaling body: %v", err) + } + rdr = bytes.NewReader(b) + } + req, err := http.NewRequest(method, url, rdr) + if err != nil { + t.Fatalf("building %s %s: %v", method, url, err) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("%s %s: %v", method, url, err) + } + defer func() { _ = resp.Body.Close() }() + out, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("reading body: %v", err) + } + return resp.StatusCode, out +} + +func decodeTask(t *testing.T, b []byte) *service.Task { + t.Helper() + var task service.Task + if err := json.Unmarshal(b, &task); err != nil { + t.Fatalf("decoding task: %v (body=%s)", err, b) + } + return &task +} + +func TestREST_CreateTask(t *testing.T) { + ctx := context.Background() + ts, _ := newTestServer(ctx, t) + + status, body := doReq(t, http.MethodPost, ts.URL+"/api/tasks", map[string]any{ + "title": "Ship it", + "status": "Inbox", + }) + if status != http.StatusCreated { + t.Fatalf("status = %d, want 201 (body=%s)", status, body) + } + task := decodeTask(t, body) + if task.ID == 0 || task.Title != "Ship it" || task.Status != "Inbox" { + t.Fatalf("unexpected task: %+v", task) + } +} + +func TestREST_GetTask(t *testing.T) { + ctx := context.Background() + ts, svc := newTestServer(ctx, t) + + created, err := svc.CreateTask(ctx, "", service.CreateTaskRequest{Title: "T"}) + if err != nil { + t.Fatalf("seeding task: %v", err) + } + + tests := []struct { + name string + id string + wantStatus int + }{ + {name: "found", id: itoa(created.ID), wantStatus: http.StatusOK}, + {name: "missing", id: "99999", wantStatus: http.StatusNotFound}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + status, _ := doReq(t, http.MethodGet, ts.URL+"/api/tasks/"+tt.id, nil) + if status != tt.wantStatus { + t.Fatalf("status = %d, want %d", status, tt.wantStatus) + } + }) + } +} + +func TestREST_UpdateTask(t *testing.T) { + ctx := context.Background() + ts, svc := newTestServer(ctx, t) + + created, err := svc.CreateTask(ctx, "", service.CreateTaskRequest{Title: "T"}) + if err != nil { + t.Fatalf("seeding task: %v", err) + } + + status, body := doReq(t, http.MethodPut, ts.URL+"/api/tasks/"+itoa(created.ID), + map[string]any{"status": "Plan"}) + if status != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%s)", status, body) + } + if got := decodeTask(t, body).Status; got != "Plan" { + t.Fatalf("status = %q, want Plan", got) + } +} + +func TestREST_UpdateTask_InvalidStatus(t *testing.T) { + ctx := context.Background() + ts, svc := newTestServer(ctx, t) + + created, err := svc.CreateTask(ctx, "", service.CreateTaskRequest{Title: "T"}) + if err != nil { + t.Fatalf("seeding task: %v", err) + } + + status, _ := doReq(t, http.MethodPut, ts.URL+"/api/tasks/"+itoa(created.ID), + map[string]any{"status": "Bogus"}) + if status != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", status) + } +} + +func TestREST_ListTasks_Filter(t *testing.T) { + ctx := context.Background() + ts, svc := newTestServer(ctx, t) + + if _, err := svc.CreateTask(ctx, "", service.CreateTaskRequest{Title: "a", Status: "Inbox"}); err != nil { + t.Fatalf("seed: %v", err) + } + if _, err := svc.CreateTask(ctx, "", service.CreateTaskRequest{Title: "b", Status: "Plan"}); err != nil { + t.Fatalf("seed: %v", err) + } + + status, body := doReq(t, http.MethodGet, ts.URL+"/api/tasks?status=Inbox", nil) + if status != http.StatusOK { + t.Fatalf("status = %d, want 200", status) + } + var tasks []*service.Task + if err := json.Unmarshal(body, &tasks); err != nil { + t.Fatalf("decode: %v", err) + } + if len(tasks) != 1 || tasks[0].Title != "a" { + t.Fatalf("unexpected filtered tasks: %+v", tasks) + } +} + +func TestREST_ChildTask(t *testing.T) { + ctx := context.Background() + ts, svc := newTestServer(ctx, t) + + feature, err := svc.CreateTask(ctx, "", service.CreateTaskRequest{Title: "feature"}) + if err != nil { + t.Fatalf("seed: %v", err) + } + + // Create a child Task via POST /api/tasks with parent_id. + status, body := doReq(t, http.MethodPost, ts.URL+"/api/tasks", + map[string]any{"title": "child", "parent_id": feature.ID}) + if status != http.StatusCreated { + t.Fatalf("create child task status = %d, want 201 (body=%s)", status, body) + } + child := decodeTask(t, body) + if child.ParentID == nil || *child.ParentID != feature.ID { + t.Fatalf("child parent_id = %v, want %d", child.ParentID, feature.ID) + } + if child.Kind != "task" { + t.Errorf("child kind = %q, want %q", child.Kind, "task") + } +} + +func TestREST_Subtasks(t *testing.T) { + ctx := context.Background() + ts, svc := newTestServer(ctx, t) + + feature, err := svc.CreateTask(ctx, "", service.CreateTaskRequest{Title: "feature"}) + if err != nil { + t.Fatalf("seed: %v", err) + } + task, err := svc.CreateTask(ctx, "", service.CreateTaskRequest{Title: "task", ParentID: &feature.ID}) + if err != nil { + t.Fatalf("seed task: %v", err) + } + + // Create a checklist subtask. + status, body := doReq(t, http.MethodPost, ts.URL+"/api/tasks/"+itoa(task.ID)+"/subtasks", + map[string]any{"title": "item"}) + if status != http.StatusCreated { + t.Fatalf("create subtask status = %d, want 201 (body=%s)", status, body) + } + var sub service.Subtask + if err := json.Unmarshal(body, &sub); err != nil { + t.Fatalf("decode subtask: %v (body=%s)", err, body) + } + if sub.TaskID != task.ID || sub.Done { + t.Fatalf("subtask = %+v, want task_id=%d done=false", sub, task.ID) + } + + // Toggle it done via PUT /api/subtasks/{id}. + status, body = doReq(t, http.MethodPut, ts.URL+"/api/subtasks/"+itoa(sub.ID), + map[string]any{"done": true}) + if status != http.StatusOK { + t.Fatalf("toggle subtask status = %d, want 200 (body=%s)", status, body) + } + + // List subtasks. + status, body = doReq(t, http.MethodGet, ts.URL+"/api/tasks/"+itoa(task.ID)+"/subtasks", nil) + if status != http.StatusOK { + t.Fatalf("list subtasks status = %d, want 200", status) + } + var subs []*service.Subtask + if err := json.Unmarshal(body, &subs); err != nil { + t.Fatalf("decode subtasks: %v", err) + } + if len(subs) != 1 || !subs[0].Done { + t.Fatalf("got subtasks %+v, want 1 done item", subs) + } + + // Delete it. + status, _ = doReq(t, http.MethodDelete, ts.URL+"/api/subtasks/"+itoa(sub.ID), nil) + if status != http.StatusNoContent { + t.Fatalf("delete subtask status = %d, want 204", status) + } +} + +func TestREST_DeleteTask_Cascade(t *testing.T) { + ctx := context.Background() + ts, svc := newTestServer(ctx, t) + + parent, err := svc.CreateTask(ctx, "", service.CreateTaskRequest{Title: "parent"}) + if err != nil { + t.Fatalf("seed: %v", err) + } + if _, err := svc.CreateTask(ctx, "", service.CreateTaskRequest{Title: "child", ParentID: &parent.ID}); err != nil { + t.Fatalf("seed child task: %v", err) + } + + status, _ := doReq(t, http.MethodDelete, ts.URL+"/api/tasks/"+itoa(parent.ID), nil) + if status != http.StatusNoContent { + t.Fatalf("delete status = %d, want 204", status) + } + + if _, err := svc.GetTask(ctx, parent.ID); !service.IsNotFound(err) { + t.Fatalf("parent should be gone, got err=%v", err) + } +} + +func TestREST_Board(t *testing.T) { + ctx := context.Background() + ts, svc := newTestServer(ctx, t) + + if _, err := svc.CreateTask(ctx, "", service.CreateTaskRequest{Title: "t", Status: "Inbox"}); err != nil { + t.Fatalf("seed: %v", err) + } + + status, body := doReq(t, http.MethodGet, ts.URL+"/api/board", nil) + if status != http.StatusOK { + t.Fatalf("status = %d, want 200", status) + } + var board service.BoardState + if err := json.Unmarshal(body, &board); err != nil { + t.Fatalf("decode board: %v", err) + } + if len(board.Columns) == 0 { + t.Fatal("board has no columns") + } +} + +// b64 base64-encodes a string for use as file attachment content. +func b64(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) } + +func TestREST_AddAttachment_File(t *testing.T) { + ctx := context.Background() + ts, svc := newTestServer(ctx, t) + + task, err := svc.CreateTask(ctx, "", service.CreateTaskRequest{Title: "t"}) + if err != nil { + t.Fatalf("seed: %v", err) + } + + status, body := doReq(t, http.MethodPost, ts.URL+"/api/tasks/"+itoa(task.ID)+"/attachments", + map[string]any{"type": "file", "filename": "DESIGN.md", "content": b64("# Design")}) + if status != http.StatusCreated { + t.Fatalf("status = %d, want 201 (body=%s)", status, body) + } + var att service.Attachment + if err := json.Unmarshal(body, &att); err != nil { + t.Fatalf("decode attachment: %v", err) + } + if att.Filename != "DESIGN.md" || att.Type != "file" { + t.Fatalf("unexpected attachment: %+v", att) + } +} + +func TestREST_AddAttachment_Link(t *testing.T) { + ctx := context.Background() + ts, svc := newTestServer(ctx, t) + + task, err := svc.CreateTask(ctx, "", service.CreateTaskRequest{Title: "t"}) + if err != nil { + t.Fatalf("seed: %v", err) + } + + status, body := doReq(t, http.MethodPost, ts.URL+"/api/tasks/"+itoa(task.ID)+"/attachments", + map[string]any{"type": "link", "url": "https://example.com", "title": "Session"}) + if status != http.StatusCreated { + t.Fatalf("status = %d, want 201 (body=%s)", status, body) + } +} + +func TestREST_AddAttachment_ChildTask(t *testing.T) { + ctx := context.Background() + ts, svc := newTestServer(ctx, t) + + parent, err := svc.CreateTask(ctx, "", service.CreateTaskRequest{Title: "parent"}) + if err != nil { + t.Fatalf("seed: %v", err) + } + child, err := svc.CreateTask(ctx, "", service.CreateTaskRequest{Title: "child", ParentID: &parent.ID}) + if err != nil { + t.Fatalf("seed child task: %v", err) + } + + // Attachments are valid on a child Task (it is a full card). + status, _ := doReq(t, http.MethodPost, ts.URL+"/api/tasks/"+itoa(child.ID)+"/attachments", + map[string]any{"type": "file", "filename": "a.md", "content": b64("x")}) + if status != http.StatusCreated { + t.Fatalf("status = %d, want 201", status) + } +} + +func TestREST_AddAttachment_UnsupportedType(t *testing.T) { + ctx := context.Background() + ts, svc := newTestServer(ctx, t) + + task, err := svc.CreateTask(ctx, "", service.CreateTaskRequest{Title: "t"}) + if err != nil { + t.Fatalf("seed: %v", err) + } + + status, _ := doReq(t, http.MethodPost, ts.URL+"/api/tasks/"+itoa(task.ID)+"/attachments", + map[string]any{"type": "file", "filename": "evil.exe", "content": b64("x")}) + if status != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 for unsupported file type", status) + } +} + +func TestREST_Attributes(t *testing.T) { + ctx := context.Background() + ts, svc := newTestServer(ctx, t) + + task, err := svc.CreateTask(ctx, "", service.CreateTaskRequest{Title: "t"}) + if err != nil { + t.Fatalf("seed: %v", err) + } + + // Upsert an attribute. + status, body := doReq(t, http.MethodPost, ts.URL+"/api/tasks/"+itoa(task.ID)+"/attributes", + map[string]any{"key": "priority", "value": "high"}) + if status != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%s)", status, body) + } + var attr service.Attribute + if err := json.Unmarshal(body, &attr); err != nil { + t.Fatalf("decode attribute: %v", err) + } + if attr.Key != "priority" || attr.Value != "high" { + t.Fatalf("attribute = %+v, want priority=high", attr) + } + + // Upsert replaces the value; the task then has exactly one attribute. + if status, _ := doReq(t, http.MethodPost, ts.URL+"/api/tasks/"+itoa(task.ID)+"/attributes", + map[string]any{"key": "priority", "value": "low"}); status != http.StatusOK { + t.Fatalf("upsert status = %d, want 200", status) + } + fetched, err := svc.GetTask(ctx, task.ID) + if err != nil { + t.Fatalf("GetTask: %v", err) + } + if len(fetched.Attributes) != 1 || fetched.Attributes[0].Value != "low" { + t.Fatalf("attributes = %+v, want single priority=low", fetched.Attributes) + } + + // Delete by key. + if status, _ := doReq(t, http.MethodDelete, ts.URL+"/api/tasks/"+itoa(task.ID)+"/attributes?key=priority", nil); status != http.StatusNoContent { + t.Fatalf("delete status = %d, want 204", status) + } + // Deleting a missing key is a 404. + if status, _ := doReq(t, http.MethodDelete, ts.URL+"/api/tasks/"+itoa(task.ID)+"/attributes?key=priority", nil); status != http.StatusNotFound { + t.Fatalf("delete-missing status = %d, want 404", status) + } +} + +func TestREST_DeleteAttachment(t *testing.T) { + ctx := context.Background() + ts, svc := newTestServer(ctx, t) + + task, err := svc.CreateTask(ctx, "", service.CreateTaskRequest{Title: "t"}) + if err != nil { + t.Fatalf("seed: %v", err) + } + att, err := svc.AddAttachment(ctx, task.ID, service.CreateAttachmentRequest{ + Type: "file", Filename: "a.md", Content: b64("x"), + }) + if err != nil { + t.Fatalf("seed attachment: %v", err) + } + + status, _ := doReq(t, http.MethodDelete, ts.URL+"/api/attachments/"+itoa(att.ID), nil) + if status != http.StatusNoContent { + t.Fatalf("status = %d, want 204", status) + } +} + +func TestREST_DeleteAttachment_NotFound(t *testing.T) { + ctx := context.Background() + ts, _ := newTestServer(ctx, t) + + status, _ := doReq(t, http.MethodDelete, ts.URL+"/api/attachments/99999", nil) + if status != http.StatusNotFound { + t.Fatalf("status = %d, want 404", status) + } +} + +// TestREST_SSE_AfterMutation connects to the SSE stream and verifies a board +// update is pushed after a REST mutation. +func TestREST_SSE_AfterMutation(t *testing.T) { + ctx := context.Background() + ts, _ := newTestServer(ctx, t) + + reqCtx, cancel := context.WithCancel(ctx) + defer cancel() + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, ts.URL+"/events", nil) + if err != nil { + t.Fatalf("building SSE request: %v", err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("connecting to /events: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + scanner := bufio.NewScanner(resp.Body) + // Drain the initial snapshot event. + for scanner.Scan() { + if scanner.Text() == "" { + break + } + } + + // Trigger a mutation via REST. + go func() { + _, _ = doReq(t, http.MethodPost, ts.URL+"/api/tasks", map[string]any{"title": "live"}) + }() + + done := make(chan bool, 1) + go func() { + for scanner.Scan() { + if strings.HasPrefix(scanner.Text(), "event:") || strings.HasPrefix(scanner.Text(), "data:") { + done <- true + return + } + } + done <- false + }() + + select { + case ok := <-done: + if !ok { + t.Fatal("did not receive an SSE event after mutation") + } + case <-time.After(10 * time.Second): + t.Fatal("timed out waiting for SSE event after mutation") + } +} + +// itoa formats an int64 as a decimal path segment. +func itoa(n int64) string { + return strconv.FormatInt(n, 10) +} diff --git a/go/plugins/kanban-mcp/internal/config/config.go b/go/plugins/kanban-mcp/internal/config/config.go new file mode 100644 index 0000000000..0bcb740329 --- /dev/null +++ b/go/plugins/kanban-mcp/internal/config/config.go @@ -0,0 +1,114 @@ +// Package config loads kanban-mcp server configuration from command-line flags +// with environment-variable fallback. Postgres is the only supported database; +// there is no SQLite or --db-type switch. +package config + +import ( + "flag" + "fmt" + "os" + "strconv" +) + +// Default configuration values. +const ( + defaultAddr = ":8080" + defaultTransport = "stdio" + defaultLogLevel = "info" +) + +// Environment variable names mirrored by the command-line flags. +const ( + envAddr = "KANBAN_ADDR" + envTransport = "KANBAN_TRANSPORT" + envDBURL = "KANBAN_DB_URL" + envDBURLFile = "KANBAN_DB_URL_FILE" + envLogLevel = "KANBAN_LOG_LEVEL" + envBoards = "KANBAN_BOARDS" + envBoardsFile = "KANBAN_BOARDS_FILE" + envReadonly = "KANBAN_READONLY" +) + +// Config holds the resolved runtime configuration for the kanban-mcp server. +type Config struct { + Addr string // --addr / KANBAN_ADDR, default ":8080" + Transport string // --transport / KANBAN_TRANSPORT, "http" | "stdio", default "stdio" + DBURL string // --db-url / KANBAN_DB_URL (Postgres connection URL) + DBURLFile string // --db-url-file / KANBAN_DB_URL_FILE (file containing URL; takes precedence) + LogLevel string // --log-level / KANBAN_LOG_LEVEL, default "info" + Boards string // --boards / KANBAN_BOARDS (inline JSON board definitions to seed) + BoardsFile string // --boards-file / KANBAN_BOARDS_FILE (path to a JSON file of board definitions; takes precedence) + Readonly bool // --readonly / KANBAN_READONLY, default false; serves the board UI read-only (hides the "New Task" button) +} + +// Load parses command-line flags (from os.Args[1:]) and falls back to the +// corresponding environment variables for any flag the user did not set. +func Load() (*Config, error) { + return loadArgs(os.Args[0], os.Args[1:]) +} + +// loadArgs is the testable core of Load: it parses the given args using a fresh +// FlagSet so tests can exercise it without touching the global flag state. +func loadArgs(prog string, args []string) (*Config, error) { + fs := flag.NewFlagSet(prog, flag.ContinueOnError) + + addr := fs.String("addr", defaultAddr, "listen address for the HTTP transport (env "+envAddr+")") + transport := fs.String("transport", defaultTransport, `transport: "http" or "stdio" (env `+envTransport+")") + dbURL := fs.String("db-url", "", "Postgres connection URL (env "+envDBURL+")") + dbURLFile := fs.String("db-url-file", "", "file containing the Postgres connection URL; takes precedence over --db-url (env "+envDBURLFile+")") + logLevel := fs.String("log-level", defaultLogLevel, "log level (env "+envLogLevel+")") + boards := fs.String("boards", "", "inline JSON array of board definitions to seed on startup (env "+envBoards+")") + boardsFile := fs.String("boards-file", "", "path to a JSON file of board definitions to seed; takes precedence over --boards (env "+envBoardsFile+")") + readonly := fs.Bool("readonly", false, "serve the board UI in read-only mode, hiding the \"New Task\" button (env "+envReadonly+")") + + if err := fs.Parse(args); err != nil { + return nil, fmt.Errorf("parsing flags: %w", err) + } + + // Track which flags were explicitly set so env vars only fill the gaps. + set := map[string]bool{} + fs.Visit(func(f *flag.Flag) { set[f.Name] = true }) + + cfg := &Config{ + Addr: resolve(set, "addr", *addr, envAddr, defaultAddr), + Transport: resolve(set, "transport", *transport, envTransport, defaultTransport), + DBURL: resolve(set, "db-url", *dbURL, envDBURL, ""), + DBURLFile: resolve(set, "db-url-file", *dbURLFile, envDBURLFile, ""), + LogLevel: resolve(set, "log-level", *logLevel, envLogLevel, defaultLogLevel), + Boards: resolve(set, "boards", *boards, envBoards, ""), + BoardsFile: resolve(set, "boards-file", *boardsFile, envBoardsFile, ""), + Readonly: resolveBool(set, "readonly", *readonly, envReadonly), + } + + return cfg, nil +} + +// resolveBool is the boolean counterpart of resolve: an explicitly-set flag +// wins, otherwise a parseable environment variable, otherwise the flag's +// default value. +func resolveBool(set map[string]bool, flagName string, flagValue bool, envKey string) bool { + if set[flagName] { + return flagValue + } + if v := os.Getenv(envKey); v != "" { + if b, err := strconv.ParseBool(v); err == nil { + return b + } + } + return flagValue +} + +// resolve picks the configuration value for a single field: an explicitly-set +// flag wins, otherwise a non-empty environment variable, otherwise the default. +func resolve(set map[string]bool, flagName, flagValue, envKey, def string) string { + if set[flagName] { + return flagValue + } + if v := os.Getenv(envKey); v != "" { + return v + } + if flagValue != "" { + return flagValue + } + return def +} diff --git a/go/plugins/kanban-mcp/internal/config/config_test.go b/go/plugins/kanban-mcp/internal/config/config_test.go new file mode 100644 index 0000000000..0cf7dcedea --- /dev/null +++ b/go/plugins/kanban-mcp/internal/config/config_test.go @@ -0,0 +1,106 @@ +package config + +import "testing" + +func TestLoad_Defaults(t *testing.T) { + // No flags and no env vars: every field should fall back to its default. + t.Setenv(envAddr, "") + t.Setenv(envTransport, "") + t.Setenv(envDBURL, "") + t.Setenv(envDBURLFile, "") + t.Setenv(envLogLevel, "") + + cfg, err := loadArgs("kanban-mcp", nil) + if err != nil { + t.Fatalf("loadArgs() error = %v", err) + } + + tests := []struct { + name string + got string + want string + }{ + {name: "Addr", got: cfg.Addr, want: defaultAddr}, + {name: "Transport", got: cfg.Transport, want: defaultTransport}, + {name: "DBURL", got: cfg.DBURL, want: ""}, + {name: "DBURLFile", got: cfg.DBURLFile, want: ""}, + {name: "LogLevel", got: cfg.LogLevel, want: defaultLogLevel}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.got != tt.want { + t.Errorf("%s = %q, want %q", tt.name, tt.got, tt.want) + } + }) + } +} + +func TestLoad_EnvOverride(t *testing.T) { + tests := []struct { + name string + envKey string + envVal string + field func(*Config) string + want string + }{ + {name: "addr from env", envKey: envAddr, envVal: ":9090", field: func(c *Config) string { return c.Addr }, want: ":9090"}, + {name: "transport from env", envKey: envTransport, envVal: "http", field: func(c *Config) string { return c.Transport }, want: "http"}, + {name: "db-url from env", envKey: envDBURL, envVal: "postgres://x", field: func(c *Config) string { return c.DBURL }, want: "postgres://x"}, + {name: "db-url-file from env", envKey: envDBURLFile, envVal: "/run/secret", field: func(c *Config) string { return c.DBURLFile }, want: "/run/secret"}, + {name: "log-level from env", envKey: envLogLevel, envVal: "debug", field: func(c *Config) string { return c.LogLevel }, want: "debug"}, + {name: "boards from env", envKey: envBoards, envVal: `[{"key":"a","columns":["X"]}]`, field: func(c *Config) string { return c.Boards }, want: `[{"key":"a","columns":["X"]}]`}, + {name: "boards-file from env", envKey: envBoardsFile, envVal: "/etc/kanban/boards/boards.json", field: func(c *Config) string { return c.BoardsFile }, want: "/etc/kanban/boards/boards.json"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv(tt.envKey, tt.envVal) + cfg, err := loadArgs("kanban-mcp", nil) + if err != nil { + t.Fatalf("loadArgs() error = %v", err) + } + if got := tt.field(cfg); got != tt.want { + t.Errorf("%s = %q, want %q", tt.name, got, tt.want) + } + }) + } +} + +func TestLoad_Readonly(t *testing.T) { + tests := []struct { + name string + envVal string + args []string + want bool + }{ + {name: "default off", want: false}, + {name: "env true", envVal: "true", want: true}, + {name: "env 1", envVal: "1", want: true}, + {name: "env false", envVal: "false", want: false}, + {name: "flag true", args: []string{"--readonly"}, want: true}, + {name: "flag beats env", envVal: "true", args: []string{"--readonly=false"}, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv(envReadonly, tt.envVal) + cfg, err := loadArgs("kanban-mcp", tt.args) + if err != nil { + t.Fatalf("loadArgs() error = %v", err) + } + if cfg.Readonly != tt.want { + t.Errorf("Readonly = %t, want %t", cfg.Readonly, tt.want) + } + }) + } +} + +func TestLoad_FlagBeatsEnv(t *testing.T) { + // An explicitly-set flag must win over the environment variable. + t.Setenv(envAddr, ":7070") + cfg, err := loadArgs("kanban-mcp", []string{"--addr=:6060"}) + if err != nil { + t.Fatalf("loadArgs() error = %v", err) + } + if cfg.Addr != ":6060" { + t.Errorf("Addr = %q, want %q", cfg.Addr, ":6060") + } +} diff --git a/go/plugins/kanban-mcp/internal/db/connect.go b/go/plugins/kanban-mcp/internal/db/connect.go new file mode 100644 index 0000000000..6d521608bf --- /dev/null +++ b/go/plugins/kanban-mcp/internal/db/connect.go @@ -0,0 +1,80 @@ +package db + +import ( + "context" + "fmt" + "log" + "os" + "strings" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +const ( + defaultMaxTimeout = 120 * time.Second + defaultInitialDelay = 500 * time.Millisecond + defaultMaxDelay = 5 * time.Second +) + +// Connect opens a Postgres connection pool for url and retries Ping with +// exponential backoff until the connection succeeds or defaultMaxTimeout elapses. +// Modeled on go/core/internal/database/connect.go (without the pgvector track, +// which the kanban schema does not use). +func Connect(ctx context.Context, url string) (*pgxpool.Pool, error) { + ctx, cancel := context.WithTimeout(ctx, defaultMaxTimeout) + defer cancel() + + config, err := pgxpool.ParseConfig(url) + if err != nil { + return nil, fmt.Errorf("failed to parse database URL: %w", err) + } + + pool, err := pgxpool.NewWithConfig(ctx, config) + if err != nil { + return nil, fmt.Errorf("failed to create database pool: %w", err) + } + + start := time.Now() + delay := defaultInitialDelay + for attempt := 1; ; attempt++ { + if err := pool.Ping(ctx); err == nil { + return pool, nil + } else { + log.Printf("database not ready (attempt %d, elapsed %s): %v", attempt, time.Since(start).Round(time.Second), err) + } + select { + case <-ctx.Done(): + pool.Close() + return nil, fmt.Errorf("database not ready after %s: %w", time.Since(start).Round(time.Second), ctx.Err()) + case <-time.After(delay): + } + delay *= 2 + if delay > defaultMaxDelay { + delay = defaultMaxDelay + } + } +} + +// ResolveURL returns url, unless urlFile is non-empty in which case the URL is +// read from that file (the file path takes precedence). +func ResolveURL(url, urlFile string) (string, error) { + if urlFile != "" { + return resolveURLFile(urlFile) + } + return url, nil +} + +// resolveURLFile reads a database connection URL from a file and returns the +// trimmed contents. Returns an error if the file cannot be read or is empty. +func resolveURLFile(path string) (string, error) { + content, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("reading URL file: %w", err) + } + url := strings.TrimSpace(string(content)) + if url == "" { + return "", fmt.Errorf("URL file %s is empty or contains only whitespace", path) + } + return url, nil +} diff --git a/go/plugins/kanban-mcp/internal/db/gen/attachments.sql.go b/go/plugins/kanban-mcp/internal/db/gen/attachments.sql.go new file mode 100644 index 0000000000..6843880cfe --- /dev/null +++ b/go/plugins/kanban-mcp/internal/db/gen/attachments.sql.go @@ -0,0 +1,131 @@ +// Code generated by sqlc. DO NOT EDIT. +// source: attachments.sql + +package dbgen + +import ( + "context" +) + +func scanAttachment(row interface { + Scan(dest ...interface{}) error +}) (Attachment, error) { + var i Attachment + err := row.Scan( + &i.ID, + &i.TaskID, + &i.Type, + &i.Filename, + &i.Url, + &i.Title, + &i.Content, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const addAttachment = `-- name: AddAttachment :one +INSERT INTO kanban.attachment (task_id, type, filename, url, title, content) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING id, task_id, type, filename, url, title, content, created_at, updated_at +` + +type AddAttachmentParams struct { + TaskID int64 + Type string + Filename string + Url string + Title string + Content string +} + +func (q *Queries) AddAttachment(ctx context.Context, arg AddAttachmentParams) (Attachment, error) { + row := q.db.QueryRow(ctx, addAttachment, + arg.TaskID, + arg.Type, + arg.Filename, + arg.Url, + arg.Title, + arg.Content, + ) + return scanAttachment(row) +} + +const getAttachment = `-- name: GetAttachment :one +SELECT id, task_id, type, filename, url, title, content, created_at, updated_at FROM kanban.attachment +WHERE id = $1 +` + +func (q *Queries) GetAttachment(ctx context.Context, id int64) (Attachment, error) { + row := q.db.QueryRow(ctx, getAttachment, id) + return scanAttachment(row) +} + +const listAttachments = `-- name: ListAttachments :many +SELECT id, task_id, type, filename, url, title, content, created_at, updated_at FROM kanban.attachment +WHERE task_id = $1 +ORDER BY created_at +` + +func (q *Queries) ListAttachments(ctx context.Context, taskID int64) ([]Attachment, error) { + rows, err := q.db.Query(ctx, listAttachments, taskID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Attachment + for rows.Next() { + i, err := scanAttachment(rows) + if err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const deleteAttachment = `-- name: DeleteAttachment :exec +DELETE FROM kanban.attachment +WHERE id = $1 +` + +func (q *Queries) DeleteAttachment(ctx context.Context, id int64) error { + _, err := q.db.Exec(ctx, deleteAttachment, id) + return err +} + +const getTaskAttribute = `-- name: GetTaskAttribute :one +SELECT id, task_id, type, filename, url, title, content, created_at, updated_at FROM kanban.attachment +WHERE task_id = $1 AND type = 'attribute' AND title = $2 +` + +type GetTaskAttributeParams struct { + TaskID int64 + Title string +} + +func (q *Queries) GetTaskAttribute(ctx context.Context, arg GetTaskAttributeParams) (Attachment, error) { + row := q.db.QueryRow(ctx, getTaskAttribute, arg.TaskID, arg.Title) + return scanAttachment(row) +} + +const setAttachmentContent = `-- name: SetAttachmentContent :one +UPDATE kanban.attachment +SET content = $2, updated_at = NOW() +WHERE id = $1 +RETURNING id, task_id, type, filename, url, title, content, created_at, updated_at +` + +type SetAttachmentContentParams struct { + ID int64 + Content string +} + +func (q *Queries) SetAttachmentContent(ctx context.Context, arg SetAttachmentContentParams) (Attachment, error) { + row := q.db.QueryRow(ctx, setAttachmentContent, arg.ID, arg.Content) + return scanAttachment(row) +} diff --git a/go/plugins/kanban-mcp/internal/db/gen/boards.sql.go b/go/plugins/kanban-mcp/internal/db/gen/boards.sql.go new file mode 100644 index 0000000000..72506ff438 --- /dev/null +++ b/go/plugins/kanban-mcp/internal/db/gen/boards.sql.go @@ -0,0 +1,138 @@ +// Code generated by sqlc. DO NOT EDIT. +// source: boards.sql + +package dbgen + +import ( + "context" +) + +func scanBoard(row interface { + Scan(dest ...interface{}) error +}) (Board, error) { + var i Board + err := row.Scan( + &i.ID, + &i.Key, + &i.Name, + &i.Description, + &i.Scope, + &i.Owner, + &i.Columns, + &i.CreatedAt, + &i.UpdatedAt, + &i.Subtasks, + ) + return i, err +} + +const createBoard = `-- name: CreateBoard :one +INSERT INTO kanban.board (key, name, description, scope, owner, columns, subtasks) +VALUES ($1, $2, $3, $4, $5, $6, $7) +RETURNING id, key, name, description, scope, owner, columns, created_at, updated_at, subtasks +` + +type CreateBoardParams struct { + Key string + Name string + Description string + Scope string + Owner string + Columns []string + Subtasks []string +} + +func (q *Queries) CreateBoard(ctx context.Context, arg CreateBoardParams) (Board, error) { + row := q.db.QueryRow(ctx, createBoard, + arg.Key, + arg.Name, + arg.Description, + arg.Scope, + arg.Owner, + arg.Columns, + arg.Subtasks, + ) + return scanBoard(row) +} + +const upsertBoard = `-- name: UpsertBoard :one +INSERT INTO kanban.board (key, name, description, scope, owner, columns, subtasks) +VALUES ($1, $2, $3, $4, $5, $6, $7) +ON CONFLICT (key) DO UPDATE +SET name = EXCLUDED.name, + description = EXCLUDED.description, + scope = EXCLUDED.scope, + owner = EXCLUDED.owner, + columns = EXCLUDED.columns, + subtasks = EXCLUDED.subtasks, + updated_at = NOW() +RETURNING id, key, name, description, scope, owner, columns, created_at, updated_at, subtasks +` + +type UpsertBoardParams struct { + Key string + Name string + Description string + Scope string + Owner string + Columns []string + Subtasks []string +} + +func (q *Queries) UpsertBoard(ctx context.Context, arg UpsertBoardParams) (Board, error) { + row := q.db.QueryRow(ctx, upsertBoard, + arg.Key, + arg.Name, + arg.Description, + arg.Scope, + arg.Owner, + arg.Columns, + arg.Subtasks, + ) + return scanBoard(row) +} + +const getBoardByKey = `-- name: GetBoardByKey :one +SELECT id, key, name, description, scope, owner, columns, created_at, updated_at, subtasks FROM kanban.board +WHERE key = $1 +` + +func (q *Queries) GetBoardByKey(ctx context.Context, key string) (Board, error) { + row := q.db.QueryRow(ctx, getBoardByKey, key) + return scanBoard(row) +} + +const getBoardByID = `-- name: GetBoardByID :one +SELECT id, key, name, description, scope, owner, columns, created_at, updated_at, subtasks FROM kanban.board +WHERE id = $1 +` + +func (q *Queries) GetBoardByID(ctx context.Context, id int64) (Board, error) { + row := q.db.QueryRow(ctx, getBoardByID, id) + return scanBoard(row) +} + +const listBoards = `-- name: ListBoards :many +SELECT id, key, name, description, scope, owner, columns, created_at, updated_at, subtasks FROM kanban.board +ORDER BY created_at +` + +func (q *Queries) ListBoards(ctx context.Context) ([]Board, error) { + rows, err := q.db.Query(ctx, listBoards) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Board + for rows.Next() { + i, err := scanBoard(rows) + if err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/go/plugins/kanban-mcp/internal/db/gen/db.go b/go/plugins/kanban-mcp/internal/db/gen/db.go new file mode 100644 index 0000000000..924e134627 --- /dev/null +++ b/go/plugins/kanban-mcp/internal/db/gen/db.go @@ -0,0 +1,35 @@ +// Code generated by sqlc. DO NOT EDIT. +// +// NOTE: This package is hand-written in the sqlc pgx/v5 output style so the +// kanban-mcp binary builds without requiring the sqlc binary to be installed. +// Regenerate with `sqlc generate` from internal/db/ to keep it in sync with the +// query definitions in queries/ and the schema in ../migrations/sql. +package dbgen + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +// DBTX is satisfied by both *pgxpool.Pool and pgx.Tx. +type DBTX interface { + Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) + Query(context.Context, string, ...interface{}) (pgx.Rows, error) + QueryRow(context.Context, string, ...interface{}) pgx.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx pgx.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/go/plugins/kanban-mcp/internal/db/gen/models.go b/go/plugins/kanban-mcp/internal/db/gen/models.go new file mode 100644 index 0000000000..38b449d42d --- /dev/null +++ b/go/plugins/kanban-mcp/internal/db/gen/models.go @@ -0,0 +1,56 @@ +// Code generated by sqlc. DO NOT EDIT. + +package dbgen + +import ( + "time" +) + +type Board struct { + ID int64 + Key string + Name string + Description string + Scope string + Owner string + Columns []string + CreatedAt time.Time + UpdatedAt time.Time + Subtasks []string +} + +type Task struct { + ID int64 + Title string + Description string + Status string + Assignee string + Labels []string + UserInputNeeded bool + ParentID *int64 + BoardID int64 + CreatedAt time.Time + UpdatedAt time.Time + Kind string +} + +type Attachment struct { + ID int64 + TaskID int64 + Type string + Filename string + Url string + Title string + Content string + CreatedAt time.Time + UpdatedAt time.Time +} + +type Subtask struct { + ID int64 + TaskID int64 + Title string + Done bool + CreatedAt time.Time + UpdatedAt time.Time +} diff --git a/go/plugins/kanban-mcp/internal/db/gen/querier.go b/go/plugins/kanban-mcp/internal/db/gen/querier.go new file mode 100644 index 0000000000..b2c8db8f2e --- /dev/null +++ b/go/plugins/kanban-mcp/internal/db/gen/querier.go @@ -0,0 +1,43 @@ +// Code generated by sqlc. DO NOT EDIT. + +package dbgen + +import ( + "context" +) + +type Querier interface { + // boards + CreateBoard(ctx context.Context, arg CreateBoardParams) (Board, error) + UpsertBoard(ctx context.Context, arg UpsertBoardParams) (Board, error) + GetBoardByKey(ctx context.Context, key string) (Board, error) + GetBoardByID(ctx context.Context, id int64) (Board, error) + ListBoards(ctx context.Context) ([]Board, error) + // tasks + CreateTask(ctx context.Context, arg CreateTaskParams) (Task, error) + CreateChildTask(ctx context.Context, arg CreateChildTaskParams) (Task, error) + GetTask(ctx context.Context, id int64) (Task, error) + ListBoardTasks(ctx context.Context, arg ListBoardTasksParams) ([]Task, error) + ListChildTasks(ctx context.Context, parentID *int64) ([]Task, error) + UpdateTask(ctx context.Context, arg UpdateTaskParams) (Task, error) + MoveTask(ctx context.Context, arg MoveTaskParams) (Task, error) + AssignTask(ctx context.Context, arg AssignTaskParams) (Task, error) + SetUserInputNeeded(ctx context.Context, arg SetUserInputNeededParams) (Task, error) + DeleteTask(ctx context.Context, id int64) error + // subtasks (checklist items) + CreateSubtask(ctx context.Context, arg CreateSubtaskParams) (Subtask, error) + GetSubtask(ctx context.Context, id int64) (Subtask, error) + ListSubtasksByTask(ctx context.Context, taskID int64) ([]Subtask, error) + SetSubtaskDone(ctx context.Context, arg SetSubtaskDoneParams) (Subtask, error) + UpdateSubtaskTitle(ctx context.Context, arg UpdateSubtaskTitleParams) (Subtask, error) + DeleteSubtask(ctx context.Context, id int64) error + // attachments (files, links, and key/value attributes) + AddAttachment(ctx context.Context, arg AddAttachmentParams) (Attachment, error) + GetAttachment(ctx context.Context, id int64) (Attachment, error) + ListAttachments(ctx context.Context, taskID int64) ([]Attachment, error) + DeleteAttachment(ctx context.Context, id int64) error + GetTaskAttribute(ctx context.Context, arg GetTaskAttributeParams) (Attachment, error) + SetAttachmentContent(ctx context.Context, arg SetAttachmentContentParams) (Attachment, error) +} + +var _ Querier = (*Queries)(nil) diff --git a/go/plugins/kanban-mcp/internal/db/gen/subtasks.sql.go b/go/plugins/kanban-mcp/internal/db/gen/subtasks.sql.go new file mode 100644 index 0000000000..ef6afb0192 --- /dev/null +++ b/go/plugins/kanban-mcp/internal/db/gen/subtasks.sql.go @@ -0,0 +1,121 @@ +// Code generated by sqlc. DO NOT EDIT. +// source: subtasks.sql + +package dbgen + +import ( + "context" +) + +func scanSubtask(row interface { + Scan(dest ...interface{}) error +}) (Subtask, error) { + var i Subtask + err := row.Scan( + &i.ID, + &i.TaskID, + &i.Title, + &i.Done, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const createSubtask = `-- name: CreateSubtask :one +INSERT INTO kanban.subtask (task_id, title) +VALUES ($1, $2) +RETURNING id, task_id, title, done, created_at, updated_at +` + +type CreateSubtaskParams struct { + TaskID int64 + Title string +} + +func (q *Queries) CreateSubtask(ctx context.Context, arg CreateSubtaskParams) (Subtask, error) { + row := q.db.QueryRow(ctx, createSubtask, arg.TaskID, arg.Title) + return scanSubtask(row) +} + +const getSubtask = `-- name: GetSubtask :one +SELECT id, task_id, title, done, created_at, updated_at FROM kanban.subtask +WHERE id = $1 +` + +func (q *Queries) GetSubtask(ctx context.Context, id int64) (Subtask, error) { + row := q.db.QueryRow(ctx, getSubtask, id) + return scanSubtask(row) +} + +const listSubtasksByTask = `-- name: ListSubtasksByTask :many +SELECT id, task_id, title, done, created_at, updated_at FROM kanban.subtask +WHERE task_id = $1 +ORDER BY id +` + +func (q *Queries) ListSubtasksByTask(ctx context.Context, taskID int64) ([]Subtask, error) { + rows, err := q.db.Query(ctx, listSubtasksByTask, taskID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Subtask + for rows.Next() { + i, err := scanSubtask(rows) + if err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const setSubtaskDone = `-- name: SetSubtaskDone :one +UPDATE kanban.subtask +SET done = $2, + updated_at = NOW() +WHERE id = $1 +RETURNING id, task_id, title, done, created_at, updated_at +` + +type SetSubtaskDoneParams struct { + ID int64 + Done bool +} + +func (q *Queries) SetSubtaskDone(ctx context.Context, arg SetSubtaskDoneParams) (Subtask, error) { + row := q.db.QueryRow(ctx, setSubtaskDone, arg.ID, arg.Done) + return scanSubtask(row) +} + +const updateSubtaskTitle = `-- name: UpdateSubtaskTitle :one +UPDATE kanban.subtask +SET title = $2, + updated_at = NOW() +WHERE id = $1 +RETURNING id, task_id, title, done, created_at, updated_at +` + +type UpdateSubtaskTitleParams struct { + ID int64 + Title string +} + +func (q *Queries) UpdateSubtaskTitle(ctx context.Context, arg UpdateSubtaskTitleParams) (Subtask, error) { + row := q.db.QueryRow(ctx, updateSubtaskTitle, arg.ID, arg.Title) + return scanSubtask(row) +} + +const deleteSubtask = `-- name: DeleteSubtask :exec +DELETE FROM kanban.subtask +WHERE id = $1 +` + +func (q *Queries) DeleteSubtask(ctx context.Context, id int64) error { + _, err := q.db.Exec(ctx, deleteSubtask, id) + return err +} diff --git a/go/plugins/kanban-mcp/internal/db/gen/tasks.sql.go b/go/plugins/kanban-mcp/internal/db/gen/tasks.sql.go new file mode 100644 index 0000000000..f520e5b81e --- /dev/null +++ b/go/plugins/kanban-mcp/internal/db/gen/tasks.sql.go @@ -0,0 +1,255 @@ +// Code generated by sqlc. DO NOT EDIT. +// source: tasks.sql + +package dbgen + +import ( + "context" +) + +func scanTask(row interface { + Scan(dest ...interface{}) error +}) (Task, error) { + var i Task + err := row.Scan( + &i.ID, + &i.Title, + &i.Description, + &i.Status, + &i.Assignee, + &i.Labels, + &i.UserInputNeeded, + &i.ParentID, + &i.BoardID, + &i.CreatedAt, + &i.UpdatedAt, + &i.Kind, + ) + return i, err +} + +const createTask = `-- name: CreateTask :one +INSERT INTO kanban.task (title, description, status, labels, board_id, kind) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING id, title, description, status, assignee, labels, user_input_needed, parent_id, board_id, created_at, updated_at, kind +` + +type CreateTaskParams struct { + Title string + Description string + Status string + Labels []string + BoardID int64 + Kind string +} + +func (q *Queries) CreateTask(ctx context.Context, arg CreateTaskParams) (Task, error) { + row := q.db.QueryRow(ctx, createTask, + arg.Title, + arg.Description, + arg.Status, + arg.Labels, + arg.BoardID, + arg.Kind, + ) + return scanTask(row) +} + +const createChildTask = `-- name: CreateChildTask :one +INSERT INTO kanban.task (title, description, status, labels, parent_id, board_id, kind) +VALUES ($1, $2, $3, $4, $5, $6, 'task') +RETURNING id, title, description, status, assignee, labels, user_input_needed, parent_id, board_id, created_at, updated_at, kind +` + +type CreateChildTaskParams struct { + Title string + Description string + Status string + Labels []string + ParentID *int64 + BoardID int64 +} + +func (q *Queries) CreateChildTask(ctx context.Context, arg CreateChildTaskParams) (Task, error) { + row := q.db.QueryRow(ctx, createChildTask, + arg.Title, + arg.Description, + arg.Status, + arg.Labels, + arg.ParentID, + arg.BoardID, + ) + return scanTask(row) +} + +const getTask = `-- name: GetTask :one +SELECT id, title, description, status, assignee, labels, user_input_needed, parent_id, board_id, created_at, updated_at, kind FROM kanban.task +WHERE id = $1 +` + +func (q *Queries) GetTask(ctx context.Context, id int64) (Task, error) { + row := q.db.QueryRow(ctx, getTask, id) + return scanTask(row) +} + +const listBoardTasks = `-- name: ListBoardTasks :many +SELECT id, title, description, status, assignee, labels, user_input_needed, parent_id, board_id, created_at, updated_at, kind FROM kanban.task +WHERE board_id = $1 + AND ($2::text IS NULL OR status = $2) + AND ($3::text IS NULL OR assignee = $3) + AND ($4::text IS NULL OR $4 = ANY(labels)) +ORDER BY created_at +` + +type ListBoardTasksParams struct { + BoardID int64 + Status *string + Assignee *string + Label *string +} + +func (q *Queries) ListBoardTasks(ctx context.Context, arg ListBoardTasksParams) ([]Task, error) { + rows, err := q.db.Query(ctx, listBoardTasks, arg.BoardID, arg.Status, arg.Assignee, arg.Label) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Task + for rows.Next() { + i, err := scanTask(rows) + if err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listChildTasks = `-- name: ListChildTasks :many +SELECT id, title, description, status, assignee, labels, user_input_needed, parent_id, board_id, created_at, updated_at, kind FROM kanban.task +WHERE parent_id = $1 +ORDER BY created_at +` + +func (q *Queries) ListChildTasks(ctx context.Context, parentID *int64) ([]Task, error) { + rows, err := q.db.Query(ctx, listChildTasks, parentID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Task + for rows.Next() { + i, err := scanTask(rows) + if err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateTask = `-- name: UpdateTask :one +UPDATE kanban.task +SET title = $2, + description = $3, + status = $4, + assignee = $5, + labels = $6, + user_input_needed = $7, + updated_at = NOW() +WHERE id = $1 +RETURNING id, title, description, status, assignee, labels, user_input_needed, parent_id, board_id, created_at, updated_at, kind +` + +type UpdateTaskParams struct { + ID int64 + Title string + Description string + Status string + Assignee string + Labels []string + UserInputNeeded bool +} + +func (q *Queries) UpdateTask(ctx context.Context, arg UpdateTaskParams) (Task, error) { + row := q.db.QueryRow(ctx, updateTask, + arg.ID, + arg.Title, + arg.Description, + arg.Status, + arg.Assignee, + arg.Labels, + arg.UserInputNeeded, + ) + return scanTask(row) +} + +const moveTask = `-- name: MoveTask :one +UPDATE kanban.task +SET status = $2, + updated_at = NOW() +WHERE id = $1 +RETURNING id, title, description, status, assignee, labels, user_input_needed, parent_id, board_id, created_at, updated_at, kind +` + +type MoveTaskParams struct { + ID int64 + Status string +} + +func (q *Queries) MoveTask(ctx context.Context, arg MoveTaskParams) (Task, error) { + row := q.db.QueryRow(ctx, moveTask, arg.ID, arg.Status) + return scanTask(row) +} + +const assignTask = `-- name: AssignTask :one +UPDATE kanban.task +SET assignee = $2, + updated_at = NOW() +WHERE id = $1 +RETURNING id, title, description, status, assignee, labels, user_input_needed, parent_id, board_id, created_at, updated_at, kind +` + +type AssignTaskParams struct { + ID int64 + Assignee string +} + +func (q *Queries) AssignTask(ctx context.Context, arg AssignTaskParams) (Task, error) { + row := q.db.QueryRow(ctx, assignTask, arg.ID, arg.Assignee) + return scanTask(row) +} + +const setUserInputNeeded = `-- name: SetUserInputNeeded :one +UPDATE kanban.task +SET user_input_needed = $2, + updated_at = NOW() +WHERE id = $1 +RETURNING id, title, description, status, assignee, labels, user_input_needed, parent_id, board_id, created_at, updated_at, kind +` + +type SetUserInputNeededParams struct { + ID int64 + UserInputNeeded bool +} + +func (q *Queries) SetUserInputNeeded(ctx context.Context, arg SetUserInputNeededParams) (Task, error) { + row := q.db.QueryRow(ctx, setUserInputNeeded, arg.ID, arg.UserInputNeeded) + return scanTask(row) +} + +const deleteTask = `-- name: DeleteTask :exec +DELETE FROM kanban.task +WHERE id = $1 +` + +func (q *Queries) DeleteTask(ctx context.Context, id int64) error { + _, err := q.db.Exec(ctx, deleteTask, id) + return err +} diff --git a/go/plugins/kanban-mcp/internal/db/queries/attachments.sql b/go/plugins/kanban-mcp/internal/db/queries/attachments.sql new file mode 100644 index 0000000000..8b2651d672 --- /dev/null +++ b/go/plugins/kanban-mcp/internal/db/queries/attachments.sql @@ -0,0 +1,27 @@ +-- name: AddAttachment :one +INSERT INTO kanban.attachment (task_id, type, filename, url, title, content) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING *; + +-- name: GetAttachment :one +SELECT * FROM kanban.attachment +WHERE id = $1; + +-- name: ListAttachments :many +SELECT * FROM kanban.attachment +WHERE task_id = $1 +ORDER BY created_at; + +-- name: DeleteAttachment :exec +DELETE FROM kanban.attachment +WHERE id = $1; + +-- name: GetTaskAttribute :one +SELECT * FROM kanban.attachment +WHERE task_id = $1 AND type = 'attribute' AND title = $2; + +-- name: SetAttachmentContent :one +UPDATE kanban.attachment +SET content = $2, updated_at = NOW() +WHERE id = $1 +RETURNING *; diff --git a/go/plugins/kanban-mcp/internal/db/queries/boards.sql b/go/plugins/kanban-mcp/internal/db/queries/boards.sql new file mode 100644 index 0000000000..9a208800bf --- /dev/null +++ b/go/plugins/kanban-mcp/internal/db/queries/boards.sql @@ -0,0 +1,29 @@ +-- name: CreateBoard :one +INSERT INTO kanban.board (key, name, description, scope, owner, columns, subtasks) +VALUES ($1, $2, $3, $4, $5, $6, $7) +RETURNING *; + +-- name: UpsertBoard :one +INSERT INTO kanban.board (key, name, description, scope, owner, columns, subtasks) +VALUES ($1, $2, $3, $4, $5, $6, $7) +ON CONFLICT (key) DO UPDATE +SET name = EXCLUDED.name, + description = EXCLUDED.description, + scope = EXCLUDED.scope, + owner = EXCLUDED.owner, + columns = EXCLUDED.columns, + subtasks = EXCLUDED.subtasks, + updated_at = NOW() +RETURNING *; + +-- name: GetBoardByKey :one +SELECT * FROM kanban.board +WHERE key = $1; + +-- name: GetBoardByID :one +SELECT * FROM kanban.board +WHERE id = $1; + +-- name: ListBoards :many +SELECT * FROM kanban.board +ORDER BY created_at; diff --git a/go/plugins/kanban-mcp/internal/db/queries/subtasks.sql b/go/plugins/kanban-mcp/internal/db/queries/subtasks.sql new file mode 100644 index 0000000000..ea00332035 --- /dev/null +++ b/go/plugins/kanban-mcp/internal/db/queries/subtasks.sql @@ -0,0 +1,31 @@ +-- name: CreateSubtask :one +INSERT INTO kanban.subtask (task_id, title) +VALUES ($1, $2) +RETURNING *; + +-- name: GetSubtask :one +SELECT * FROM kanban.subtask +WHERE id = $1; + +-- name: ListSubtasksByTask :many +SELECT * FROM kanban.subtask +WHERE task_id = $1 +ORDER BY id; + +-- name: SetSubtaskDone :one +UPDATE kanban.subtask +SET done = $2, + updated_at = NOW() +WHERE id = $1 +RETURNING *; + +-- name: UpdateSubtaskTitle :one +UPDATE kanban.subtask +SET title = $2, + updated_at = NOW() +WHERE id = $1 +RETURNING *; + +-- name: DeleteSubtask :exec +DELETE FROM kanban.subtask +WHERE id = $1; diff --git a/go/plugins/kanban-mcp/internal/db/queries/tasks.sql b/go/plugins/kanban-mcp/internal/db/queries/tasks.sql new file mode 100644 index 0000000000..4ae15b8db8 --- /dev/null +++ b/go/plugins/kanban-mcp/internal/db/queries/tasks.sql @@ -0,0 +1,63 @@ +-- name: CreateTask :one +INSERT INTO kanban.task (title, description, status, labels, board_id, kind) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING *; + +-- name: CreateChildTask :one +INSERT INTO kanban.task (title, description, status, labels, parent_id, board_id, kind) +VALUES ($1, $2, $3, $4, $5, $6, 'task') +RETURNING *; + +-- name: GetTask :one +SELECT * FROM kanban.task +WHERE id = $1; + +-- name: ListBoardTasks :many +SELECT * FROM kanban.task +WHERE board_id = sqlc.arg('board_id') + AND (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status')) + AND (sqlc.narg('assignee')::text IS NULL OR assignee = sqlc.narg('assignee')) + AND (sqlc.narg('label')::text IS NULL OR sqlc.narg('label') = ANY(labels)) +ORDER BY created_at; + +-- name: ListChildTasks :many +SELECT * FROM kanban.task +WHERE parent_id = $1 +ORDER BY created_at; + +-- name: UpdateTask :one +UPDATE kanban.task +SET title = $2, + description = $3, + status = $4, + assignee = $5, + labels = $6, + user_input_needed = $7, + updated_at = NOW() +WHERE id = $1 +RETURNING *; + +-- name: MoveTask :one +UPDATE kanban.task +SET status = $2, + updated_at = NOW() +WHERE id = $1 +RETURNING *; + +-- name: AssignTask :one +UPDATE kanban.task +SET assignee = $2, + updated_at = NOW() +WHERE id = $1 +RETURNING *; + +-- name: SetUserInputNeeded :one +UPDATE kanban.task +SET user_input_needed = $2, + updated_at = NOW() +WHERE id = $1 +RETURNING *; + +-- name: DeleteTask :exec +DELETE FROM kanban.task +WHERE id = $1; diff --git a/go/plugins/kanban-mcp/internal/db/sqlc.yaml b/go/plugins/kanban-mcp/internal/db/sqlc.yaml new file mode 100644 index 0000000000..219547c84e --- /dev/null +++ b/go/plugins/kanban-mcp/internal/db/sqlc.yaml @@ -0,0 +1,24 @@ +version: "2" +sql: + - schema: ["../migrations/sql"] + queries: "queries" + engine: "postgresql" + gen: + go: + package: "dbgen" + out: "gen" + sql_package: "pgx/v5" + emit_interface: true + emit_pointers_for_null_types: true + overrides: + # pgx/v5 native mode maps all timestamptz → pgtype.Timestamptz by default. + # Override to keep idiomatic time.Time / *time.Time throughout. + - db_type: "timestamptz" + nullable: false + go_type: + type: "time.Time" + - db_type: "timestamptz" + nullable: true + go_type: + type: "time.Time" + pointer: true diff --git a/go/plugins/kanban-mcp/internal/db/status.go b/go/plugins/kanban-mcp/internal/db/status.go new file mode 100644 index 0000000000..7f75e861fc --- /dev/null +++ b/go/plugins/kanban-mcp/internal/db/status.go @@ -0,0 +1,132 @@ +// Package db provides Postgres connection helpers and pure status/type logic +// for the kanban-mcp server. It is the local analogue of go/core/internal/database +// (which cannot be imported across the internal/ boundary). +package db + +import ( + "path/filepath" + "slices" + "strings" +) + +// TaskStatus is one of the fixed kanban workflow columns. +type TaskStatus string + +const ( + StatusInbox TaskStatus = "Inbox" + StatusPlan TaskStatus = "Plan" + StatusDevelop TaskStatus = "Develop" + StatusTesting TaskStatus = "Testing" + StatusCodeReview TaskStatus = "CodeReview" + StatusRelease TaskStatus = "Release" + StatusDone TaskStatus = "Done" +) + +// StatusWorkflow lists the statuses in their canonical board order. The board UI +// and the move-next/move-prev semantics rely on this ordering. +var StatusWorkflow = []TaskStatus{ + StatusInbox, + StatusPlan, + StatusDevelop, + StatusTesting, + StatusCodeReview, + StatusRelease, + StatusDone, +} + +// ValidStatus reports whether s is one of the known workflow statuses. +func ValidStatus(s TaskStatus) bool { + return slices.Contains(StatusWorkflow, s) +} + +// DefaultColumns is the ordered column set seeded for the built-in "default" +// board. It mirrors StatusWorkflow as plain strings so it can be used directly as +// a board's columns (boards store columns as TEXT[] / []string, not TaskStatus). +var DefaultColumns = func() []string { + cols := make([]string, len(StatusWorkflow)) + for i, s := range StatusWorkflow { + cols[i] = string(s) + } + return cols +}() + +// Board scope values. Boards are either shared ("general") or bound to a named +// agent ("agent") for UI grouping / convention. There is no access control. +const ( + BoardScopeGeneral = "general" + BoardScopeAgent = "agent" +) + +// DefaultBoardKey is the key of the built-in board created by migration 000002, +// used as the fallback board when a caller omits an explicit board key. +const DefaultBoardKey = "default" + +// ValidColumn reports whether s is one of the given board columns. +func ValidColumn(columns []string, s string) bool { + return slices.Contains(columns, s) +} + +// ValidScope reports whether s is a known board scope. +func ValidScope(s string) bool { + return s == BoardScopeGeneral || s == BoardScopeAgent +} + +// Task kinds, persisted in the task.kind column. A Feature is a top-level card; +// a Task is either a child of a Feature (parent_id set) or a standalone top-level +// card (parent_id NULL). Both are full kanban cards; only the Task level can +// carry checklist subtasks. +const ( + KindFeature = "feature" + KindTask = "task" +) + +// AttachmentType is the kind of row stored in kanban.attachment. The table backs +// three shapes distinguished by this column: file (filename + base64 content), +// link (url + title), and attribute (a key/value pair: title=key, content=value). +type AttachmentType string + +const ( + AttachmentTypeFile AttachmentType = "file" + AttachmentTypeLink AttachmentType = "link" + AttachmentTypeAttribute AttachmentType = "attribute" +) + +// ValidUserAttachmentType reports whether t is a type accepted by add_attachment. +// Attributes are excluded: they are managed via set_attribute / delete_attribute. +func ValidUserAttachmentType(t AttachmentType) bool { + return t == AttachmentTypeFile || t == AttachmentTypeLink +} + +// AllowedFileExtensions is the set of filename extensions (lower-case, with the +// leading dot) accepted for file attachments. Text types render inline in the UI; +// binary types (pdf/docx/xlsx) are offered as downloads. +var AllowedFileExtensions = map[string]bool{ + ".md": true, + ".markdown": true, + ".html": true, + ".htm": true, + ".txt": true, + ".yaml": true, + ".yml": true, + ".csv": true, + ".pdf": true, + ".docx": true, + ".xlsx": true, +} + +// ValidFileExtension reports whether filename ends in an allowed extension +// (case-insensitive). +func ValidFileExtension(filename string) bool { + return AllowedFileExtensions[strings.ToLower(filepath.Ext(filename))] +} + +// AllowedFileExtensionList returns the allowed extensions as a sorted, +// comma-separated string for use in error messages. +func AllowedFileExtensionList() string { + exts := make([]string, 0, len(AllowedFileExtensions)) + for e := range AllowedFileExtensions { + exts = append(exts, e) + } + slices.Sort(exts) + return strings.Join(exts, ", ") +} diff --git a/go/plugins/kanban-mcp/internal/db/status_test.go b/go/plugins/kanban-mcp/internal/db/status_test.go new file mode 100644 index 0000000000..14562122a7 --- /dev/null +++ b/go/plugins/kanban-mcp/internal/db/status_test.go @@ -0,0 +1,87 @@ +package db + +import "testing" + +func TestValidStatus(t *testing.T) { + tests := []struct { + name string + status TaskStatus + want bool + }{ + {name: "inbox", status: StatusInbox, want: true}, + {name: "plan", status: StatusPlan, want: true}, + {name: "develop", status: StatusDevelop, want: true}, + {name: "testing", status: StatusTesting, want: true}, + {name: "code review", status: StatusCodeReview, want: true}, + {name: "release", status: StatusRelease, want: true}, + {name: "done", status: StatusDone, want: true}, + {name: "empty", status: TaskStatus(""), want: false}, + {name: "invalid", status: TaskStatus("invalid"), want: false}, + {name: "wrong case", status: TaskStatus("inbox"), want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ValidStatus(tt.status); got != tt.want { + t.Errorf("ValidStatus(%q) = %v, want %v", tt.status, got, tt.want) + } + }) + } +} + +func TestStatusWorkflow_Order(t *testing.T) { + want := []TaskStatus{ + StatusInbox, StatusPlan, StatusDevelop, StatusTesting, + StatusCodeReview, StatusRelease, StatusDone, + } + if len(StatusWorkflow) != len(want) { + t.Fatalf("StatusWorkflow length = %d, want %d", len(StatusWorkflow), len(want)) + } + for i := range want { + if StatusWorkflow[i] != want[i] { + t.Errorf("StatusWorkflow[%d] = %q, want %q", i, StatusWorkflow[i], want[i]) + } + } +} + +func TestValidUserAttachmentType(t *testing.T) { + tests := []struct { + name string + typ AttachmentType + want bool + }{ + {name: "file", typ: AttachmentTypeFile, want: true}, + {name: "link", typ: AttachmentTypeLink, want: true}, + {name: "attribute not user-addable", typ: AttachmentTypeAttribute, want: false}, + {name: "empty", typ: AttachmentType(""), want: false}, + {name: "invalid", typ: AttachmentType("image"), want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ValidUserAttachmentType(tt.typ); got != tt.want { + t.Errorf("ValidUserAttachmentType(%q) = %v, want %v", tt.typ, got, tt.want) + } + }) + } +} + +func TestValidFileExtension(t *testing.T) { + tests := []struct { + name string + filename string + want bool + }{ + {name: "markdown", filename: "DESIGN.md", want: true}, + {name: "uppercase pdf", filename: "report.PDF", want: true}, + {name: "xlsx", filename: "data.xlsx", want: true}, + {name: "no extension", filename: "README", want: false}, + {name: "disallowed", filename: "evil.exe", want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ValidFileExtension(tt.filename); got != tt.want { + t.Errorf("ValidFileExtension(%q) = %v, want %v", tt.filename, got, tt.want) + } + }) + } +} diff --git a/go/plugins/kanban-mcp/internal/integration/integration_test.go b/go/plugins/kanban-mcp/internal/integration/integration_test.go new file mode 100644 index 0000000000..2fcc619a91 --- /dev/null +++ b/go/plugins/kanban-mcp/internal/integration/integration_test.go @@ -0,0 +1,364 @@ +// Package integration holds an end-to-end Postgres integration test for the +// kanban-mcp server (Step 11). It exercises the full TaskService workflow — +// create, update, move, assign, subtask, attachment, attribute and cascade delete — +// against a real Postgres instance. +// +// The test uses a testcontainers Postgres by default and is skipped when Docker +// is unavailable. Set KANBAN_TEST_POSTGRES_URL to run against an existing +// Postgres instead (the test isolates itself by re-running migrations, which is +// a no-op if the kanban schema already exists). +package integration + +import ( + "context" + "encoding/base64" + "os" + "os/exec" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + testcontainers "github.com/testcontainers/testcontainers-go" + tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres" + "github.com/testcontainers/testcontainers-go/wait" + + kdb "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/db" + dbgen "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/db/gen" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/migrations" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/service" +) + +// postgresURL returns a Postgres connection string, preferring the +// KANBAN_TEST_POSTGRES_URL env var and otherwise starting a testcontainer. +// Tests skip when neither is available. +func postgresURL(ctx context.Context, t *testing.T) string { + t.Helper() + + if url := os.Getenv("KANBAN_TEST_POSTGRES_URL"); url != "" { + return url + } + + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available and KANBAN_TEST_POSTGRES_URL unset; skipping integration test") + } + + pgContainer, err := tcpostgres.Run(ctx, + "postgres:16-alpine", + tcpostgres.WithDatabase("kanban_test"), + tcpostgres.WithUsername("postgres"), + tcpostgres.WithPassword("kanban"), + testcontainers.WithWaitStrategy( + wait.ForLog("database system is ready to accept connections"). + WithOccurrence(2). + WithStartupTimeout(60*time.Second), + ), + ) + if err != nil { + t.Fatalf("starting postgres container: %v", err) + } + t.Cleanup(func() { + if err := pgContainer.Terminate(context.Background()); err != nil { + t.Logf("warning: failed to terminate postgres container: %v", err) + } + }) + + connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable") + if err != nil { + t.Fatalf("getting connection string: %v", err) + } + return connStr +} + +// TestPostgres_Integration drives a full CRUD + subtask + assign + attachment + +// cascade workflow against a real Postgres, verifying the migration runner, +// connection helper and service layer all cooperate. +func TestPostgres_Integration(t *testing.T) { + ctx := context.Background() + url := postgresURL(ctx, t) + + // Resolve + migrate + connect: the same sequence main.go performs at startup. + resolved, err := kdb.ResolveURL(url, "") + if err != nil { + t.Fatalf("ResolveURL() error = %v", err) + } + if err := migrations.RunUp(resolved); err != nil { + t.Fatalf("RunUp() error = %v", err) + } + + pool, err := kdb.Connect(ctx, resolved) + if err != nil { + t.Fatalf("Connect() error = %v", err) + } + defer pool.Close() + + svc := service.NewTaskService(dbgen.New(pool), pool, service.NopBroadcaster{}) + + // 1. Create a top-level task. + task, err := svc.CreateTask(ctx, "", service.CreateTaskRequest{ + Title: "Ship Step 11", + Description: "Dockerfile + Helm + integration test", + Labels: []string{"infra", "release"}, + }) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + if task.Status != kdb.StatusInbox { + t.Errorf("default status = %q, want %q", task.Status, kdb.StatusInbox) + } + if len(task.Labels) != 2 { + t.Errorf("labels = %v, want 2 entries", task.Labels) + } + + // 2. Update fields. + newTitle := "Ship Step 11 (final)" + uin := true + updated, err := svc.UpdateTask(ctx, task.ID, service.UpdateTaskRequest{ + Title: &newTitle, + UserInputNeeded: &uin, + }) + if err != nil { + t.Fatalf("UpdateTask() error = %v", err) + } + if updated.Title != newTitle || !updated.UserInputNeeded { + t.Errorf("UpdateTask() = %+v, want title=%q user_input_needed=true", updated, newTitle) + } + + // 3. Move through the workflow. + moved, err := svc.MoveTask(ctx, task.ID, kdb.StatusDevelop) + if err != nil { + t.Fatalf("MoveTask() error = %v", err) + } + if moved.Status != kdb.StatusDevelop { + t.Errorf("status after move = %q, want %q", moved.Status, kdb.StatusDevelop) + } + + // 3b. Invalid move is rejected. + if _, err := svc.MoveTask(ctx, task.ID, kdb.TaskStatus("Nope")); err == nil { + t.Error("MoveTask() with invalid status: expected error, got nil") + } + + // 4. Assign and re-assign. + if _, err := svc.AssignTask(ctx, task.ID, "alice"); err != nil { + t.Fatalf("AssignTask() error = %v", err) + } + assignee := "alice" + listed, err := svc.ListTasks(ctx, "", service.TaskFilter{Assignee: &assignee}) + if err != nil { + t.Fatalf("ListTasks(assignee) error = %v", err) + } + if len(listed) != 1 || listed[0].ID != task.ID { + t.Errorf("ListTasks(assignee=alice) = %d tasks, want exactly task %d", len(listed), task.ID) + } + + // 5. Feature → Task → Subtask hierarchy. `task` is a Feature; create a child + // Task under it, then a checklist subtask on the child Task. + child, err := svc.CreateTask(ctx, "", service.CreateTaskRequest{Title: "write Dockerfile", ParentID: &task.ID}) + if err != nil { + t.Fatalf("CreateTask(child) error = %v", err) + } + if child.ParentID == nil || *child.ParentID != task.ID || child.Kind != kdb.KindTask { + t.Errorf("child task = %+v, want a Task with parent %d", child, task.ID) + } + // A Task cannot parent another Task (one level of Feature→Task only). + if _, err := svc.CreateTask(ctx, "", service.CreateTaskRequest{Title: "nested", ParentID: &child.ID}); err == nil { + t.Error("CreateTask() nested under a Task: expected error, got nil") + } + sub, err := svc.CreateSubtask(ctx, child.ID, "compile binary") + if err != nil { + t.Fatalf("CreateSubtask() error = %v", err) + } + if sub.TaskID != child.ID || sub.Done { + t.Errorf("subtask = %+v, want task_id=%d done=false", sub, child.ID) + } + // Checklist subtasks can only be added to Tasks, not Features. + if _, err := svc.CreateSubtask(ctx, task.ID, "nope"); err == nil { + t.Error("CreateSubtask() on a Feature: expected error, got nil") + } + + // 6. Attachments on a card (file on the Feature + link on the child Task); + // file content is stored base64-encoded. + fileAtt, err := svc.AddAttachment(ctx, task.ID, service.CreateAttachmentRequest{ + Type: kdb.AttachmentTypeFile, + Filename: "DESIGN.md", + Content: base64.StdEncoding.EncodeToString([]byte("# Design\n\nKanban server.")), + }) + if err != nil { + t.Fatalf("AddAttachment(file) error = %v", err) + } + linkAtt, err := svc.AddAttachment(ctx, child.ID, service.CreateAttachmentRequest{ + Type: kdb.AttachmentTypeLink, + URL: "https://claude.ai/session/abc", + Title: "Agent Session", + }) + if err != nil { + t.Fatalf("AddAttachment(link) error = %v", err) + } + + // 6b. Key/value attributes on the Feature (same table, type=attribute). + if _, err := svc.SetAttribute(ctx, task.ID, "priority", "high"); err != nil { + t.Fatalf("SetAttribute() error = %v", err) + } + // Upsert replaces the value rather than adding a duplicate. + if _, err := svc.SetAttribute(ctx, task.ID, "priority", "critical"); err != nil { + t.Fatalf("SetAttribute(upsert) error = %v", err) + } + + // 7. GetTask assembles children/checklist/attachments/attributes. + full, err := svc.GetTask(ctx, task.ID) + if err != nil { + t.Fatalf("GetTask(feature) error = %v", err) + } + if len(full.Children) != 1 { + t.Errorf("GetTask(feature).Children = %d, want 1", len(full.Children)) + } + // Attributes are reported separately from file/link attachments. + if len(full.Attachments) != 1 { + t.Errorf("GetTask(feature).Attachments = %d, want 1", len(full.Attachments)) + } + if len(full.Attributes) != 1 || full.Attributes[0].Key != "priority" || full.Attributes[0].Value != "critical" { + t.Errorf("GetTask(feature).Attributes = %+v, want single priority=critical", full.Attributes) + } + fullChild, err := svc.GetTask(ctx, child.ID) + if err != nil { + t.Fatalf("GetTask(child) error = %v", err) + } + if len(fullChild.Subtasks) != 1 { + t.Errorf("GetTask(child).Subtasks = %d, want 1", len(fullChild.Subtasks)) + } + + // 8. Board groups all cards (Feature + child Task) by status. + board, err := svc.GetBoard(ctx, "") + if err != nil { + t.Fatalf("GetBoard() error = %v", err) + } + if len(board.Columns) != len(kdb.StatusWorkflow) { + t.Errorf("board columns = %d, want %d", len(board.Columns), len(kdb.StatusWorkflow)) + } + cards := 0 + for _, col := range board.Columns { + cards += len(col.Tasks) + } + if cards != 2 { + t.Errorf("cards on board = %d, want 2 (feature + child task)", cards) + } + + // 9. Delete one attachment explicitly. + if err := svc.DeleteAttachment(ctx, linkAtt.ID); err != nil { + t.Fatalf("DeleteAttachment() error = %v", err) + } + + // 10. Cascade delete: removing the Feature removes the child Task, its + // checklist subtask, and remaining attachments. + if err := svc.DeleteTask(ctx, task.ID); err != nil { + t.Fatalf("DeleteTask() error = %v", err) + } + if _, err := svc.GetTask(ctx, task.ID); err == nil { + t.Error("GetTask() after delete: expected not-found error, got nil") + } + + // Verify directly in Postgres that the cascade emptied every table. + assertCount(ctx, t, pool, "SELECT count(*) FROM kanban.task", 0) + assertCount(ctx, t, pool, "SELECT count(*) FROM kanban.attachment", 0) + assertCount(ctx, t, pool, "SELECT count(*) FROM kanban.subtask", 0) + _ = fileAtt // file attachment row removed by cascade; kept for assertion clarity +} + +// TestPostgres_Boards drives the dynamic-boards workflow against a real Postgres: +// the seeded default board, a runtime-created board with its own columns, +// board-scoped task listing, and the rule that a task may only move between its +// own board's columns. +func TestPostgres_Boards(t *testing.T) { + ctx := context.Background() + url := postgresURL(ctx, t) + + resolved, err := kdb.ResolveURL(url, "") + if err != nil { + t.Fatalf("ResolveURL() error = %v", err) + } + if err := migrations.RunUp(resolved); err != nil { + t.Fatalf("RunUp() error = %v", err) + } + pool, err := kdb.Connect(ctx, resolved) + if err != nil { + t.Fatalf("Connect() error = %v", err) + } + defer pool.Close() + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM kanban.task") + _, _ = pool.Exec(ctx, "DELETE FROM kanban.board WHERE key <> 'default'") + }) + + svc := service.NewTaskService(dbgen.New(pool), pool, service.NopBroadcaster{}) + + // The default board is seeded by migration 000002 with the 7 workflow columns. + def, err := svc.GetBoardMeta(ctx, kdb.DefaultBoardKey) + if err != nil { + t.Fatalf("GetBoardMeta(default) error = %v", err) + } + if len(def.Columns) != len(kdb.DefaultColumns) { + t.Errorf("default board columns = %v, want %v", def.Columns, kdb.DefaultColumns) + } + + // Create a board with its own columns. + board, err := svc.CreateBoard(ctx, service.CreateBoardRequest{ + Key: "team", + Name: "Team", + Columns: []string{"Todo", "Doing", "Done"}, + }) + if err != nil { + t.Fatalf("CreateBoard() error = %v", err) + } + + // A task on the board defaults to that board's first column. + task, err := svc.CreateTask(ctx, "team", service.CreateTaskRequest{Title: "scoped"}) + if err != nil { + t.Fatalf("CreateTask(team) error = %v", err) + } + if string(task.Status) != "Todo" || task.BoardID != board.ID { + t.Errorf("task = {status:%q board:%d}, want {Todo %d}", task.Status, task.BoardID, board.ID) + } + + // Move within the board is allowed; move to a foreign board's column is rejected. + if _, err := svc.MoveTask(ctx, task.ID, kdb.TaskStatus("Doing")); err != nil { + t.Fatalf("MoveTask(Doing) error = %v", err) + } + if _, err := svc.MoveTask(ctx, task.ID, kdb.StatusDevelop); err == nil { + t.Error("MoveTask() to default board's 'Develop' on a team task: expected error, got nil") + } + + // Board-scoped listing isolates tasks per board. + if _, err := svc.CreateTask(ctx, "", service.CreateTaskRequest{Title: "on default"}); err != nil { + t.Fatalf("CreateTask(default) error = %v", err) + } + teamTasks, err := svc.ListTasks(ctx, "team", service.TaskFilter{}) + if err != nil { + t.Fatalf("ListTasks(team) error = %v", err) + } + if len(teamTasks) != 1 || teamTasks[0].Title != "scoped" { + t.Errorf("team tasks = %+v, want only the scoped task", teamTasks) + } + + // UpsertBoard is idempotent: same key updates instead of inserting. + again, err := svc.UpsertBoard(ctx, service.CreateBoardRequest{ + Key: "team", + Name: "Team v2", + Columns: []string{"Backlog", "Todo", "Doing", "Done"}, + }) + if err != nil { + t.Fatalf("UpsertBoard() error = %v", err) + } + if again.ID != board.ID || again.Name != "Team v2" || len(again.Columns) != 4 { + t.Errorf("upsert = %+v, want same id with updated name/columns", again) + } +} + +func assertCount(ctx context.Context, t *testing.T, pool *pgxpool.Pool, query string, want int64) { + t.Helper() + var got int64 + if err := pool.QueryRow(ctx, query).Scan(&got); err != nil { + t.Fatalf("query %q: %v", query, err) + } + if got != want { + t.Errorf("%q = %d, want %d", query, got, want) + } +} diff --git a/go/plugins/kanban-mcp/internal/mcp/tools.go b/go/plugins/kanban-mcp/internal/mcp/tools.go new file mode 100644 index 0000000000..df17aec1f2 --- /dev/null +++ b/go/plugins/kanban-mcp/internal/mcp/tools.go @@ -0,0 +1,658 @@ +// Package mcp wires the kanban TaskService to the Model Context Protocol. It +// registers the kanban tools (10 task + 2 board + 2 attachment + 2 attribute) on an mcp.Server +// using typed input/output structs, so kagent can drive the board over either the +// stdio or the streamable-HTTP transport. +// +// Each handler parses its typed input, calls the corresponding TaskService +// method, and returns the typed output. When the service returns an error the +// handler returns that error too: the go-sdk maps a non-nil handler error onto a +// CallToolResult with IsError=true and the error string in the text content, so +// the model can see the failure and self-correct. +package mcp + +import ( + "context" + "fmt" + + "github.com/google/jsonschema-go/jsonschema" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/db" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/service" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/ui" +) + +// MCP App (SEP-1865) constants for the task-progress widget. The tools link to +// the ui:// resource via _meta.ui.resourceUri; a host that supports MCP Apps +// renders the resource HTML as an interactive view and feeds it the tool result. +const ( + // taskProgressResourceURI is the ui:// resource the progress tools render + // into. The scheme must be ui:// for the host to treat it as an MCP App. + taskProgressResourceURI = "ui://kanban/task-progress" + // mcpAppHTMLMimeType marks a resource as MCP App HTML. The profile= parameter + // is required; a plain text/html resource will not render as an app. + mcpAppHTMLMimeType = "text/html;profile=mcp-app" +) + +// objectOutputSchema is a permissive output schema used for tools whose result +// contains the self-referential service.Task type (a Feature carries its child +// Tasks). The go-sdk's automatic schema inference panics with "cycle detected" +// on such recursive Go types, so we supply an explicit object schema to skip +// inference. The structured result is still returned to the caller; only the +// advertised output schema is relaxed to a generic object. +func objectOutputSchema() *jsonschema.Schema { + return &jsonschema.Schema{Type: "object"} +} + +// --------------------------------------------------------------------------- +// Tool input/output types +// --------------------------------------------------------------------------- + +// ListTasksInput filters the task list. All fields are optional; an empty +// request returns all cards (Features and Tasks) of the default board. Setting +// parent_id returns the child Tasks of that Feature instead (board is implied by +// the parent). +type ListTasksInput struct { + Board string `json:"board,omitempty" jsonschema:"Board key to list tasks from; defaults to 'default'"` + Status string `json:"status,omitempty" jsonschema:"Filter by column/status (must be a column of the board)"` + Assignee string `json:"assignee,omitempty" jsonschema:"Filter by assignee"` + Label string `json:"label,omitempty" jsonschema:"Return only tasks containing this label"` + ParentID *int64 `json:"parent_id,omitempty" jsonschema:"If set, list the child Tasks of this Feature instead of all board cards"` +} + +// TasksOutput is the result of a list operation. +type TasksOutput struct { + Tasks []*service.Task `json:"tasks"` +} + +// TaskOutput wraps a single task result. +type TaskOutput struct { + Task *service.Task `json:"task"` +} + +// SubtaskOutput wraps a single checklist subtask result. +type SubtaskOutput struct { + Subtask *service.Subtask `json:"subtask"` +} + +// GetTaskInput identifies a task to fetch. +type GetTaskInput struct { + ID int64 `json:"id" jsonschema:"Task ID"` +} + +// CreateTaskInput is the payload for create_task. With parent_id set, the new +// task is created as a child Task under that Feature; otherwise it is a top-level +// card whose kind ("feature" or "task") is selected by Kind. +type CreateTaskInput struct { + Board string `json:"board,omitempty" jsonschema:"Board key to create a top-level card on; defaults to 'default'. Ignored when parent_id is set (the board is inherited from the Feature)"` + ParentID *int64 `json:"parent_id,omitempty" jsonschema:"If set, create a child Task under this Feature; if omitted, create a top-level card (see kind)"` + Kind string `json:"kind,omitempty" jsonschema:"For a top-level card: 'feature' (default) or 'task' for a standalone Task. Ignored when parent_id is set"` + Title string `json:"title" jsonschema:"Task title"` + Description string `json:"description,omitempty" jsonschema:"Task description"` + Status string `json:"status,omitempty" jsonschema:"Initial column/status; defaults to the board's first column. Must be one of the board's columns"` + Labels []string `json:"labels,omitempty" jsonschema:"Labels to attach to the task"` +} + +// CreateSubtaskInput is the payload for create_subtask (a checklist item). +type CreateSubtaskInput struct { + TaskID int64 `json:"task_id" jsonschema:"ID of the Task (a child task, not a Feature) to add the checklist item to"` + Title string `json:"title" jsonschema:"Checklist item title"` +} + +// ToggleSubtaskInput sets or clears a checklist item's done flag. +type ToggleSubtaskInput struct { + ID int64 `json:"id" jsonschema:"Subtask (checklist item) ID"` + Done bool `json:"done" jsonschema:"Whether the checklist item is done"` +} + +// UpdateSubtaskInput renames a checklist item. +type UpdateSubtaskInput struct { + ID int64 `json:"id" jsonschema:"Subtask (checklist item) ID"` + Title string `json:"title" jsonschema:"New checklist item title"` +} + +// DeleteSubtaskInput identifies a checklist item to delete. +type DeleteSubtaskInput struct { + ID int64 `json:"id" jsonschema:"Subtask (checklist item) ID"` +} + +// AssignTaskInput is the payload for assign_task. An empty assignee clears the +// current assignment. +type AssignTaskInput struct { + ID int64 `json:"id" jsonschema:"Task ID"` + Assignee string `json:"assignee" jsonschema:"Assignee name; empty string clears the assignment"` +} + +// MoveTaskInput is the payload for move_task. +type MoveTaskInput struct { + ID int64 `json:"id" jsonschema:"Task ID"` + Status string `json:"status" jsonschema:"Target column/status; must be one of the columns of the task's board"` +} + +// UpdateTaskInput carries partial updates for update_task. Nil pointer fields are +// left unchanged. +type UpdateTaskInput struct { + ID int64 `json:"id" jsonschema:"Task ID"` + Title *string `json:"title,omitempty" jsonschema:"New title"` + Description *string `json:"description,omitempty" jsonschema:"New description"` + Status *string `json:"status,omitempty" jsonschema:"New workflow status"` + Assignee *string `json:"assignee,omitempty" jsonschema:"New assignee; empty string clears it"` + Labels *[]string `json:"labels,omitempty" jsonschema:"Replacement label set"` + UserInputNeeded *bool `json:"user_input_needed,omitempty" jsonschema:"Whether the task is blocked waiting on human input"` +} + +// SetUserInputNeededInput toggles the human-in-the-loop flag. +type SetUserInputNeededInput struct { + ID int64 `json:"id" jsonschema:"Task ID"` + UserInputNeeded bool `json:"user_input_needed" jsonschema:"Whether the task is blocked waiting on human input"` +} + +// DeleteTaskInput identifies a task to delete. Subtasks and attachments cascade. +type DeleteTaskInput struct { + ID int64 `json:"id" jsonschema:"Task ID; subtasks and attachments are deleted with it"` +} + +// SuccessOutput is the result of an operation that has no return value. +type SuccessOutput struct { + Success bool `json:"success"` +} + +// GetBoardInput selects which board to return. +type GetBoardInput struct { + Board string `json:"board,omitempty" jsonschema:"Board key to fetch; defaults to 'default'"` +} + +// BoardOutput wraps the full state of a single board. +type BoardOutput struct { + Board *service.BoardState `json:"board"` +} + +// ListBoardsInput has no fields. +type ListBoardsInput struct{} + +// BoardsOutput is the result of list_boards. +type BoardsOutput struct { + Boards []*service.Board `json:"boards"` +} + +// CreateBoardInput is the payload for create_board. +type CreateBoardInput struct { + Key string `json:"key" jsonschema:"Unique board key (slug) used to address the board"` + Name string `json:"name,omitempty" jsonschema:"Display name; defaults to the key"` + Description string `json:"description,omitempty" jsonschema:"Board description"` + Columns []string `json:"columns" jsonschema:"Ordered list of column names; tasks on this board can only use these columns"` + Subtasks []string `json:"subtasks,omitempty" jsonschema:"Optional checklist template; these subtask titles are auto-added to every new Task created on this board"` + Scope string `json:"scope,omitempty" jsonschema:"Board scope: 'general' (shared) or 'agent' (bound to an agent); defaults to 'general'"` + Owner string `json:"owner,omitempty" jsonschema:"Owning agent name when scope is 'agent'"` +} + +// BoardMetaOutput wraps a single board's metadata. +type BoardMetaOutput struct { + Board *service.Board `json:"board"` +} + +// AddAttachmentInput is the payload for add_attachment. The required fields +// depend on type: "file" needs filename and base64 content; "link" needs url. +type AddAttachmentInput struct { + TaskID int64 `json:"task_id" jsonschema:"ID of the task card (Feature or Task) to attach to"` + Type string `json:"type" jsonschema:"Attachment type: file or link"` + Filename string `json:"filename,omitempty" jsonschema:"File name with extension (required for type=file); allowed: md, markdown, html, htm, txt, yaml, yml, csv, pdf, docx, xlsx"` + Content string `json:"content,omitempty" jsonschema:"Base64-encoded file bytes (required for type=file)"` + URL string `json:"url,omitempty" jsonschema:"Link URL (required for type=link)"` + Title string `json:"title,omitempty" jsonschema:"Link title (optional for type=link)"` +} + +// AttachmentOutput wraps a single attachment result. +type AttachmentOutput struct { + Attachment *service.Attachment `json:"attachment"` +} + +// DeleteAttachmentInput identifies an attachment to delete. +type DeleteAttachmentInput struct { + ID int64 `json:"id" jsonschema:"Attachment ID"` +} + +// SetAttributeInput is the payload for set_attribute (upsert a key/value pair). +type SetAttributeInput struct { + TaskID int64 `json:"task_id" jsonschema:"ID of the card (Feature or Task) to set the attribute on"` + Key string `json:"key" jsonschema:"Attribute key"` + Value string `json:"value" jsonschema:"Attribute value"` +} + +// AttributeOutput wraps a single attribute result. +type AttributeOutput struct { + Attribute *service.Attribute `json:"attribute"` +} + +// DeleteAttributeInput identifies an attribute (by card + key) to delete. +type DeleteAttributeInput struct { + TaskID int64 `json:"task_id" jsonschema:"ID of the card holding the attribute"` + Key string `json:"key" jsonschema:"Attribute key to remove"` +} + +// TaskProgressInput identifies the card whose progress widget to render. Shared +// by show_task_progress (model + app) and refresh_task_progress (app-only). +type TaskProgressInput struct { + ID int64 `json:"id" jsonschema:"ID of the card (Feature or Task) to render a progress widget for"` +} + +// TaskProgressOutput wraps the computed progress. It is returned as the tool's +// structuredContent; the MCP App View renders it. +type TaskProgressOutput struct { + Progress *service.TaskProgress `json:"progress"` +} + +// --------------------------------------------------------------------------- +// Server construction +// --------------------------------------------------------------------------- + +// NewServer builds an mcp.Server with all kanban tools registered against the +// given TaskService (13 task/subtask + 2 board + 2 attachment + 2 attribute). The returned +// server can be run over any transport (stdio in main.go, streamable HTTP in the +// HTTP server). +func NewServer(svc *service.TaskService) *mcpsdk.Server { + server := mcpsdk.NewServer(&mcpsdk.Implementation{ + Name: "kanban", + Version: "v1.0.0", + }, nil) + + // Task tools. Tools whose result embeds the recursive service.Task type + // supply an explicit OutputSchema to avoid the go-sdk schema-inference cycle + // panic (see objectOutputSchema). + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "list_tasks", + Description: "List cards (Features and Tasks), optionally filtered by status, assignee, or label. Set parent_id to list a Feature's child Tasks instead.", + OutputSchema: objectOutputSchema(), + }, handleListTasks(svc)) + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "get_task", + Description: "Get a single card by ID, including its checklist subtasks, attachments, and (for a Feature) its child Tasks.", + OutputSchema: objectOutputSchema(), + }, handleGetTask(svc)) + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "create_task", + Description: "Create a Feature (top-level card) or, with parent_id set, a child Task under a Feature. Status defaults to the board's first column. If the board defines a default subtask template, every new Task (standalone or child) is created with those checklist subtasks pre-populated; Features get none.", + OutputSchema: objectOutputSchema(), + }, handleCreateTask(svc)) + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "create_subtask", + Description: "Add a checklist subtask (title + done flag) to a Task. Checklist subtasks can only be added to Tasks, not Features.", + OutputSchema: objectOutputSchema(), + }, handleCreateSubtask(svc)) + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "toggle_subtask", + Description: "Set or clear the done flag on a checklist subtask.", + OutputSchema: objectOutputSchema(), + }, handleToggleSubtask(svc)) + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "update_subtask", + Description: "Rename a checklist subtask.", + OutputSchema: objectOutputSchema(), + }, handleUpdateSubtask(svc)) + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "delete_subtask", + Description: "Delete a checklist subtask by ID.", + }, handleDeleteSubtask(svc)) + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "assign_task", + Description: "Assign a task to someone. An empty assignee clears the assignment.", + OutputSchema: objectOutputSchema(), + }, handleAssignTask(svc)) + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "move_task", + Description: "Move a task to a different workflow status.", + OutputSchema: objectOutputSchema(), + }, handleMoveTask(svc)) + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "update_task", + Description: "Update one or more fields of a task. Unset fields are left unchanged.", + OutputSchema: objectOutputSchema(), + }, handleUpdateTask(svc)) + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "set_user_input_needed", + Description: "Set or clear the human-in-the-loop flag on a task.", + OutputSchema: objectOutputSchema(), + }, handleSetUserInputNeeded(svc)) + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "delete_task", + Description: "Delete a card. Its checklist subtasks and attachments are deleted with it; deleting a Feature also deletes its child Tasks.", + }, handleDeleteTask(svc)) + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "get_board", + Description: "Get the full state of a board (default 'default'): its columns and every card (Feature or Task) grouped by column, with checklist subtasks and attachments.", + OutputSchema: objectOutputSchema(), + }, handleGetBoard(svc)) + + // Board tools (2). + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "list_boards", + Description: "List all boards with their key, name, scope/owner, column sets, and default subtask template (if any).", + OutputSchema: objectOutputSchema(), + }, handleListBoards(svc)) + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "create_board", + Description: "Create a new board with its own ordered set of columns. Tasks on the board can only use these columns. Optionally provide subtasks: a default checklist template auto-added to every new Task created on the board.", + OutputSchema: objectOutputSchema(), + }, handleCreateBoard(svc)) + + // Attachment tools (2). File content must be base64-encoded; allowed file + // extensions: md, markdown, html, htm, txt, yaml, yml, csv, pdf, docx, xlsx. + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "add_attachment", + Description: "Add a file or link attachment to a card (Feature or Task). For type=file, content must be base64-encoded and the filename extension must be one of: md, markdown, html, htm, txt, yaml, yml, csv, pdf, docx, xlsx.", + }, handleAddAttachment(svc)) + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "delete_attachment", + Description: "Delete a file or link attachment by ID.", + }, handleDeleteAttachment(svc)) + + // Attribute tools (2): simple key/value pairs on a card, upsert by key. + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "set_attribute", + Description: "Set (upsert) a key/value attribute on a card (Feature or Task). Setting an existing key replaces its value.", + }, handleSetAttribute(svc)) + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "delete_attribute", + Description: "Delete a key/value attribute from a card by its key.", + }, handleDeleteAttribute(svc)) + + // MCP App (SEP-1865): the task-progress widget. The ui:// resource carries + // the single-file HTML View; show_task_progress links to it so MCP-App hosts + // render an interactive progress card, and refresh_task_progress is the + // app-only channel the View uses to re-fetch data in place. + registerTaskProgressApp(server, svc) + + return server +} + +// registerTaskProgressApp registers the task-progress MCP App: the ui:// HTML +// resource plus the model+app show tool and the app-only refresh tool. Both +// tools share one handler and one ui:// resourceUri; only their visibility +// differs. +func registerTaskProgressApp(server *mcpsdk.Server, svc *service.TaskService) { + server.AddResource(&mcpsdk.Resource{ + Name: "task-progress", + Title: "Task progress", + URI: taskProgressResourceURI, + MIMEType: mcpAppHTMLMimeType, + Description: "Interactive progress widget for a single kanban card (MCP App).", + }, func(_ context.Context, _ *mcpsdk.ReadResourceRequest) (*mcpsdk.ReadResourceResult, error) { + return &mcpsdk.ReadResourceResult{ + Contents: []*mcpsdk.ResourceContents{{ + URI: taskProgressResourceURI, + MIMEType: mcpAppHTMLMimeType, + Text: string(ui.TaskProgressHTML()), + }}, + }, nil + }) + + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "show_task_progress", + Description: "Render an interactive progress widget for a card (Feature or Task) inline in the chat. Shows completion percent, per-column child-task counts (Feature) or checklist progress (Task), and refreshes live. Use this when the user asks to see or track a specific task's progress.", + OutputSchema: objectOutputSchema(), + Meta: mcpsdk.Meta{"ui": map[string]any{"resourceUri": taskProgressResourceURI}}, + }, handleTaskProgress(svc)) + + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "refresh_task_progress", + Description: "Internal MCP App control: re-fetch the progress data for the rendered task-progress widget. Hidden from the model (app-only).", + OutputSchema: objectOutputSchema(), + Meta: mcpsdk.Meta{"ui": map[string]any{ + "resourceUri": taskProgressResourceURI, + "visibility": []any{"app"}, + }}, + }, handleTaskProgress(svc)) +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +func handleListTasks(svc *service.TaskService) mcpsdk.ToolHandlerFor[ListTasksInput, TasksOutput] { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, in ListTasksInput) (*mcpsdk.CallToolResult, TasksOutput, error) { + filter := service.TaskFilter{ParentID: in.ParentID} + if in.Status != "" { + st := db.TaskStatus(in.Status) + filter.Status = &st + } + if in.Assignee != "" { + filter.Assignee = &in.Assignee + } + if in.Label != "" { + filter.Label = &in.Label + } + + tasks, err := svc.ListTasks(ctx, in.Board, filter) + if err != nil { + return nil, TasksOutput{}, fmt.Errorf("listing tasks: %w", err) + } + return nil, TasksOutput{Tasks: tasks}, nil + } +} + +func handleGetTask(svc *service.TaskService) mcpsdk.ToolHandlerFor[GetTaskInput, TaskOutput] { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, in GetTaskInput) (*mcpsdk.CallToolResult, TaskOutput, error) { + task, err := svc.GetTask(ctx, in.ID) + if err != nil { + return nil, TaskOutput{}, fmt.Errorf("getting task: %w", err) + } + return nil, TaskOutput{Task: task}, nil + } +} + +func handleCreateTask(svc *service.TaskService) mcpsdk.ToolHandlerFor[CreateTaskInput, TaskOutput] { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, in CreateTaskInput) (*mcpsdk.CallToolResult, TaskOutput, error) { + task, err := svc.CreateTask(ctx, in.Board, service.CreateTaskRequest{ + Title: in.Title, + Description: in.Description, + Status: db.TaskStatus(in.Status), + Labels: in.Labels, + ParentID: in.ParentID, + Kind: in.Kind, + }) + if err != nil { + return nil, TaskOutput{}, fmt.Errorf("creating task: %w", err) + } + return nil, TaskOutput{Task: task}, nil + } +} + +func handleCreateSubtask(svc *service.TaskService) mcpsdk.ToolHandlerFor[CreateSubtaskInput, SubtaskOutput] { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, in CreateSubtaskInput) (*mcpsdk.CallToolResult, SubtaskOutput, error) { + sub, err := svc.CreateSubtask(ctx, in.TaskID, in.Title) + if err != nil { + return nil, SubtaskOutput{}, fmt.Errorf("creating subtask: %w", err) + } + return nil, SubtaskOutput{Subtask: sub}, nil + } +} + +func handleToggleSubtask(svc *service.TaskService) mcpsdk.ToolHandlerFor[ToggleSubtaskInput, SubtaskOutput] { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, in ToggleSubtaskInput) (*mcpsdk.CallToolResult, SubtaskOutput, error) { + sub, err := svc.ToggleSubtask(ctx, in.ID, in.Done) + if err != nil { + return nil, SubtaskOutput{}, fmt.Errorf("toggling subtask: %w", err) + } + return nil, SubtaskOutput{Subtask: sub}, nil + } +} + +func handleUpdateSubtask(svc *service.TaskService) mcpsdk.ToolHandlerFor[UpdateSubtaskInput, SubtaskOutput] { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, in UpdateSubtaskInput) (*mcpsdk.CallToolResult, SubtaskOutput, error) { + sub, err := svc.UpdateSubtask(ctx, in.ID, in.Title) + if err != nil { + return nil, SubtaskOutput{}, fmt.Errorf("updating subtask: %w", err) + } + return nil, SubtaskOutput{Subtask: sub}, nil + } +} + +func handleDeleteSubtask(svc *service.TaskService) mcpsdk.ToolHandlerFor[DeleteSubtaskInput, SuccessOutput] { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, in DeleteSubtaskInput) (*mcpsdk.CallToolResult, SuccessOutput, error) { + if err := svc.DeleteSubtask(ctx, in.ID); err != nil { + return nil, SuccessOutput{}, fmt.Errorf("deleting subtask: %w", err) + } + return nil, SuccessOutput{Success: true}, nil + } +} + +func handleAssignTask(svc *service.TaskService) mcpsdk.ToolHandlerFor[AssignTaskInput, TaskOutput] { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, in AssignTaskInput) (*mcpsdk.CallToolResult, TaskOutput, error) { + task, err := svc.AssignTask(ctx, in.ID, in.Assignee) + if err != nil { + return nil, TaskOutput{}, fmt.Errorf("assigning task: %w", err) + } + return nil, TaskOutput{Task: task}, nil + } +} + +func handleMoveTask(svc *service.TaskService) mcpsdk.ToolHandlerFor[MoveTaskInput, TaskOutput] { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, in MoveTaskInput) (*mcpsdk.CallToolResult, TaskOutput, error) { + task, err := svc.MoveTask(ctx, in.ID, db.TaskStatus(in.Status)) + if err != nil { + return nil, TaskOutput{}, fmt.Errorf("moving task: %w", err) + } + return nil, TaskOutput{Task: task}, nil + } +} + +func handleUpdateTask(svc *service.TaskService) mcpsdk.ToolHandlerFor[UpdateTaskInput, TaskOutput] { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, in UpdateTaskInput) (*mcpsdk.CallToolResult, TaskOutput, error) { + req := service.UpdateTaskRequest{ + Title: in.Title, + Description: in.Description, + Assignee: in.Assignee, + Labels: in.Labels, + UserInputNeeded: in.UserInputNeeded, + } + if in.Status != nil { + st := db.TaskStatus(*in.Status) + req.Status = &st + } + + task, err := svc.UpdateTask(ctx, in.ID, req) + if err != nil { + return nil, TaskOutput{}, fmt.Errorf("updating task: %w", err) + } + return nil, TaskOutput{Task: task}, nil + } +} + +func handleSetUserInputNeeded(svc *service.TaskService) mcpsdk.ToolHandlerFor[SetUserInputNeededInput, TaskOutput] { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, in SetUserInputNeededInput) (*mcpsdk.CallToolResult, TaskOutput, error) { + uin := in.UserInputNeeded + task, err := svc.UpdateTask(ctx, in.ID, service.UpdateTaskRequest{UserInputNeeded: &uin}) + if err != nil { + return nil, TaskOutput{}, fmt.Errorf("setting user_input_needed: %w", err) + } + return nil, TaskOutput{Task: task}, nil + } +} + +func handleDeleteTask(svc *service.TaskService) mcpsdk.ToolHandlerFor[DeleteTaskInput, SuccessOutput] { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, in DeleteTaskInput) (*mcpsdk.CallToolResult, SuccessOutput, error) { + if err := svc.DeleteTask(ctx, in.ID); err != nil { + return nil, SuccessOutput{}, fmt.Errorf("deleting task: %w", err) + } + return nil, SuccessOutput{Success: true}, nil + } +} + +func handleGetBoard(svc *service.TaskService) mcpsdk.ToolHandlerFor[GetBoardInput, BoardOutput] { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, in GetBoardInput) (*mcpsdk.CallToolResult, BoardOutput, error) { + board, err := svc.GetBoard(ctx, in.Board) + if err != nil { + return nil, BoardOutput{}, fmt.Errorf("getting board: %w", err) + } + return nil, BoardOutput{Board: board}, nil + } +} + +func handleListBoards(svc *service.TaskService) mcpsdk.ToolHandlerFor[ListBoardsInput, BoardsOutput] { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, _ ListBoardsInput) (*mcpsdk.CallToolResult, BoardsOutput, error) { + boards, err := svc.ListBoards(ctx) + if err != nil { + return nil, BoardsOutput{}, fmt.Errorf("listing boards: %w", err) + } + return nil, BoardsOutput{Boards: boards}, nil + } +} + +func handleCreateBoard(svc *service.TaskService) mcpsdk.ToolHandlerFor[CreateBoardInput, BoardMetaOutput] { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, in CreateBoardInput) (*mcpsdk.CallToolResult, BoardMetaOutput, error) { + board, err := svc.CreateBoard(ctx, service.CreateBoardRequest{ + Key: in.Key, + Name: in.Name, + Description: in.Description, + Scope: in.Scope, + Owner: in.Owner, + Columns: in.Columns, + Subtasks: in.Subtasks, + }) + if err != nil { + return nil, BoardMetaOutput{}, fmt.Errorf("creating board: %w", err) + } + return nil, BoardMetaOutput{Board: board}, nil + } +} + +func handleAddAttachment(svc *service.TaskService) mcpsdk.ToolHandlerFor[AddAttachmentInput, AttachmentOutput] { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, in AddAttachmentInput) (*mcpsdk.CallToolResult, AttachmentOutput, error) { + att, err := svc.AddAttachment(ctx, in.TaskID, service.CreateAttachmentRequest{ + Type: db.AttachmentType(in.Type), + Filename: in.Filename, + Content: in.Content, + URL: in.URL, + Title: in.Title, + }) + if err != nil { + return nil, AttachmentOutput{}, fmt.Errorf("adding attachment: %w", err) + } + return nil, AttachmentOutput{Attachment: att}, nil + } +} + +func handleDeleteAttachment(svc *service.TaskService) mcpsdk.ToolHandlerFor[DeleteAttachmentInput, SuccessOutput] { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, in DeleteAttachmentInput) (*mcpsdk.CallToolResult, SuccessOutput, error) { + if err := svc.DeleteAttachment(ctx, in.ID); err != nil { + return nil, SuccessOutput{}, fmt.Errorf("deleting attachment: %w", err) + } + return nil, SuccessOutput{Success: true}, nil + } +} + +func handleSetAttribute(svc *service.TaskService) mcpsdk.ToolHandlerFor[SetAttributeInput, AttributeOutput] { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, in SetAttributeInput) (*mcpsdk.CallToolResult, AttributeOutput, error) { + attr, err := svc.SetAttribute(ctx, in.TaskID, in.Key, in.Value) + if err != nil { + return nil, AttributeOutput{}, fmt.Errorf("setting attribute: %w", err) + } + return nil, AttributeOutput{Attribute: attr}, nil + } +} + +func handleDeleteAttribute(svc *service.TaskService) mcpsdk.ToolHandlerFor[DeleteAttributeInput, SuccessOutput] { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, in DeleteAttributeInput) (*mcpsdk.CallToolResult, SuccessOutput, error) { + if err := svc.DeleteAttribute(ctx, in.TaskID, in.Key); err != nil { + return nil, SuccessOutput{}, fmt.Errorf("deleting attribute: %w", err) + } + return nil, SuccessOutput{Success: true}, nil + } +} + +// handleTaskProgress backs both show_task_progress and refresh_task_progress. It +// returns the computed progress as structuredContent (for the MCP App View) plus +// the human summary as the text content block, which is the required fallback +// for non-UI hosts and the text the model sees. +func handleTaskProgress(svc *service.TaskService) mcpsdk.ToolHandlerFor[TaskProgressInput, TaskProgressOutput] { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, in TaskProgressInput) (*mcpsdk.CallToolResult, TaskProgressOutput, error) { + progress, err := svc.TaskProgress(ctx, in.ID) + if err != nil { + return nil, TaskProgressOutput{}, fmt.Errorf("getting task progress: %w", err) + } + result := &mcpsdk.CallToolResult{ + Content: []mcpsdk.Content{&mcpsdk.TextContent{Text: progress.Summary}}, + } + return result, TaskProgressOutput{Progress: progress}, nil + } +} diff --git a/go/plugins/kanban-mcp/internal/mcp/tools_test.go b/go/plugins/kanban-mcp/internal/mcp/tools_test.go new file mode 100644 index 0000000000..255d452c5a --- /dev/null +++ b/go/plugins/kanban-mcp/internal/mcp/tools_test.go @@ -0,0 +1,688 @@ +package mcp + +import ( + "context" + "encoding/base64" + "encoding/json" + "os/exec" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + testcontainers "github.com/testcontainers/testcontainers-go" + tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres" + "github.com/testcontainers/testcontainers-go/wait" + + dbgen "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/db/gen" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/migrations" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/service" +) + +// b64 base64-encodes a string for use as file attachment content. +func b64(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) } + +// startPostgres starts a Postgres container, runs the kanban migrations, and +// returns a connection string. Tests skip when Docker is not available. This is a +// thin local copy of go/core/internal/dbtest (which cannot be imported across the +// internal/ boundary), mirroring the service-package test helper. +func startPostgres(ctx context.Context, t *testing.T) string { + t.Helper() + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available, skipping container test") + } + + pgContainer, err := tcpostgres.Run(ctx, + "postgres:16-alpine", + tcpostgres.WithDatabase("kanban_test"), + tcpostgres.WithUsername("postgres"), + tcpostgres.WithPassword("kanban"), + testcontainers.WithWaitStrategy( + wait.ForLog("database system is ready to accept connections"). + WithOccurrence(2). + WithStartupTimeout(60*time.Second), + ), + ) + if err != nil { + t.Fatalf("starting postgres container: %v", err) + } + t.Cleanup(func() { + if err := pgContainer.Terminate(context.Background()); err != nil { + t.Logf("warning: failed to terminate postgres container: %v", err) + } + }) + + connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable") + if err != nil { + t.Fatalf("getting connection string: %v", err) + } + + if err := migrations.RunUp(connStr); err != nil { + t.Fatalf("running migrations: %v", err) + } + return connStr +} + +// newTestClient starts Postgres, builds the MCP server over an in-memory +// transport, connects a client, and returns the connected client session. +func newTestClient(ctx context.Context, t *testing.T) *mcpsdk.ClientSession { + t.Helper() + url := startPostgres(ctx, t) + + pool, err := pgxpool.New(ctx, url) + if err != nil { + t.Fatalf("creating pool: %v", err) + } + t.Cleanup(pool.Close) + + svc := service.NewTaskService(dbgen.New(pool), pool, nil) + server := NewServer(svc) + + serverTransport, clientTransport := mcpsdk.NewInMemoryTransports() + + serverSession, err := server.Connect(ctx, serverTransport, nil) + if err != nil { + t.Fatalf("connecting server: %v", err) + } + t.Cleanup(func() { _ = serverSession.Close() }) + + client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: "test", Version: "v0"}, nil) + clientSession, err := client.Connect(ctx, clientTransport, nil) + if err != nil { + t.Fatalf("connecting client: %v", err) + } + t.Cleanup(func() { _ = clientSession.Close() }) + + return clientSession +} + +// callTool invokes a tool and decodes the structured output into out. It fails +// the test if the call reported isError. +func callTool(ctx context.Context, t *testing.T, cs *mcpsdk.ClientSession, name string, args map[string]any, out any) *mcpsdk.CallToolResult { + t.Helper() + res, err := cs.CallTool(ctx, &mcpsdk.CallToolParams{Name: name, Arguments: args}) + if err != nil { + t.Fatalf("CallTool(%s) protocol error: %v", name, err) + } + if res.IsError { + t.Fatalf("CallTool(%s) returned isError: %s", name, contentText(res)) + } + if out != nil { + decodeStructured(t, res, out) + } + return res +} + +// decodeStructured re-marshals the structured tool output and unmarshals it into +// out, which keeps the assertions independent of the on-the-wire representation. +func decodeStructured(t *testing.T, res *mcpsdk.CallToolResult, out any) { + t.Helper() + raw, err := json.Marshal(res.StructuredContent) + if err != nil { + t.Fatalf("marshaling structured content: %v", err) + } + if err := json.Unmarshal(raw, out); err != nil { + t.Fatalf("unmarshaling structured content %s: %v", raw, err) + } +} + +// contentText concatenates the text content of a result for error messages. +func contentText(res *mcpsdk.CallToolResult) string { + var s strings.Builder + for _, c := range res.Content { + if tc, ok := c.(*mcpsdk.TextContent); ok { + s.WriteString(tc.Text) + } + } + return s.String() +} + +func TestMCPTool_CreateTask(t *testing.T) { + ctx := context.Background() + cs := newTestClient(ctx, t) + + var out TaskOutput + callTool(ctx, t, cs, "create_task", map[string]any{ + "title": "Test task", + "status": "Plan", + "labels": []string{"alpha", "beta"}, + }, &out) + + if out.Task == nil { + t.Fatal("create_task returned nil task") + } + if out.Task.Title != "Test task" { + t.Errorf("title = %q, want %q", out.Task.Title, "Test task") + } + if out.Task.Status != "Plan" { + t.Errorf("status = %q, want %q", out.Task.Status, "Plan") + } + if len(out.Task.Labels) != 2 { + t.Errorf("labels = %v, want 2 labels", out.Task.Labels) + } +} + +func TestMCPTool_MoveTask_Invalid(t *testing.T) { + ctx := context.Background() + cs := newTestClient(ctx, t) + + var created TaskOutput + callTool(ctx, t, cs, "create_task", map[string]any{"title": "Moveable"}, &created) + + res, err := cs.CallTool(ctx, &mcpsdk.CallToolParams{ + Name: "move_task", + Arguments: map[string]any{"id": created.Task.ID, "status": "Bogus"}, + }) + if err != nil { + t.Fatalf("CallTool protocol error: %v", err) + } + if !res.IsError { + t.Fatalf("move_task with invalid status should set isError, got success: %s", contentText(res)) + } +} + +func TestMCPTool_CreateChildTask(t *testing.T) { + ctx := context.Background() + cs := newTestClient(ctx, t) + + var feature TaskOutput + callTool(ctx, t, cs, "create_task", map[string]any{"title": "Feature"}, &feature) + + var child TaskOutput + callTool(ctx, t, cs, "create_task", map[string]any{ + "parent_id": feature.Task.ID, + "title": "Child task", + }, &child) + + if child.Task.ParentID == nil || *child.Task.ParentID != feature.Task.ID { + t.Errorf("child parent_id = %v, want %d", child.Task.ParentID, feature.Task.ID) + } + if child.Task.Kind != "task" { + t.Errorf("child kind = %q, want %q", child.Task.Kind, "task") + } +} + +func TestMCPTool_CreateSubtask(t *testing.T) { + ctx := context.Background() + cs := newTestClient(ctx, t) + + var feature TaskOutput + callTool(ctx, t, cs, "create_task", map[string]any{"title": "Feature"}, &feature) + var task TaskOutput + callTool(ctx, t, cs, "create_task", map[string]any{ + "parent_id": feature.Task.ID, "title": "Task", + }, &task) + + var sub SubtaskOutput + callTool(ctx, t, cs, "create_subtask", map[string]any{ + "task_id": task.Task.ID, + "title": "checklist item", + }, &sub) + if sub.Subtask == nil || sub.Subtask.TaskID != task.Task.ID || sub.Subtask.Done { + t.Fatalf("create_subtask returned %+v, want an item on task %d with done=false", sub.Subtask, task.Task.ID) + } + + // Toggle it done. + var toggled SubtaskOutput + callTool(ctx, t, cs, "toggle_subtask", map[string]any{"id": sub.Subtask.ID, "done": true}, &toggled) + if !toggled.Subtask.Done { + t.Errorf("toggle_subtask done = false, want true") + } + + // Adding a checklist item to a Feature is rejected. + res, err := cs.CallTool(ctx, &mcpsdk.CallToolParams{ + Name: "create_subtask", + Arguments: map[string]any{"task_id": feature.Task.ID, "title": "nope"}, + }) + if err != nil { + t.Fatalf("create_subtask protocol error: %v", err) + } + if !res.IsError { + t.Error("create_subtask on a Feature: expected isError, got success") + } +} + +func TestMCPTool_AssignTask(t *testing.T) { + ctx := context.Background() + cs := newTestClient(ctx, t) + + var created TaskOutput + callTool(ctx, t, cs, "create_task", map[string]any{"title": "Assignable"}, &created) + + var assigned TaskOutput + callTool(ctx, t, cs, "assign_task", map[string]any{ + "id": created.Task.ID, + "assignee": "alice", + }, &assigned) + + if assigned.Task.Assignee != "alice" { + t.Errorf("assignee = %q, want %q", assigned.Task.Assignee, "alice") + } +} + +func TestMCPTool_DeleteTask_Cascade(t *testing.T) { + ctx := context.Background() + cs := newTestClient(ctx, t) + + var parent TaskOutput + callTool(ctx, t, cs, "create_task", map[string]any{"title": "Parent"}, &parent) + + var child TaskOutput + callTool(ctx, t, cs, "create_task", map[string]any{ + "parent_id": parent.Task.ID, + "title": "Child", + }, &child) + callTool(ctx, t, cs, "create_subtask", map[string]any{ + "task_id": child.Task.ID, + "title": "checklist", + }, nil) + + callTool(ctx, t, cs, "add_attachment", map[string]any{ + "task_id": parent.Task.ID, + "type": "link", + "url": "https://example.com", + "title": "ref", + }, nil) + + var del SuccessOutput + callTool(ctx, t, cs, "delete_task", map[string]any{"id": parent.Task.ID}, &del) + if !del.Success { + t.Fatal("delete_task did not report success") + } + + res, err := cs.CallTool(ctx, &mcpsdk.CallToolParams{ + Name: "get_task", + Arguments: map[string]any{"id": parent.Task.ID}, + }) + if err != nil { + t.Fatalf("CallTool protocol error: %v", err) + } + if !res.IsError { + t.Fatalf("get_task on deleted task should set isError, got: %s", contentText(res)) + } +} + +func TestMCPTool_Boards(t *testing.T) { + ctx := context.Background() + cs := newTestClient(ctx, t) + + // list_boards starts with just the built-in default board. + var listed BoardsOutput + callTool(ctx, t, cs, "list_boards", map[string]any{}, &listed) + if len(listed.Boards) != 1 || listed.Boards[0].Key != "default" { + t.Fatalf("initial boards = %+v, want only the default board", listed.Boards) + } + + // create_board adds a board with its own columns. + var created BoardMetaOutput + callTool(ctx, t, cs, "create_board", map[string]any{ + "key": "team", + "name": "Team", + "columns": []any{"Todo", "Doing", "Done"}, + }, &created) + if created.Board == nil || created.Board.Key != "team" || len(created.Board.Columns) != 3 { + t.Fatalf("create_board returned %+v, want a 3-column team board", created.Board) + } + + // A task created on the board defaults to its first column. + var task TaskOutput + callTool(ctx, t, cs, "create_task", map[string]any{ + "board": "team", + "title": "scoped task", + }, &task) + if string(task.Task.Status) != "Todo" { + t.Errorf("task status = %q, want %q (board's first column)", task.Task.Status, "Todo") + } + + // get_board for the team board reflects its columns and the new task. + var board BoardOutput + callTool(ctx, t, cs, "get_board", map[string]any{"board": "team"}, &board) + if board.Board == nil || len(board.Board.Columns) != 3 { + t.Fatalf("get_board(team) columns = %+v, want 3", board.Board) + } + + // Moving the task to a column from a different board is rejected. + res, err := cs.CallTool(ctx, &mcpsdk.CallToolParams{ + Name: "move_task", + Arguments: map[string]any{"id": task.Task.ID, "status": "Develop"}, + }) + if err != nil { + t.Fatalf("move_task protocol error: %v", err) + } + if !res.IsError { + t.Error("move_task to a foreign board's column: expected isError, got success") + } +} + +func TestMCPTool_GetBoard(t *testing.T) { + ctx := context.Background() + cs := newTestClient(ctx, t) + + for _, tc := range []struct{ title, status string }{ + {"in inbox", "Inbox"}, + {"in plan", "Plan"}, + {"in develop", "Develop"}, + } { + var out TaskOutput + callTool(ctx, t, cs, "create_task", map[string]any{ + "title": tc.title, + "status": tc.status, + }, &out) + callTool(ctx, t, cs, "add_attachment", map[string]any{ + "task_id": out.Task.ID, + "type": "file", + "filename": "NOTES.md", + "content": b64("# notes"), + }, nil) + } + + var board BoardOutput + callTool(ctx, t, cs, "get_board", map[string]any{}, &board) + + if board.Board == nil { + t.Fatal("get_board returned nil board") + } + // 7 workflow columns are always present. + if len(board.Board.Columns) != 7 { + t.Fatalf("columns = %d, want 7", len(board.Board.Columns)) + } + + total := 0 + for _, col := range board.Board.Columns { + for _, task := range col.Tasks { + total++ + if len(task.Attachments) != 1 { + t.Errorf("task %d in %s has %d attachments, want 1", task.ID, col.Status, len(task.Attachments)) + } + } + } + if total != 3 { + t.Errorf("total tasks on board = %d, want 3", total) + } +} + +func TestMCPTool_AddAttachment_File(t *testing.T) { + ctx := context.Background() + cs := newTestClient(ctx, t) + + var task TaskOutput + callTool(ctx, t, cs, "create_task", map[string]any{"title": "Has file"}, &task) + + var att AttachmentOutput + callTool(ctx, t, cs, "add_attachment", map[string]any{ + "task_id": task.Task.ID, + "type": "file", + "filename": "DESIGN.md", + "content": b64("# Design"), + }, &att) + + if att.Attachment == nil { + t.Fatal("add_attachment returned nil attachment") + } + if att.Attachment.Type != "file" || att.Attachment.Filename != "DESIGN.md" { + t.Errorf("attachment = %+v, want file/DESIGN.md", att.Attachment) + } +} + +func TestMCPTool_AddAttachment_UnsupportedType(t *testing.T) { + ctx := context.Background() + cs := newTestClient(ctx, t) + + var task TaskOutput + callTool(ctx, t, cs, "create_task", map[string]any{"title": "Has bad file"}, &task) + + res, err := cs.CallTool(ctx, &mcpsdk.CallToolParams{ + Name: "add_attachment", + Arguments: map[string]any{ + "task_id": task.Task.ID, + "type": "file", + "filename": "evil.exe", + "content": b64("x"), + }, + }) + if err != nil { + t.Fatalf("CallTool protocol error: %v", err) + } + if !res.IsError { + t.Fatalf("add_attachment with unsupported type should set isError, got success: %s", contentText(res)) + } +} + +func TestMCPTool_Attributes(t *testing.T) { + ctx := context.Background() + cs := newTestClient(ctx, t) + + var task TaskOutput + callTool(ctx, t, cs, "create_task", map[string]any{"title": "Has attrs"}, &task) + + var attr AttributeOutput + callTool(ctx, t, cs, "set_attribute", map[string]any{ + "task_id": task.Task.ID, "key": "priority", "value": "high", + }, &attr) + if attr.Attribute == nil || attr.Attribute.Key != "priority" || attr.Attribute.Value != "high" { + t.Fatalf("set_attribute = %+v, want priority=high", attr.Attribute) + } + + // Upsert: same key replaces the value. + callTool(ctx, t, cs, "set_attribute", map[string]any{ + "task_id": task.Task.ID, "key": "priority", "value": "low", + }, &attr) + + var got TaskOutput + callTool(ctx, t, cs, "get_task", map[string]any{"id": task.Task.ID}, &got) + if len(got.Task.Attributes) != 1 || got.Task.Attributes[0].Value != "low" { + t.Fatalf("attributes = %+v, want single priority=low", got.Task.Attributes) + } + if len(got.Task.Attachments) != 0 { + t.Errorf("attachments = %d, want 0 (attributes are separate)", len(got.Task.Attachments)) + } + + // Delete by key. + callTool(ctx, t, cs, "delete_attribute", map[string]any{ + "task_id": task.Task.ID, "key": "priority", + }, nil) + // Decode into a fresh value: the empty attributes field is omitted from the + // response (omitempty), so reusing the prior struct would keep stale data. + var afterDelete TaskOutput + callTool(ctx, t, cs, "get_task", map[string]any{"id": task.Task.ID}, &afterDelete) + if len(afterDelete.Task.Attributes) != 0 { + t.Errorf("attributes after delete = %d, want 0", len(afterDelete.Task.Attributes)) + } +} + +func TestMCPTool_AddAttachment_Link(t *testing.T) { + ctx := context.Background() + cs := newTestClient(ctx, t) + + var task TaskOutput + callTool(ctx, t, cs, "create_task", map[string]any{"title": "Has link"}, &task) + + var att AttachmentOutput + callTool(ctx, t, cs, "add_attachment", map[string]any{ + "task_id": task.Task.ID, + "type": "link", + "url": "https://claude.ai/session/abc", + "title": "Agent Session", + }, &att) + + if att.Attachment.Type != "link" || att.Attachment.URL != "https://claude.ai/session/abc" { + t.Errorf("attachment = %+v, want link/url", att.Attachment) + } +} + +func TestMCPTool_AddAttachment_ChildTask(t *testing.T) { + ctx := context.Background() + cs := newTestClient(ctx, t) + + var parent TaskOutput + callTool(ctx, t, cs, "create_task", map[string]any{"title": "Parent"}, &parent) + + var child TaskOutput + callTool(ctx, t, cs, "create_task", map[string]any{ + "parent_id": parent.Task.ID, + "title": "Child", + }, &child) + + // Attachments are valid on any card, including a child Task. + var att AttachmentOutput + callTool(ctx, t, cs, "add_attachment", map[string]any{ + "task_id": child.Task.ID, + "type": "link", + "url": "https://example.com", + }, &att) + if att.Attachment == nil || att.Attachment.TaskID != child.Task.ID { + t.Fatalf("add_attachment on child task = %+v, want an attachment on task %d", att.Attachment, child.Task.ID) + } +} + +func TestMCPTool_ShowTaskProgress_Feature(t *testing.T) { + ctx := context.Background() + cs := newTestClient(ctx, t) + + var feature TaskOutput + callTool(ctx, t, cs, "create_task", map[string]any{"title": "Epic"}, &feature) + + // Two children: one moved to Done, one left in the first column. + var c1, c2 TaskOutput + callTool(ctx, t, cs, "create_task", map[string]any{"parent_id": feature.Task.ID, "title": "c1"}, &c1) + callTool(ctx, t, cs, "create_task", map[string]any{"parent_id": feature.Task.ID, "title": "c2"}, &c2) + callTool(ctx, t, cs, "move_task", map[string]any{"id": c1.Task.ID, "status": "Done"}, nil) + + var out TaskProgressOutput + res := callTool(ctx, t, cs, "show_task_progress", map[string]any{"id": feature.Task.ID}, &out) + + if out.Progress == nil { + t.Fatal("show_task_progress returned nil progress") + } + if out.Progress.Kind != "feature" { + t.Errorf("kind = %q, want feature", out.Progress.Kind) + } + if out.Progress.TotalCount != 2 || out.Progress.DoneCount != 1 { + t.Errorf("counts = %d/%d, want 1/2 done", out.Progress.DoneCount, out.Progress.TotalCount) + } + if out.Progress.Board.DoneColumn != "Done" { + t.Errorf("done_column = %q, want Done", out.Progress.Board.DoneColumn) + } + // The text content is the required fallback (a human summary, not the JSON). + if txt := contentText(res); txt == "" { + t.Error("show_task_progress returned no text fallback") + } + + // refresh_task_progress (app-only) returns the same structured shape. + var refreshed TaskProgressOutput + callTool(ctx, t, cs, "refresh_task_progress", map[string]any{"id": feature.Task.ID}, &refreshed) + if refreshed.Progress == nil || refreshed.Progress.TaskID != feature.Task.ID { + t.Fatalf("refresh_task_progress = %+v, want progress for task %d", refreshed.Progress, feature.Task.ID) + } +} + +// TestMCPTool_TaskProgressApp_Metadata verifies the MCP App wiring: the tools +// advertise the ui:// resourceUri (and the refresh tool is app-only), and the +// ui:// resource serves the View HTML with the MCP App MIME type. +func TestMCPTool_TaskProgressApp_Metadata(t *testing.T) { + ctx := context.Background() + cs := newTestClient(ctx, t) + + tools, err := cs.ListTools(ctx, &mcpsdk.ListToolsParams{}) + if err != nil { + t.Fatalf("ListTools: %v", err) + } + byName := map[string]*mcpsdk.Tool{} + for _, tl := range tools.Tools { + byName[tl.Name] = tl + } + + show := byName["show_task_progress"] + if show == nil { + t.Fatal("show_task_progress not registered") + } + if uri := uiResourceURI(show.Meta); uri != "ui://kanban/task-progress" { + t.Errorf("show_task_progress resourceUri = %q, want ui://kanban/task-progress", uri) + } + + refresh := byName["refresh_task_progress"] + if refresh == nil { + t.Fatal("refresh_task_progress not registered") + } + if !hasAppOnlyVisibility(refresh.Meta) { + t.Errorf("refresh_task_progress should be app-only (visibility:[\"app\"]), meta = %+v", refresh.Meta) + } + + resources, err := cs.ListResources(ctx, &mcpsdk.ListResourcesParams{}) + if err != nil { + t.Fatalf("ListResources: %v", err) + } + var found bool + for _, r := range resources.Resources { + if r.URI == "ui://kanban/task-progress" { + found = true + if r.MIMEType != "text/html;profile=mcp-app" { + t.Errorf("resource MIME = %q, want text/html;profile=mcp-app", r.MIMEType) + } + } + } + if !found { + t.Fatal("ui://kanban/task-progress resource not registered") + } + + read, err := cs.ReadResource(ctx, &mcpsdk.ReadResourceParams{URI: "ui://kanban/task-progress"}) + if err != nil { + t.Fatalf("ReadResource: %v", err) + } + if len(read.Contents) == 0 || read.Contents[0].MIMEType != "text/html;profile=mcp-app" { + t.Fatalf("read contents = %+v, want one text/html;profile=mcp-app block", read.Contents) + } + if !strings.Contains(read.Contents[0].Text, "ui/initialize") { + t.Error("View HTML does not contain the MCP Apps handshake (ui/initialize)") + } +} + +// uiResourceURI extracts _meta.ui.resourceUri, matching how hosts detect an App. +func uiResourceURI(meta map[string]any) string { + ui, _ := meta["ui"].(map[string]any) + if ui == nil { + return "" + } + uri, _ := ui["resourceUri"].(string) + return uri +} + +// hasAppOnlyVisibility reports whether _meta.ui.visibility is exactly app-only. +func hasAppOnlyVisibility(meta map[string]any) bool { + ui, _ := meta["ui"].(map[string]any) + if ui == nil { + return false + } + vis, ok := ui["visibility"].([]any) + if !ok || len(vis) == 0 { + return false + } + for _, v := range vis { + if s, _ := v.(string); s != "app" { + return false + } + } + return true +} + +func TestMCPTool_DeleteAttachment(t *testing.T) { + ctx := context.Background() + cs := newTestClient(ctx, t) + + var task TaskOutput + callTool(ctx, t, cs, "create_task", map[string]any{"title": "Has attachment"}, &task) + + var att AttachmentOutput + callTool(ctx, t, cs, "add_attachment", map[string]any{ + "task_id": task.Task.ID, + "type": "link", + "url": "https://example.com", + }, &att) + + var del SuccessOutput + callTool(ctx, t, cs, "delete_attachment", map[string]any{"id": att.Attachment.ID}, &del) + if !del.Success { + t.Fatal("delete_attachment did not report success") + } +} diff --git a/go/plugins/kanban-mcp/internal/migrations/migrations.go b/go/plugins/kanban-mcp/internal/migrations/migrations.go new file mode 100644 index 0000000000..421ef668ca --- /dev/null +++ b/go/plugins/kanban-mcp/internal/migrations/migrations.go @@ -0,0 +1,105 @@ +// Package migrations applies the kanban-owned database schema using golang-migrate. +// It ships its own migration set in a dedicated "kanban" Postgres schema and tracks +// state in the kanban_schema_migrations table, independent of the kagent core/vector +// tracks. Modeled on go/core/pkg/migrations/runner.go (newMigrate), which it cannot +// import across the internal/ boundary. +package migrations + +import ( + "database/sql" + "embed" + "errors" + "fmt" + "time" + + "github.com/golang-migrate/migrate/v4" + migratepgx "github.com/golang-migrate/migrate/v4/database/pgx/v5" + "github.com/golang-migrate/migrate/v4/source/iofs" + _ "github.com/jackc/pgx/v5/stdlib" +) + +//go:embed sql +var fsys embed.FS + +// migrationsTable is the dedicated state table for the kanban track. It is +// separate from the kagent core ("schema_migrations") and vector tracks so the +// two binaries can migrate the same database without colliding. +const migrationsTable = "kanban_schema_migrations" + +// RunUp applies all pending kanban migrations against url. migrate.ErrNoChange +// (no pending migrations) is treated as success. +func RunUp(url string) error { + mg, err := newMigrate(url) + if err != nil { + return err + } + defer closeMigrate(mg) + + if err := mg.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { + return fmt.Errorf("kanban migrations: %w", err) + } + return nil +} + +// newMigrate opens a dedicated database connection (sql.Open via the pgx stdlib +// shim — a single connection, not a pool, because the advisory lock is session +// level) and constructs a migrate.Migrate over the embedded sql dir. +func newMigrate(url string) (*migrate.Migrate, error) { + db, err := sql.Open("pgx", url) + if err != nil { + return nil, fmt.Errorf("open database for kanban migrations: %w", err) + } + + // sql.Open is lazy, so verify the database is actually reachable before + // building the driver. At startup the database may still be coming up (the + // app and DB often start together in k8s), so retry briefly with backoff. + if err := waitForDB(db); err != nil { + return nil, fmt.Errorf("connect to database for kanban migrations: %w", err) + } + + src, err := iofs.New(fsys, "sql") + if err != nil { + return nil, fmt.Errorf("load kanban migration files: %w", err) + } + + driver, err := migratepgx.WithInstance(db, &migratepgx.Config{ + MigrationsTable: migrationsTable, + }) + if err != nil { + return nil, fmt.Errorf("create kanban migration driver: %w", err) + } + + mg, err := migrate.NewWithInstance("iofs", src, "postgres", driver) + if err != nil { + return nil, fmt.Errorf("create kanban migrator: %w", err) + } + return mg, nil +} + +// waitForDB pings db until it responds or the deadline elapses, tolerating a +// database that is still accepting connections (common right after the DB +// container/pod starts). +func waitForDB(db *sql.DB) error { + const ( + timeout = 30 * time.Second + interval = 250 * time.Millisecond + ) + deadline := time.Now().Add(timeout) + var err error + for { + if err = db.Ping(); err == nil { + return nil + } + if time.Now().After(deadline) { + return err + } + time.Sleep(interval) + } +} + +// closeMigrate closes mg, joining any source/database close errors. There is no +// caller to return them to (RunUp has already returned), so they are wrapped and +// discarded; the underlying connection is released either way. +func closeMigrate(mg *migrate.Migrate) { + _, _ = mg.Close() +} diff --git a/go/plugins/kanban-mcp/internal/migrations/migrations_test.go b/go/plugins/kanban-mcp/internal/migrations/migrations_test.go new file mode 100644 index 0000000000..92fa5e3c5e --- /dev/null +++ b/go/plugins/kanban-mcp/internal/migrations/migrations_test.go @@ -0,0 +1,154 @@ +package migrations + +import ( + "context" + "database/sql" + "os/exec" + "testing" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" + testcontainers "github.com/testcontainers/testcontainers-go" + tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres" + "github.com/testcontainers/testcontainers-go/wait" +) + +// startPostgres starts a Postgres container and returns its connection string, +// registering termination with t.Cleanup. Tests are skipped when Docker is not +// available so they remain runnable in environments without a container runtime. +// This is a thin local copy of go/core/internal/dbtest (which cannot be imported +// across the internal/ boundary). +func startPostgres(ctx context.Context, t *testing.T) string { + t.Helper() + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available, skipping container test") + } + + pgContainer, err := tcpostgres.Run(ctx, + "postgres:16-alpine", + tcpostgres.WithDatabase("kanban_test"), + tcpostgres.WithUsername("postgres"), + tcpostgres.WithPassword("kanban"), + testcontainers.WithWaitStrategy( + wait.ForLog("database system is ready to accept connections"). + WithOccurrence(2). + WithStartupTimeout(60*time.Second), + ), + ) + if err != nil { + t.Fatalf("starting postgres container: %v", err) + } + t.Cleanup(func() { + if err := pgContainer.Terminate(context.Background()); err != nil { + t.Logf("warning: failed to terminate postgres container: %v", err) + } + }) + + connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable") + if err != nil { + t.Fatalf("getting connection string: %v", err) + } + + // The container's mapped port may not be reachable on the host the instant + // the readiness log fires (host port-forwarding can lag), so wait for the + // connection to actually succeed before tests open it directly. + db, err := sql.Open("pgx", connStr) + if err != nil { + t.Fatalf("open db for readiness wait: %v", err) + } + defer db.Close() + if err := waitForDB(db); err != nil { + t.Fatalf("waiting for database: %v", err) + } + return connStr +} + +func TestRunUp(t *testing.T) { + ctx := context.Background() + url := startPostgres(ctx, t) + + if err := RunUp(url); err != nil { + t.Fatalf("RunUp() error = %v", err) + } + + db, err := sql.Open("pgx", url) + if err != nil { + t.Fatalf("open db: %v", err) + } + defer db.Close() + + for _, table := range []string{"kanban.task", "kanban.attachment", "kanban.board", "kanban.subtask"} { + var reg *string + if err := db.QueryRow("SELECT to_regclass($1)", table).Scan(®); err != nil { + t.Fatalf("to_regclass(%s): %v", table, err) + } + if reg == nil { + t.Errorf("table %s does not exist after RunUp", table) + } + } + + // The built-in default board must be seeded with the 7 workflow columns. + var defaultColCount int + if err := db.QueryRow( + "SELECT array_length(columns, 1) FROM kanban.board WHERE key = 'default'", + ).Scan(&defaultColCount); err != nil { + t.Fatalf("reading default board: %v", err) + } + if defaultColCount != 7 { + t.Errorf("default board columns = %d, want 7", defaultColCount) + } + + var migCount int + if err := db.QueryRow("SELECT count(*) FROM kanban_schema_migrations").Scan(&migCount); err != nil { + t.Fatalf("count kanban_schema_migrations: %v", err) + } + if migCount == 0 { + t.Errorf("expected at least one row in kanban_schema_migrations, got 0") + } + + // Running RunUp again must be a no-op (ErrNoChange handled as success). + if err := RunUp(url); err != nil { + t.Fatalf("RunUp() second call error = %v", err) + } +} + +func TestRunUp_Coexist(t *testing.T) { + ctx := context.Background() + url := startPostgres(ctx, t) + + db, err := sql.Open("pgx", url) + if err != nil { + t.Fatalf("open db: %v", err) + } + defer db.Close() + + // Simulate the kagent core schema owning a top-level public.task table. + if _, err := db.Exec("CREATE TABLE public.task (id BIGINT PRIMARY KEY)"); err != nil { + t.Fatalf("create public.task: %v", err) + } + + if err := RunUp(url); err != nil { + t.Fatalf("RunUp() error = %v", err) + } + + for _, table := range []string{"public.task", "kanban.task"} { + var reg *string + if err := db.QueryRow("SELECT to_regclass($1)", table).Scan(®); err != nil { + t.Fatalf("to_regclass(%s): %v", table, err) + } + if reg == nil { + t.Errorf("expected %s to exist, got NULL", table) + } + } + + // public.task must remain untouched (no kanban columns leaked into it). + var colCount int + if err := db.QueryRow( + "SELECT count(*) FROM information_schema.columns WHERE table_schema='public' AND table_name='task'", + ).Scan(&colCount); err != nil { + t.Fatalf("count public.task columns: %v", err) + } + if colCount != 1 { + t.Errorf("public.task should have 1 column (id), got %d", colCount) + } +} diff --git a/go/plugins/kanban-mcp/internal/migrations/sql/000001_initial.down.sql b/go/plugins/kanban-mcp/internal/migrations/sql/000001_initial.down.sql new file mode 100644 index 0000000000..b6573d40d1 --- /dev/null +++ b/go/plugins/kanban-mcp/internal/migrations/sql/000001_initial.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS kanban.attachment; +DROP TABLE IF EXISTS kanban.task; +DROP SCHEMA IF EXISTS kanban; diff --git a/go/plugins/kanban-mcp/internal/migrations/sql/000001_initial.up.sql b/go/plugins/kanban-mcp/internal/migrations/sql/000001_initial.up.sql new file mode 100644 index 0000000000..f593e7a1ca --- /dev/null +++ b/go/plugins/kanban-mcp/internal/migrations/sql/000001_initial.up.sql @@ -0,0 +1,29 @@ +CREATE SCHEMA IF NOT EXISTS kanban; + +CREATE TABLE kanban.task ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + status VARCHAR(32) NOT NULL DEFAULT 'Inbox', + assignee VARCHAR(255) NOT NULL DEFAULT '', + labels TEXT[] NOT NULL DEFAULT '{}', + user_input_needed BOOLEAN NOT NULL DEFAULT FALSE, + parent_id BIGINT REFERENCES kanban.task(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_task_parent_id ON kanban.task(parent_id); +CREATE INDEX idx_task_status ON kanban.task(status); + +CREATE TABLE kanban.attachment ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + task_id BIGINT NOT NULL REFERENCES kanban.task(id) ON DELETE CASCADE, + type VARCHAR(16) NOT NULL, + filename VARCHAR(255) NOT NULL DEFAULT '', + url TEXT NOT NULL DEFAULT '', + title VARCHAR(255) NOT NULL DEFAULT '', + content TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_attachment_task_id ON kanban.attachment(task_id); diff --git a/go/plugins/kanban-mcp/internal/migrations/sql/000002_boards.down.sql b/go/plugins/kanban-mcp/internal/migrations/sql/000002_boards.down.sql new file mode 100644 index 0000000000..361eeff329 --- /dev/null +++ b/go/plugins/kanban-mcp/internal/migrations/sql/000002_boards.down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS kanban.idx_task_board_id; +ALTER TABLE kanban.task DROP COLUMN IF EXISTS board_id; +DROP TABLE IF EXISTS kanban.board; diff --git a/go/plugins/kanban-mcp/internal/migrations/sql/000002_boards.up.sql b/go/plugins/kanban-mcp/internal/migrations/sql/000002_boards.up.sql new file mode 100644 index 0000000000..98f93ebdb5 --- /dev/null +++ b/go/plugins/kanban-mcp/internal/migrations/sql/000002_boards.up.sql @@ -0,0 +1,33 @@ +CREATE TABLE kanban.board ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + key VARCHAR(64) NOT NULL UNIQUE, + name VARCHAR(255) NOT NULL, + description TEXT NOT NULL DEFAULT '', + scope VARCHAR(16) NOT NULL DEFAULT 'general', + owner VARCHAR(255) NOT NULL DEFAULT '', + columns TEXT[] NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Built-in board that holds all pre-existing tasks. Its columns are the original +-- fixed workflow so the default board behaves exactly as the v1.x single board. +INSERT INTO kanban.board (key, name, description, scope, owner, columns) +VALUES ( + 'default', + 'Default', + 'Default kanban board', + 'general', + '', + ARRAY['Inbox', 'Plan', 'Develop', 'Testing', 'CodeReview', 'Release', 'Done'] +); + +ALTER TABLE kanban.task + ADD COLUMN board_id BIGINT REFERENCES kanban.board(id) ON DELETE CASCADE; + +UPDATE kanban.task +SET board_id = (SELECT id FROM kanban.board WHERE key = 'default'); + +ALTER TABLE kanban.task ALTER COLUMN board_id SET NOT NULL; + +CREATE INDEX idx_task_board_id ON kanban.task(board_id); diff --git a/go/plugins/kanban-mcp/internal/migrations/sql/000003_subtasks.down.sql b/go/plugins/kanban-mcp/internal/migrations/sql/000003_subtasks.down.sql new file mode 100644 index 0000000000..5dfb0044a7 --- /dev/null +++ b/go/plugins/kanban-mcp/internal/migrations/sql/000003_subtasks.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS kanban.subtask; diff --git a/go/plugins/kanban-mcp/internal/migrations/sql/000003_subtasks.up.sql b/go/plugins/kanban-mcp/internal/migrations/sql/000003_subtasks.up.sql new file mode 100644 index 0000000000..1c9b451661 --- /dev/null +++ b/go/plugins/kanban-mcp/internal/migrations/sql/000003_subtasks.up.sql @@ -0,0 +1,14 @@ +-- Checklist subtasks (v1.6): lightweight items attached to a Task. Unlike the +-- v1.x "subtasks" (which were full kanban.task rows), these carry only a title +-- and a done flag. The service enforces that subtasks attach to Tasks (child +-- tasks), not Features (top-level tasks); the FK itself only enforces that the +-- owning task row exists. +CREATE TABLE kanban.subtask ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + task_id BIGINT NOT NULL REFERENCES kanban.task(id) ON DELETE CASCADE, + title TEXT NOT NULL, + done BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_subtask_task_id ON kanban.subtask(task_id); diff --git a/go/plugins/kanban-mcp/internal/migrations/sql/000004_due_date.down.sql b/go/plugins/kanban-mcp/internal/migrations/sql/000004_due_date.down.sql new file mode 100644 index 0000000000..f3323cc536 --- /dev/null +++ b/go/plugins/kanban-mcp/internal/migrations/sql/000004_due_date.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE kanban.task + DROP COLUMN IF EXISTS due_date; diff --git a/go/plugins/kanban-mcp/internal/migrations/sql/000004_due_date.up.sql b/go/plugins/kanban-mcp/internal/migrations/sql/000004_due_date.up.sql new file mode 100644 index 0000000000..198bd5d44b --- /dev/null +++ b/go/plugins/kanban-mcp/internal/migrations/sql/000004_due_date.up.sql @@ -0,0 +1,3 @@ +-- Add an optional due date to cards (Features and Tasks). NULL means no due date. +ALTER TABLE kanban.task + ADD COLUMN due_date TIMESTAMPTZ; diff --git a/go/plugins/kanban-mcp/internal/migrations/sql/000005_kind.down.sql b/go/plugins/kanban-mcp/internal/migrations/sql/000005_kind.down.sql new file mode 100644 index 0000000000..82218755a5 --- /dev/null +++ b/go/plugins/kanban-mcp/internal/migrations/sql/000005_kind.down.sql @@ -0,0 +1 @@ +ALTER TABLE kanban.task DROP COLUMN kind; diff --git a/go/plugins/kanban-mcp/internal/migrations/sql/000005_kind.up.sql b/go/plugins/kanban-mcp/internal/migrations/sql/000005_kind.up.sql new file mode 100644 index 0000000000..2c90b6abe1 --- /dev/null +++ b/go/plugins/kanban-mcp/internal/migrations/sql/000005_kind.up.sql @@ -0,0 +1,5 @@ +-- kind distinguishes a Feature from a Task independently of parent_id, so a +-- top-level card can be either a Feature or a standalone Task. Existing rows are +-- backfilled from parent_id: child cards become Tasks, the rest stay Features. +ALTER TABLE kanban.task ADD COLUMN kind VARCHAR(16) NOT NULL DEFAULT 'feature'; +UPDATE kanban.task SET kind = 'task' WHERE parent_id IS NOT NULL; diff --git a/go/plugins/kanban-mcp/internal/migrations/sql/000006_drop_due_date.down.sql b/go/plugins/kanban-mcp/internal/migrations/sql/000006_drop_due_date.down.sql new file mode 100644 index 0000000000..29e56bba95 --- /dev/null +++ b/go/plugins/kanban-mcp/internal/migrations/sql/000006_drop_due_date.down.sql @@ -0,0 +1 @@ +ALTER TABLE kanban.task ADD COLUMN due_date TIMESTAMPTZ; diff --git a/go/plugins/kanban-mcp/internal/migrations/sql/000006_drop_due_date.up.sql b/go/plugins/kanban-mcp/internal/migrations/sql/000006_drop_due_date.up.sql new file mode 100644 index 0000000000..c76cab8d98 --- /dev/null +++ b/go/plugins/kanban-mcp/internal/migrations/sql/000006_drop_due_date.up.sql @@ -0,0 +1,4 @@ +-- due_date is no longer a dedicated column: dates (create_date, due_date, +-- close_date, and any *_date key) are now stored as card attributes +-- (type='attribute' rows in kanban.attachment). Drop the column. +ALTER TABLE kanban.task DROP COLUMN due_date; diff --git a/go/plugins/kanban-mcp/internal/migrations/sql/000007_board_subtasks.down.sql b/go/plugins/kanban-mcp/internal/migrations/sql/000007_board_subtasks.down.sql new file mode 100644 index 0000000000..44ae72fc56 --- /dev/null +++ b/go/plugins/kanban-mcp/internal/migrations/sql/000007_board_subtasks.down.sql @@ -0,0 +1 @@ +ALTER TABLE kanban.board DROP COLUMN subtasks; diff --git a/go/plugins/kanban-mcp/internal/migrations/sql/000007_board_subtasks.up.sql b/go/plugins/kanban-mcp/internal/migrations/sql/000007_board_subtasks.up.sql new file mode 100644 index 0000000000..0618b6fa6a --- /dev/null +++ b/go/plugins/kanban-mcp/internal/migrations/sql/000007_board_subtasks.up.sql @@ -0,0 +1,5 @@ +-- subtasks is an optional per-board checklist template. When a Task is created on +-- the board, these titles are auto-added as its checklist subtasks. Existing rows +-- default to an empty template (no auto-added subtasks). +ALTER TABLE kanban.board + ADD COLUMN subtasks TEXT[] NOT NULL DEFAULT '{}'; diff --git a/go/plugins/kanban-mcp/internal/seed/seed.go b/go/plugins/kanban-mcp/internal/seed/seed.go new file mode 100644 index 0000000000..c8fd840a8c --- /dev/null +++ b/go/plugins/kanban-mcp/internal/seed/seed.go @@ -0,0 +1,74 @@ +// Package seed parses board definitions from configuration (an inline JSON string +// or a JSON file, the file taking precedence) and upserts them into the database +// at startup. Seeding is idempotent: redeploys reconcile board names and columns +// via UpsertBoard, so the same Helm values can be applied repeatedly. +package seed + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/service" +) + +// BoardSpec is one board definition as provided by configuration (Helm values +// rendered to JSON). It mirrors service.CreateBoardRequest in JSON form. +type BoardSpec struct { + Key string `json:"key"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Scope string `json:"scope,omitempty"` + Owner string `json:"owner,omitempty"` + Columns []string `json:"columns"` + Subtasks []string `json:"subtasks,omitempty"` +} + +// Upserter is the subset of service.TaskService that seeding needs. +type Upserter interface { + UpsertBoard(ctx context.Context, req service.CreateBoardRequest) (*service.Board, error) +} + +// Parse resolves the board specs from the inline JSON and/or file. The file path +// takes precedence over the inline value. An empty source yields no specs and no +// error. +func Parse(inline, file string) ([]BoardSpec, error) { + data := strings.TrimSpace(inline) + if file != "" { + content, err := os.ReadFile(file) + if err != nil { + return nil, fmt.Errorf("reading boards file %q: %w", file, err) + } + data = strings.TrimSpace(string(content)) + } + if data == "" { + return nil, nil + } + + var specs []BoardSpec + if err := json.Unmarshal([]byte(data), &specs); err != nil { + return nil, fmt.Errorf("parsing board definitions: %w", err) + } + return specs, nil +} + +// Apply upserts each spec via the Upserter. It returns the first error +// encountered. +func Apply(ctx context.Context, u Upserter, specs []BoardSpec) error { + for _, s := range specs { + if _, err := u.UpsertBoard(ctx, service.CreateBoardRequest{ + Key: s.Key, + Name: s.Name, + Description: s.Description, + Scope: s.Scope, + Owner: s.Owner, + Columns: s.Columns, + Subtasks: s.Subtasks, + }); err != nil { + return fmt.Errorf("seeding board %q: %w", s.Key, err) + } + } + return nil +} diff --git a/go/plugins/kanban-mcp/internal/seed/seed_test.go b/go/plugins/kanban-mcp/internal/seed/seed_test.go new file mode 100644 index 0000000000..d98eb228fe --- /dev/null +++ b/go/plugins/kanban-mcp/internal/seed/seed_test.go @@ -0,0 +1,83 @@ +package seed + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/service" +) + +func TestParse_Empty(t *testing.T) { + specs, err := Parse("", "") + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if specs != nil { + t.Errorf("Parse() = %v, want nil", specs) + } +} + +func TestParse_Inline(t *testing.T) { + specs, err := Parse(`[{"key":"team","columns":["Todo","Done"]}]`, "") + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if len(specs) != 1 { + t.Fatalf("len(specs) = %d, want 1", len(specs)) + } + if specs[0].Key != "team" || len(specs[0].Columns) != 2 { + t.Errorf("spec = %+v, want key=team with 2 columns", specs[0]) + } +} + +func TestParse_FilePrecedence(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "boards.json") + if err := os.WriteFile(path, []byte(`[{"key":"fromfile","columns":["A"]}]`), 0o600); err != nil { + t.Fatalf("writing file: %v", err) + } + + // Inline is provided too, but the file takes precedence. + specs, err := Parse(`[{"key":"inline","columns":["B"]}]`, path) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if len(specs) != 1 || specs[0].Key != "fromfile" { + t.Errorf("specs = %+v, want the file's board to win", specs) + } +} + +func TestParse_BadJSON(t *testing.T) { + if _, err := Parse(`not json`, ""); err == nil { + t.Fatal("Parse() expected error for bad JSON, got nil") + } +} + +// fakeUpserter records the requests passed to UpsertBoard. +type fakeUpserter struct { + reqs []service.CreateBoardRequest +} + +func (f *fakeUpserter) UpsertBoard(_ context.Context, req service.CreateBoardRequest) (*service.Board, error) { + f.reqs = append(f.reqs, req) + return &service.Board{Key: req.Key}, nil +} + +func TestApply_UpsertsEach(t *testing.T) { + specs := []BoardSpec{ + {Key: "a", Columns: []string{"X"}}, + {Key: "b", Name: "B", Columns: []string{"Y", "Z"}}, + } + f := &fakeUpserter{} + if err := Apply(context.Background(), f, specs); err != nil { + t.Fatalf("Apply() error = %v", err) + } + if len(f.reqs) != 2 { + t.Fatalf("UpsertBoard called %d times, want 2", len(f.reqs)) + } + if f.reqs[0].Key != "a" || f.reqs[1].Key != "b" { + t.Errorf("upserted keys = %q,%q, want a,b", f.reqs[0].Key, f.reqs[1].Key) + } +} diff --git a/go/plugins/kanban-mcp/internal/service/attachment_service_test.go b/go/plugins/kanban-mcp/internal/service/attachment_service_test.go new file mode 100644 index 0000000000..17c66376e5 --- /dev/null +++ b/go/plugins/kanban-mcp/internal/service/attachment_service_test.go @@ -0,0 +1,458 @@ +package service + +import ( + "context" + "encoding/base64" + "strings" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/db" +) + +// b64 base64-encodes a string for use as file attachment content (file content +// is stored base64-encoded). +func b64(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) } + +// dbCountAttachments counts attachment rows for a task directly, bypassing the +// service, to verify persistence and cascade behaviour. +func dbCountAttachments(ctx context.Context, t *testing.T, pool *pgxpool.Pool, taskID int64) int { + t.Helper() + var n int + if err := pool.QueryRow(ctx, "SELECT count(*) FROM kanban.attachment WHERE task_id = $1", taskID).Scan(&n); err != nil { + t.Fatalf("counting attachments for task %d: %v", taskID, err) + } + return n +} + +func TestAddAttachment_File(t *testing.T) { + ctx := context.Background() + svc, _, _ := newTestService(ctx, t) + + task, err := svc.CreateTask(ctx, "", CreateTaskRequest{Title: "with file"}) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + + got, err := svc.AddAttachment(ctx, task.ID, CreateAttachmentRequest{ + Type: db.AttachmentTypeFile, + Filename: "DESIGN.md", + Content: b64("# Design\n\nOverview"), + }) + if err != nil { + t.Fatalf("AddAttachment() error = %v", err) + } + if got.Type != db.AttachmentTypeFile { + t.Errorf("type = %q, want %q", got.Type, db.AttachmentTypeFile) + } + if got.Filename != "DESIGN.md" { + t.Errorf("filename = %q, want %q", got.Filename, "DESIGN.md") + } + if got.Content != b64("# Design\n\nOverview") { + t.Errorf("content = %q, want base64 design content", got.Content) + } + if got.TaskID != task.ID { + t.Errorf("task_id = %d, want %d", got.TaskID, task.ID) + } + + // Verify persisted, not just echoed. + fetched, err := svc.GetTask(ctx, task.ID) + if err != nil { + t.Fatalf("GetTask() error = %v", err) + } + if len(fetched.Attachments) != 1 || fetched.Attachments[0].Filename != "DESIGN.md" { + t.Errorf("persisted attachments = %v, want one DESIGN.md", fetched.Attachments) + } +} + +func TestAddAttachment_Link(t *testing.T) { + ctx := context.Background() + svc, _, _ := newTestService(ctx, t) + + task, err := svc.CreateTask(ctx, "", CreateTaskRequest{Title: "with link"}) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + + got, err := svc.AddAttachment(ctx, task.ID, CreateAttachmentRequest{ + Type: db.AttachmentTypeLink, + URL: "https://claude.ai/session/abc", + Title: "Agent Session", + }) + if err != nil { + t.Fatalf("AddAttachment() error = %v", err) + } + if got.Type != db.AttachmentTypeLink { + t.Errorf("type = %q, want %q", got.Type, db.AttachmentTypeLink) + } + if got.URL != "https://claude.ai/session/abc" { + t.Errorf("url = %q, want session url", got.URL) + } + if got.Title != "Agent Session" { + t.Errorf("title = %q, want %q", got.Title, "Agent Session") + } +} + +func TestAddAttachment_ChildTaskAllowed(t *testing.T) { + ctx := context.Background() + svc, _, _ := newTestService(ctx, t) + + // Both Features and child Tasks are full cards and may carry attachments. + feature, err := svc.CreateTask(ctx, "", CreateTaskRequest{Title: "feature"}) + if err != nil { + t.Fatalf("CreateTask(feature) error = %v", err) + } + task, err := svc.CreateTask(ctx, "", CreateTaskRequest{Title: "task", ParentID: &feature.ID}) + if err != nil { + t.Fatalf("CreateTask(child) error = %v", err) + } + + got, err := svc.AddAttachment(ctx, task.ID, CreateAttachmentRequest{ + Type: db.AttachmentTypeFile, + Filename: "x.md", + Content: b64("content"), + }) + if err != nil { + t.Fatalf("AddAttachment() on child task error = %v, want success", err) + } + if got.TaskID != task.ID { + t.Errorf("attachment task_id = %d, want %d", got.TaskID, task.ID) + } +} + +func TestAddAttachment_TaskNotFound(t *testing.T) { + ctx := context.Background() + svc, _, _ := newTestService(ctx, t) + + _, err := svc.AddAttachment(ctx, 999999, CreateAttachmentRequest{ + Type: db.AttachmentTypeLink, + URL: "https://example.com", + }) + if err == nil { + t.Fatal("AddAttachment() expected error for missing task, got nil") + } + if !IsNotFound(err) { + t.Errorf("error = %v, want wrapped pgx.ErrNoRows", err) + } +} + +func TestAddAttachment_InvalidType(t *testing.T) { + ctx := context.Background() + svc, _, _ := newTestService(ctx, t) + + task, err := svc.CreateTask(ctx, "", CreateTaskRequest{Title: "host"}) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + + _, err = svc.AddAttachment(ctx, task.ID, CreateAttachmentRequest{Type: db.AttachmentType("invalid")}) + if err == nil { + t.Fatal("AddAttachment() expected error for invalid type, got nil") + } + if !strings.Contains(err.Error(), "file") || !strings.Contains(err.Error(), "link") { + t.Errorf("error = %q, want it to list valid types file and link", err.Error()) + } +} + +func TestAddAttachment_FileMissingFields(t *testing.T) { + ctx := context.Background() + svc, _, _ := newTestService(ctx, t) + + task, err := svc.CreateTask(ctx, "", CreateTaskRequest{Title: "host"}) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + + tests := []struct { + name string + req CreateAttachmentRequest + }{ + { + name: "empty filename", + req: CreateAttachmentRequest{Type: db.AttachmentTypeFile, Content: "data"}, + }, + { + name: "empty content", + req: CreateAttachmentRequest{Type: db.AttachmentTypeFile, Filename: "x.md"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := svc.AddAttachment(ctx, task.ID, tt.req); err == nil { + t.Fatalf("AddAttachment() expected error for %s, got nil", tt.name) + } + }) + } +} + +func TestAddAttachment_LinkMissingURL(t *testing.T) { + ctx := context.Background() + svc, _, _ := newTestService(ctx, t) + + task, err := svc.CreateTask(ctx, "", CreateTaskRequest{Title: "host"}) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + + if _, err := svc.AddAttachment(ctx, task.ID, CreateAttachmentRequest{Type: db.AttachmentTypeLink, Title: "no url"}); err == nil { + t.Fatal("AddAttachment() expected error for missing url, got nil") + } +} + +func TestDeleteAttachment_Valid(t *testing.T) { + ctx := context.Background() + svc, _, pool := newTestService(ctx, t) + + task, err := svc.CreateTask(ctx, "", CreateTaskRequest{Title: "host"}) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + att, err := svc.AddAttachment(ctx, task.ID, CreateAttachmentRequest{ + Type: db.AttachmentTypeLink, URL: "https://example.com", + }) + if err != nil { + t.Fatalf("AddAttachment() error = %v", err) + } + + if err := svc.DeleteAttachment(ctx, att.ID); err != nil { + t.Fatalf("DeleteAttachment() error = %v", err) + } + if n := dbCountAttachments(ctx, t, pool, task.ID); n != 0 { + t.Errorf("attachment count after delete = %d, want 0", n) + } +} + +func TestDeleteAttachment_NotFound(t *testing.T) { + ctx := context.Background() + svc, _, _ := newTestService(ctx, t) + + err := svc.DeleteAttachment(ctx, 999999) + if err == nil { + t.Fatal("DeleteAttachment() expected error for missing attachment, got nil") + } + if !IsNotFound(err) { + t.Errorf("error = %v, want wrapped pgx.ErrNoRows", err) + } +} + +func TestDeleteTask_CascadeWithAttachments(t *testing.T) { + ctx := context.Background() + svc, _, pool := newTestService(ctx, t) + + parent, err := svc.CreateTask(ctx, "", CreateTaskRequest{Title: "parent"}) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + if _, err := svc.AddAttachment(ctx, parent.ID, CreateAttachmentRequest{ + Type: db.AttachmentTypeFile, Filename: "a.md", Content: b64("a"), + }); err != nil { + t.Fatalf("AddAttachment() error = %v", err) + } + if _, err := svc.AddAttachment(ctx, parent.ID, CreateAttachmentRequest{ + Type: db.AttachmentTypeLink, URL: "https://example.com", + }); err != nil { + t.Fatalf("AddAttachment() error = %v", err) + } + child, err := svc.CreateTask(ctx, "", CreateTaskRequest{Title: "child", ParentID: &parent.ID}) + if err != nil { + t.Fatalf("CreateTask(child) error = %v", err) + } + + if err := svc.DeleteTask(ctx, parent.ID); err != nil { + t.Fatalf("DeleteTask() error = %v", err) + } + + if _, err := svc.GetTask(ctx, parent.ID); !IsNotFound(err) { + t.Errorf("GetTask(parent) error = %v, want not-found", err) + } + if _, err := svc.GetTask(ctx, child.ID); !IsNotFound(err) { + t.Errorf("GetTask(child) error = %v, want not-found", err) + } + if n := dbCountAttachments(ctx, t, pool, parent.ID); n != 0 { + t.Errorf("attachment count after cascade delete = %d, want 0", n) + } +} + +func TestGetTask_WithAttachments(t *testing.T) { + ctx := context.Background() + svc, _, _ := newTestService(ctx, t) + + task, err := svc.CreateTask(ctx, "", CreateTaskRequest{Title: "host"}) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + if _, err := svc.AddAttachment(ctx, task.ID, CreateAttachmentRequest{ + Type: db.AttachmentTypeFile, Filename: "one.md", Content: b64("1"), + }); err != nil { + t.Fatalf("AddAttachment() error = %v", err) + } + if _, err := svc.AddAttachment(ctx, task.ID, CreateAttachmentRequest{ + Type: db.AttachmentTypeLink, URL: "https://example.com", Title: "link", + }); err != nil { + t.Fatalf("AddAttachment() error = %v", err) + } + + got, err := svc.GetTask(ctx, task.ID) + if err != nil { + t.Fatalf("GetTask() error = %v", err) + } + if len(got.Attachments) != 2 { + t.Fatalf("attachments = %d, want 2", len(got.Attachments)) + } + if got.Attachments[0].Filename != "one.md" { + t.Errorf("first attachment filename = %q, want %q", got.Attachments[0].Filename, "one.md") + } + if got.Attachments[1].Type != db.AttachmentTypeLink { + t.Errorf("second attachment type = %q, want %q", got.Attachments[1].Type, db.AttachmentTypeLink) + } +} + +func TestAddAttachment_UnsupportedFileType(t *testing.T) { + ctx := context.Background() + svc, _, _ := newTestService(ctx, t) + + task, err := svc.CreateTask(ctx, "", CreateTaskRequest{Title: "host"}) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + + _, err = svc.AddAttachment(ctx, task.ID, CreateAttachmentRequest{ + Type: db.AttachmentTypeFile, Filename: "malware.exe", Content: b64("x"), + }) + if err == nil { + t.Fatal("AddAttachment() expected error for unsupported file type, got nil") + } + if !strings.Contains(err.Error(), "unsupported file type") { + t.Errorf("error = %q, want unsupported-file-type message", err.Error()) + } +} + +func TestAddAttachment_FileNotBase64(t *testing.T) { + ctx := context.Background() + svc, _, _ := newTestService(ctx, t) + + task, err := svc.CreateTask(ctx, "", CreateTaskRequest{Title: "host"}) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + + _, err = svc.AddAttachment(ctx, task.ID, CreateAttachmentRequest{ + Type: db.AttachmentTypeFile, Filename: "notes.txt", Content: "%%% not base64 %%%", + }) + if err == nil { + t.Fatal("AddAttachment() expected error for non-base64 content, got nil") + } + if !strings.Contains(err.Error(), "base64") { + t.Errorf("error = %q, want base64 message", err.Error()) + } +} + +func TestSetAttribute_Upsert(t *testing.T) { + ctx := context.Background() + svc, _, _ := newTestService(ctx, t) + + task, err := svc.CreateTask(ctx, "", CreateTaskRequest{Title: "host"}) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + + attr, err := svc.SetAttribute(ctx, task.ID, "priority", "high") + if err != nil { + t.Fatalf("SetAttribute() error = %v", err) + } + if attr.Key != "priority" || attr.Value != "high" { + t.Errorf("attr = %+v, want priority=high", attr) + } + + // Setting the same key again replaces the value (upsert). + if _, err := svc.SetAttribute(ctx, task.ID, "priority", "low"); err != nil { + t.Fatalf("SetAttribute(upsert) error = %v", err) + } + + got, err := svc.GetTask(ctx, task.ID) + if err != nil { + t.Fatalf("GetTask() error = %v", err) + } + if len(got.Attributes) != 1 { + t.Fatalf("attributes = %d, want 1 (upsert, not duplicate)", len(got.Attributes)) + } + if got.Attributes[0].Value != "low" { + t.Errorf("attribute value = %q, want %q", got.Attributes[0].Value, "low") + } + // Attributes must not be reported as file/link attachments. + if len(got.Attachments) != 0 { + t.Errorf("attachments = %d, want 0 (attributes are separate)", len(got.Attachments)) + } +} + +func TestSetAttribute_EmptyKey(t *testing.T) { + ctx := context.Background() + svc, _, _ := newTestService(ctx, t) + + task, err := svc.CreateTask(ctx, "", CreateTaskRequest{Title: "host"}) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + if _, err := svc.SetAttribute(ctx, task.ID, " ", "v"); err == nil { + t.Fatal("SetAttribute() expected error for empty key, got nil") + } +} + +func TestDeleteAttribute(t *testing.T) { + ctx := context.Background() + svc, _, _ := newTestService(ctx, t) + + task, err := svc.CreateTask(ctx, "", CreateTaskRequest{Title: "host"}) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + if _, err := svc.SetAttribute(ctx, task.ID, "team", "platform"); err != nil { + t.Fatalf("SetAttribute() error = %v", err) + } + + if err := svc.DeleteAttribute(ctx, task.ID, "team"); err != nil { + t.Fatalf("DeleteAttribute() error = %v", err) + } + got, err := svc.GetTask(ctx, task.ID) + if err != nil { + t.Fatalf("GetTask() error = %v", err) + } + if len(got.Attributes) != 0 { + t.Errorf("attributes after delete = %d, want 0", len(got.Attributes)) + } + + // Deleting a missing key is a not-found. + if err := svc.DeleteAttribute(ctx, task.ID, "team"); !IsNotFound(err) { + t.Errorf("DeleteAttribute(missing) error = %v, want not-found", err) + } +} + +func TestBroadcast_CalledOnAttachmentMutation(t *testing.T) { + ctx := context.Background() + svc, b, _ := newTestService(ctx, t) + + task, err := svc.CreateTask(ctx, "", CreateTaskRequest{Title: "host"}) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + + before := b.calls() + att, err := svc.AddAttachment(ctx, task.ID, CreateAttachmentRequest{ + Type: db.AttachmentTypeLink, URL: "https://example.com", + }) + if err != nil { + t.Fatalf("AddAttachment() error = %v", err) + } + if got := b.calls() - before; got < 1 { + t.Errorf("add: Broadcast called %d times, want >= 1", got) + } + + before = b.calls() + if err := svc.DeleteAttachment(ctx, att.ID); err != nil { + t.Fatalf("DeleteAttachment() error = %v", err) + } + if got := b.calls() - before; got < 1 { + t.Errorf("delete: Broadcast called %d times, want >= 1", got) + } +} diff --git a/go/plugins/kanban-mcp/internal/service/board_service.go b/go/plugins/kanban-mcp/internal/service/board_service.go new file mode 100644 index 0000000000..abb727a20d --- /dev/null +++ b/go/plugins/kanban-mcp/internal/service/board_service.go @@ -0,0 +1,192 @@ +package service + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/db" + dbgen "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/db/gen" +) + +// Board is the JSON-facing metadata for a kanban board: its key, display name, +// ordered column set, and scope/owner. It does not carry tasks (see BoardState). +type Board struct { + ID int64 `json:"id"` + Key string `json:"key"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Scope string `json:"scope"` + Owner string `json:"owner,omitempty"` + Columns []string `json:"columns"` + Subtasks []string `json:"subtasks,omitempty"` // checklist template auto-added to new Tasks + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// CreateBoardRequest is the input for CreateBoard / UpsertBoard. Columns is +// required and must contain at least one non-empty column. Name defaults to Key +// and Scope defaults to "general" when empty. +type CreateBoardRequest struct { + Key string + Name string + Description string + Scope string + Owner string + Columns []string + Subtasks []string // optional checklist template auto-added to new Tasks +} + +// normalize validates and fills defaults for a board request, returning the +// cleaned key/name/scope, the trimmed, de-duplicated column list, and the +// (optional) trimmed, de-duplicated subtask template. +func (req CreateBoardRequest) normalize() (key, name, scope string, columns, subtasks []string, err error) { + key = strings.TrimSpace(req.Key) + if key == "" { + return "", "", "", nil, nil, fmt.Errorf("board key is required") + } + + columns = normalizeColumns(req.Columns) + if len(columns) == 0 { + return "", "", "", nil, nil, fmt.Errorf("board %q must define at least one column", key) + } + + // The subtask template is optional; reuse the column cleaner to trim, drop + // empties, and de-duplicate. + subtasks = normalizeColumns(req.Subtasks) + + name = strings.TrimSpace(req.Name) + if name == "" { + name = key + } + + scope = strings.TrimSpace(req.Scope) + if scope == "" { + scope = db.BoardScopeGeneral + } + if !db.ValidScope(scope) { + return "", "", "", nil, nil, fmt.Errorf("invalid board scope %q, valid scopes are %q and %q", + scope, db.BoardScopeGeneral, db.BoardScopeAgent) + } + + return key, name, scope, columns, subtasks, nil +} + +// ListBoards returns all boards ordered by creation time. +func (s *TaskService) ListBoards(ctx context.Context) ([]*Board, error) { + rows, err := s.q.ListBoards(ctx) + if err != nil { + return nil, fmt.Errorf("listing boards: %w", err) + } + out := make([]*Board, 0, len(rows)) + for _, row := range rows { + out = append(out, mapBoard(row)) + } + return out, nil +} + +// GetBoardMeta returns a single board's metadata by key. Not-found is reported as +// a wrapped pgx.ErrNoRows. +func (s *TaskService) GetBoardMeta(ctx context.Context, key string) (*Board, error) { + row, err := s.q.GetBoardByKey(ctx, key) + if err != nil { + return nil, fmt.Errorf("getting board %q: %w", key, err) + } + return mapBoard(row), nil +} + +// CreateBoard inserts a new board. The key must be unique; a duplicate key +// surfaces as the underlying Postgres unique-violation error. +func (s *TaskService) CreateBoard(ctx context.Context, req CreateBoardRequest) (*Board, error) { + key, name, scope, columns, subtasks, err := req.normalize() + if err != nil { + return nil, err + } + + row, err := s.q.CreateBoard(ctx, dbgen.CreateBoardParams{ + Key: key, + Name: name, + Description: req.Description, + Scope: scope, + Owner: req.Owner, + Columns: columns, + Subtasks: subtasks, + }) + if err != nil { + return nil, fmt.Errorf("creating board %q: %w", key, err) + } + return mapBoard(row), nil +} + +// UpsertBoard inserts a board or, if a board with the same key already exists, +// updates its name/description/scope/owner/columns. It is used for idempotent +// seeding from configuration. +func (s *TaskService) UpsertBoard(ctx context.Context, req CreateBoardRequest) (*Board, error) { + key, name, scope, columns, subtasks, err := req.normalize() + if err != nil { + return nil, err + } + + row, err := s.q.UpsertBoard(ctx, dbgen.UpsertBoardParams{ + Key: key, + Name: name, + Description: req.Description, + Scope: scope, + Owner: req.Owner, + Columns: columns, + Subtasks: subtasks, + }) + if err != nil { + return nil, fmt.Errorf("upserting board %q: %w", key, err) + } + return mapBoard(row), nil +} + +// resolveBoard fetches a board by key, defaulting an empty key to the built-in +// default board. It is the entry point for board-scoped task operations. +func (s *TaskService) resolveBoard(ctx context.Context, key string) (dbgen.Board, error) { + if key == "" { + key = db.DefaultBoardKey + } + board, err := s.q.GetBoardByKey(ctx, key) + if err != nil { + return dbgen.Board{}, fmt.Errorf("getting board %q: %w", key, err) + } + return board, nil +} + +// normalizeColumns trims whitespace, drops empty entries, and removes duplicate +// columns (case-sensitive) while preserving first-seen order. +func normalizeColumns(columns []string) []string { + out := make([]string, 0, len(columns)) + seen := make(map[string]struct{}, len(columns)) + for _, c := range columns { + c = strings.TrimSpace(c) + if c == "" { + continue + } + if _, ok := seen[c]; ok { + continue + } + seen[c] = struct{}{} + out = append(out, c) + } + return out +} + +// mapBoard converts a dbgen.Board row into the API Board metadata type. +func mapBoard(row dbgen.Board) *Board { + return &Board{ + ID: row.ID, + Key: row.Key, + Name: row.Name, + Description: row.Description, + Scope: row.Scope, + Owner: row.Owner, + Columns: row.Columns, + Subtasks: row.Subtasks, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } +} diff --git a/go/plugins/kanban-mcp/internal/service/board_service_test.go b/go/plugins/kanban-mcp/internal/service/board_service_test.go new file mode 100644 index 0000000000..b6ea54d438 --- /dev/null +++ b/go/plugins/kanban-mcp/internal/service/board_service_test.go @@ -0,0 +1,213 @@ +package service + +import ( + "context" + "testing" + + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/db" +) + +func TestDefaultBoard_Seeded(t *testing.T) { + ctx := context.Background() + svc, _, _ := newTestService(ctx, t) + + board, err := svc.GetBoardMeta(ctx, db.DefaultBoardKey) + if err != nil { + t.Fatalf("GetBoardMeta(default) error = %v", err) + } + if len(board.Columns) != len(db.DefaultColumns) { + t.Errorf("default board columns = %v, want %v", board.Columns, db.DefaultColumns) + } + if board.Columns[0] != string(db.StatusInbox) { + t.Errorf("first column = %q, want %q", board.Columns[0], db.StatusInbox) + } +} + +func TestCreateBoard_AndScopedTask(t *testing.T) { + ctx := context.Background() + svc, _, _ := newTestService(ctx, t) + + board, err := svc.CreateBoard(ctx, CreateBoardRequest{ + Key: "team", + Name: "Team", + Columns: []string{"Todo", "Doing", "Done"}, + }) + if err != nil { + t.Fatalf("CreateBoard() error = %v", err) + } + if board.Scope != db.BoardScopeGeneral { + t.Errorf("scope = %q, want %q (default)", board.Scope, db.BoardScopeGeneral) + } + + // A task created on the board defaults to that board's first column. + task, err := svc.CreateTask(ctx, "team", CreateTaskRequest{Title: "first"}) + if err != nil { + t.Fatalf("CreateTask(team) error = %v", err) + } + if string(task.Status) != "Todo" { + t.Errorf("default status = %q, want %q (board's first column)", task.Status, "Todo") + } + if task.BoardID != board.ID { + t.Errorf("task.BoardID = %d, want %d", task.BoardID, board.ID) + } +} + +func TestSubtaskTemplate_AutoAddedToTasks(t *testing.T) { + ctx := context.Background() + svc, _, _ := newTestService(ctx, t) + + board, err := svc.CreateBoard(ctx, CreateBoardRequest{ + Key: "worker", + Columns: []string{"Todo", "Doing", "Done"}, + Subtasks: []string{"Implement", "Open PR", "Monitor CI"}, + }) + if err != nil { + t.Fatalf("CreateBoard() error = %v", err) + } + if len(board.Subtasks) != 3 { + t.Fatalf("board.Subtasks = %v, want 3 template items", board.Subtasks) + } + + // A standalone Task created on the board auto-gets the template subtasks. + task, err := svc.CreateTask(ctx, "worker", CreateTaskRequest{Title: "do work", Kind: db.KindTask}) + if err != nil { + t.Fatalf("CreateTask(task) error = %v", err) + } + if len(task.Subtasks) != 3 { + t.Errorf("task.Subtasks = %d, want 3 from template", len(task.Subtasks)) + } + fetched, err := svc.GetTask(ctx, task.ID) + if err != nil { + t.Fatalf("GetTask() error = %v", err) + } + if len(fetched.Subtasks) != 3 || fetched.Subtasks[0].Title != "Implement" { + t.Errorf("persisted subtasks = %+v, want 3 template items in order", fetched.Subtasks) + } + + // A Feature is a container and must not receive checklist subtasks. + feature, err := svc.CreateTask(ctx, "worker", CreateTaskRequest{Title: "epic", Kind: db.KindFeature}) + if err != nil { + t.Fatalf("CreateTask(feature) error = %v", err) + } + if len(feature.Subtasks) != 0 { + t.Errorf("feature.Subtasks = %d, want 0 (features have no checklist)", len(feature.Subtasks)) + } + + // A child Task under the Feature also inherits the board's template. + child, err := svc.CreateTask(ctx, "worker", CreateTaskRequest{Title: "child", ParentID: &feature.ID}) + if err != nil { + t.Fatalf("CreateTask(child) error = %v", err) + } + if len(child.Subtasks) != 3 { + t.Errorf("child.Subtasks = %d, want 3 from template", len(child.Subtasks)) + } +} + +func TestCreateBoard_Validation(t *testing.T) { + ctx := context.Background() + svc, _, _ := newTestService(ctx, t) + + if _, err := svc.CreateBoard(ctx, CreateBoardRequest{Key: "", Columns: []string{"A"}}); err == nil { + t.Error("CreateBoard() with empty key: expected error, got nil") + } + if _, err := svc.CreateBoard(ctx, CreateBoardRequest{Key: "x", Columns: nil}); err == nil { + t.Error("CreateBoard() with no columns: expected error, got nil") + } + if _, err := svc.CreateBoard(ctx, CreateBoardRequest{Key: "x", Columns: []string{"A"}, Scope: "bogus"}); err == nil { + t.Error("CreateBoard() with invalid scope: expected error, got nil") + } +} + +func TestMoveTask_RestrictedToBoardColumns(t *testing.T) { + ctx := context.Background() + svc, _, _ := newTestService(ctx, t) + + if _, err := svc.CreateBoard(ctx, CreateBoardRequest{Key: "team", Columns: []string{"Todo", "Doing", "Done"}}); err != nil { + t.Fatalf("CreateBoard() error = %v", err) + } + task, err := svc.CreateTask(ctx, "team", CreateTaskRequest{Title: "t"}) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + + // Valid move within the board's columns. + if _, err := svc.MoveTask(ctx, task.ID, db.TaskStatus("Doing")); err != nil { + t.Fatalf("MoveTask(Doing) error = %v", err) + } + + // A column from a *different* board (the default board's "Develop") is rejected. + if _, err := svc.MoveTask(ctx, task.ID, db.StatusDevelop); err == nil { + t.Error("MoveTask() to a foreign board's column: expected error, got nil") + } + + // Persisted status is unchanged by the rejected move. + fetched, err := svc.GetTask(ctx, task.ID) + if err != nil { + t.Fatalf("GetTask() error = %v", err) + } + if string(fetched.Status) != "Doing" { + t.Errorf("status = %q, want %q (rejected move must not persist)", fetched.Status, "Doing") + } +} + +func TestUpsertBoard_Idempotent(t *testing.T) { + ctx := context.Background() + svc, _, _ := newTestService(ctx, t) + + first, err := svc.UpsertBoard(ctx, CreateBoardRequest{Key: "team", Name: "Team", Columns: []string{"Todo", "Done"}}) + if err != nil { + t.Fatalf("UpsertBoard() error = %v", err) + } + + // Re-upserting the same key updates name/columns rather than creating a new row. + second, err := svc.UpsertBoard(ctx, CreateBoardRequest{Key: "team", Name: "Team v2", Columns: []string{"Backlog", "Todo", "Done"}}) + if err != nil { + t.Fatalf("UpsertBoard() second error = %v", err) + } + if second.ID != first.ID { + t.Errorf("upsert created a new board (id %d -> %d), want same id", first.ID, second.ID) + } + if second.Name != "Team v2" || len(second.Columns) != 3 { + t.Errorf("upsert did not update fields: %+v", second) + } + + boards, err := svc.ListBoards(ctx) + if err != nil { + t.Fatalf("ListBoards() error = %v", err) + } + // default + team = 2. + if len(boards) != 2 { + t.Errorf("ListBoards() = %d boards, want 2 (default + team)", len(boards)) + } +} + +func TestBoardScopedListing(t *testing.T) { + ctx := context.Background() + svc, _, _ := newTestService(ctx, t) + + if _, err := svc.CreateBoard(ctx, CreateBoardRequest{Key: "team", Columns: []string{"Todo", "Done"}}); err != nil { + t.Fatalf("CreateBoard() error = %v", err) + } + if _, err := svc.CreateTask(ctx, "team", CreateTaskRequest{Title: "team task"}); err != nil { + t.Fatalf("CreateTask(team) error = %v", err) + } + if _, err := svc.CreateTask(ctx, "", CreateTaskRequest{Title: "default task"}); err != nil { + t.Fatalf("CreateTask(default) error = %v", err) + } + + teamTasks, err := svc.ListTasks(ctx, "team", TaskFilter{}) + if err != nil { + t.Fatalf("ListTasks(team) error = %v", err) + } + if len(teamTasks) != 1 || teamTasks[0].Title != "team task" { + t.Errorf("team board tasks = %+v, want exactly the team task", teamTasks) + } + + defaultTasks, err := svc.ListTasks(ctx, "", TaskFilter{}) + if err != nil { + t.Fatalf("ListTasks(default) error = %v", err) + } + if len(defaultTasks) != 1 || defaultTasks[0].Title != "default task" { + t.Errorf("default board tasks = %+v, want exactly the default task", defaultTasks) + } +} diff --git a/go/plugins/kanban-mcp/internal/service/progress.go b/go/plugins/kanban-mcp/internal/service/progress.go new file mode 100644 index 0000000000..d751a0b1f6 --- /dev/null +++ b/go/plugins/kanban-mcp/internal/service/progress.go @@ -0,0 +1,233 @@ +package service + +import ( + "context" + "fmt" + "math" + + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/db" +) + +// TaskProgress is the data the task-progress MCP App View renders. It is the +// structuredContent returned by the show_task_progress / refresh_task_progress +// tools. The text Summary doubles as the tool's text fallback for non-UI hosts. +// +// For a Feature, Columns holds the per-column count of its child Tasks and +// Children lists those child Tasks; Subtasks is empty. For a Task, Subtasks +// holds the checklist items and Columns/Children are empty. +type TaskProgress struct { + TaskID int64 `json:"task_id"` + Title string `json:"title"` + Kind string `json:"kind"` // "feature" | "task" + Status db.TaskStatus `json:"status"` + Assignee string `json:"assignee,omitempty"` + Labels []string `json:"labels,omitempty"` + UserInputNeeded bool `json:"user_input_needed"` + Percent int `json:"percent"` // 0..100 overall completion + DoneCount int `json:"done_count"` // children in done column, or subtasks done + TotalCount int `json:"total_count"` // children, or subtasks + Summary string `json:"summary"` + Board ProgressBoard `json:"board"` + Columns []ColumnCount `json:"columns,omitempty"` // Feature: child count per board column + Children []ProgressItem `json:"children,omitempty"` // Feature: child Tasks + Subtasks []ProgressItem `json:"subtasks,omitempty"` // Task: checklist items +} + +// ProgressBoard is the board context the widget needs to render columns and +// identify the terminal ("done") column. +type ProgressBoard struct { + Key string `json:"key"` + Name string `json:"name"` + Columns []string `json:"columns"` + DoneColumn string `json:"done_column"` +} + +// ColumnCount is the number of a Feature's child Tasks sitting in one column. +type ColumnCount struct { + Status string `json:"status"` + Count int `json:"count"` +} + +// ProgressItem is one row in the widget: a child Task (Status + column-derived +// Percent) for a Feature, or a checklist subtask (Done) for a Task. +type ProgressItem struct { + ID int64 `json:"id"` + Title string `json:"title"` + Status db.TaskStatus `json:"status,omitempty"` // child Tasks only + Percent int `json:"percent"` // child: column progress; subtask: 0/100 + Done bool `json:"done"` +} + +// TaskProgress assembles the progress view for a single card (Feature or Task). +// It loads the card (with children/subtasks) and its board, then computes a +// completion percentage and the per-item breakdown. Not-found surfaces as a +// wrapped pgx.ErrNoRows (via GetTask). +func (s *TaskService) TaskProgress(ctx context.Context, id int64) (*TaskProgress, error) { + task, err := s.GetTask(ctx, id) + if err != nil { + return nil, err + } + + board, err := s.q.GetBoardByID(ctx, task.BoardID) + if err != nil { + return nil, fmt.Errorf("getting board for task %d: %w", id, err) + } + cols := board.Columns + doneCol := "" + if len(cols) > 0 { + doneCol = cols[len(cols)-1] + } + + p := &TaskProgress{ + TaskID: task.ID, + Title: task.Title, + Kind: task.Kind, + Status: task.Status, + Assignee: task.Assignee, + Labels: task.Labels, + UserInputNeeded: task.UserInputNeeded, + Board: ProgressBoard{ + Key: board.Key, + Name: board.Name, + Columns: cols, + DoneColumn: doneCol, + }, + } + + if task.Kind == db.KindFeature { + // Each child bar reflects that child Task's own completion, so load each + // child's checklist subtasks before computing the breakdown. + for _, child := range task.Children { + subs, err := s.ListSubtasks(ctx, child.ID) + if err != nil { + return nil, fmt.Errorf("listing subtasks for child task %d: %w", child.ID, err) + } + child.Subtasks = subs + } + fillFeatureProgress(p, task, cols, doneCol) + } else { + fillTaskProgress(p, task, cols, doneCol) + } + + p.Summary = progressSummary(p) + return p, nil +} + +// fillFeatureProgress computes a Feature's percent as the mean completion of its +// child Tasks (each child's checklist ratio, or its column position when it has +// no checklist) and tallies children per column. With no children it falls back +// to the Feature's own column position. +func fillFeatureProgress(p *TaskProgress, task *Task, cols []string, doneCol string) { + counts := make(map[string]int, len(cols)) + sum := 0 + for _, child := range task.Children { + cp, _, _ := childTaskProgress(child.Subtasks, string(child.Status), cols) + sum += cp + counts[string(child.Status)]++ + p.Children = append(p.Children, ProgressItem{ + ID: child.ID, + Title: child.Title, + Status: child.Status, + Percent: cp, + Done: string(child.Status) == doneCol && doneCol != "", + }) + } + + p.TotalCount = len(task.Children) + p.DoneCount = counts[doneCol] + + p.Columns = make([]ColumnCount, 0, len(cols)) + for _, col := range cols { + p.Columns = append(p.Columns, ColumnCount{Status: col, Count: counts[col]}) + } + + if p.TotalCount > 0 { + p.Percent = int(math.Round(float64(sum) / float64(p.TotalCount))) + } else { + p.Percent = columnProgress(string(task.Status), cols) + } +} + +// fillTaskProgress computes a Task's percent from its checklist subtasks when it +// has any, otherwise from its own column position. +func fillTaskProgress(p *TaskProgress, task *Task, cols []string, doneCol string) { + done := 0 + for _, sub := range task.Subtasks { + pct := 0 + if sub.Done { + pct = 100 + done++ + } + p.Subtasks = append(p.Subtasks, ProgressItem{ + ID: sub.ID, + Title: sub.Title, + Percent: pct, + Done: sub.Done, + }) + } + + p.TotalCount = len(task.Subtasks) + p.DoneCount = done + + if p.TotalCount > 0 { + p.Percent = int(math.Round(float64(done) / float64(p.TotalCount) * 100)) + } else { + p.Percent = columnProgress(string(task.Status), cols) + } +} + +// childTaskProgress computes one child Task's completion for the Feature view: +// the checklist done ratio when the Task has checklist subtasks, otherwise its +// column position. It returns the percent and the done/total checklist counts. +func childTaskProgress(subs []*Subtask, status string, cols []string) (percent, done, total int) { + total = len(subs) + for _, sub := range subs { + if sub.Done { + done++ + } + } + if total > 0 { + percent = int(math.Round(float64(done) / float64(total) * 100)) + } else { + percent = columnProgress(status, cols) + } + return percent, done, total +} + +// columnProgress maps a status onto a 0..100 position within the board's ordered +// columns: the first column is 0%, the last is 100%. A single-column board (or a +// status equal to the only column) is treated as complete. An unknown status is +// 0%. +func columnProgress(status string, cols []string) int { + idx := -1 + for i, c := range cols { + if c == status { + idx = i + break + } + } + if idx < 0 { + return 0 + } + if len(cols) <= 1 { + return 100 + } + return int(math.Round(float64(idx) / float64(len(cols)-1) * 100)) +} + +// progressSummary builds the one-line human summary used as the tool's text +// fallback (shown to the model and to non-UI hosts). +func progressSummary(p *TaskProgress) string { + if p.Kind == db.KindFeature { + if p.TotalCount == 0 { + return fmt.Sprintf("Feature %q has no child tasks yet; currently in column %q (%d%%).", p.Title, p.Status, p.Percent) + } + return fmt.Sprintf("Feature %q is %d%% complete — %d of %d child tasks done (in %q).", + p.Title, p.Percent, p.DoneCount, p.TotalCount, p.Board.DoneColumn) + } + if p.TotalCount > 0 { + return fmt.Sprintf("Task %q is %d%% complete — %d of %d checklist items done.", + p.Title, p.Percent, p.DoneCount, p.TotalCount) + } + return fmt.Sprintf("Task %q is in column %q (%d%%).", p.Title, p.Status, p.Percent) +} diff --git a/go/plugins/kanban-mcp/internal/service/progress_test.go b/go/plugins/kanban-mcp/internal/service/progress_test.go new file mode 100644 index 0000000000..b2cf907a40 --- /dev/null +++ b/go/plugins/kanban-mcp/internal/service/progress_test.go @@ -0,0 +1,241 @@ +package service + +import ( + "testing" + + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/db" +) + +func TestColumnProgress(t *testing.T) { + cols := db.DefaultColumns // Inbox..Done, 7 columns + + tests := []struct { + name string + status string + cols []string + want int + }{ + {name: "first column is 0%", status: "Inbox", cols: cols, want: 0}, + {name: "last column is 100%", status: "Done", cols: cols, want: 100}, + {name: "middle column", status: "Develop", cols: cols, want: 33}, + {name: "unknown status is 0%", status: "Nope", cols: cols, want: 0}, + {name: "single column present is 100%", status: "Only", cols: []string{"Only"}, want: 100}, + {name: "no columns is 0%", status: "x", cols: nil, want: 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := columnProgress(tt.status, tt.cols); got != tt.want { + t.Errorf("columnProgress(%q) = %d, want %d", tt.status, got, tt.want) + } + }) + } +} + +func TestFillTaskProgress_Subtasks(t *testing.T) { + cols := db.DefaultColumns + task := &Task{ + ID: 1, Title: "Impl", Kind: db.KindTask, Status: db.StatusDevelop, + Subtasks: []*Subtask{ + {ID: 1, Title: "a", Done: true}, + {ID: 2, Title: "b", Done: true}, + {ID: 3, Title: "c", Done: false}, + {ID: 4, Title: "d", Done: false}, + }, + } + p := &TaskProgress{Kind: task.Kind, Title: task.Title, Status: task.Status} + fillTaskProgress(p, task, cols, "Done") + + if p.TotalCount != 4 || p.DoneCount != 2 { + t.Fatalf("counts = %d/%d, want 2/4", p.DoneCount, p.TotalCount) + } + if p.Percent != 50 { + t.Errorf("percent = %d, want 50 (from checklist, not column)", p.Percent) + } + if len(p.Subtasks) != 4 || !p.Subtasks[0].Done || p.Subtasks[0].Percent != 100 { + t.Errorf("subtasks not mapped correctly: %+v", p.Subtasks) + } +} + +func TestFillTaskProgress_NoSubtasks_UsesColumn(t *testing.T) { + cols := db.DefaultColumns + task := &Task{ID: 1, Kind: db.KindTask, Status: db.StatusDone} + p := &TaskProgress{Kind: task.Kind, Status: task.Status} + fillTaskProgress(p, task, cols, "Done") + + if p.Percent != 100 { + t.Errorf("percent = %d, want 100 (Done column, no subtasks)", p.Percent) + } + if p.TotalCount != 0 { + t.Errorf("total = %d, want 0", p.TotalCount) + } +} + +func TestFillFeatureProgress(t *testing.T) { + cols := db.DefaultColumns + feature := &Task{ + ID: 1, Title: "Epic", Kind: db.KindFeature, Status: db.StatusPlan, + Children: []*Task{ + {ID: 2, Title: "c1", Status: db.StatusDone}, // 100 + {ID: 3, Title: "c2", Status: db.StatusDone}, // 100 + {ID: 4, Title: "c3", Status: db.StatusInbox}, // 0 + }, + } + p := &TaskProgress{Kind: feature.Kind, Title: feature.Title, Status: feature.Status} + fillFeatureProgress(p, feature, cols, "Done") + + if p.TotalCount != 3 || p.DoneCount != 2 { + t.Fatalf("counts = %d/%d, want 2/3 done", p.DoneCount, p.TotalCount) + } + // mean(100,100,0) = 66.67 -> 67 + if p.Percent != 67 { + t.Errorf("percent = %d, want 67 (mean child column progress)", p.Percent) + } + if len(p.Columns) != len(cols) { + t.Fatalf("columns = %d, want %d (all board columns)", len(p.Columns), len(cols)) + } + // Inbox should have 1, Done should have 2. + counts := map[string]int{} + for _, c := range p.Columns { + counts[c.Status] = c.Count + } + if counts["Inbox"] != 1 || counts["Done"] != 2 { + t.Errorf("column counts = %+v, want Inbox=1 Done=2", counts) + } + if len(p.Children) != 3 || !p.Children[0].Done { + t.Errorf("children not mapped correctly: %+v", p.Children) + } +} + +func TestFillFeatureProgress_ChildChecklistDrivesPercent(t *testing.T) { + cols := db.DefaultColumns + feature := &Task{ + ID: 1, Title: "Epic", Kind: db.KindFeature, Status: db.StatusPlan, + Children: []*Task{ + // Child in the first column but with a half-done checklist -> 50%, + // not its column position (0%). + {ID: 2, Title: "c1", Status: db.StatusInbox, Subtasks: []*Subtask{ + {ID: 1, Done: true}, {ID: 2, Done: false}, + }}, + // Child with no checklist falls back to its column position (Done -> 100%). + {ID: 3, Title: "c2", Status: db.StatusDone}, + }, + } + p := &TaskProgress{Kind: feature.Kind, Title: feature.Title, Status: feature.Status} + fillFeatureProgress(p, feature, cols, "Done") + + if p.Children[0].Percent != 50 { + t.Errorf("c1 percent = %d, want 50 (checklist ratio, not column)", p.Children[0].Percent) + } + if p.Children[1].Percent != 100 { + t.Errorf("c2 percent = %d, want 100 (Done column, no checklist)", p.Children[1].Percent) + } + // mean(50,100) = 75 + if p.Percent != 75 { + t.Errorf("percent = %d, want 75 (mean of child completions)", p.Percent) + } + // done_count still tracks children sitting in the done column. + if p.DoneCount != 1 || p.TotalCount != 2 { + t.Errorf("counts = %d/%d, want 1/2", p.DoneCount, p.TotalCount) + } +} + +func TestFillFeatureProgress_NoChildren_UsesOwnColumn(t *testing.T) { + cols := db.DefaultColumns + feature := &Task{ID: 1, Kind: db.KindFeature, Status: db.StatusDevelop} + p := &TaskProgress{Kind: feature.Kind, Status: feature.Status} + fillFeatureProgress(p, feature, cols, "Done") + + if p.TotalCount != 0 { + t.Errorf("total = %d, want 0", p.TotalCount) + } + if p.Percent != 33 { + t.Errorf("percent = %d, want 33 (own Develop column position)", p.Percent) + } +} + +func TestChildTaskProgress(t *testing.T) { + cols := db.DefaultColumns // Inbox..Done + + tests := []struct { + name string + subs []*Subtask + status string + wantPercent int + wantDone int + wantTotal int + }{ + { + name: "checklist ratio wins over column", + subs: []*Subtask{{Done: true}, {Done: true}, {Done: false}, {Done: false}}, + status: "Inbox", + wantPercent: 50, wantDone: 2, wantTotal: 4, + }, + { + name: "all checklist items done is 100", + subs: []*Subtask{{Done: true}, {Done: true}}, + status: "Plan", + wantPercent: 100, wantDone: 2, wantTotal: 2, + }, + { + name: "no checklist falls back to column position", + subs: nil, + status: "Develop", + wantPercent: 33, wantDone: 0, wantTotal: 0, + }, + { + name: "no checklist in done column is 100", + subs: nil, + status: "Done", + wantPercent: 100, wantDone: 0, wantTotal: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pct, done, total := childTaskProgress(tt.subs, tt.status, cols) + if pct != tt.wantPercent || done != tt.wantDone || total != tt.wantTotal { + t.Errorf("childTaskProgress() = (%d,%d,%d), want (%d,%d,%d)", + pct, done, total, tt.wantPercent, tt.wantDone, tt.wantTotal) + } + }) + } +} + +func TestProgressSummary(t *testing.T) { + tests := []struct { + name string + p *TaskProgress + want string + }{ + { + name: "feature with children", + p: &TaskProgress{Kind: db.KindFeature, Title: "Epic", Percent: 67, DoneCount: 2, + TotalCount: 3, Board: ProgressBoard{DoneColumn: "Done"}}, + want: `Feature "Epic" is 67% complete — 2 of 3 child tasks done (in "Done").`, + }, + { + name: "feature without children", + p: &TaskProgress{Kind: db.KindFeature, Title: "Empty", Percent: 0, Status: "Inbox"}, + want: `Feature "Empty" has no child tasks yet; currently in column "Inbox" (0%).`, + }, + { + name: "task with checklist", + p: &TaskProgress{Kind: db.KindTask, Title: "Impl", Percent: 50, DoneCount: 2, TotalCount: 4}, + want: `Task "Impl" is 50% complete — 2 of 4 checklist items done.`, + }, + { + name: "task without checklist", + p: &TaskProgress{Kind: db.KindTask, Title: "Plain", Percent: 100, Status: "Done"}, + want: `Task "Plain" is in column "Done" (100%).`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := progressSummary(tt.p); got != tt.want { + t.Errorf("progressSummary() =\n %q\nwant\n %q", got, tt.want) + } + }) + } +} diff --git a/go/plugins/kanban-mcp/internal/service/task_service.go b/go/plugins/kanban-mcp/internal/service/task_service.go new file mode 100644 index 0000000000..7c59a14f27 --- /dev/null +++ b/go/plugins/kanban-mcp/internal/service/task_service.go @@ -0,0 +1,997 @@ +// Package service implements the kanban TaskService: the central domain logic +// through which all task and attachment mutations flow. Every mutation persists +// via the sqlc-generated dbgen queries and then broadcasts the full board state +// so connected SSE clients stay in sync. +package service + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/db" + dbgen "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/db/gen" +) + +// Task is the JSON-facing domain type returned by MCP tools and the REST API. It +// is assembled from the sqlc row type (dbgen.Task) plus eager-loaded checklist +// Subtasks, child tasks (Children), and Attachments where applicable. +// +// A task is either a Feature (Kind == "feature", ParentID == nil) or a Task +// (Kind == "task", ParentID points at a Feature). Both are full kanban cards; +// only Tasks carry checklist Subtasks. Children is populated by GetTask for a +// Feature so callers can see the Feature's child Tasks. +type Task struct { + ID int64 `json:"id"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Status db.TaskStatus `json:"status"` + Kind string `json:"kind"` // "feature" | "task" + Assignee string `json:"assignee,omitempty"` + Labels []string `json:"labels,omitempty"` + UserInputNeeded bool `json:"user_input_needed"` + ParentID *int64 `json:"parent_id,omitempty"` + BoardID int64 `json:"board_id"` + Subtasks []*Subtask `json:"subtasks,omitempty"` // checklist items (Tasks only) + Children []*Task `json:"children,omitempty"` // child Tasks (Features only) + Attachments []*Attachment `json:"attachments,omitempty"` // file + link rows + Attributes []*Attribute `json:"attributes,omitempty"` // key/value rows + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Subtask is a lightweight checklist item attached to a Task. Unlike a Task it +// has no status, assignee, labels, attachments, or board position: only a title +// and a done flag. +type Subtask struct { + ID int64 `json:"id"` + TaskID int64 `json:"task_id"` + Title string `json:"title"` + Done bool `json:"done"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Attachment is the JSON-facing domain type for a task file or link attachment. +// For type=file, Content holds the base64-encoded file bytes. +type Attachment struct { + ID int64 `json:"id"` + TaskID int64 `json:"task_id"` + Type db.AttachmentType `json:"type"` + Filename string `json:"filename,omitempty"` + Content string `json:"content,omitempty"` + URL string `json:"url,omitempty"` + Title string `json:"title,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Attribute is a simple key/value pair attached to a card. It is persisted as a +// type="attribute" row in kanban.attachment (title=key, content=value), sharing +// the attachment table but exposed to callers as a clean key/value pair. +type Attribute struct { + ID int64 `json:"id"` + TaskID int64 `json:"task_id"` + Key string `json:"key"` + Value string `json:"value"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// BoardState is the full state of a single board: its metadata plus one column +// per board-defined column, each holding that column's top-level tasks. It is the +// payload broadcast to SSE clients (scoped to the board) after every mutation. +type BoardState struct { + Board *Board `json:"board"` + Columns []Column `json:"columns"` +} + +// Column holds the top-level tasks for a single board column. +type Column struct { + Status db.TaskStatus `json:"status"` + Tasks []*Task `json:"tasks"` +} + +// TaskFilter narrows ListTasks results. A nil field means "no constraint". +type TaskFilter struct { + Status *db.TaskStatus + Assignee *string + Label *string + ParentID *int64 // nil = top-level only (WHERE parent_id IS NULL) +} + +// CreateTaskRequest is the input for CreateTask. Status defaults to the board's +// first column when empty. When ParentID is set the new task is created as a +// child Task of that Feature (the board is inherited from the Feature). For a +// top-level card (ParentID nil) Kind selects "feature" (default) or "task" (a +// standalone Task with no parent); it is ignored when ParentID is set. +type CreateTaskRequest struct { + Title string + Description string + Status db.TaskStatus + Labels []string + ParentID *int64 + Kind string // "feature" (default) | "task"; top-level only +} + +// UpdateTaskRequest carries partial updates. A nil pointer field is left +// unchanged. +type UpdateTaskRequest struct { + Title *string + Description *string + Status *db.TaskStatus + Assignee *string + Labels *[]string + UserInputNeeded *bool +} + +// Broadcaster receives the state of a single board (identified by boardKey) after +// every mutation that affects it. The SSE Hub implements this; a no-op stub is +// used for transports without live clients (e.g. stdio). +type Broadcaster interface { + Broadcast(boardKey string, event any) +} + +// NopBroadcaster is a Broadcaster that discards events. It lets TaskService run +// before the SSE Hub is wired in (and in stdio mode, which has no SSE clients). +type NopBroadcaster struct{} + +// Broadcast implements Broadcaster and does nothing. +func (NopBroadcaster) Broadcast(string, any) {} + +// TaskService is the central domain service for tasks and attachments. +type TaskService struct { + q *dbgen.Queries + pool *pgxpool.Pool + broadcaster Broadcaster +} + +// NewTaskService constructs a TaskService. If b is nil, a NopBroadcaster is used. +func NewTaskService(q *dbgen.Queries, pool *pgxpool.Pool, b Broadcaster) *TaskService { + if b == nil { + b = NopBroadcaster{} + } + return &TaskService{q: q, pool: pool, broadcaster: b} +} + +// ListTasks returns tasks matching the filter. With filter.ParentID == nil the +// query returns the named board's cards (Features and child Tasks alike). When +// filter.ParentID is set the result is that Feature's child Tasks (the board is +// implied by the parent, so boardKey is ignored). +func (s *TaskService) ListTasks(ctx context.Context, boardKey string, filter TaskFilter) ([]*Task, error) { + if filter.ParentID != nil { + rows, err := s.q.ListChildTasks(ctx, filter.ParentID) + if err != nil { + return nil, fmt.Errorf("listing child tasks for parent %d: %w", *filter.ParentID, err) + } + return mapTasks(rows), nil + } + + board, err := s.resolveBoard(ctx, boardKey) + if err != nil { + return nil, err + } + + params := dbgen.ListBoardTasksParams{BoardID: board.ID} + if filter.Status != nil { + st := string(*filter.Status) + params.Status = &st + } + params.Assignee = filter.Assignee + params.Label = filter.Label + + rows, err := s.q.ListBoardTasks(ctx, params) + if err != nil { + return nil, fmt.Errorf("listing tasks for board %q: %w", board.Key, err) + } + return mapTasks(rows), nil +} + +// GetTask returns a single task with its checklist subtasks and attachments +// eager-loaded via separate queries (sqlc has no ORM-style preload). For a +// Feature it also loads its child Tasks (Children). Not-found is reported as a +// wrapped pgx.ErrNoRows. +func (s *TaskService) GetTask(ctx context.Context, id int64) (*Task, error) { + row, err := s.q.GetTask(ctx, id) + if err != nil { + return nil, fmt.Errorf("getting task %d: %w", id, err) + } + t := mapTask(row) + + subs, err := s.q.ListSubtasksByTask(ctx, t.ID) + if err != nil { + return nil, fmt.Errorf("listing subtasks for task %d: %w", id, err) + } + t.Subtasks = mapSubtasks(subs) + + // A Feature exposes its child Tasks so a detail view can list them. + if t.Kind == db.KindFeature { + children, err := s.q.ListChildTasks(ctx, &t.ID) + if err != nil { + return nil, fmt.Errorf("listing child tasks for feature %d: %w", id, err) + } + t.Children = mapTasks(children) + } + + atts, err := s.q.ListAttachments(ctx, t.ID) + if err != nil { + return nil, fmt.Errorf("listing attachments for task %d: %w", id, err) + } + t.Attachments, t.Attributes = splitAttachmentRows(atts) + + return t, nil +} + +// CreateTask inserts a new task. When req.ParentID is nil it creates a Feature +// (top-level task) on the named board (empty boardKey = the default board). When +// req.ParentID is set it creates a child Task under that Feature, inheriting the +// Feature's board; the parent must itself be a Feature (a Task cannot parent +// another Task). Status defaults to the board's first column when empty and must +// otherwise be one of the board's columns. +func (s *TaskService) CreateTask(ctx context.Context, boardKey string, req CreateTaskRequest) (*Task, error) { + if req.ParentID != nil { + return s.createChildTask(ctx, *req.ParentID, req) + } + + board, err := s.resolveBoard(ctx, boardKey) + if err != nil { + return nil, err + } + + status := string(req.Status) + if status == "" { + status = board.Columns[0] + } + if !db.ValidColumn(board.Columns, status) { + return nil, columnError(board, status) + } + + kind := req.Kind + if kind == "" { + kind = db.KindFeature + } + if kind != db.KindFeature && kind != db.KindTask { + return nil, fmt.Errorf("invalid kind %q: must be %q or %q", kind, db.KindFeature, db.KindTask) + } + + row, err := s.q.CreateTask(ctx, dbgen.CreateTaskParams{ + Title: req.Title, + Description: req.Description, + Status: status, + Labels: normalizeLabels(req.Labels), + BoardID: board.ID, + Kind: kind, + }) + if err != nil { + return nil, fmt.Errorf("creating task: %w", err) + } + + task := mapTask(row) + // Only Tasks carry checklist subtasks, so the board's subtask template is + // applied to standalone Tasks (Features are containers and have none). + if kind == db.KindTask { + subs, err := s.applySubtaskTemplate(ctx, row.ID, board.Subtasks) + if err != nil { + return nil, err + } + task.Subtasks = subs + } + + s.broadcastBoardKey(ctx, board.Key) + return task, nil +} + +// createChildTask inserts a child Task under the Feature parentID. The parent is +// fetched first so a missing parent surfaces as a wrapped pgx.ErrNoRows, and a +// parent that is itself a child Task is rejected (one level of nesting only). +func (s *TaskService) createChildTask(ctx context.Context, parentID int64, req CreateTaskRequest) (*Task, error) { + parent, err := s.q.GetTask(ctx, parentID) + if err != nil { + return nil, fmt.Errorf("getting parent task %d: %w", parentID, err) + } + if mapTask(parent).Kind != db.KindFeature { + return nil, fmt.Errorf("a task's parent must be a feature") + } + + board, err := s.q.GetBoardByID(ctx, parent.BoardID) + if err != nil { + return nil, fmt.Errorf("getting board for task %d: %w", parentID, err) + } + + status := string(req.Status) + if status == "" { + status = board.Columns[0] + } + if !db.ValidColumn(board.Columns, status) { + return nil, columnError(board, status) + } + + pid := parentID + row, err := s.q.CreateChildTask(ctx, dbgen.CreateChildTaskParams{ + Title: req.Title, + Description: req.Description, + Status: status, + Labels: normalizeLabels(req.Labels), + ParentID: &pid, + BoardID: parent.BoardID, + }) + if err != nil { + return nil, fmt.Errorf("creating child task of feature %d: %w", parentID, err) + } + + // A child card is always a Task, so it inherits the board's subtask template. + task := mapTask(row) + subs, err := s.applySubtaskTemplate(ctx, row.ID, board.Subtasks) + if err != nil { + return nil, err + } + task.Subtasks = subs + + s.broadcastBoardID(ctx, parent.BoardID) + return task, nil +} + +// applySubtaskTemplate creates one checklist subtask per template title on the +// given task, in order, returning the created subtasks. An empty template is a +// no-op (returns nil), so boards without a template behave exactly as before. +func (s *TaskService) applySubtaskTemplate(ctx context.Context, taskID int64, titles []string) ([]*Subtask, error) { + if len(titles) == 0 { + return nil, nil + } + subs := make([]*Subtask, 0, len(titles)) + for _, title := range titles { + row, err := s.q.CreateSubtask(ctx, dbgen.CreateSubtaskParams{TaskID: taskID, Title: title}) + if err != nil { + return nil, fmt.Errorf("creating template subtask %q for task %d: %w", title, taskID, err) + } + subs = append(subs, mapSubtask(row)) + } + return subs, nil +} + +// UpdateTask applies the non-nil fields of req to the task, preserving the rest. +func (s *TaskService) UpdateTask(ctx context.Context, id int64, req UpdateTaskRequest) (*Task, error) { + current, err := s.q.GetTask(ctx, id) + if err != nil { + return nil, fmt.Errorf("getting task %d: %w", id, err) + } + + params := dbgen.UpdateTaskParams{ + ID: current.ID, + Title: current.Title, + Description: current.Description, + Status: current.Status, + Assignee: current.Assignee, + Labels: current.Labels, + UserInputNeeded: current.UserInputNeeded, + } + if req.Title != nil { + params.Title = *req.Title + } + if req.Description != nil { + params.Description = *req.Description + } + if req.Status != nil { + board, err := s.q.GetBoardByID(ctx, current.BoardID) + if err != nil { + return nil, fmt.Errorf("getting board for task %d: %w", id, err) + } + if !db.ValidColumn(board.Columns, string(*req.Status)) { + return nil, columnError(board, string(*req.Status)) + } + params.Status = string(*req.Status) + } + if req.Assignee != nil { + params.Assignee = *req.Assignee + } + if req.Labels != nil { + params.Labels = normalizeLabels(*req.Labels) + } + if req.UserInputNeeded != nil { + params.UserInputNeeded = *req.UserInputNeeded + } + + row, err := s.q.UpdateTask(ctx, params) + if err != nil { + return nil, fmt.Errorf("updating task %d: %w", id, err) + } + + if err := s.syncCloseDate(ctx, id, current.Status, row.Status); err != nil { + return nil, fmt.Errorf("syncing close_date for task %d: %w", id, err) + } + + s.broadcastBoardID(ctx, row.BoardID) + return mapTask(row), nil +} + +// MoveTask changes a task's status after validating that the target column +// belongs to the task's own board (tasks can only move between their board's +// predefined columns). +func (s *TaskService) MoveTask(ctx context.Context, id int64, status db.TaskStatus) (*Task, error) { + current, err := s.q.GetTask(ctx, id) + if err != nil { + return nil, fmt.Errorf("getting task %d: %w", id, err) + } + board, err := s.q.GetBoardByID(ctx, current.BoardID) + if err != nil { + return nil, fmt.Errorf("getting board for task %d: %w", id, err) + } + if !db.ValidColumn(board.Columns, string(status)) { + return nil, columnError(board, string(status)) + } + + row, err := s.q.MoveTask(ctx, dbgen.MoveTaskParams{ + ID: id, + Status: string(status), + }) + if err != nil { + return nil, fmt.Errorf("moving task %d: %w", id, err) + } + + if err := s.syncCloseDate(ctx, id, current.Status, row.Status); err != nil { + return nil, fmt.Errorf("syncing close_date for task %d: %w", id, err) + } + + s.broadcastBoardID(ctx, row.BoardID) + return mapTask(row), nil +} + +// AssignTask sets a task's assignee. An empty assignee is valid and clears the +// current assignment. +func (s *TaskService) AssignTask(ctx context.Context, id int64, assignee string) (*Task, error) { + row, err := s.q.AssignTask(ctx, dbgen.AssignTaskParams{ + ID: id, + Assignee: assignee, + }) + if err != nil { + return nil, fmt.Errorf("assigning task %d: %w", id, err) + } + + s.broadcastBoardID(ctx, row.BoardID) + return mapTask(row), nil +} + +// ListSubtasks returns the checklist subtasks of a task in insertion order. A +// missing task simply yields an empty list (the FK guarantees rows only exist +// for real tasks). +func (s *TaskService) ListSubtasks(ctx context.Context, taskID int64) ([]*Subtask, error) { + rows, err := s.q.ListSubtasksByTask(ctx, taskID) + if err != nil { + return nil, fmt.Errorf("listing subtasks for task %d: %w", taskID, err) + } + return mapSubtasks(rows), nil +} + +// CreateSubtask adds a checklist subtask (title + done flag) to a Task. The +// owning task must exist and must be a Task, not a Feature: checklist subtasks +// attach to Tasks only. A missing task surfaces as a wrapped pgx.ErrNoRows. +func (s *TaskService) CreateSubtask(ctx context.Context, taskID int64, title string) (*Subtask, error) { + task, err := s.q.GetTask(ctx, taskID) + if err != nil { + return nil, fmt.Errorf("getting task %d: %w", taskID, err) + } + if task.ParentID == nil { + return nil, fmt.Errorf("subtasks can only be added to tasks, not features") + } + + row, err := s.q.CreateSubtask(ctx, dbgen.CreateSubtaskParams{ + TaskID: taskID, + Title: title, + }) + if err != nil { + return nil, fmt.Errorf("creating subtask of task %d: %w", taskID, err) + } + + s.broadcastBoardID(ctx, task.BoardID) + return mapSubtask(row), nil +} + +// ToggleSubtask sets or clears a checklist subtask's done flag. +func (s *TaskService) ToggleSubtask(ctx context.Context, id int64, done bool) (*Subtask, error) { + current, err := s.q.GetSubtask(ctx, id) + if err != nil { + return nil, fmt.Errorf("getting subtask %d: %w", id, err) + } + row, err := s.q.SetSubtaskDone(ctx, dbgen.SetSubtaskDoneParams{ID: id, Done: done}) + if err != nil { + return nil, fmt.Errorf("updating subtask %d: %w", id, err) + } + s.broadcastSubtaskBoard(ctx, current.TaskID) + return mapSubtask(row), nil +} + +// UpdateSubtask renames a checklist subtask. +func (s *TaskService) UpdateSubtask(ctx context.Context, id int64, title string) (*Subtask, error) { + current, err := s.q.GetSubtask(ctx, id) + if err != nil { + return nil, fmt.Errorf("getting subtask %d: %w", id, err) + } + row, err := s.q.UpdateSubtaskTitle(ctx, dbgen.UpdateSubtaskTitleParams{ID: id, Title: title}) + if err != nil { + return nil, fmt.Errorf("updating subtask %d: %w", id, err) + } + s.broadcastSubtaskBoard(ctx, current.TaskID) + return mapSubtask(row), nil +} + +// DeleteSubtask removes a checklist subtask by id. A missing subtask is reported +// as a wrapped pgx.ErrNoRows (existence is checked first because the underlying +// DELETE is a no-op, not an error, when no row matches). +func (s *TaskService) DeleteSubtask(ctx context.Context, id int64) error { + current, err := s.q.GetSubtask(ctx, id) + if err != nil { + return fmt.Errorf("getting subtask %d: %w", id, err) + } + if err := s.q.DeleteSubtask(ctx, id); err != nil { + return fmt.Errorf("deleting subtask %d: %w", id, err) + } + s.broadcastSubtaskBoard(ctx, current.TaskID) + return nil +} + +// DeleteTask removes a task. Postgres ON DELETE CASCADE removes its subtasks and +// attachments. The delete and the post-delete board read run in one transaction. +func (s *TaskService) DeleteTask(ctx context.Context, id int64) error { + // Resolve the board before deleting so the post-delete broadcast targets the + // right board's subscribers. + task, err := s.q.GetTask(ctx, id) + if err != nil { + return fmt.Errorf("getting task %d: %w", id, err) + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("beginning delete tx: %w", err) + } + defer tx.Rollback(ctx) //nolint:errcheck // rollback is a no-op after commit + + qtx := s.q.WithTx(tx) + if err := qtx.DeleteTask(ctx, id); err != nil { + return fmt.Errorf("deleting task %d: %w", id, err) + } + + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("committing delete of task %d: %w", id, err) + } + + s.broadcastBoardID(ctx, task.BoardID) + return nil +} + +// CreateAttachmentRequest is the input for AddAttachment. The required fields +// depend on Type: type=file needs Filename and Content; type=link needs URL +// (Title is optional). +type CreateAttachmentRequest struct { + Type db.AttachmentType + Filename string + Content string + URL string + Title string +} + +// AddAttachment attaches a file or link to a task card (a Feature or a Task). +// The task must exist. Checklist subtasks cannot carry attachments because they +// are not tasks. The request is validated according to its Type before any DB +// write. +func (s *TaskService) AddAttachment(ctx context.Context, taskID int64, req CreateAttachmentRequest) (*Attachment, error) { + task, err := s.q.GetTask(ctx, taskID) + if err != nil { + return nil, fmt.Errorf("getting task %d: %w", taskID, err) + } + + if !db.ValidUserAttachmentType(req.Type) { + return nil, fmt.Errorf("invalid attachment type %q, valid types are %q and %q", + req.Type, db.AttachmentTypeFile, db.AttachmentTypeLink) + } + + switch req.Type { + case db.AttachmentTypeFile: + if req.Filename == "" { + return nil, fmt.Errorf("filename is required for file attachments") + } + if !db.ValidFileExtension(req.Filename) { + return nil, fmt.Errorf("unsupported file type %q; allowed extensions: %s", + req.Filename, db.AllowedFileExtensionList()) + } + if req.Content == "" { + return nil, fmt.Errorf("content is required for file attachments") + } + if _, err := base64.StdEncoding.DecodeString(req.Content); err != nil { + return nil, fmt.Errorf("file content must be base64-encoded: %w", err) + } + case db.AttachmentTypeLink: + if req.URL == "" { + return nil, fmt.Errorf("url is required for link attachments") + } + } + + row, err := s.q.AddAttachment(ctx, dbgen.AddAttachmentParams{ + TaskID: taskID, + Type: string(req.Type), + Filename: req.Filename, + Url: req.URL, + Title: req.Title, + Content: req.Content, + }) + if err != nil { + return nil, fmt.Errorf("adding attachment to task %d: %w", taskID, err) + } + + s.broadcastBoardID(ctx, task.BoardID) + return mapAttachment(row), nil +} + +// GetAttachment returns a single file or link attachment by id. A missing +// attachment is reported as a wrapped pgx.ErrNoRows. +func (s *TaskService) GetAttachment(ctx context.Context, id int64) (*Attachment, error) { + row, err := s.q.GetAttachment(ctx, id) + if err != nil { + return nil, fmt.Errorf("getting attachment %d: %w", id, err) + } + return mapAttachment(row), nil +} + +// DeleteAttachment removes an attachment by id. A missing attachment is reported +// as a wrapped pgx.ErrNoRows. Existence is checked first because the underlying +// DELETE is a no-op (not an error) when no row matches. +func (s *TaskService) DeleteAttachment(ctx context.Context, id int64) error { + att, err := s.q.GetAttachment(ctx, id) + if err != nil { + return fmt.Errorf("getting attachment %d: %w", id, err) + } + + if err := s.q.DeleteAttachment(ctx, id); err != nil { + return fmt.Errorf("deleting attachment %d: %w", id, err) + } + + // Resolve the owning task's board so the broadcast targets the right board. + if task, err := s.q.GetTask(ctx, att.TaskID); err == nil { + s.broadcastBoardID(ctx, task.BoardID) + } + return nil +} + +// SetAttribute upserts a key/value attribute on a card (Feature or Task). The +// attribute is stored as a type="attribute" row in kanban.attachment (title=key, +// content=value). Setting an existing key replaces its value. The task must +// exist and the key must be non-empty. +func (s *TaskService) SetAttribute(ctx context.Context, taskID int64, key, value string) (*Attribute, error) { + task, err := s.q.GetTask(ctx, taskID) + if err != nil { + return nil, fmt.Errorf("getting task %d: %w", taskID, err) + } + if strings.TrimSpace(key) == "" { + return nil, fmt.Errorf("attribute key is required") + } + + var row dbgen.Attachment + existing, err := s.q.GetTaskAttribute(ctx, dbgen.GetTaskAttributeParams{TaskID: taskID, Title: key}) + switch { + case err == nil: + // Upsert: replace the value of the existing key. + row, err = s.q.SetAttachmentContent(ctx, dbgen.SetAttachmentContentParams{ID: existing.ID, Content: value}) + if err != nil { + return nil, fmt.Errorf("updating attribute %q on task %d: %w", key, taskID, err) + } + case errors.Is(err, pgx.ErrNoRows): + row, err = s.q.AddAttachment(ctx, dbgen.AddAttachmentParams{ + TaskID: taskID, + Type: string(db.AttachmentTypeAttribute), + Title: key, + Content: value, + }) + if err != nil { + return nil, fmt.Errorf("adding attribute %q to task %d: %w", key, taskID, err) + } + default: + return nil, fmt.Errorf("looking up attribute %q on task %d: %w", key, taskID, err) + } + + s.broadcastBoardID(ctx, task.BoardID) + return mapAttribute(row), nil +} + +// DeleteAttribute removes the attribute with the given key from a card. A missing +// key (or task) is reported as a wrapped pgx.ErrNoRows so callers map it to 404. +func (s *TaskService) DeleteAttribute(ctx context.Context, taskID int64, key string) error { + row, err := s.q.GetTaskAttribute(ctx, dbgen.GetTaskAttributeParams{TaskID: taskID, Title: key}) + if err != nil { + return fmt.Errorf("getting attribute %q on task %d: %w", key, taskID, err) + } + if err := s.q.DeleteAttachment(ctx, row.ID); err != nil { + return fmt.Errorf("deleting attribute %q on task %d: %w", key, taskID, err) + } + if task, err := s.q.GetTask(ctx, taskID); err == nil { + s.broadcastBoardID(ctx, task.BoardID) + } + return nil +} + +// GetBoard returns the full state of the named board (empty boardKey = the +// default board): every card (Feature or Task) grouped into the board's columns, +// with checklist subtasks and attachments eager-loaded. It is the snapshot used +// by the SSE Hub on client connect and by the get_board MCP tool / REST endpoint. +func (s *TaskService) GetBoard(ctx context.Context, boardKey string) (*BoardState, error) { + board, err := s.resolveBoard(ctx, boardKey) + if err != nil { + return nil, err + } + return s.buildBoardState(ctx, board) +} + +// buildBoardState assembles the full state of one board: its metadata plus one +// column per board-defined column. Features and Tasks are flat cards grouped by +// status; each card has its checklist subtasks and attachments eager-loaded +// (Features have no checklist subtasks, so that load returns empty for them). +func (s *TaskService) buildBoardState(ctx context.Context, board dbgen.Board) (*BoardState, error) { + rows, err := s.q.ListBoardTasks(ctx, dbgen.ListBoardTasksParams{BoardID: board.ID}) + if err != nil { + return nil, fmt.Errorf("listing tasks for board %q: %w", board.Key, err) + } + + byStatus := make(map[string][]*Task, len(board.Columns)) + for _, row := range rows { + t := mapTask(row) + + subs, err := s.q.ListSubtasksByTask(ctx, t.ID) + if err != nil { + return nil, fmt.Errorf("listing subtasks for task %d: %w", t.ID, err) + } + t.Subtasks = mapSubtasks(subs) + + atts, err := s.q.ListAttachments(ctx, t.ID) + if err != nil { + return nil, fmt.Errorf("listing attachments for task %d: %w", t.ID, err) + } + t.Attachments, t.Attributes = splitAttachmentRows(atts) + + byStatus[string(t.Status)] = append(byStatus[string(t.Status)], t) + } + + state := &BoardState{ + Board: mapBoard(board), + Columns: make([]Column, 0, len(board.Columns)), + } + for _, col := range board.Columns { + state.Columns = append(state.Columns, Column{ + Status: db.TaskStatus(col), + Tasks: byStatus[col], + }) + } + return state, nil +} + +// broadcastBoardKey builds the named board's state and broadcasts it to that +// board's subscribers. Broadcast failures are non-fatal for the mutation that +// triggered them: the write already succeeded. +func (s *TaskService) broadcastBoardKey(ctx context.Context, boardKey string) { + board, err := s.q.GetBoardByKey(ctx, boardKey) + if err != nil { + return + } + s.broadcastBoardState(ctx, board) +} + +// broadcastBoardID is broadcastBoardKey addressed by board id, used when a +// mutation only has the task's board_id at hand. +func (s *TaskService) broadcastBoardID(ctx context.Context, boardID int64) { + board, err := s.q.GetBoardByID(ctx, boardID) + if err != nil { + return + } + s.broadcastBoardState(ctx, board) +} + +// broadcastSubtaskBoard resolves the board owning the subtask's task and +// broadcasts that board's state, used after a checklist subtask mutation. +func (s *TaskService) broadcastSubtaskBoard(ctx context.Context, taskID int64) { + task, err := s.q.GetTask(ctx, taskID) + if err != nil { + return + } + s.broadcastBoardID(ctx, task.BoardID) +} + +// broadcastBoardState builds and broadcasts a single board's state. +func (s *TaskService) broadcastBoardState(ctx context.Context, board dbgen.Board) { + state, err := s.buildBoardState(ctx, board) + if err != nil { + // The mutation succeeded; a failed board read should not fail it. Send a + // refresh signal carrying just the board metadata. + s.broadcaster.Broadcast(board.Key, &BoardState{Board: mapBoard(board), Columns: []Column{}}) + return + } + s.broadcaster.Broadcast(board.Key, state) +} + +// columnError builds a descriptive error for a status that is not one of a +// board's columns. +func columnError(board dbgen.Board, status string) error { + return fmt.Errorf("invalid column %q for board %q; valid columns are %v", status, board.Key, board.Columns) +} + +// IsNotFound reports whether err is (or wraps) the pgx no-rows sentinel. +func IsNotFound(err error) bool { + return errors.Is(err, pgx.ErrNoRows) +} + +// normalizeLabels returns a non-nil label slice suitable for the +// `labels TEXT[] NOT NULL` column: nil becomes an empty (not NULL) slice, and +// duplicates are removed case-insensitively while preserving first-seen order +// and original casing (per the label rules in design.md). +func normalizeLabels(labels []string) []string { + out := make([]string, 0, len(labels)) + seen := make(map[string]struct{}, len(labels)) + for _, l := range labels { + key := strings.ToLower(l) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, l) + } + return out +} + +// mapTask converts a dbgen.Task row into the API Task domain type. Kind is read +// from the persisted column so a top-level card may be either a Feature or a +// standalone Task; legacy rows with an empty kind fall back to deriving it from +// parent_id (child = Task, top-level = Feature). +func mapTask(row dbgen.Task) *Task { + kind := row.Kind + if kind == "" { + kind = db.KindFeature + if row.ParentID != nil { + kind = db.KindTask + } + } + return &Task{ + ID: row.ID, + Title: row.Title, + Description: row.Description, + Status: db.TaskStatus(row.Status), + Kind: kind, + Assignee: row.Assignee, + Labels: row.Labels, + UserInputNeeded: row.UserInputNeeded, + ParentID: row.ParentID, + BoardID: row.BoardID, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } +} + +// closeDateKey is the attribute key whose value the service auto-manages: it is +// set to the current date (YYYY-MM-DD) when a card enters the Done column and +// removed when it leaves Done. Other date attributes (e.g. due_date) are user-set. +const closeDateKey = "close_date" + +// syncCloseDate keeps the auto-managed close_date attribute in step with a card's +// status: entering Done stamps today's date, leaving Done removes it. A status +// change that does not cross the Done boundary leaves any existing value intact. +func (s *TaskService) syncCloseDate(ctx context.Context, taskID int64, oldStatus, newStatus string) error { + done := string(db.StatusDone) + switch { + case newStatus == done && oldStatus != done: + return s.upsertAttributeRow(ctx, taskID, closeDateKey, time.Now().UTC().Format("2006-01-02")) + case newStatus != done && oldStatus == done: + return s.deleteAttributeRow(ctx, taskID, closeDateKey) + default: + return nil + } +} + +// upsertAttributeRow sets key=value on a card without broadcasting (the caller is +// expected to broadcast once its own mutation completes). It mirrors SetAttribute's +// persistence but omits the GetTask/validation/broadcast steps. +func (s *TaskService) upsertAttributeRow(ctx context.Context, taskID int64, key, value string) error { + existing, err := s.q.GetTaskAttribute(ctx, dbgen.GetTaskAttributeParams{TaskID: taskID, Title: key}) + switch { + case err == nil: + _, err = s.q.SetAttachmentContent(ctx, dbgen.SetAttachmentContentParams{ID: existing.ID, Content: value}) + return err + case errors.Is(err, pgx.ErrNoRows): + _, err = s.q.AddAttachment(ctx, dbgen.AddAttachmentParams{ + TaskID: taskID, + Type: string(db.AttachmentTypeAttribute), + Title: key, + Content: value, + }) + return err + default: + return err + } +} + +// deleteAttributeRow removes key from a card without broadcasting. A missing key +// is not an error. +func (s *TaskService) deleteAttributeRow(ctx context.Context, taskID int64, key string) error { + existing, err := s.q.GetTaskAttribute(ctx, dbgen.GetTaskAttributeParams{TaskID: taskID, Title: key}) + if errors.Is(err, pgx.ErrNoRows) { + return nil + } + if err != nil { + return err + } + return s.q.DeleteAttachment(ctx, existing.ID) +} + +// mapTasks maps a slice of dbgen rows to API Task domain types. +func mapTasks(rows []dbgen.Task) []*Task { + out := make([]*Task, 0, len(rows)) + for _, row := range rows { + out = append(out, mapTask(row)) + } + return out +} + +// mapSubtask converts a dbgen.Subtask row into the API Subtask domain type. +func mapSubtask(row dbgen.Subtask) *Subtask { + return &Subtask{ + ID: row.ID, + TaskID: row.TaskID, + Title: row.Title, + Done: row.Done, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } +} + +// mapSubtasks maps a slice of subtask rows. +func mapSubtasks(rows []dbgen.Subtask) []*Subtask { + out := make([]*Subtask, 0, len(rows)) + for _, row := range rows { + out = append(out, mapSubtask(row)) + } + return out +} + +// mapAttachment converts a dbgen.Attachment row into the API Attachment type. +func mapAttachment(row dbgen.Attachment) *Attachment { + return &Attachment{ + ID: row.ID, + TaskID: row.TaskID, + Type: db.AttachmentType(row.Type), + Filename: row.Filename, + Content: row.Content, + URL: row.Url, + Title: row.Title, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } +} + +// mapAttribute converts a type="attribute" attachment row into an Attribute, +// mapping title→Key and content→Value. +func mapAttribute(row dbgen.Attachment) *Attribute { + return &Attribute{ + ID: row.ID, + TaskID: row.TaskID, + Key: row.Title, + Value: row.Content, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } +} + +// splitAttachmentRows partitions the rows of kanban.attachment for a task into +// file/link attachments and key/value attributes, mapping each to its domain +// type. Both slices are non-nil so JSON omitempty drops empty ones cleanly. +func splitAttachmentRows(rows []dbgen.Attachment) ([]*Attachment, []*Attribute) { + atts := make([]*Attachment, 0, len(rows)) + attrs := make([]*Attribute, 0) + for _, row := range rows { + if db.AttachmentType(row.Type) == db.AttachmentTypeAttribute { + attrs = append(attrs, mapAttribute(row)) + continue + } + atts = append(atts, mapAttachment(row)) + } + return atts, attrs +} diff --git a/go/plugins/kanban-mcp/internal/service/task_service_test.go b/go/plugins/kanban-mcp/internal/service/task_service_test.go new file mode 100644 index 0000000000..12b0a49fac --- /dev/null +++ b/go/plugins/kanban-mcp/internal/service/task_service_test.go @@ -0,0 +1,644 @@ +package service + +import ( + "context" + "os/exec" + "sync" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + testcontainers "github.com/testcontainers/testcontainers-go" + tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres" + "github.com/testcontainers/testcontainers-go/wait" + + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/db" + dbgen "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/db/gen" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/migrations" +) + +// countingBroadcaster records how many times Broadcast was invoked. It is the +// test double for the SSE Hub injected in Step 6. +type countingBroadcaster struct { + mu sync.Mutex + count int + last any +} + +func (c *countingBroadcaster) Broadcast(_ string, event any) { + c.mu.Lock() + defer c.mu.Unlock() + c.count++ + c.last = event +} + +func (c *countingBroadcaster) calls() int { + c.mu.Lock() + defer c.mu.Unlock() + return c.count +} + +// startPostgres starts a Postgres container, runs the kanban migrations, and +// returns a connection string. Tests skip when Docker is not available. This is +// a thin local copy of go/core/internal/dbtest (which cannot be imported across +// the internal/ boundary). +func startPostgres(ctx context.Context, t *testing.T) string { + t.Helper() + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available, skipping container test") + } + + pgContainer, err := tcpostgres.Run(ctx, + "postgres:16-alpine", + tcpostgres.WithDatabase("kanban_test"), + tcpostgres.WithUsername("postgres"), + tcpostgres.WithPassword("kanban"), + testcontainers.WithWaitStrategy( + wait.ForLog("database system is ready to accept connections"). + WithOccurrence(2). + WithStartupTimeout(60*time.Second), + ), + ) + if err != nil { + t.Fatalf("starting postgres container: %v", err) + } + t.Cleanup(func() { + if err := pgContainer.Terminate(context.Background()); err != nil { + t.Logf("warning: failed to terminate postgres container: %v", err) + } + }) + + connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable") + if err != nil { + t.Fatalf("getting connection string: %v", err) + } + + if err := migrations.RunUp(connStr); err != nil { + t.Fatalf("running migrations: %v", err) + } + return connStr +} + +// newTestService starts Postgres, migrates, and returns a TaskService wired to a +// counting broadcaster plus the pool for direct verification queries. +func newTestService(ctx context.Context, t *testing.T) (*TaskService, *countingBroadcaster, *pgxpool.Pool) { + t.Helper() + url := startPostgres(ctx, t) + + pool, err := pgxpool.New(ctx, url) + if err != nil { + t.Fatalf("creating pool: %v", err) + } + t.Cleanup(pool.Close) + + b := &countingBroadcaster{} + svc := NewTaskService(dbgen.New(pool), pool, b) + return svc, b, pool +} + +// dbAssignee reads the assignee column directly from the DB, bypassing the +// service, to verify a value was actually persisted. +func dbAssignee(ctx context.Context, t *testing.T, pool *pgxpool.Pool, id int64) string { + t.Helper() + var assignee string + if err := pool.QueryRow(ctx, "SELECT assignee FROM kanban.task WHERE id = $1", id).Scan(&assignee); err != nil { + t.Fatalf("querying assignee for task %d: %v", id, err) + } + return assignee +} + +func TestCreateTask_Defaults(t *testing.T) { + ctx := context.Background() + svc, _, _ := newTestService(ctx, t) + + got, err := svc.CreateTask(ctx, "", CreateTaskRequest{Title: "no status"}) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + if got.Status != db.StatusInbox { + t.Errorf("default status = %q, want %q", got.Status, db.StatusInbox) + } +} + +func TestCreateTask_WithStatus(t *testing.T) { + ctx := context.Background() + svc, _, _ := newTestService(ctx, t) + + got, err := svc.CreateTask(ctx, "", CreateTaskRequest{Title: "planned", Status: db.StatusPlan}) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + if got.Status != db.StatusPlan { + t.Errorf("status = %q, want %q", got.Status, db.StatusPlan) + } + + // Verify persisted, not just echoed back. + fetched, err := svc.GetTask(ctx, got.ID) + if err != nil { + t.Fatalf("GetTask() error = %v", err) + } + if fetched.Status != db.StatusPlan { + t.Errorf("persisted status = %q, want %q", fetched.Status, db.StatusPlan) + } +} + +func TestCreateTask_WithLabels(t *testing.T) { + ctx := context.Background() + svc, _, _ := newTestService(ctx, t) + + labels := []string{"priority:high", "team:platform"} + got, err := svc.CreateTask(ctx, "", CreateTaskRequest{Title: "labeled", Labels: labels}) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + if len(got.Labels) != 2 || got.Labels[0] != "priority:high" || got.Labels[1] != "team:platform" { + t.Errorf("labels = %v, want %v", got.Labels, labels) + } +} + +func TestGetTask_NotFound(t *testing.T) { + ctx := context.Background() + svc, _, _ := newTestService(ctx, t) + + _, err := svc.GetTask(ctx, 999999) + if err == nil { + t.Fatal("GetTask() expected error for missing task, got nil") + } + if !IsNotFound(err) { + t.Errorf("GetTask() error = %v, want wrapped pgx.ErrNoRows", err) + } +} + +func TestMoveTask_Valid(t *testing.T) { + ctx := context.Background() + svc, _, _ := newTestService(ctx, t) + + created, err := svc.CreateTask(ctx, "", CreateTaskRequest{Title: "to move"}) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + + moved, err := svc.MoveTask(ctx, created.ID, db.StatusDevelop) + if err != nil { + t.Fatalf("MoveTask() error = %v", err) + } + if moved.Status != db.StatusDevelop { + t.Errorf("status = %q, want %q", moved.Status, db.StatusDevelop) + } + + fetched, err := svc.GetTask(ctx, created.ID) + if err != nil { + t.Fatalf("GetTask() error = %v", err) + } + if fetched.Status != db.StatusDevelop { + t.Errorf("persisted status = %q, want %q", fetched.Status, db.StatusDevelop) + } +} + +func TestMoveTask_InvalidStatus(t *testing.T) { + ctx := context.Background() + svc, _, _ := newTestService(ctx, t) + + created, err := svc.CreateTask(ctx, "", CreateTaskRequest{Title: "stay put"}) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + + if _, err := svc.MoveTask(ctx, created.ID, db.TaskStatus("Nonsense")); err == nil { + t.Fatal("MoveTask() expected error for invalid status, got nil") + } + + // Status must be unchanged in the DB (no write happened). + fetched, err := svc.GetTask(ctx, created.ID) + if err != nil { + t.Fatalf("GetTask() error = %v", err) + } + if fetched.Status != db.StatusInbox { + t.Errorf("status changed to %q despite invalid move; want %q", fetched.Status, db.StatusInbox) + } +} + +func TestUpdateTask_Partial(t *testing.T) { + ctx := context.Background() + svc, _, _ := newTestService(ctx, t) + + created, err := svc.CreateTask(ctx, "", CreateTaskRequest{Title: "original", Description: "desc"}) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + + newTitle := "updated" + got, err := svc.UpdateTask(ctx, created.ID, UpdateTaskRequest{Title: &newTitle}) + if err != nil { + t.Fatalf("UpdateTask() error = %v", err) + } + if got.Title != "updated" { + t.Errorf("title = %q, want %q", got.Title, "updated") + } + if got.Description != "desc" { + t.Errorf("description = %q, want unchanged %q", got.Description, "desc") + } +} + +func TestListTasks_Filter(t *testing.T) { + ctx := context.Background() + svc, _, _ := newTestService(ctx, t) + + for _, tc := range []CreateTaskRequest{ + {Title: "a", Status: db.StatusInbox}, + {Title: "b", Status: db.StatusInbox}, + {Title: "c", Status: db.StatusPlan}, + } { + if _, err := svc.CreateTask(ctx, "", tc); err != nil { + t.Fatalf("CreateTask(%s) error = %v", tc.Title, err) + } + } + + got, err := svc.ListTasks(ctx, "", TaskFilter{Status: new(db.StatusInbox)}) + if err != nil { + t.Fatalf("ListTasks() error = %v", err) + } + if len(got) != 2 { + t.Errorf("Inbox tasks = %d, want 2", len(got)) + } + + all, err := svc.ListTasks(ctx, "", TaskFilter{}) + if err != nil { + t.Fatalf("ListTasks() error = %v", err) + } + if len(all) != 3 { + t.Errorf("all tasks = %d, want 3", len(all)) + } +} + +func TestListTasks_LabelFilter(t *testing.T) { + ctx := context.Background() + svc, _, _ := newTestService(ctx, t) + + if _, err := svc.CreateTask(ctx, "", CreateTaskRequest{Title: "backend", Labels: []string{"team:backend"}}); err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + if _, err := svc.CreateTask(ctx, "", CreateTaskRequest{Title: "frontend", Labels: []string{"team:frontend"}}); err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + + got, err := svc.ListTasks(ctx, "", TaskFilter{Label: new("team:backend")}) + if err != nil { + t.Fatalf("ListTasks() error = %v", err) + } + if len(got) != 1 { + t.Fatalf("label-filtered tasks = %d, want 1", len(got)) + } + if got[0].Title != "backend" { + t.Errorf("filtered task = %q, want %q", got[0].Title, "backend") + } +} + +func TestDeleteTask_Simple(t *testing.T) { + ctx := context.Background() + svc, _, _ := newTestService(ctx, t) + + created, err := svc.CreateTask(ctx, "", CreateTaskRequest{Title: "doomed"}) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + + if err := svc.DeleteTask(ctx, created.ID); err != nil { + t.Fatalf("DeleteTask() error = %v", err) + } + + if _, err := svc.GetTask(ctx, created.ID); !IsNotFound(err) { + t.Errorf("GetTask() after delete error = %v, want not-found", err) + } +} + +func TestAssignTask(t *testing.T) { + ctx := context.Background() + svc, _, pool := newTestService(ctx, t) + + created, err := svc.CreateTask(ctx, "", CreateTaskRequest{Title: "assign me"}) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + + // Assign. + got, err := svc.AssignTask(ctx, created.ID, "alice") + if err != nil { + t.Fatalf("AssignTask() error = %v", err) + } + if got.Assignee != "alice" { + t.Errorf("assignee = %q, want %q", got.Assignee, "alice") + } + if a := dbAssignee(ctx, t, pool, created.ID); a != "alice" { + t.Errorf("persisted assignee = %q, want %q", a, "alice") + } + + // Reassign. + got, err = svc.AssignTask(ctx, created.ID, "bob") + if err != nil { + t.Fatalf("AssignTask() reassign error = %v", err) + } + if got.Assignee != "bob" { + t.Errorf("reassigned = %q, want %q", got.Assignee, "bob") + } + + // Clear. + got, err = svc.AssignTask(ctx, created.ID, "") + if err != nil { + t.Fatalf("AssignTask() clear error = %v", err) + } + if got.Assignee != "" { + t.Errorf("cleared assignee = %q, want empty", got.Assignee) + } + if a := dbAssignee(ctx, t, pool, created.ID); a != "" { + t.Errorf("persisted assignee = %q, want empty", a) + } +} + +func TestListTasks_AssigneeFilter(t *testing.T) { + ctx := context.Background() + svc, _, _ := newTestService(ctx, t) + + alice1, err := svc.CreateTask(ctx, "", CreateTaskRequest{Title: "a1"}) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + if _, err := svc.AssignTask(ctx, alice1.ID, "alice"); err != nil { + t.Fatalf("AssignTask() error = %v", err) + } + bob1, err := svc.CreateTask(ctx, "", CreateTaskRequest{Title: "b1"}) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + if _, err := svc.AssignTask(ctx, bob1.ID, "bob"); err != nil { + t.Fatalf("AssignTask() error = %v", err) + } + + got, err := svc.ListTasks(ctx, "", TaskFilter{Assignee: new("alice")}) + if err != nil { + t.Fatalf("ListTasks() error = %v", err) + } + if len(got) != 1 { + t.Fatalf("assignee-filtered tasks = %d, want 1", len(got)) + } + if got[0].Title != "a1" { + t.Errorf("filtered task = %q, want %q", got[0].Title, "a1") + } +} + +// childTask is a helper that creates a Feature and a child Task under it, +// returning both. +func childTask(ctx context.Context, t *testing.T, svc *TaskService) (*Task, *Task) { + t.Helper() + feature, err := svc.CreateTask(ctx, "", CreateTaskRequest{Title: "feature"}) + if err != nil { + t.Fatalf("CreateTask(feature) error = %v", err) + } + task, err := svc.CreateTask(ctx, "", CreateTaskRequest{Title: "task", ParentID: &feature.ID}) + if err != nil { + t.Fatalf("CreateTask(child) error = %v", err) + } + return feature, task +} + +func TestCreateChildTask_Valid(t *testing.T) { + ctx := context.Background() + svc, _, _ := newTestService(ctx, t) + + feature, task := childTask(ctx, t, svc) + if feature.Kind != db.KindFeature { + t.Errorf("feature kind = %q, want %q", feature.Kind, db.KindFeature) + } + if task.Kind != db.KindTask { + t.Errorf("task kind = %q, want %q", task.Kind, db.KindTask) + } + if task.ParentID == nil || *task.ParentID != feature.ID { + t.Errorf("task ParentID = %v, want %d", task.ParentID, feature.ID) + } + if task.Status != db.StatusInbox { + t.Errorf("task status = %q, want %q", task.Status, db.StatusInbox) + } +} + +func TestCreateChildTask_ParentNotFound(t *testing.T) { + ctx := context.Background() + svc, _, _ := newTestService(ctx, t) + + missing := int64(999999) + _, err := svc.CreateTask(ctx, "", CreateTaskRequest{Title: "orphan", ParentID: &missing}) + if err == nil { + t.Fatal("CreateTask() expected error for missing parent, got nil") + } + if !IsNotFound(err) { + t.Errorf("CreateTask() error = %v, want wrapped pgx.ErrNoRows", err) + } +} + +func TestCreateChildTask_NestedRejection(t *testing.T) { + ctx := context.Background() + svc, _, _ := newTestService(ctx, t) + + _, task := childTask(ctx, t, svc) + + _, err := svc.CreateTask(ctx, "", CreateTaskRequest{Title: "grandchild", ParentID: &task.ID}) + if err == nil { + t.Fatal("CreateTask() expected error nesting under a Task, got nil") + } + if err.Error() != "a task's parent must be a feature" { + t.Errorf("error = %q, want %q", err.Error(), "a task's parent must be a feature") + } +} + +func TestCreateSubtask_Checklist(t *testing.T) { + ctx := context.Background() + svc, _, _ := newTestService(ctx, t) + + feature, task := childTask(ctx, t, svc) + + sub, err := svc.CreateSubtask(ctx, task.ID, "write the code") + if err != nil { + t.Fatalf("CreateSubtask() error = %v", err) + } + if sub.TaskID != task.ID { + t.Errorf("subtask TaskID = %d, want %d", sub.TaskID, task.ID) + } + if sub.Done { + t.Error("new subtask Done = true, want false") + } + if sub.Title != "write the code" { + t.Errorf("subtask title = %q, want %q", sub.Title, "write the code") + } + + // Subtasks can only be added to Tasks, not Features. + if _, err := svc.CreateSubtask(ctx, feature.ID, "nope"); err == nil { + t.Fatal("CreateSubtask() on a Feature: expected error, got nil") + } +} + +func TestSubtask_ToggleUpdateDelete(t *testing.T) { + ctx := context.Background() + svc, _, _ := newTestService(ctx, t) + + _, task := childTask(ctx, t, svc) + sub, err := svc.CreateSubtask(ctx, task.ID, "step 1") + if err != nil { + t.Fatalf("CreateSubtask() error = %v", err) + } + + toggled, err := svc.ToggleSubtask(ctx, sub.ID, true) + if err != nil { + t.Fatalf("ToggleSubtask() error = %v", err) + } + if !toggled.Done { + t.Error("ToggleSubtask(true) Done = false, want true") + } + + renamed, err := svc.UpdateSubtask(ctx, sub.ID, "step one") + if err != nil { + t.Fatalf("UpdateSubtask() error = %v", err) + } + if renamed.Title != "step one" { + t.Errorf("title = %q, want %q", renamed.Title, "step one") + } + if !renamed.Done { + t.Error("UpdateSubtask cleared Done; want it preserved") + } + + if err := svc.DeleteSubtask(ctx, sub.ID); err != nil { + t.Fatalf("DeleteSubtask() error = %v", err) + } + subs, err := svc.ListSubtasks(ctx, task.ID) + if err != nil { + t.Fatalf("ListSubtasks() error = %v", err) + } + if len(subs) != 0 { + t.Errorf("subtasks after delete = %d, want 0", len(subs)) + } +} + +func TestDeleteTask_Cascade(t *testing.T) { + ctx := context.Background() + svc, _, pool := newTestService(ctx, t) + + feature, task := childTask(ctx, t, svc) + sub, err := svc.CreateSubtask(ctx, task.ID, "checklist item") + if err != nil { + t.Fatalf("CreateSubtask() error = %v", err) + } + + // Deleting the Feature cascades to its child Task and that Task's checklist. + if err := svc.DeleteTask(ctx, feature.ID); err != nil { + t.Fatalf("DeleteTask() error = %v", err) + } + + for _, id := range []int64{feature.ID, task.ID} { + if _, err := svc.GetTask(ctx, id); !IsNotFound(err) { + t.Errorf("GetTask(%d) after cascade delete error = %v, want not-found", id, err) + } + } + var n int + if err := pool.QueryRow(ctx, "SELECT count(*) FROM kanban.subtask WHERE id = $1", sub.ID).Scan(&n); err != nil { + t.Fatalf("counting subtask: %v", err) + } + if n != 0 { + t.Errorf("subtask rows after cascade = %d, want 0", n) + } +} + +func TestGetTask_WithChecklistAndChildren(t *testing.T) { + ctx := context.Background() + svc, _, _ := newTestService(ctx, t) + + feature, task := childTask(ctx, t, svc) + if _, err := svc.CreateSubtask(ctx, task.ID, "item1"); err != nil { + t.Fatalf("CreateSubtask() error = %v", err) + } + if _, err := svc.CreateSubtask(ctx, task.ID, "item2"); err != nil { + t.Fatalf("CreateSubtask() error = %v", err) + } + + // The Task carries its checklist subtasks. + gotTask, err := svc.GetTask(ctx, task.ID) + if err != nil { + t.Fatalf("GetTask(task) error = %v", err) + } + if len(gotTask.Subtasks) != 2 { + t.Fatalf("task subtasks = %d, want 2", len(gotTask.Subtasks)) + } + if gotTask.Subtasks[0].Title != "item1" || gotTask.Subtasks[1].Title != "item2" { + t.Errorf("subtask titles = [%q, %q], want [item1, item2]", + gotTask.Subtasks[0].Title, gotTask.Subtasks[1].Title) + } + + // The Feature carries its child Tasks. + gotFeature, err := svc.GetTask(ctx, feature.ID) + if err != nil { + t.Fatalf("GetTask(feature) error = %v", err) + } + if len(gotFeature.Children) != 1 || gotFeature.Children[0].ID != task.ID { + t.Errorf("feature children = %+v, want only task %d", gotFeature.Children, task.ID) + } +} + +func TestBroadcast_CalledOnMutation(t *testing.T) { + ctx := context.Background() + svc, b, _ := newTestService(ctx, t) + + tests := []struct { + name string + op func() error + }{ + { + name: "create", + op: func() error { + _, err := svc.CreateTask(ctx, "", CreateTaskRequest{Title: "x"}) + return err + }, + }, + { + name: "update", + op: func() error { + created, err := svc.CreateTask(ctx, "", CreateTaskRequest{Title: "y"}) + if err != nil { + return err + } + title := "y2" + _, err = svc.UpdateTask(ctx, created.ID, UpdateTaskRequest{Title: &title}) + return err + }, + }, + { + name: "move", + op: func() error { + created, err := svc.CreateTask(ctx, "", CreateTaskRequest{Title: "z"}) + if err != nil { + return err + } + _, err = svc.MoveTask(ctx, created.ID, db.StatusPlan) + return err + }, + }, + { + name: "delete", + op: func() error { + created, err := svc.CreateTask(ctx, "", CreateTaskRequest{Title: "w"}) + if err != nil { + return err + } + return svc.DeleteTask(ctx, created.ID) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + before := b.calls() + if err := tt.op(); err != nil { + t.Fatalf("%s op error = %v", tt.name, err) + } + if got := b.calls() - before; got < 1 { + t.Errorf("%s: Broadcast called %d times, want >= 1", tt.name, got) + } + }) + } +} diff --git a/go/plugins/kanban-mcp/internal/sse/hub.go b/go/plugins/kanban-mcp/internal/sse/hub.go new file mode 100644 index 0000000000..6f66eda151 --- /dev/null +++ b/go/plugins/kanban-mcp/internal/sse/hub.go @@ -0,0 +1,171 @@ +// Package sse implements a minimal Server-Sent Events hub used to push the full +// kanban board state to connected browsers after every mutation. It depends only +// on the standard library: the board is server-to-client push only, so SSE is +// sufficient and proxy-friendly without any additional dependency. +// +// Subscriptions are board-scoped: each client connects to /events?board={key} and +// only receives events for that board. Broadcast(boardKey, data) delivers to the +// subscribers of that board. +package sse + +import ( + "encoding/json" + "fmt" + "net/http" + "sync" +) + +// subBuffer is the per-subscriber channel buffer. A small buffer absorbs short +// bursts; once full, Broadcast drops the event for that subscriber rather than +// blocking the publisher (see Broadcast). +const subBuffer = 8 + +// defaultBoardKey is used when a client connects without a ?board= query param. +const defaultBoardKey = "default" + +// Event is a single SSE message. Type is always "board_update" in v1; Data is the +// JSON-serializable payload (a single board's state). +type Event struct { + Type string `json:"type"` + Data any `json:"data"` +} + +// SnapshotFunc returns the current state of the named board, used to send an +// initial snapshot to a client when it first connects. It may be nil, in which +// case no snapshot is sent. +type SnapshotFunc func(board string) any + +// Hub fans out events to all subscribed SSE clients, scoped per board. It is safe +// for concurrent use by multiple goroutines. Subscribers are keyed by their event +// channel; the map value is the board key the subscriber is interested in. +type Hub struct { + mu sync.RWMutex + subs map[chan Event]string + snapshot SnapshotFunc +} + +// NewHub constructs an empty Hub. The optional snapshot function provides the +// board state sent to each client on connect; pass nil to disable snapshots. +func NewHub(snapshot SnapshotFunc) *Hub { + return &Hub{ + subs: make(map[chan Event]string), + snapshot: snapshot, + } +} + +// Subscribe registers a new subscriber for the given board (empty = "default") +// and returns its event channel. The caller must call Unsubscribe with the same +// channel when done. +func (h *Hub) Subscribe(board string) chan Event { + if board == "" { + board = defaultBoardKey + } + ch := make(chan Event, subBuffer) + h.mu.Lock() + h.subs[ch] = board + h.mu.Unlock() + return ch +} + +// Unsubscribe removes ch from the hub and closes it. It is safe to call multiple +// times: a channel that is not registered is ignored. +func (h *Hub) Unsubscribe(ch chan Event) { + h.mu.Lock() + defer h.mu.Unlock() + if _, ok := h.subs[ch]; ok { + delete(h.subs, ch) + close(ch) + } +} + +// SubscriberCount reports the number of currently connected subscribers. +func (h *Hub) SubscriberCount() int { + h.mu.RLock() + defer h.mu.RUnlock() + return len(h.subs) +} + +// Broadcast sends a "board_update" event carrying data to every subscriber of +// boardKey. It is non-blocking: if a subscriber's buffer is full (a slow client), +// the event is dropped for that subscriber so one slow client cannot stall the +// others. +// +// Broadcast implements service.Broadcaster. +func (h *Hub) Broadcast(boardKey string, data any) { + if boardKey == "" { + boardKey = defaultBoardKey + } + event := Event{Type: "board_update", Data: data} + h.mu.RLock() + defer h.mu.RUnlock() + for ch, board := range h.subs { + if board != boardKey { + continue + } + select { + case ch <- event: + default: + // Subscriber buffer full: drop to stay non-blocking. + } + } +} + +// ServeSSE handles an SSE connection: it sets the streaming headers, sends an +// initial snapshot (if a snapshot function is configured) for the requested +// board, then streams that board's events until the client disconnects or the hub +// closes the channel. The board is taken from the ?board= query param (default +// "default"). +func (h *Hub) ServeSSE(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming unsupported", http.StatusInternalServerError) + return + } + + board := r.URL.Query().Get("board") + if board == "" { + board = defaultBoardKey + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + + ch := h.Subscribe(board) + defer h.Unsubscribe(ch) + + // Flush the response headers immediately so the client's request returns and + // the stream is established before any event is sent. Without this an HTTP + // client blocks in Do() until the first byte is written. + w.WriteHeader(http.StatusOK) + flusher.Flush() + + // Initial snapshot so a freshly connected client renders immediately and so a + // reconnecting client recovers state without a separate fetch. + if h.snapshot != nil { + if data, err := json.Marshal(h.snapshot(board)); err == nil { + fmt.Fprintf(w, "event: snapshot\ndata: %s\n\n", data) + flusher.Flush() + } + } + + ctx := r.Context() + for { + select { + case <-ctx.Done(): + return + case event, ok := <-ch: + if !ok { + // Hub unsubscribed/closed this channel. + return + } + data, err := json.Marshal(event.Data) + if err != nil { + continue + } + fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event.Type, data) + flusher.Flush() + } + } +} diff --git a/go/plugins/kanban-mcp/internal/sse/hub_test.go b/go/plugins/kanban-mcp/internal/sse/hub_test.go new file mode 100644 index 0000000000..e7bfc090ad --- /dev/null +++ b/go/plugins/kanban-mcp/internal/sse/hub_test.go @@ -0,0 +1,269 @@ +package sse_test + +import ( + "bufio" + "context" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/service" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/sse" +) + +// compile-time assertion that *Hub satisfies the service.Broadcaster contract it +// is wired into in main.go. +var _ service.Broadcaster = (*sse.Hub)(nil) + +func TestHub_SubscribeUnsubscribe(t *testing.T) { + h := sse.NewHub(nil) + + a := h.Subscribe("default") + b := h.Subscribe("default") + c := h.Subscribe("default") + + // Unsubscribe one client; it must not receive the broadcast. + h.Unsubscribe(b) + + h.Broadcast("default", "payload") + + for name, ch := range map[string]chan sse.Event{"a": a, "b": b, "c": c} { + if name == "b" { + // b is closed; a receive must observe the closed channel (no event). + select { + case ev, ok := <-ch: + if ok { + t.Errorf("unsubscribed client %s received event %+v", name, ev) + } + default: + t.Errorf("unsubscribed client %s channel should be closed", name) + } + continue + } + select { + case ev := <-ch: + if ev.Type != "board_update" { + t.Errorf("client %s: got type %q, want board_update", name, ev.Type) + } + if ev.Data != "payload" { + t.Errorf("client %s: got data %v, want payload", name, ev.Data) + } + default: + t.Errorf("client %s did not receive event", name) + } + } +} + +func TestHub_Unsubscribe_Idempotent(t *testing.T) { + h := sse.NewHub(nil) + ch := h.Subscribe("default") + h.Unsubscribe(ch) + // Second unsubscribe must not panic (would double-close otherwise). + h.Unsubscribe(ch) +} + +func TestHub_Broadcast_NonBlocking(t *testing.T) { + h := sse.NewHub(nil) + + // A slow subscriber that never drains its channel. + slow := h.Subscribe("default") + fast := h.Subscribe("default") + + // Overfill the slow subscriber's buffer well beyond capacity. If Broadcast + // blocked on a full buffer this would deadlock the test. + done := make(chan struct{}) + go func() { + for i := range 1000 { + h.Broadcast("default", i) + } + close(done) + }() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("Broadcast blocked on a slow subscriber") + } + + // The fast subscriber should still have received at least one event (buffered). + select { + case <-fast: + default: + t.Error("fast subscriber received no events") + } + + _ = slow +} + +func TestHub_ConcurrentSubscribers(t *testing.T) { + h := sse.NewHub(nil) + + const n = 50 + chans := make([]chan sse.Event, n) + var wg sync.WaitGroup + wg.Add(n) + for i := range n { + go func(i int) { + defer wg.Done() + chans[i] = h.Subscribe("default") + }(i) + } + wg.Wait() + + h.Broadcast("default", "hello") + + for i, ch := range chans { + select { + case ev := <-ch: + if ev.Data != "hello" { + t.Errorf("subscriber %d: got %v, want hello", i, ev.Data) + } + case <-time.After(time.Second): + t.Errorf("subscriber %d did not receive event", i) + } + } +} + +func TestServeSSE_Snapshot(t *testing.T) { + snapshot := map[string]string{"hello": "world"} + h := sse.NewHub(func(string) any { return snapshot }) + + srv := httptest.NewServer(http.HandlerFunc(h.ServeSSE)) + defer srv.Close() + + ctx := t.Context() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("connect: %v", err) + } + defer resp.Body.Close() + + if got := resp.Header.Get("Content-Type"); got != "text/event-stream" { + t.Errorf("Content-Type = %q, want text/event-stream", got) + } + if got := resp.Header.Get("Cache-Control"); got != "no-cache" { + t.Errorf("Cache-Control = %q, want no-cache", got) + } + + r := bufio.NewReader(resp.Body) + gotSnapshot := readEvent(t, r) + if !strings.Contains(gotSnapshot, "event: snapshot") { + t.Errorf("first event missing snapshot header: %q", gotSnapshot) + } + if !strings.Contains(gotSnapshot, `"hello":"world"`) { + t.Errorf("snapshot missing data: %q", gotSnapshot) + } +} + +func TestServeSSE_Broadcast(t *testing.T) { + h := sse.NewHub(nil) + + srv := httptest.NewServer(http.HandlerFunc(h.ServeSSE)) + defer srv.Close() + + ctx := t.Context() + + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("connect: %v", err) + } + defer resp.Body.Close() + + // Wait until the server has registered the subscriber before broadcasting. + deadline := time.Now().Add(2 * time.Second) + for h.SubscriberCount() == 0 { + if time.Now().After(deadline) { + t.Fatal("subscriber never registered") + } + time.Sleep(5 * time.Millisecond) + } + + h.Broadcast("default", map[string]string{"k": "v"}) + + r := bufio.NewReader(resp.Body) + got := readEvent(t, r) + if !strings.Contains(got, "event: board_update") { + t.Errorf("event missing board_update type: %q", got) + } + if !strings.Contains(got, `"k":"v"`) { + t.Errorf("event missing data: %q", got) + } +} + +func TestServeSSE_ClientDisconnect(t *testing.T) { + h := sse.NewHub(nil) + + srv := httptest.NewServer(http.HandlerFunc(h.ServeSSE)) + defer srv.Close() + + ctx, cancel := context.WithCancel(context.Background()) + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("connect: %v", err) + } + + deadline := time.Now().Add(2 * time.Second) + for h.SubscriberCount() == 0 { + if time.Now().After(deadline) { + t.Fatal("subscriber never registered") + } + time.Sleep(5 * time.Millisecond) + } + + // Disconnect the client; the handler must Unsubscribe via r.Context().Done(). + cancel() + resp.Body.Close() + + deadline = time.Now().Add(2 * time.Second) + for h.SubscriberCount() != 0 { + if time.Now().After(deadline) { + t.Fatal("subscriber not removed after client disconnect") + } + time.Sleep(5 * time.Millisecond) + } +} + +// readEvent reads a single SSE event (terminated by a blank line) from r. +func readEvent(t *testing.T, r *bufio.Reader) string { + t.Helper() + var b strings.Builder + type result struct { + s string + err error + } + resCh := make(chan result, 1) + go func() { + for { + line, err := r.ReadString('\n') + b.WriteString(line) + if err != nil { + resCh <- result{b.String(), err} + return + } + if line == "\n" { + resCh <- result{b.String(), nil} + return + } + } + }() + select { + case res := <-resCh: + if res.err != nil { + t.Fatalf("reading event: %v (got %q)", res.err, res.s) + } + return res.s + case <-time.After(3 * time.Second): + t.Fatal("timed out reading SSE event") + return "" + } +} diff --git a/go/plugins/kanban-mcp/internal/ui/embed.go b/go/plugins/kanban-mcp/internal/ui/embed.go new file mode 100644 index 0000000000..dcdd522804 --- /dev/null +++ b/go/plugins/kanban-mcp/internal/ui/embed.go @@ -0,0 +1,54 @@ +// Package ui serves the embedded single-page Kanban board. The SPA is a single +// vanilla HTML+JS+CSS file (no build step) compiled into the binary via go:embed, +// so the server ships as one self-contained artifact. The page fetches the board +// from the REST API and subscribes to /events (SSE) for live updates. +package ui + +import ( + "bytes" + _ "embed" + "net/http" +) + +// readonlySentinel is the default read-only declaration in index.html. Handler +// rewrites it to "true" when the server runs in read-only mode so the SPA hides +// the "New Task" button. Keeping the default in the source file means the +// embedded page is valid as-is (read-only off) without any substitution. +const ( + readonlySentinel = "const READONLY = false;" + readonlyEnabled = "const READONLY = true;" +) + +// indexHTML is the full single-page application, embedded at build time. A build +// failure here (e.g. a missing index.html) is caught at compile time. +// +//go:embed index.html +var indexHTML []byte + +// taskProgressHTML is the self-contained MCP App View served as the +// ui://kanban/task-progress resource. It is a single vanilla HTML+JS+CSS file +// (no external assets) so it can be embedded directly in the MCP resource body. +// +//go:embed task_progress.html +var taskProgressHTML []byte + +// TaskProgressHTML returns the MCP App View HTML for the task-progress widget. +// The mcp package serves these bytes as the ui://kanban/task-progress resource. +func TaskProgressHTML() []byte { + return taskProgressHTML +} + +// Handler returns an http.Handler that serves the embedded SPA as text/html for +// every request. It is mounted at "/" by the server; client-side routing (if any) +// and the REST/SSE surfaces live under their own prefixes. When readonly is true +// the served page hides the "New Task" button. +func Handler(readonly bool) http.Handler { + page := indexHTML + if readonly { + page = bytes.Replace(indexHTML, []byte(readonlySentinel), []byte(readonlyEnabled), 1) + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(page) + }) +} diff --git a/go/plugins/kanban-mcp/internal/ui/embed_test.go b/go/plugins/kanban-mcp/internal/ui/embed_test.go new file mode 100644 index 0000000000..23f36f060c --- /dev/null +++ b/go/plugins/kanban-mcp/internal/ui/embed_test.go @@ -0,0 +1,90 @@ +package ui + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// TestUI_Embedded verifies the index.html is embedded and non-empty at init time. +// A failed or missing embed surfaces here rather than as a blank page in a browser. +func TestUI_Embedded(t *testing.T) { + if len(indexHTML) == 0 { + t.Fatal("indexHTML is empty: go:embed of index.html failed") + } + if !strings.Contains(string(indexHTML), "Kanban") { + t.Fatal("embedded UI does not contain expected marker \"Kanban\"") + } +} + +// TestTaskProgressHTML_Embedded verifies the MCP App View is embedded and looks +// like a valid MCP App (contains the handshake the host expects). +func TestTaskProgressHTML_Embedded(t *testing.T) { + html := string(TaskProgressHTML()) + if html == "" { + t.Fatal("TaskProgressHTML is empty: go:embed of task_progress.html failed") + } + for _, marker := range []string{"ui/initialize", "ui/notifications/tool-result", "refresh_task_progress"} { + if !strings.Contains(html, marker) { + t.Errorf("embedded View missing expected marker %q", marker) + } + } +} + +// TestUI_Handler verifies GET / returns 200 with an HTML content type and a body +// containing the "Kanban" marker. It uses httptest.NewRecorder so no network +// listener is required. +func TestUI_Handler(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + + Handler(false).ServeHTTP(rec, req) + + res := rec.Result() + defer func() { _ = res.Body.Close() }() + + if res.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d", res.StatusCode, http.StatusOK) + } + if ct := res.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { + t.Fatalf("Content-Type = %q, want text/html prefix", ct) + } + if !strings.Contains(rec.Body.String(), "Kanban") { + t.Fatal("response body does not contain expected marker \"Kanban\"") + } +} + +// TestUI_Handler_Readonly verifies the served page reflects the read-only flag: +// the default page declares READONLY false, and Handler(true) rewrites it so the +// SPA hides the "New Task" button. +func TestUI_Handler_Readonly(t *testing.T) { + if !strings.Contains(string(indexHTML), readonlySentinel) { + t.Fatalf("index.html missing read-only sentinel %q", readonlySentinel) + } + + tests := []struct { + name string + readonly bool + want string + }{ + {name: "default is read-write", readonly: false, want: readonlySentinel}, + {name: "readonly rewrites flag", readonly: true, want: readonlyEnabled}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + + Handler(tt.readonly).ServeHTTP(rec, req) + + body := rec.Body.String() + if !strings.Contains(body, tt.want) { + t.Fatalf("body does not contain %q", tt.want) + } + if tt.readonly && strings.Contains(body, readonlySentinel) { + t.Fatal("readonly page still contains read-write sentinel") + } + }) + } +} diff --git a/go/plugins/kanban-mcp/internal/ui/index.html b/go/plugins/kanban-mcp/internal/ui/index.html new file mode 100644 index 0000000000..27f8ba6ada --- /dev/null +++ b/go/plugins/kanban-mcp/internal/ui/index.html @@ -0,0 +1,1535 @@ + + + + + +Kanban Board + + + + +
+

Kanban Board

+ connecting... +
+ +
+
+
+
+ + + + + + + + diff --git a/go/plugins/kanban-mcp/internal/ui/task_progress.html b/go/plugins/kanban-mcp/internal/ui/task_progress.html new file mode 100644 index 0000000000..53e5d703f3 --- /dev/null +++ b/go/plugins/kanban-mcp/internal/ui/task_progress.html @@ -0,0 +1,578 @@ + + + + + + Kanban Task Progress + + + +
+ + +
Loading task progress…
+
+ + + + diff --git a/go/plugins/kanban-mcp/main.go b/go/plugins/kanban-mcp/main.go new file mode 100644 index 0000000000..134156d680 --- /dev/null +++ b/go/plugins/kanban-mcp/main.go @@ -0,0 +1,136 @@ +// Command kanban-mcp is the MCP Kanban server. It resolves configuration, runs +// the kanban Postgres migrations, opens a connection pool, and serves the board. +// In stdio mode it runs the MCP server over stdin/stdout so kagent can register +// it as an MCP server. HTTP transport (REST + SSE + MCP over HTTP) is wired in a +// later step. +package main + +import ( + "context" + "errors" + "flag" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/config" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/db" + dbgen "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/db/gen" + kmcp "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/mcp" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/migrations" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/seed" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/service" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/sse" +) + +// seedBoards parses board definitions from config and upserts them. It is called +// once at startup, after migrations and before serving, for every transport. +func seedBoards(ctx context.Context, cfg *config.Config, svc *service.TaskService) error { + specs, err := seed.Parse(cfg.Boards, cfg.BoardsFile) + if err != nil { + return err + } + if len(specs) == 0 { + return nil + } + if err := seed.Apply(ctx, svc, specs); err != nil { + return err + } + log.Printf("kanban-mcp: seeded %d board(s) from configuration", len(specs)) + return nil +} + +func main() { + cfg, err := config.Load() + if err != nil { + // `--help` / `-h` is a clean exit, not an error. + if errors.Is(err, flag.ErrHelp) { + os.Exit(0) + } + log.Fatalf("kanban-mcp: failed to load config: %v", err) + } + + log.Printf("kanban-mcp config: addr=%s transport=%s db-url-set=%t db-url-file=%q log-level=%s readonly=%t", + cfg.Addr, cfg.Transport, cfg.DBURL != "", cfg.DBURLFile, cfg.LogLevel, cfg.Readonly) + + if err := run(cfg); err != nil { + log.Fatalf("kanban-mcp: %v", err) + } +} + +// run resolves the database URL, migrates, connects, and serves the configured +// transport. It is separated from main so the exit path is a single error return. +func run(cfg *config.Config) error { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + url, err := db.ResolveURL(cfg.DBURL, cfg.DBURLFile) + if err != nil { + return err + } + if url == "" { + return errors.New("no database URL configured: set --db-url or --db-url-file") + } + + if err := migrations.RunUp(url); err != nil { + return err + } + + pool, err := db.Connect(ctx, url) + if err != nil { + return err + } + defer pool.Close() + + if cfg.Transport == "stdio" { + // stdio mode has no SSE hub: the board is driven entirely by MCP tools. + svc := service.NewTaskService(dbgen.New(pool), pool, service.NopBroadcaster{}) + if err := seedBoards(ctx, cfg, svc); err != nil { + return err + } + server := kmcp.NewServer(svc) + log.Printf("kanban-mcp: serving MCP over stdio") + if err := server.Run(ctx, &mcpsdk.StdioTransport{}); err != nil { + return err + } + return nil + } + + // HTTP mode: the SSE hub is the broadcaster, and its snapshot reads back from + // the service. The service and hub reference each other, so the hub is built + // first with a closure that captures the service once it is assigned. + var svc *service.TaskService + hub := sse.NewHub(func(board string) any { + state, err := svc.GetBoard(context.Background(), board) + if err != nil { + return &service.BoardState{Columns: []service.Column{}} + } + return state + }) + svc = service.NewTaskService(dbgen.New(pool), pool, hub) + + if err := seedBoards(ctx, cfg, svc); err != nil { + return err + } + + srv := NewHTTPServer(cfg, svc, hub) + + // Shut the server down cleanly when the context is cancelled (SIGINT/SIGTERM). + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) + }() + + log.Printf("kanban-mcp listening on %s", cfg.Addr) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + return nil +} diff --git a/go/plugins/kanban-mcp/server.go b/go/plugins/kanban-mcp/server.go new file mode 100644 index 0000000000..8dd88a3c5c --- /dev/null +++ b/go/plugins/kanban-mcp/server.go @@ -0,0 +1,64 @@ +// HTTP server wiring for the kanban-mcp binary. NewHTTPServer mounts all four +// surfaces on a single port: +// +// - /mcp MCP over the streamable-HTTP transport (JSON-RPC). +// - /events SSE board stream (initial snapshot + board_update events). +// - /api/* REST API. Stubbed with 501 Not Implemented until Step 9, except +// the GET /api/tasks/{id} not-found path which returns 404 so clients and +// the UI behave correctly before the full handlers land. +// - / Embedded single-page UI served by internal/ui. +// +// It is extracted from main so the wiring can be exercised by httptest in unit +// tests without opening a real listener or database. +package main + +import ( + "net/http" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/api" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/config" + kmcp "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/mcp" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/service" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/sse" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/ui" +) + +// NewHTTPServer builds the *http.Server that serves the MCP, SSE, REST, and UI +// surfaces for the kanban board. The returned server is not yet listening; +// callers invoke ListenAndServe (or pass it to httptest in tests). +func NewHTTPServer(cfg *config.Config, svc *service.TaskService, hub *sse.Hub) *http.Server { + return &http.Server{ + Addr: cfg.Addr, + Handler: newMux(svc, hub, cfg.Readonly), + } +} + +// newMux wires every route onto a fresh ServeMux. When readonly is true the +// embedded board UI is served in read-only mode (the "New Task" button is +// hidden). +func newMux(svc *service.TaskService, hub *sse.Hub, readonly bool) *http.ServeMux { + mcpServer := kmcp.NewServer(svc) + mcpHandler := mcpsdk.NewStreamableHTTPHandler( + func(*http.Request) *mcpsdk.Server { return mcpServer }, + nil, + ) + + mux := http.NewServeMux() + mux.Handle("/mcp", mcpHandler) + mux.HandleFunc("/events", hub.ServeSSE) + + // REST API (Step 9). + mux.HandleFunc("/api/tasks", api.TasksHandler(svc)) + mux.HandleFunc("/api/tasks/", api.TaskHandler(svc)) + mux.HandleFunc("/api/subtasks/", api.SubtaskHandler(svc)) + mux.HandleFunc("/api/attachments/", api.AttachmentHandler(svc)) + mux.HandleFunc("/api/board", api.BoardHandler(svc)) + mux.HandleFunc("/api/boards", api.BoardsHandler(svc)) + + // Embedded single-page UI (Step 10). + mux.Handle("/", ui.Handler(readonly)) + + return mux +} diff --git a/go/plugins/kanban-mcp/server_test.go b/go/plugins/kanban-mcp/server_test.go new file mode 100644 index 0000000000..248df79900 --- /dev/null +++ b/go/plugins/kanban-mcp/server_test.go @@ -0,0 +1,216 @@ +package main + +import ( + "bufio" + "context" + "net/http" + "net/http/httptest" + "os/exec" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + testcontainers "github.com/testcontainers/testcontainers-go" + tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres" + "github.com/testcontainers/testcontainers-go/wait" + + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/config" + dbgen "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/db/gen" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/migrations" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/service" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/sse" +) + +// startPostgres starts a Postgres container, runs the kanban migrations, and +// returns a connection string. Tests skip when Docker is not available. This is a +// thin local copy of go/core/internal/dbtest (which cannot be imported across the +// internal/ boundary), mirroring the other package test helpers. +func startPostgres(ctx context.Context, t *testing.T) string { + t.Helper() + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available, skipping container test") + } + + pgContainer, err := tcpostgres.Run(ctx, + "postgres:16-alpine", + tcpostgres.WithDatabase("kanban_test"), + tcpostgres.WithUsername("postgres"), + tcpostgres.WithPassword("kanban"), + testcontainers.WithWaitStrategy( + wait.ForLog("database system is ready to accept connections"). + WithOccurrence(2). + WithStartupTimeout(60*time.Second), + ), + ) + if err != nil { + t.Fatalf("starting postgres container: %v", err) + } + t.Cleanup(func() { + if err := pgContainer.Terminate(context.Background()); err != nil { + t.Logf("warning: failed to terminate postgres container: %v", err) + } + }) + + connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable") + if err != nil { + t.Fatalf("getting connection string: %v", err) + } + + if err := migrations.RunUp(connStr); err != nil { + t.Fatalf("running migrations: %v", err) + } + return connStr +} + +// newTestServer starts Postgres, builds the wired HTTP server, and returns an +// httptest.Server backed by it plus the live TaskService for direct mutation. +func newTestServer(ctx context.Context, t *testing.T) (*httptest.Server, *service.TaskService) { + t.Helper() + url := startPostgres(ctx, t) + + pool, err := pgxpool.New(ctx, url) + if err != nil { + t.Fatalf("creating pool: %v", err) + } + t.Cleanup(pool.Close) + + var svc *service.TaskService + hub := sse.NewHub(func(board string) any { + state, err := svc.GetBoard(context.Background(), board) + if err != nil { + return &service.BoardState{Columns: []service.Column{}} + } + return state + }) + svc = service.NewTaskService(dbgen.New(pool), pool, hub) + + srv := NewHTTPServer(&config.Config{Addr: ":0", Transport: "http"}, svc, hub) + ts := httptest.NewServer(srv.Handler) + t.Cleanup(ts.Close) + return ts, svc +} + +// TestHTTPServer_MCP connects a real MCP client over the streamable-HTTP +// transport to /mcp and verifies a tool call returns a valid result. +func TestHTTPServer_MCP(t *testing.T) { + ctx := context.Background() + ts, _ := newTestServer(ctx, t) + + client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: "test", Version: "v0"}, nil) + session, err := client.Connect(ctx, &mcpsdk.StreamableClientTransport{ + Endpoint: ts.URL + "/mcp", + }, nil) + if err != nil { + t.Fatalf("connecting MCP client: %v", err) + } + defer func() { _ = session.Close() }() + + res, err := session.CallTool(ctx, &mcpsdk.CallToolParams{ + Name: "get_board", + Arguments: map[string]any{}, + }) + if err != nil { + t.Fatalf("calling get_board: %v", err) + } + if res.IsError { + t.Fatalf("get_board returned an error result: %+v", res.Content) + } + if len(res.Content) == 0 { + t.Fatal("get_board returned no content") + } +} + +// TestHTTPServer_SSE connects to /events and verifies the streaming content type +// and the initial snapshot event. +func TestHTTPServer_SSE(t *testing.T) { + ctx := context.Background() + ts, _ := newTestServer(ctx, t) + + reqCtx, cancel := context.WithCancel(ctx) + defer cancel() + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, ts.URL+"/events", nil) + if err != nil { + t.Fatalf("building request: %v", err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("connecting to /events: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if got := resp.Header.Get("Content-Type"); !strings.HasPrefix(got, "text/event-stream") { + t.Fatalf("Content-Type = %q, want text/event-stream", got) + } + + // Read the initial snapshot event line. + scanner := bufio.NewScanner(resp.Body) + var sawSnapshot bool + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "event: snapshot") { + sawSnapshot = true + break + } + } + if !sawSnapshot { + t.Fatal("did not receive initial snapshot event") + } +} + +// TestHTTPServer_NotFound verifies a GET for a non-existent task returns 404. +func TestHTTPServer_NotFound(t *testing.T) { + ctx := context.Background() + ts, _ := newTestServer(ctx, t) + + resp, err := http.Get(ts.URL + "/api/tasks/99999") + if err != nil { + t.Fatalf("GET /api/tasks/99999: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusNotFound) + } +} + +// TestHTTPServer_CORS verifies the /mcp endpoint negotiates an MCP session, +// returning the expected Mcp-Session-Id header on initialize. +func TestHTTPServer_CORS(t *testing.T) { + ctx := context.Background() + ts, _ := newTestServer(ctx, t) + + client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: "test", Version: "v0"}, nil) + session, err := client.Connect(ctx, &mcpsdk.StreamableClientTransport{ + Endpoint: ts.URL + "/mcp", + }, nil) + if err != nil { + t.Fatalf("connecting MCP client: %v", err) + } + defer func() { _ = session.Close() }() + + if session.ID() == "" { + t.Fatal("expected a non-empty MCP session ID from /mcp") + } +} + +// TestHTTPServer_UI verifies the root route serves the embedded single-page UI +// (Step 10): 200 with an HTML body. +func TestHTTPServer_UI(t *testing.T) { + ctx := context.Background() + ts, _ := newTestServer(ctx, t) + + resp, err := http.Get(ts.URL + "/") + if err != nil { + t.Fatalf("GET /: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK) + } + if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { + t.Fatalf("Content-Type = %q, want text/html prefix", ct) + } +}