mcp support

This commit is contained in:
Bastian Wagner
2026-06-11 11:19:07 +02:00
parent e1cc78ca27
commit c8603be226
11 changed files with 1116 additions and 9 deletions

View File

@@ -0,0 +1,143 @@
import { BadRequestException } from '@nestjs/common';
import {
ListTemplate,
UserList,
} from '../list-templates/list-template.types';
import { ListTemplatesService } from '../list-templates/list-templates.service';
import { ListsService } from '../lists/lists.service';
import { ListSuggestionAgentService } from './list-suggestion-agent.service';
describe('ListSuggestionAgentService', () => {
let listsService: Pick<ListsService, 'listLists' | 'createList'>;
let listTemplatesService: Pick<ListTemplatesService, 'listTemplates'>;
let service: ListSuggestionAgentService;
beforeEach(() => {
listsService = {
listLists: jest.fn(),
createList: jest.fn(),
};
listTemplatesService = {
listTemplates: jest.fn(),
};
service = new ListSuggestionAgentService(
listsService as ListsService,
listTemplatesService as ListTemplatesService,
);
});
it('suggests read-only list ideas from matching templates', async () => {
jest.mocked(listsService.listLists).mockResolvedValue([
list({ name: 'Urlaub: Sommerurlaub' }),
]);
jest.mocked(listTemplatesService.listTemplates).mockResolvedValue([
template({
id: 'template-1',
name: 'Urlaub',
kind: 'packing',
items: ['Pass', 'Tickets', 'Ladegeraete'],
}),
template({
id: 'template-2',
name: 'Wocheneinkauf',
kind: 'shopping',
items: ['Milch'],
}),
]);
const result = await service.suggestLists('user-1', {
goal: 'Sommerurlaub',
constraints: ['Handgepaeck beachten'],
});
expect(result.suggestions[0]).toEqual(
expect.objectContaining({
name: 'Urlaub: Sommerurlaub 2',
kind: 'packing',
sourceTemplateId: 'template-1',
}),
);
expect(result.suggestions[0].items[0]).toEqual(
expect.objectContaining({
title: 'Handgepaeck beachten',
required: true,
}),
);
expect(listsService.createList).not.toHaveBeenCalled();
});
it('falls back to inferred kind when no template matches', async () => {
jest.mocked(listsService.listLists).mockResolvedValue([]);
jest.mocked(listTemplatesService.listTemplates).mockResolvedValue([]);
const result = await service.suggestLists('user-1', {
goal: 'Projektplanung fuer Release',
});
expect(result.suggestions[0]).toEqual(
expect.objectContaining({
name: 'Projektplanung fuer Release',
kind: 'todo',
}),
);
expect(result.suggestions[0].items).toEqual(
expect.arrayContaining([
expect.objectContaining({ title: 'Ziel klaeren' }),
]),
);
});
it('rejects empty goals and invalid constraints', async () => {
await expect(service.suggestLists('user-1', { goal: ' ' })).rejects.toThrow(
BadRequestException,
);
await expect(
service.suggestLists('user-1', {
goal: 'Liste',
constraints: 'bad' as never,
}),
).rejects.toThrow('Constraints must be an array.');
});
});
function template(options: {
id: string;
name: string;
kind: ListTemplate['kind'];
items: string[];
}): ListTemplate {
return {
id: options.id,
ownerId: 'user-1',
name: options.name,
kind: options.kind,
items: options.items.map((title, position) => ({
id: `${options.id}-item-${position}`,
title,
required: true,
position,
createdAt: now(),
updatedAt: now(),
})),
createdAt: now(),
updatedAt: now(),
};
}
function list(options: { name: string }): UserList {
return {
id: 'list-1',
ownerId: 'user-1',
accessRole: 'owner',
name: options.name,
kind: 'packing',
items: [],
collaborators: [],
createdAt: now(),
updatedAt: now(),
};
}
function now(): string {
return new Date(0).toISOString();
}

View File

