Compare commits

..

2 Commits

Author SHA1 Message Date
9236cb73c6 feat(mcp): MCP server for AI clients, consuming the REST API
New app apps/mcp (NestJS + @nestjs-mcp/server 2): Streamable HTTP at
POST /mcp, stateless.

- 24 tools: projects (mine, public, details with settings, board, stats,
  analytics, activity), tickets (my work, search, details with comments and
  history, create, update, move, assign, release, comment, link), releases
  (list, upcoming, details with progress and burnup, create, publish) and
  users (whoami, search). Admin operations stay in the web.
- No credentials of its own: each call's Authorization header (a Pireon
  token) is forwarded to the API; without it only public projects are read.
- Typed REST client generated from apps/api/openapi.json (openapi-fetch +
  openapi-typescript via `nx run mcp:openapi-types`); API refusals become
  readable tool errors; parameters validated with zod.
- Tests: the real MCP against a simulated API (token forwarding, errors,
  validation, writes).
- Docker target `mcp` (Node 24) and an optional compose service under the
  `mcp` profile; zod declared as a root dependency.
- Docs: chapter 9 «Servidor MCP», references in 01/06/07 and the index;
  Word/PDF regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 18:59:38 +02:00
71dc2d6f2d fix(api): typed OpenAPI query params and not-found messages that hide private projects
- Query DTO properties with a default value (limit, offset, days, perColumn,
  assignedToMe, search, archived, requiresReason, force) were documented as
  `object` in openapi.json, which broke generated clients. They now declare
  their type explicitly.
- Reading a private project, ticket, release or its comments without access
  answered "Project <internal id> not found", confirming the project exists.
  ReadAccess now reports what the caller asked for (e2e covered).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 18:59:38 +02:00
47 changed files with 6136 additions and 153 deletions

View File

@@ -9,3 +9,6 @@ package-lock.json
# Generated by the API on startup (nx serve api): keep them byte-for-byte as written
apps/api/openapi.json
apps/api/schema.gql
# Generated from apps/api/openapi.json (nx run mcp:openapi-types)
apps/mcp/src/app/api/taskstream.schema.ts

28
.vscode/launch.json vendored
View File

@@ -6,24 +6,36 @@
"request": "launch",
"name": "Debug api with Nx",
"runtimeExecutable": "npx",
"runtimeArgs": [
"nx",
"serve",
"api"
],
"runtimeArgs": ["nx", "serve", "api"],
"env": {
"NODE_OPTIONS": "--inspect=9229"
},
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"skipFiles": [
"<node_internals>/**"
],
"skipFiles": ["<node_internals>/**"],
"sourceMaps": true,
"outFiles": [
"${workspaceFolder}/apps/api/dist/**/*.(m|c|)js",
"!**/node_modules/**"
]
},
{
"type": "node",
"request": "launch",
"name": "Debug mcp with Nx",
"runtimeExecutable": "npx",
"runtimeArgs": ["nx", "serve", "mcp"],
"env": {
"NODE_OPTIONS": "--inspect=9230"
},
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"skipFiles": ["<node_internals>/**"],
"sourceMaps": true,
"outFiles": [
"${workspaceFolder}/apps/mcp/dist/**/*.(m|c|)js",
"!**/node_modules/**"
]
}
]
}

View File

@@ -1,6 +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 --profile mcp up -d --build → plus the MCP server
#
# @pi/ui comes from Pireon's private Nexus. Its token is passed as a build
# secret (compose: `secrets.nexus_token` ← $NEXUS_TOKEN) and never lands in a
@@ -35,6 +36,24 @@ HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --retries=5 \
CMD wget -qO- http://127.0.0.1:3000/api/health > /dev/null || exit 1
CMD ["node", "main.js"]
# ---- MCP: server for AI clients, consumes the API over REST ------------
FROM deps AS build-mcp
COPY . .
RUN npx nx run mcp:prune
# @nestjs-mcp/server 2 targets Node 24.
FROM node:24-alpine AS mcp
WORKDIR /app
ENV NODE_ENV=production MCP_PORT=3010
COPY --from=build-mcp /workspace/dist/apps/mcp/package.json /workspace/dist/apps/mcp/package-lock.json ./
RUN npm ci --omit=dev --ignore-scripts --no-audit --no-fund && npm cache clean --force
COPY --from=build-mcp /workspace/dist/apps/mcp/main.js ./
USER node
EXPOSE 3010
HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=5 \
CMD wget -qO- http://127.0.0.1:3010/health > /dev/null || exit 1
CMD ["node", "main.js"]
# ---- Web: Angular SPA behind nginx (proxies /api and /graphql) ---------
FROM deps AS build-web
COPY . .

