From d5595f11cd3f9426362cdcdf4490734409ca6d6e Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Fri, 12 Jun 2026 11:08:04 +0200 Subject: [PATCH] mcp --- listify-api/.env.docker.example | 2 +- listify-api/.env.example | 2 +- listify-api/README.md | 6 +- listify-api/src/assistant/assistant.module.ts | 3 +- .../src/assistant/assistant.service.spec.ts | 235 +++-------- .../src/assistant/assistant.service.ts | 374 ++---------------- 6 files changed, 86 insertions(+), 536 deletions(-) diff --git a/listify-api/.env.docker.example b/listify-api/.env.docker.example index b53b6e5..b10d5b4 100644 --- a/listify-api/.env.docker.example +++ b/listify-api/.env.docker.example @@ -19,7 +19,7 @@ JWT_REFRESH_SECRET=change-me-refresh-secret CLIENT_URL=http://localhost:8080 MISTRAL_API_KEY= -MISTRAL_MODEL=mistral-small-latest +MISTRAL_AGENT_ID= MAIL_ENABLED=true SMTP_HOST=host.docker.internal diff --git a/listify-api/.env.example b/listify-api/.env.example index 914d47d..e474090 100644 --- a/listify-api/.env.example +++ b/listify-api/.env.example @@ -16,7 +16,7 @@ JWT_REFRESH_SECRET=change-me-refresh-secret CLIENT_URL=http://localhost:4200 MISTRAL_API_KEY= -MISTRAL_MODEL=mistral-small-latest +MISTRAL_AGENT_ID= MAIL_ENABLED=true SMTP_HOST=localhost diff --git a/listify-api/README.md b/listify-api/README.md index 5191bc0..e28f9d8 100644 --- a/listify-api/README.md +++ b/listify-api/README.md @@ -46,14 +46,14 @@ $ npm run start:prod ## Mistral assistant -The in-app assistant calls Mistral from the API server. Configure the key in the API environment, never in the Angular client: +The in-app assistant calls a configured Mistral agent from the API server. Configure the key and agent id in the API environment, never in the Angular client: ```bash MISTRAL_API_KEY=your-mistral-api-key -MISTRAL_MODEL=mistral-small-latest +MISTRAL_AGENT_ID=your-mistral-agent-id ``` -The authenticated frontend calls `POST /api/assistant/chat`; the API then calls Mistral and may execute allowed Listify actions such as creating lists or adding list items. +The Mistral agent should have the `listify` connector attached. The authenticated frontend calls `POST /api/assistant/chat`; the API forwards the conversation to `POST /v1/agents/completions` and returns the assistant text. Listify does not parse or execute tool calls locally in this path. ## Run tests diff --git a/listify-api/src/assistant/assistant.module.ts b/listify-api/src/assistant/assistant.module.ts index e3f37d0..abc03f8 100644 --- a/listify-api/src/assistant/assistant.module.ts +++ b/listify-api/src/assistant/assistant.module.ts @@ -1,11 +1,10 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; -import { ListsModule } from '../lists/lists.module'; import { AssistantController } from './assistant.controller'; import { AssistantService } from './assistant.service'; @Module({ - imports: [AuthModule, ListsModule], + imports: [AuthModule], controllers: [AssistantController], providers: [AssistantService], }) diff --git a/listify-api/src/assistant/assistant.service.spec.ts b/listify-api/src/assistant/assistant.service.spec.ts index 586ff7e..02401cb 100644 --- a/listify-api/src/assistant/assistant.service.spec.ts +++ b/listify-api/src/assistant/assistant.service.spec.ts @@ -1,170 +1,71 @@ import { ServiceUnavailableException } from '@nestjs/common'; -import { UserList } from '../list-templates/list-template.types'; -import { ListsService } from '../lists/lists.service'; import { AssistantService } from './assistant.service'; describe('AssistantService', () => { const originalFetch = global.fetch; const originalApiKey = process.env.MISTRAL_API_KEY; - let listsService: Pick; + const originalAgentId = process.env.MISTRAL_AGENT_ID; let service: AssistantService; beforeEach(() => { process.env.MISTRAL_API_KEY = 'test-key'; + process.env.MISTRAL_AGENT_ID = 'agent-listify'; global.fetch = jest.fn(); - listsService = { - listLists: jest.fn(), - createList: jest.fn(), - addItem: jest.fn(), - }; - service = new AssistantService(listsService as ListsService); + service = new AssistantService(); }); afterEach(() => { global.fetch = originalFetch; process.env.MISTRAL_API_KEY = originalApiKey; - delete process.env.MISTRAL_MODEL; + process.env.MISTRAL_AGENT_ID = originalAgentId; }); - it('returns a plain assistant response without tool calls', async () => { + it('forwards messages to the configured Mistral agent', async () => { mockMistralResponse({ - choices: [{ message: { content: 'Klar, ich helfe dir.' } }], + choices: [ + { + message: { + content: 'Ich habe den Listify-Connector verwendet.', + }, + }, + ], }); const result = await service.chat('user-1', { - messages: [{ role: 'user', content: 'Hallo' }], + messages: [ + { role: 'assistant', content: 'Hallo' }, + { role: 'user', content: 'Erstelle eine Liste' }, + ], }); + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.mistral.ai/v1/agents/completions', + expect.objectContaining({ + method: 'POST', + headers: { + Authorization: 'Bearer test-key', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + agent_id: 'agent-listify', + messages: [ + { role: 'assistant', content: 'Hallo' }, + { role: 'user', content: 'Erstelle eine Liste' }, + ], + stream: false, + response_format: { + type: 'text', + }, + }), + }), + ); expect(result).toEqual({ - message: { role: 'assistant', content: 'Klar, ich helfe dir.' }, + message: { + role: 'assistant', + content: 'Ich habe den Listify-Connector verwendet.', + }, actions: [], }); - expect(global.fetch).toHaveBeenCalledTimes(1); - }); - - it('executes create_list tool calls and returns the final assistant response', async () => { - const createdList = list({ id: 'list-1', name: 'Sommerurlaub' }); - const completedList = list({ - id: 'list-1', - name: 'Sommerurlaub', - items: ['Pass'], - }); - jest.mocked(listsService.createList).mockResolvedValue(createdList); - jest.mocked(listsService.addItem).mockResolvedValue(completedList); - mockMistralResponse( - { - choices: [ - { - message: { - content: '', - tool_calls: [ - { - id: 'call-1', - type: 'function', - function: { - name: 'create_list', - arguments: JSON.stringify({ - name: 'Sommerurlaub', - kind: 'packing', - items: [{ title: 'Pass' }], - }), - }, - }, - ], - }, - }, - ], - }, - { - choices: [ - { - message: { - content: 'Ich habe die Packliste Sommerurlaub erstellt.', - }, - }, - ], - }, - ); - - const result = await service.chat('user-1', { - messages: [{ role: 'user', content: 'Erstelle eine Packliste' }], - }); - - expect(listsService.createList).toHaveBeenCalledWith('user-1', { - name: 'Sommerurlaub', - description: undefined, - kind: 'packing', - }); - expect(listsService.addItem).toHaveBeenCalledWith('user-1', 'list-1', { - title: 'Pass', - notes: undefined, - quantity: undefined, - required: undefined, - }); - expect(result.actions).toEqual([ - { - type: 'list.created', - listId: 'list-1', - list: completedList, - }, - ]); - expect(result.message.content).toBe( - 'Ich habe die Packliste Sommerurlaub erstellt.', - ); - }); - - it('executes add_list_item tool calls', async () => { - const updatedList = list({ - id: 'list-1', - name: 'Einkauf', - items: ['Milch'], - }); - jest.mocked(listsService.addItem).mockResolvedValue(updatedList); - mockMistralResponse( - { - choices: [ - { - message: { - content: '', - tool_calls: [ - { - id: 'call-1', - type: 'function', - function: { - name: 'add_list_item', - arguments: JSON.stringify({ - listId: 'list-1', - title: 'Milch', - quantity: 2, - }), - }, - }, - ], - }, - }, - ], - }, - { - choices: [{ message: { content: 'Milch wurde hinzugefuegt.' } }], - }, - ); - - const result = await service.chat('user-1', { - messages: [{ role: 'user', content: 'Fuege Milch hinzu' }], - }); - - expect(listsService.addItem).toHaveBeenCalledWith('user-1', 'list-1', { - title: 'Milch', - notes: undefined, - quantity: 2, - required: undefined, - }); - expect(result.actions[0]).toEqual({ - type: 'list.item_added', - listId: 'list-1', - itemTitle: 'Milch', - list: updatedList, - }); }); it('fails clearly when the api key is missing', async () => { @@ -176,45 +77,21 @@ describe('AssistantService', () => { }), ).rejects.toThrow(ServiceUnavailableException); }); + + it('fails clearly when the agent id is missing', async () => { + delete process.env.MISTRAL_AGENT_ID; + + await expect( + service.chat('user-1', { + messages: [{ role: 'user', content: 'Hallo' }], + }), + ).rejects.toThrow(ServiceUnavailableException); + }); }); -function mockMistralResponse(...responses: object[]): void { - jest.mocked(global.fetch).mockImplementation(async () => { - const response = responses.shift() ?? responses[responses.length - 1]; - - return { - ok: true, - json: async () => response, - } as Response; - }); -} - -function list(options: { - id: string; - name: string; - items?: string[]; -}): UserList { - return { - id: options.id, - ownerId: 'user-1', - accessRole: 'owner', - name: options.name, - kind: 'custom', - items: (options.items ?? []).map((title, position) => ({ - id: `item-${position}`, - title, - required: true, - checked: false, - position, - createdAt: now(), - updatedAt: now(), - })), - collaborators: [], - createdAt: now(), - updatedAt: now(), - }; -} - -function now(): string { - return new Date(0).toISOString(); +function mockMistralResponse(response: object): void { + jest.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => response, + } as Response); } diff --git a/listify-api/src/assistant/assistant.service.ts b/listify-api/src/assistant/assistant.service.ts index 74e0536..c08239c 100644 --- a/listify-api/src/assistant/assistant.service.ts +++ b/listify-api/src/assistant/assistant.service.ts @@ -3,186 +3,59 @@ import { Injectable, ServiceUnavailableException, } from '@nestjs/common'; -import { ListTemplateKind } from '../list-templates/list-template.types'; -import { ListsService } from '../lists/lists.service'; import { - AddListItemToolInput, - AssistantAction, AssistantChatMessage, AssistantChatRequest, AssistantChatResponse, - CreateListToolInput, } from './assistant.types'; -type MistralRole = 'system' | 'user' | 'assistant' | 'tool'; - -interface MistralMessage { - role: MistralRole; - content: string | null; - tool_call_id?: string; - tool_calls?: MistralToolCall[]; -} - -interface MistralToolCall { - id: string; - type: 'function'; - function: { - name: string; - arguments: string; - }; -} - -interface MistralChatResponse { +interface MistralAgentCompletionResponse { choices?: Array<{ message?: { content?: string | null; - tool_calls?: MistralToolCall[]; }; }>; } @Injectable() export class AssistantService { - private readonly endpoint = 'https://api.mistral.ai/v1/chat/completions'; - - constructor(private readonly listsService: ListsService) {} + private readonly endpoint = 'https://api.mistral.ai/v1/agents/completions'; async chat( - userId: string, + _userId: string, request: AssistantChatRequest, ): Promise { const messages = this.normalizeMessages(request.messages); - const firstResponse = await this.callMistral([ - this.systemMessage(), - ...messages.map((message) => ({ - role: message.role, - content: message.content, - })), - ]); - const firstMessage = this.extractMessage(firstResponse); - const actions: AssistantAction[] = []; - const toolCalls = firstMessage.tool_calls ?? []; + const response = await this.callMistralAgent(messages); + const content = response.choices?.[0]?.message?.content?.trim(); - if (toolCalls.length === 0) { - return { - message: { - role: 'assistant', - content: this.normalizeAssistantContent(firstMessage.content), - }, - actions, - }; + if (!content) { + throw new ServiceUnavailableException('Mistral response was empty.'); } - const toolMessages: MistralMessage[] = []; - - for (const toolCall of toolCalls) { - const toolResult = await this.executeToolCall(userId, toolCall, actions); - toolMessages.push({ - role: 'tool', - tool_call_id: toolCall.id, - content: JSON.stringify(toolResult), - }); - } - - const finalResponse = await this.callMistral([ - this.systemMessage(), - ...messages.map((message) => ({ - role: message.role, - content: message.content, - })), - { - role: 'assistant', - content: firstMessage.content ?? '', - tool_calls: toolCalls, - }, - ...toolMessages, - ]); - const finalMessage = this.extractMessage(finalResponse); - return { message: { role: 'assistant', - content: this.normalizeAssistantContent(finalMessage.content), + content, }, - actions, + actions: [], }; } - private async executeToolCall( - userId: string, - toolCall: MistralToolCall, - actions: AssistantAction[], - ): Promise { - const args = this.parseToolArguments(toolCall.function.arguments); - - if (toolCall.function.name === 'list_existing_lists') { - return { - lists: (await this.listsService.listLists(userId)).map((list) => ({ - id: list.id, - name: list.name, - description: list.description, - kind: list.kind, - itemCount: list.items.length, - items: list.items.map((item) => ({ - id: item.id, - title: item.title, - required: item.required, - checked: item.checked, - })), - })), - }; - } - - if (toolCall.function.name === 'create_list') { - const input = this.normalizeCreateListInput(args); - let list = await this.listsService.createList(userId, { - name: input.name!, - description: input.description, - kind: input.kind, - }); - - for (const item of input.items ?? []) { - list = await this.listsService.addItem(userId, list.id, item); - } - - actions.push({ - type: 'list.created', - listId: list.id, - list, - }); - - return { list }; - } - - if (toolCall.function.name === 'add_list_item') { - const input = this.normalizeAddListItemInput(args); - const list = await this.listsService.addItem(userId, input.listId!, { - title: input.title!, - notes: input.notes, - quantity: input.quantity, - required: input.required, - }); - - actions.push({ - type: 'list.item_added', - listId: list.id, - itemTitle: input.title!, - list, - }); - - return { list }; - } - - return { error: `Unsupported tool: ${toolCall.function.name}` }; - } - - private async callMistral(messages: MistralMessage[]) { + private async callMistralAgent( + messages: AssistantChatMessage[], + ): Promise { const apiKey = process.env.MISTRAL_API_KEY; + const agentId = process.env.MISTRAL_AGENT_ID; if (!apiKey) { throw new ServiceUnavailableException('Mistral API key is not configured.'); } + if (!agentId) { + throw new ServiceUnavailableException('Mistral agent id is not configured.'); + } + const response = await fetch(this.endpoint, { method: 'POST', headers: { @@ -190,18 +63,20 @@ export class AssistantService { 'Content-Type': 'application/json', }, body: JSON.stringify({ - model: process.env.MISTRAL_MODEL ?? 'mistral-small-latest', + agent_id: agentId, messages, - tools: this.tools(), - tool_choice: 'auto', + stream: false, + response_format: { + type: 'text', + }, }), }); if (!response.ok) { - throw new ServiceUnavailableException('Mistral API request failed.'); + throw new ServiceUnavailableException('Mistral agent request failed.'); } - return (await response.json()) as MistralChatResponse; + return (await response.json()) as MistralAgentCompletionResponse; } private normalizeMessages( @@ -225,205 +100,4 @@ export class AssistantService { return { role: message.role, content }; }); } - - private normalizeCreateListInput(value: unknown): CreateListToolInput { - const input = this.objectValue(value); - const name = this.requiredString(input.name, 'name'); - const description = this.optionalString(input.description); - const kind = this.optionalKind(input.kind); - const rawItems = input.items; - - if (rawItems !== undefined && !Array.isArray(rawItems)) { - throw new BadRequestException('items must be an array.'); - } - - return { - name, - description, - kind, - items: rawItems - ?.slice(0, 50) - .map((item) => this.normalizeAddListItemInput(item, false)), - }; - } - - private normalizeAddListItemInput( - value: unknown, - requireListId = true, - ): AddListItemToolInput { - const input = this.objectValue(value); - const title = this.requiredString(input.title, 'title'); - const listId = requireListId - ? this.requiredString(input.listId, 'listId') - : undefined; - const notes = this.optionalString(input.notes); - const quantity = - input.quantity === undefined ? undefined : Number(input.quantity); - - if ( - quantity !== undefined && - (!Number.isFinite(quantity) || quantity <= 0) - ) { - throw new BadRequestException('quantity must be greater than zero.'); - } - - if ( - input.required !== undefined && - typeof input.required !== 'boolean' - ) { - throw new BadRequestException('required must be a boolean.'); - } - - return { - listId, - title, - notes, - quantity, - required: input.required, - }; - } - - private parseToolArguments(value: string): unknown { - try { - return value ? (JSON.parse(value) as unknown) : {}; - } catch { - throw new BadRequestException('Tool arguments must be valid JSON.'); - } - } - - private objectValue(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new BadRequestException('Tool arguments must be an object.'); - } - - return value as Record; - } - - private requiredString(value: unknown, field: string): string { - const normalized = typeof value === 'string' ? value.trim() : ''; - - if (!normalized) { - throw new BadRequestException(`${field} is required.`); - } - - return normalized; - } - - private optionalString(value: unknown): string | undefined { - if (value === undefined || value === null) { - return undefined; - } - - const normalized = typeof value === 'string' ? value.trim() : ''; - return normalized || undefined; - } - - private optionalKind(value: unknown): ListTemplateKind | undefined { - if (value === undefined || value === null || value === '') { - return undefined; - } - - if ( - value === 'packing' || - value === 'shopping' || - value === 'todo' || - value === 'custom' - ) { - return value; - } - - throw new BadRequestException('kind is invalid.'); - } - - private extractMessage(response: MistralChatResponse) { - const message = response.choices?.[0]?.message; - - if (!message) { - throw new ServiceUnavailableException('Mistral response was empty.'); - } - - return message; - } - - private normalizeAssistantContent(content: string | null | undefined): string { - return content?.trim() || 'Ich habe die Anfrage verarbeitet.'; - } - - private systemMessage(): MistralMessage { - return { - role: 'system', - content: - 'Du bist der Listify-Assistent. Antworte knapp und hilfreich auf Deutsch. ' + - 'Nutze Tools, wenn der Nutzer Listen sehen, neue Listen erstellen oder Items hinzufuegen moechte. ' + - 'Erstelle oder aendere nur, wenn der Nutzer das klar anfordert. Loeschen, Teilen, Abhaken und Bearbeiten bestehender Items ist nicht erlaubt.', - }; - } - - private tools() { - return [ - { - type: 'function', - function: { - name: 'list_existing_lists', - description: 'Liest die Listen des angemeldeten Users.', - parameters: { - type: 'object', - properties: {}, - }, - }, - }, - { - type: 'function', - function: { - name: 'create_list', - description: - 'Erstellt eine neue Liste mit optionalen Start-Items fuer den angemeldeten User.', - parameters: { - type: 'object', - required: ['name'], - properties: { - name: { type: 'string' }, - description: { type: 'string' }, - kind: { - type: 'string', - enum: ['packing', 'shopping', 'todo', 'custom'], - }, - items: { - type: 'array', - items: { - type: 'object', - required: ['title'], - properties: { - title: { type: 'string' }, - notes: { type: 'string' }, - quantity: { type: 'number' }, - required: { type: 'boolean' }, - }, - }, - }, - }, - }, - }, - }, - { - type: 'function', - function: { - name: 'add_list_item', - description: - 'Fuegt ein Item zu einer bestehenden Liste hinzu, auf die der User Zugriff hat.', - parameters: { - type: 'object', - required: ['listId', 'title'], - properties: { - listId: { type: 'string' }, - title: { type: 'string' }, - notes: { type: 'string' }, - quantity: { type: 'number' }, - required: { type: 'boolean' }, - }, - }, - }, - }, - ]; - } }