@@ -0,0 +1,280 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import {
ListTemplate,
ListTemplateKind,
UserList,
} from '../list-templates/list-template.types';
import { ListTemplatesService } from '../list-templates/list-templates.service';
import { ListsService } from '../lists/lists.service';
import {
ListSuggestionsResult,
SuggestListsInput,
SuggestedList,
SuggestedListItem,
} from './list-suggestion.types';
@Injectable()
export class ListSuggestionAgentService {
constructor(
private readonly listsService: ListsService,
private readonly listTemplatesService: ListTemplatesService,
) {}
async suggestLists(
userId: string,
input: SuggestListsInput,
): Promise<ListSuggestionsResult> {
const goal = this.requireGoal(input.goal);
const kind = this.normalizeKind(input.kind) ?? this.inferKind(goal);
const constraints = this.normalizeConstraints(input.constraints);
const [lists, templates] = await Promise.all([
this.listsService.listLists(userId),
this.listTemplatesService.listTemplates(userId),
]);
const existingNames = new Set(lists.map((list) => this.nameKey(list.name)));
const matchingTemplates = this.rankTemplates(templates, goal, kind).slice(0, 2);
const suggestions = matchingTemplates.map((template) =>
this.suggestFromTemplate(template, goal, constraints, existingNames),
);
if (suggestions.length < 3) {
suggestions.push(
this.suggestFallbackList(goal, kind, constraints, existingNames),
);
}
return {
suggestions: suggestions.slice(0, 3),
};
}
private suggestFromTemplate(
template: ListTemplate,
goal: string,
constraints: string[],
existingNames: Set<string>,
): SuggestedList {
const name = this.uniqueName(
`${template.name}: ${this.toTitleFragment(goal)}`,
existingNames,
);
const items = [...template.items]
.sort((left, right) => left.position - right.position)
.slice(0, 12)
.map((item) => ({
title: item.title,
notes: item.notes,
quantity: item.quantity,
required: item.required,
}));
return {
name,
description:
template.description ??
`Vorschlag auf Basis der Vorlage "${template.name}".`,
kind: template.kind,
items: this.withConstraintItems(items, constraints),
sourceTemplateId: template.id,
sourceTemplateName: template.name,
rationale: `Nutzt die bestehende Vorlage "${template.name}", weil sie zum Ziel passt.`,
};
}
private suggestFallbackList(
goal: string,
kind: ListTemplateKind,
constraints: string[],
existingNames: Set<string>,
): SuggestedList {
return {
name: this.uniqueName(this.toTitleFragment(goal), existingNames),
description: `Neue ${this.kindLabel(kind)} fuer ${goal}.`,
kind,
items: this.withConstraintItems(this.defaultItems(kind), constraints),
rationale:
'Erzeugt eine neue Liste, weil keine passendere Vorlage priorisiert wurde.',
};
}
private rankTemplates(
templates: ListTemplate[],
goal: string,
kind: ListTemplateKind,
): ListTemplate[] {
const goalTokens = this.tokenize(goal);
return [...templates]
.map((template) => ({
template,
score:
(template.kind === kind ? 5 : 0) +
this.tokenScore(goalTokens, template.name) +
this.tokenScore(goalTokens, template.description ?? '') +
template.items.reduce(
(score, item) => score + this.tokenScore(goalTokens, item.title),
0,
),
}))
.filter((entry) => entry.score > 0)
.sort((left, right) => right.score - left.score)
.map((entry) => entry.template);
}
private withConstraintItems(
items: SuggestedListItem[],
constraints: string[],
): SuggestedListItem[] {
const constraintItems = constraints.map((constraint) => ({
title: constraint,
notes: 'Vom Nutzer genannte Randbedingung.',
required: true,
}));
return [...constraintItems, ...items].slice(0, 15);
}
private defaultItems(kind: ListTemplateKind): SuggestedListItem[] {
if (kind === 'packing') {
return [
{ title: 'Reisedokumente pruefen', required: true },
{ title: 'Tickets und Buchungen sichern', required: true },
{ title: 'Ladegeraete einpacken', required: true },
{ title: 'Kleidung nach Wetter planen', required: true },
{ title: 'Reiseapotheke vorbereiten', required: false },
];
}
if (kind === 'shopping') {
return [
{ title: 'Grundnahrungsmittel', required: true },
{ title: 'Frisches Obst und Gemuese', required: true },
{ title: 'Getraenke', required: false },
{ title: 'Haushaltsartikel', required: false },
{ title: 'Vorratsschrank pruefen', required: true },
];
}
if (kind === 'todo') {
return [
{ title: 'Ziel klaeren', required: true },
{ title: 'Naechste konkrete Aufgabe festlegen', required: true },
{ title: 'Abhaengigkeiten pruefen', required: true },
{ title: 'Zeitfenster einplanen', required: false },
{ title: 'Offene Punkte nachfassen', required: false },
];
}
return [
{ title: 'Ziel pruefen', required: true },
{ title: 'Wichtige Punkte sammeln', required: true },
{ title: 'Prioritaeten setzen', required: true },
{ title: 'Naechsten Schritt festlegen', required: false },
];
}
private inferKind(goal: string): ListTemplateKind {
const normalizedGoal = goal.toLowerCase();
if (/(urlaub|reise|pack|koffer|trip|flug|hotel)/.test(normalizedGoal)) {
return 'packing';
}
if (/(einkauf|shopping|supermarkt|lebensmittel|markt)/.test(normalizedGoal)) {
return 'shopping';
}
if (/(todo|aufgabe|projekt|planung|woche|erledigen)/.test(normalizedGoal)) {
return 'todo';
}
return 'custom';
}
private normalizeKind(kind?: ListTemplateKind): ListTemplateKind | undefined {
if (kind === undefined) {
return undefined;
}
if (
kind !== 'packing' &&
kind !== 'shopping' &&
kind !== 'todo' &&
kind !== 'custom'
) {
throw new BadRequestException('List kind is invalid.');
}
return kind;
}
private normalizeConstraints(constraints?: string[]): string[] {
if (constraints === undefined) {
return [];
}
if (!Array.isArray(constraints)) {
throw new BadRequestException('Constraints must be an array.');
}
return constraints
.map((constraint) => constraint.trim())
.filter((constraint) => constraint.length > 0)
.slice(0, 5);
}
private requireGoal(goal?: string): string {
const normalizedGoal = goal?.trim();
if (!normalizedGoal) {
throw new BadRequestException('Suggestion goal is required.');
}
return normalizedGoal;
}
private uniqueName(name: string, existingNames: Set<string>): string {
let candidate = name;
let suffix = 2;
while (existingNames.has(this.nameKey(candidate))) {
candidate = `${name} ${suffix}`;
suffix += 1;
}
existingNames.add(this.nameKey(candidate));
return candidate;
}
private tokenScore(tokens: string[], value: string): number {
const normalizedValue = value.toLowerCase();
return tokens.filter((token) => normalizedValue.includes(token)).length;
}
private tokenize(value: string): string[] {
return value
.toLowerCase()
.split(/[^a-z0-9]+/i)
.map((token) => token.trim())
.filter((token) => token.length >= 3);
}
private toTitleFragment(value: string): string {
const compactValue = value.replace(/\s+/g, ' ').trim();
return compactValue.charAt(0).toUpperCase() + compactValue.slice(1);
}
private nameKey(name: string): string {
return name.trim().toLowerCase();
}
private kindLabel(kind: ListTemplateKind): string {
return kind === 'packing'
? 'Packliste'
: kind === 'shopping'
? 'Einkaufsliste'
: kind === 'todo'
? 'Todo-Liste'
: 'Liste';
}
}