View File

@@ -977,7 +977,14 @@ describe('task-stream (e2e)', () => {
() => rest(null, 'GET', `/projects/${key}`),
(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 () => {

View File

@@ -179,11 +179,7 @@
"description": "Name or email",
"schema": {
"default": "",
"allOf": [
{
"$ref": "#/components/schemas/Object"
}
]
"type": "string"
}
},
{
@@ -194,11 +190,7 @@
"minimum": 1,
"maximum": 100,
"default": 20,
"allOf": [
{
"$ref": "#/components/schemas/Object"
}
]
"type": "number"
}
}
],
@@ -1023,11 +1015,7 @@
"minimum": 1,
"maximum": 200,
"default": 50,
"allOf": [
{
"$ref": "#/components/schemas/Object"
}
]
"type": "number"
}
}
],
@@ -1206,11 +1194,7 @@
"minimum": 7,
"maximum": 365,
"default": 90,
"allOf": [
{
"$ref": "#/components/schemas/Object"
}
]
"type": "number"
}
}
],
@@ -1313,11 +1297,7 @@
"minimum": 1,
"maximum": 200,
"default": 50,
"allOf": [
{
"$ref": "#/components/schemas/Object"
}
]
"type": "number"
}
},
{
@@ -1752,11 +1732,7 @@
"minimum": 1,
"maximum": 200,
"default": 50,
"allOf": [
{
"$ref": "#/components/schemas/Object"
}
]
"type": "number"
}
},
{
@@ -1766,11 +1742,7 @@
"schema": {
"minimum": 0,
"default": 0,
"allOf": [
{
"$ref": "#/components/schemas/Object"
}
]
"type": "number"
}
},
{
@@ -1780,11 +1752,7 @@
"description": "true: tickets assigned to me. false: any ticket of my projects (e.g. ?assignedToMe=false&category=QA)",
"schema": {
"default": true,
"allOf": [
{
"$ref": "#/components/schemas/Object"
}
]
"type": "boolean"
}
},
{
@@ -1900,11 +1868,7 @@
"minimum": 1,
"maximum": 200,
"default": 50,
"allOf": [
{
"$ref": "#/components/schemas/Object"
}
]
"type": "number"
}
},
{
@@ -1914,11 +1878,7 @@
"schema": {
"minimum": 0,
"default": 0,
"allOf": [
{
"$ref": "#/components/schemas/Object"
}
]
"type": "number"
}
},
{
@@ -2981,11 +2941,7 @@
"minimum": 1,
"maximum": 200,
"default": 50,
"allOf": [
{
"$ref": "#/components/schemas/Object"
}
]
"type": "number"
}
},
{
@@ -2995,11 +2951,7 @@
"schema": {
"minimum": 0,
"default": 0,
"allOf": [
{
"$ref": "#/components/schemas/Object"
}
]
"type": "number"
}
}
],
@@ -3201,11 +3153,7 @@
"minimum": 1,
"maximum": 200,
"default": 50,
"allOf": [
{
"$ref": "#/components/schemas/Object"
}
]
"type": "number"
}
},
{
@@ -3215,11 +3163,7 @@
"schema": {
"minimum": 0,
"default": 0,
"allOf": [
{
"$ref": "#/components/schemas/Object"
}
]
"type": "number"
}
}
],
@@ -3511,11 +3455,7 @@
"minimum": 1,
"maximum": 50,
"default": 10,
"allOf": [
{
"$ref": "#/components/schemas/Object"
}
]
"type": "number"
}
}
],
@@ -4149,11 +4089,7 @@
"minimum": 1,
"maximum": 200,
"default": 50,
"allOf": [
{
"$ref": "#/components/schemas/Object"
}
]
"type": "number"
}
},
{
@@ -4163,11 +4099,7 @@
"schema": {
"minimum": 0,
"default": 0,
"allOf": [
{
"$ref": "#/components/schemas/Object"
}
]
"type": "number"
}
}
],
@@ -4730,10 +4662,6 @@
"message"
]
},
"Object": {
"type": "object",
"properties": {}
},
"UserDto": {
"type": "object",
"properties": {
@@ -5168,11 +5096,11 @@
]
},
"requiresReason": {
"type": "object",
"type": "boolean",
"default": false
},
"archived": {
"type": "object",
"type": "boolean",
"default": false
}
},
@@ -5233,7 +5161,7 @@
]
},
"archived": {
"type": "object",
"type": "boolean",
"default": false
}
},
@@ -5257,7 +5185,7 @@
"maxLength": 40
},
"archived": {
"type": "object",
"type": "boolean",
"default": false
}
},
@@ -6410,7 +6338,7 @@
"type": "object",
"properties": {
"force": {
"type": "object",
"type": "boolean",
"default": false,
"description": "Publish even with unfinished tickets"
}

View 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
View 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
View 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
View 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"
}
}
}
}

