diff --git a/.prettierignore b/.prettierignore index 516a5d1..39748fa 100644 --- a/.prettierignore +++ b/.prettierignore @@ -9,3 +9,6 @@ package-lock.json # Generated by the API on startup (nx serve api): keep them byte-for-byte as written apps/api/openapi.json apps/api/schema.gql + +# Generated from apps/api/openapi.json (nx run mcp:openapi-types) +apps/mcp/src/app/api/taskstream.schema.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 2ecd6a9..7cc908a 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -6,24 +6,36 @@ "request": "launch", "name": "Debug api with Nx", "runtimeExecutable": "npx", - "runtimeArgs": [ - "nx", - "serve", - "api" - ], + "runtimeArgs": ["nx", "serve", "api"], "env": { "NODE_OPTIONS": "--inspect=9229" }, "console": "integratedTerminal", "internalConsoleOptions": "neverOpen", - "skipFiles": [ - "/**" - ], + "skipFiles": ["/**"], "sourceMaps": true, "outFiles": [ "${workspaceFolder}/apps/api/dist/**/*.(m|c|)js", "!**/node_modules/**" ] + }, + { + "type": "node", + "request": "launch", + "name": "Debug mcp with Nx", + "runtimeExecutable": "npx", + "runtimeArgs": ["nx", "serve", "mcp"], + "env": { + "NODE_OPTIONS": "--inspect=9230" + }, + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "skipFiles": ["/**"], + "sourceMaps": true, + "outFiles": [ + "${workspaceFolder}/apps/mcp/dist/**/*.(m|c|)js", + "!**/node_modules/**" + ] } ] } diff --git a/Dockerfile b/Dockerfile index 033703b..4265b70 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,7 @@ # syntax=docker/dockerfile:1.7 -# TaskStream images (one Dockerfile, two targets): -# docker compose up -d --build → api + web + infrastructure +# TaskStream images (one Dockerfile, three targets): +# docker compose up -d --build → api + web + infrastructure +# docker compose --profile mcp up -d --build → plus the MCP server # # @pi/ui comes from Pireon's private Nexus. Its token is passed as a build # secret (compose: `secrets.nexus_token` ← $NEXUS_TOKEN) and never lands in a @@ -35,6 +36,24 @@ HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --retries=5 \ CMD wget -qO- http://127.0.0.1:3000/api/health > /dev/null || exit 1 CMD ["node", "main.js"] +# ---- MCP: server for AI clients, consumes the API over REST ------------ +FROM deps AS build-mcp +COPY . . +RUN npx nx run mcp:prune + +# @nestjs-mcp/server 2 targets Node 24. +FROM node:24-alpine AS mcp +WORKDIR /app +ENV NODE_ENV=production MCP_PORT=3010 +COPY --from=build-mcp /workspace/dist/apps/mcp/package.json /workspace/dist/apps/mcp/package-lock.json ./ +RUN npm ci --omit=dev --ignore-scripts --no-audit --no-fund && npm cache clean --force +COPY --from=build-mcp /workspace/dist/apps/mcp/main.js ./ +USER node +EXPOSE 3010 +HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=5 \ + CMD wget -qO- http://127.0.0.1:3010/health > /dev/null || exit 1 +CMD ["node", "main.js"] + # ---- Web: Angular SPA behind nginx (proxies /api and /graphql) --------- FROM deps AS build-web COPY . . diff --git a/apps/mcp/eslint.config.mjs b/apps/mcp/eslint.config.mjs new file mode 100644 index 0000000..799314b --- /dev/null +++ b/apps/mcp/eslint.config.mjs @@ -0,0 +1,7 @@ +import baseConfig from '../../eslint.config.mjs'; + +export default [ + ...baseConfig, + // Generated from apps/api/openapi.json (nx run mcp:openapi-types). + { ignores: ['src/app/api/taskstream.schema.ts'] }, +]; diff --git a/apps/mcp/jest.config.cts b/apps/mcp/jest.config.cts new file mode 100644 index 0000000..88f54ae --- /dev/null +++ b/apps/mcp/jest.config.cts @@ -0,0 +1,10 @@ +module.exports = { + displayName: 'mcp', + preset: '../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../coverage/apps/mcp', +}; diff --git a/apps/mcp/package.json b/apps/mcp/package.json new file mode 100644 index 0000000..f9fc908 --- /dev/null +++ b/apps/mcp/package.json @@ -0,0 +1,21 @@ +{ + "name": "mcp", + "version": "0.0.1", + "private": true, + "description": "Runtime dependencies of the MCP image. Nx uses this list for dist/apps/mcp/package.json (pruned lockfile for Docker).", + "dependencies": { + "@modelcontextprotocol/server": "^2.3.1", + "@nestjs-mcp/server": "^2.0.0", + "@nestjs/common": "^11.0.0", + "@nestjs/config": "^4.0.4", + "@nestjs/core": "^11.0.0", + "@nestjs/platform-express": "^11.0.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.15.1", + "openapi-fetch": "^0.17.0", + "reflect-metadata": "^0.2.0", + "rxjs": "^7.8.0", + "tslib": "^2.8.1", + "zod": "^4.6.5" + } +} diff --git a/apps/mcp/project.json b/apps/mcp/project.json new file mode 100644 index 0000000..b975b9b --- /dev/null +++ b/apps/mcp/project.json @@ -0,0 +1,66 @@ +{ + "name": "mcp", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/mcp/src", + "projectType": "application", + "tags": ["scope:mcp", "type:app"], + "targets": { + "prune-lockfile": { + "dependsOn": ["build"], + "cache": true, + "executor": "@nx/js:prune-lockfile", + "outputs": [ + "{workspaceRoot}/dist/apps/mcp/package.json", + "{workspaceRoot}/dist/apps/mcp/package-lock.json" + ], + "options": { + "buildTarget": "build" + } + }, + "copy-workspace-modules": { + "dependsOn": ["build"], + "cache": true, + "outputs": ["{workspaceRoot}/dist/apps/mcp/workspace_modules"], + "executor": "@nx/js:copy-workspace-modules", + "options": { + "buildTarget": "build" + } + }, + "prune": { + "dependsOn": ["prune-lockfile", "copy-workspace-modules"], + "executor": "nx:noop" + }, + "serve": { + "continuous": true, + "executor": "@nx/js:node", + "defaultConfiguration": "development", + "dependsOn": ["build"], + "options": { + "buildTarget": "mcp:build", + "runBuildTargetDependencies": false + }, + "configurations": { + "development": { + "buildTarget": "mcp:build:development" + }, + "production": { + "buildTarget": "mcp:build:production" + } + } + }, + "test": { + "options": { + "passWithNoTests": true + } + }, + "openapi-types": { + "executor": "nx:run-commands", + "inputs": ["{workspaceRoot}/apps/api/openapi.json"], + "outputs": ["{projectRoot}/src/app/api/taskstream.schema.ts"], + "cache": true, + "options": { + "command": "npx -y openapi-typescript@7.13.0 apps/api/openapi.json -o apps/mcp/src/app/api/taskstream.schema.ts" + } + } + } +} diff --git a/apps/mcp/src/app/api/taskstream-api.ts b/apps/mcp/src/app/api/taskstream-api.ts new file mode 100644 index 0000000..d5c8d23 --- /dev/null +++ b/apps/mcp/src/app/api/taskstream-api.ts @@ -0,0 +1,58 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { McpContext } from '@nestjs-mcp/server'; +import createClient, { type Client } from 'openapi-fetch'; +import type { components, paths } from './taskstream.schema'; + +export type Schemas = components['schemas']; +export type TaskStreamClient = Client; + +export const TASKSTREAM_API_URL = Symbol('TASKSTREAM_API_URL'); + +/** An answer of the API other than 2xx, with its message for the model. */ +export class ApiError extends Error { + constructor( + readonly status: number, + message: string, + ) { + super(message); + } +} + +/** + * The task-stream REST API (typed from apps/api/openapi.json). Each MCP call + * is made with the caller's own Pireon token, forwarded from its + * `Authorization` header: the MCP keeps no credentials, and every user sees + * exactly what the API lets them see (public projects only, without a token). + */ +@Injectable() +export class TaskStreamApi { + constructor(@Inject(TASKSTREAM_API_URL) private readonly baseUrl: string) {} + + /** A client acting as the caller of this MCP request. */ + as(ctx: McpContext): TaskStreamClient { + const authorization = ctx.headers.authorization; + return createClient({ + baseUrl: this.baseUrl, + headers: authorization ? { authorization } : {}, + }); + } + + /** The body of a successful answer; otherwise an ApiError with the API's message. */ + async data( + request: Promise<{ data?: T; error?: unknown; response: Response }>, + ): Promise { + const { data, error, response } = await request; + if (response.ok) return data as T; + throw new ApiError(response.status, describe(response.status, error)); + } +} + +function describe(status: number, error: unknown): string { + const body = (error ?? {}) as { message?: unknown; code?: unknown }; + const message = Array.isArray(body.message) + ? body.message.join('; ') + : String(body.message ?? `HTTP ${status}`); + if (status === 401) + return `${message}. Este MCP usa el token de Pireon del cliente: configúralo con el header "Authorization: Bearer " (sin token solo se ven proyectos públicos).`; + return body.code ? `${message} (${String(body.code)})` : message; +} diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts new file mode 100644 index 0000000..9a014bc --- /dev/null +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -0,0 +1,4292 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + '/api/health': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** API and databases status */ + get: operations['HealthController_check']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/auth/config': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** How to sign in with Pireon (null when not configured) */ + get: operations['AuthController_config']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/auth/token': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Exchange a grant for Pireon tokens (proxy of Pireon /oauth/token) + * @description For browsers (Pireon has no CORS) and Swagger UI: adds the client secret server-side. Apps with their own Pireon OAuth client can get tokens from Pireon directly; any valid Pireon access token is accepted by this API. + */ + post: operations['AuthController_token']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/users/me': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** The user behind the access token */ + get: operations['UsersController_me']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/users': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Search Pireon users (to assign tickets or add members) + * @description Needs the `users:read` scope in the access token. + */ + get: operations['UsersController_search']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/users/{id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** A Pireon user */ + get: operations['UsersController_findOne']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/webhooks/pireon': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Pireon events (called by Pireon, not by apps) + * @description `user.deactivated` closes the user's sessions; `token.revoked` forgets the token. Disabled unless PIREON_WEBHOOK_SECRET is set. + */ + post: operations['PireonWebhookController_receive']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/projects': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Projects where I am a member */ + get: operations['ProjectsController_mine']; + put?: never; + /** Create a project (you become its owner) */ + post: operations['ProjectsController_create']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/projects/{project}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** A project with its members and settings */ + get: operations['ProjectsController_findOne']; + put?: never; + post?: never; + /** + * Delete a project (owner only) + * @description Deletes the project and, right after (asynchronously, through a saga), its tickets, comments and releases; links from tickets of other projects are removed and the key becomes free. Cannot be undone from the API. `confirm` must be the project key. + */ + delete: operations['ProjectsController_remove']; + options?: never; + head?: never; + /** Rename, describe or make public/private a project (owner/admin) */ + patch: operations['ProjectsController_update']; + trace?: never; + }; + '/api/projects/{project}/settings': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Statuses, transitions, ticket types and areas (owner/admin) + * @description Replaces the whole configuration (send what GET /projects/{project} returns, changed). Values can be added, renamed, reordered or archived, never removed: tickets and statistics keep referring to them. + */ + put: operations['ProjectsController_updateSettings']; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/projects/{project}/board': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Kanban board: one column per status */ + get: operations['ProjectsController_board']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/projects/{project}/stats': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Tracking dashboard: counts by status, type, person… */ + get: operations['ProjectsController_stats']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/projects/{project}/analytics': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Statistics of the last `days` days + * @description Created vs resolved per week, cumulative flow per day, cycle and lead time, throughput per person and QA pass/fail. + */ + get: operations['ProjectsController_analytics']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/projects/{project}/activity': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Activity feed (newest first) */ + get: operations['ProjectsController_activity']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/projects/{project}/members': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Add a Pireon user to the project (owner/admin) + * @description The user must exist and be active in Pireon. + */ + post: operations['ProjectsController_addMember']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/projects/{project}/members/{userId}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Remove a member (owner/admin, or yourself to leave) */ + delete: operations['ProjectsController_removeMember']; + options?: never; + head?: never; + /** Change a member's role (owner/admin) */ + patch: operations['ProjectsController_changeMemberRole']; + trace?: never; + }; + '/api/me/issues': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * My work: tickets across all my projects + * @description Assigned to me by default; with assignedToMe=false, any ticket of my projects (e.g. waiting for QA). + */ + get: operations['IssuesController_mine']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/projects/{project}/issues': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Search the tickets of a project */ + get: operations['IssuesController_search']; + put?: never; + /** + * Create a ticket + * @description Starts in the project's first active TODO-category status (BACKLOG by default). The response carries its key (e.g. WEB-13). + */ + post: operations['IssuesController_create']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/issues/{issue}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** A ticket */ + get: operations['IssuesController_findOne']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Edit title, description, type, priority, area, labels… */ + patch: operations['IssuesController_update']; + trace?: never; + }; + '/api/issues/{issue}/status': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Move the ticket through the workflow (incl. QA) + * @description Only the transitions in `allowedTransitions` are accepted (422 otherwise). QA_FAILED and CANCELLED need a reason. + */ + put: operations['IssuesController_changeStatus']; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/issues/{issue}/assignee': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Assign to a project member (null to unassign) */ + put: operations['IssuesController_assign']; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/issues/{issue}/release': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Put the ticket in a planned release (null takes it out) */ + put: operations['IssuesController_setRelease']; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/issues/{issue}/links': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Relate tickets (FE ↔ BE ↔ QA) + * @description e.g. WEB-3 DEPENDS_ON API-7, WEB-9 TESTS WEB-3. May cross projects. + */ + post: operations['IssuesController_link']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/issues/{issue}/links/{type}/{target}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Remove a relation */ + delete: operations['IssuesController_unlink']; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/issues/{issue}/comments': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Comments of the ticket (oldest first) */ + get: operations['IssuesController_listComments']; + put?: never; + /** Comment on the ticket */ + post: operations['IssuesController_addComment']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/issues/{issue}/history': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** History of the ticket (newest first) */ + get: operations['IssuesController_history']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/comments/{commentId}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Delete a comment (author or project admin) */ + delete: operations['CommentsController_delete']; + options?: never; + head?: never; + /** Edit a comment (author only) */ + patch: operations['CommentsController_edit']; + trace?: never; + }; + '/api/me/releases': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Planned releases of all my projects, soonest target date first */ + get: operations['ReleasesController_upcoming']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/projects/{project}/releases': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Releases of a project */ + get: operations['ReleasesController_list']; + put?: never; + /** Plan a release (owner/admin) */ + post: operations['ReleasesController_create']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/releases/{releaseId}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** A release */ + get: operations['ReleasesController_findOne']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Rename, describe or move the target date */ + patch: operations['ReleasesController_update']; + trace?: never; + }; + '/api/releases/{releaseId}/progress': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Finished tickets out of the total */ + get: operations['ReleasesController_progress']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/releases/{releaseId}/burnup': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Burnup: tickets in the release and finished ones, day by day */ + get: operations['ReleasesController_burnup']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/releases/{releaseId}/issues': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Tickets included in the release */ + get: operations['ReleasesController_issuesOf']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/releases/{releaseId}/publish': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Ship the release + * @description Fails (422) while it has unfinished tickets unless `force` is true. + */ + post: operations['ReleasesController_publish']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/releases/{releaseId}/archive': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Archive a planned release (its tickets leave it) */ + post: operations['ReleasesController_archive']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/explore/projects': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Public projects (no token needed) + * @description Their board, tickets, comments, releases, activity and statistics can be read without a token too. + */ + get: operations['ExploreController_publicProjects']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + AuthorizationConfigDto: { + /** @example https://oauth.pireon.dev/oauth/authorize */ + authorizeUrl: string; + /** @description Public id of the authorization_code client ("Sign in with Pireon"); null when only email + password is configured */ + clientId: string | null; + /** + * @example [ + * "users:read" + * ] + */ + scopes: string[]; + /** @description Whether email + password sign-in is allowed */ + passwordGrant: boolean; + }; + IssueTokenDto: { + /** @enum {string} */ + grant_type: 'authorization_code' | 'password' | 'refresh_token'; + /** @description authorization_code: code returned by Pireon */ + code?: string; + /** @description authorization_code: PKCE verifier */ + code_verifier?: string; + /** @description authorization_code: redirect URI used to authorize */ + redirect_uri?: string; + /** @description password: Pireon email */ + username?: string; + /** @description password: Pireon password */ + password?: string; + /** @description refresh_token */ + refresh_token?: string; + }; + TokenResponseDto: { + access_token: string; + refresh_token: string | null; + /** @description Seconds */ + expires_in: number; + /** @example Bearer */ + token_type: string; + }; + MeDto: { + /** @description Pireon user id */ + id: string; + name: string; + email: string; + isActive: boolean; + /** @description Global roles in Pireon */ + roles: string[]; + /** @description Roles inside the task-stream OAuth client */ + appRoles: string[]; + }; + ApiErrorDto: { + /** @example 404 */ + statusCode: number; + /** + * @description Domain error code + * @enum {string} + */ + code?: + | 'NOT_FOUND' + | 'FORBIDDEN' + | 'VALIDATION' + | 'CONFLICT' + | 'BUSINESS_RULE'; + /** + * @description Human message (a list of messages for invalid input) + * @example Issue not found + */ + message: string | string[]; + }; + UserDto: { + /** @description Pireon user id */ + id: string; + name: string; + email: string; + isActive: boolean; + }; + UserRefDto: { + /** @description Pireon user id */ + id: string; + name: string; + }; + /** @enum {string} */ + ProjectRole: 'OWNER' | 'ADMIN' | 'MEMBER' | 'VIEWER'; + ProjectMemberDto: { + user: components['schemas']['UserRefDto']; + role: components['schemas']['ProjectRole']; + }; + /** @enum {string} */ + StatusCategory: 'TODO' | 'IN_PROGRESS' | 'QA' | 'DONE' | 'CANCELLED'; + /** @enum {string} */ + SettingColor: + | 'slate' + | 'stone' + | 'violet' + | 'blue' + | 'sky' + | 'teal' + | 'green' + | 'amber' + | 'orange' + | 'red' + | 'pink'; + WorkflowStatusDto: { + /** + * @description Stable id on tickets + * @example IN_REVIEW + */ + id: string; + /** @example En revisión */ + name: string; + category: components['schemas']['StatusCategory']; + color: components['schemas']['SettingColor']; + /** @description Entering it needs a reason */ + requiresReason: boolean; + /** @description No new tickets or transitions into it */ + archived: boolean; + }; + StatusTransitionsDto: { + /** @example IN_QA */ + from: string; + /** + * @example [ + * "DONE", + * "QA_FAILED" + * ] + */ + to: string[]; + }; + /** @enum {string} */ + TypeIcon: + | 'bug' + | 'feature' + | 'palette' + | 'task' + | 'qa' + | 'flag' + | 'star-fill' + | 'rocket' + | 'document' + | 'light-bulb' + | 'key' + | 'support'; + IssueTypeOptionDto: { + /** @example BUG */ + id: string; + /** @example Bug */ + name: string; + icon: components['schemas']['TypeIcon']; + color: components['schemas']['SettingColor']; + archived: boolean; + }; + AreaOptionDto: { + /** @example FRONTEND */ + id: string; + /** @example Frontend */ + name: string; + archived: boolean; + }; + ProjectSettingsDto: { + /** @description In board order */ + statuses: components['schemas']['WorkflowStatusDto'][]; + transitions: components['schemas']['StatusTransitionsDto'][]; + issueTypes: components['schemas']['IssueTypeOptionDto'][]; + areas: components['schemas']['AreaOptionDto'][]; + }; + ProjectDto: { + /** Format: uuid */ + id: string; + /** + * @description Prefix of the ticket keys + * @example WEB + */ + key: string; + /** @example Checkout revamp */ + name: string; + description: string | null; + /** @description Readable by anyone, also without a token */ + isPublic: boolean; + owner: components['schemas']['UserRefDto']; + members: components['schemas']['ProjectMemberDto'][]; + settings: components['schemas']['ProjectSettingsDto']; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + /** @description Version of the aggregate (event count) */ + version: number; + }; + CreateProjectDto: { + /** + * @description 2-10 letters/digits starting with a letter → tickets WEB-1, WEB-2… + * @example WEB + */ + key: string; + /** @example Checkout revamp */ + name: string; + description?: string | null; + /** + * @description Anyone can read it without signing in (board, tickets, comments, releases, activity, statistics) + * @default false + */ + isPublic: boolean; + }; + CommandResultDto: { + /** @description Id of the aggregate written */ + id: string; + /** + * @description Human key when one was assigned (project or issue) + * @example WEB-13 + */ + key?: string; + /** @description Version after the write. Writes are acknowledged at once and the read model catches up asynchronously: a GET may briefly return the previous version (or 404 right after a create). */ + version: number; + }; + UpdateProjectDto: { + /** @example Checkout revamp */ + name?: string; + description?: string | null; + /** + * @description Anyone can read it without signing in (board, tickets, comments, releases, activity, statistics) + * @default false + */ + isPublic: boolean; + }; + WorkflowStatusBodyDto: { + /** + * @description Never change an existing id + * @example IN_REVIEW + */ + id: string; + /** @example En revisión */ + name: string; + category: components['schemas']['StatusCategory']; + color: components['schemas']['SettingColor']; + /** @default false */ + requiresReason: boolean; + /** @default false */ + archived: boolean; + }; + StatusTransitionsBodyDto: { + /** @example IN_QA */ + from: string; + /** + * @example [ + * "DONE", + * "QA_FAILED" + * ] + */ + to: string[]; + }; + IssueTypeOptionBodyDto: { + /** @example SPIKE */ + id: string; + /** @example Spike */ + name: string; + icon: components['schemas']['TypeIcon']; + color: components['schemas']['SettingColor']; + /** @default false */ + archived: boolean; + }; + AreaOptionBodyDto: { + /** @example DATA */ + id: string; + /** @example Datos */ + name: string; + /** @default false */ + archived: boolean; + }; + ProjectSettingsBodyDto: { + /** @description In board order. Values cannot be removed: archive them instead. */ + statuses: components['schemas']['WorkflowStatusBodyDto'][]; + transitions: components['schemas']['StatusTransitionsBodyDto'][]; + issueTypes: components['schemas']['IssueTypeOptionBodyDto'][]; + areas: components['schemas']['AreaOptionBodyDto'][]; + }; + /** @enum {string} */ + IssuePriority: 'LOWEST' | 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'; + ReleaseRefDto: { + /** Format: uuid */ + id: string; + /** @example v1.4.0 */ + name: string; + }; + /** @enum {string} */ + IssueLinkType: + | 'BLOCKS' + | 'DEPENDS_ON' + | 'TESTS' + | 'DUPLICATES' + | 'RELATES_TO'; + /** + * @description OUTWARD: this issue → other. INWARD: other → this issue. + * @enum {string} + */ + LinkDirection: 'OUTWARD' | 'INWARD'; + IssueLinkDto: { + type: components['schemas']['IssueLinkType']; + /** @description OUTWARD: this issue → other. INWARD: other → this issue. */ + direction: components['schemas']['LinkDirection']; + /** Format: uuid */ + issueId: string; + /** @example WEB-3 */ + issueKey: string; + }; + IssueDto: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + projectId: string; + /** @example WEB */ + projectKey: string; + /** @example 12 */ + number: number; + /** + * @description Human key + * @example WEB-12 + */ + key: string; + /** + * @description A ticket type id + * @example BUG + */ + type: string; + /** @example FRONTEND */ + area: string | null; + title: string; + description: string | null; + /** + * @description A status id + * @example IN_PROGRESS + */ + status: string; + statusCategory: components['schemas']['StatusCategory']; + /** @description Statuses this ticket can move to next (project's workflow) */ + allowedTransitions: string[]; + priority: components['schemas']['IssuePriority']; + reporter: components['schemas']['UserRefDto']; + assignee: components['schemas']['UserRefDto'] | null; + release: components['schemas']['ReleaseRefDto'] | null; + labels: string[]; + /** @description Figma frame */ + figmaUrl: string | null; + storyPoints: number | null; + /** Format: date */ + dueDate: string | null; + links: components['schemas']['IssueLinkDto'][]; + commentCount: number; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + version: number; + }; + BoardColumnDto: { + /** @example IN_PROGRESS */ + status: string; + issues: components['schemas']['IssueDto'][]; + /** @description Tickets in the column (may exceed `issues`) */ + total: number; + }; + CountByStatusDto: { + /** + * @description Status id + * @example IN_PROGRESS + */ + key: string; + count: number; + }; + CountByKeyDto: { + /** @description Type, priority or area ("NONE" = unset) */ + key: string; + count: number; + }; + CountByAssigneeDto: { + /** @description Null = unassigned */ + user: components['schemas']['UserRefDto'] | null; + count: number; + }; + ProjectStatsDto: { + total: number; + /** @description Not done nor cancelled */ + open: number; + byStatus: components['schemas']['CountByStatusDto'][]; + byType: components['schemas']['CountByKeyDto'][]; + byPriority: components['schemas']['CountByKeyDto'][]; + byArea: components['schemas']['CountByKeyDto'][]; + /** @description Open tickets per person */ + byAssignee: components['schemas']['CountByAssigneeDto'][]; + }; + CreatedResolvedDto: { + /** + * Format: date + * @description Monday of the week + */ + week: string; + created: number; + resolved: number; + }; + FlowSeriesDto: { + /** @example IN_PROGRESS */ + status: string; + /** @description One value per date */ + counts: number[]; + }; + CumulativeFlowDto: { + dates: string[]; + series: components['schemas']['FlowSeriesDto'][]; + }; + DurationStatsDto: { + count: number; + averageDays: number | null; + /** @description 85% of tickets take at most this long */ + p85Days: number | null; + }; + ThroughputDto: { + /** Format: date */ + week: string; + user: components['schemas']['UserRefDto'] | null; + count: number; + }; + QaStatsDto: { + /** @description Left QA to a done status */ + passed: number; + /** @description Left QA back to work */ + failed: number; + failureRate: number | null; + }; + ProjectAnalyticsDto: { + /** Format: date-time */ + from: string; + /** Format: date-time */ + to: string; + createdVsResolved: components['schemas']['CreatedResolvedDto'][]; + /** @description Tickets in each status at the end of every day */ + cumulativeFlow: components['schemas']['CumulativeFlowDto']; + /** @description Start of work → done */ + cycleTime: components['schemas']['DurationStatsDto']; + /** @description Creation → done */ + leadTime: components['schemas']['DurationStatsDto']; + throughput: components['schemas']['ThroughputDto'][]; + qa: components['schemas']['QaStatsDto']; + }; + ActivityDto: { + id: string; + /** @example IssueStatusChanged */ + type: string; + /** Format: uuid */ + projectId: string; + issueId: string | null; + /** @example WEB-12 */ + issueKey: string | null; + actor: components['schemas']['UserRefDto']; + /** @example Ana movió WEB-12 de En curso a En revisión */ + summary: string; + /** Format: date-time */ + occurredAt: string; + }; + AddMemberDto: { + /** @description Pireon user id (see GET /users) */ + userId: string; + /** @default MEMBER */ + role: components['schemas']['ProjectRole']; + }; + ChangeMemberRoleDto: { + role: components['schemas']['ProjectRole']; + }; + IssuePageDto: { + items: components['schemas']['IssueDto'][]; + /** @description Total matches (for pagination) */ + total: number; + }; + CreateIssueDto: { + description?: string | null; + /** + * @description An area id of the project's settings + * @example FRONTEND + */ + area?: string | null; + priority?: components['schemas']['IssuePriority']; + labels?: string[]; + /** @description Figma frame (https) for UI changes */ + figmaUrl?: string | null; + storyPoints?: number | null; + /** Format: date */ + dueDate?: string | null; + /** + * @description A ticket type id of the project's settings + * @example BUG + */ + type: string; + /** @example El botón de pagar no responde en Safari */ + title: string; + /** @description Pireon user id */ + assigneeId?: string | null; + /** Format: uuid */ + releaseId?: string | null; + }; + UpdateIssueDto: { + description?: string | null; + /** + * @description An area id of the project's settings + * @example FRONTEND + */ + area?: string | null; + priority?: components['schemas']['IssuePriority']; + labels?: string[]; + /** @description Figma frame (https) for UI changes */ + figmaUrl?: string | null; + storyPoints?: number | null; + /** Format: date */ + dueDate?: string | null; + /** + * @description A ticket type id + * @example FEATURE + */ + type?: string; + title?: string; + }; + ChangeStatusDto: { + /** + * @description A status id of the project's settings, among the ticket's allowedTransitions + * @example IN_REVIEW + */ + status: string; + /** @description Required by statuses with requiresReason (e.g. QA_FAILED) */ + reason?: string | null; + }; + AssignIssueDto: { + /** @description Pireon user id of a project member, or null to unassign */ + assigneeId: string | null; + }; + SetIssueReleaseDto: { + /** + * Format: uuid + * @description Planned release, or null to take the ticket out + */ + releaseId: string | null; + }; + LinkIssueDto: { + type: components['schemas']['IssueLinkType']; + /** + * @description Other ticket: id or key + * @example API-3 + */ + targetIssue: string; + }; + CommentDto: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + issueId: string; + author: components['schemas']['UserRefDto']; + body: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + editedAt: string | null; + version: number; + }; + CommentPageDto: { + items: components['schemas']['CommentDto'][]; + total: number; + }; + CommentBodyDto: { + /** @example Reproducido en Safari 17. */ + body: string; + }; + /** @enum {string} */ + ReleaseStatus: 'PLANNED' | 'RELEASED' | 'ARCHIVED'; + ReleaseDto: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + projectId: string; + /** @example v1.4.0 */ + name: string; + description: string | null; + /** Format: date */ + targetDate: string | null; + status: components['schemas']['ReleaseStatus']; + /** Format: date-time */ + releasedAt: string | null; + /** Format: date-time */ + createdAt: string; + version: number; + }; + CreateReleaseDto: { + /** @example v1.4.0 */ + name: string; + description?: string | null; + /** + * Format: date + * @example 2026-11-14 + */ + targetDate?: string | null; + }; + UpdateReleaseDto: { + /** @example v1.4.0 */ + name?: string; + description?: string | null; + /** + * Format: date + * @example 2026-11-14 + */ + targetDate?: string | null; + }; + ReleaseProgressDto: { + total: number; + finished: number; + percent: number; + byStatus: components['schemas']['CountByStatusDto'][]; + }; + ReleaseBurnupDto: { + /** Format: uuid */ + releaseId: string; + /** Format: date */ + targetDate: string | null; + dates: string[]; + /** @description Tickets in the release (not cancelled) */ + scope: number[]; + done: number[]; + }; + PublishReleaseDto: { + /** + * @description Publish even with unfinished tickets + * @default false + */ + force: boolean; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + HealthController_check: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': unknown; + }; + }; + /** @description A database is down */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + AuthController_config: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['AuthorizationConfigDto']; + }; + }; + }; + }; + AuthController_token: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['IssueTokenDto']; + 'application/x-www-form-urlencoded': components['schemas']['IssueTokenDto']; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['TokenResponseDto']; + }; + }; + /** @description Pireon rejected the grant */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + UsersController_me: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['MeDto']; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + }; + }; + UsersController_search: { + parameters: { + query?: { + /** @description Name or email */ + search?: string; + limit?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['UserDto'][]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + }; + }; + UsersController_findOne: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['UserDto']; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + }; + }; + PireonWebhookController_receive: { + parameters: { + query?: never; + header: { + 'x-webhook-signature': string; + /** @description HMAC-SHA256 of the raw body with PIREON_WEBHOOK_SECRET (hex) */ + 'X-Webhook-Signature': string; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Processed */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Missing or invalid signature */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + ProjectsController_mine: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ProjectDto'][]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + }; + }; + ProjectsController_create: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['CreateProjectDto']; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['CommandResultDto']; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + }; + }; + ProjectsController_findOne: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project id or key */ + project: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ProjectDto']; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + }; + }; + ProjectsController_remove: { + parameters: { + query: { + /** @description The project key, to confirm the deletion */ + confirm: string; + }; + header?: never; + path: { + /** @description Project id or key */ + project: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['CommandResultDto']; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + }; + }; + ProjectsController_update: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project id or key */ + project: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['UpdateProjectDto']; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['CommandResultDto']; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + }; + }; + ProjectsController_updateSettings: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project id or key */ + project: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['ProjectSettingsBodyDto']; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['CommandResultDto']; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + }; + }; + ProjectsController_board: { + parameters: { + query?: { + /** @description Only this release */ + releaseId?: string; + /** @description Only this person (Pireon user id) */ + assigneeId?: string; + /** @description An area id */ + area?: string; + perColumn?: number; + }; + header?: never; + path: { + /** @description Project id or key */ + project: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['BoardColumnDto'][]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + }; + }; + ProjectsController_stats: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project id or key */ + project: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ProjectStatsDto']; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + }; + }; + ProjectsController_analytics: { + parameters: { + query?: { + days?: number; + }; + header?: never; + path: { + /** @description Project id or key */ + project: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ProjectAnalyticsDto']; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + }; + }; + ProjectsController_activity: { + parameters: { + query?: { + /** @description Only this ticket */ + issueId?: string; + limit?: number; + /** @description Cursor: entries older than this date */ + before?: string; + }; + header?: never; + path: { + /** @description Project id or key */ + project: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ActivityDto'][]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + }; + }; + ProjectsController_addMember: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project id or key */ + project: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['AddMemberDto']; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['CommandResultDto']; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + }; + }; + ProjectsController_removeMember: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project id or key */ + project: string; + userId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['CommandResultDto']; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + }; + }; + ProjectsController_changeMemberRole: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project id or key */ + project: string; + userId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['ChangeMemberRoleDto']; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['CommandResultDto']; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + }; + }; + IssuesController_mine: { + parameters: { + query?: { + limit?: number; + offset?: number; + /** @description true: tickets assigned to me. false: any ticket of my projects (e.g. ?assignedToMe=false&category=QA) */ + assignedToMe?: boolean; + /** @description Status ids. Repeat the parameter or separate with commas */ + status?: string[]; + /** @description Whatever each project calls its statuses */ + category?: components['schemas']['StatusCategory'][]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['IssuePageDto']; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + }; + }; + IssuesController_search: { + parameters: { + query?: { + limit?: number; + offset?: number; + /** @description Status ids. Repeat the parameter or separate with commas */ + status?: string[]; + category?: components['schemas']['StatusCategory'][]; + /** @description Ticket type ids */ + type?: string[]; + /** @description Area ids */ + area?: string[]; + priority?: components['schemas']['IssuePriority'][]; + /** @description Pireon user id */ + assigneeId?: string; + /** @description Only tickets without assignee */ + unassigned?: boolean; + releaseId?: string; + label?: string[]; + /** @description Matches key, title or description */ + search?: string; + }; + header?: never; + path: { + /** @description Project id or key */ + project: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['IssuePageDto']; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + }; + }; + IssuesController_create: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project id or key */ + project: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['CreateIssueDto']; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['CommandResultDto']; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + }; + }; + IssuesController_findOne: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Ticket id or key */ + issue: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['IssueDto']; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + }; + }; + IssuesController_update: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Ticket id or key */ + issue: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['UpdateIssueDto']; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['CommandResultDto']; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + }; + }; + IssuesController_changeStatus: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Ticket id or key */ + issue: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['ChangeStatusDto']; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['CommandResultDto']; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + }; + }; + IssuesController_assign: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Ticket id or key */ + issue: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['AssignIssueDto']; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['CommandResultDto']; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + }; + }; + IssuesController_setRelease: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Ticket id or key */ + issue: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['SetIssueReleaseDto']; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['CommandResultDto']; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + }; + }; + IssuesController_link: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Ticket id or key */ + issue: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['LinkIssueDto']; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['CommandResultDto']; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + }; + }; + IssuesController_unlink: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Ticket id or key */ + issue: string; + type: components['schemas']['IssueLinkType']; + /** @description Other ticket: id or key */ + target: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['CommandResultDto']; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + }; + }; + IssuesController_listComments: { + parameters: { + query?: { + limit?: number; + offset?: number; + }; + header?: never; + path: { + /** @description Ticket id or key */ + issue: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['CommentPageDto']; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + }; + }; + IssuesController_addComment: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Ticket id or key */ + issue: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['CommentBodyDto']; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['CommandResultDto']; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + }; + }; + IssuesController_history: { + parameters: { + query?: { + limit?: number; + offset?: number; + }; + header?: never; + path: { + /** @description Ticket id or key */ + issue: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ActivityDto'][]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + }; + }; + CommentsController_delete: { + parameters: { + query?: never; + header?: never; + path: { + commentId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['CommandResultDto']; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + }; + }; + CommentsController_edit: { + parameters: { + query?: never; + header?: never; + path: { + commentId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['CommentBodyDto']; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['CommandResultDto']; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + }; + }; + ReleasesController_upcoming: { + parameters: { + query?: { + limit?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ReleaseDto'][]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + }; + }; + ReleasesController_list: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project id or key */ + project: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ReleaseDto'][]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + }; + }; + ReleasesController_create: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project id or key */ + project: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['CreateReleaseDto']; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['CommandResultDto']; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + }; + }; + ReleasesController_findOne: { + parameters: { + query?: never; + header?: never; + path: { + releaseId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ReleaseDto']; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + }; + }; + ReleasesController_update: { + parameters: { + query?: never; + header?: never; + path: { + releaseId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['UpdateReleaseDto']; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['CommandResultDto']; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + }; + }; + ReleasesController_progress: { + parameters: { + query?: never; + header?: never; + path: { + releaseId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ReleaseProgressDto']; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + }; + }; + ReleasesController_burnup: { + parameters: { + query?: never; + header?: never; + path: { + releaseId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ReleaseBurnupDto']; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + }; + }; + ReleasesController_issuesOf: { + parameters: { + query?: { + limit?: number; + offset?: number; + }; + header?: never; + path: { + releaseId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['IssuePageDto']; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + }; + }; + ReleasesController_publish: { + parameters: { + query?: never; + header?: never; + path: { + releaseId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['PublishReleaseDto']; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['CommandResultDto']; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + }; + }; + ReleasesController_archive: { + parameters: { + query?: never; + header?: never; + path: { + releaseId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['CommandResultDto']; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ApiErrorDto']; + }; + }; + }; + }; + ExploreController_publicProjects: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ProjectDto'][]; + }; + }; + }; + }; +} diff --git a/apps/mcp/src/app/app.module.ts b/apps/mcp/src/app/app.module.ts new file mode 100644 index 0000000..0c90528 --- /dev/null +++ b/apps/mcp/src/app/app.module.ts @@ -0,0 +1,48 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { McpModule } from '@nestjs-mcp/server'; +import { TASKSTREAM_API_URL, TaskStreamApi } from './api/taskstream-api'; +import { HealthController } from './health.controller'; +import { IssuesResolver } from './tools/issues.resolver'; +import { ProjectsResolver } from './tools/projects.resolver'; +import { ReleasesResolver } from './tools/releases.resolver'; +import { UsersResolver } from './tools/users.resolver'; + +const INSTRUCTIONS = `TaskStream: issue tracker (projects, tickets, QA, releases). +Projects and tickets go by key (WEB, WEB-12). Statuses, ticket types and areas are per +project: read their ids with get_project before creating, updating or moving tickets, and +use a ticket's allowedTransitions (get_issue) to move it. Tools act as the user whose Pireon +token the client sends; without one, only public projects can be read.`; + +@Module({ + imports: [ + ConfigModule.forRoot({ isGlobal: true }), + McpModule.forRootAsync({ + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + name: 'taskstream', + version: '1.0.0', + instructions: INSTRUCTIONS, + logging: { level: config.get('MCP_LOG_LEVEL', 'log') }, + }), + }), + ], + controllers: [HealthController], + providers: [ + { + provide: TASKSTREAM_API_URL, + inject: [ConfigService], + // Origin of the API (its routes already start with /api). + useFactory: (config: ConfigService) => + config + .get('TASKSTREAM_API_URL', 'http://localhost:3000') + .replace(/\/+$/, ''), + }, + TaskStreamApi, + ProjectsResolver, + IssuesResolver, + ReleasesResolver, + UsersResolver, + ], +}) +export class AppModule {} diff --git a/apps/mcp/src/app/health.controller.ts b/apps/mcp/src/app/health.controller.ts new file mode 100644 index 0000000..5f85b36 --- /dev/null +++ b/apps/mcp/src/app/health.controller.ts @@ -0,0 +1,10 @@ +import { Controller, Get } from '@nestjs/common'; + +/** For Docker / Dokploy healthchecks. */ +@Controller('health') +export class HealthController { + @Get() + check() { + return { status: 'ok' }; + } +} diff --git a/apps/mcp/src/app/mcp.spec.ts b/apps/mcp/src/app/mcp.spec.ts new file mode 100644 index 0000000..3cce7d0 --- /dev/null +++ b/apps/mcp/src/app/mcp.spec.ts @@ -0,0 +1,214 @@ +import { INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { createServer, IncomingMessage, Server } from 'node:http'; +import { AddressInfo } from 'node:net'; +import { TASKSTREAM_API_URL } from './api/taskstream-api'; +import { AppModule } from './app.module'; + +/** What the fake API received. */ +interface Received { + method: string; + url: string; + authorization?: string; + body: unknown; +} + +const project = { + id: 'p1', + key: 'WEB', + name: 'Web', + description: null, + isPublic: false, + owner: { id: '101', name: 'Ana' }, + members: [{ user: { id: '101', name: 'Ana' }, role: 'OWNER' }], + settings: { + statuses: [ + { + id: 'TODO', + name: 'Por hacer', + category: 'TODO', + color: 'slate', + requiresReason: false, + archived: false, + }, + ], + transitions: [], + issueTypes: [ + { id: 'BUG', name: 'Bug', icon: 'bug', color: 'red', archived: false }, + ], + areas: [], + }, + createdAt: '2026-10-01T00:00:00Z', + updatedAt: '2026-10-02T00:00:00Z', + version: 3, +}; + +/** A stand-in for the task-stream API: answers a few routes and records every call. */ +function fakeApi(received: Received[]): Server { + return createServer(async (req: IncomingMessage, res) => { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + const text = Buffer.concat(chunks).toString(); + received.push({ + method: req.method ?? '', + url: req.url ?? '', + authorization: req.headers.authorization, + body: text ? JSON.parse(text) : undefined, + }); + const reply = (status: number, body: unknown) => { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(body)); + }; + if (req.url === '/api/projects/WEB') return reply(200, project); + if (req.url === '/api/projects/SECRET') + return reply(401, { statusCode: 401, message: 'Missing bearer token' }); + if (req.url === '/api/issues/WEB-1/status') + return reply(200, { id: 'i1', key: 'WEB-1', version: 4 }); + if (req.url === '/api/issues/WEB-1/status-denied') + return reply(422, { + statusCode: 422, + code: 'BUSINESS_RULE', + message: 'Cannot move', + }); + return reply(404, { statusCode: 404, message: 'Not found' }); + }); +} + +describe('TaskStream MCP', () => { + let app: INestApplication; + let api: Server; + let url: string; + const received: Received[] = []; + + /** One JSON-RPC call over Streamable HTTP (answers come as an SSE event). */ + async function rpc( + method: string, + params: object, + token?: string, + ): Promise> { + const response = await fetch(`${url}/mcp`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'mcp-protocol-version': '2025-06-18', + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), + }); + const text = await response.text(); + const data = text.startsWith('{') + ? text + : (text.split('\n').find((line) => line.startsWith('data:')) ?? '').slice( + 5, + ); + return JSON.parse(data).result; + } + + const call = (name: string, args: object, token?: string) => + rpc('tools/call', { name, arguments: args }, token) as Promise<{ + isError?: boolean; + content: { type: string; text: string }[]; + }>; + + beforeAll(async () => { + api = fakeApi(received); + await new Promise((resolve) => api.listen(0, resolve)); + const apiUrl = `http://127.0.0.1:${(api.address() as AddressInfo).port}`; + const moduleRef = await Test.createTestingModule({ imports: [AppModule] }) + .overrideProvider(TASKSTREAM_API_URL) + .useValue(apiUrl) + .compile(); + app = moduleRef.createNestApplication({ logger: false }); + await app.listen(0); + url = (await app.getUrl()).replace('[::1]', '127.0.0.1'); + }); + + afterAll(async () => { + await app?.close(); + await new Promise((resolve) => api?.close(resolve)); + }); + + beforeEach(() => (received.length = 0)); + + it('lists the tools, read-only ones marked as such', async () => { + const { tools } = (await rpc('tools/list', {})) as { + tools: { name: string; annotations?: { readOnlyHint?: boolean } }[]; + }; + const names = tools.map((t) => t.name); + expect(names).toEqual( + expect.arrayContaining([ + 'list_my_projects', + 'get_project', + 'get_board', + 'search_issues', + 'get_issue', + 'create_issue', + 'change_issue_status', + 'get_release', + 'whoami', + ]), + ); + const byName = new Map(tools.map((t) => [t.name, t])); + expect(byName.get('get_project')?.annotations?.readOnlyHint).toBe(true); + expect(byName.get('create_issue')?.annotations?.readOnlyHint).toBe(false); + }); + + it("calls the API with the caller's own token and compacts the answer", async () => { + const result = await call( + 'get_project', + { project: 'WEB' }, + 'pireon-token', + ); + + expect(received).toEqual([ + expect.objectContaining({ + method: 'GET', + url: '/api/projects/WEB', + authorization: 'Bearer pireon-token', + }), + ]); + expect(JSON.parse(result.content[0].text)).toMatchObject({ + key: 'WEB', + owner: 'Ana', + members: [{ id: '101', name: 'Ana', role: 'OWNER' }], + statuses: [{ id: 'TODO', category: 'TODO' }], + issueTypes: [{ id: 'BUG', name: 'Bug' }], + }); + }); + + it('sends no credentials when the client has none (public projects only)', async () => { + await call('get_project', { project: 'WEB' }); + expect(received[0].authorization).toBeUndefined(); + }); + + it('turns API refusals into tool errors the model can read', async () => { + const result = await call('get_project', { project: 'SECRET' }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/Error 401: Missing bearer token/); + expect(result.content[0].text).toMatch(/Authorization: Bearer/); + }); + + it('writes through the REST API', async () => { + const result = await call( + 'change_issue_status', + { issue: 'WEB-1', status: 'QA_FAILED', reason: 'Falla en Safari' }, + 'pireon-token', + ); + expect(result.isError).toBeUndefined(); + expect(received[0]).toMatchObject({ + method: 'PUT', + url: '/api/issues/WEB-1/status', + body: { status: 'QA_FAILED', reason: 'Falla en Safari' }, + }); + }); + + it('validates the arguments before calling the API', async () => { + const result = await call('get_project_analytics', { + project: 'WEB', + days: 2, + }); + expect(result.isError).toBe(true); + expect(received).toHaveLength(0); + }); +}); diff --git a/apps/mcp/src/app/tools/issues.resolver.ts b/apps/mcp/src/app/tools/issues.resolver.ts new file mode 100644 index 0000000..8ca5525 --- /dev/null +++ b/apps/mcp/src/app/tools/issues.resolver.ts @@ -0,0 +1,402 @@ +import { McpContext, Resolver, Tool } from '@nestjs-mcp/server'; +import { z } from 'zod'; +import { TaskStreamApi } from '../api/taskstream-api'; +import { issueRef, limit, projectRef } from './params'; +import { issueLine } from './summaries'; +import { toolResult } from './tool-result'; + +const READ = { readOnlyHint: true, openWorldHint: false } as const; +const WRITE = { + readOnlyHint: false, + destructiveHint: false, + openWorldHint: false, +} as const; + +const PRIORITY = z.enum(['LOWEST', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL']); +const CATEGORY = z.enum(['TODO', 'IN_PROGRESS', 'QA', 'DONE', 'CANCELLED']); +const LINK_TYPE = z.enum([ + 'BLOCKS', + 'DEPENDS_ON', + 'TESTS', + 'DUPLICATES', + 'RELATES_TO', +]); + +const ids = (what: string) => + z + .array(z.string()) + .optional() + .describe(`${what} ids of the project's settings (see get_project)`); + +const issueFields = { + description: z.string().max(50000).optional(), + area: z.string().optional().describe('Area id (see get_project)'), + priority: PRIORITY.optional(), + labels: z.array(z.string().max(40)).max(20).optional(), + figmaUrl: z.string().url().optional().describe('Figma frame (https)'), + storyPoints: z.number().int().min(0).max(100).optional(), + dueDate: z.string().optional().describe('ISO date, e.g. 2026-11-14'), +}; + +type IssueFields = { + description?: string; + area?: string; + priority?: z.infer; + labels?: string[]; + figmaUrl?: string; + storyPoints?: number; + dueDate?: string; +}; + +@Resolver() +export class IssuesResolver { + constructor(private readonly api: TaskStreamApi) {} + + /* ---- Reading ---- */ + + @Tool({ + name: 'my_issues', + description: + "Tickets across all the user's projects: assigned to them (default) or anyone's, filtered by status category (e.g. QA to see what waits for testing).", + paramsSchema: z.object({ + assignedToMe: z.boolean().default(true), + categories: z + .array(CATEGORY) + .optional() + .describe('Default: open ones (TODO, IN_PROGRESS, QA)'), + limit: limit(200, 50), + }), + annotations: READ, + }) + mine( + params: { + assignedToMe: boolean; + categories?: z.infer[]; + limit: number; + }, + ctx: McpContext, + ) { + return toolResult(async () => { + const page = await this.api.data( + this.api.as(ctx).GET('/api/me/issues', { + params: { + query: { + assignedToMe: params.assignedToMe, + category: params.categories ?? ['TODO', 'IN_PROGRESS', 'QA'], + limit: params.limit, + }, + }, + }), + ); + return { total: page.total, items: page.items.map(issueLine) }; + }); + } + + @Tool({ + name: 'search_issues', + description: + 'Search the tickets of a project by text (key, title, description) and filters.', + paramsSchema: z.object({ + project: projectRef, + search: z.string().max(200).optional(), + statuses: ids('Status'), + categories: z.array(CATEGORY).optional(), + types: ids('Ticket type'), + areas: ids('Area'), + priorities: z.array(PRIORITY).optional(), + assigneeId: z.string().optional().describe('User id'), + unassigned: z.boolean().optional(), + releaseId: z.string().optional(), + labels: z.array(z.string()).optional(), + limit: limit(200, 30), + offset: z.number().int().min(0).default(0), + }), + annotations: READ, + }) + search( + params: { + project: string; + search?: string; + statuses?: string[]; + categories?: z.infer[]; + types?: string[]; + areas?: string[]; + priorities?: z.infer[]; + assigneeId?: string; + unassigned?: boolean; + releaseId?: string; + labels?: string[]; + limit: number; + offset: number; + }, + ctx: McpContext, + ) { + return toolResult(async () => { + const page = await this.api.data( + this.api.as(ctx).GET('/api/projects/{project}/issues', { + params: { + path: { project: params.project }, + query: { + search: params.search, + status: params.statuses, + category: params.categories, + type: params.types, + area: params.areas, + priority: params.priorities, + assigneeId: params.assigneeId, + unassigned: params.unassigned, + releaseId: params.releaseId, + label: params.labels, + limit: params.limit, + offset: params.offset, + }, + }, + }), + ); + return { total: page.total, items: page.items.map(issueLine) }; + }); + } + + @Tool({ + name: 'get_issue', + description: + 'A ticket in full: description, links, allowed next statuses, and optionally its comments and history.', + paramsSchema: z.object({ + issue: issueRef, + withComments: z.boolean().default(true), + withHistory: z.boolean().default(false), + }), + annotations: READ, + }) + getIssue( + params: { issue: string; withComments: boolean; withHistory: boolean }, + ctx: McpContext, + ) { + return toolResult(async () => { + const client = this.api.as(ctx); + const path = { issue: params.issue }; + const issue = await this.api.data( + client.GET('/api/issues/{issue}', { params: { path } }), + ); + const comments = params.withComments + ? ( + await this.api.data( + client.GET('/api/issues/{issue}/comments', { + params: { path, query: { limit: 100 } }, + }), + ) + ).items.map((c) => ({ + id: c.id, + author: c.author.name, + at: c.createdAt, + body: c.body, + })) + : undefined; + const history = params.withHistory + ? ( + await this.api.data( + client.GET('/api/issues/{issue}/history', { + params: { path, query: { limit: 100 } }, + }), + ) + ).map((a) => ({ at: a.occurredAt, summary: a.summary })) + : undefined; + return { + ...issueLine(issue), + description: issue.description, + reporter: issue.reporter.name, + figmaUrl: issue.figmaUrl, + storyPoints: issue.storyPoints, + dueDate: issue.dueDate, + allowedTransitions: issue.allowedTransitions, + links: issue.links, + createdAt: issue.createdAt, + comments, + history, + }; + }); + } + + /* ---- Writing ---- */ + + @Tool({ + name: 'create_issue', + description: + 'Create a ticket. Type and area are ids of the project settings (see get_project); it starts in the first pending status.', + paramsSchema: z.object({ + project: projectRef, + type: z.string().describe('Ticket type id, e.g. BUG'), + title: z.string().min(1).max(255), + assigneeId: z.string().optional().describe('User id (a project member)'), + releaseId: z.string().optional(), + ...issueFields, + }), + annotations: WRITE, + }) + create( + params: IssueFields & { + project: string; + type: string; + title: string; + assigneeId?: string; + releaseId?: string; + }, + ctx: McpContext, + ) { + const { project, ...body } = params; + return toolResult(() => + this.api.data( + this.api.as(ctx).POST('/api/projects/{project}/issues', { + params: { path: { project } }, + body, + }), + ), + ); + } + + @Tool({ + name: 'update_issue', + description: 'Change the title, description, type, area, priority, labels…', + paramsSchema: z.object({ + issue: issueRef, + title: z.string().min(1).max(255).optional(), + type: z.string().optional().describe('Ticket type id'), + ...issueFields, + }), + annotations: { ...WRITE, idempotentHint: true }, + }) + update( + params: IssueFields & { issue: string; title?: string; type?: string }, + ctx: McpContext, + ) { + const { issue, ...body } = params; + return toolResult(() => + this.api.data( + this.api.as(ctx).PATCH('/api/issues/{issue}', { + params: { path: { issue } }, + body, + }), + ), + ); + } + + @Tool({ + name: 'change_issue_status', + description: + "Move a ticket to another status. It must be one of the ticket's allowedTransitions (see get_issue); some statuses need a reason.", + paramsSchema: z.object({ + issue: issueRef, + status: z.string().describe('Status id, e.g. IN_PROGRESS'), + reason: z.string().max(5000).optional(), + }), + annotations: WRITE, + }) + changeStatus( + { + issue, + status, + reason, + }: { issue: string; status: string; reason?: string }, + ctx: McpContext, + ) { + return toolResult(() => + this.api.data( + this.api.as(ctx).PUT('/api/issues/{issue}/status', { + params: { path: { issue } }, + body: { status, reason }, + }), + ), + ); + } + + @Tool({ + name: 'assign_issue', + description: 'Assign a ticket to a project member, or unassign it (null).', + paramsSchema: z.object({ + issue: issueRef, + assigneeId: z.string().nullable().describe('User id, or null'), + }), + annotations: { ...WRITE, idempotentHint: true }, + }) + assign( + { issue, assigneeId }: { issue: string; assigneeId: string | null }, + ctx: McpContext, + ) { + return toolResult(() => + this.api.data( + this.api.as(ctx).PUT('/api/issues/{issue}/assignee', { + params: { path: { issue } }, + body: { assigneeId }, + }), + ), + ); + } + + @Tool({ + name: 'set_issue_release', + description: 'Put a ticket in a planned release, or take it out (null).', + paramsSchema: z.object({ + issue: issueRef, + releaseId: z.string().nullable(), + }), + annotations: { ...WRITE, idempotentHint: true }, + }) + setRelease( + { issue, releaseId }: { issue: string; releaseId: string | null }, + ctx: McpContext, + ) { + return toolResult(() => + this.api.data( + this.api.as(ctx).PUT('/api/issues/{issue}/release', { + params: { path: { issue } }, + body: { releaseId }, + }), + ), + ); + } + + @Tool({ + name: 'add_comment', + description: 'Comment on a ticket (decisions, explanations, steps).', + paramsSchema: z.object({ + issue: issueRef, + body: z.string().min(1).max(20000), + }), + annotations: WRITE, + }) + comment({ issue, body }: { issue: string; body: string }, ctx: McpContext) { + return toolResult(() => + this.api.data( + this.api.as(ctx).POST('/api/issues/{issue}/comments', { + params: { path: { issue } }, + body: { body }, + }), + ), + ); + } + + @Tool({ + name: 'link_issues', + description: + 'Relate two tickets, e.g. WEB-3 DEPENDS_ON API-7 (they may be in different projects).', + paramsSchema: z.object({ + issue: issueRef, + type: LINK_TYPE, + target: issueRef, + }), + annotations: WRITE, + }) + link( + params: { issue: string; type: z.infer; target: string }, + ctx: McpContext, + ) { + return toolResult(() => + this.api.data( + this.api.as(ctx).POST('/api/issues/{issue}/links', { + params: { path: { issue: params.issue } }, + body: { type: params.type, targetIssue: params.target }, + }), + ), + ); + } +} diff --git a/apps/mcp/src/app/tools/params.ts b/apps/mcp/src/app/tools/params.ts new file mode 100644 index 0000000..88b8011 --- /dev/null +++ b/apps/mcp/src/app/tools/params.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +/** Shared parameter schemas (descriptions are what the model reads). */ +export const projectRef = z + .string() + .min(1) + .describe('Project key (e.g. "WEB") or id'); + +export const issueRef = z + .string() + .min(1) + .describe('Ticket key (e.g. "WEB-12") or id'); + +export const releaseId = z.string().min(1).describe('Release id'); + +export const limit = (max: number, fallback: number) => + z + .number() + .int() + .min(1) + .max(max) + .default(fallback) + .describe(`How many (1-${max})`); diff --git a/apps/mcp/src/app/tools/projects.resolver.ts b/apps/mcp/src/app/tools/projects.resolver.ts new file mode 100644 index 0000000..b046e5a --- /dev/null +++ b/apps/mcp/src/app/tools/projects.resolver.ts @@ -0,0 +1,170 @@ +import { McpContext, Resolver, Tool } from '@nestjs-mcp/server'; +import { z } from 'zod'; +import { TaskStreamApi } from '../api/taskstream-api'; +import { limit, projectRef } from './params'; +import { issueLine, projectLine, projectSettings } from './summaries'; +import { toolResult } from './tool-result'; + +const READ = { readOnlyHint: true, openWorldHint: false } as const; + +@Resolver() +export class ProjectsResolver { + constructor(private readonly api: TaskStreamApi) {} + + @Tool({ + name: 'list_my_projects', + description: 'Projects where the signed-in user is a member.', + annotations: READ, + }) + listMine(ctx: McpContext) { + return toolResult(async () => + (await this.api.data(this.api.as(ctx).GET('/api/projects'))).map( + projectLine, + ), + ); + } + + @Tool({ + name: 'list_public_projects', + description: 'Public projects: anyone can read them, also without a token.', + annotations: READ, + }) + listPublic(ctx: McpContext) { + return toolResult(async () => + (await this.api.data(this.api.as(ctx).GET('/api/explore/projects'))).map( + projectLine, + ), + ); + } + + @Tool({ + name: 'get_project', + description: + 'A project with its team and settings: the status, ticket type and area ids that the other tools accept, and which statuses need a reason.', + paramsSchema: z.object({ project: projectRef }), + annotations: READ, + }) + getProject({ project }: { project: string }, ctx: McpContext) { + return toolResult(async () => { + const found = await this.api.data( + this.api + .as(ctx) + .GET('/api/projects/{project}', { params: { path: { project } } }), + ); + return { + ...projectLine(found), + members: found.members.map((m) => ({ + id: m.user.id, + name: m.user.name, + role: m.role, + })), + ...projectSettings(found), + }; + }); + } + + @Tool({ + name: 'get_board', + description: + 'Kanban board: one column per status (in board order) with its tickets.', + paramsSchema: z.object({ + project: projectRef, + assigneeId: z.string().optional().describe('Only this person (user id)'), + releaseId: z.string().optional().describe('Only this release'), + area: z.string().optional().describe('Only this area id'), + perColumn: limit(200, 25), + }), + annotations: READ, + }) + board( + params: { + project: string; + assigneeId?: string; + releaseId?: string; + area?: string; + perColumn: number; + }, + ctx: McpContext, + ) { + const { project, ...query } = params; + return toolResult(async () => + ( + await this.api.data( + this.api.as(ctx).GET('/api/projects/{project}/board', { + params: { path: { project }, query }, + }), + ) + ).map((column) => ({ + status: column.status, + total: column.total, + issues: column.issues.map(issueLine), + })), + ); + } + + @Tool({ + name: 'get_project_stats', + description: + 'Tracking numbers: total and open tickets, by status, type, priority, area and open tickets per person.', + paramsSchema: z.object({ project: projectRef }), + annotations: READ, + }) + stats({ project }: { project: string }, ctx: McpContext) { + return toolResult(() => + this.api.data( + this.api.as(ctx).GET('/api/projects/{project}/stats', { + params: { path: { project } }, + }), + ), + ); + } + + @Tool({ + name: 'get_project_analytics', + description: + 'Statistics over the last days: created vs resolved per week, cumulative flow, cycle and lead time (average and p85), throughput per person and QA pass/fail rate.', + paramsSchema: z.object({ + project: projectRef, + days: z.number().int().min(7).max(365).default(90), + }), + annotations: READ, + }) + analytics( + { project, days }: { project: string; days: number }, + ctx: McpContext, + ) { + return toolResult(() => + this.api.data( + this.api.as(ctx).GET('/api/projects/{project}/analytics', { + params: { path: { project }, query: { days } }, + }), + ), + ); + } + + @Tool({ + name: 'get_project_activity', + description: 'Latest activity of a project (newest first).', + paramsSchema: z.object({ project: projectRef, limit: limit(200, 30) }), + annotations: READ, + }) + activity( + { project, limit }: { project: string; limit: number }, + ctx: McpContext, + ) { + return toolResult(async () => + ( + await this.api.data( + this.api.as(ctx).GET('/api/projects/{project}/activity', { + params: { path: { project }, query: { limit } }, + }), + ) + ).map((a) => ({ + at: a.occurredAt, + who: a.actor.name, + summary: a.summary, + issue: a.issueKey, + })), + ); + } +} diff --git a/apps/mcp/src/app/tools/releases.resolver.ts b/apps/mcp/src/app/tools/releases.resolver.ts new file mode 100644 index 0000000..7339f75 --- /dev/null +++ b/apps/mcp/src/app/tools/releases.resolver.ts @@ -0,0 +1,151 @@ +import { McpContext, Resolver, Tool } from '@nestjs-mcp/server'; +import { z } from 'zod'; +import { TaskStreamApi } from '../api/taskstream-api'; +import { limit, projectRef, releaseId } from './params'; +import { issueLine, releaseLine } from './summaries'; +import { toolResult } from './tool-result'; + +const READ = { readOnlyHint: true, openWorldHint: false } as const; + +@Resolver() +export class ReleasesResolver { + constructor(private readonly api: TaskStreamApi) {} + + @Tool({ + name: 'list_releases', + description: 'Releases of a project (planned, released and archived).', + paramsSchema: z.object({ project: projectRef }), + annotations: READ, + }) + list({ project }: { project: string }, ctx: McpContext) { + return toolResult(async () => + ( + await this.api.data( + this.api.as(ctx).GET('/api/projects/{project}/releases', { + params: { path: { project } }, + }), + ) + ).map(releaseLine), + ); + } + + @Tool({ + name: 'upcoming_releases', + description: + "Planned releases of all the user's projects, soonest target date first.", + paramsSchema: z.object({ limit: limit(50, 10) }), + annotations: READ, + }) + upcoming({ limit }: { limit: number }, ctx: McpContext) { + return toolResult(async () => + ( + await this.api.data( + this.api + .as(ctx) + .GET('/api/me/releases', { params: { query: { limit } } }), + ) + ).map((release) => ({ + ...releaseLine(release), + projectId: release.projectId, + })), + ); + } + + @Tool({ + name: 'get_release', + description: + 'A release with its progress (finished out of total), its tickets and, optionally, the day-by-day burnup.', + paramsSchema: z.object({ + releaseId, + withBurnup: z.boolean().default(false), + }), + annotations: READ, + }) + get(params: { releaseId: string; withBurnup: boolean }, ctx: McpContext) { + return toolResult(async () => { + const client = this.api.as(ctx); + const path = { releaseId: params.releaseId }; + const [release, progress, issues] = await Promise.all([ + this.api.data( + client.GET('/api/releases/{releaseId}', { params: { path } }), + ), + this.api.data( + client.GET('/api/releases/{releaseId}/progress', { + params: { path }, + }), + ), + this.api.data( + client.GET('/api/releases/{releaseId}/issues', { + params: { path, query: { limit: 200 } }, + }), + ), + ]); + const burnup = params.withBurnup + ? await this.api.data( + client.GET('/api/releases/{releaseId}/burnup', { + params: { path }, + }), + ) + : undefined; + return { + ...releaseLine(release), + description: release.description, + progress, + issues: issues.items.map(issueLine), + burnup, + }; + }); + } + + @Tool({ + name: 'create_release', + description: 'Plan a release in a project (owners and admins).', + paramsSchema: z.object({ + project: projectRef, + name: z.string().min(1).max(60).describe('e.g. v1.4.0'), + description: z.string().max(5000).optional(), + targetDate: z.string().optional().describe('ISO date'), + }), + annotations: { readOnlyHint: false, destructiveHint: false }, + }) + create( + params: { + project: string; + name: string; + description?: string; + targetDate?: string; + }, + ctx: McpContext, + ) { + const { project, ...body } = params; + return toolResult(() => + this.api.data( + this.api.as(ctx).POST('/api/projects/{project}/releases', { + params: { path: { project } }, + body, + }), + ), + ); + } + + @Tool({ + name: 'publish_release', + description: + 'Ship a planned release. Fails while it has unfinished tickets unless force is true.', + paramsSchema: z.object({ releaseId, force: z.boolean().default(false) }), + annotations: { readOnlyHint: false, destructiveHint: false }, + }) + publish( + { releaseId, force }: { releaseId: string; force: boolean }, + ctx: McpContext, + ) { + return toolResult(() => + this.api.data( + this.api.as(ctx).POST('/api/releases/{releaseId}/publish', { + params: { path: { releaseId } }, + body: { force }, + }), + ), + ); + } +} diff --git a/apps/mcp/src/app/tools/summaries.ts b/apps/mcp/src/app/tools/summaries.ts new file mode 100644 index 0000000..e672cf7 --- /dev/null +++ b/apps/mcp/src/app/tools/summaries.ts @@ -0,0 +1,63 @@ +import type { Schemas } from '../api/taskstream-api'; + +/* + * Compact views for listings: what the model needs to reason, without ids + * and nested objects it rarely uses (full detail through the get_* tools). + */ + +type Issue = Schemas['IssueDto']; +type Project = Schemas['ProjectDto']; +type Release = Schemas['ReleaseDto']; + +export const issueLine = (issue: Issue) => ({ + key: issue.key, + title: issue.title, + type: issue.type, + status: issue.status, + statusCategory: issue.statusCategory, + priority: issue.priority, + area: issue.area, + assignee: issue.assignee?.name ?? null, + release: issue.release?.name ?? null, + labels: issue.labels, + updatedAt: issue.updatedAt, +}); + +export const projectLine = (project: Project) => ({ + key: project.key, + name: project.name, + description: project.description, + isPublic: project.isPublic, + owner: project.owner.name, + members: project.members.length, + updatedAt: project.updatedAt, +}); + +/** Statuses, types and areas by id: what create/update/move tools accept. */ +export const projectSettings = (project: Project) => ({ + statuses: project.settings.statuses.map((s) => ({ + id: s.id, + name: s.name, + category: s.category, + requiresReason: s.requiresReason, + archived: s.archived, + })), + issueTypes: project.settings.issueTypes.map((t) => ({ + id: t.id, + name: t.name, + archived: t.archived, + })), + areas: project.settings.areas.map((a) => ({ + id: a.id, + name: a.name, + archived: a.archived, + })), +}); + +export const releaseLine = (release: Release) => ({ + id: release.id, + name: release.name, + status: release.status, + targetDate: release.targetDate, + releasedAt: release.releasedAt, +}); diff --git a/apps/mcp/src/app/tools/tool-result.ts b/apps/mcp/src/app/tools/tool-result.ts new file mode 100644 index 0000000..c05264f --- /dev/null +++ b/apps/mcp/src/app/tools/tool-result.ts @@ -0,0 +1,34 @@ +import type { CallToolResult } from '@modelcontextprotocol/server'; +import { ApiError } from '../api/taskstream-api'; + +/** + * Runs a tool and answers with its result as JSON text, or, if the API + * refuses it, with an error result the model can read and act on (instead of + * a protocol error that aborts the call). + */ +export async function toolResult( + run: () => Promise, +): Promise { + try { + const result = await run(); + return { + content: [ + { + type: 'text', + text: + typeof result === 'string' + ? result + : JSON.stringify(result ?? { ok: true }, null, 2), + }, + ], + }; + } catch (error) { + if (!(error instanceof ApiError)) throw error; + return { + isError: true, + content: [ + { type: 'text', text: `Error ${error.status}: ${error.message}` }, + ], + }; + } +} diff --git a/apps/mcp/src/app/tools/users.resolver.ts b/apps/mcp/src/app/tools/users.resolver.ts new file mode 100644 index 0000000..3fbcef0 --- /dev/null +++ b/apps/mcp/src/app/tools/users.resolver.ts @@ -0,0 +1,49 @@ +import { McpContext, Resolver, Tool } from '@nestjs-mcp/server'; +import { z } from 'zod'; +import { TaskStreamApi } from '../api/taskstream-api'; +import { limit } from './params'; +import { toolResult } from './tool-result'; + +const READ = { readOnlyHint: true, openWorldHint: false } as const; + +@Resolver() +export class UsersResolver { + constructor(private readonly api: TaskStreamApi) {} + + @Tool({ + name: 'whoami', + description: + 'The user this MCP acts as (from the Pireon token the client sends).', + annotations: READ, + }) + whoami(ctx: McpContext) { + return toolResult(() => + this.api.data(this.api.as(ctx).GET('/api/users/me')), + ); + } + + @Tool({ + name: 'search_users', + description: + 'Find Pireon users by name or email (to assign tickets: use their id).', + paramsSchema: z.object({ + search: z.string().min(2), + limit: limit(50, 10), + }), + annotations: READ, + }) + search( + { search, limit }: { search: string; limit: number }, + ctx: McpContext, + ) { + return toolResult(async () => + ( + await this.api.data( + this.api + .as(ctx) + .GET('/api/users', { params: { query: { search, limit } } }), + ) + ).map((u) => ({ id: u.id, name: u.name, email: u.email })), + ); + } +} diff --git a/apps/mcp/src/assets/.gitkeep b/apps/mcp/src/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/mcp/src/main.ts b/apps/mcp/src/main.ts new file mode 100644 index 0000000..57fa416 --- /dev/null +++ b/apps/mcp/src/main.ts @@ -0,0 +1,15 @@ +import { Logger } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app/app.module'; + +/** MCP server of TaskStream over Streamable HTTP: `POST /mcp`. */ +async function bootstrap() { + const app = await NestFactory.create(AppModule); + app.enableShutdownHooks(); + // Not PORT: Nx loads the workspace .env into every task, and there PORT is the API's. + const port = Number(process.env['MCP_PORT'] ?? 3010); + await app.listen(port); + Logger.log(`🔌 MCP: http://localhost:${port}/mcp`, 'Bootstrap'); +} + +void bootstrap(); diff --git a/apps/mcp/tsconfig.app.json b/apps/mcp/tsconfig.app.json new file mode 100644 index 0000000..540251e --- /dev/null +++ b/apps/mcp/tsconfig.app.json @@ -0,0 +1,24 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["node"], + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "target": "es2021", + "moduleResolution": "bundler", + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts"], + "exclude": [ + "jest.config.ts", + "jest.config.cts", + "src/**/*.spec.ts", + "src/**/*.test.ts" + ] +} diff --git a/apps/mcp/tsconfig.json b/apps/mcp/tsconfig.json new file mode 100644 index 0000000..c1e2dd4 --- /dev/null +++ b/apps/mcp/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "compilerOptions": { + "esModuleInterop": true + } +} diff --git a/apps/mcp/tsconfig.spec.json b/apps/mcp/tsconfig.spec.json new file mode 100644 index 0000000..56d0d69 --- /dev/null +++ b/apps/mcp/tsconfig.spec.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "moduleResolution": "bundler", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "jest.config.cts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/apps/mcp/webpack.config.js b/apps/mcp/webpack.config.js new file mode 100644 index 0000000..36d6869 --- /dev/null +++ b/apps/mcp/webpack.config.js @@ -0,0 +1,25 @@ +const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin'); +const { join } = require('path'); + +module.exports = { + output: { + path: join(__dirname, '../../dist/apps/mcp'), + clean: true, + ...(process.env.NODE_ENV !== 'production' && { + devtoolModuleFilenameTemplate: '[absolute-resource-path]', + }), + }, + plugins: [ + new NxAppWebpackPlugin({ + target: 'node', + compiler: 'tsc', + main: './src/main.ts', + tsConfig: './tsconfig.app.json', + assets: ['./src/assets'], + optimization: false, + outputHashing: 'none', + generatePackageJson: true, + sourceMap: true, + }), + ], +}; diff --git a/docker-compose.override.yml b/docker-compose.override.yml index bd5155b..cf689bc 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -6,6 +6,8 @@ services: ports: ['8080:80'] api: ports: ['3000:3000'] + mcp: + ports: ['3010:3010'] postgres: ports: ['5432:5432'] mongo: diff --git a/docker-compose.yml b/docker-compose.yml index 79b1d2b..d2f3ca2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -62,6 +62,27 @@ services: redis: { condition: service_healthy } restart: unless-stopped + # MCP server for AI clients (Claude, Cursor…): Streamable HTTP at /mcp. It consumes the API + # with each caller's own Pireon token (Authorization header), so it holds no credentials. + # Optional: it only starts with the `mcp` profile (`docker compose --profile mcp up -d`; in + # Dokploy, COMPOSE_PROFILES=mcp) and needs a domain → service `mcp`, port 3010. + mcp: + profiles: [mcp] + build: + context: . + target: mcp + secrets: [nexus_token] + image: task-stream-mcp + expose: ['3010'] + networks: + default: + aliases: [task-stream-mcp] + environment: + TASKSTREAM_API_URL: http://task-stream-api:3000 + depends_on: + api: { condition: service_healthy } + restart: unless-stopped + postgres: image: postgres:17-alpine networks: diff --git a/docs/01-vision-general.md b/docs/01-vision-general.md index 0f0da3f..bd5f141 100644 --- a/docs/01-vision-general.md +++ b/docs/01-vision-general.md @@ -44,12 +44,13 @@ proyecto (`OWNER`, `ADMIN`, `MEMBER`, `VIEWER`). apps/ api/ composición de la API (NestJS): config, GraphQL, conexiones, e2e web/ composición del cliente (Angular): rutas, providers, estilos, nginx + mcp/ servidor MCP para asistentes de IA (NestJS), consume la API REST libs/ api/{shared,identity,tracker}/{domain,application,infrastructure,presenters} web/{shared,identity,tracker}/{domain,application,infrastructure,presenters} (+ web/shared/ui) docs/ esta documentación codegen.ts tipos GraphQL del cliente generados desde apps/api/schema.gql -Dockerfile dos destinos: api y web +Dockerfile tres destinos: api, web y mcp docker-compose.yml ``` diff --git a/docs/06-desarrollo.md b/docs/06-desarrollo.md index 68f40db..c4544c5 100644 --- a/docs/06-desarrollo.md +++ b/docs/06-desarrollo.md @@ -25,16 +25,18 @@ la web y la API comparten origen igual que en Docker. ## 6.3 Comandos -| Comando | Qué hace | -| -------------------------------- | --------------------------------------------------------------------------------- | -| `npx nx run-many -t lint` | Lint de todo, incluidas las reglas de capas y contextos | -| `npx nx run-many -t test` | Tests unitarios (dominio y aplicación, API y web) | -| `npx nx e2e api` | Flujo completo de la API contra Docker, con Pireon simulado | -| `npx nx run-many -t build` | Builds de la API y la web | -| `npx nx run web:codegen` | Regenera los tipos GraphQL del cliente desde `apps/api/schema.gql` | -| `npx nx run api:prune` | Build de la API con `package.json` y lockfile podados (lo usa Docker) | -| `npx nx graph` | Grafo de dependencias entre proyectos | -| `npx nx test api-tracker-domain` | Tests de una librería concreta (`api--`, `web--`) | +| Comando | Qué hace | +| -------------------------------- | ----------------------------------------------------------------------------------- | +| `npx nx run-many -t lint` | Lint de todo, incluidas las reglas de capas y contextos | +| `npx nx run-many -t test` | Tests unitarios (dominio y aplicación, API y web) | +| `npx nx e2e api` | Flujo completo de la API contra Docker, con Pireon simulado | +| `npx nx run-many -t build` | Builds de la API y la web | +| `npx nx run web:codegen` | Regenera los tipos GraphQL del cliente desde `apps/api/schema.gql` | +| `npx nx serve mcp` | Servidor MCP en `http://localhost:3010/mcp`, contra la API local ([MCP](09-mcp.md)) | +| `npx nx run mcp:openapi-types` | Regenera los tipos REST del MCP desde `apps/api/openapi.json` | +| `npx nx run api:prune` | Build de la API con `package.json` y lockfile podados (lo usa Docker) | +| `npx nx graph` | Grafo de dependencias entre proyectos | +| `npx nx test api-tracker-domain` | Tests de una librería concreta (`api--`, `web--`) | ### Tests diff --git a/docs/07-despliegue.md b/docs/07-despliegue.md index 9829e09..8f39cce 100644 --- a/docs/07-despliegue.md +++ b/docs/07-despliegue.md @@ -40,7 +40,7 @@ Todos tienen healthcheck; `web` espera a que `api` esté sana, y `api` a las tre ### Imágenes -El `Dockerfile` tiene dos destinos que comparten la instalación de dependencias: +El `Dockerfile` tiene tres destinos que comparten la instalación de dependencias: - **Instalación**: `npm ci` del workspace. `@pi/ui` viene del Nexus privado; el token llega como **build secret** desde la variable `NEXUS_TOKEN` (`secrets.nexus_token` en `docker-compose.yml`), se escribe en un `npmrc` temporal y @@ -48,6 +48,8 @@ El `Dockerfile` tiene dos destinos que comparten la instalación de dependencias - **`api`**: `nx run api:prune` genera el bundle con un `package.json` y un lockfile podados a partir de `apps/api/package.json`. La imagen final solo instala esas dependencias (sin Angular ni paquetes privados). Si la API empieza a usar un paquete nuevo **en tiempo de ejecución**, hay que añadirlo a `apps/api/package.json`. +- **`mcp`**: el [servidor MCP](09-mcp.md), podado igual que la API (`nx run mcp:prune`), sobre Node 24. Su servicio + en `docker-compose.yml` va en el perfil `mcp`: solo arranca con `--profile mcp` (en Dokploy, `COMPOSE_PROFILES=mcp`). - **`web`**: build de producción de Angular servido por nginx (`apps/web/nginx.conf`): fallback de rutas a `index.html`, caché larga para los bundles con hash y sin caché para `index.html`. diff --git a/docs/09-mcp.md b/docs/09-mcp.md new file mode 100644 index 0000000..2e26631 --- /dev/null +++ b/docs/09-mcp.md @@ -0,0 +1,132 @@ +# 9. Servidor MCP + +`apps/mcp` es un servidor [MCP](https://modelcontextprotocol.io) (Model Context Protocol) para que asistentes de IA +(Claude Code, Claude Desktop, Cursor…) trabajen con TaskStream: consultar tableros y tickets, crear y mover tickets, +comentar, gestionar releases o leer las estadísticas. Está hecho con NestJS y +[`@nestjs-mcp/server`](https://www.npmjs.com/package/@nestjs-mcp/server) 2.x, y consume la **API REST** como cualquier +otra app: con tipos generados desde `apps/api/openapi.json`. + +```mermaid +flowchart LR + C["Cliente MCP
(Claude…)"] -- "POST /mcp
Authorization: Bearer token" --> M["apps/mcp
(NestJS)"] + M -- "REST /api/…
mismo token" --> A["apps/api"] + A -- "valida el token" --> P["Pireon"] +``` + +## 9.1 Cómo funciona + +- **Un endpoint**: `POST /mcp`, transporte _Streamable HTTP_. Sin estado (la versión 2026-07-28 del protocolo retiró + las sesiones): cualquier instancia atiende cualquier llamada. +- **Sin credenciales propias**: cada llamada lleva su `Authorization: Bearer `, y el MCP la reenvía + tal cual a la API. Cada persona ve y puede cambiar exactamente lo que la API le permite (su rol en cada proyecto); + **sin token**, solo se leen los proyectos públicos. +- **Errores legibles**: si la API rechaza algo (permiso, regla de negocio, transición no permitida…), la herramienta + responde con un error que el modelo puede leer y corregir («Cannot move WEB-16 from Backlog to Hecho. Allowed: Por + hacer, En curso, Cancelado»), en vez de cortar la llamada. +- **Validación**: los parámetros de cada herramienta se validan con zod antes de llamar a la API. +- **Respuestas compactas**: los listados devuelven lo que el modelo necesita (clave, título, estado, persona…); el + detalle completo, con `get_issue` o `get_project`. + +## 9.2 Herramientas + +| Ámbito | Herramienta | Qué hace | +| --------- | ----------------------- | ------------------------------------------------------------------------------------------- | +| Usuario | `whoami` | El usuario del token | +| | `search_users` | Buscar personas de Pireon (para asignar) | +| Proyectos | `list_my_projects` | Mis proyectos | +| | `list_public_projects` | Proyectos públicos | +| | `get_project` | Equipo y configuración: ids de estados, tipos y áreas que aceptan las demás herramientas | +| | `get_board` | Tablero por columnas (filtros por persona, release, área) | +| | `get_project_stats` | Cifras de seguimiento | +| | `get_project_analytics` | Estadísticas de los últimos días (creados/resueltos, flujo, tiempos, throughput, QA) | +| | `get_project_activity` | Actividad reciente | +| Tickets | `my_issues` | Mi trabajo en todos los proyectos (o de cualquiera, por categoría: p. ej. lo que espera QA) | +| | `search_issues` | Buscar por texto y filtros | +| | `get_issue` | Detalle, transiciones permitidas, enlaces, comentarios e historial | +| | `create_issue` | Crear un ticket | +| | `update_issue` | Cambiar título, descripción, tipo, área, prioridad, etiquetas… | +| | `change_issue_status` | Mover de estado (con motivo cuando el estado lo pide) | +| | `assign_issue` | Asignar o desasignar | +| | `set_issue_release` | Meter en una release o sacar | +| | `add_comment` | Comentar | +| | `link_issues` | Relacionar tickets (`DEPENDS_ON`, `BLOCKS`, `TESTS`…) | +| Releases | `list_releases` | Releases de un proyecto | +| | `upcoming_releases` | Próximas releases de mis proyectos | +| | `get_release` | Progreso, tickets y burnup | +| | `create_release` | Planificar una release | +| | `publish_release` | Publicar (con `force` si quedan tickets sin terminar) | + +Las de lectura van marcadas como `readOnlyHint`, para que el cliente no pida confirmación al usarlas. Lo que es de +administración (miembros, configuración, visibilidad, borrar proyectos) no se expone: se hace desde la web. + +## 9.3 Conectar un cliente + +Hace falta un token de Pireon. Con email y contraseña (si la API tiene configurado el cliente `password`): + +```sh +curl -s https://api.taskstream.pireon.dev/api/auth/token \ + -H 'content-type: application/json' \ + -d '{"grant_type":"password","username":"tu@email.com","password":"…"}' +# → { "access_token": "…", "refresh_token": "…", "expires_in": … } +``` + +Claude Code: + +```sh +claude mcp add --transport http taskstream https://mcp.taskstream.pireon.dev/mcp \ + --header "Authorization: Bearer $TASKSTREAM_TOKEN" +# en local: http://localhost:3010/mcp +``` + +Otros clientes (JSON de configuración): + +```json +{ + "mcpServers": { + "taskstream": { + "type": "http", + "url": "https://mcp.taskstream.pireon.dev/mcp", + "headers": { "Authorization": "Bearer " } + } + } +} +``` + +El token caduca como cualquier sesión de Pireon: cuando la herramienta responda «Error 401», hay que pedir otro (o +renovarlo con `grant_type: refresh_token`). El MCP todavía no implementa el flujo de autorización OAuth del propio +protocolo MCP, así que el cliente no lo obtiene solo. + +## 9.4 Desarrollo + +```sh +npx nx serve api # la API en :3000 +npx nx serve mcp # el MCP en http://localhost:3010/mcp (TASKSTREAM_API_URL=http://localhost:3000) +npx @modelcontextprotocol/inspector # probar las herramientas en el navegador +npx nx test mcp # MCP real contra una API simulada: token reenviado, errores, validación +npx nx run mcp:openapi-types # regenerar los tipos tras cambiar la API (apps/api/openapi.json) +``` + +| Variable | Por defecto | Qué es | +| -------------------- | ----------------------- | ------------------------------------------------------------------------------------------ | +| `MCP_PORT` | `3010` | Puerto del MCP (no `PORT`: Nx carga el `.env` del workspace, donde `PORT` es el de la API) | +| `TASKSTREAM_API_URL` | `http://localhost:3000` | Origen de la API (sus rutas ya llevan `/api`) | +| `MCP_LOG_LEVEL` | `log` | `error`, `warn`, `log`, `debug` o `verbose` | + +Añadir una herramienta: un método con `@Tool({ name, description, paramsSchema: z.object(…), annotations })` en el +resolver de su ámbito (`apps/mcp/src/app/tools`), que llame a la API con `this.api.as(ctx)` (cliente tipado con el +token de quien llama) y devuelva `toolResult(…)`. + +La imagen solo instala lo que lista `apps/mcp/package.json`: si el MCP empieza a usar un paquete nuevo en tiempo de +ejecución, hay que añadirlo ahí (igual que en la API). + +## 9.5 Despliegue + +El servicio `mcp` de `docker-compose.yml` va en el perfil `mcp`, así que no arranca salvo que se pida: + +```sh +docker compose --profile mcp up -d --build # local: http://localhost:3010/mcp +``` + +En Dokploy: `COMPOSE_PROFILES=mcp` en las variables del proyecto y un dominio (p. ej. `mcp.taskstream.pireon.dev`) → +servicio `mcp`, puerto 3010. Habla con la API por la red interna (`http://task-stream-api:3000`). La imagen usa Node 24, +que es lo que pide `@nestjs-mcp/server` 2. diff --git a/docs/README.md b/docs/README.md index d803d51..1668873 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3,16 +3,17 @@ TaskStream es una alternativa a Jira para equipos de desarrollo: proyectos, tickets (bugs, features, cambios de UI, tareas, QA), releases, comentarios y relaciones entre tickets, todo en tiempo real. -| Documento | Para qué | -| ----------------------------------------- | ------------------------------------------------------------------------ | -| [1. Visión general](01-vision-general.md) | Qué hace el producto, stack y estructura del monorepo | -| [2. Arquitectura](02-arquitectura.md) | Hexagonal, CQRS, event sourcing, flujo escritura → lectura y tiempo real | -| [3. API](03-api.md) | Dominio (agregados, workflow, eventos) y la API GraphQL | -| [4. Cliente web](04-cliente-web.md) | Angular, NgRx Signals con eventos, GraphQL y `@pi/ui` | -| [5. Autenticación](05-autenticacion.md) | Pireon: tokens, login en el navegador, sesiones y webhooks | -| [6. Desarrollo](06-desarrollo.md) | Puesta en marcha, comandos, tests y cómo añadir una funcionalidad | -| [7. Despliegue](07-despliegue.md) | Docker, variables de entorno y escalado | -| [8. Decisiones](08-decisiones.md) | Por qué está hecho así (y qué alternativas se descartaron) | +| Documento | Para qué | +| ----------------------------------------- | ------------------------------------------------------------------------- | +| [1. Visión general](01-vision-general.md) | Qué hace el producto, stack y estructura del monorepo | +| [2. Arquitectura](02-arquitectura.md) | Hexagonal, CQRS, event sourcing, flujo escritura → lectura y tiempo real | +| [3. API](03-api.md) | Dominio (agregados, workflow, eventos) y la API GraphQL | +| [4. Cliente web](04-cliente-web.md) | Angular, NgRx Signals con eventos, GraphQL y `@pi/ui` | +| [5. Autenticación](05-autenticacion.md) | Pireon: tokens, login en el navegador, sesiones y webhooks | +| [6. Desarrollo](06-desarrollo.md) | Puesta en marcha, comandos, tests y cómo añadir una funcionalidad | +| [7. Despliegue](07-despliegue.md) | Docker, variables de entorno y escalado | +| [8. Decisiones](08-decisiones.md) | Por qué está hecho así (y qué alternativas se descartaron) | +| [9. Servidor MCP](09-mcp.md) | Usar TaskStream desde asistentes de IA (Claude…): herramientas y conexión | Cada librería de `libs/` tiene además su propio `README.md` con su responsabilidad y su contenido. diff --git a/docs/TaskStream-Documentacion.docx b/docs/TaskStream-Documentacion.docx index a24e69e..c291c08 100644 Binary files a/docs/TaskStream-Documentacion.docx and b/docs/TaskStream-Documentacion.docx differ diff --git a/docs/TaskStream-Documentacion.pdf b/docs/TaskStream-Documentacion.pdf index 6488179..0b97566 100644 Binary files a/docs/TaskStream-Documentacion.pdf and b/docs/TaskStream-Documentacion.pdf differ diff --git a/package-lock.json b/package-lock.json index 4496cf8..92cdc18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,8 @@ "@apollo/server": "^5.5.1", "@as-integrations/express5": "^1.1.2", "@graphql-typed-document-node/core": "^3.2.0", + "@modelcontextprotocol/server": "^2.3.1", + "@nestjs-mcp/server": "^2.0.0", "@nestjs/apollo": "^13.4.5", "@nestjs/common": "^11.0.0", "@nestjs/config": "^4.0.4", @@ -47,12 +49,14 @@ "ioredis": "^5.11.1", "joi": "^18.2.9", "mongoose": "^8.24.4", + "openapi-fetch": "^0.17.0", "pg": "^8.23.1", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.0", "tslib": "^2.8.1", "typeorm": "^0.3.31", - "ws": "^8.22.0" + "ws": "^8.22.0", + "zod": "^4.6.5" }, "devDependencies": { "@analogjs/vite-plugin-angular": "~2.6.0", @@ -2234,6 +2238,16 @@ "node": "^20.19.0 || ^22.12.0 || >=23" } }, + "node_modules/@angular/cli/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@angular/common": { "version": "22.1.8", "resolved": "https://registry.npmjs.org/@angular/common/-/common-22.1.8.tgz", @@ -10677,6 +10691,46 @@ "integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==", "license": "MIT" }, + "node_modules/@modelcontextprotocol/core": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.3.1.tgz", + "integrity": "sha512-laVmIhPGpWi7rG9q+0UigblRN0Hws/Yjlb6qLFtVqryXVLkc4h+yN8z731POD2LnAGQlFe7/tXhUm7Bi0YB91g==", + "license": "Apache-2.0", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/node": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/node/-/node-2.1.1.tgz", + "integrity": "sha512-EY92OAXN2xYL7tNS/MVsWJW1v8VHAPwl9B4Ca3pCrjmvqyC+S1C/6UHsMxF2GoFPraS5Yyg56vFz9brVpFojMA==", + "license": "Apache-2.0", + "dependencies": { + "@hono/node-server": "^1.19.9", + "hono": "^4.11.4" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@modelcontextprotocol/server": "^2.3.0" + } + }, + "node_modules/@modelcontextprotocol/node/node_modules/@hono/node-server": { + "version": "1.19.17", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", + "integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", @@ -10718,6 +10772,19 @@ } } }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.3.1.tgz", + "integrity": "sha512-e59MfWuSssj6DoQ96JCTqdW1AXT5/XtnTl0WWd6MU1Ka434fFUGGosCLJbGNySDhHGvxQWoRWK137FXYFkYNWw==", + "license": "Apache-2.0", + "dependencies": { + "@modelcontextprotocol/core": "2.3.1", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@mongodb-js/saslprep": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.5.5.tgz", @@ -11144,6 +11211,28 @@ "@tybys/wasm-util": "^0.9.0" } }, + "node_modules/@nestjs-mcp/server": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@nestjs-mcp/server/-/server-2.0.0.tgz", + "integrity": "sha512-5hC/T4W++78d/gEcTuqGU3jOPddrL1wBd+cRWzDzxmv7MronwXxzkFyVDVG2+matGlGrxNM+M5XeGVVjHN05Sw==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/node": "^2.0.0", + "@modelcontextprotocol/server": "^2.0.0", + "zod": "^4.4.3" + }, + "engines": { + "node": ">=24", + "pnpm": ">=11" + }, + "peerDependencies": { + "@nestjs/common": "^11.1.28", + "@nestjs/core": "^11.1.28", + "@nestjs/platform-express": "^11.1.28", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.2" + } + }, "node_modules/@nestjs/apollo": { "version": "13.4.5", "resolved": "https://registry.npmjs.org/@nestjs/apollo/-/apollo-13.4.5.tgz", @@ -19567,7 +19656,6 @@ "version": "4.13.12", "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.12.tgz", "integrity": "sha512-6E2QDAc9Ick9Sq77ZrGS/dk2WUYni91aufTw6LJKpV7w8kW5/GxVUc650FOADOmlwg3K+f7Pun6XlV+pYmW6gw==", - "dev": true, "license": "MIT", "engines": { "node": ">=16.9.0" @@ -25518,6 +25606,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openapi-fetch": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.17.0.tgz", + "integrity": "sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==", + "license": "MIT", + "dependencies": { + "openapi-typescript-helpers": "^0.1.0" + } + }, + "node_modules/openapi-typescript-helpers": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.1.0.tgz", + "integrity": "sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==", + "license": "MIT" + }, "node_modules/opener": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", @@ -32681,9 +32784,9 @@ } }, "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 60a1351..f9a782a 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "@apollo/server": "^5.5.1", "@as-integrations/express5": "^1.1.2", "@graphql-typed-document-node/core": "^3.2.0", + "@modelcontextprotocol/server": "^2.3.1", + "@nestjs-mcp/server": "^2.0.0", "@nestjs/apollo": "^13.4.5", "@nestjs/common": "^11.0.0", "@nestjs/config": "^4.0.4", @@ -43,12 +45,14 @@ "ioredis": "^5.11.1", "joi": "^18.2.9", "mongoose": "^8.24.4", + "openapi-fetch": "^0.17.0", "pg": "^8.23.1", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.0", "tslib": "^2.8.1", "typeorm": "^0.3.31", - "ws": "^8.22.0" + "ws": "^8.22.0", + "zod": "^4.6.5" }, "devDependencies": { "@analogjs/vite-plugin-angular": "~2.6.0",