View File

@@ -0,0 +1,28 @@
import { ListTemplateKind } from '../list-templates/list-template.types';
export interface SuggestListsInput {
goal?: string;
kind?: ListTemplateKind;
constraints?: string[];
}
export interface SuggestedListItem {
title: string;
notes?: string;
quantity?: number;
required: boolean;
}
export interface SuggestedList {
name: string;
description?: string;
kind: ListTemplateKind;
items: SuggestedListItem[];
sourceTemplateId?: string;
sourceTemplateName?: string;
rationale: string;
}
export interface ListSuggestionsResult {
suggestions: SuggestedList[];
}

View File

@@ -0,0 +1,161 @@
import { Injectable } from '@nestjs/common';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import * as z from 'zod/v4';
import { ListTemplateKind } from '../list-templates/list-template.types';
import { ListTemplatesService } from '../list-templates/list-templates.service';
import { ListsService } from '../lists/lists.service';
import { ListSuggestionAgentService } from './list-suggestion-agent.service';
const listKindSchema = z
.enum(['packing', 'shopping', 'todo', 'custom'])
.optional();
@Injectable()
export class McpServerService {
constructor(
private readonly listsService: ListsService,
private readonly listTemplatesService: ListTemplatesService,
private readonly listSuggestionAgentService: ListSuggestionAgentService,
) {}
createServer(userId: string): McpServer {
const server = new McpServer({
name: 'listify',
version: '1.0.0',
});
server.registerTool(
'list_existing_lists',
{
title: 'List existing lists',
description:
'Returns the authenticated user lists. This tool is read-only.',
inputSchema: {
includeItems: z
.boolean()
.optional()
.describe('Whether to include list items in the response.'),
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
openWorldHint: false,
},
},
async ({ includeItems = false }) => {
const lists = await this.listsService.listLists(userId);
const result = {
lists: lists.map((list) => ({
id: list.id,
name: list.name,
description: list.description,
kind: list.kind,
accessRole: list.accessRole,
itemCount: list.items.length,
items: includeItems
? list.items.map((item) => ({
id: item.id,
title: item.title,
notes: item.notes,
quantity: item.quantity,
required: item.required,
checked: item.checked,
position: item.position,
}))
: undefined,
})),
};
return this.toToolResult(result);
},
);
server.registerTool(
'list_templates',
{
title: 'List templates',
description:
'Returns the authenticated user list templates. This tool is read-only.',
inputSchema: {
kind: listKindSchema.describe('Optional template kind filter.'),
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
openWorldHint: false,
},
},
async ({ kind }) => {
const templates = await this.listTemplatesService.listTemplates(userId);
const result = {
templates: templates
.filter((template) => !kind || template.kind === kind)
.map((template) => ({
id: template.id,
name: template.name,
description: template.description,
kind: template.kind,
items: template.items.map((item) => ({
id: item.id,
title: item.title,
notes: item.notes,
quantity: item.quantity,
required: item.required,
position: item.position,
})),
})),
};
return this.toToolResult(result);
},
);
server.registerTool(
'suggest_lists',
{
title: 'Suggest lists',
description:
'Suggests new lists for the authenticated user without creating or modifying data.',
inputSchema: {
goal: z.string().min(1).describe('What the user wants a list for.'),
kind: listKindSchema.describe('Optional desired list kind.'),
constraints: z
.array(z.string().min(1))
.optional()
.describe('Optional constraints or must-have list items.'),
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
openWorldHint: false,
},
},
async ({ goal, kind, constraints }) => {
const result = await this.listSuggestionAgentService.suggestLists(
userId,
{
goal,
kind: kind as ListTemplateKind | undefined,
constraints,
},
);
return this.toToolResult(result);
},
);
return server;
}
private toToolResult(data: object) {
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(data, null, 2),
},
],
structuredContent: data as Record<string, unknown>,
};
}
}