View 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;
}

File diff suppressed because it is too large Load Diff

View 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 {}

View 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' };
}
}

View 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);
});
});

View 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 },
}),
),
);
}
}

View 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})`);

View 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,
})),
);
}
}

View 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 },
}),
),
);
}
}

View 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,
});

View 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}` },
],
};
}
}

View 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 })),
);
}
}

View File

15
apps/mcp/src/main.ts Normal file
View 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();

View 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
View File

@@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.base.json",
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"compilerOptions": {
"esModuleInterop": true
}
}

View 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"
]
}

View 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,
}),
],
};

View File

@@ -6,6 +6,8 @@ services:
ports: ['8080:80']
api:
ports: ['3000:3000']
mcp:
ports: ['3010:3010']
postgres:
ports: ['5432:5432']
mongo:

View File

@@ -62,6 +62,27 @@ services:
redis: { condition: service_healthy }
restart: unless-stopped
# MCP server for AI clients (Claude, Cursor…): Streamable HTTP at /mcp. It consumes the API
# with each caller's own Pireon token (Authorization header), so it holds no credentials.
# Optional: it only starts with the `mcp` profile (`docker compose --profile mcp up -d`; in
# Dokploy, COMPOSE_PROFILES=mcp) and needs a domain → service `mcp`, port 3010.
mcp:
profiles: [mcp]
build:
context: .
target: mcp
secrets: [nexus_token]
image: task-stream-mcp
expose: ['3010']
networks:
default:
aliases: [task-stream-mcp]
environment:
TASKSTREAM_API_URL: http://task-stream-api:3000
depends_on:
api: { condition: service_healthy }
restart: unless-stopped
postgres:
image: postgres:17-alpine
networks:

View File

