diff --git a/docker-compose.mcps.yml b/docker-compose.mcps.yml index d1c3e52..710cf64 100644 --- a/docker-compose.mcps.yml +++ b/docker-compose.mcps.yml @@ -97,6 +97,18 @@ services: networks: - mcp-network + outline-mcp: + build: + context: ./packages/outline-mcp + container_name: outline-mcp + env_file: + - .env + ports: + - "3008:3008" + restart: unless-stopped + networks: + - mcp-network + networks: mcp-network: driver: bridge diff --git a/packages/outline-mcp/Dockerfile b/packages/outline-mcp/Dockerfile new file mode 100644 index 0000000..1ea52a4 --- /dev/null +++ b/packages/outline-mcp/Dockerfile @@ -0,0 +1,15 @@ +FROM node:20-bookworm-slim AS builder +WORKDIR /app +COPY package.json ./ +RUN npm install -g pnpm@10.18.3 && pnpm install --ignore-workspace +COPY tsconfig.json ./ +COPY src ./src +RUN pnpm build + +FROM node:20-bookworm-slim +WORKDIR /app +COPY --from=builder /app/package.json ./ +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/dist ./dist + +ENTRYPOINT ["node", "dist/start.js"] diff --git a/packages/outline-mcp/package.json b/packages/outline-mcp/package.json new file mode 100644 index 0000000..4811d16 --- /dev/null +++ b/packages/outline-mcp/package.json @@ -0,0 +1,26 @@ +{ + "name": "@mintel/outline-mcp", + "version": "1.0.0", + "description": "Outline MCP server for Mintel infrastructure", + "main": "dist/index.js", + "type": "module", + "scripts": { + "build": "tsc", + "start": "node dist/start.js", + "dev": "tsx watch src/index.ts", + "test:unit": "vitest run" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.5.0", + "axios": "^1.7.2", + "dotenv": "^17.3.1", + "express": "^4.19.2" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.14.10", + "tsx": "^4.19.2", + "typescript": "^5.5.3", + "vitest": "^2.1.3" + } +} diff --git a/packages/outline-mcp/src/index.test.ts b/packages/outline-mcp/src/index.test.ts new file mode 100644 index 0000000..a70ef03 --- /dev/null +++ b/packages/outline-mcp/src/index.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import axios from 'axios'; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; + +// Mock environment +process.env.OUTLINE_API_TOKEN = 'test-token'; +process.env.OUTLINE_BASE_URL = 'https://outline.test'; + +// Mock axios +vi.mock('axios', () => { + const mockPost = vi.fn(); + return { + default: { + create: vi.fn(() => ({ + post: mockPost + })) + } + }; +}); + +describe('Outline MCP Server Tools', () => { + let mockPost: any; + let requestHandler: any; + + beforeEach(async () => { + vi.clearAllMocks(); + + // Extract the mock + const axiosInstance = axios.create(); + mockPost = axiosInstance.post; + + // Mock Server setRequestHandler + let capturedHandler: any; + vi.spyOn(Server.prototype, 'setRequestHandler').mockImplementation((schema: any, handler: any) => { + // The first call is usually ListToolsRequestSchema, second is CallToolRequestSchema + // We just store the last one which is CallToolRequestSchema + capturedHandler = handler; + return {} as any; + }); + + // Re-import to trigger registration + vi.resetModules(); + await import('./index.js'); + + requestHandler = capturedHandler; + }); + + it('should search documents', async () => { + mockPost.mockResolvedValueOnce({ data: { data: [{ id: '1', title: 'Test' }] } }); + + const result = await requestHandler({ + params: { + name: 'outline_search_documents', + arguments: { query: 'test' } + } + }); + + expect(mockPost).toHaveBeenCalledWith('/api/documents.search', { query: 'test', collectionId: undefined }); + expect(result.content[0].text).toContain('Test'); + }); + + it('should get a document', async () => { + mockPost.mockResolvedValueOnce({ data: { data: { id: 'doc1', title: 'Doc 1' } } }); + + const result = await requestHandler({ + params: { + name: 'outline_get_document', + arguments: { id: 'doc1' } + } + }); + + expect(mockPost).toHaveBeenCalledWith('/api/documents.info', { id: 'doc1' }); + expect(result.content[0].text).toContain('Doc 1'); + }); + + it('should create a document', async () => { + mockPost.mockResolvedValueOnce({ data: { data: { id: 'new-doc' } } }); + + const result = await requestHandler({ + params: { + name: 'outline_create_document', + arguments: { collectionId: 'col1', title: 'New Doc', text: 'Hello', publish: true } + } + }); + + expect(mockPost).toHaveBeenCalledWith('/api/documents.create', { + collectionId: 'col1', title: 'New Doc', text: 'Hello', publish: true + }); + expect(result.content[0].text).toContain('new-doc'); + }); +}); diff --git a/packages/outline-mcp/src/index.ts b/packages/outline-mcp/src/index.ts new file mode 100644 index 0000000..6c73d29 --- /dev/null +++ b/packages/outline-mcp/src/index.ts @@ -0,0 +1,184 @@ +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + Tool, +} from "@modelcontextprotocol/sdk/types.js"; +import axios from "axios"; + +// Environment variables +const OUTLINE_API_TOKEN = process.env.OUTLINE_API_TOKEN; +const OUTLINE_BASE_URL = process.env.OUTLINE_BASE_URL; + +if (!OUTLINE_API_TOKEN || !OUTLINE_BASE_URL) { + console.error("Missing required environment variables: OUTLINE_API_TOKEN or OUTLINE_BASE_URL"); + process.exit(1); +} + +const axiosInstance = axios.create({ + baseURL: OUTLINE_BASE_URL, + headers: { + Authorization: `Bearer ${OUTLINE_API_TOKEN}`, + "Content-Type": "application/json", + Accept: "application/json", + }, +}); + +// Define tools +const tools: Tool[] = [ + { + name: "outline_search_documents", + description: "Search for documents in Outline.", + inputSchema: { + type: "object", + properties: { + query: { type: "string", description: "Search query" }, + collectionId: { type: "string", description: "Optional collection ID to scope search" }, + }, + required: ["query"], + }, + }, + { + name: "outline_get_document", + description: "Get a document by its ID.", + inputSchema: { + type: "object", + properties: { + id: { type: "string", description: "The ID of the document" }, + }, + required: ["id"], + }, + }, + { + name: "outline_create_document", + description: "Create a new document.", + inputSchema: { + type: "object", + properties: { + collectionId: { type: "string", description: "The ID of the collection" }, + title: { type: "string", description: "Document title" }, + text: { type: "string", description: "Document content in Markdown" }, + publish: { type: "boolean", description: "Whether to publish the document" }, + }, + required: ["collectionId", "title", "text"], + }, + }, + { + name: "outline_update_document", + description: "Update an existing document.", + inputSchema: { + type: "object", + properties: { + id: { type: "string", description: "The ID of the document" }, + title: { type: "string", description: "New title" }, + text: { type: "string", description: "New content in Markdown" }, + }, + required: ["id"], + }, + }, + { + name: "outline_create_collection", + description: "Create a new collection.", + inputSchema: { + type: "object", + properties: { + name: { type: "string", description: "Collection name" }, + description: { type: "string", description: "Collection description" }, + }, + required: ["name"], + }, + }, +]; + +const server = new Server( + { + name: "at-mintel-outline-mcp", + version: "1.0.0", + }, + { + capabilities: { + tools: {}, + }, + } +); + +server.setRequestHandler(ListToolsRequestSchema, async () => { + return { tools }; +}); + +server.setRequestHandler(CallToolRequestSchema, async (request) => { + try { + switch (request.params.name) { + case "outline_search_documents": { + const { query, collectionId } = request.params.arguments as any; + const res = await axiosInstance.post("/api/documents.search", { + query, + collectionId, + }); + return { + content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }], + }; + } + case "outline_get_document": { + const { id } = request.params.arguments as any; + const res = await axiosInstance.post("/api/documents.info", { id }); + return { + content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }], + }; + } + case "outline_create_document": { + const { collectionId, title, text, publish } = request.params.arguments as any; + const res = await axiosInstance.post("/api/documents.create", { + collectionId, + title, + text, + publish: publish ?? true, + }); + return { + content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }], + }; + } + case "outline_update_document": { + const { id, title, text } = request.params.arguments as any; + const res = await axiosInstance.post("/api/documents.update", { + id, + title, + text, + }); + return { + content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }], + }; + } + case "outline_create_collection": { + const { name, description } = request.params.arguments as any; + const res = await axiosInstance.post("/api/collections.create", { + name, + description, + }); + return { + content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }], + }; + } + default: + throw new Error(`Unknown tool: ${request.params.name}`); + } + } catch (error: any) { + const errorMsg = error.response?.data ? JSON.stringify(error.response.data) : error.message; + return { + content: [{ type: "text", text: `API Error: ${errorMsg}` }], + isError: true, + }; + } +}); + +async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error("Outline MCP Server running on stdio"); +} + +main().catch((error) => { + console.error("Fatal error:", error); + process.exit(1); +}); diff --git a/packages/outline-mcp/src/start.ts b/packages/outline-mcp/src/start.ts new file mode 100644 index 0000000..7c89879 --- /dev/null +++ b/packages/outline-mcp/src/start.ts @@ -0,0 +1,13 @@ +import { config } from 'dotenv'; +import { resolve } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); + +config({ quiet: true, path: resolve(__dirname, '../../../.env.local') }); +config({ quiet: true, path: resolve(__dirname, '../../../.env') }); + +import('./index.js').catch(err => { + console.error('Failed to start Outline MCP Server:', err); + process.exit(1); +}); diff --git a/packages/outline-mcp/tsconfig.json b/packages/outline-mcp/tsconfig.json new file mode 100644 index 0000000..9266b8e --- /dev/null +++ b/packages/outline-mcp/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": [ + "src/**/*" + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e24fbb2..e4b378f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -754,6 +754,37 @@ importers: specifier: ^2.0.0 version: 2.1.9(@types/node@22.19.10)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0) + packages/outline-mcp: + dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.5.0 + version: 1.27.1(zod@3.25.76) + axios: + specifier: ^1.7.2 + version: 1.13.5 + dotenv: + specifier: ^17.3.1 + version: 17.3.1 + express: + specifier: ^4.19.2 + version: 4.22.1 + devDependencies: + '@types/express': + specifier: ^4.17.21 + version: 4.17.25 + '@types/node': + specifier: ^20.14.10 + version: 20.19.33 + tsx: + specifier: ^4.19.2 + version: 4.21.0 + typescript: + specifier: ^5.5.3 + version: 5.9.3 + vitest: + specifier: ^2.1.3 + version: 2.1.9(@types/node@20.19.33)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0) + packages/page-audit: dependencies: commander: