Compare commits
2 Commits
e656222b54
...
9236cb73c6
| Author | SHA1 | Date | |
|---|---|---|---|
| 9236cb73c6 | |||
| 71dc2d6f2d |
@@ -9,3 +9,6 @@ package-lock.json
|
|||||||
# Generated by the API on startup (nx serve api): keep them byte-for-byte as written
|
# Generated by the API on startup (nx serve api): keep them byte-for-byte as written
|
||||||
apps/api/openapi.json
|
apps/api/openapi.json
|
||||||
apps/api/schema.gql
|
apps/api/schema.gql
|
||||||
|
|
||||||
|
# Generated from apps/api/openapi.json (nx run mcp:openapi-types)
|
||||||
|
apps/mcp/src/app/api/taskstream.schema.ts
|
||||||
|
|||||||
28
.vscode/launch.json
vendored
28
.vscode/launch.json
vendored
@@ -6,24 +6,36 @@
|
|||||||
"request": "launch",
|
"request": "launch",
|
||||||
"name": "Debug api with Nx",
|
"name": "Debug api with Nx",
|
||||||
"runtimeExecutable": "npx",
|
"runtimeExecutable": "npx",
|
||||||
"runtimeArgs": [
|
"runtimeArgs": ["nx", "serve", "api"],
|
||||||
"nx",
|
|
||||||
"serve",
|
|
||||||
"api"
|
|
||||||
],
|
|
||||||
"env": {
|
"env": {
|
||||||
"NODE_OPTIONS": "--inspect=9229"
|
"NODE_OPTIONS": "--inspect=9229"
|
||||||
},
|
},
|
||||||
"console": "integratedTerminal",
|
"console": "integratedTerminal",
|
||||||
"internalConsoleOptions": "neverOpen",
|
"internalConsoleOptions": "neverOpen",
|
||||||
"skipFiles": [
|
"skipFiles": ["<node_internals>/**"],
|
||||||
"<node_internals>/**"
|
|
||||||
],
|
|
||||||
"sourceMaps": true,
|
"sourceMaps": true,
|
||||||
"outFiles": [
|
"outFiles": [
|
||||||
"${workspaceFolder}/apps/api/dist/**/*.(m|c|)js",
|
"${workspaceFolder}/apps/api/dist/**/*.(m|c|)js",
|
||||||
"!**/node_modules/**"
|
"!**/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": ["<node_internals>/**"],
|
||||||
|
"sourceMaps": true,
|
||||||
|
"outFiles": [
|
||||||
|
"${workspaceFolder}/apps/mcp/dist/**/*.(m|c|)js",
|
||||||
|
"!**/node_modules/**"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
23
Dockerfile
23
Dockerfile
@@ -1,6 +1,7 @@
|
|||||||
# syntax=docker/dockerfile:1.7
|
# syntax=docker/dockerfile:1.7
|
||||||
# TaskStream images (one Dockerfile, two targets):
|
# TaskStream images (one Dockerfile, three targets):
|
||||||
# docker compose up -d --build → api + web + infrastructure
|
# 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
|
# @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
|
# 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 wget -qO- http://127.0.0.1:3000/api/health > /dev/null || exit 1
|
||||||
CMD ["node", "main.js"]
|
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) ---------
|
# ---- Web: Angular SPA behind nginx (proxies /api and /graphql) ---------
|
||||||
FROM deps AS build-web
|
FROM deps AS build-web
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
@@ -977,7 +977,14 @@ describe('task-stream (e2e)', () => {
|
|||||||
() => rest(null, 'GET', `/projects/${key}`),
|
() => rest(null, 'GET', `/projects/${key}`),
|
||||||
(r) => r.status === 404,
|
(r) => r.status === 404,
|
||||||
);
|
);
|
||||||
expect((await rest(null, 'GET', `/issues/${issue}`)).status).toBe(404);
|
const hidden = await rest(null, 'GET', `/issues/${issue}`);
|
||||||
|
expect(hidden.status).toBe(404);
|
||||||
|
// The answer names what was asked, not the private project behind it.
|
||||||
|
expect(hidden.body['message']).toContain(issue);
|
||||||
|
expect(hidden.body['message']).not.toContain(projectId);
|
||||||
|
const project = await rest(null, 'GET', `/projects/${key}`);
|
||||||
|
expect(project.body['message']).toContain(key);
|
||||||
|
expect(project.body['message']).not.toContain(projectId);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deletes a project with everything in it (cascade saga) and frees its key', async () => {
|
it('deletes a project with everything in it (cascade saga) and frees its key', async () => {
|
||||||
|
|||||||
@@ -179,11 +179,7 @@
|
|||||||
"description": "Name or email",
|
"description": "Name or email",
|
||||||
"schema": {
|
"schema": {
|
||||||
"default": "",
|
"default": "",
|
||||||
"allOf": [
|
"type": "string"
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/Object"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -194,11 +190,7 @@
|
|||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"maximum": 100,
|
"maximum": 100,
|
||||||
"default": 20,
|
"default": 20,
|
||||||
"allOf": [
|
"type": "number"
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/Object"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -1023,11 +1015,7 @@
|
|||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"maximum": 200,
|
"maximum": 200,
|
||||||
"default": 50,
|
"default": 50,
|
||||||
"allOf": [
|
"type": "number"
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/Object"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -1206,11 +1194,7 @@
|
|||||||
"minimum": 7,
|
"minimum": 7,
|
||||||
"maximum": 365,
|
"maximum": 365,
|
||||||
"default": 90,
|
"default": 90,
|
||||||
"allOf": [
|
"type": "number"
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/Object"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -1313,11 +1297,7 @@
|
|||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"maximum": 200,
|
"maximum": 200,
|
||||||
"default": 50,
|
"default": 50,
|
||||||
"allOf": [
|
"type": "number"
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/Object"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1752,11 +1732,7 @@
|
|||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"maximum": 200,
|
"maximum": 200,
|
||||||
"default": 50,
|
"default": 50,
|
||||||
"allOf": [
|
"type": "number"
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/Object"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1766,11 +1742,7 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"default": 0,
|
"default": 0,
|
||||||
"allOf": [
|
"type": "number"
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/Object"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1780,11 +1752,7 @@
|
|||||||
"description": "true: tickets assigned to me. false: any ticket of my projects (e.g. ?assignedToMe=false&category=QA)",
|
"description": "true: tickets assigned to me. false: any ticket of my projects (e.g. ?assignedToMe=false&category=QA)",
|
||||||
"schema": {
|
"schema": {
|
||||||
"default": true,
|
"default": true,
|
||||||
"allOf": [
|
"type": "boolean"
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/Object"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1900,11 +1868,7 @@
|
|||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"maximum": 200,
|
"maximum": 200,
|
||||||
"default": 50,
|
"default": 50,
|
||||||
"allOf": [
|
"type": "number"
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/Object"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1914,11 +1878,7 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"default": 0,
|
"default": 0,
|
||||||
"allOf": [
|
"type": "number"
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/Object"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2981,11 +2941,7 @@
|
|||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"maximum": 200,
|
"maximum": 200,
|
||||||
"default": 50,
|
"default": 50,
|
||||||
"allOf": [
|
"type": "number"
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/Object"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2995,11 +2951,7 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"default": 0,
|
"default": 0,
|
||||||
"allOf": [
|
"type": "number"
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/Object"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -3201,11 +3153,7 @@
|
|||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"maximum": 200,
|
"maximum": 200,
|
||||||
"default": 50,
|
"default": 50,
|
||||||
"allOf": [
|
"type": "number"
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/Object"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -3215,11 +3163,7 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"default": 0,
|
"default": 0,
|
||||||
"allOf": [
|
"type": "number"
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/Object"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -3511,11 +3455,7 @@
|
|||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"maximum": 50,
|
"maximum": 50,
|
||||||
"default": 10,
|
"default": 10,
|
||||||
"allOf": [
|
"type": "number"
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/Object"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -4149,11 +4089,7 @@
|
|||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"maximum": 200,
|
"maximum": 200,
|
||||||
"default": 50,
|
"default": 50,
|
||||||
"allOf": [
|
"type": "number"
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/Object"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -4163,11 +4099,7 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"default": 0,
|
"default": 0,
|
||||||
"allOf": [
|
"type": "number"
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/Object"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -4730,10 +4662,6 @@
|
|||||||
"message"
|
"message"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"Object": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {}
|
|
||||||
},
|
|
||||||
"UserDto": {
|
"UserDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -5168,11 +5096,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"requiresReason": {
|
"requiresReason": {
|
||||||
"type": "object",
|
"type": "boolean",
|
||||||
"default": false
|
"default": false
|
||||||
},
|
},
|
||||||
"archived": {
|
"archived": {
|
||||||
"type": "object",
|
"type": "boolean",
|
||||||
"default": false
|
"default": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -5233,7 +5161,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"archived": {
|
"archived": {
|
||||||
"type": "object",
|
"type": "boolean",
|
||||||
"default": false
|
"default": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -5257,7 +5185,7 @@
|
|||||||
"maxLength": 40
|
"maxLength": 40
|
||||||
},
|
},
|
||||||
"archived": {
|
"archived": {
|
||||||
"type": "object",
|
"type": "boolean",
|
||||||
"default": false
|
"default": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -6410,7 +6338,7 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"force": {
|
"force": {
|
||||||
"type": "object",
|
"type": "boolean",
|
||||||
"default": false,
|
"default": false,
|
||||||
"description": "Publish even with unfinished tickets"
|
"description": "Publish even with unfinished tickets"
|
||||||
}
|
}
|
||||||
|
|||||||
7
apps/mcp/eslint.config.mjs
Normal file
7
apps/mcp/eslint.config.mjs
Normal file
@@ -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'] },
|
||||||
|
];
|
||||||
10
apps/mcp/jest.config.cts
Normal file
10
apps/mcp/jest.config.cts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
module.exports = {
|
||||||
|
displayName: 'mcp',
|
||||||
|
preset: '../../jest.preset.js',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
transform: {
|
||||||
|
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
|
||||||
|
},
|
||||||
|
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||||
|
coverageDirectory: '../../coverage/apps/mcp',
|
||||||
|
};
|
||||||
21
apps/mcp/package.json
Normal file
21
apps/mcp/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
66
apps/mcp/project.json
Normal file
66
apps/mcp/project.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
58
apps/mcp/src/app/api/taskstream-api.ts
Normal file
58
apps/mcp/src/app/api/taskstream-api.ts
Normal file
@@ -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<paths>;
|
||||||
|
|
||||||
|
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<paths>({
|
||||||
|
baseUrl: this.baseUrl,
|
||||||
|
headers: authorization ? { authorization } : {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The body of a successful answer; otherwise an ApiError with the API's message. */
|
||||||
|
async data<T>(
|
||||||
|
request: Promise<{ data?: T; error?: unknown; response: Response }>,
|
||||||
|
): Promise<T> {
|
||||||
|
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 <token>" (sin token solo se ven proyectos públicos).`;
|
||||||
|
return body.code ? `${message} (${String(body.code)})` : message;
|
||||||
|
}
|
||||||
4292
apps/mcp/src/app/api/taskstream.schema.ts
Normal file
4292
apps/mcp/src/app/api/taskstream.schema.ts
Normal file
File diff suppressed because it is too large
Load Diff
48
apps/mcp/src/app/app.module.ts
Normal file
48
apps/mcp/src/app/app.module.ts
Normal file
@@ -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<string>('TASKSTREAM_API_URL', 'http://localhost:3000')
|
||||||
|
.replace(/\/+$/, ''),
|
||||||
|
},
|
||||||
|
TaskStreamApi,
|
||||||
|
ProjectsResolver,
|
||||||
|
IssuesResolver,
|
||||||
|
ReleasesResolver,
|
||||||
|
UsersResolver,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
10
apps/mcp/src/app/health.controller.ts
Normal file
10
apps/mcp/src/app/health.controller.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
|
||||||
|
/** For Docker / Dokploy healthchecks. */
|
||||||
|
@Controller('health')
|
||||||
|
export class HealthController {
|
||||||
|
@Get()
|
||||||
|
check() {
|
||||||
|
return { status: 'ok' };
|
||||||
|
}
|
||||||
|
}
|
||||||
214
apps/mcp/src/app/mcp.spec.ts
Normal file
214
apps/mcp/src/app/mcp.spec.ts
Normal file
@@ -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<Record<string, unknown>> {
|
||||||
|
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<void>((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);
|
||||||
|
});
|
||||||
|
});
|
||||||
402
apps/mcp/src/app/tools/issues.resolver.ts
Normal file
402
apps/mcp/src/app/tools/issues.resolver.ts
Normal file
@@ -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<typeof PRIORITY>;
|
||||||
|
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<typeof CATEGORY>[];
|
||||||
|
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<typeof CATEGORY>[];
|
||||||
|
types?: string[];
|
||||||
|
areas?: string[];
|
||||||
|
priorities?: z.infer<typeof PRIORITY>[];
|
||||||
|
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<typeof LINK_TYPE>; 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 },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
apps/mcp/src/app/tools/params.ts
Normal file
23
apps/mcp/src/app/tools/params.ts
Normal file
@@ -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})`);
|
||||||
170
apps/mcp/src/app/tools/projects.resolver.ts
Normal file
170
apps/mcp/src/app/tools/projects.resolver.ts
Normal file
@@ -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,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
151
apps/mcp/src/app/tools/releases.resolver.ts
Normal file
151
apps/mcp/src/app/tools/releases.resolver.ts
Normal file
@@ -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 },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
63
apps/mcp/src/app/tools/summaries.ts
Normal file
63
apps/mcp/src/app/tools/summaries.ts
Normal file
@@ -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,
|
||||||
|
});
|
||||||
34
apps/mcp/src/app/tools/tool-result.ts
Normal file
34
apps/mcp/src/app/tools/tool-result.ts
Normal file
@@ -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<unknown>,
|
||||||
|
): Promise<CallToolResult> {
|
||||||
|
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}` },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
49
apps/mcp/src/app/tools/users.resolver.ts
Normal file
49
apps/mcp/src/app/tools/users.resolver.ts
Normal file
@@ -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 })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
0
apps/mcp/src/assets/.gitkeep
Normal file
0
apps/mcp/src/assets/.gitkeep
Normal file
15
apps/mcp/src/main.ts
Normal file
15
apps/mcp/src/main.ts
Normal file
@@ -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();
|
||||||
24
apps/mcp/tsconfig.app.json
Normal file
24
apps/mcp/tsconfig.app.json
Normal file
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
16
apps/mcp/tsconfig.json
Normal file
16
apps/mcp/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"files": [],
|
||||||
|
"include": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.spec.json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"esModuleInterop": true
|
||||||
|
}
|
||||||
|
}
|
||||||
16
apps/mcp/tsconfig.spec.json
Normal file
16
apps/mcp/tsconfig.spec.json
Normal file
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
25
apps/mcp/webpack.config.js
Normal file
25
apps/mcp/webpack.config.js
Normal file
@@ -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,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -6,6 +6,8 @@ services:
|
|||||||
ports: ['8080:80']
|
ports: ['8080:80']
|
||||||
api:
|
api:
|
||||||
ports: ['3000:3000']
|
ports: ['3000:3000']
|
||||||
|
mcp:
|
||||||
|
ports: ['3010:3010']
|
||||||
postgres:
|
postgres:
|
||||||
ports: ['5432:5432']
|
ports: ['5432:5432']
|
||||||
mongo:
|
mongo:
|
||||||
|
|||||||
@@ -62,6 +62,27 @@ services:
|
|||||||
redis: { condition: service_healthy }
|
redis: { condition: service_healthy }
|
||||||
restart: unless-stopped
|
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:
|
postgres:
|
||||||
image: postgres:17-alpine
|
image: postgres:17-alpine
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -44,12 +44,13 @@ proyecto (`OWNER`, `ADMIN`, `MEMBER`, `VIEWER`).
|
|||||||
apps/
|
apps/
|
||||||
api/ composición de la API (NestJS): config, GraphQL, conexiones, e2e
|
api/ composición de la API (NestJS): config, GraphQL, conexiones, e2e
|
||||||
web/ composición del cliente (Angular): rutas, providers, estilos, nginx
|
web/ composición del cliente (Angular): rutas, providers, estilos, nginx
|
||||||
|
mcp/ servidor MCP para asistentes de IA (NestJS), consume la API REST
|
||||||
libs/
|
libs/
|
||||||
api/{shared,identity,tracker}/{domain,application,infrastructure,presenters}
|
api/{shared,identity,tracker}/{domain,application,infrastructure,presenters}
|
||||||
web/{shared,identity,tracker}/{domain,application,infrastructure,presenters} (+ web/shared/ui)
|
web/{shared,identity,tracker}/{domain,application,infrastructure,presenters} (+ web/shared/ui)
|
||||||
docs/ esta documentación
|
docs/ esta documentación
|
||||||
codegen.ts tipos GraphQL del cliente generados desde apps/api/schema.gql
|
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
|
docker-compose.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -25,16 +25,18 @@ la web y la API comparten origen igual que en Docker.
|
|||||||
|
|
||||||
## 6.3 Comandos
|
## 6.3 Comandos
|
||||||
|
|
||||||
| Comando | Qué hace |
|
| Comando | Qué hace |
|
||||||
| -------------------------------- | --------------------------------------------------------------------------------- |
|
| -------------------------------- | ----------------------------------------------------------------------------------- |
|
||||||
| `npx nx run-many -t lint` | Lint de todo, incluidas las reglas de capas y contextos |
|
| `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 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 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-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 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 serve mcp` | Servidor MCP en `http://localhost:3010/mcp`, contra la API local ([MCP](09-mcp.md)) |
|
||||||
| `npx nx graph` | Grafo de dependencias entre proyectos |
|
| `npx nx run mcp:openapi-types` | Regenera los tipos REST del MCP desde `apps/api/openapi.json` |
|
||||||
| `npx nx test api-tracker-domain` | Tests de una librería concreta (`api-<contexto>-<capa>`, `web-<contexto>-<capa>`) |
|
| `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-<contexto>-<capa>`, `web-<contexto>-<capa>`) |
|
||||||
|
|
||||||
### Tests
|
### Tests
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ Todos tienen healthcheck; `web` espera a que `api` esté sana, y `api` a las tre
|
|||||||
|
|
||||||
### Imágenes
|
### 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**
|
- **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
|
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
|
- **`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).
|
`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`.
|
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
|
- **`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`.
|
`index.html`, caché larga para los bundles con hash y sin caché para `index.html`.
|
||||||
|
|
||||||
|
|||||||
132
docs/09-mcp.md
Normal file
132
docs/09-mcp.md
Normal file
@@ -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<br/>(Claude…)"] -- "POST /mcp<br/>Authorization: Bearer token" --> M["apps/mcp<br/>(NestJS)"]
|
||||||
|
M -- "REST /api/…<br/>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 <token de Pireon>`, 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 <token de Pireon>" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -3,16 +3,17 @@
|
|||||||
TaskStream es una alternativa a Jira para equipos de desarrollo: proyectos, tickets (bugs, features, cambios de UI,
|
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.
|
tareas, QA), releases, comentarios y relaciones entre tickets, todo en tiempo real.
|
||||||
|
|
||||||
| Documento | Para qué |
|
| Documento | Para qué |
|
||||||
| ----------------------------------------- | ------------------------------------------------------------------------ |
|
| ----------------------------------------- | ------------------------------------------------------------------------- |
|
||||||
| [1. Visión general](01-vision-general.md) | Qué hace el producto, stack y estructura del monorepo |
|
| [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 |
|
| [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 |
|
| [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` |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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) |
|
| [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.
|
Cada librería de `libs/` tiene además su propio `README.md` con su responsabilidad y su contenido.
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -48,13 +48,17 @@ export class MeDto extends UserDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class UserSearchQueryDto {
|
export class UserSearchQueryDto {
|
||||||
@ApiPropertyOptional({ description: 'Name or email', default: '' })
|
@ApiPropertyOptional({
|
||||||
|
type: String,
|
||||||
|
description: 'Name or email',
|
||||||
|
default: '',
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(100)
|
@MaxLength(100)
|
||||||
search = '';
|
search = '';
|
||||||
|
|
||||||
@ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 })
|
@ApiPropertyOptional({ type: Number, default: 20, minimum: 1, maximum: 100 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ export class GetCommentsQueryHandler
|
|||||||
'Issue',
|
'Issue',
|
||||||
query.issueId,
|
query.issueId,
|
||||||
);
|
);
|
||||||
await this.access.assertCanView(issue.projectId, query.viewerId);
|
await this.access.assertCanView(issue.projectId, query.viewerId, {
|
||||||
|
entity: 'Issue',
|
||||||
|
ref: query.issueId,
|
||||||
|
});
|
||||||
return this.findComments.findByIssue(
|
return this.findComments.findByIssue(
|
||||||
issue.id,
|
issue.id,
|
||||||
Math.min(query.limit, 200),
|
Math.min(query.limit, 200),
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ export class GetIssueQueryHandler
|
|||||||
? await this.findIssues.findById(query.idOrKey)
|
? await this.findIssues.findById(query.idOrKey)
|
||||||
: await this.findIssues.findByKey(query.idOrKey.toUpperCase());
|
: await this.findIssues.findByKey(query.idOrKey.toUpperCase());
|
||||||
const found = this.access.assertFound(issue, 'Issue', query.idOrKey);
|
const found = this.access.assertFound(issue, 'Issue', query.idOrKey);
|
||||||
await this.access.assertCanView(found.projectId, query.viewerId);
|
await this.access.assertCanView(found.projectId, query.viewerId, {
|
||||||
|
entity: 'Issue',
|
||||||
|
ref: query.idOrKey,
|
||||||
|
});
|
||||||
return found;
|
return found;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ export class GetProjectQueryHandler
|
|||||||
? await this.findProjects.findById(query.idOrKey)
|
? await this.findProjects.findById(query.idOrKey)
|
||||||
: await this.findProjects.findByKey(query.idOrKey.toUpperCase());
|
: await this.findProjects.findByKey(query.idOrKey.toUpperCase());
|
||||||
const found = this.access.assertFound(project, 'Project', query.idOrKey);
|
const found = this.access.assertFound(project, 'Project', query.idOrKey);
|
||||||
await this.access.assertCanView(found.id, query.viewerId);
|
await this.access.assertCanView(found.id, query.viewerId, {
|
||||||
|
entity: 'Project',
|
||||||
|
ref: query.idOrKey,
|
||||||
|
});
|
||||||
return found;
|
return found;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ export class GetReleaseBurnupQueryHandler
|
|||||||
'Release',
|
'Release',
|
||||||
query.releaseId,
|
query.releaseId,
|
||||||
);
|
);
|
||||||
await this.access.assertCanView(release.projectId, query.viewerId);
|
await this.access.assertCanView(release.projectId, query.viewerId, {
|
||||||
|
entity: 'Release',
|
||||||
|
ref: query.releaseId,
|
||||||
|
});
|
||||||
const [issues, timeline] = await Promise.all([
|
const [issues, timeline] = await Promise.all([
|
||||||
this.findIssues.findByProject(release.projectId),
|
this.findIssues.findByProject(release.projectId),
|
||||||
this.findTimeline.findByProject(release.projectId),
|
this.findTimeline.findByProject(release.projectId),
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ export class GetReleaseProgressQueryHandler
|
|||||||
'Release',
|
'Release',
|
||||||
query.releaseId,
|
query.releaseId,
|
||||||
);
|
);
|
||||||
await this.access.assertCanView(release.projectId, query.viewerId);
|
await this.access.assertCanView(release.projectId, query.viewerId, {
|
||||||
|
entity: 'Release',
|
||||||
|
ref: query.releaseId,
|
||||||
|
});
|
||||||
return this.findIssues.releaseProgress(release.id);
|
return this.findIssues.releaseProgress(release.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ export class GetReleaseQueryHandler
|
|||||||
'Release',
|
'Release',
|
||||||
query.releaseId,
|
query.releaseId,
|
||||||
);
|
);
|
||||||
await this.access.assertCanView(release.projectId, query.viewerId);
|
await this.access.assertCanView(release.projectId, query.viewerId, {
|
||||||
|
entity: 'Release',
|
||||||
|
ref: query.releaseId,
|
||||||
|
});
|
||||||
return release;
|
return release;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,10 +12,18 @@ import { FindProjectsRepository } from '../ports/find-projects.repository';
|
|||||||
export class ReadAccess {
|
export class ReadAccess {
|
||||||
constructor(private readonly findProjects: FindProjectsRepository) {}
|
constructor(private readonly findProjects: FindProjectsRepository) {}
|
||||||
|
|
||||||
/** The viewer's role, or null when they read a public project as an outsider. */
|
/**
|
||||||
|
* The viewer's role, or null when they read a public project as an outsider.
|
||||||
|
* `asked` is what the caller looked for: the error names it, never the
|
||||||
|
* project behind it (that would confirm a private project exists).
|
||||||
|
*/
|
||||||
async assertCanView(
|
async assertCanView(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
viewerId: string | null,
|
viewerId: string | null,
|
||||||
|
asked: { entity: string; ref: string } = {
|
||||||
|
entity: 'Project',
|
||||||
|
ref: projectId,
|
||||||
|
},
|
||||||
): Promise<ProjectRole | null> {
|
): Promise<ProjectRole | null> {
|
||||||
const role = viewerId
|
const role = viewerId
|
||||||
? await this.findProjects.findRole(projectId, viewerId)
|
? await this.findProjects.findRole(projectId, viewerId)
|
||||||
@@ -23,7 +31,7 @@ export class ReadAccess {
|
|||||||
if (role) return role;
|
if (role) return role;
|
||||||
const project = await this.findProjects.findById(projectId);
|
const project = await this.findProjects.findById(projectId);
|
||||||
// Same answer for "doesn't exist" and "not yours": don't leak projects.
|
// Same answer for "doesn't exist" and "not yours": don't leak projects.
|
||||||
if (!project?.isPublic) throw new NotFoundError('Project', projectId);
|
if (!project?.isPublic) throw new NotFoundError(asked.entity, asked.ref);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -132,11 +132,11 @@ export class WorkflowStatusBodyDto {
|
|||||||
@ApiProperty({ enum: SETTING_COLORS, enumName: 'SettingColor' })
|
@ApiProperty({ enum: SETTING_COLORS, enumName: 'SettingColor' })
|
||||||
@IsIn(SETTING_COLORS)
|
@IsIn(SETTING_COLORS)
|
||||||
color!: string;
|
color!: string;
|
||||||
@ApiPropertyOptional({ default: false })
|
@ApiPropertyOptional({ type: Boolean, default: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
requiresReason = false;
|
requiresReason = false;
|
||||||
@ApiPropertyOptional({ default: false })
|
@ApiPropertyOptional({ type: Boolean, default: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
archived = false;
|
archived = false;
|
||||||
@@ -164,7 +164,7 @@ export class IssueTypeOptionBodyDto {
|
|||||||
@ApiProperty({ enum: SETTING_COLORS, enumName: 'SettingColor' })
|
@ApiProperty({ enum: SETTING_COLORS, enumName: 'SettingColor' })
|
||||||
@IsIn(SETTING_COLORS)
|
@IsIn(SETTING_COLORS)
|
||||||
color!: string;
|
color!: string;
|
||||||
@ApiPropertyOptional({ default: false })
|
@ApiPropertyOptional({ type: Boolean, default: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
archived = false;
|
archived = false;
|
||||||
@@ -177,7 +177,7 @@ export class AreaOptionBodyDto {
|
|||||||
@MinLength(1)
|
@MinLength(1)
|
||||||
@MaxLength(40)
|
@MaxLength(40)
|
||||||
name!: string;
|
name!: string;
|
||||||
@ApiPropertyOptional({ default: false })
|
@ApiPropertyOptional({ type: Boolean, default: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
archived = false;
|
archived = false;
|
||||||
@@ -218,7 +218,7 @@ export class ProjectSettingsBodyDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class AnalyticsQueryDto {
|
export class AnalyticsQueryDto {
|
||||||
@ApiPropertyOptional({ default: 90, minimum: 7, maximum: 365 })
|
@ApiPropertyOptional({ type: Number, default: 90, minimum: 7, maximum: 365 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@@ -249,7 +249,7 @@ export class BoardQueryDto {
|
|||||||
@Matches(SETTING_ID)
|
@Matches(SETTING_ID)
|
||||||
area?: string;
|
area?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ default: 50, minimum: 1, maximum: 200 })
|
@ApiPropertyOptional({ type: Number, default: 50, minimum: 1, maximum: 200 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@@ -264,7 +264,7 @@ export class ActivityQueryDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
issueId?: string;
|
issueId?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ default: 50, minimum: 1, maximum: 200 })
|
@ApiPropertyOptional({ type: Number, default: 50, minimum: 1, maximum: 200 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@@ -282,7 +282,7 @@ export class ActivityQueryDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class PageQueryDto {
|
export class PageQueryDto {
|
||||||
@ApiPropertyOptional({ default: 50, minimum: 1, maximum: 200 })
|
@ApiPropertyOptional({ type: Number, default: 50, minimum: 1, maximum: 200 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@@ -290,7 +290,7 @@ export class PageQueryDto {
|
|||||||
@Max(200)
|
@Max(200)
|
||||||
limit = 50;
|
limit = 50;
|
||||||
|
|
||||||
@ApiPropertyOptional({ default: 0, minimum: 0 })
|
@ApiPropertyOptional({ type: Number, default: 0, minimum: 0 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@@ -302,6 +302,7 @@ export class PageQueryDto {
|
|||||||
|
|
||||||
export class MyIssuesQueryDto extends PageQueryDto {
|
export class MyIssuesQueryDto extends PageQueryDto {
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
|
type: Boolean,
|
||||||
default: true,
|
default: true,
|
||||||
description:
|
description:
|
||||||
'true: tickets assigned to me. false: any ticket of my projects (e.g. ?assignedToMe=false&category=QA)',
|
'true: tickets assigned to me. false: any ticket of my projects (e.g. ?assignedToMe=false&category=QA)',
|
||||||
@@ -333,7 +334,7 @@ export class MyIssuesQueryDto extends PageQueryDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class UpcomingReleasesQueryDto {
|
export class UpcomingReleasesQueryDto {
|
||||||
@ApiPropertyOptional({ default: 10, minimum: 1, maximum: 50 })
|
@ApiPropertyOptional({ type: Number, default: 10, minimum: 1, maximum: 50 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@@ -613,6 +614,7 @@ export class UpdateReleaseDto extends PartialType(CreateReleaseDto) {}
|
|||||||
|
|
||||||
export class PublishReleaseDto {
|
export class PublishReleaseDto {
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
description: 'Publish even with unfinished tickets',
|
description: 'Publish even with unfinished tickets',
|
||||||
})
|
})
|
||||||
|
|||||||
113
package-lock.json
generated
113
package-lock.json
generated
@@ -22,6 +22,8 @@
|
|||||||
"@apollo/server": "^5.5.1",
|
"@apollo/server": "^5.5.1",
|
||||||
"@as-integrations/express5": "^1.1.2",
|
"@as-integrations/express5": "^1.1.2",
|
||||||
"@graphql-typed-document-node/core": "^3.2.0",
|
"@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/apollo": "^13.4.5",
|
||||||
"@nestjs/common": "^11.0.0",
|
"@nestjs/common": "^11.0.0",
|
||||||
"@nestjs/config": "^4.0.4",
|
"@nestjs/config": "^4.0.4",
|
||||||
@@ -47,12 +49,14 @@
|
|||||||
"ioredis": "^5.11.1",
|
"ioredis": "^5.11.1",
|
||||||
"joi": "^18.2.9",
|
"joi": "^18.2.9",
|
||||||
"mongoose": "^8.24.4",
|
"mongoose": "^8.24.4",
|
||||||
|
"openapi-fetch": "^0.17.0",
|
||||||
"pg": "^8.23.1",
|
"pg": "^8.23.1",
|
||||||
"reflect-metadata": "^0.2.0",
|
"reflect-metadata": "^0.2.0",
|
||||||
"rxjs": "^7.8.0",
|
"rxjs": "^7.8.0",
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
"typeorm": "^0.3.31",
|
"typeorm": "^0.3.31",
|
||||||
"ws": "^8.22.0"
|
"ws": "^8.22.0",
|
||||||
|
"zod": "^4.6.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@analogjs/vite-plugin-angular": "~2.6.0",
|
"@analogjs/vite-plugin-angular": "~2.6.0",
|
||||||
@@ -2234,6 +2238,16 @@
|
|||||||
"node": "^20.19.0 || ^22.12.0 || >=23"
|
"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": {
|
"node_modules/@angular/common": {
|
||||||
"version": "22.1.8",
|
"version": "22.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/@angular/common/-/common-22.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/@angular/common/-/common-22.1.8.tgz",
|
||||||
@@ -10677,6 +10691,46 @@
|
|||||||
"integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==",
|
"integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@modelcontextprotocol/sdk": {
|
||||||
"version": "1.30.0",
|
"version": "1.30.0",
|
||||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz",
|
"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": {
|
"node_modules/@mongodb-js/saslprep": {
|
||||||
"version": "1.5.5",
|
"version": "1.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.5.5.tgz",
|
"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.5.5.tgz",
|
||||||
@@ -11144,6 +11211,28 @@
|
|||||||
"@tybys/wasm-util": "^0.9.0"
|
"@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": {
|
"node_modules/@nestjs/apollo": {
|
||||||
"version": "13.4.5",
|
"version": "13.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/apollo/-/apollo-13.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/apollo/-/apollo-13.4.5.tgz",
|
||||||
@@ -19567,7 +19656,6 @@
|
|||||||
"version": "4.13.12",
|
"version": "4.13.12",
|
||||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.12.tgz",
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.12.tgz",
|
||||||
"integrity": "sha512-6E2QDAc9Ick9Sq77ZrGS/dk2WUYni91aufTw6LJKpV7w8kW5/GxVUc650FOADOmlwg3K+f7Pun6XlV+pYmW6gw==",
|
"integrity": "sha512-6E2QDAc9Ick9Sq77ZrGS/dk2WUYni91aufTw6LJKpV7w8kW5/GxVUc650FOADOmlwg3K+f7Pun6XlV+pYmW6gw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.9.0"
|
"node": ">=16.9.0"
|
||||||
@@ -25518,6 +25606,21 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/opener": {
|
||||||
"version": "1.5.2",
|
"version": "1.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz",
|
||||||
@@ -32681,9 +32784,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "4.4.3",
|
"version": "4.6.5",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz",
|
||||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
"integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
"@apollo/server": "^5.5.1",
|
"@apollo/server": "^5.5.1",
|
||||||
"@as-integrations/express5": "^1.1.2",
|
"@as-integrations/express5": "^1.1.2",
|
||||||
"@graphql-typed-document-node/core": "^3.2.0",
|
"@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/apollo": "^13.4.5",
|
||||||
"@nestjs/common": "^11.0.0",
|
"@nestjs/common": "^11.0.0",
|
||||||
"@nestjs/config": "^4.0.4",
|
"@nestjs/config": "^4.0.4",
|
||||||
@@ -43,12 +45,14 @@
|
|||||||
"ioredis": "^5.11.1",
|
"ioredis": "^5.11.1",
|
||||||
"joi": "^18.2.9",
|
"joi": "^18.2.9",
|
||||||
"mongoose": "^8.24.4",
|
"mongoose": "^8.24.4",
|
||||||
|
"openapi-fetch": "^0.17.0",
|
||||||
"pg": "^8.23.1",
|
"pg": "^8.23.1",
|
||||||
"reflect-metadata": "^0.2.0",
|
"reflect-metadata": "^0.2.0",
|
||||||
"rxjs": "^7.8.0",
|
"rxjs": "^7.8.0",
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
"typeorm": "^0.3.31",
|
"typeorm": "^0.3.31",
|
||||||
"ws": "^8.22.0"
|
"ws": "^8.22.0",
|
||||||
|
"zod": "^4.6.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@analogjs/vite-plugin-angular": "~2.6.0",
|
"@analogjs/vite-plugin-angular": "~2.6.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user