@@ -44,12 +44,13 @@ proyecto (`OWNER`, `ADMIN`, `MEMBER`, `VIEWER`).
apps/
api/ composición de la API (NestJS): config, GraphQL, conexiones, e2e
web/ composición del cliente (Angular): rutas, providers, estilos, nginx
mcp/ servidor MCP para asistentes de IA (NestJS), consume la API REST
libs/
api/{shared,identity,tracker}/{domain,application,infrastructure,presenters}
web/{shared,identity,tracker}/{domain,application,infrastructure,presenters} (+ web/shared/ui)
docs/ esta documentación
codegen.ts tipos GraphQL del cliente generados desde apps/api/schema.gql
Dockerfile dos destinos: api y web
Dockerfile tres destinos: api, web y mcp
docker-compose.yml
```

View File

@@ -26,12 +26,14 @@ la web y la API comparten origen igual que en Docker.
## 6.3 Comandos
| Comando | Qué hace |
| -------------------------------- | --------------------------------------------------------------------------------- |
| -------------------------------- | ----------------------------------------------------------------------------------- |
| `npx nx run-many -t lint` | Lint de todo, incluidas las reglas de capas y contextos |
| `npx nx run-many -t test` | Tests unitarios (dominio y aplicación, API y web) |
| `npx nx e2e api` | Flujo completo de la API contra Docker, con Pireon simulado |
| `npx nx run-many -t build` | Builds de la API y la web |
| `npx nx run web:codegen` | Regenera los tipos GraphQL del cliente desde `apps/api/schema.gql` |
| `npx nx serve mcp` | Servidor MCP en `http://localhost:3010/mcp`, contra la API local ([MCP](09-mcp.md)) |
| `npx nx run mcp:openapi-types` | Regenera los tipos REST del MCP desde `apps/api/openapi.json` |
| `npx nx run api:prune` | Build de la API con `package.json` y lockfile podados (lo usa Docker) |
| `npx nx graph` | Grafo de dependencias entre proyectos |
| `npx nx test api-tracker-domain` | Tests de una librería concreta (`api-<contexto>-<capa>`, `web-<contexto>-<capa>`) |

View File

@@ -40,7 +40,7 @@ Todos tienen healthcheck; `web` espera a que `api` esté sana, y `api` a las tre
### Imágenes
El `Dockerfile` tiene dos destinos que comparten la instalación de dependencias:
El `Dockerfile` tiene tres destinos que comparten la instalación de dependencias:
- **Instalación**: `npm ci` del workspace. `@pi/ui` viene del Nexus privado; el token llega como **build secret**
desde la variable `NEXUS_TOKEN` (`secrets.nexus_token` en `docker-compose.yml`), se escribe en un `npmrc` temporal y
@@ -48,6 +48,8 @@ El `Dockerfile` tiene dos destinos que comparten la instalación de dependencias
- **`api`**: `nx run api:prune` genera el bundle con un `package.json` y un lockfile podados a partir de
`apps/api/package.json`. La imagen final solo instala esas dependencias (sin Angular ni paquetes privados).
Si la API empieza a usar un paquete nuevo **en tiempo de ejecución**, hay que añadirlo a `apps/api/package.json`.
- **`mcp`**: el [servidor MCP](09-mcp.md), podado igual que la API (`nx run mcp:prune`), sobre Node 24. Su servicio
en `docker-compose.yml` va en el perfil `mcp`: solo arranca con `--profile mcp` (en Dokploy, `COMPOSE_PROFILES=mcp`).
- **`web`**: build de producción de Angular servido por nginx (`apps/web/nginx.conf`): fallback de rutas a
`index.html`, caché larga para los bundles con hash y sin caché para `index.html`.

132
docs/09-mcp.md Normal file
View 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.

View File

@@ -4,7 +4,7 @@ TaskStream es una alternativa a Jira para equipos de desarrollo: proyectos, tick
tareas, QA), releases, comentarios y relaciones entre tickets, todo en tiempo real.
| Documento | Para qué |
| ----------------------------------------- | ------------------------------------------------------------------------ |
| ----------------------------------------- | ------------------------------------------------------------------------- |
| [1. Visión general](01-vision-general.md) | Qué hace el producto, stack y estructura del monorepo |
| [2. Arquitectura](02-arquitectura.md) | Hexagonal, CQRS, event sourcing, flujo escritura → lectura y tiempo real |
| [3. API](03-api.md) | Dominio (agregados, workflow, eventos) y la API GraphQL |
@@ -13,6 +13,7 @@ tareas, QA), releases, comentarios y relaciones entre tickets, todo en tiempo re
| [6. Desarrollo](06-desarrollo.md) | Puesta en marcha, comandos, tests y cómo añadir una funcionalidad |
| [7. Despliegue](07-despliegue.md) | Docker, variables de entorno y escalado |
| [8. Decisiones](08-decisiones.md) | Por qué está hecho así (y qué alternativas se descartaron) |
| [9. Servidor MCP](09-mcp.md) | Usar TaskStream desde asistentes de IA (Claude…): herramientas y conexión |
Cada librería de `libs/` tiene además su propio `README.md` con su responsabilidad y su contenido.