View File

@@ -0,0 +1,214 @@
import {
Controller,
Delete,
ForbiddenException,
Get,
HttpStatus,
Post,
Req,
Res,
UnauthorizedException,
UseGuards,
} from '@nestjs/common';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
import { randomUUID } from 'crypto';
import type { Response } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { McpServerService } from './mcp-server.service';
import type { AuthenticatedRequest } from '../auth/auth.types';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
interface McpSession {
server: McpServer;
transport: StreamableHTTPServerTransport;
userId: string;
}
@Controller('mcp')
@UseGuards(JwtAuthGuard)
export class McpController {
private readonly sessions = new Map<string, McpSession>();
constructor(private readonly mcpServerService: McpServerService) {}
@Post()
async post(
@Req() request: AuthenticatedRequest,
@Res() response: Response,
): Promise<void> {
const userId = this.requireUserId(request);
const sessionId = this.sessionIdFrom(request);
if (sessionId) {
await this.handleExistingSession(sessionId, userId, request, response);
return;
}
if (!isInitializeRequest(request.body)) {
this.writeJsonRpcError(
response,
HttpStatus.BAD_REQUEST,
-32000,
'Bad Request: No valid session ID provided.',
);
return;
}
await this.handleInitialize(userId, request, response);
}
@Get()
async get(
@Req() request: AuthenticatedRequest,
@Res() response: Response,
): Promise<void> {
await this.handleSessionRequest(request, response);
}
@Delete()
async delete(
@Req() request: AuthenticatedRequest,
@Res() response: Response,
): Promise<void> {
await this.handleSessionRequest(request, response);
}
private async handleInitialize(
userId: string,
request: AuthenticatedRequest,
response: Response,
): Promise<void> {
const server = this.mcpServerService.createServer(userId);
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sessionId) => {
this.sessions.set(sessionId, {
server,
transport,
userId,
});
},
});
transport.onclose = () => {
const sessionId = transport.sessionId;
if (sessionId) {
this.sessions.delete(sessionId);
}
void server.close();
};
try {
await server.connect(transport);
await transport.handleRequest(request, response, request.body);
} catch (error) {
await this.closeSession(server, transport);
if (!response.headersSent) {
this.writeJsonRpcError(
response,
HttpStatus.INTERNAL_SERVER_ERROR,
-32603,
'Internal server error.',
);
}
}
}
private async handleSessionRequest(
request: AuthenticatedRequest,
response: Response,
): Promise<void> {
const userId = this.requireUserId(request);
const sessionId = this.sessionIdFrom(request);
if (!sessionId) {
response.status(HttpStatus.BAD_REQUEST).send('Missing MCP session ID.');
return;
}
await this.handleExistingSession(sessionId, userId, request, response);
}
private async handleExistingSession(
sessionId: string,
userId: string,
request: AuthenticatedRequest,
response: Response,
): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) {
response.status(HttpStatus.BAD_REQUEST).send('Invalid MCP session ID.');
return;
}
if (session.userId !== userId) {
throw new ForbiddenException('MCP session belongs to another user.');
}
try {
await session.transport.handleRequest(request, response, request.body);
} catch (error) {
if (!response.headersSent) {
this.writeJsonRpcError(
response,
HttpStatus.INTERNAL_SERVER_ERROR,
-32603,
'Internal server error.',
);
}
}
}
private async closeSession(
server: McpServer,
transport: StreamableHTTPServerTransport,
): Promise<void> {
const sessionId = transport.sessionId;
if (sessionId) {
this.sessions.delete(sessionId);
}
await transport.close();
await server.close();
}
private requireUserId(request: AuthenticatedRequest): string {
if (!request.user?.sub) {
throw new UnauthorizedException('Authenticated user is required.');
}
return request.user.sub;
}
private sessionIdFrom(request: AuthenticatedRequest): string | undefined {
const sessionId = request.headers['mcp-session-id'];
if (Array.isArray(sessionId)) {
return sessionId[0];
}
return sessionId;
}
private writeJsonRpcError(
response: Response,
status: number,
code: number,
message: string,
): void {
response.status(status).json({
jsonrpc: '2.0',
error: {
code,
message,
},
id: null,
});
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { ListTemplatesModule } from '../list-templates/list-templates.module';
import { ListsModule } from '../lists/lists.module';
import { McpController } from './mcp.controller';
import { ListSuggestionAgentService } from './list-suggestion-agent.service';
import { McpServerService } from './mcp-server.service';
@Module({
imports: [ListsModule, ListTemplatesModule],
controllers: [McpController],
providers: [ListSuggestionAgentService, McpServerService],
})
export class McpModule {}