Binary file not shown.

Binary file not shown.

View File

@@ -48,13 +48,17 @@ export class MeDto extends UserDto {
}
export class UserSearchQueryDto {
@ApiPropertyOptional({ description: 'Name or email', default: '' })
@ApiPropertyOptional({
type: String,
description: 'Name or email',
default: '',
})
@IsOptional()
@IsString()
@MaxLength(100)
search = '';
@ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 })
@ApiPropertyOptional({ type: Number, default: 20, minimum: 1, maximum: 100 })
@IsOptional()
@Type(() => Number)
@IsInt()

View File

@@ -21,7 +21,10 @@ export class GetCommentsQueryHandler
'Issue',
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(
issue.id,
Math.min(query.limit, 200),

View File

@@ -18,7 +18,10 @@ export class GetIssueQueryHandler
? await this.findIssues.findById(query.idOrKey)
: await this.findIssues.findByKey(query.idOrKey.toUpperCase());
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;
}
}

View File

@@ -18,7 +18,10 @@ export class GetProjectQueryHandler
? await this.findProjects.findById(query.idOrKey)
: await this.findProjects.findByKey(query.idOrKey.toUpperCase());
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;
}
}

View File

@@ -24,7 +24,10 @@ export class GetReleaseBurnupQueryHandler
'Release',
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([
this.findIssues.findByProject(release.projectId),
this.findTimeline.findByProject(release.projectId),

View File

@@ -23,7 +23,10 @@ export class GetReleaseProgressQueryHandler
'Release',
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);
}
}

View File

@@ -19,7 +19,10 @@ export class GetReleaseQueryHandler
'Release',
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;
}
}

View File

@@ -12,10 +12,18 @@ import { FindProjectsRepository } from '../ports/find-projects.repository';
export class ReadAccess {
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(
projectId: string,
viewerId: string | null,
asked: { entity: string; ref: string } = {
entity: 'Project',
ref: projectId,
},
): Promise<ProjectRole | null> {
const role = viewerId
? await this.findProjects.findRole(projectId, viewerId)
@@ -23,7 +31,7 @@ export class ReadAccess {
if (role) return role;
const project = await this.findProjects.findById(projectId);
// 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;
}

View File

@@ -132,11 +132,11 @@ export class WorkflowStatusBodyDto {
@ApiProperty({ enum: SETTING_COLORS, enumName: 'SettingColor' })
@IsIn(SETTING_COLORS)
color!: string;
@ApiPropertyOptional({ default: false })
@ApiPropertyOptional({ type: Boolean, default: false })
@IsOptional()
@IsBoolean()
requiresReason = false;
@ApiPropertyOptional({ default: false })
@ApiPropertyOptional({ type: Boolean, default: false })
@IsOptional()
@IsBoolean()
archived = false;
@@ -164,7 +164,7 @@ export class IssueTypeOptionBodyDto {
@ApiProperty({ enum: SETTING_COLORS, enumName: 'SettingColor' })
@IsIn(SETTING_COLORS)
color!: string;
@ApiPropertyOptional({ default: false })
@ApiPropertyOptional({ type: Boolean, default: false })
@IsOptional()
@IsBoolean()
archived = false;
@@ -177,7 +177,7 @@ export class AreaOptionBodyDto {
@MinLength(1)
@MaxLength(40)
name!: string;
@ApiPropertyOptional({ default: false })
@ApiPropertyOptional({ type: Boolean, default: false })
@IsOptional()
@IsBoolean()
archived = false;
@@ -218,7 +218,7 @@ export class ProjectSettingsBodyDto {
}
export class AnalyticsQueryDto {
@ApiPropertyOptional({ default: 90, minimum: 7, maximum: 365 })
@ApiPropertyOptional({ type: Number, default: 90, minimum: 7, maximum: 365 })
@IsOptional()
@Type(() => Number)
@IsInt()
@@ -249,7 +249,7 @@ export class BoardQueryDto {
@Matches(SETTING_ID)
area?: string;
@ApiPropertyOptional({ default: 50, minimum: 1, maximum: 200 })
@ApiPropertyOptional({ type: Number, default: 50, minimum: 1, maximum: 200 })
@IsOptional()
@Type(() => Number)
@IsInt()
@@ -264,7 +264,7 @@ export class ActivityQueryDto {
@IsString()
issueId?: string;
@ApiPropertyOptional({ default: 50, minimum: 1, maximum: 200 })
@ApiPropertyOptional({ type: Number, default: 50, minimum: 1, maximum: 200 })
@IsOptional()
@Type(() => Number)
@IsInt()
@@ -282,7 +282,7 @@ export class ActivityQueryDto {
}
export class PageQueryDto {
@ApiPropertyOptional({ default: 50, minimum: 1, maximum: 200 })
@ApiPropertyOptional({ type: Number, default: 50, minimum: 1, maximum: 200 })
@IsOptional()
@Type(() => Number)
@IsInt()
@@ -290,7 +290,7 @@ export class PageQueryDto {
@Max(200)
limit = 50;
@ApiPropertyOptional({ default: 0, minimum: 0 })
@ApiPropertyOptional({ type: Number, default: 0, minimum: 0 })
@IsOptional()
@Type(() => Number)
@IsInt()
@@ -302,6 +302,7 @@ export class PageQueryDto {
export class MyIssuesQueryDto extends PageQueryDto {
@ApiPropertyOptional({
type: Boolean,
default: true,
description:
'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 {
@ApiPropertyOptional({ default: 10, minimum: 1, maximum: 50 })
@ApiPropertyOptional({ type: Number, default: 10, minimum: 1, maximum: 50 })
@IsOptional()
@Type(() => Number)
@IsInt()
@@ -613,6 +614,7 @@ export class UpdateReleaseDto extends PartialType(CreateReleaseDto) {}
export class PublishReleaseDto {
@ApiPropertyOptional({
type: Boolean,
default: false,
description: 'Publish even with unfinished tickets',
})

113
package-lock.json generated
View File

@@ -22,6 +22,8 @@
"@apollo/server": "^5.5.1",
"@as-integrations/express5": "^1.1.2",
"@graphql-typed-document-node/core": "^3.2.0",
"@modelcontextprotocol/server": "^2.3.1",
"@nestjs-mcp/server": "^2.0.0",
"@nestjs/apollo": "^13.4.5",
"@nestjs/common": "^11.0.0",
"@nestjs/config": "^4.0.4",
@@ -47,12 +49,14 @@
"ioredis": "^5.11.1",
"joi": "^18.2.9",
"mongoose": "^8.24.4",
"openapi-fetch": "^0.17.0",
"pg": "^8.23.1",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0",
"tslib": "^2.8.1",
"typeorm": "^0.3.31",
"ws": "^8.22.0"
"ws": "^8.22.0",
"zod": "^4.6.5"
},
"devDependencies": {
"@analogjs/vite-plugin-angular": "~2.6.0",
@@ -2234,6 +2238,16 @@
"node": "^20.19.0 || ^22.12.0 || >=23"
}
},
"node_modules/@angular/cli/node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@angular/common": {
"version": "22.1.8",
"resolved": "https://registry.npmjs.org/@angular/common/-/common-22.1.8.tgz",
@@ -10677,6 +10691,46 @@
"integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==",
"license": "MIT"
},
"node_modules/@modelcontextprotocol/core": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.3.1.tgz",
"integrity": "sha512-laVmIhPGpWi7rG9q+0UigblRN0Hws/Yjlb6qLFtVqryXVLkc4h+yN8z731POD2LnAGQlFe7/tXhUm7Bi0YB91g==",
"license": "Apache-2.0",
"dependencies": {
"zod": "^4.2.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@modelcontextprotocol/node": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/node/-/node-2.1.1.tgz",
"integrity": "sha512-EY92OAXN2xYL7tNS/MVsWJW1v8VHAPwl9B4Ca3pCrjmvqyC+S1C/6UHsMxF2GoFPraS5Yyg56vFz9brVpFojMA==",
"license": "Apache-2.0",
"dependencies": {
"@hono/node-server": "^1.19.9",
"hono": "^4.11.4"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@modelcontextprotocol/server": "^2.3.0"
}
},
"node_modules/@modelcontextprotocol/node/node_modules/@hono/node-server": {
"version": "1.19.17",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz",
"integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==",
"license": "MIT",
"engines": {
"node": ">=18.14.1"
},
"peerDependencies": {
"hono": "^4"
}
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz",
@@ -10718,6 +10772,19 @@
}
}
},
"node_modules/@modelcontextprotocol/server": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.3.1.tgz",
"integrity": "sha512-e59MfWuSssj6DoQ96JCTqdW1AXT5/XtnTl0WWd6MU1Ka434fFUGGosCLJbGNySDhHGvxQWoRWK137FXYFkYNWw==",
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/core": "2.3.1",
"zod": "^4.2.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@mongodb-js/saslprep": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.5.5.tgz",
@@ -11144,6 +11211,28 @@
"@tybys/wasm-util": "^0.9.0"
}
},
"node_modules/@nestjs-mcp/server": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@nestjs-mcp/server/-/server-2.0.0.tgz",
"integrity": "sha512-5hC/T4W++78d/gEcTuqGU3jOPddrL1wBd+cRWzDzxmv7MronwXxzkFyVDVG2+matGlGrxNM+M5XeGVVjHN05Sw==",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/node": "^2.0.0",
"@modelcontextprotocol/server": "^2.0.0",
"zod": "^4.4.3"
},
"engines": {
"node": ">=24",
"pnpm": ">=11"
},
"peerDependencies": {
"@nestjs/common": "^11.1.28",
"@nestjs/core": "^11.1.28",
"@nestjs/platform-express": "^11.1.28",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2"
}
},
"node_modules/@nestjs/apollo": {
"version": "13.4.5",
"resolved": "https://registry.npmjs.org/@nestjs/apollo/-/apollo-13.4.5.tgz",
@@ -19567,7 +19656,6 @@
"version": "4.13.12",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.12.tgz",
"integrity": "sha512-6E2QDAc9Ick9Sq77ZrGS/dk2WUYni91aufTw6LJKpV7w8kW5/GxVUc650FOADOmlwg3K+f7Pun6XlV+pYmW6gw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@@ -25518,6 +25606,21 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/openapi-fetch": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.17.0.tgz",
"integrity": "sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==",
"license": "MIT",
"dependencies": {
"openapi-typescript-helpers": "^0.1.0"
}
},
"node_modules/openapi-typescript-helpers": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.1.0.tgz",
"integrity": "sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==",
"license": "MIT"
},
"node_modules/opener": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz",
@@ -32681,9 +32784,9 @@
}
},
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"version": "4.6.5",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz",
"integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"

View File

@@ -18,6 +18,8 @@
"@apollo/server": "^5.5.1",
"@as-integrations/express5": "^1.1.2",
"@graphql-typed-document-node/core": "^3.2.0",
"@modelcontextprotocol/server": "^2.3.1",
"@nestjs-mcp/server": "^2.0.0",
"@nestjs/apollo": "^13.4.5",
"@nestjs/common": "^11.0.0",
"@nestjs/config": "^4.0.4",
@@ -43,12 +45,14 @@
"ioredis": "^5.11.1",
"joi": "^18.2.9",
"mongoose": "^8.24.4",
"openapi-fetch": "^0.17.0",
"pg": "^8.23.1",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0",
"tslib": "^2.8.1",
"typeorm": "^0.3.31",
"ws": "^8.22.0"
"ws": "^8.22.0",
"zod": "^4.6.5"
},
"devDependencies": {
"@analogjs/vite-plugin-angular": "~2.6.0",