├── .coveralls.yml ├── .eslintrc.json ├── .github └── workflows │ └── test.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── credentials └── .gitkeep ├── eslint.config.js ├── jest.config.mjs ├── jest.setup.ts ├── package-lock.json ├── package.json ├── scripts └── test-notion.ts ├── src ├── __mocks__ │ ├── @modelcontextprotocol │ │ └── sdk.ts │ ├── node_process.ts │ └── server.ts ├── __tests__ │ ├── index.test.ts │ ├── mock-objects.ts │ ├── test-utils.test.ts │ └── test-utils.ts ├── config │ ├── __llm__ │ │ └── README.md │ ├── __tests__ │ │ └── server-config.test.ts │ └── server-config.ts ├── constants │ ├── instructions.ts │ ├── notion.ts │ ├── prompts.ts │ └── tools.ts ├── handlers │ ├── __llm__ │ │ └── README.md │ ├── __tests__ │ │ ├── notifications.test.ts │ │ ├── prompt-handlers.test.ts │ │ ├── resource-handlers.test.ts │ │ ├── sampling.test.ts │ │ └── tool-handlers.test.ts │ ├── notifications.ts │ ├── prompt-handlers.ts │ ├── resource-handlers.ts │ ├── sampling.ts │ └── tool-handlers.ts ├── index.ts ├── server.ts ├── services │ ├── __llm__ │ │ └── README.md │ ├── __mocks__ │ │ └── notion-service.ts │ ├── __tests__ │ │ ├── __mocks__ │ │ │ ├── notion-client.ts │ │ │ └── notion-objects.ts │ │ ├── notion-service.test.ts │ │ └── systemprompt-service.test.ts │ ├── notion-service.ts │ └── systemprompt-service.ts ├── types │ ├── __llm__ │ │ └── README.md │ ├── index.ts │ ├── notion.ts │ ├── systemprompt.ts │ ├── tool-args.ts │ └── tool-schemas.ts └── utils │ ├── __tests__ │ ├── mcp-mappers.test.ts │ ├── message-handlers.test.ts │ ├── notion-utils.test.ts │ ├── tool-validation.test.ts │ └── validation.test.ts │ ├── mcp-mappers.ts │ ├── message-handlers.ts │ ├── notion-utils.ts │ ├── tool-validation.ts │ └── validation.ts ├── tsconfig.json └── tsconfig.test.json /.coveralls.yml: -------------------------------------------------------------------------------- 1 | repo_token: ${COVERALLS_REPO_TOKEN} 2 | service_name: github-actions 3 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"], 4 | "parser": "@typescript-eslint/parser", 5 | "plugins": ["@typescript-eslint"], 6 | "overrides": [ 7 | { 8 | "files": [ 9 | "**/*.test.ts", 10 | "**/*.test.tsx", 11 | "**/__tests__/**/*.ts", 12 | "**/__mocks__/**/*.ts" 13 | ], 14 | "rules": { 15 | "@typescript-eslint/no-explicit-any": "off", 16 | "@typescript-eslint/ban-ts-comment": "off", 17 | "@typescript-eslint/no-unsafe-assignment": "off", 18 | "@typescript-eslint/no-unsafe-member-access": "off", 19 | "@typescript-eslint/no-unsafe-call": "off", 20 | "@typescript-eslint/no-unsafe-return": "off" 21 | } 22 | } 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test & Coverage 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - name: Use Node.js 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version: "18.x" 20 | 21 | - name: Install dependencies 22 | run: npm ci 23 | 24 | - name: Run tests with coverage 25 | run: npm run test:coverage 26 | 27 | - name: Coveralls 28 | uses: coverallsapp/github-action@v2 29 | with: 30 | github-token: ${{ secrets.GITHUB_TOKEN }} 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | build/ 3 | *.log 4 | .env* 5 | 6 | agent 7 | agent/* 8 | 9 | coverage/ 10 | output/ 11 | scripts/* 12 | !scripts/test-notion.ts 13 | 14 | # Ignore credential files but not the directory 15 | credentials/* 16 | !credentials/.gitkeep 17 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## [1.0.3] - 2024-03-27 6 | 7 | ### Changed 8 | 9 | - Updated system prompt handling and validation 10 | - Enhanced tool validation and type definitions 11 | - Improved message handling utilities 12 | - Refined Notion integration and utilities 13 | 14 | ## [1.0.2] - 2025-01-15 15 | 16 | ### Changed 17 | 18 | - Enhanced test configuration and coverage across handlers and services 19 | - Updated tool handling system with improved type definitions 20 | - Refined prompt handling implementation and tests 21 | - Improved system prompt service functionality 22 | 23 | ### Fixed 24 | 25 | - Various type safety improvements in tool arguments and schemas 26 | - Standardized handler implementations across the codebase 27 | 28 | ## [1.0.1] - 2024-03-26 29 | 30 | ### Changed 31 | 32 | - Improved Notion service type definitions and standardization 33 | - Enhanced test coverage and configuration 34 | - Refactored page parent handling for better type safety 35 | - Updated search functionality to include pagination support 36 | - Optimized Jest configuration for better test performance 37 | 38 | ### Fixed 39 | 40 | - Fixed page parent type handling and mapping 41 | - Corrected search results pagination structure 42 | - Removed outdated documentation references 43 | 44 | ## [1.0.0] - 2024-02-24 45 | 46 | ### Added 47 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | Copyright (c) 2025 SystemPrompt 6 | 7 | Licensed under the Apache License, Version 2.0 (the "License") with the following 8 | additional conditions: 9 | 10 | 1. You must obtain a valid API key from SystemPrompt (https://systemprompt.io/console) 11 | to use this software. 12 | 13 | 2. You may not use this software without a valid SystemPrompt API key. 14 | 15 | 3. You may not distribute, sublicense, or transfer your SystemPrompt API key 16 | to any third party. 17 | 18 | 4. SystemPrompt reserves the right to revoke API keys at any time. 19 | 20 | Subject to the foregoing conditions and the conditions of the Apache License, 21 | Version 2.0, you may obtain a copy of the License at 22 | 23 | http://www.apache.org/licenses/LICENSE-2.0 24 | 25 | Unless required by applicable law or agreed to in writing, software 26 | distributed under the License is distributed on an "AS IS" BASIS, 27 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 28 | See the License for the specific language governing permissions and 29 | limitations under the License. 30 | 31 | For licensing inquiries, please contact: support@systemprompt.io 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # systemprompt-mcp-notion 2 | 3 | [![npm version](https://img.shields.io/npm/v/systemprompt-mcp-notion.svg)](https://www.npmjs.com/package/systemprompt-mcp-notion) 4 | [![Coverage Status](https://coveralls.io/repos/github/Ejb503/systemprompt-mcp-notion/badge.svg?branch=main)](https://coveralls.io/github/Ejb503/systemprompt-mcp-notion?branch=main) 5 | [![Twitter Follow](https://img.shields.io/twitter/follow/tyingshoelaces_?style=social)](https://twitter.com/tyingshoelaces_) 6 | [![Discord](https://img.shields.io/discord/1255160891062620252?color=7289da&label=discord)](https://discord.com/invite/wkAbSuPWpr) 7 | [![smithery badge](https://smithery.ai/badge/systemprompt-mcp-notion)](https://smithery.ai/server/systemprompt-mcp-notion) 8 | 9 | [Website](https://systemprompt.io) | [Documentation](https://systemprompt.io/documentation) 10 | 11 | # SystemPrompt MCP Notion Server 12 | 13 | A high-performance Model Context Protocol (MCP) server that seamlessly integrates Notion into your AI workflows. This server enables AI agents to interact with Notion pages and databases through a standardized protocol. This server supports and requires MCP Sampling, which is required to the MCP to create and update Notion pages. 14 | 15 | A compatible MCP client is available [here](https://github.com/Ejb503/multimodal-mcp-client). 16 | 17 | SystemPrompt Notion Server MCP server 18 | 19 | ## Server Capabilities 20 | 21 | ```typescript 22 | const serverCapabilities: { capabilities: ServerCapabilities } = { 23 | capabilities: { 24 | resources: { 25 | listChanged: true, 26 | }, 27 | tools: {}, 28 | prompts: { 29 | listChanged: true, 30 | }, 31 | sampling: {}, 32 | }, 33 | }; 34 | ``` 35 | 36 | ## Key Features 37 | 38 | - **📝 Comprehensive Content Management** 39 | 40 | - Create and update pages with rich text formatting 41 | - Search across your Notion workspace 42 | 43 | - **🛠 Developer-Friendly** 44 | - Extensive test coverage with Jest 45 | - TypeScript support 46 | - Comprehensive error handling 47 | - Detailed logging and debugging tools 48 | 49 | ## Prerequisites 50 | 51 | Before using this server, you'll need: 52 | 53 | 1. **Systemprompt API Key** (Free) 54 | 55 | - Sign up at [systemprompt.io/console](https://systemprompt.io/console) 56 | - Create a new API key in your dashboard 57 | 58 | 2. **Notion Account and Workspace** 59 | 60 | - Active Notion account 61 | - Workspace with content you want to access 62 | 63 | 3. **Notion Integration** 64 | 65 | - Create at [notion.so/my-integrations](https://www.notion.so/my-integrations) 66 | - Required capabilities: 67 | - Read/Update/Insert content 68 | - Database management 69 | - Search functionality 70 | 71 | 4. **MCP-Compatible Client** 72 | - [Systemprompt MCP Client](https://github.com/Ejb503/multimodal-mcp-client) 73 | - Any other MCP-compatible client 74 | 75 | ## Quick Start 76 | 77 | 1. **Installation** 78 | 79 | ### Installing via Smithery 80 | 81 | To install systemprompt-mcp-notion for Claude Desktop automatically via [Smithery](https://smithery.ai/server/systemprompt-mcp-notion): 82 | 83 | ```bash 84 | npx -y @smithery/cli install systemprompt-mcp-notion --client claude 85 | ``` 86 | 87 | ```bash 88 | npm install systemprompt-mcp-notion 89 | ``` 90 | 91 | 2. **Configuration** 92 | Create a `.env` file: 93 | 94 | ```env 95 | SYSTEMPROMPT_API_KEY=your_systemprompt_api_key 96 | NOTION_API_KEY=your_notion_integration_token 97 | ``` 98 | 99 | 3. **MCP Configuration** 100 | Add the following to your MCP configuration JSON: 101 | 102 | ```json 103 | { 104 | "mcpServers": { 105 | "notion": { 106 | "command": "npx", 107 | "args": ["systemprompt-mcp-notion"], 108 | "env": { 109 | "SYSTEMPROMPT_API_KEY": "your_systemprompt_api_key", 110 | "NOTION_API_KEY": "your_notion_integration_token" 111 | } 112 | } 113 | } 114 | } 115 | ``` 116 | 117 | Alternatively, if you've installed the package locally: 118 | 119 | ```json 120 | { 121 | "mcpServers": { 122 | "notion": { 123 | "command": "node", 124 | "args": ["./node_modules/systemprompt-mcp-notion/build/index.js"], 125 | "env": { 126 | "SYSTEMPROMPT_API_KEY": "your_systemprompt_api_key", 127 | "NOTION_API_KEY": "your_notion_integration_token" 128 | } 129 | } 130 | } 131 | } 132 | ``` 133 | 134 | ## Development 135 | 136 | ### Setup 137 | 138 | 1. Clone the repository: 139 | 140 | ```bash 141 | git clone https://github.com/systemprompt-io/systemprompt-mcp-notion.git 142 | cd systemprompt-mcp-notion 143 | ``` 144 | 145 | 2. Install dependencies: 146 | 147 | ```bash 148 | npm install 149 | ``` 150 | 151 | 3. Set up environment: 152 | ```bash 153 | cp .env.example .env 154 | # Edit .env with your API keys 155 | ``` 156 | 157 | ### Testing 158 | 159 | We maintain high test coverage using Jest: 160 | 161 | ```bash 162 | # Run all tests 163 | npm test 164 | 165 | # Watch mode for development 166 | npm run test:watch 167 | 168 | # Generate coverage report 169 | npm run test:coverage 170 | 171 | # Test Notion API connection 172 | npm run test:notion 173 | ``` 174 | -------------------------------------------------------------------------------- /credentials/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ejb503/systemprompt-mcp-notion/056c9cef94df3e45efbe37b41457898182cfa693/credentials/.gitkeep -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import eslint from "@eslint/js"; 2 | import tseslint from "@typescript-eslint/eslint-plugin"; 3 | import tsparser from "@typescript-eslint/parser"; 4 | import globals from "globals"; 5 | 6 | export default [ 7 | eslint.configs.recommended, 8 | { 9 | ignores: [ 10 | "build/**/*", 11 | "node_modules/**/*", 12 | "coverage/**/*", 13 | "jest.setup.ts", 14 | ], 15 | }, 16 | { 17 | files: ["**/*.js", "**/*.ts", "**/*.tsx"], 18 | languageOptions: { 19 | parser: tsparser, 20 | parserOptions: { 21 | ecmaVersion: "latest", 22 | sourceType: "module", 23 | }, 24 | globals: { 25 | ...globals.node, 26 | ...globals.jest, 27 | process: true, 28 | console: true, 29 | Buffer: true, 30 | fetch: true, 31 | Headers: true, 32 | Blob: true, 33 | setImmediate: true, 34 | RequestInfo: true, 35 | RequestInit: true, 36 | }, 37 | }, 38 | plugins: { 39 | "@typescript-eslint": tseslint, 40 | }, 41 | rules: { 42 | ...tseslint.configs.recommended.rules, 43 | "@typescript-eslint/no-unused-vars": [ 44 | "error", 45 | { 46 | argsIgnorePattern: "^_", 47 | varsIgnorePattern: "^_", 48 | }, 49 | ], 50 | "@typescript-eslint/no-explicit-any": "off", 51 | "no-console": "off", 52 | "no-undef": "off", 53 | "no-dupe-keys": "off", 54 | }, 55 | }, 56 | { 57 | files: [ 58 | "**/*.test.ts", 59 | "**/*.test.tsx", 60 | "**/__tests__/**/*.ts", 61 | "**/__mocks__/**/*.ts", 62 | "scripts/**/*", 63 | "src/handlers/**/*", 64 | "src/services/**/*", 65 | ], 66 | rules: { 67 | "@typescript-eslint/no-explicit-any": "off", 68 | "@typescript-eslint/ban-ts-comment": "off", 69 | "@typescript-eslint/no-unsafe-assignment": "off", 70 | "@typescript-eslint/no-unsafe-member-access": "off", 71 | "@typescript-eslint/no-unsafe-call": "off", 72 | "@typescript-eslint/no-unsafe-return": "off", 73 | "@typescript-eslint/no-unused-vars": "off", 74 | "no-unused-vars": "off", 75 | }, 76 | }, 77 | ]; 78 | -------------------------------------------------------------------------------- /jest.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | export default { 3 | preset: "ts-jest", 4 | testEnvironment: "node", 5 | extensionsToTreatAsEsm: [".ts", ".mts"], 6 | moduleNameMapper: { 7 | "(.+)\\.js": "$1", 8 | "^@modelcontextprotocol/sdk$": 9 | "/src/__mocks__/@modelcontextprotocol/sdk.ts", 10 | "^@modelcontextprotocol/sdk/server/stdio$": 11 | "/src/__mocks__/@modelcontextprotocol/sdk.ts", 12 | "^@modelcontextprotocol/sdk/server$": 13 | "/src/__mocks__/@modelcontextprotocol/sdk.ts", 14 | "^node:process$": "/src/__mocks__/node_process.ts", 15 | }, 16 | transform: { 17 | "^.+\\.ts$": [ 18 | "ts-jest", 19 | { 20 | tsconfig: "tsconfig.json", 21 | useESM: true, 22 | }, 23 | ], 24 | "^.+\\.js$": [ 25 | "babel-jest", 26 | { 27 | presets: [["@babel/preset-env", { targets: { node: "current" } }]], 28 | }, 29 | ], 30 | }, 31 | transformIgnorePatterns: [], 32 | testMatch: ["**/__tests__/**/*.test.ts"], 33 | collectCoverage: true, 34 | collectCoverageFrom: [ 35 | "src/**/*.ts", 36 | "!src/**/*.test.ts", 37 | "!src/**/*.d.ts", 38 | "!src/types/**/*", 39 | ], 40 | }; 41 | -------------------------------------------------------------------------------- /jest.setup.ts: -------------------------------------------------------------------------------- 1 | import { jest } from "@jest/globals"; 2 | import { 3 | createMockResponse, 4 | MockResponseOptions, 5 | } from "./src/__tests__/test-utils"; 6 | 7 | // Define global types for our custom matchers and utilities 8 | declare global { 9 | // eslint-disable-next-line @typescript-eslint/no-namespace 10 | namespace jest { 11 | interface Matchers { 12 | toBeValidDate(): R; 13 | toBeValidUUID(): R; 14 | } 15 | } 16 | 17 | function mockFetchResponse(data: any, options?: MockResponseOptions): void; 18 | function mockFetchError(message: string): void; 19 | } 20 | 21 | // Mock fetch globally with a more flexible implementation 22 | const mockFetch = jest.fn( 23 | (input: RequestInfo | URL, init?: RequestInit): Promise => { 24 | // Default success response 25 | return Promise.resolve(createMockResponse({})); 26 | } 27 | ); 28 | 29 | // Type assertion for global fetch mock 30 | global.fetch = mockFetch; 31 | 32 | // Utility to set up fetch mock responses 33 | global.mockFetchResponse = (data: any, options: MockResponseOptions = {}) => { 34 | mockFetch.mockImplementationOnce(() => 35 | Promise.resolve(createMockResponse(data, options)) 36 | ); 37 | }; 38 | 39 | // Utility to set up fetch mock error 40 | global.mockFetchError = (message: string) => { 41 | mockFetch.mockImplementationOnce(() => Promise.reject(new Error(message))); 42 | }; 43 | 44 | // Add custom matchers 45 | expect.extend({ 46 | toBeValidDate(received: string) { 47 | const date = new Date(received); 48 | const pass = date instanceof Date && !isNaN(date.getTime()); 49 | return { 50 | pass, 51 | message: () => 52 | `expected ${received} to ${pass ? "not " : ""}be a valid date string`, 53 | }; 54 | }, 55 | toBeValidUUID(received: string) { 56 | const uuidRegex = 57 | /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; 58 | const pass = uuidRegex.test(received); 59 | return { 60 | pass, 61 | message: () => 62 | `expected ${received} to ${pass ? "not " : ""}be a valid UUID`, 63 | }; 64 | }, 65 | }); 66 | 67 | // Reset all mocks before each test 68 | beforeEach(() => { 69 | jest.clearAllMocks(); 70 | }); 71 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "systemprompt-mcp-notion", 3 | "version": "1.0.7", 4 | "description": "A specialized Model Context Protocol (MCP) server that integrates Notion into your AI workflows. This server enables seamless access to Notion through MCP, allowing AI agents to interact with pages, databases, and comments.", 5 | "type": "module", 6 | "bin": { 7 | "systemprompt-mcp-notion": "./build/index.js" 8 | }, 9 | "files": [ 10 | "build" 11 | ], 12 | "scripts": { 13 | "build": "tsc", 14 | "prepare": "npm run build", 15 | "watch": "tsc --watch", 16 | "inspector": "npx @modelcontextprotocol/inspector build/index.js", 17 | "test": "jest", 18 | "test:watch": "jest --watch", 19 | "test:coverage": "jest --coverage", 20 | "test:notion": "node --loader ts-node/esm ./scripts/test-notion.ts", 21 | "lint": "eslint .", 22 | "lint:fix": "eslint . --fix" 23 | }, 24 | "dependencies": { 25 | "@modelcontextprotocol/sdk": "^0.6.0", 26 | "@notionhq/client": "^2.2.15", 27 | "ajv": "^8.17.1", 28 | "dotenv": "^16.4.5" 29 | }, 30 | "devDependencies": { 31 | "@babel/preset-env": "^7.26.0", 32 | "@types/dotenv": "^8.2.0", 33 | "@types/jest": "^29.5.12", 34 | "@types/json-schema": "^7.0.15", 35 | "@types/node": "^20.11.24", 36 | "@typescript-eslint/eslint-plugin": "^8.20.0", 37 | "@typescript-eslint/parser": "^8.20.0", 38 | "babel-jest": "^29.7.0", 39 | "cross-env": "^7.0.3", 40 | "eslint": "^9.18.0", 41 | "globals": "^15.14.0", 42 | "jest": "^29.7.0", 43 | "ts-jest": "^29.1.2", 44 | "ts-jest-resolver": "^2.0.1", 45 | "ts-node": "^10.9.2", 46 | "typescript": "^5.3.3" 47 | }, 48 | "repository": { 49 | "type": "git", 50 | "url": "https://github.com/Ejb503/systemprompt-mcp-notion" 51 | }, 52 | "keywords": [ 53 | "systemprompt", 54 | "mcp", 55 | "model-context-protocol", 56 | "notion" 57 | ], 58 | "author": "SystemPrompt", 59 | "license": "Apache-2.0", 60 | "bugs": { 61 | "url": "https://github.com/Ejb503/systemprompt-mcp-notion/issues" 62 | }, 63 | "homepage": "https://systemprompt.io", 64 | "engines": { 65 | "node": ">=18.0.0" 66 | } 67 | } -------------------------------------------------------------------------------- /scripts/test-notion.ts: -------------------------------------------------------------------------------- 1 | import { Client } from "@notionhq/client"; 2 | import { config } from "dotenv"; 3 | import type { 4 | PageObjectResponse, 5 | DatabaseObjectResponse, 6 | } from "@notionhq/client/build/src/api-endpoints.d.ts"; 7 | 8 | // Load environment variables 9 | config(); 10 | 11 | const notion = new Client({ 12 | auth: process.env.NOTION_API_KEY, 13 | }); 14 | 15 | async function testNotionAPI() { 16 | try { 17 | // First test the API key by getting user bot info 18 | console.log("\n=== Testing API Access ==="); 19 | try { 20 | const response = await notion.users.me({}); 21 | console.log("Bot Info:", JSON.stringify(response, null, 2)); 22 | console.log("Integration Name:", response.name); 23 | console.log("Integration Type:", response.type); 24 | } catch (error) { 25 | console.error( 26 | "Failed to get bot info. Your API key might be invalid:", 27 | error 28 | ); 29 | return; 30 | } 31 | 32 | console.log("\n=== Testing Workspace Access ==="); 33 | // Try to list all pages without any filter first 34 | const workspaceResponse = await notion.search({ 35 | page_size: 10, 36 | }); 37 | console.log("Total items in workspace:", workspaceResponse.results.length); 38 | console.log( 39 | "Types of items found:", 40 | workspaceResponse.results 41 | .map((item) => item.object) 42 | .filter((v, i, a) => a.indexOf(v) === i) 43 | ); 44 | 45 | if (workspaceResponse.results.length === 0) { 46 | console.log("\nNo items found in workspace. This likely means:"); 47 | console.log( 48 | "1. You haven't shared any pages/databases with the integration" 49 | ); 50 | console.log("2. The integration doesn't have the correct permissions"); 51 | console.log("\nTo fix this:"); 52 | console.log("1. Go to a page in your Notion workspace"); 53 | console.log("2. Click the '•••' menu in the top right"); 54 | console.log("3. Click 'Add connections'"); 55 | console.log("4. Select your integration"); 56 | return; 57 | } 58 | 59 | console.log("\n=== Testing Search Pages ==="); 60 | const searchResponse = await notion.search({ 61 | query: "test", 62 | filter: { 63 | property: "object", 64 | value: "page", 65 | }, 66 | }); 67 | console.log("Number of pages found:", searchResponse.results.length); 68 | 69 | console.log("\n=== Testing List Databases ==="); 70 | const databasesResponse = await notion.search({ 71 | filter: { 72 | property: "object", 73 | value: "database", 74 | }, 75 | }); 76 | console.log("Number of databases found:", databasesResponse.results.length); 77 | 78 | if (databasesResponse.results.length > 0) { 79 | const firstDatabase = databasesResponse 80 | .results[0] as DatabaseObjectResponse; 81 | console.log("\nFound database:"); 82 | console.log("- ID:", firstDatabase.id); 83 | console.log( 84 | "- Title:", 85 | firstDatabase.title?.[0]?.plain_text || "Untitled" 86 | ); 87 | 88 | console.log("\n=== Testing Database Access ==="); 89 | try { 90 | const databaseItems = await notion.databases.query({ 91 | database_id: firstDatabase.id, 92 | page_size: 10, 93 | }); 94 | console.log("Can query database:", true); 95 | console.log("Number of items:", databaseItems.results.length); 96 | } catch (error) { 97 | console.log("Cannot query database. Error:", error); 98 | } 99 | } else { 100 | console.log("\nNo databases found. To test database functionality:"); 101 | console.log("1. Create a database in Notion"); 102 | console.log("2. Share it with this integration"); 103 | console.log("3. Run this test again"); 104 | } 105 | } catch (error) { 106 | console.error("\nError:", error); 107 | if (error instanceof Error) { 108 | console.error("Error message:", error.message); 109 | console.error("Error stack:", error.stack); 110 | } 111 | } 112 | } 113 | 114 | // Run the tests 115 | console.log("Starting Notion API tests..."); 116 | console.log( 117 | "Using API Key:", 118 | process.env.NOTION_API_KEY ? "Present" : "Missing" 119 | ); 120 | testNotionAPI().then(() => console.log("\nTests completed")); 121 | -------------------------------------------------------------------------------- /src/__mocks__/@modelcontextprotocol/sdk.ts: -------------------------------------------------------------------------------- 1 | import { jest } from "@jest/globals"; 2 | import mockProcess from "../node_process"; 3 | 4 | // Mock server type 5 | export type MockServer = { 6 | setRequestHandler: jest.Mock; 7 | connect: jest.Mock; 8 | onRequest: jest.Mock; 9 | }; 10 | 11 | // Mock implementations 12 | const mockServer = jest.fn().mockImplementation(() => ({ 13 | setRequestHandler: jest.fn(), 14 | connect: jest.fn(), 15 | onRequest: jest.fn(), 16 | })); 17 | 18 | const mockStdioServerTransport = jest.fn().mockImplementation(() => ({ 19 | onRequest: jest.fn(), 20 | onNotification: jest.fn(), 21 | })); 22 | 23 | const mockTypes = { 24 | ListToolsRequest: jest.fn(), 25 | CallToolRequest: jest.fn(), 26 | ToolCallContent: jest.fn().mockImplementation((args: unknown) => ({ 27 | type: "sampling_request", 28 | sampling_request: { 29 | method: "createPage", 30 | params: { 31 | parent: { 32 | type: "workspace", 33 | workspace: true, 34 | }, 35 | properties: { 36 | title: [ 37 | { 38 | text: { 39 | content: "Test Page", 40 | }, 41 | }, 42 | ], 43 | }, 44 | children: [], 45 | }, 46 | }, 47 | })), 48 | }; 49 | 50 | // Export everything needed by the tests 51 | export const Server = mockServer; 52 | export const StdioServerTransport = mockStdioServerTransport; 53 | export const types = mockTypes; 54 | export { mockProcess as process }; 55 | 56 | // Default export for ESM compatibility 57 | export default { 58 | Server: mockServer, 59 | StdioServerTransport: mockStdioServerTransport, 60 | types: mockTypes, 61 | process: mockProcess, 62 | }; 63 | 64 | // Mark as ESM module 65 | Object.defineProperty(exports, "__esModule", { value: true }); 66 | -------------------------------------------------------------------------------- /src/__mocks__/node_process.ts: -------------------------------------------------------------------------------- 1 | import { jest } from "@jest/globals"; 2 | 3 | const mockProcess = { 4 | stdout: { 5 | write: jest.fn(), 6 | on: jest.fn(), 7 | }, 8 | stdin: { 9 | on: jest.fn(), 10 | resume: jest.fn(), 11 | setEncoding: jest.fn(), 12 | }, 13 | env: {}, 14 | exit: jest.fn(), 15 | }; 16 | 17 | export default mockProcess; 18 | -------------------------------------------------------------------------------- /src/__mocks__/server.ts: -------------------------------------------------------------------------------- 1 | import { jest } from "@jest/globals"; 2 | import type { Server } from "@modelcontextprotocol/sdk/server/index.js"; 3 | 4 | export const mockServer: Partial = {}; 5 | 6 | export const getMockServer = () => mockServer; 7 | -------------------------------------------------------------------------------- /src/__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | jest, 3 | describe, 4 | it, 5 | expect, 6 | beforeEach, 7 | afterEach, 8 | } from "@jest/globals"; 9 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 10 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 11 | import type { MockServer } from "../__mocks__/@modelcontextprotocol/sdk"; 12 | import { SystemPromptService } from "../services/systemprompt-service.js"; 13 | import { NotionService } from "../services/notion-service.js"; 14 | import { 15 | ListResourcesRequestSchema, 16 | ReadResourceRequestSchema, 17 | ListToolsRequestSchema, 18 | ListPromptsRequestSchema, 19 | GetPromptRequestSchema, 20 | CallToolRequestSchema, 21 | CreateMessageRequestSchema, 22 | } from "@modelcontextprotocol/sdk/types.js"; 23 | import { serverConfig, serverCapabilities } from "../config/server-config.js"; 24 | import { main } from "../index.js"; 25 | 26 | // Mock dependencies 27 | jest.mock("../services/systemprompt-service.js", () => ({ 28 | SystemPromptService: { 29 | initialize: jest.fn(), 30 | getInstance: jest.fn(), 31 | cleanup: jest.fn(), 32 | }, 33 | })); 34 | jest.mock("../services/notion-service.js", () => ({ 35 | NotionService: { 36 | initialize: jest.fn(), 37 | getInstance: jest.fn().mockReturnValue({ 38 | searchPages: jest.fn(), 39 | getPage: jest.fn(), 40 | createPage: jest.fn(), 41 | updatePage: jest.fn(), 42 | deletePage: jest.fn(), 43 | }), 44 | }, 45 | })); 46 | jest.mock("dotenv", () => ({ 47 | config: jest.fn(), 48 | })); 49 | 50 | // Mock the server module 51 | const mockServer: MockServer = { 52 | setRequestHandler: jest.fn(), 53 | connect: jest.fn(), 54 | onRequest: jest.fn(), 55 | }; 56 | 57 | jest.mock("@modelcontextprotocol/sdk/server/index.js", () => ({ 58 | Server: jest.fn().mockImplementation(() => mockServer), 59 | })); 60 | 61 | // Mock process.env 62 | const originalEnv = process.env; 63 | 64 | describe("Server Initialization", () => { 65 | beforeEach(() => { 66 | jest.clearAllMocks(); 67 | process.env = { ...originalEnv }; 68 | jest.resetModules(); 69 | 70 | // Reset server mock 71 | Object.assign(mockServer, { 72 | setRequestHandler: jest.fn(), 73 | connect: jest.fn(), 74 | onRequest: jest.fn(), 75 | }); 76 | }); 77 | 78 | afterAll(() => { 79 | process.env = originalEnv; 80 | }); 81 | 82 | it("should initialize services and connect server with valid environment", async () => { 83 | // Set up environment variables 84 | process.env.SYSTEMPROMPT_API_KEY = "test-systemprompt-key"; 85 | process.env.NOTION_API_KEY = "test-notion-key"; 86 | 87 | // Run main function 88 | await main(); 89 | 90 | // Verify SystemPrompt service initialization 91 | expect(SystemPromptService.initialize).toHaveBeenCalledWith( 92 | "test-systemprompt-key" 93 | ); 94 | 95 | // Verify Notion service initialization 96 | expect(NotionService.initialize).toHaveBeenCalledWith("test-notion-key"); 97 | 98 | // Verify server initialization 99 | expect(Server).toHaveBeenCalledWith(serverConfig, serverCapabilities); 100 | 101 | // Verify request handlers were set 102 | expect(mockServer.setRequestHandler).toHaveBeenCalledWith( 103 | ListResourcesRequestSchema, 104 | expect.any(Function) 105 | ); 106 | expect(mockServer.setRequestHandler).toHaveBeenCalledWith( 107 | ReadResourceRequestSchema, 108 | expect.any(Function) 109 | ); 110 | expect(mockServer.setRequestHandler).toHaveBeenCalledWith( 111 | ListToolsRequestSchema, 112 | expect.any(Function) 113 | ); 114 | expect(mockServer.setRequestHandler).toHaveBeenCalledWith( 115 | ListPromptsRequestSchema, 116 | expect.any(Function) 117 | ); 118 | expect(mockServer.setRequestHandler).toHaveBeenCalledWith( 119 | GetPromptRequestSchema, 120 | expect.any(Function) 121 | ); 122 | expect(mockServer.setRequestHandler).toHaveBeenCalledWith( 123 | CallToolRequestSchema, 124 | expect.any(Function) 125 | ); 126 | expect(mockServer.setRequestHandler).toHaveBeenCalledWith( 127 | CreateMessageRequestSchema, 128 | expect.any(Function) 129 | ); 130 | 131 | // Verify server connection 132 | expect(mockServer.connect).toHaveBeenCalled(); 133 | }); 134 | 135 | it("should throw error if SYSTEMPROMPT_API_KEY is missing", async () => { 136 | // Set up environment variables without SYSTEMPROMPT_API_KEY 137 | process.env.NOTION_API_KEY = "test-notion-key"; 138 | delete process.env.SYSTEMPROMPT_API_KEY; 139 | 140 | // Import and run main function 141 | const { main } = await import("../index.js"); 142 | await expect(main()).rejects.toThrow( 143 | "SYSTEMPROMPT_API_KEY environment variable is required" 144 | ); 145 | }); 146 | 147 | it("should throw error if NOTION_API_KEY is missing", async () => { 148 | // Set up environment variables without NOTION_API_KEY 149 | process.env.SYSTEMPROMPT_API_KEY = "test-systemprompt-key"; 150 | delete process.env.NOTION_API_KEY; 151 | 152 | // Import and run main function 153 | const { main } = await import("../index.js"); 154 | await expect(main()).rejects.toThrow( 155 | "NOTION_API_KEY environment variable is required" 156 | ); 157 | }); 158 | }); 159 | -------------------------------------------------------------------------------- /src/__tests__/mock-objects.ts: -------------------------------------------------------------------------------- 1 | import type { Prompt } from "@modelcontextprotocol/sdk/types.js"; 2 | import type { JSONSchema7TypeName } from "json-schema"; 3 | import type { SystempromptPromptResponse } from "../types/systemprompt.js"; 4 | 5 | // Basic mock with simple string input 6 | export const mockSystemPromptResult: SystempromptPromptResponse = { 7 | id: "123", 8 | instruction: { 9 | static: "You are a helpful assistant that helps users write documentation.", 10 | dynamic: "", 11 | state: "", 12 | }, 13 | input: { 14 | name: "message", 15 | description: "The user's documentation request", 16 | type: ["message"], 17 | schema: { 18 | type: "object" as JSONSchema7TypeName, 19 | properties: { 20 | message: { 21 | type: "string" as JSONSchema7TypeName, 22 | description: "The user's documentation request", 23 | }, 24 | }, 25 | required: ["message"], 26 | }, 27 | }, 28 | output: { 29 | name: "response", 30 | description: "The assistant's response", 31 | type: ["message"], 32 | schema: { 33 | type: "object" as JSONSchema7TypeName, 34 | properties: { 35 | response: { 36 | type: "string" as JSONSchema7TypeName, 37 | description: "The assistant's response", 38 | }, 39 | }, 40 | required: ["response"], 41 | }, 42 | }, 43 | metadata: { 44 | title: "Documentation Helper", 45 | description: "An assistant that helps users write better documentation", 46 | created: new Date().toISOString(), 47 | updated: new Date().toISOString(), 48 | version: 1, 49 | status: "published", 50 | author: "test-user", 51 | log_message: "Initial creation", 52 | }, 53 | _link: "https://systemprompt.io/prompts/123", 54 | }; 55 | 56 | // Mock with array input 57 | export const mockArrayPromptResult: SystempromptPromptResponse = { 58 | id: "124", 59 | instruction: { 60 | dynamic: "", 61 | state: "", 62 | static: 63 | "You are a helpful assistant that helps users manage their todo lists.", 64 | }, 65 | input: { 66 | name: "todos", 67 | description: "The user's todo list items", 68 | type: ["structured_data"], 69 | schema: { 70 | type: "object" as JSONSchema7TypeName, 71 | properties: { 72 | items: { 73 | type: "array" as JSONSchema7TypeName, 74 | description: "List of todo items", 75 | items: { 76 | type: "string" as JSONSchema7TypeName, 77 | description: "A todo item", 78 | }, 79 | minItems: 1, 80 | }, 81 | priority: { 82 | type: "string" as JSONSchema7TypeName, 83 | enum: ["high", "medium", "low"], 84 | description: "Priority level for the items", 85 | }, 86 | }, 87 | required: ["items"], 88 | }, 89 | }, 90 | output: { 91 | name: "organized_todos", 92 | description: "The organized todo list", 93 | type: ["structured_data"], 94 | schema: { 95 | type: "object" as JSONSchema7TypeName, 96 | properties: { 97 | organized_items: { 98 | type: "array" as JSONSchema7TypeName, 99 | items: { 100 | type: "string" as JSONSchema7TypeName, 101 | }, 102 | }, 103 | }, 104 | required: ["organized_items"], 105 | }, 106 | }, 107 | metadata: { 108 | title: "Todo List Organizer", 109 | description: "An assistant that helps users organize their todo lists", 110 | created: new Date().toISOString(), 111 | updated: new Date().toISOString(), 112 | version: 1, 113 | status: "published", 114 | author: "test-user", 115 | log_message: "Initial creation", 116 | }, 117 | _link: "https://systemprompt.io/prompts/124", 118 | }; 119 | 120 | // Mock with nested object input 121 | export const mockNestedPromptResult: SystempromptPromptResponse = { 122 | id: "125", 123 | instruction: { 124 | dynamic: "", 125 | state: "", 126 | static: 127 | "You are a helpful assistant that helps users manage their contacts.", 128 | }, 129 | input: { 130 | name: "contact", 131 | description: "The contact information", 132 | type: ["structured_data"], 133 | schema: { 134 | type: "object" as JSONSchema7TypeName, 135 | properties: { 136 | person: { 137 | type: "object" as JSONSchema7TypeName, 138 | description: "Person's information", 139 | properties: { 140 | name: { 141 | type: "object" as JSONSchema7TypeName, 142 | properties: { 143 | first: { 144 | type: "string" as JSONSchema7TypeName, 145 | description: "First name", 146 | }, 147 | last: { 148 | type: "string" as JSONSchema7TypeName, 149 | description: "Last name", 150 | }, 151 | }, 152 | required: ["first", "last"], 153 | }, 154 | contact: { 155 | type: "object" as JSONSchema7TypeName, 156 | properties: { 157 | email: { 158 | type: "string" as JSONSchema7TypeName, 159 | description: "Email address", 160 | format: "email", 161 | }, 162 | phone: { 163 | type: "string" as JSONSchema7TypeName, 164 | description: "Phone number", 165 | pattern: "^\\+?[1-9]\\d{1,14}$", 166 | }, 167 | }, 168 | required: ["email"], 169 | }, 170 | }, 171 | required: ["name"], 172 | }, 173 | tags: { 174 | type: "array" as JSONSchema7TypeName, 175 | description: "Contact tags", 176 | items: { 177 | type: "string" as JSONSchema7TypeName, 178 | }, 179 | }, 180 | }, 181 | required: ["person"], 182 | }, 183 | }, 184 | output: { 185 | name: "formatted_contact", 186 | description: "The formatted contact information", 187 | type: ["structured_data"], 188 | schema: { 189 | type: "object" as JSONSchema7TypeName, 190 | properties: { 191 | formatted: { 192 | type: "string" as JSONSchema7TypeName, 193 | }, 194 | }, 195 | required: ["formatted"], 196 | }, 197 | }, 198 | metadata: { 199 | title: "Contact Manager", 200 | description: "An assistant that helps users manage their contacts", 201 | created: new Date().toISOString(), 202 | updated: new Date().toISOString(), 203 | version: 1, 204 | status: "published", 205 | author: "test-user", 206 | log_message: "Initial creation", 207 | }, 208 | _link: "https://systemprompt.io/prompts/125", 209 | }; 210 | 211 | // Test mocks for edge cases 212 | export const mockEmptyPropsPrompt = { 213 | ...mockSystemPromptResult, 214 | input: { 215 | ...mockSystemPromptResult.input, 216 | schema: { 217 | type: "object" as JSONSchema7TypeName, 218 | properties: {}, 219 | }, 220 | }, 221 | }; 222 | 223 | export const mockInvalidPropsPrompt = { 224 | ...mockSystemPromptResult, 225 | input: { 226 | ...mockSystemPromptResult.input, 227 | schema: { 228 | type: "object" as JSONSchema7TypeName, 229 | properties: { 230 | test1: { 231 | type: "string" as JSONSchema7TypeName, 232 | }, 233 | }, 234 | }, 235 | }, 236 | }; 237 | 238 | export const mockWithoutDescPrompt = { 239 | ...mockSystemPromptResult, 240 | input: { 241 | ...mockSystemPromptResult.input, 242 | schema: { 243 | type: "object" as JSONSchema7TypeName, 244 | properties: { 245 | test: { 246 | type: "string" as JSONSchema7TypeName, 247 | }, 248 | }, 249 | required: ["test"], 250 | }, 251 | }, 252 | }; 253 | 254 | export const mockWithoutRequiredPrompt = { 255 | ...mockSystemPromptResult, 256 | input: { 257 | ...mockSystemPromptResult.input, 258 | schema: { 259 | type: "object" as JSONSchema7TypeName, 260 | properties: { 261 | test: { 262 | type: "string" as JSONSchema7TypeName, 263 | description: "test field", 264 | }, 265 | }, 266 | }, 267 | }, 268 | }; 269 | 270 | export const mockFalsyDescPrompt = { 271 | ...mockSystemPromptResult, 272 | input: { 273 | ...mockSystemPromptResult.input, 274 | schema: { 275 | type: "object" as JSONSchema7TypeName, 276 | properties: { 277 | test1: { 278 | type: "string" as JSONSchema7TypeName, 279 | description: "", 280 | }, 281 | test2: { 282 | type: "string" as JSONSchema7TypeName, 283 | description: "", 284 | }, 285 | test3: { 286 | type: "string" as JSONSchema7TypeName, 287 | description: "", 288 | }, 289 | }, 290 | required: ["test1", "test2", "test3"], 291 | }, 292 | }, 293 | }; 294 | 295 | // Expected MCP format for basic mock 296 | export const mockMCPPrompt: Prompt = { 297 | name: "Documentation Helper", 298 | description: "An assistant that helps users write better documentation", 299 | messages: [ 300 | { 301 | role: "assistant", 302 | content: { 303 | type: "text", 304 | text: "You are a helpful assistant that helps users write documentation.", 305 | }, 306 | }, 307 | ], 308 | arguments: [ 309 | { 310 | name: "message", 311 | description: "The user's documentation request", 312 | required: true, 313 | }, 314 | ], 315 | }; 316 | 317 | // Expected MCP format for array mock 318 | export const mockArrayMCPPrompt: Prompt = { 319 | name: "Todo List Organizer", 320 | description: "An assistant that helps users organize their todo lists", 321 | messages: [ 322 | { 323 | role: "assistant", 324 | content: { 325 | type: "text", 326 | text: "You are a helpful assistant that helps users manage their todo lists.", 327 | }, 328 | }, 329 | ], 330 | arguments: [ 331 | { 332 | name: "items", 333 | description: "List of todo items", 334 | required: true, 335 | }, 336 | { 337 | name: "priority", 338 | description: "Priority level for the items", 339 | required: false, 340 | }, 341 | ], 342 | }; 343 | 344 | // Expected MCP format for nested mock 345 | export const mockNestedMCPPrompt: Prompt = { 346 | name: "Contact Manager", 347 | description: "An assistant that helps users manage their contacts", 348 | messages: [ 349 | { 350 | role: "assistant", 351 | content: { 352 | type: "text", 353 | text: "You are a helpful assistant that helps users manage their contacts.", 354 | }, 355 | }, 356 | ], 357 | arguments: [ 358 | { 359 | name: "person", 360 | description: "Person's information", 361 | required: true, 362 | }, 363 | { 364 | name: "tags", 365 | description: "Contact tags", 366 | required: false, 367 | }, 368 | ], 369 | }; 370 | -------------------------------------------------------------------------------- /src/__tests__/test-utils.test.ts: -------------------------------------------------------------------------------- 1 | import { jest, describe, it, expect } from "@jest/globals"; 2 | import { 3 | createMockResponse, 4 | TestFixtures, 5 | flushPromises, 6 | isError, 7 | createPartialMock, 8 | } from "./test-utils"; 9 | 10 | describe("Test Utilities", () => { 11 | describe("createMockResponse", () => { 12 | it("should create a successful response with defaults", async () => { 13 | const data = { test: "data" }; 14 | const response = createMockResponse(data); 15 | 16 | expect(response.ok).toBe(true); 17 | expect(response.status).toBe(200); 18 | expect(response.statusText).toBe("OK"); 19 | expect(response.headers).toBeInstanceOf(Headers); 20 | 21 | // Test response methods 22 | expect(await response.json()).toEqual(data); 23 | expect(await response.text()).toBe(JSON.stringify(data)); 24 | expect(await response.blob()).toBeInstanceOf(Blob); 25 | }); 26 | 27 | it("should create a response with custom options", async () => { 28 | const data = "test-data"; 29 | const options = { 30 | ok: false, 31 | status: 400, 32 | statusText: "Bad Request", 33 | headers: { "Content-Type": "text/plain" }, 34 | }; 35 | const response = createMockResponse(data, options); 36 | 37 | expect(response.ok).toBe(false); 38 | expect(response.status).toBe(400); 39 | expect(response.statusText).toBe("Bad Request"); 40 | expect(response.headers.get("Content-Type")).toBe("text/plain"); 41 | 42 | // Test string data handling 43 | expect(await response.text()).toBe(data); 44 | }); 45 | }); 46 | 47 | describe("TestFixtures", () => { 48 | it("should create a note with default values", () => { 49 | const note = TestFixtures.createNote(); 50 | 51 | expect(note).toHaveProperty("id", "test-note-1"); 52 | expect(note).toHaveProperty("title", "Test Note"); 53 | expect(note).toHaveProperty("content", "Test content"); 54 | expect(note.created).toMatch( 55 | /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ 56 | ); 57 | }); 58 | 59 | it("should create a note with custom overrides", () => { 60 | const overrides = { 61 | id: "custom-id", 62 | title: "Custom Title", 63 | extraField: "extra", 64 | }; 65 | const note = TestFixtures.createNote(overrides); 66 | 67 | expect(note).toMatchObject(overrides); 68 | expect(note).toHaveProperty("content", "Test content"); 69 | expect(note.created).toBeDefined(); 70 | }); 71 | 72 | it("should create a list of notes with specified count", () => { 73 | const count = 3; 74 | const notes = TestFixtures.createNoteList(count); 75 | 76 | expect(notes).toHaveLength(count); 77 | notes.forEach((note, index) => { 78 | expect(note).toHaveProperty("id", `test-note-${index + 1}`); 79 | expect(note).toHaveProperty("title", "Test Note"); 80 | expect(note).toHaveProperty("content", "Test content"); 81 | expect(note.created).toBeDefined(); 82 | }); 83 | }); 84 | }); 85 | 86 | describe("flushPromises", () => { 87 | it("should wait for promises to resolve", async () => { 88 | let resolved = false; 89 | Promise.resolve().then(() => { 90 | resolved = true; 91 | }); 92 | 93 | expect(resolved).toBe(false); 94 | await flushPromises(); 95 | expect(resolved).toBe(true); 96 | }); 97 | 98 | it("should handle multiple promises", async () => { 99 | const results: number[] = []; 100 | Promise.resolve().then(() => results.push(1)); 101 | Promise.resolve().then(() => results.push(2)); 102 | Promise.resolve().then(() => results.push(3)); 103 | 104 | expect(results).toHaveLength(0); 105 | await flushPromises(); 106 | expect(results).toEqual([1, 2, 3]); 107 | }); 108 | }); 109 | 110 | describe("isError", () => { 111 | it("should identify Error objects", () => { 112 | expect(isError(new Error("test"))).toBe(true); 113 | expect(isError(new TypeError("test"))).toBe(true); 114 | }); 115 | 116 | it("should reject non-Error objects", () => { 117 | expect(isError({})).toBe(false); 118 | expect(isError("error")).toBe(false); 119 | expect(isError(null)).toBe(false); 120 | expect(isError(undefined)).toBe(false); 121 | expect(isError(42)).toBe(false); 122 | }); 123 | }); 124 | 125 | describe("createPartialMock", () => { 126 | interface TestService { 127 | method1(): string; 128 | method2(arg: number): Promise; 129 | } 130 | 131 | it("should create a typed mock object", () => { 132 | const mockMethod = jest.fn<() => string>().mockReturnValue("test"); 133 | const mock = createPartialMock({ 134 | method1: mockMethod, 135 | }); 136 | 137 | expect(mock.method1()).toBe("test"); 138 | expect(mock.method1).toHaveBeenCalled(); 139 | }); 140 | 141 | it("should handle empty overrides", () => { 142 | const mock = createPartialMock(); 143 | expect(mock).toEqual({}); 144 | }); 145 | 146 | it("should preserve mock functionality", async () => { 147 | const mockMethod = jest 148 | .fn<(arg: number) => Promise>() 149 | .mockResolvedValue(42); 150 | const mock = createPartialMock({ 151 | method2: mockMethod, 152 | }); 153 | 154 | const result = await mock.method2(1); 155 | expect(result).toBe(42); 156 | expect(mock.method2).toHaveBeenCalledWith(1); 157 | }); 158 | }); 159 | }); 160 | -------------------------------------------------------------------------------- /src/__tests__/test-utils.ts: -------------------------------------------------------------------------------- 1 | import { jest } from "@jest/globals"; 2 | 3 | /** 4 | * Type for mock response options 5 | */ 6 | export interface MockResponseOptions { 7 | ok?: boolean; 8 | status?: number; 9 | statusText?: string; 10 | headers?: Record; 11 | } 12 | 13 | /** 14 | * Creates a mock response object for testing 15 | */ 16 | export function createMockResponse( 17 | data: any, 18 | options: MockResponseOptions = {} 19 | ): Response { 20 | const { ok = true, status = 200, statusText = "OK", headers = {} } = options; 21 | 22 | return { 23 | ok, 24 | status, 25 | statusText, 26 | headers: new Headers(headers), 27 | json: () => Promise.resolve(data), 28 | text: () => 29 | Promise.resolve(typeof data === "string" ? data : JSON.stringify(data)), 30 | blob: () => Promise.resolve(new Blob()), 31 | } as Response; 32 | } 33 | 34 | /** 35 | * Test fixture generator for common test data 36 | */ 37 | export class TestFixtures { 38 | static createNote(overrides = {}) { 39 | return { 40 | id: "test-note-1", 41 | title: "Test Note", 42 | content: "Test content", 43 | created: new Date().toISOString(), 44 | ...overrides, 45 | }; 46 | } 47 | 48 | static createNoteList(count: number) { 49 | return Array.from({ length: count }, (_, i) => 50 | this.createNote({ id: `test-note-${i + 1}` }) 51 | ); 52 | } 53 | } 54 | 55 | /** 56 | * Helper to wait for promises to resolve 57 | */ 58 | export const flushPromises = () => 59 | new Promise((resolve) => setImmediate(resolve)); 60 | 61 | /** 62 | * Type guard for error objects 63 | */ 64 | export function isError(error: unknown): error is Error { 65 | return error instanceof Error; 66 | } 67 | 68 | /** 69 | * Creates a partial mock object with type safety 70 | */ 71 | export function createPartialMock( 72 | overrides: Partial = {} 73 | ): jest.Mocked { 74 | return overrides as jest.Mocked; 75 | } 76 | -------------------------------------------------------------------------------- /src/config/__llm__/README.md: -------------------------------------------------------------------------------- 1 | # System Prompt Notion Integration Server 2 | 3 | ## Overview 4 | 5 | This directory contains the configuration and metadata for the System Prompt Notion Integration Server, which implements the Model Context Protocol (MCP) for Notion services. It provides a standardized interface for AI agents to interact with Notion pages, databases, and comments. 6 | 7 | ## Files 8 | 9 | ### `server-config.ts` 10 | 11 | The main configuration file that exports: 12 | 13 | - `serverConfig`: Server metadata and Notion integration settings 14 | - `serverCapabilities`: Server capability definitions 15 | 16 | ## Configuration Structure 17 | 18 | ### Server Configuration 19 | 20 | ```typescript 21 | { 22 | name: string; // "systemprompt-mcp-notion" 23 | version: string; // Current server version 24 | metadata: { 25 | name: string; // "System Prompt Notion Integration Server" 26 | description: string; // Server description 27 | icon: string; // "mdi:notion" 28 | color: string; // "black" 29 | serverStartTime: number; // Server start timestamp 30 | environment: string; // process.env.NODE_ENV 31 | customData: { 32 | serverFeatures: string[]; // ["notion-pages", "notion-databases", "notion-comments"] 33 | supportedAPIs: string[]; // ["notion"] 34 | authProvider: string; // "notion-api" 35 | requiredScopes: string[]; // Notion API capabilities needed for access 36 | } 37 | } 38 | } 39 | ``` 40 | 41 | ### Server Capabilities 42 | 43 | ```typescript 44 | { 45 | capabilities: { 46 | resources: { 47 | listChanged: true, // Support for resource change notifications 48 | }, 49 | tools: {}, // Notion API-specific tool capabilities 50 | prompts: { 51 | listChanged: true, // Support for prompt change notifications 52 | } 53 | } 54 | } 55 | ``` 56 | 57 | ## Usage 58 | 59 | Import the configuration objects when setting up the MCP server: 60 | 61 | ```typescript 62 | import { serverConfig, serverCapabilities } from "./config/server-config.js"; 63 | ``` 64 | 65 | ## Environment Variables 66 | 67 | The server requires these environment variables: 68 | 69 | - `NODE_ENV`: Runtime environment (development/production) 70 | - `NOTION_API_KEY`: Notion integration token for API access 71 | - `SYSTEMPROMPT_API_KEY`: Systemprompt API key 72 | 73 | ## Features 74 | 75 | The server provides these core features: 76 | 77 | - **Page Management**: Create, read, update pages 78 | - **Database Integration**: Query and manage database items 79 | - **Comments**: Create and retrieve page comments 80 | - **Resource Notifications**: Real-time updates for resource changes 81 | - **MCP Compliance**: Full implementation of the Model Context Protocol 82 | 83 | ## Supported Notion Features 84 | 85 | - Page Operations 86 | - Database Operations 87 | - Comments and Discussions 88 | - Content Search 89 | - Property Management 90 | 91 | ## Authentication 92 | 93 | The server uses Notion's API token-based authentication with the following capabilities: 94 | 95 | - Read content 96 | - Update content 97 | - Insert content 98 | - Comment access 99 | 100 | Additional capabilities can be configured as needed for expanded API access. 101 | -------------------------------------------------------------------------------- /src/config/__tests__/server-config.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "@jest/globals"; 2 | import { serverConfig, serverCapabilities } from "../server-config.js"; 3 | 4 | describe("Server Configuration", () => { 5 | describe("serverConfig", () => { 6 | it("should have correct basic properties", () => { 7 | expect(serverConfig.name).toBe("systemprompt-mcp-notion"); 8 | expect(serverConfig.version).toBe("1.0.0"); 9 | }); 10 | 11 | it("should have correct metadata", () => { 12 | expect(serverConfig.metadata).toBeDefined(); 13 | expect(serverConfig.metadata.name).toBe( 14 | "System Prompt Notion Integration Server" 15 | ); 16 | expect(serverConfig.metadata.icon).toBe("mdi:notion"); 17 | expect(serverConfig.metadata.color).toBe("black"); 18 | expect(typeof serverConfig.metadata.serverStartTime).toBe("number"); 19 | expect(serverConfig.metadata.environment).toBe(process.env.NODE_ENV); 20 | }); 21 | 22 | it("should have correct custom data", () => { 23 | expect(serverConfig.metadata.customData).toBeDefined(); 24 | expect(serverConfig.metadata.customData.serverFeatures).toEqual([ 25 | "notion-pages", 26 | "notion-databases", 27 | "notion-comments", 28 | ]); 29 | expect(serverConfig.metadata.customData.supportedAPIs).toEqual([ 30 | "notion", 31 | ]); 32 | expect(serverConfig.metadata.customData.authProvider).toBe("notion-api"); 33 | expect(serverConfig.metadata.customData.requiredCapabilities).toEqual([ 34 | "read_content", 35 | "update_content", 36 | "insert_content", 37 | "comment_access", 38 | ]); 39 | }); 40 | }); 41 | 42 | describe("serverCapabilities", () => { 43 | it("should have correct capabilities structure", () => { 44 | expect(serverCapabilities.capabilities).toEqual({ 45 | resources: { 46 | listChanged: true, 47 | }, 48 | tools: {}, 49 | prompts: { 50 | listChanged: true, 51 | }, 52 | sampling: {}, 53 | }); 54 | }); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /src/config/server-config.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Implementation, 3 | ServerCapabilities, 4 | } from "@modelcontextprotocol/sdk/types.js"; 5 | 6 | interface ServerMetadata { 7 | name: string; 8 | description: string; 9 | icon: string; 10 | color: string; 11 | serverStartTime: number; 12 | environment: string | undefined; 13 | customData: { 14 | serverFeatures: string[]; 15 | supportedAPIs: string[]; 16 | authProvider: string; 17 | requiredCapabilities: string[]; 18 | }; 19 | } 20 | 21 | export const serverConfig: Implementation & { metadata: ServerMetadata } = { 22 | name: "systemprompt-mcp-notion", 23 | version: "1.0.7", 24 | metadata: { 25 | name: "System Prompt Notion Integration Server", 26 | description: 27 | "MCP server providing seamless integration with Notion. Enables AI agents to interact with Notion pages, databases, and comments through a secure, standardized interface.", 28 | icon: "mdi:notion", 29 | color: "black", 30 | serverStartTime: Date.now(), 31 | environment: process.env.NODE_ENV, 32 | customData: { 33 | serverFeatures: ["notion-pages", "notion-databases", "notion-comments"], 34 | supportedAPIs: ["notion"], 35 | authProvider: "notion-api", 36 | requiredCapabilities: [ 37 | "read_content", 38 | "update_content", 39 | "insert_content", 40 | "comment_access", 41 | ], 42 | }, 43 | }, 44 | }; 45 | 46 | export const serverCapabilities: { capabilities: ServerCapabilities } = { 47 | capabilities: { 48 | resources: { 49 | listChanged: true, 50 | }, 51 | tools: {}, 52 | prompts: { 53 | listChanged: true, 54 | }, 55 | sampling: {}, 56 | }, 57 | }; 58 | -------------------------------------------------------------------------------- /src/constants/instructions.ts: -------------------------------------------------------------------------------- 1 | // Instructions for creating new Notion pages 2 | export const NOTION_PAGE_CREATOR_INSTRUCTIONS = `You are an expert at creating Notion pages using the Notion API. Your task is to generate a rich, detailed Notion API request that expands upon basic user instructions to create comprehensive, well-structured pages. 3 | 4 | INPUT PARAMETERS: 5 | The user message will include these parameters: 6 | 1. databaseId: The ID of the database to create the page in (required) 7 | 2. userInstructions: Basic guidance for creating the page (required) 8 | 9 | YOUR ROLE: 10 | You should act as a content enhancer and structure expert by: 11 | 1. Taking the basic instructions and expanding them into a comprehensive page structure 12 | 2. Breaking down simple content into well-organized sections with appropriate headers 13 | 3. Adding relevant supplementary sections where appropriate 14 | 4. Using the full range of Notion blocks to create engaging, readable content 15 | 5. Maintaining the original intent while adding depth and clarity 16 | 17 | PARAMETER HANDLING: 18 | 1. Instructions: Parse the userInstructions to determine: 19 | - The page title (required) 20 | - The intended purpose and scope of the page 21 | - Opportunities for additional relevant sections 22 | 23 | 2. Content Enhancement: 24 | - Break content into logical sections with clear headers 25 | - Add appropriate formatting and structure 26 | - Include supplementary sections where valuable 27 | - Use varied block types to improve readability 28 | 29 | RESPONSE FORMAT: 30 | You must return a valid JSON object that matches this exact schema: 31 | { 32 | "parent": { 33 | "database_id": string, // ID of the database to create the page in 34 | }, 35 | "properties": { 36 | "title": [{ 37 | "text": { 38 | "content": string // Extract title from user instructions 39 | } 40 | }] 41 | }, 42 | "children": [ // Array of content blocks 43 | { 44 | "object": "block", 45 | "type": string, // The block type (paragraph, heading_1, etc.) 46 | [blockType]: { // Object matching the block type 47 | "rich_text": [{ 48 | "text": { 49 | "content": string // The block's content 50 | } 51 | }] 52 | } 53 | } 54 | ] 55 | } 56 | 57 | CONTENT FORMATTING RULES: 58 | 1. Title: Create a clear, descriptive title that captures the page's purpose 59 | 2. Structure: Create a logical hierarchy of content using: 60 | - heading_1 for main sections 61 | - heading_2 for subsections 62 | - heading_3 for detailed breakdowns 63 | 3. Content Enhancement: 64 | - Break long paragraphs into digestible chunks 65 | - Use bulleted_list_item for key points and features 66 | - Use numbered_list_item for sequential steps or priorities 67 | - Use quote blocks to highlight important information 68 | - Use code blocks for technical content 69 | - Use toggle blocks for supplementary information 70 | 4. Rich Text: Always wrap text content in the rich_text array format 71 | 72 | BLOCK TYPE REFERENCE: 73 | - paragraph: Regular text content 74 | - heading_1/2/3: Section headers (3 levels) 75 | - bulleted_list_item: Unordered list items 76 | - numbered_list_item: Ordered list items 77 | - to_do: Checkable items (- [ ] or - [x]) 78 | - toggle: Collapsible content 79 | - code: Code snippets with syntax highlighting 80 | - quote: Block quotes 81 | 82 | Remember: Your goal is to create a comprehensive, well-structured page that expands upon the basic input while maintaining its core purpose. Don't just replicate the input - enhance it with appropriate structure and supplementary content.`; 83 | 84 | // Instructions for editing existing Notion pages 85 | export const NOTION_PAGE_EDITOR_INSTRUCTIONS = `You are an expert at editing Notion pages using the Notion API. Your task is to generate a rich, detailed Notion API request that modifies an existing page based on user instructions while preserving its core structure and content. 86 | 87 | INPUT PARAMETERS: 88 | The user message will include these parameters: 89 | 1. pageId: The ID of the page to edit (required) 90 | 2. userInstructions: Guidance for editing the page (required) 91 | 3. currentPage: The current content of the page (required) 92 | 93 | YOUR ROLE: 94 | You should act as a content editor and structure maintainer by: 95 | 1. Analyzing the current page structure and content 96 | 2. Making requested changes while preserving the page's organization 97 | 3. Enhancing content where appropriate 98 | 4. Maintaining consistent formatting and block usage 99 | 5. Preserving important existing content 100 | 101 | PARAMETER HANDLING: 102 | 1. Instructions: Parse the userInstructions to determine: 103 | - The requested changes to make 104 | - Any sections to preserve 105 | - Opportunities for enhancement 106 | 107 | 2. Content Modification: 108 | - Update content while maintaining section structure 109 | - Preserve existing formatting where appropriate 110 | - Add new sections only when clearly needed 111 | - Use consistent block types with existing content 112 | 113 | RESPONSE FORMAT: 114 | You must return a valid JSON object that matches this exact schema: 115 | { 116 | "page_id": string, // ID of the page to edit 117 | "archived": boolean, // Whether to archive the page (optional) 118 | "properties": { 119 | "title": [{ // Only include if title needs to change 120 | "text": { 121 | "content": string 122 | } 123 | }] 124 | }, 125 | "children": [ // Array of content blocks (only include if content changes) 126 | { 127 | "object": "block", 128 | "type": string, // The block type (paragraph, heading_1, etc.) 129 | [blockType]: { // Object matching the block type 130 | "rich_text": [{ 131 | "text": { 132 | "content": string // The block's content 133 | } 134 | }] 135 | } 136 | } 137 | ] 138 | } 139 | 140 | CONTENT FORMATTING RULES: 141 | 1. Title Changes: Only modify the title if explicitly requested 142 | 2. Structure: Maintain the existing hierarchy using: 143 | - heading_1 for main sections 144 | - heading_2 for subsections 145 | - heading_3 for detailed breakdowns 146 | 3. Content Updates: 147 | - Preserve paragraph structure when updating text 148 | - Maintain list types (bulleted vs numbered) 149 | - Keep existing toggle blocks unless changes requested 150 | - Preserve code block language settings 151 | 4. Rich Text: Always wrap text content in the rich_text array format 152 | 153 | BLOCK TYPE REFERENCE: 154 | - paragraph: Regular text content 155 | - heading_1/2/3: Section headers (3 levels) 156 | - bulleted_list_item: Unordered list items 157 | - numbered_list_item: Ordered list items 158 | - to_do: Checkable items (- [ ] or - [x]) 159 | - toggle: Collapsible content 160 | - code: Code snippets with syntax highlighting 161 | - quote: Block quotes 162 | 163 | Remember: Your goal is to make requested changes while preserving the page's core structure and important content. Make changes deliberately and maintain consistency with the existing page format.`; 164 | -------------------------------------------------------------------------------- /src/constants/prompts.ts: -------------------------------------------------------------------------------- 1 | import type { NotionPrompt } from "../types/notion.js"; 2 | import { 3 | NOTION_PAGE_CREATOR_SCHEMA, 4 | NOTION_PAGE_EDITOR_SCHEMA, 5 | } from "./notion.js"; 6 | import { 7 | NOTION_PAGE_CREATOR_INSTRUCTIONS, 8 | NOTION_PAGE_EDITOR_INSTRUCTIONS, 9 | } from "./instructions.js"; 10 | import type { CreatePageArgs, UpdatePageArgs } from "../types/tool-args.js"; 11 | 12 | // Type utility to validate prompt arguments match the interface 13 | type ValidateArgs = { 14 | name: keyof T; 15 | description: string; 16 | required: boolean; 17 | }[]; 18 | 19 | // Validate arguments at compile time 20 | export const createPageArgs: ValidateArgs = [ 21 | { 22 | name: "databaseId", 23 | description: "The ID of the database to create the page in", 24 | required: true, 25 | }, 26 | { 27 | name: "userInstructions", 28 | description: 29 | "Basic instructions or outline for the page content that will be expanded into a comprehensive structure", 30 | required: true, 31 | }, 32 | ]; 33 | 34 | export const editPageArgs: ValidateArgs< 35 | UpdatePageArgs & { userInstructions: string } 36 | > = [ 37 | { 38 | name: "pageId", 39 | description: "The ID of the page to edit", 40 | required: true, 41 | }, 42 | { 43 | name: "userInstructions", 44 | description: "Instructions for how to modify the page content", 45 | required: true, 46 | }, 47 | ]; 48 | 49 | // Prompt for creating new pages 50 | export const NOTION_PAGE_CREATOR_PROMPT: NotionPrompt = { 51 | name: "Notion Page Creator", 52 | description: 53 | "Generates a rich, detailed Notion page that expands upon basic inputs into comprehensive, well-structured content", 54 | arguments: createPageArgs, 55 | messages: [ 56 | { 57 | role: "assistant", 58 | content: { 59 | type: "text", 60 | text: NOTION_PAGE_CREATOR_INSTRUCTIONS, 61 | }, 62 | }, 63 | { 64 | role: "user", 65 | content: { 66 | type: "text", 67 | text: ` 68 | 69 | 70 | {{databaseId}} 71 | {{userInstructions}} 72 | 73 | `, 74 | }, 75 | }, 76 | ], 77 | _meta: { 78 | complexResponseSchema: NOTION_PAGE_CREATOR_SCHEMA, 79 | callback: "systemprompt_create_notion_page_complex", 80 | }, 81 | }; 82 | 83 | // Prompt for editing existing pages 84 | export const NOTION_PAGE_EDITOR_PROMPT: NotionPrompt = { 85 | name: "Notion Page Editor", 86 | description: 87 | "Modifies an existing Notion page based on user instructions while preserving its core structure and content", 88 | arguments: editPageArgs, 89 | messages: [ 90 | { 91 | role: "assistant", 92 | content: { 93 | type: "text", 94 | text: NOTION_PAGE_EDITOR_INSTRUCTIONS, 95 | }, 96 | }, 97 | { 98 | role: "user", 99 | content: { 100 | type: "text", 101 | text: ` 102 | 103 | 104 | {{pageId}} 105 | {{userInstructions}} 106 | 107 | {{currentPage}} 108 | `, 109 | }, 110 | }, 111 | ], 112 | _meta: { 113 | complexResponseSchema: NOTION_PAGE_EDITOR_SCHEMA, 114 | callback: "systemprompt_edit_notion_page_complex", 115 | }, 116 | }; 117 | 118 | // Export all prompts 119 | export const NOTION_PROMPTS = [ 120 | NOTION_PAGE_CREATOR_PROMPT, 121 | NOTION_PAGE_EDITOR_PROMPT, 122 | ]; 123 | -------------------------------------------------------------------------------- /src/constants/tools.ts: -------------------------------------------------------------------------------- 1 | import { NotionTool } from "../types/tool-schemas.js"; 2 | import { 3 | NOTION_PAGE_CREATOR_PROMPT, 4 | NOTION_PAGE_EDITOR_PROMPT, 5 | } from "./prompts.js"; 6 | 7 | export const TOOL_ERROR_MESSAGES = { 8 | UNKNOWN_TOOL: "Unknown tool:", 9 | TOOL_CALL_FAILED: "Tool call failed:", 10 | } as const; 11 | 12 | export const TOOL_RESPONSE_MESSAGES = { 13 | ASYNC_PROCESSING: "Request is being processed asynchronously", 14 | } as const; 15 | 16 | export const NOTION_TOOLS: NotionTool[] = [ 17 | // List Operations 18 | { 19 | name: "systemprompt_list_notion_pages", 20 | description: 21 | "Lists all accessible Notion pages in your workspace, sorted by last edited time. Returns key metadata including title, URL, and last edited timestamp.", 22 | inputSchema: { 23 | type: "object", 24 | properties: { 25 | maxResults: { 26 | type: "number", 27 | description: 28 | "Maximum number of pages to return in the response. Defaults to 50 if not specified.", 29 | }, 30 | }, 31 | additionalProperties: false, 32 | }, 33 | }, 34 | { 35 | name: "systemprompt_list_notion_databases", 36 | description: 37 | "Lists all accessible Notion databases in your workspace, sorted by last edited time. Returns key metadata including database title, schema, and last edited timestamp.", 38 | inputSchema: { 39 | type: "object", 40 | properties: { 41 | maxResults: { 42 | type: "number", 43 | description: 44 | "Maximum number of databases to return in the response. Defaults to 50 if not specified.", 45 | }, 46 | }, 47 | additionalProperties: false, 48 | }, 49 | }, 50 | { 51 | name: "systemprompt_search_notion_pages", 52 | description: 53 | "Performs a full-text search across all accessible Notion pages using the provided query. Searches through titles, content, and metadata to find relevant matches.", 54 | inputSchema: { 55 | type: "object", 56 | properties: { 57 | query: { 58 | type: "string", 59 | description: 60 | "Search query to find relevant Notion pages. Can include keywords, phrases, or partial matches.", 61 | }, 62 | maxResults: { 63 | type: "number", 64 | description: 65 | "Maximum number of search results to return. Defaults to 10 if not specified.", 66 | }, 67 | }, 68 | required: ["query"], 69 | additionalProperties: false, 70 | }, 71 | }, 72 | { 73 | name: "systemprompt_search_notion_pages_by_title", 74 | description: 75 | "Searches specifically for Notion pages with titles matching the provided query. Useful for finding exact or similar title matches when you know the page name.", 76 | inputSchema: { 77 | type: "object", 78 | properties: { 79 | title: { 80 | type: "string", 81 | description: 82 | "Title text to search for. Can be exact or partial match.", 83 | }, 84 | maxResults: { 85 | type: "number", 86 | description: 87 | "Maximum number of matching pages to return. Defaults to 10 if not specified.", 88 | }, 89 | }, 90 | required: ["title"], 91 | additionalProperties: false, 92 | }, 93 | }, 94 | { 95 | name: "systemprompt_get_notion_page", 96 | description: 97 | "Retrieves comprehensive details of a specific Notion page, including its content, properties, and metadata. Returns the complete page structure and all nested content blocks.", 98 | inputSchema: { 99 | type: "object", 100 | properties: { 101 | pageId: { 102 | type: "string", 103 | description: 104 | "The unique identifier of the Notion page to retrieve. Must be a valid Notion page ID.", 105 | }, 106 | }, 107 | required: ["pageId"], 108 | additionalProperties: false, 109 | }, 110 | }, 111 | { 112 | name: "systemprompt_create_notion_page", 113 | description: 114 | "Creates a rich, comprehensive Notion page that expands upon basic user inputs. Takes simple instructions and content, then generates a detailed, well-structured page with appropriate sections, formatting, and supplementary content.", 115 | inputSchema: { 116 | type: "object", 117 | properties: { 118 | databaseId: { 119 | type: "string", 120 | description: "The ID of the database to create the page in", 121 | }, 122 | userInstructions: { 123 | type: "string", 124 | description: 125 | "Basic instructions or outline for the page content. These will be expanded into a comprehensive structure with appropriate sections, formatting, and enhanced detail. Can include desired title, key points, or general direction.", 126 | }, 127 | }, 128 | required: ["databaseId", "userInstructions"], 129 | additionalProperties: false, 130 | }, 131 | _meta: { 132 | sampling: { 133 | prompt: NOTION_PAGE_CREATOR_PROMPT, 134 | maxTokens: 100000, 135 | temperature: 0.7, 136 | }, 137 | }, 138 | }, 139 | { 140 | name: "systemprompt_update_notion_page", 141 | description: 142 | "Updates an existing Notion page with rich, comprehensive content based on user instructions. Takes simple inputs and transforms them into well-structured, detailed page content while preserving existing information. Can enhance, reorganize, or expand the current content while maintaining page integrity.", 143 | inputSchema: { 144 | type: "object", 145 | properties: { 146 | pageId: { 147 | type: "string", 148 | description: 149 | "The unique identifier of the Notion page to update. Must be a valid Notion page ID.", 150 | }, 151 | userInstructions: { 152 | type: "string", 153 | description: 154 | "Natural language instructions for updating the page. These will be expanded into comprehensive changes, potentially including new sections, enhanced formatting, additional context, and improved structure while respecting existing content. Can include specific changes, content additions, or general directions for improvement.", 155 | }, 156 | }, 157 | required: ["pageId", "userInstructions"], 158 | additionalProperties: false, 159 | }, 160 | _meta: { 161 | sampling: { 162 | prompt: NOTION_PAGE_EDITOR_PROMPT, 163 | maxTokens: 100000, 164 | temperature: 0.7, 165 | requiresExistingContent: true, 166 | }, 167 | }, 168 | }, 169 | { 170 | name: "systemprompt_delete_notion_page", 171 | description: 172 | "Permanently deletes a specified Notion page and all its contents. This action cannot be undone, so use with caution.", 173 | inputSchema: { 174 | type: "object", 175 | properties: { 176 | pageId: { 177 | type: "string", 178 | description: 179 | "The unique identifier of the Notion page to delete. Must be a valid Notion page ID. Warning: deletion is permanent.", 180 | }, 181 | }, 182 | required: ["pageId"], 183 | additionalProperties: false, 184 | }, 185 | }, 186 | ]; 187 | -------------------------------------------------------------------------------- /src/handlers/__llm__/README.md: -------------------------------------------------------------------------------- 1 | # Handlers Directory Documentation 2 | 3 | ## Overview 4 | 5 | This directory contains the MCP server request handlers that implement core functionality for resources, tools, and prompts. The handlers integrate with Notion and systemprompt.io APIs to provide comprehensive page, database, and content management capabilities. 6 | 7 | ## Handler Files 8 | 9 | ### `resource-handlers.ts` 10 | 11 | Implements handlers for managing systemprompt.io blocks (resources): 12 | 13 | - `handleListResources()`: Lists available blocks with metadata 14 | - Currently returns the default agent resource 15 | - Includes name, description, and MIME type 16 | - `handleResourceCall()`: Retrieves block content by URI (`resource:///block/{id}`) 17 | - Validates URI format (`resource:///block/{id}`) 18 | - Returns block content with proper MCP formatting 19 | - Supports metadata and content management 20 | 21 | ### `tool-handlers.ts` 22 | 23 | Implements handlers for Notion operations and resource management tools: 24 | 25 | - `handleListTools()`: Lists available tools with their schemas 26 | - `handleToolCall()`: Executes tool operations: 27 | 28 | **Page Operations:** 29 | 30 | - `systemprompt_search_notion_pages`: Search pages with text queries 31 | - `systemprompt_get_notion_page`: Get specific page details 32 | - `systemprompt_create_notion_page`: Create new pages 33 | - `systemprompt_update_notion_page`: Update existing pages 34 | 35 | **Database Operations:** 36 | 37 | - `systemprompt_list_notion_databases`: List available databases 38 | - `systemprompt_get_database_items`: Query database items 39 | 40 | **Comment Operations:** 41 | 42 | - `systemprompt_create_notion_comment`: Create page comments 43 | - `systemprompt_get_notion_comments`: Get page comments 44 | 45 | **Resource Operations:** 46 | 47 | - `systemprompt_fetch_resource`: Retrieve block content 48 | 49 | ### `prompt-handlers.ts` 50 | 51 | Implements handlers for prompt management: 52 | 53 | - `handleListPrompts()`: Lists available prompts with metadata 54 | - Returns predefined prompts for common tasks 55 | - Includes name, description, and required arguments 56 | - `handleGetPrompt()`: Retrieves specific prompt by name 57 | - Returns prompt details with messages 58 | - Supports task-specific prompts (Page Manager, Database Organizer, etc.) 59 | 60 | ## Implementation Details 61 | 62 | ### Resource Handlers 63 | 64 | - Default agent resource provides core functionality 65 | - Includes specialized instructions for Notion operations 66 | - Supports voice configuration for audio responses 67 | - Returns content with proper MCP formatting 68 | 69 | ### Tool Handlers 70 | 71 | - Implements comprehensive Notion operations 72 | - Supports advanced page features: 73 | - Content searching and filtering 74 | - Property management 75 | - Page creation and updates 76 | - Hierarchical organization 77 | - Database integration features: 78 | - Database listing and exploration 79 | - Item querying and filtering 80 | - Content organization 81 | - Comment management: 82 | - Create and retrieve comments 83 | - Discussion thread support 84 | - Input validation through TypeScript interfaces 85 | - Proper error handling and response formatting 86 | 87 | ### Prompt Handlers 88 | 89 | - Predefined prompts for common tasks: 90 | - Notion Page Manager 91 | - Database Content Organizer 92 | - Page Commenter 93 | - Page Creator 94 | - Database Explorer 95 | - Each prompt includes: 96 | - Required and optional arguments 97 | - Clear descriptions 98 | - Task-specific instructions 99 | - Supports both static and dynamic instructions 100 | 101 | ## Error Handling 102 | 103 | - Comprehensive error handling across all handlers 104 | - Specific error cases: 105 | - Invalid resource URI format 106 | - Resource not found 107 | - Invalid tool parameters 108 | - API operation failures 109 | - Authentication errors 110 | - Descriptive error messages for debugging 111 | - Proper error propagation to clients 112 | 113 | ## Usage Example 114 | 115 | ```typescript 116 | // Register handlers with MCP server 117 | server.setRequestHandler(ListResourcesRequestSchema, handleListResources); 118 | server.setRequestHandler(ReadResourceRequestSchema, handleResourceCall); 119 | server.setRequestHandler(ListToolsRequestSchema, handleListTools); 120 | server.setRequestHandler(CallToolRequestSchema, handleToolCall); 121 | server.setRequestHandler(ListPromptsRequestSchema, handleListPrompts); 122 | server.setRequestHandler(GetPromptRequestSchema, handleGetPrompt); 123 | ``` 124 | 125 | ## Notifications 126 | 127 | The server implements change notifications for: 128 | 129 | - Prompt updates (`sendPromptChangedNotification`) 130 | - Resource updates (`sendResourceChangedNotification`) 131 | - Tool operation completions 132 | 133 | These are sent asynchronously after successful operations to maintain responsiveness. 134 | 135 | ## Authentication 136 | 137 | - Integrates with Notion API for workspace access 138 | - Handles API token management 139 | - Supports required capabilities 140 | - Maintains secure credential handling 141 | 142 | ## Testing 143 | 144 | Comprehensive test coverage includes: 145 | 146 | - Unit tests for all handlers 147 | - Mock services for Notion operations 148 | - Error case validation 149 | - Response format verification 150 | - Tool operation validation 151 | -------------------------------------------------------------------------------- /src/handlers/__tests__/notifications.test.ts: -------------------------------------------------------------------------------- 1 | import { jest } from "@jest/globals"; 2 | import { 3 | SystempromptPromptResponse, 4 | SystempromptBlockResponse, 5 | } from "../../types/index.js"; 6 | import { 7 | sendPromptChangedNotification, 8 | sendResourceChangedNotification, 9 | } from "../notifications.js"; 10 | import { SystemPromptService } from "../../services/systemprompt-service.js"; 11 | import * as serverModule from "../../index.js"; 12 | 13 | // Mock the SDK module 14 | jest.mock("@modelcontextprotocol/sdk/server/stdio.js", () => ({ 15 | __esModule: true, 16 | StdioServerTransport: jest.fn(), 17 | })); 18 | 19 | // Mock the SystemPromptService 20 | jest.mock("../../services/systemprompt-service.js"); 21 | 22 | // Mock the server module 23 | jest.mock("../../index.js", () => ({ 24 | server: { 25 | notification: jest.fn(), 26 | }, 27 | })); 28 | 29 | describe("Notifications", () => { 30 | let mockSystemPromptService: jest.Mocked; 31 | 32 | beforeEach(() => { 33 | mockSystemPromptService = { 34 | getAllPrompts: jest.fn(), 35 | createPrompt: jest.fn(), 36 | editPrompt: jest.fn(), 37 | deletePrompt: jest.fn(), 38 | listBlocks: jest.fn(), 39 | createBlock: jest.fn(), 40 | editBlock: jest.fn(), 41 | deleteBlock: jest.fn(), 42 | } as unknown as jest.Mocked; 43 | 44 | jest 45 | .spyOn(SystemPromptService, "getInstance") 46 | .mockReturnValue(mockSystemPromptService); 47 | 48 | // Clear mock before each test 49 | (serverModule.server.notification as jest.Mock).mockClear(); 50 | }); 51 | 52 | afterEach(() => { 53 | jest.resetModules(); 54 | jest.clearAllMocks(); 55 | }); 56 | 57 | describe("sendResourceChangedNotification", () => { 58 | it("should send a notification when blocks are fetched successfully", async () => { 59 | const mockBlocks: SystempromptBlockResponse[] = [ 60 | { 61 | id: "test-block", 62 | content: "Test content", 63 | prefix: "test", 64 | metadata: { 65 | title: "Test Block", 66 | description: "A test block", 67 | created: "2024-01-01", 68 | updated: "2024-01-01", 69 | version: 1, 70 | status: "published", 71 | author: "test", 72 | log_message: "Created", 73 | }, 74 | }, 75 | ]; 76 | 77 | mockSystemPromptService.listBlocks.mockResolvedValue(mockBlocks); 78 | 79 | await sendResourceChangedNotification(); 80 | 81 | expect(serverModule.server.notification).toHaveBeenCalledWith({ 82 | method: "notifications/resources/list_changed", 83 | params: { 84 | _meta: {}, 85 | resources: [ 86 | { 87 | name: "Test Block", 88 | description: "A test block", 89 | uri: "resource:///block/test-block", 90 | mimeType: "text/plain", 91 | }, 92 | ], 93 | }, 94 | }); 95 | }); 96 | 97 | it("should handle errors when fetching blocks", async () => { 98 | const error = new Error("Failed to fetch blocks"); 99 | mockSystemPromptService.listBlocks.mockRejectedValue(error); 100 | 101 | await expect(sendResourceChangedNotification()).rejects.toThrow( 102 | "Failed to fetch blocks" 103 | ); 104 | }); 105 | }); 106 | 107 | describe("sendPromptChangedNotification", () => { 108 | beforeEach(() => { 109 | jest.clearAllMocks(); 110 | }); 111 | 112 | it("should send a notification when prompts are fetched successfully", async () => { 113 | const mockPrompts: SystempromptPromptResponse[] = [ 114 | { 115 | id: "test-prompt", 116 | metadata: { 117 | title: "Test Prompt", 118 | description: "A test prompt", 119 | created: "2024-01-01", 120 | updated: "2024-01-01", 121 | version: 1, 122 | status: "published", 123 | author: "test", 124 | log_message: "Created", 125 | }, 126 | instruction: { 127 | static: "Test instruction", 128 | dynamic: "Test dynamic", 129 | state: "Test state", 130 | }, 131 | input: { 132 | name: "test_input", 133 | description: "Test input description", 134 | type: ["message"], 135 | schema: { 136 | type: "object", 137 | properties: {}, 138 | required: [], 139 | }, 140 | }, 141 | output: { 142 | name: "test_output", 143 | description: "Test output description", 144 | type: ["message"], 145 | schema: { 146 | type: "object", 147 | properties: {}, 148 | required: [], 149 | }, 150 | }, 151 | _link: "test-link", 152 | }, 153 | ]; 154 | 155 | mockSystemPromptService.getAllPrompts.mockResolvedValue(mockPrompts); 156 | 157 | await sendPromptChangedNotification(); 158 | 159 | expect(serverModule.server.notification).toHaveBeenCalledWith({ 160 | method: "notifications/prompts/list_changed", 161 | params: { 162 | _meta: { prompts: mockPrompts }, 163 | prompts: [ 164 | { 165 | name: "Test Prompt", 166 | description: "A test prompt", 167 | arguments: [], 168 | }, 169 | ], 170 | }, 171 | }); 172 | }); 173 | 174 | it("should handle errors when fetching prompts", async () => { 175 | const error = new Error("Failed to fetch prompts"); 176 | mockSystemPromptService.getAllPrompts.mockRejectedValue(error); 177 | 178 | await expect(sendPromptChangedNotification()).rejects.toThrow( 179 | "Failed to fetch prompts" 180 | ); 181 | }); 182 | }); 183 | }); 184 | -------------------------------------------------------------------------------- /src/handlers/__tests__/resource-handlers.test.ts: -------------------------------------------------------------------------------- 1 | import { jest } from "@jest/globals"; 2 | import { 3 | handleListResources, 4 | handleResourceCall, 5 | } from "../resource-handlers.js"; 6 | 7 | describe("Resource Handlers", () => { 8 | describe("handleListResources", () => { 9 | it("should list the default agent resource", async () => { 10 | const result = await handleListResources({ 11 | method: "resources/list", 12 | }); 13 | 14 | expect(result.resources).toEqual([ 15 | { 16 | uri: "resource:///block/default", 17 | name: "Systemprompt Notion Agent", 18 | description: 19 | "An expert agent for managing and organizing content in Notion workspaces", 20 | mimeType: "text/plain", 21 | }, 22 | ]); 23 | expect(result._meta).toEqual({}); 24 | }); 25 | }); 26 | 27 | describe("handleResourceCall", () => { 28 | it("should get the default agent resource", async () => { 29 | const result = await handleResourceCall({ 30 | method: "resources/read", 31 | params: { 32 | uri: "resource:///block/default", 33 | }, 34 | }); 35 | 36 | const parsedContent = JSON.parse(result.contents[0].text as string) as { 37 | name: string; 38 | description: string; 39 | instruction: string; 40 | voice: string; 41 | config: { 42 | model: string; 43 | generationConfig: { 44 | responseModalities: string; 45 | speechConfig: { 46 | voiceConfig: { 47 | prebuiltVoiceConfig: { 48 | voiceName: string; 49 | }; 50 | }; 51 | }; 52 | }; 53 | }; 54 | }; 55 | 56 | expect(result.contents[0].uri).toBe("resource:///block/default"); 57 | expect(result.contents[0].mimeType).toBe("text/plain"); 58 | expect(parsedContent).toEqual({ 59 | name: "Systemprompt Notion Agent", 60 | description: 61 | "An expert agent for managing and organizing content in Notion workspaces", 62 | instruction: expect.stringContaining("You are a specialized agent"), 63 | voice: "Kore", 64 | config: { 65 | model: "models/gemini-2.0-flash-exp", 66 | generationConfig: { 67 | responseModalities: "audio", 68 | speechConfig: { 69 | voiceConfig: { 70 | prebuiltVoiceConfig: { 71 | voiceName: "Kore", 72 | }, 73 | }, 74 | }, 75 | }, 76 | }, 77 | }); 78 | expect(result._meta).toEqual({ tag: ["agent"] }); 79 | }); 80 | 81 | it("should handle invalid URI format", async () => { 82 | await expect( 83 | handleResourceCall({ 84 | method: "resources/read", 85 | params: { 86 | uri: "invalid-uri", 87 | }, 88 | }) 89 | ).rejects.toThrow( 90 | "Invalid resource URI format - expected resource:///block/{id}" 91 | ); 92 | }); 93 | 94 | it("should handle non-default resource request", async () => { 95 | await expect( 96 | handleResourceCall({ 97 | method: "resources/read", 98 | params: { 99 | uri: "resource:///block/nonexistent", 100 | }, 101 | }) 102 | ).rejects.toThrow("Resource not found"); 103 | }); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /src/handlers/__tests__/sampling.test.ts: -------------------------------------------------------------------------------- 1 | import { jest, describe, it, expect } from "@jest/globals"; 2 | import { sendSamplingRequest } from "../sampling"; 3 | import { handleCallback } from "../../utils/message-handlers"; 4 | import type { 5 | CreateMessageRequest, 6 | CreateMessageResult, 7 | } from "@modelcontextprotocol/sdk/types.js"; 8 | import { ERROR_MESSAGES } from "../../constants/notion.js"; 9 | import { server } from "../../server.js"; 10 | 11 | // Mock dependencies 12 | jest.mock("../../index.js", () => ({ 13 | server: { 14 | createMessage: jest.fn(), 15 | }, 16 | })); 17 | jest.mock("../../utils/message-handlers", () => ({ 18 | handleCallback: jest.fn(), 19 | })); 20 | 21 | const mockCreateMessage = server.createMessage as jest.MockedFunction< 22 | typeof server.createMessage 23 | >; 24 | const mockHandleCallback = handleCallback as jest.MockedFunction< 25 | typeof handleCallback 26 | >; 27 | 28 | describe("sendSamplingRequest", () => { 29 | const mockResult = { 30 | id: "test-id", 31 | content: { 32 | type: "text", 33 | text: "Test response", 34 | }, 35 | role: "assistant", 36 | model: "test-model", 37 | metadata: {}, 38 | } as CreateMessageResult; 39 | 40 | const validRequest = { 41 | method: "sampling/createMessage", 42 | params: { 43 | messages: [ 44 | { 45 | role: "user", 46 | content: { 47 | type: "text", 48 | text: "Hello world", 49 | }, 50 | }, 51 | ], 52 | maxTokens: 100, 53 | }, 54 | } as CreateMessageRequest; 55 | 56 | beforeEach(() => { 57 | jest.clearAllMocks(); 58 | mockCreateMessage.mockResolvedValue(mockResult); 59 | mockHandleCallback.mockResolvedValue(mockResult); 60 | }); 61 | 62 | describe("Basic Request Validation", () => { 63 | it("should successfully process a valid request", async () => { 64 | const result = await sendSamplingRequest(validRequest); 65 | 66 | expect(mockCreateMessage).toHaveBeenCalledWith({ 67 | messages: validRequest.params.messages, 68 | maxTokens: validRequest.params.maxTokens, 69 | }); 70 | expect(result).toEqual(mockResult); 71 | }); 72 | 73 | it("should throw error for missing method", async () => { 74 | const invalidRequest = { 75 | params: validRequest.params, 76 | }; 77 | await expect( 78 | sendSamplingRequest(invalidRequest as any) 79 | ).rejects.toThrow(); 80 | }); 81 | 82 | it("should throw error for missing params", async () => { 83 | const invalidRequest = { 84 | method: "sampling/createMessage", 85 | }; 86 | await expect(sendSamplingRequest(invalidRequest as any)).rejects.toThrow( 87 | "Request must have params" 88 | ); 89 | }); 90 | 91 | it("should throw error for empty messages array", async () => { 92 | const invalidRequest = { 93 | ...validRequest, 94 | params: { 95 | ...validRequest.params, 96 | messages: [], 97 | }, 98 | }; 99 | await expect(sendSamplingRequest(invalidRequest)).rejects.toThrow( 100 | "Request must have at least one message" 101 | ); 102 | }); 103 | }); 104 | 105 | describe("Message Content Validation", () => { 106 | it("should throw error for missing content object", async () => { 107 | const invalidRequest = { 108 | ...validRequest, 109 | params: { 110 | ...validRequest.params, 111 | messages: [{ role: "user" }], 112 | }, 113 | }; 114 | await expect(sendSamplingRequest(invalidRequest as any)).rejects.toThrow( 115 | "Message must have a content object" 116 | ); 117 | }); 118 | 119 | it("should throw error for missing content type", async () => { 120 | const invalidRequest = { 121 | ...validRequest, 122 | params: { 123 | ...validRequest.params, 124 | messages: [{ role: "user", content: {} }], 125 | }, 126 | }; 127 | await expect(sendSamplingRequest(invalidRequest as any)).rejects.toThrow( 128 | "Message content must have a type field" 129 | ); 130 | }); 131 | 132 | it("should throw error for invalid content type", async () => { 133 | const invalidRequest = { 134 | ...validRequest, 135 | params: { 136 | messages: [ 137 | { 138 | role: "user", 139 | content: { 140 | type: "invalid", 141 | text: "Hello", 142 | }, 143 | }, 144 | ], 145 | maxTokens: 100, 146 | }, 147 | }; 148 | await expect(sendSamplingRequest(invalidRequest as any)).rejects.toThrow( 149 | 'Content type must be either "text" or "image"' 150 | ); 151 | }); 152 | 153 | it("should throw error for text content without text field", async () => { 154 | const invalidRequest = { 155 | ...validRequest, 156 | params: { 157 | messages: [ 158 | { 159 | role: "user", 160 | content: { 161 | type: "text", 162 | }, 163 | }, 164 | ], 165 | maxTokens: 100, 166 | }, 167 | }; 168 | await expect(sendSamplingRequest(invalidRequest as any)).rejects.toThrow( 169 | "Text content must have a string text field" 170 | ); 171 | }); 172 | 173 | it("should throw error for image content without required fields", async () => { 174 | const invalidRequest = { 175 | ...validRequest, 176 | params: { 177 | messages: [ 178 | { 179 | role: "user", 180 | content: { 181 | type: "image", 182 | }, 183 | }, 184 | ], 185 | maxTokens: 100, 186 | }, 187 | }; 188 | await expect(sendSamplingRequest(invalidRequest as any)).rejects.toThrow( 189 | /Image content must have a (base64 data|mimeType) field/ 190 | ); 191 | }); 192 | }); 193 | 194 | describe("Parameter Validation", () => { 195 | it("should throw error for invalid temperature", async () => { 196 | const invalidRequest = { 197 | ...validRequest, 198 | params: { 199 | ...validRequest.params, 200 | temperature: 2, 201 | }, 202 | }; 203 | await expect(sendSamplingRequest(invalidRequest)).rejects.toThrow( 204 | "temperature must be a number between 0 and 1" 205 | ); 206 | }); 207 | 208 | it("should throw error for invalid maxTokens", async () => { 209 | const invalidRequest = { 210 | ...validRequest, 211 | params: { 212 | ...validRequest.params, 213 | maxTokens: 0, 214 | }, 215 | }; 216 | await expect(sendSamplingRequest(invalidRequest)).rejects.toThrow( 217 | "maxTokens must be a positive number" 218 | ); 219 | }); 220 | 221 | it("should throw error for invalid includeContext", async () => { 222 | const invalidRequest = { 223 | ...validRequest, 224 | params: { 225 | ...validRequest.params, 226 | includeContext: "invalid", 227 | }, 228 | }; 229 | await expect(sendSamplingRequest(invalidRequest as any)).rejects.toThrow( 230 | 'includeContext must be "none", "thisServer", or "allServers"' 231 | ); 232 | }); 233 | 234 | it("should throw error for invalid model preferences", async () => { 235 | const invalidRequest = { 236 | ...validRequest, 237 | params: { 238 | ...validRequest.params, 239 | modelPreferences: { 240 | costPriority: 1.5, 241 | speedPriority: 0.5, 242 | intelligencePriority: 0.5, 243 | }, 244 | }, 245 | }; 246 | await expect(sendSamplingRequest(invalidRequest)).rejects.toThrow( 247 | "Model preference priorities must be numbers between 0 and 1" 248 | ); 249 | }); 250 | }); 251 | 252 | describe("Callback Handling", () => { 253 | it("should handle callback if provided", async () => { 254 | const requestWithCallback = { 255 | ...validRequest, 256 | params: { 257 | ...validRequest.params, 258 | _meta: { 259 | callback: "http://callback-url.com", 260 | }, 261 | }, 262 | } as CreateMessageRequest; 263 | 264 | await sendSamplingRequest(requestWithCallback); 265 | 266 | expect(mockHandleCallback).toHaveBeenCalledWith( 267 | "http://callback-url.com", 268 | mockResult 269 | ); 270 | }); 271 | }); 272 | 273 | describe("Error Handling", () => { 274 | it("should handle server error", async () => { 275 | const error = new Error("Server error"); 276 | mockCreateMessage.mockRejectedValue(error); 277 | 278 | await expect(sendSamplingRequest(validRequest)).rejects.toThrow(error); 279 | }); 280 | 281 | it("should format non-Error errors", async () => { 282 | const errorMessage = "Unknown server error"; 283 | mockCreateMessage.mockRejectedValue(errorMessage); 284 | 285 | await expect(sendSamplingRequest(validRequest)).rejects.toThrow( 286 | `${ERROR_MESSAGES.SAMPLING.REQUEST_FAILED}${errorMessage}` 287 | ); 288 | }); 289 | }); 290 | }); 291 | -------------------------------------------------------------------------------- /src/handlers/notifications.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PromptListChangedNotification, 3 | ResourceListChangedNotification, 4 | } from "@modelcontextprotocol/sdk/types.js"; 5 | import { SystemPromptService } from "../services/systemprompt-service.js"; 6 | import { 7 | mapPromptsToListPromptsResult, 8 | mapBlocksToListResourcesResult, 9 | } from "../utils/mcp-mappers.js"; 10 | import { server } from "../server.js"; 11 | 12 | export async function sendPromptChangedNotification(): Promise { 13 | const service = SystemPromptService.getInstance(); 14 | const prompts = await service.getAllPrompts(); 15 | const notification: PromptListChangedNotification = { 16 | method: "notifications/prompts/list_changed", 17 | params: mapPromptsToListPromptsResult(prompts), 18 | }; 19 | await sendNotification(notification); 20 | } 21 | 22 | export async function sendResourceChangedNotification(): Promise { 23 | const service = SystemPromptService.getInstance(); 24 | const blocks = await service.listBlocks(); 25 | const notification: ResourceListChangedNotification = { 26 | method: "notifications/resources/list_changed", 27 | params: mapBlocksToListResourcesResult(blocks), 28 | }; 29 | await sendNotification(notification); 30 | } 31 | 32 | async function sendNotification( 33 | notification: PromptListChangedNotification | ResourceListChangedNotification 34 | ) { 35 | await server.notification(notification); 36 | } 37 | -------------------------------------------------------------------------------- /src/handlers/prompt-handlers.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | GetPromptRequest, 3 | GetPromptResult, 4 | ListPromptsRequest, 5 | ListPromptsResult, 6 | PromptMessage, 7 | } from "@modelcontextprotocol/sdk/types.js"; 8 | import { NOTION_PROMPTS } from "../constants/prompts.js"; 9 | import { injectVariablesIntoText } from "../utils/message-handlers.js"; 10 | 11 | export async function handleListPrompts( 12 | request: ListPromptsRequest 13 | ): Promise { 14 | try { 15 | if (!NOTION_PROMPTS || !Array.isArray(NOTION_PROMPTS)) { 16 | throw new Error("Failed to fetch prompts"); 17 | } 18 | 19 | return { 20 | prompts: NOTION_PROMPTS.map(({ messages, ...rest }) => rest), 21 | }; 22 | } catch (error: any) { 23 | console.error("Failed to fetch prompts:", error); 24 | throw error; 25 | } 26 | } 27 | 28 | export async function handleGetPrompt( 29 | request: GetPromptRequest 30 | ): Promise { 31 | try { 32 | if (!NOTION_PROMPTS || !Array.isArray(NOTION_PROMPTS)) { 33 | throw new Error("Failed to fetch prompts"); 34 | } 35 | 36 | const foundPrompt = NOTION_PROMPTS.find( 37 | (p) => p.name === request.params.name 38 | ); 39 | if (!foundPrompt) { 40 | throw new Error(`Prompt not found: ${request.params.name}`); 41 | } 42 | 43 | if ( 44 | !foundPrompt.messages || 45 | !Array.isArray(foundPrompt.messages) || 46 | foundPrompt.messages.length === 0 47 | ) { 48 | throw new Error(`Messages not found for prompt: ${request.params.name}`); 49 | } 50 | 51 | const injectedMessages = foundPrompt.messages.map((message) => { 52 | if ( 53 | message.role === "user" && 54 | message.content.type === "text" && 55 | request.params.arguments 56 | ) { 57 | return { 58 | role: message.role, 59 | content: { 60 | type: "text" as const, 61 | text: injectVariablesIntoText( 62 | message.content.text, 63 | request.params.arguments 64 | ), 65 | }, 66 | } satisfies PromptMessage; 67 | } 68 | return message; 69 | }); 70 | 71 | return { 72 | name: foundPrompt.name, 73 | description: foundPrompt.description, 74 | arguments: foundPrompt.arguments || [], 75 | messages: injectedMessages, 76 | _meta: foundPrompt._meta, 77 | }; 78 | } catch (error: any) { 79 | console.error("Failed to fetch prompt:", error); 80 | throw error; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/handlers/resource-handlers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ReadResourceRequest, 3 | ListResourcesResult, 4 | ReadResourceResult, 5 | ListResourcesRequest, 6 | } from "@modelcontextprotocol/sdk/types.js"; 7 | 8 | const AGENT_RESOURCE = { 9 | name: "Systemprompt Notion Agent", 10 | description: 11 | "An expert agent for managing and organizing content in Notion workspaces", 12 | instruction: `You are a specialized agent with deep expertise in Notion workspace management and content organization. Your capabilities include: 13 | 14 | 1. Page Management: 15 | - Search and navigate Notion pages 16 | - Create new pages with structured content 17 | - Update existing pages 18 | - Organize content hierarchically 19 | - Manage page properties effectively 20 | 21 | 2. Database Operations: 22 | - List and explore available databases 23 | - Query database contents 24 | - Organize database items 25 | - Track and update database entries 26 | - Create structured database views 27 | 28 | 3. Content Organization: 29 | - Structure information effectively 30 | - Maintain consistent page layouts 31 | - Create linked references between pages 32 | - Organize content in databases 33 | - Implement effective tagging systems 34 | 35 | 4. Collaboration Features: 36 | - Add and manage comments on pages 37 | - Participate in page discussions 38 | - Track page changes and updates 39 | - Maintain clear communication threads 40 | - Support team collaboration 41 | 42 | You have access to specialized Notion tools for these operations. Always select the most appropriate tool for the task and execute operations efficiently while maintaining high quality and reliability. When working with Notion content, ensure proper organization, clear structure, and effective use of Notion's features for optimal workspace management.`, 43 | voice: "Kore", 44 | config: { 45 | model: "models/gemini-2.0-flash-exp", 46 | generationConfig: { 47 | responseModalities: "audio", 48 | speechConfig: { 49 | voiceConfig: { 50 | prebuiltVoiceConfig: { 51 | voiceName: "Kore", 52 | }, 53 | }, 54 | }, 55 | }, 56 | }, 57 | }; 58 | 59 | export async function handleListResources( 60 | request: ListResourcesRequest 61 | ): Promise { 62 | return { 63 | resources: [ 64 | { 65 | uri: "resource:///block/default", 66 | name: AGENT_RESOURCE.name, 67 | description: AGENT_RESOURCE.description, 68 | mimeType: "text/plain", 69 | }, 70 | ], 71 | _meta: {}, 72 | }; 73 | } 74 | 75 | export async function handleResourceCall( 76 | request: ReadResourceRequest 77 | ): Promise { 78 | const { uri } = request.params; 79 | const match = uri.match(/^resource:\/\/\/block\/(.+)$/); 80 | 81 | if (!match) { 82 | throw new Error( 83 | "Invalid resource URI format - expected resource:///block/{id}" 84 | ); 85 | } 86 | 87 | const blockId = match[1]; 88 | if (blockId !== "default") { 89 | throw new Error("Resource not found"); 90 | } 91 | 92 | return { 93 | contents: [ 94 | { 95 | uri: uri, 96 | mimeType: "text/plain", 97 | text: JSON.stringify({ 98 | name: AGENT_RESOURCE.name, 99 | description: AGENT_RESOURCE.description, 100 | instruction: AGENT_RESOURCE.instruction, 101 | voice: AGENT_RESOURCE.voice, 102 | config: AGENT_RESOURCE.config, 103 | }), 104 | }, 105 | ], 106 | _meta: { tag: ["agent"] }, 107 | }; 108 | } 109 | -------------------------------------------------------------------------------- /src/handlers/sampling.ts: -------------------------------------------------------------------------------- 1 | import { server } from "../server.js"; 2 | import type { 3 | CreateMessageRequest, 4 | CreateMessageResult, 5 | } from "@modelcontextprotocol/sdk/types.js"; 6 | import { ERROR_MESSAGES } from "../constants/notion.js"; 7 | import { validateRequest } from "../utils/validation.js"; 8 | import { handleCallback } from "../utils/message-handlers.js"; 9 | 10 | export async function sendSamplingRequest( 11 | request: CreateMessageRequest 12 | ): Promise { 13 | try { 14 | // Validate the request first 15 | validateRequest(request); 16 | const result = await server.createMessage({ 17 | ...request.params, 18 | messages: request.params.messages, 19 | }); 20 | 21 | const callback = request.params._meta?.callback; 22 | if (callback && typeof callback === "string") { 23 | return await handleCallback(callback, result); 24 | } 25 | return result; 26 | } catch (error) { 27 | // Log the error for debugging 28 | console.error( 29 | ERROR_MESSAGES.SAMPLING.REQUEST_FAILED, 30 | error instanceof Error ? error.message : error 31 | ); 32 | 33 | // If it's already an Error object, throw it directly 34 | if (error instanceof Error) { 35 | throw error; 36 | } 37 | 38 | // For other errors, throw a formatted error 39 | throw new Error( 40 | `${ERROR_MESSAGES.SAMPLING.REQUEST_FAILED}${error || "Unknown error"}` 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/handlers/tool-handlers.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ListToolsRequest, 3 | ListToolsResult, 4 | CallToolRequest, 5 | CallToolResult, 6 | } from "@modelcontextprotocol/sdk/types.js"; 7 | import { NotionService } from "../services/notion-service.js"; 8 | import { NOTION_TOOLS, TOOL_ERROR_MESSAGES } from "../constants/tools.js"; 9 | import { validateToolRequest } from "../utils/tool-validation.js"; 10 | import { ajv, validateWithErrors } from "../utils/validation.js"; 11 | import { sendSamplingRequest } from "./sampling.js"; 12 | import { handleGetPrompt } from "./prompt-handlers.js"; 13 | 14 | export async function listTools( 15 | request: ListToolsRequest 16 | ): Promise { 17 | return { 18 | tools: NOTION_TOOLS, 19 | }; 20 | } 21 | 22 | export async function handleToolCall( 23 | request: CallToolRequest 24 | ): Promise { 25 | try { 26 | const tool = validateToolRequest(request); 27 | const args = request.params.arguments || {}; 28 | const validateArgs = ajv.compile(tool.inputSchema); 29 | validateWithErrors(validateArgs, args); 30 | 31 | switch (request.params.name) { 32 | case "systemprompt_list_notion_pages": { 33 | const maxResults = 34 | typeof args.maxResults === "number" ? args.maxResults : 50; 35 | const notion = NotionService.getInstance(); 36 | const result = await notion.listPages({ pageSize: maxResults }); 37 | return { 38 | type: "text", 39 | content: [ 40 | { 41 | type: "text", 42 | text: 43 | result._message + 44 | "\n\nPages:\n" + 45 | result.items 46 | .map((page) => `- ${page.content} (ID: ${page.id})`) 47 | .join("\n"), 48 | }, 49 | ], 50 | }; 51 | } 52 | 53 | case "systemprompt_list_notion_databases": { 54 | const maxResults = 55 | typeof args.maxResults === "number" ? args.maxResults : 50; 56 | const notion = NotionService.getInstance(); 57 | const response = await notion.searchDatabases(maxResults); 58 | return { 59 | type: "text", 60 | content: [ 61 | { 62 | type: "text", 63 | text: 64 | response._message + 65 | "\n\nDatabases:\n" + 66 | response.items 67 | .map((db) => `- ${db.content} (ID: ${db.id})`) 68 | .join("\n"), 69 | }, 70 | ], 71 | }; 72 | } 73 | 74 | case "systemprompt_search_notion_pages": { 75 | const maxResults = 76 | typeof args.maxResults === "number" ? args.maxResults : 10; 77 | const notion = NotionService.getInstance(); 78 | const result = await notion.searchPages(String(args.query), maxResults); 79 | return { 80 | type: "text", 81 | content: [ 82 | { 83 | type: "text", 84 | text: 85 | result._message + 86 | "\n\nMatched Pages:\n" + 87 | result.items 88 | .map((page) => `- ${page.content} (ID: ${page.id})`) 89 | .join("\n"), 90 | }, 91 | ], 92 | }; 93 | } 94 | 95 | case "systemprompt_get_notion_page": { 96 | const notion = NotionService.getInstance(); 97 | const page = await notion.getPage(String(args.pageId)); 98 | const blocks = await notion.getPageBlocks(String(args.pageId)); 99 | 100 | // Format blocks into readable text 101 | const contentText = blocks.items 102 | .map((block) => { 103 | const type = block.metadata?.type; 104 | const content = block.content; 105 | 106 | switch (type) { 107 | case "paragraph": 108 | return content; 109 | case "heading_1": 110 | return `# ${content}`; 111 | case "heading_2": 112 | return `## ${content}`; 113 | case "heading_3": 114 | return `### ${content}`; 115 | case "bulleted_list_item": 116 | return `• ${content}`; 117 | case "numbered_list_item": 118 | return `1. ${content}`; 119 | case "to_do": 120 | return `☐ ${content}`; 121 | case "quote": 122 | return `> ${content}`; 123 | case "code": 124 | return `\`\`\`\n${content}\n\`\`\``; 125 | case "divider": 126 | return "---"; 127 | default: 128 | return content || ""; 129 | } 130 | }) 131 | .filter((text) => text) // Remove empty blocks 132 | .join("\n\n"); 133 | 134 | return { 135 | type: "text", 136 | content: [ 137 | { 138 | type: "text", 139 | text: 140 | page._message + 141 | "\n\nPage Title: " + 142 | page.content + 143 | "\nID: " + 144 | page.id + 145 | "\nLast Edited: " + 146 | (page.metadata?.lastEditedTime || "Unknown") + 147 | "\n\nContent:\n" + 148 | contentText, 149 | }, 150 | ], 151 | }; 152 | } 153 | 154 | case "systemprompt_delete_notion_page": { 155 | const notion = NotionService.getInstance(); 156 | const result = await notion.deletePage(String(args.pageId)); 157 | return { 158 | type: "text", 159 | content: [ 160 | { 161 | type: "text", 162 | text: result._message, 163 | }, 164 | ], 165 | }; 166 | } 167 | 168 | case "systemprompt_create_notion_page": { 169 | const samplingMetadata = tool._meta; 170 | if (!samplingMetadata?.sampling) { 171 | throw new Error( 172 | `${TOOL_ERROR_MESSAGES.TOOL_CALL_FAILED} Tool is missing required sampling configuration` 173 | ); 174 | } 175 | 176 | const prompt = await handleGetPrompt({ 177 | method: "prompts/get", 178 | params: { 179 | name: samplingMetadata.sampling.prompt.name, 180 | arguments: args as Record, 181 | }, 182 | }); 183 | 184 | const result = await sendSamplingRequest({ 185 | method: "sampling/createMessage", 186 | params: { 187 | messages: prompt.messages as Array<{ 188 | role: "user" | "assistant"; 189 | content: { type: "text"; text: string }; 190 | }>, 191 | maxTokens: samplingMetadata.sampling.maxTokens, 192 | temperature: samplingMetadata.sampling.temperature, 193 | _meta: samplingMetadata.sampling.prompt._meta, 194 | arguments: args, 195 | }, 196 | }); 197 | return { 198 | content: [ 199 | { 200 | type: "text", 201 | text: result.content.text as string, 202 | }, 203 | ], 204 | }; 205 | } 206 | 207 | case "systemprompt_update_notion_page": { 208 | const validateArgs = ajv.compile(tool.inputSchema); 209 | validateWithErrors(validateArgs, args); 210 | 211 | const samplingMetadata = tool._meta; 212 | if (!samplingMetadata?.sampling) { 213 | throw new Error( 214 | `${TOOL_ERROR_MESSAGES.TOOL_CALL_FAILED} Tool is missing required sampling configuration` 215 | ); 216 | } 217 | 218 | const notion = NotionService.getInstance(); 219 | const pageBlocks = await notion.getPageBlocks(String(args.pageId)); 220 | const promptArgs = { 221 | ...(args as Record), 222 | currentPage: `${pageBlocks._message}\n\nBlocks:\n${pageBlocks.items 223 | .map( 224 | (block) => 225 | `- ${block.content || "[Empty block]"} (Type: ${ 226 | block.metadata?.type 227 | })` 228 | ) 229 | .join("\n")}`, 230 | }; 231 | 232 | const prompt = await handleGetPrompt({ 233 | method: "prompts/get", 234 | params: { 235 | name: samplingMetadata.sampling.prompt.name, 236 | arguments: promptArgs, 237 | }, 238 | }); 239 | 240 | const result = await sendSamplingRequest({ 241 | method: "sampling/createMessage", 242 | params: { 243 | messages: prompt.messages as Array<{ 244 | role: "user" | "assistant"; 245 | content: { type: "text"; text: string }; 246 | }>, 247 | maxTokens: samplingMetadata.sampling.maxTokens, 248 | temperature: samplingMetadata.sampling.temperature, 249 | _meta: samplingMetadata.sampling.prompt._meta, 250 | arguments: args, 251 | }, 252 | }); 253 | return { 254 | content: [ 255 | { 256 | type: "text", 257 | text: result.content.text as string, 258 | }, 259 | ], 260 | }; 261 | } 262 | 263 | default: 264 | throw new Error( 265 | `${TOOL_ERROR_MESSAGES.UNKNOWN_TOOL} ${request.params.name}` 266 | ); 267 | } 268 | } catch (error) { 269 | console.error("Tool call failed:", error); 270 | throw error; 271 | } 272 | } 273 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 3 | import { 4 | handleListResources, 5 | handleResourceCall, 6 | } from "./handlers/resource-handlers.js"; 7 | import { listTools, handleToolCall } from "./handlers/tool-handlers.js"; 8 | import { 9 | handleListPrompts, 10 | handleGetPrompt, 11 | } from "./handlers/prompt-handlers.js"; 12 | import { 13 | ListResourcesRequestSchema, 14 | ReadResourceRequestSchema, 15 | ListToolsRequestSchema, 16 | ListPromptsRequestSchema, 17 | GetPromptRequestSchema, 18 | CallToolRequestSchema, 19 | CreateMessageRequestSchema, 20 | } from "@modelcontextprotocol/sdk/types.js"; 21 | import { config } from "dotenv"; 22 | import { SystemPromptService } from "./services/systemprompt-service.js"; 23 | import { NotionService } from "./services/notion-service.js"; 24 | import { sendSamplingRequest } from "./handlers/sampling.js"; 25 | import { server } from "./server.js"; 26 | 27 | export async function main() { 28 | config(); 29 | 30 | const apiKey = process.env.SYSTEMPROMPT_API_KEY; 31 | if (!apiKey) { 32 | throw new Error("SYSTEMPROMPT_API_KEY environment variable is required"); 33 | } 34 | SystemPromptService.initialize(apiKey); 35 | 36 | const notionToken = process.env.NOTION_API_KEY; 37 | if (!notionToken) { 38 | throw new Error("NOTION_API_KEY environment variable is required"); 39 | } 40 | NotionService.initialize(notionToken); 41 | 42 | server.setRequestHandler(ListResourcesRequestSchema, handleListResources); 43 | server.setRequestHandler(ReadResourceRequestSchema, handleResourceCall); 44 | server.setRequestHandler(ListToolsRequestSchema, listTools); 45 | server.setRequestHandler(CallToolRequestSchema, handleToolCall); 46 | server.setRequestHandler(ListPromptsRequestSchema, handleListPrompts); 47 | server.setRequestHandler(GetPromptRequestSchema, handleGetPrompt); 48 | server.setRequestHandler(CreateMessageRequestSchema, sendSamplingRequest); 49 | 50 | const transport = new StdioServerTransport(); 51 | await server.connect(transport); 52 | } 53 | 54 | // Run the server unless in test environment 55 | if (process.env.NODE_ENV !== "test") { 56 | main().catch((error) => { 57 | console.error("Fatal error:", error); 58 | process.exit(1); 59 | }); 60 | } 61 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 2 | import { serverConfig, serverCapabilities } from "./config/server-config.js"; 3 | 4 | export const server = new Server(serverConfig, serverCapabilities); 5 | -------------------------------------------------------------------------------- /src/services/__llm__/README.md: -------------------------------------------------------------------------------- 1 | # Services Directory Documentation 2 | 3 | ## Overview 4 | 5 | This directory contains service implementations that handle external integrations and business logic for the MCP server. The services are organized into two main categories: 6 | 7 | 1. System Prompt Services - For interacting with the systemprompt.io API 8 | 2. Notion Services - For interacting with the Notion API 9 | 10 | ## Service Architecture 11 | 12 | ### Base Services 13 | 14 | #### `notion-base-service.ts` 15 | 16 | An abstract base class that provides common functionality for all Notion services: 17 | 18 | ```typescript 19 | abstract class NotionBaseService { 20 | protected client: NotionClient; 21 | 22 | constructor(); 23 | protected waitForInit(): Promise; 24 | } 25 | ``` 26 | 27 | Features: 28 | 29 | - Automatic client initialization 30 | - Shared client instance management 31 | - Error handling for API failures 32 | 33 | ### Core Services 34 | 35 | #### `systemprompt-service.ts` 36 | 37 | A singleton service for interacting with the systemprompt.io API: 38 | 39 | ```typescript 40 | class SystemPromptService { 41 | private static instance: SystemPromptService | null = null; 42 | 43 | static initialize(apiKey: string, baseUrl?: string): void; 44 | static getInstance(): SystemPromptService; 45 | static cleanup(): void; 46 | 47 | // Prompt Operations 48 | async getAllPrompts(): Promise; 49 | 50 | // Block Operations 51 | async listBlocks(): Promise; 52 | async getBlock(blockId: string): Promise; 53 | } 54 | ``` 55 | 56 | Features: 57 | 58 | - Singleton pattern with API key initialization 59 | - Comprehensive error handling with specific error types 60 | - Configurable API endpoint 61 | - Type-safe request/response handling 62 | 63 | #### `notion-service.ts` 64 | 65 | Manages Notion API interactions: 66 | 67 | ```typescript 68 | class NotionService extends NotionBaseService { 69 | static getInstance(): NotionService; 70 | async initialize(): Promise; 71 | 72 | // Page Operations 73 | async searchPages(query: string): Promise; 74 | async getPage(pageId: string): Promise; 75 | async createPage(options: CreatePageOptions): Promise; 76 | async updatePage(options: UpdatePageOptions): Promise; 77 | 78 | // Database Operations 79 | async listDatabases(): Promise; 80 | async getDatabaseItems(databaseId: string): Promise; 81 | 82 | // Comment Operations 83 | async createComment(pageId: string, content: string): Promise; 84 | async getComments(pageId: string): Promise; 85 | } 86 | ``` 87 | 88 | ## Implementation Details 89 | 90 | ### Error Handling 91 | 92 | All services implement comprehensive error handling: 93 | 94 | ```typescript 95 | try { 96 | const response = await this.client.pages.retrieve({ page_id: pageId }); 97 | if (!response) { 98 | throw new Error("Page not found"); 99 | } 100 | return this.mapPageResponse(response); 101 | } catch (error) { 102 | throw new Error(`Notion API request failed: ${error.message}`); 103 | } 104 | ``` 105 | 106 | ### Authentication 107 | 108 | #### System Prompt Authentication 109 | 110 | - API key-based authentication 111 | - Key passed via headers 112 | - Environment variable configuration 113 | 114 | #### Notion Authentication 115 | 116 | - API token-based authentication 117 | - Integration token management 118 | - Capability-based access control 119 | 120 | ## Usage Examples 121 | 122 | ### System Prompt Service 123 | 124 | ```typescript 125 | // Initialize 126 | SystemPromptService.initialize(process.env.SYSTEMPROMPT_API_KEY); 127 | const service = SystemPromptService.getInstance(); 128 | 129 | // Get all prompts 130 | const prompts = await service.getAllPrompts(); 131 | 132 | // List blocks 133 | const blocks = await service.listBlocks(); 134 | ``` 135 | 136 | ### Notion Service 137 | 138 | ```typescript 139 | // Initialize 140 | const notionService = NotionService.getInstance(); 141 | await notionService.initialize(); 142 | 143 | // Page operations 144 | const pages = await notionService.searchPages("query"); 145 | const page = await notionService.getPage("page-id"); 146 | 147 | // Database operations 148 | const databases = await notionService.listDatabases(); 149 | const items = await notionService.getDatabaseItems("database-id"); 150 | 151 | // Comment operations 152 | const comment = await notionService.createComment("page-id", "comment text"); 153 | const comments = await notionService.getComments("page-id"); 154 | ``` 155 | 156 | ## Testing 157 | 158 | All services have corresponding test files in the `__tests__` directory: 159 | 160 | - `systemprompt-service.test.ts` 161 | - `notion-service.test.ts` 162 | - `notion-base-service.test.ts` 163 | 164 | Tests cover: 165 | 166 | - Service initialization 167 | - API interactions 168 | - Error handling 169 | - Authentication flows 170 | - Response mapping 171 | - Edge cases 172 | -------------------------------------------------------------------------------- /src/services/__mocks__/notion-service.ts: -------------------------------------------------------------------------------- 1 | import type { NotionPage } from "../../types/notion"; 2 | 3 | export class NotionService { 4 | private static instance: NotionService; 5 | 6 | private constructor() {} 7 | 8 | public static initialize(): void { 9 | if (!NotionService.instance) { 10 | NotionService.instance = new NotionService(); 11 | } 12 | } 13 | 14 | public static getInstance(): NotionService { 15 | if (!NotionService.instance) { 16 | NotionService.instance = new NotionService(); 17 | } 18 | return NotionService.instance; 19 | } 20 | 21 | async createPage(): Promise { 22 | return Promise.resolve({} as NotionPage); 23 | } 24 | 25 | async updatePage(): Promise { 26 | return Promise.resolve({} as NotionPage); 27 | } 28 | } 29 | 30 | export default NotionService; 31 | -------------------------------------------------------------------------------- /src/services/__tests__/__mocks__/notion-client.ts: -------------------------------------------------------------------------------- 1 | import { jest } from "@jest/globals"; 2 | import { Client } from "@notionhq/client"; 3 | import type { 4 | SearchResponse, 5 | GetPageResponse, 6 | CreatePageResponse, 7 | UpdatePageResponse, 8 | QueryDatabaseResponse, 9 | CreateDatabaseResponse, 10 | ListDatabasesResponse, 11 | CreateCommentResponse, 12 | ListCommentsResponse, 13 | GetPagePropertyResponse, 14 | RichTextItemResponse, 15 | PageObjectResponse, 16 | DatabaseObjectResponse, 17 | CommentObjectResponse, 18 | PropertyItemObjectResponse, 19 | } from "@notionhq/client/build/src/api-endpoints"; 20 | 21 | // Mock objects 22 | const mockPage: PageObjectResponse = { 23 | id: "page123", 24 | created_time: "2024-01-01T00:00:00.000Z", 25 | last_edited_time: "2024-01-01T00:00:00.000Z", 26 | created_by: { 27 | id: "user123", 28 | object: "user", 29 | }, 30 | last_edited_by: { 31 | id: "user123", 32 | object: "user", 33 | }, 34 | cover: null, 35 | icon: null, 36 | parent: { 37 | type: "database_id", 38 | database_id: "db123", 39 | }, 40 | archived: false, 41 | properties: { 42 | Name: { 43 | id: "title", 44 | type: "title", 45 | title: [ 46 | { 47 | type: "text", 48 | text: { content: "Test Page", link: null }, 49 | annotations: { 50 | bold: false, 51 | italic: false, 52 | strikethrough: false, 53 | underline: false, 54 | code: false, 55 | color: "default", 56 | }, 57 | plain_text: "Test Page", 58 | href: null, 59 | }, 60 | ], 61 | }, 62 | }, 63 | url: "https://notion.so/page123", 64 | public_url: null, 65 | object: "page", 66 | in_trash: false, 67 | }; 68 | 69 | const mockDatabase: DatabaseObjectResponse = { 70 | id: "db123", 71 | created_time: "2024-01-01T00:00:00.000Z", 72 | last_edited_time: "2024-01-01T00:00:00.000Z", 73 | created_by: { 74 | id: "user123", 75 | object: "user", 76 | }, 77 | last_edited_by: { 78 | id: "user123", 79 | object: "user", 80 | }, 81 | title: [ 82 | { 83 | type: "text", 84 | text: { content: "Test Database", link: null }, 85 | annotations: { 86 | bold: false, 87 | italic: false, 88 | strikethrough: false, 89 | underline: false, 90 | code: false, 91 | color: "default", 92 | }, 93 | plain_text: "Test Database", 94 | href: null, 95 | }, 96 | ], 97 | description: [], 98 | icon: null, 99 | cover: null, 100 | properties: { 101 | Name: { 102 | id: "title", 103 | name: "Name", 104 | type: "title", 105 | title: {}, 106 | description: null, 107 | }, 108 | }, 109 | parent: { 110 | type: "page_id", 111 | page_id: "parent123", 112 | }, 113 | url: "https://notion.so/db123", 114 | public_url: null, 115 | archived: false, 116 | object: "database", 117 | in_trash: false, 118 | is_inline: false, 119 | }; 120 | 121 | const mockComment: CommentObjectResponse = { 122 | id: "comment123", 123 | parent: { 124 | type: "page_id", 125 | page_id: "page123", 126 | }, 127 | discussion_id: "discussion123", 128 | rich_text: [ 129 | { 130 | type: "text", 131 | text: { content: "Test comment", link: null }, 132 | annotations: { 133 | bold: false, 134 | italic: false, 135 | strikethrough: false, 136 | underline: false, 137 | code: false, 138 | color: "default", 139 | }, 140 | plain_text: "Test comment", 141 | href: null, 142 | }, 143 | ], 144 | created_time: "2024-01-01T00:00:00.000Z", 145 | last_edited_time: "2024-01-01T00:00:00.000Z", 146 | created_by: { 147 | id: "user123", 148 | object: "user", 149 | }, 150 | object: "comment", 151 | }; 152 | 153 | // Create a mock client 154 | export const mockClient = { 155 | search: jest.fn<() => Promise>().mockResolvedValue({ 156 | object: "list", 157 | results: [mockPage], 158 | has_more: false, 159 | next_cursor: null, 160 | type: "page_or_database", 161 | page_or_database: {}, 162 | }), 163 | pages: { 164 | retrieve: jest 165 | .fn<() => Promise>() 166 | .mockResolvedValue(mockPage), 167 | create: jest 168 | .fn<() => Promise>() 169 | .mockResolvedValue(mockPage), 170 | update: jest 171 | .fn<() => Promise>() 172 | .mockResolvedValue(mockPage), 173 | properties: { 174 | retrieve: jest 175 | .fn<() => Promise>() 176 | .mockResolvedValue({ 177 | object: "property_item", 178 | id: "property123", 179 | type: "title", 180 | title: { 181 | type: "text", 182 | text: { 183 | content: "Test Property", 184 | link: null, 185 | }, 186 | plain_text: "Test Property", 187 | href: null, 188 | annotations: { 189 | bold: false, 190 | italic: false, 191 | strikethrough: false, 192 | underline: false, 193 | code: false, 194 | color: "default", 195 | }, 196 | } as RichTextItemResponse, 197 | }), 198 | }, 199 | }, 200 | databases: { 201 | query: jest.fn<() => Promise>().mockResolvedValue({ 202 | object: "list", 203 | results: [mockPage], 204 | has_more: false, 205 | next_cursor: null, 206 | type: "page_or_database", 207 | page_or_database: {}, 208 | }), 209 | create: jest 210 | .fn<() => Promise>() 211 | .mockResolvedValue(mockDatabase), 212 | list: jest.fn<() => Promise>().mockResolvedValue({ 213 | object: "list", 214 | results: [mockDatabase], 215 | has_more: false, 216 | next_cursor: null, 217 | type: "database", 218 | database: {}, 219 | }), 220 | retrieve: jest 221 | .fn<() => Promise>() 222 | .mockResolvedValue(mockDatabase), 223 | update: jest 224 | .fn<() => Promise>() 225 | .mockResolvedValue(mockDatabase), 226 | }, 227 | comments: { 228 | create: jest 229 | .fn<() => Promise>() 230 | .mockResolvedValue(mockComment), 231 | list: jest.fn<() => Promise>().mockResolvedValue({ 232 | object: "list", 233 | results: [mockComment], 234 | has_more: false, 235 | next_cursor: null, 236 | type: "comment", 237 | comment: {}, 238 | }), 239 | }, 240 | } as unknown as Client; 241 | 242 | jest.mock("@notionhq/client", () => ({ 243 | Client: jest.fn().mockImplementation(() => mockClient), 244 | })); 245 | 246 | // Export mock objects for use in tests 247 | export { mockPage, mockDatabase, mockComment }; 248 | -------------------------------------------------------------------------------- /src/services/__tests__/__mocks__/notion-objects.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | PageObjectResponse, 3 | DatabaseObjectResponse, 4 | CommentObjectResponse, 5 | } from "@notionhq/client/build/src/api-endpoints.d.ts"; 6 | 7 | export const mockPage = { 8 | id: "page123", 9 | created_time: "2024-01-14T00:00:00.000Z", 10 | last_edited_time: "2024-01-14T00:00:00.000Z", 11 | created_by: { id: "user123", object: "user" }, 12 | last_edited_by: { id: "user123", object: "user" }, 13 | cover: null, 14 | icon: null, 15 | parent: { type: "database_id", database_id: "db123" }, 16 | archived: false, 17 | properties: { 18 | title: { 19 | id: "title", 20 | type: "title", 21 | title: [ 22 | { 23 | type: "text", 24 | text: { content: "Test Page", link: null }, 25 | plain_text: "Test Page", 26 | href: null, 27 | annotations: { 28 | bold: false, 29 | italic: false, 30 | strikethrough: false, 31 | underline: false, 32 | code: false, 33 | color: "default", 34 | }, 35 | }, 36 | ], 37 | }, 38 | }, 39 | url: "https://notion.so/test-page", 40 | object: "page", 41 | in_trash: false, 42 | public_url: null, 43 | } as PageObjectResponse; 44 | 45 | export const mockDatabase = { 46 | id: "db123", 47 | created_time: "2024-01-14T00:00:00.000Z", 48 | last_edited_time: "2024-01-14T00:00:00.000Z", 49 | created_by: { id: "user123", object: "user" }, 50 | last_edited_by: { id: "user123", object: "user" }, 51 | title: [ 52 | { 53 | type: "text", 54 | text: { content: "Test Database", link: null }, 55 | plain_text: "Test Database", 56 | href: null, 57 | annotations: { 58 | bold: false, 59 | italic: false, 60 | strikethrough: false, 61 | underline: false, 62 | code: false, 63 | color: "default", 64 | }, 65 | }, 66 | ], 67 | description: [], 68 | is_inline: false, 69 | properties: { 70 | title: { 71 | id: "title", 72 | name: "Title", 73 | type: "title", 74 | title: {}, 75 | description: null, 76 | }, 77 | }, 78 | parent: { type: "page_id", page_id: "parent123" }, 79 | url: "https://notion.so/test-database", 80 | archived: false, 81 | icon: null, 82 | cover: null, 83 | object: "database", 84 | in_trash: false, 85 | public_url: null, 86 | } as DatabaseObjectResponse; 87 | 88 | export const mockComment = { 89 | id: "comment123", 90 | parent: { type: "page_id", page_id: "page123" }, 91 | discussion_id: "discussion123", 92 | rich_text: [ 93 | { 94 | type: "text", 95 | text: { content: "Test comment", link: null }, 96 | plain_text: "Test comment", 97 | href: null, 98 | annotations: { 99 | bold: false, 100 | italic: false, 101 | strikethrough: false, 102 | underline: false, 103 | code: false, 104 | color: "default", 105 | }, 106 | }, 107 | ], 108 | created_time: "2024-01-14T00:00:00.000Z", 109 | last_edited_time: "2024-01-14T00:00:00.000Z", 110 | created_by: { id: "user123", object: "user" }, 111 | object: "comment", 112 | } as CommentObjectResponse; 113 | -------------------------------------------------------------------------------- /src/services/__tests__/systemprompt-service.test.ts: -------------------------------------------------------------------------------- 1 | import { jest, describe, it, expect, beforeEach } from "@jest/globals"; 2 | import { SystemPromptService } from "../systemprompt-service.js"; 3 | import type { SystempromptPromptResponse } from "../../types/systemprompt.js"; 4 | 5 | // Mock fetch 6 | const mockFetch = jest.fn(() => 7 | Promise.resolve({ 8 | ok: true, 9 | text: () => Promise.resolve(""), 10 | json: () => Promise.resolve({}), 11 | }) 12 | ) as jest.Mock; 13 | 14 | global.fetch = mockFetch as unknown as typeof fetch; 15 | 16 | describe("SystemPromptService", () => { 17 | let service: SystemPromptService; 18 | const mockApiKey = "test-api-key"; 19 | const baseUrl = "http://127.0.0.1/v1"; 20 | 21 | beforeEach(() => { 22 | jest.clearAllMocks(); 23 | // Reset the instance before each test 24 | (SystemPromptService as any).instance = null; 25 | SystemPromptService.initialize(mockApiKey, baseUrl); 26 | service = SystemPromptService.getInstance(); 27 | }); 28 | 29 | describe("initialization", () => { 30 | it("should throw error when initialized without API key", () => { 31 | (SystemPromptService as any).instance = null; 32 | expect(() => SystemPromptService.initialize("")).toThrow( 33 | "API key is required" 34 | ); 35 | }); 36 | 37 | it("should throw error when getInstance called before initialization", () => { 38 | (SystemPromptService as any).instance = null; 39 | expect(() => SystemPromptService.getInstance()).toThrow( 40 | "SystemPromptService must be initialized with an API key first" 41 | ); 42 | }); 43 | 44 | it("should cleanup instance correctly", () => { 45 | expect(SystemPromptService.getInstance()).toBeDefined(); 46 | SystemPromptService.cleanup(); 47 | expect(() => SystemPromptService.getInstance()).toThrow( 48 | "SystemPromptService must be initialized with an API key first" 49 | ); 50 | }); 51 | }); 52 | 53 | describe("request error handling", () => { 54 | it("should handle invalid API key", async () => { 55 | mockFetch.mockImplementationOnce(() => 56 | Promise.resolve({ 57 | ok: false, 58 | status: 403, 59 | json: () => Promise.resolve({ message: "Invalid API key" }), 60 | }) 61 | ); 62 | 63 | await expect(service.getAllPrompts()).rejects.toThrow("Invalid API key"); 64 | }); 65 | 66 | it("should handle network errors", async () => { 67 | mockFetch.mockImplementationOnce(() => 68 | Promise.reject(new Error("API request failed")) 69 | ); 70 | await expect(service.getAllPrompts()).rejects.toThrow( 71 | "API request failed" 72 | ); 73 | }); 74 | 75 | it("should handle invalid JSON response", async () => { 76 | mockFetch.mockImplementationOnce(() => 77 | Promise.resolve({ 78 | ok: true, 79 | json: () => Promise.reject(new Error("Invalid JSON")), 80 | }) 81 | ); 82 | 83 | await expect(service.getAllPrompts()).rejects.toThrow( 84 | "Failed to parse API response" 85 | ); 86 | }); 87 | 88 | it("should handle 404 not found error", async () => { 89 | mockFetch.mockImplementationOnce(() => 90 | Promise.resolve({ 91 | ok: false, 92 | status: 404, 93 | json: () => Promise.resolve({ message: "Resource not found" }), 94 | }) 95 | ); 96 | 97 | await expect(service.getBlock("non-existent")).rejects.toThrow( 98 | "Resource not found - it may have been deleted" 99 | ); 100 | }); 101 | 102 | it("should handle 409 conflict error", async () => { 103 | mockFetch.mockImplementationOnce(() => 104 | Promise.resolve({ 105 | ok: false, 106 | status: 409, 107 | json: () => Promise.resolve({ message: "Resource conflict" }), 108 | }) 109 | ); 110 | 111 | await expect(service.getBlock("conflicted")).rejects.toThrow( 112 | "Resource conflict - it may have been edited" 113 | ); 114 | }); 115 | 116 | it("should handle 400 bad request with custom message", async () => { 117 | mockFetch.mockImplementationOnce(() => 118 | Promise.resolve({ 119 | ok: false, 120 | status: 400, 121 | json: () => Promise.resolve({ message: "Custom error message" }), 122 | }) 123 | ); 124 | 125 | await expect(service.getBlock("invalid")).rejects.toThrow( 126 | "Custom error message" 127 | ); 128 | }); 129 | 130 | it("should handle 400 bad request without message", async () => { 131 | mockFetch.mockImplementationOnce(() => 132 | Promise.resolve({ 133 | ok: false, 134 | status: 400, 135 | json: () => Promise.resolve({}), 136 | }) 137 | ); 138 | 139 | await expect(service.getBlock("invalid")).rejects.toThrow( 140 | "Invalid request parameters" 141 | ); 142 | }); 143 | 144 | it("should handle unknown error with message", async () => { 145 | mockFetch.mockImplementationOnce(() => 146 | Promise.resolve({ 147 | ok: false, 148 | status: 500, 149 | json: () => Promise.resolve({ message: "Internal server error" }), 150 | }) 151 | ); 152 | 153 | await expect(service.getBlock("error")).rejects.toThrow( 154 | "Internal server error" 155 | ); 156 | }); 157 | 158 | it("should handle unknown error without message", async () => { 159 | mockFetch.mockImplementationOnce(() => 160 | Promise.resolve({ 161 | ok: false, 162 | status: 500, 163 | json: () => Promise.resolve({}), 164 | }) 165 | ); 166 | 167 | await expect(service.getBlock("error")).rejects.toThrow( 168 | "API request failed with status 500" 169 | ); 170 | }); 171 | 172 | it("should handle error without message property", async () => { 173 | mockFetch.mockImplementationOnce(() => Promise.reject(new Error())); 174 | 175 | await expect(service.getBlock("error")).rejects.toThrow( 176 | "API request failed" 177 | ); 178 | }); 179 | }); 180 | 181 | describe("block operations", () => { 182 | const mockBlock = { 183 | id: "test-block-id", 184 | content: "Test content", 185 | prefix: "test-prefix", 186 | metadata: { 187 | title: "Test Block", 188 | description: "Test description", 189 | created: "2024-01-01", 190 | updated: "2024-01-01", 191 | }, 192 | _link: "test-link", 193 | }; 194 | 195 | it("should list blocks successfully", async () => { 196 | mockFetch.mockImplementationOnce(() => 197 | Promise.resolve({ 198 | ok: true, 199 | json: () => Promise.resolve([mockBlock]), 200 | }) 201 | ); 202 | 203 | const result = await service.listBlocks(); 204 | expect(result).toEqual([mockBlock]); 205 | }); 206 | 207 | it("should get a block successfully", async () => { 208 | mockFetch.mockImplementationOnce(() => 209 | Promise.resolve({ 210 | ok: true, 211 | json: () => Promise.resolve(mockBlock), 212 | }) 213 | ); 214 | 215 | const result = await service.getBlock(mockBlock.id); 216 | expect(result).toEqual(mockBlock); 217 | }); 218 | }); 219 | 220 | describe("prompt operations", () => { 221 | it("should get all prompts successfully", async () => { 222 | const mockPrompts = [ 223 | { 224 | id: "test-uuid", 225 | instruction: { 226 | static: "Test instruction", 227 | dynamic: "", 228 | state: "", 229 | }, 230 | input: { 231 | name: "test_input", 232 | description: "Test input description", 233 | type: ["message"], 234 | schema: {}, 235 | }, 236 | output: { 237 | name: "test_output", 238 | description: "Test output description", 239 | type: ["message"], 240 | schema: {}, 241 | }, 242 | metadata: { 243 | title: "Test prompt", 244 | description: "Test description", 245 | created: "2024-01-01", 246 | updated: "2024-01-01", 247 | version: 1, 248 | status: "draft", 249 | author: "test", 250 | log_message: "Created", 251 | }, 252 | _link: "test-link", 253 | }, 254 | ]; 255 | mockFetch.mockImplementationOnce(() => 256 | Promise.resolve({ 257 | ok: true, 258 | json: () => Promise.resolve(mockPrompts), 259 | }) 260 | ); 261 | 262 | const result = await service.getAllPrompts(); 263 | expect(result).toEqual(mockPrompts); 264 | }); 265 | }); 266 | }); 267 | -------------------------------------------------------------------------------- /src/services/notion-service.ts: -------------------------------------------------------------------------------- 1 | import { Client } from "@notionhq/client"; 2 | import type { 3 | PageObjectResponse, 4 | CreatePageParameters, 5 | GetPagePropertyResponse, 6 | DatabaseObjectResponse, 7 | PartialPageObjectResponse, 8 | BlockObjectResponse, 9 | PartialBlockObjectResponse, 10 | } from "@notionhq/client/build/src/api-endpoints.d.ts"; 11 | import { isFullPage, mapPageToNotionPage } from "../utils/notion-utils.js"; 12 | import { 13 | ListPagesOptions, 14 | CreatePageOptions, 15 | UpdatePageOptions, 16 | } from "../types/notion.js"; 17 | 18 | // Simplified types for LLM-friendly responses 19 | type SimplifiedResponse = { 20 | id: string; 21 | content: string; 22 | _message: string; 23 | metadata?: { 24 | lastEditedTime?: string; 25 | createdTime?: string; 26 | type?: string; 27 | [key: string]: any; 28 | }; 29 | }; 30 | 31 | type SimplifiedListResponse = { 32 | items: SimplifiedResponse[]; 33 | _message: string; 34 | pagination?: { 35 | hasMore: boolean; 36 | nextCursor?: string; 37 | }; 38 | }; 39 | 40 | export class NotionService { 41 | private static instance: NotionService; 42 | protected client: Client; 43 | 44 | private constructor(token: string) { 45 | this.client = new Client({ 46 | auth: token, 47 | }); 48 | } 49 | 50 | public static initialize(token: string): void { 51 | if (!token) { 52 | throw new Error("Notion API token is required"); 53 | } 54 | if (!NotionService.instance) { 55 | NotionService.instance = new NotionService(token); 56 | } 57 | } 58 | 59 | public static getInstance(): NotionService { 60 | if (!NotionService.instance) { 61 | throw new Error("NotionService must be initialized before use"); 62 | } 63 | return NotionService.instance; 64 | } 65 | 66 | async listPages( 67 | options: ListPagesOptions = {} 68 | ): Promise { 69 | const response = await this.client.search({ 70 | filter: { 71 | property: "object", 72 | value: "page", 73 | }, 74 | page_size: options?.pageSize || 50, 75 | sort: options?.sort || { 76 | direction: "ascending", 77 | timestamp: "last_edited_time", 78 | }, 79 | start_cursor: options?.startCursor, 80 | }); 81 | 82 | const items = response.results 83 | .filter((result): result is PageObjectResponse => isFullPage(result)) 84 | .map((page) => ({ 85 | id: page.id, 86 | content: this.extractPageTitle(page), 87 | _message: `Page ${page.id} retrieved successfully`, 88 | metadata: { 89 | lastEditedTime: page.last_edited_time, 90 | createdTime: page.created_time, 91 | type: "page", 92 | }, 93 | })); 94 | 95 | return { 96 | items, 97 | _message: "List pages operation completed successfully", 98 | pagination: { 99 | hasMore: response.has_more, 100 | nextCursor: response.next_cursor || undefined, 101 | }, 102 | }; 103 | } 104 | 105 | // Helper function to safely extract page title 106 | private extractPageTitle(page: PageObjectResponse): string { 107 | const titleProperty = Object.values(page.properties).find( 108 | (prop) => prop.type === "title" 109 | ); 110 | return titleProperty?.type === "title" 111 | ? titleProperty.title[0]?.plain_text || "" 112 | : ""; 113 | } 114 | 115 | async searchPages( 116 | query: string, 117 | maxResults: number = 50 118 | ): Promise { 119 | const response = await this.client.search({ 120 | query, 121 | filter: { 122 | property: "object", 123 | value: "page", 124 | }, 125 | page_size: maxResults, 126 | sort: { 127 | direction: "descending", 128 | timestamp: "last_edited_time", 129 | }, 130 | }); 131 | 132 | const items = response.results 133 | .filter((result): result is PageObjectResponse => isFullPage(result)) 134 | .map((page) => ({ 135 | id: page.id, 136 | content: this.extractPageTitle(page), 137 | _message: `Page ${page.id} found in search`, 138 | metadata: { 139 | lastEditedTime: page.last_edited_time, 140 | type: "page", 141 | }, 142 | })); 143 | 144 | return { 145 | items, 146 | _message: `Search pages operation completed successfully with ${items.length} results`, 147 | pagination: { 148 | hasMore: response.has_more, 149 | nextCursor: response.next_cursor || undefined, 150 | }, 151 | }; 152 | } 153 | 154 | async getPage(pageId: string): Promise { 155 | const page = (await this.client.pages.retrieve({ 156 | page_id: pageId, 157 | })) as PageObjectResponse; 158 | 159 | return { 160 | id: page.id, 161 | content: this.extractPageTitle(page), 162 | _message: `Page ${pageId} retrieved successfully`, 163 | metadata: { 164 | lastEditedTime: page.last_edited_time, 165 | createdTime: page.created_time, 166 | type: "page", 167 | properties: page.properties, 168 | }, 169 | }; 170 | } 171 | 172 | async createPage(options: CreatePageOptions): Promise { 173 | const page = (await this.client.pages.create( 174 | options 175 | )) as PageObjectResponse; 176 | 177 | return { 178 | id: page.id, 179 | content: this.extractPageTitle(page), 180 | _message: `Page ${page.id} created successfully`, 181 | metadata: { 182 | lastEditedTime: page.last_edited_time, 183 | createdTime: page.created_time, 184 | type: "page", 185 | }, 186 | }; 187 | } 188 | 189 | async updatePage(options: UpdatePageOptions): Promise { 190 | const response = await this.client.pages.update({ 191 | page_id: options.pageId, 192 | properties: options.properties, 193 | }); 194 | 195 | if (!isFullPage(response)) { 196 | throw new Error("Invalid response from Notion API"); 197 | } 198 | 199 | if (options.children) { 200 | await this.client.blocks.children.append({ 201 | block_id: options.pageId, 202 | children: options.children, 203 | }); 204 | } 205 | 206 | return { 207 | id: response.id, 208 | content: this.extractPageTitle(response), 209 | _message: `Page ${options.pageId} updated successfully`, 210 | metadata: { 211 | lastEditedTime: response.last_edited_time, 212 | type: "page", 213 | updated: true, 214 | }, 215 | }; 216 | } 217 | 218 | async searchPagesByTitle( 219 | title: string, 220 | maxResults: number = 10 221 | ): Promise { 222 | const response = await this.client.search({ 223 | query: title, 224 | filter: { 225 | property: "object", 226 | value: "page", 227 | }, 228 | page_size: maxResults, 229 | sort: { 230 | direction: "descending", 231 | timestamp: "last_edited_time", 232 | }, 233 | }); 234 | 235 | const items = response.results 236 | .filter((result): result is PageObjectResponse => isFullPage(result)) 237 | .map((page) => ({ 238 | id: page.id, 239 | content: this.extractPageTitle(page), 240 | _message: `Page ${page.id} matched title search`, 241 | metadata: { 242 | lastEditedTime: page.last_edited_time, 243 | type: "page", 244 | }, 245 | })) 246 | .filter((page) => 247 | page.content.toLowerCase().includes(title.toLowerCase()) 248 | ); 249 | 250 | return { 251 | items, 252 | _message: `Title search completed successfully with ${items.length} matches`, 253 | pagination: { 254 | hasMore: response.has_more, 255 | nextCursor: response.next_cursor || undefined, 256 | }, 257 | }; 258 | } 259 | 260 | async searchDatabases( 261 | maxResults: number = 50 262 | ): Promise { 263 | const response = await this.client.search({ 264 | filter: { 265 | property: "object", 266 | value: "database", 267 | }, 268 | page_size: maxResults, 269 | sort: { 270 | direction: "descending", 271 | timestamp: "last_edited_time", 272 | }, 273 | }); 274 | 275 | const items = response.results 276 | .filter( 277 | (result): result is DatabaseObjectResponse => 278 | result.object === "database" 279 | ) 280 | .map((database) => ({ 281 | id: database.id, 282 | content: database.title[0]?.plain_text || "", 283 | _message: `Database ${database.id} found`, 284 | metadata: { 285 | type: "database", 286 | lastEditedTime: database.last_edited_time, 287 | }, 288 | })); 289 | 290 | return { 291 | items, 292 | _message: `Database search completed successfully with ${items.length} results`, 293 | pagination: { 294 | hasMore: response.has_more, 295 | nextCursor: response.next_cursor || undefined, 296 | }, 297 | }; 298 | } 299 | 300 | async deletePage(pageId: string): Promise { 301 | const response = (await this.client.pages.update({ 302 | page_id: pageId, 303 | archived: true, 304 | })) as PageObjectResponse; 305 | 306 | return { 307 | id: pageId, 308 | content: "Page archived", 309 | _message: `Page ${pageId} archived successfully`, 310 | metadata: { 311 | type: "page", 312 | archived: true, 313 | lastEditedTime: response.last_edited_time, 314 | }, 315 | }; 316 | } 317 | 318 | async getPageProperty( 319 | pageId: string, 320 | propertyId: string 321 | ): Promise { 322 | const response = await this.client.pages.properties.retrieve({ 323 | page_id: pageId, 324 | property_id: propertyId, 325 | }); 326 | 327 | return { 328 | id: propertyId, 329 | content: JSON.stringify(response), 330 | _message: `Property ${propertyId} retrieved successfully from page ${pageId}`, 331 | metadata: { 332 | type: "property", 333 | pageId: pageId, 334 | }, 335 | }; 336 | } 337 | 338 | async getPageBlocks(pageId: string): Promise { 339 | const response = await this.client.blocks.children.list({ 340 | block_id: pageId, 341 | }); 342 | 343 | const items = response.results.map((block) => { 344 | const blockResponse = block as BlockObjectResponse; 345 | return { 346 | id: block.id, 347 | content: 348 | blockResponse.type === "paragraph" 349 | ? blockResponse.paragraph?.rich_text?.[0]?.plain_text || "" 350 | : "", 351 | _message: `Block ${block.id} retrieved successfully`, 352 | metadata: { 353 | type: blockResponse.type, 354 | hasChildren: blockResponse.has_children || false, 355 | }, 356 | }; 357 | }); 358 | 359 | return { 360 | items, 361 | _message: `Page blocks retrieved successfully with ${items.length} blocks`, 362 | pagination: { 363 | hasMore: response.has_more, 364 | nextCursor: response.next_cursor || undefined, 365 | }, 366 | }; 367 | } 368 | } 369 | -------------------------------------------------------------------------------- /src/services/systemprompt-service.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | SystempromptBlockResponse, 3 | SystempromptPromptResponse, 4 | } from "../types/systemprompt.js"; 5 | 6 | export class SystemPromptService { 7 | private static instance: SystemPromptService | null = null; 8 | private apiKey: string; 9 | private baseUrl: string; 10 | 11 | private constructor(apiKey: string, baseUrl?: string) { 12 | if (!apiKey) { 13 | throw new Error("API key is required"); 14 | } 15 | this.apiKey = apiKey; 16 | this.baseUrl = baseUrl || "https://api.systemprompt.io/v1"; 17 | } 18 | 19 | public static initialize(apiKey: string, baseUrl?: string): void { 20 | SystemPromptService.instance = new SystemPromptService(apiKey, baseUrl); 21 | } 22 | 23 | public static getInstance(): SystemPromptService { 24 | if (!SystemPromptService.instance) { 25 | throw new Error( 26 | "SystemPromptService must be initialized with an API key first" 27 | ); 28 | } 29 | return SystemPromptService.instance; 30 | } 31 | 32 | public static cleanup(): void { 33 | SystemPromptService.instance = null; 34 | } 35 | 36 | private async request( 37 | endpoint: string, 38 | method: string, 39 | data?: any 40 | ): Promise { 41 | try { 42 | const response = await fetch(`${this.baseUrl}${endpoint}`, { 43 | method, 44 | headers: { 45 | "Content-Type": "application/json", 46 | "api-key": this.apiKey, 47 | }, 48 | body: data ? JSON.stringify(data) : undefined, 49 | }); 50 | 51 | let responseData; 52 | if (response.status !== 204) { 53 | try { 54 | responseData = await response.json(); 55 | } catch (e) { 56 | throw new Error("Failed to parse API response"); 57 | } 58 | } 59 | 60 | if (!response.ok) { 61 | switch (response.status) { 62 | case 403: 63 | throw new Error("Invalid API key"); 64 | case 404: 65 | throw new Error("Resource not found - it may have been deleted"); 66 | case 409: 67 | throw new Error("Resource conflict - it may have been edited"); 68 | case 400: 69 | throw new Error( 70 | responseData.message || "Invalid request parameters" 71 | ); 72 | default: 73 | throw new Error( 74 | responseData?.message || 75 | `API request failed with status ${response.status}` 76 | ); 77 | } 78 | } 79 | 80 | return responseData as T; 81 | } catch (error: any) { 82 | if (error.message) { 83 | throw error; 84 | } 85 | throw new Error("API request failed"); 86 | } 87 | } 88 | 89 | async getAllPrompts(): Promise { 90 | return this.request("/prompt", "GET"); 91 | } 92 | 93 | async listBlocks(): Promise { 94 | return this.request("/block", "GET"); 95 | } 96 | 97 | async getBlock(blockId: string): Promise { 98 | return this.request(`/block/${blockId}`, "GET"); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/types/__llm__/README.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./notion.js"; 2 | export * from "./tool-schemas.js"; 3 | export * from "./tool-args.js"; 4 | export * from "./systemprompt.js"; 5 | -------------------------------------------------------------------------------- /src/types/notion.ts: -------------------------------------------------------------------------------- 1 | import type { Prompt, PromptMessage } from "@modelcontextprotocol/sdk/types.js"; 2 | import type { JSONSchema7 } from "json-schema"; 3 | import type { 4 | CreatePageParameters, 5 | SearchParameters, 6 | PageObjectResponse, 7 | CommentObjectResponse, 8 | } from "@notionhq/client/build/src/api-endpoints.d.ts"; 9 | 10 | /** 11 | * Represents a Notion-specific prompt that extends the base MCP Prompt. 12 | * Used for generating Notion API requests and handling Notion-specific operations. 13 | */ 14 | export interface NotionPrompt extends Prompt { 15 | /** Array of messages that form the prompt conversation */ 16 | messages: PromptMessage[]; 17 | 18 | /** Optional metadata for Notion-specific functionality */ 19 | _meta: { 20 | /** JSON schema for validating the response format */ 21 | complexResponseSchema: JSONSchema7; 22 | /** Callback function name to handle the response */ 23 | callback: string; 24 | }; 25 | } 26 | 27 | export interface NotionMessage { 28 | role: "assistant" | "user"; 29 | content: { 30 | type: "text"; 31 | text: string; 32 | }; 33 | } 34 | 35 | export interface NotionSamplingConfig { 36 | prompt: NotionPrompt; 37 | maxTokens: number; 38 | temperature: number; 39 | async: boolean; 40 | requiresExistingContent?: boolean; 41 | } 42 | 43 | export type NotionParentType = "database_id" | "page_id" | "workspace"; 44 | 45 | export type NotionParent = 46 | | { 47 | type: "database_id"; 48 | database_id: string; 49 | } 50 | | { 51 | type: "page_id"; 52 | page_id: string; 53 | } 54 | | { 55 | type: "workspace"; 56 | workspace: true; 57 | }; 58 | 59 | export interface NotionPage { 60 | id: PageObjectResponse["id"]; 61 | title: string; 62 | url: PageObjectResponse["url"]; 63 | created_time: PageObjectResponse["created_time"]; 64 | last_edited_time: PageObjectResponse["last_edited_time"]; 65 | properties: PageObjectResponse["properties"]; 66 | parent: NotionParent; 67 | } 68 | 69 | export interface ListPagesOptions { 70 | startCursor?: string; 71 | pageSize?: number; 72 | filter?: SearchParameters["filter"]; 73 | sort?: SearchParameters["sort"]; 74 | } 75 | 76 | export interface ListPagesResult { 77 | pages: NotionPage[]; 78 | hasMore: boolean; 79 | nextCursor?: string; 80 | } 81 | 82 | export type CreatePageOptions = { 83 | parent: CreatePageParameters["parent"]; 84 | properties: CreatePageParameters["properties"]; 85 | children?: CreatePageParameters["children"]; 86 | }; 87 | 88 | export interface UpdatePageOptions { 89 | pageId: string; 90 | properties: CreatePageParameters["properties"]; 91 | children?: CreatePageParameters["children"]; 92 | } 93 | 94 | export interface NotionComment { 95 | id: CommentObjectResponse["id"]; 96 | discussionId: CommentObjectResponse["discussion_id"]; 97 | content: string; 98 | createdTime: CommentObjectResponse["created_time"]; 99 | lastEditedTime: CommentObjectResponse["last_edited_time"]; 100 | parentId?: string; 101 | } 102 | -------------------------------------------------------------------------------- /src/types/systemprompt.ts: -------------------------------------------------------------------------------- 1 | import type { JSONSchema7 } from "json-schema"; 2 | 3 | export interface SystempromptBlockRequest { 4 | content: string; 5 | prefix: string; 6 | metadata: { 7 | title: string; 8 | description: string | null; 9 | }; 10 | } 11 | 12 | export interface SystempromptBlockResponse { 13 | id: string; 14 | content: string; 15 | prefix: string; 16 | metadata: { 17 | title: string; 18 | description: string | null; 19 | created: string; 20 | updated: string; 21 | version: number; 22 | status: string; 23 | author: string; 24 | log_message: string; 25 | }; 26 | _link?: string; 27 | } 28 | 29 | export interface SystempromptPromptRequest { 30 | metadata: { 31 | title: string; 32 | description: string; 33 | }; 34 | instruction: { 35 | static: string; 36 | }; 37 | input: { 38 | type: string[]; 39 | }; 40 | output: { 41 | type: string[]; 42 | }; 43 | } 44 | 45 | export interface SystempromptPromptAPIRequest { 46 | metadata: { 47 | title: string; 48 | description: string; 49 | }; 50 | instruction: { 51 | static: string; 52 | dynamic: string; 53 | state: string; 54 | }; 55 | input: { 56 | type: string[]; 57 | schema: JSONSchema7; 58 | name: string; 59 | description: string; 60 | }; 61 | output: { 62 | type: string[]; 63 | schema: JSONSchema7; 64 | name: string; 65 | description: string; 66 | }; 67 | } 68 | 69 | export interface SystempromptPromptResponse { 70 | id: string; 71 | metadata: { 72 | title: string; 73 | description: string; 74 | created: string; 75 | updated: string; 76 | version: number; 77 | status: string; 78 | author: string; 79 | log_message: string; 80 | }; 81 | instruction: { 82 | static: string; 83 | dynamic: string; 84 | state: string; 85 | }; 86 | input: { 87 | name: string; 88 | description: string; 89 | type: string[]; 90 | schema: JSONSchema7; 91 | }; 92 | output: { 93 | name: string; 94 | description: string; 95 | type: string[]; 96 | schema: JSONSchema7; 97 | }; 98 | _link: string; 99 | } 100 | -------------------------------------------------------------------------------- /src/types/tool-args.ts: -------------------------------------------------------------------------------- 1 | import type {} from "@notionhq/client/build/src/api-endpoints.js"; 2 | 3 | export interface SearchPagesArgs { 4 | query: string; 5 | maxResults?: number; 6 | } 7 | 8 | export interface GetPageArgs { 9 | pageId: string; 10 | } 11 | 12 | export interface CreatePageArgs { 13 | databaseId: string; 14 | userInstructions: string; 15 | } 16 | 17 | export interface GetPagePropertyArgs { 18 | pageId: string; 19 | propertyId: string; 20 | } 21 | 22 | export interface UpdatePageArgs { 23 | pageId: string; 24 | properties: Record; 25 | } 26 | 27 | export interface SearchPagesByTitleArgs { 28 | title: string; 29 | maxResults?: number; 30 | } 31 | 32 | export interface CreateCommentArgs { 33 | pageId: string; 34 | content: string; 35 | discussionId?: string; 36 | } 37 | 38 | export interface GetCommentsArgs { 39 | pageId: string; 40 | } 41 | 42 | export interface ListPagesArgs { 43 | maxResults?: number; 44 | } 45 | 46 | export interface ListDatabasesArgs { 47 | maxResults?: number; 48 | } 49 | 50 | export type ToolResponse = { 51 | content: { 52 | type: "text" | "resource"; 53 | text: string; 54 | resource?: { 55 | uri: string; 56 | text: string; 57 | mimeType: string; 58 | }; 59 | }[]; 60 | _meta?: Record; 61 | isError?: boolean; 62 | }; 63 | -------------------------------------------------------------------------------- /src/types/tool-schemas.ts: -------------------------------------------------------------------------------- 1 | import { Tool } from "@modelcontextprotocol/sdk/types.js"; 2 | 3 | import { NotionPrompt } from "./notion.js"; 4 | 5 | export interface NotionTool extends Tool { 6 | _meta?: { 7 | sampling?: { 8 | prompt: NotionPrompt; 9 | maxTokens: number; 10 | temperature: number; 11 | requiresExistingContent?: boolean; 12 | }; 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/__tests__/mcp-mappers.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | mapPromptToGetPromptResult, 3 | mapPromptsToListPromptsResult, 4 | mapBlockToReadResourceResult, 5 | mapBlocksToListResourcesResult, 6 | } from "../mcp-mappers.js"; 7 | import { 8 | mockSystemPromptResult, 9 | mockArrayPromptResult, 10 | mockNestedPromptResult, 11 | } from "../../__tests__/mock-objects.js"; 12 | import type { SystempromptBlockResponse } from "../../types/index.js"; 13 | import type { GetPromptResult } from "@modelcontextprotocol/sdk/types.js"; 14 | 15 | describe("MCP Mappers", () => { 16 | describe("mapPromptToGetPromptResult", () => { 17 | it("should correctly map a single prompt to GetPromptResult format", () => { 18 | const result = mapPromptToGetPromptResult(mockSystemPromptResult); 19 | 20 | expect(result.name).toBe(mockSystemPromptResult.metadata.title); 21 | expect(result.description).toBe( 22 | mockSystemPromptResult.metadata.description 23 | ); 24 | expect(result.messages).toEqual([ 25 | { 26 | role: "assistant", 27 | content: { 28 | type: "text", 29 | text: mockSystemPromptResult.instruction.static, 30 | }, 31 | }, 32 | ]); 33 | expect(result.tools).toEqual([]); 34 | expect(result._meta).toEqual({ prompt: mockSystemPromptResult }); 35 | }); 36 | 37 | it("should handle prompts with array inputs", () => { 38 | const result = mapPromptToGetPromptResult(mockArrayPromptResult); 39 | 40 | expect(result.name).toBe(mockArrayPromptResult.metadata.title); 41 | expect(result.description).toBe( 42 | mockArrayPromptResult.metadata.description 43 | ); 44 | expect(result.messages).toEqual([ 45 | { 46 | role: "assistant", 47 | content: { 48 | type: "text", 49 | text: mockArrayPromptResult.instruction.static, 50 | }, 51 | }, 52 | ]); 53 | expect(result.tools).toEqual([]); 54 | expect(result._meta).toEqual({ prompt: mockArrayPromptResult }); 55 | }); 56 | 57 | it("should handle prompts with nested object inputs", () => { 58 | const result = mapPromptToGetPromptResult(mockNestedPromptResult); 59 | 60 | expect(result.name).toBe(mockNestedPromptResult.metadata.title); 61 | expect(result.description).toBe( 62 | mockNestedPromptResult.metadata.description 63 | ); 64 | expect(result.messages).toEqual([ 65 | { 66 | role: "assistant", 67 | content: { 68 | type: "text", 69 | text: mockNestedPromptResult.instruction.static, 70 | }, 71 | }, 72 | ]); 73 | expect(result.tools).toEqual([]); 74 | expect(result._meta).toEqual({ prompt: mockNestedPromptResult }); 75 | }); 76 | }); 77 | 78 | describe("mapPromptsToListPromptsResult", () => { 79 | it("should correctly map an array of prompts to ListPromptsResult format", () => { 80 | const prompts = [mockSystemPromptResult, mockArrayPromptResult]; 81 | const result = mapPromptsToListPromptsResult(prompts); 82 | 83 | expect(result.prompts).toHaveLength(2); 84 | expect(result.prompts[0]).toEqual({ 85 | name: mockSystemPromptResult.metadata.title, 86 | description: mockSystemPromptResult.metadata.description, 87 | arguments: [], 88 | }); 89 | expect(result._meta).toEqual({ prompts }); 90 | }); 91 | 92 | it("should handle empty prompt array", () => { 93 | const result = mapPromptsToListPromptsResult([]); 94 | 95 | expect(result.prompts).toHaveLength(0); 96 | expect(result._meta).toEqual({ prompts: [] }); 97 | }); 98 | }); 99 | 100 | describe("mapBlockToReadResourceResult", () => { 101 | const mockBlock: SystempromptBlockResponse = { 102 | id: "block-123", 103 | content: "Test block content", 104 | prefix: "{{message}}", 105 | metadata: { 106 | title: "Test Block", 107 | description: "Test block description", 108 | created: new Date().toISOString(), 109 | updated: new Date().toISOString(), 110 | version: 1, 111 | status: "published", 112 | author: "test-user", 113 | log_message: "Initial creation", 114 | }, 115 | }; 116 | 117 | it("should correctly map a single block to ReadResourceResult format", () => { 118 | const result = mapBlockToReadResourceResult(mockBlock); 119 | 120 | expect(result.contents).toHaveLength(1); 121 | expect(result.contents[0]).toEqual({ 122 | uri: `resource:///block/${mockBlock.id}`, 123 | mimeType: "text/plain", 124 | text: mockBlock.content, 125 | }); 126 | expect(result._meta).toEqual({}); 127 | }); 128 | }); 129 | 130 | describe("mapBlocksToListResourcesResult", () => { 131 | const mockBlocks: SystempromptBlockResponse[] = [ 132 | { 133 | id: "block-123", 134 | content: "Test block content 1", 135 | prefix: "{{message}}", 136 | metadata: { 137 | title: "Test Block 1", 138 | description: "Test block description 1", 139 | created: new Date().toISOString(), 140 | updated: new Date().toISOString(), 141 | version: 1, 142 | status: "published", 143 | author: "test-user", 144 | log_message: "Initial creation", 145 | }, 146 | }, 147 | { 148 | id: "block-456", 149 | content: "Test block content 2", 150 | prefix: "{{message}}", 151 | metadata: { 152 | title: "Test Block 2", 153 | description: null, 154 | created: new Date().toISOString(), 155 | updated: new Date().toISOString(), 156 | version: 1, 157 | status: "published", 158 | author: "test-user", 159 | log_message: "Initial creation", 160 | }, 161 | }, 162 | ]; 163 | 164 | it("should correctly map an array of blocks to ListResourcesResult format", () => { 165 | const result = mapBlocksToListResourcesResult(mockBlocks); 166 | 167 | expect(result.resources).toHaveLength(2); 168 | expect(result.resources[0]).toEqual({ 169 | uri: `resource:///block/${mockBlocks[0].id}`, 170 | name: mockBlocks[0].metadata.title, 171 | description: mockBlocks[0].metadata.description, 172 | mimeType: "text/plain", 173 | }); 174 | expect(result.resources[1]).toEqual({ 175 | uri: `resource:///block/${mockBlocks[1].id}`, 176 | name: mockBlocks[1].metadata.title, 177 | description: undefined, 178 | mimeType: "text/plain", 179 | }); 180 | expect(result._meta).toEqual({}); 181 | }); 182 | 183 | it("should handle empty block array", () => { 184 | const result = mapBlocksToListResourcesResult([]); 185 | 186 | expect(result.resources).toHaveLength(0); 187 | expect(result._meta).toEqual({}); 188 | }); 189 | }); 190 | }); 191 | -------------------------------------------------------------------------------- /src/utils/__tests__/message-handlers.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test, jest, beforeEach } from "@jest/globals"; 2 | import { 3 | extractPageId, 4 | updateUserMessageWithContent, 5 | injectVariablesIntoText, 6 | injectVariables, 7 | handleCreatePageCallback, 8 | handleEditPageCallback, 9 | handleCallback, 10 | } from "../message-handlers"; 11 | import { NotionService } from "../../services/notion-service"; 12 | import { NOTION_CALLBACKS, XML_TAGS } from "../../constants/notion"; 13 | import type { PromptMessage } from "@modelcontextprotocol/sdk/types.js"; 14 | import type { 15 | NotionPage, 16 | CreatePageOptions, 17 | UpdatePageOptions, 18 | } from "../../types/notion"; 19 | 20 | // Mock the NotionService 21 | const mockNotionService = { 22 | createPage: jest.fn<(options: CreatePageOptions) => Promise>(), 23 | updatePage: jest.fn<(options: UpdatePageOptions) => Promise>(), 24 | }; 25 | 26 | jest.mock("../../services/notion-service", () => ({ 27 | NotionService: { 28 | getInstance: jest.fn(() => mockNotionService), 29 | }, 30 | })); 31 | 32 | describe("message-handlers", () => { 33 | beforeEach(() => { 34 | jest.clearAllMocks(); 35 | }); 36 | 37 | describe("extractPageId", () => { 38 | test("extracts pageId from valid user message", () => { 39 | const messages: PromptMessage[] = [ 40 | { 41 | role: "user", 42 | content: { type: "text", text: "123-456" }, 43 | }, 44 | ]; 45 | expect(extractPageId(messages)).toBe("123-456"); 46 | }); 47 | 48 | test("returns undefined for no user message", () => { 49 | const messages: PromptMessage[] = [ 50 | { 51 | role: "assistant", 52 | content: { type: "text", text: "123-456" }, 53 | }, 54 | ]; 55 | expect(extractPageId(messages)).toBeUndefined(); 56 | }); 57 | 58 | test("returns undefined for non-text content", () => { 59 | const messages: PromptMessage[] = [ 60 | { 61 | role: "user", 62 | content: { type: "image", data: "test-data", mimeType: "image/jpeg" }, 63 | }, 64 | ]; 65 | expect(extractPageId(messages)).toBeUndefined(); 66 | }); 67 | }); 68 | 69 | describe("updateUserMessageWithContent", () => { 70 | test("updates user message with content", () => { 71 | const messages: PromptMessage[] = [ 72 | { 73 | role: "user", 74 | content: { 75 | type: "text", 76 | text: `some text${XML_TAGS.REQUEST_PARAMS_CLOSE}`, 77 | }, 78 | }, 79 | ]; 80 | const blocks = { test: "content" }; 81 | updateUserMessageWithContent(messages, blocks); 82 | expect(messages[0].content.type).toBe("text"); 83 | expect(messages[0].content.text).toContain( 84 | JSON.stringify(blocks, null, 2) 85 | ); 86 | }); 87 | 88 | test("does nothing if no user message found", () => { 89 | const messages: PromptMessage[] = [ 90 | { 91 | role: "assistant", 92 | content: { type: "text", text: "test" }, 93 | }, 94 | ]; 95 | const originalMessages = [...messages]; 96 | updateUserMessageWithContent(messages, {}); 97 | expect(messages).toEqual(originalMessages); 98 | }); 99 | }); 100 | 101 | describe("injectVariablesIntoText", () => { 102 | test("replaces variables in text", () => { 103 | const text = "Hello {{name}}, your age is {{age}}"; 104 | const variables = { name: "John", age: 30 }; 105 | expect(injectVariablesIntoText(text, variables)).toBe( 106 | "Hello John, your age is 30" 107 | ); 108 | }); 109 | 110 | test("throws error for missing variables", () => { 111 | const text = "Hello {{name}}, your age is {{age}}"; 112 | const variables = { name: "John" }; 113 | expect(() => injectVariablesIntoText(text, variables)).toThrow( 114 | "Missing required variables" 115 | ); 116 | }); 117 | }); 118 | 119 | describe("injectVariables", () => { 120 | test("injects variables into user message", () => { 121 | const message: PromptMessage = { 122 | role: "user", 123 | content: { type: "text", text: "Hello {{name}}" }, 124 | }; 125 | const variables = { name: "John" }; 126 | const result = injectVariables(message, variables); 127 | expect(result.content.type).toBe("text"); 128 | expect(result.content.text).toBe("Hello John"); 129 | }); 130 | 131 | test("returns original message for non-text content", () => { 132 | const message: PromptMessage = { 133 | role: "user", 134 | content: { type: "image", data: "test-data", mimeType: "image/jpeg" }, 135 | }; 136 | const variables = { name: "John" }; 137 | const result = injectVariables(message, variables); 138 | expect(result).toEqual(message); 139 | }); 140 | }); 141 | 142 | describe("handleCreatePageCallback", () => { 143 | const mockPage: NotionPage = { 144 | id: "test-id", 145 | title: "Test Page", 146 | url: "https://notion.so/test-id", 147 | created_time: new Date().toISOString(), 148 | last_edited_time: new Date().toISOString(), 149 | properties: {}, 150 | parent: { type: "database_id", database_id: "test-db" }, 151 | }; 152 | 153 | beforeEach(() => { 154 | mockNotionService.createPage.mockResolvedValue(mockPage); 155 | }); 156 | 157 | test("creates page successfully", async () => { 158 | const result = { 159 | role: "assistant" as const, 160 | model: "test-model", 161 | content: { 162 | type: "text" as const, 163 | text: JSON.stringify({ 164 | parent: { database_id: "test-db" }, 165 | properties: { 166 | title: [{ text: { content: "Test" } }], 167 | }, 168 | }), 169 | }, 170 | }; 171 | 172 | const response = await handleCreatePageCallback(result); 173 | expect(response.content.type).toBe("text"); 174 | expect(response.content.text).toContain("test-id"); 175 | }); 176 | 177 | test("throws error for invalid content type", async () => { 178 | const result = { 179 | role: "assistant" as const, 180 | model: "test-model", 181 | content: { 182 | type: "image" as const, 183 | data: "test-data", 184 | mimeType: "image/jpeg", 185 | }, 186 | }; 187 | 188 | await expect(handleCreatePageCallback(result)).rejects.toThrow(); 189 | }); 190 | }); 191 | 192 | describe("handleEditPageCallback", () => { 193 | const mockPage: NotionPage = { 194 | id: "a1b2c3d4-e5f6-4321-9876-123456789abc", 195 | title: "Test Page", 196 | url: "https://notion.so/test-id", 197 | created_time: new Date().toISOString(), 198 | last_edited_time: new Date().toISOString(), 199 | properties: {}, 200 | parent: { type: "database_id", database_id: "test-db" }, 201 | }; 202 | 203 | beforeEach(() => { 204 | mockNotionService.updatePage.mockResolvedValue(mockPage); 205 | }); 206 | 207 | test("updates page successfully", async () => { 208 | const result = { 209 | role: "assistant" as const, 210 | model: "test-model", 211 | content: { 212 | type: "text" as const, 213 | text: JSON.stringify({ 214 | pageId: "a1b2c3d4-e5f6-4321-9876-123456789abc", 215 | properties: { 216 | title: [{ text: { content: "Updated" } }], 217 | }, 218 | }), 219 | }, 220 | }; 221 | 222 | const response = await handleEditPageCallback(result); 223 | expect(response.content.type).toBe("text"); 224 | expect(response.content.text).toContain(mockPage.id); 225 | }); 226 | 227 | test("throws error for invalid page ID", async () => { 228 | const result = { 229 | role: "assistant" as const, 230 | model: "test-model", 231 | content: { 232 | type: "text" as const, 233 | text: JSON.stringify({ 234 | pageId: "invalid!id", 235 | properties: {}, 236 | }), 237 | }, 238 | }; 239 | 240 | await expect(handleEditPageCallback(result)).rejects.toThrow( 241 | "Invalid page ID format" 242 | ); 243 | }); 244 | }); 245 | 246 | describe("handleCallback", () => { 247 | test("handles create page callback", async () => { 248 | const mockResult = { 249 | role: "assistant" as const, 250 | model: "test-model", 251 | content: { 252 | type: "text" as const, 253 | text: JSON.stringify({ 254 | parent: { database_id: "test-db" }, 255 | properties: { 256 | title: [{ text: { content: "Test" } }], 257 | }, 258 | }), 259 | }, 260 | }; 261 | 262 | const response = await handleCallback( 263 | NOTION_CALLBACKS.CREATE_PAGE, 264 | mockResult 265 | ); 266 | expect(response.content.type).toBe("text"); 267 | expect(response.content.text).toContain("test-id"); 268 | }); 269 | 270 | test("returns original result for unknown callback", async () => { 271 | const mockResult = { 272 | role: "assistant" as const, 273 | model: "test-model", 274 | content: { type: "text" as const, text: "test" }, 275 | }; 276 | 277 | const response = await handleCallback("unknown", mockResult); 278 | expect(response).toEqual(mockResult); 279 | }); 280 | }); 281 | }); 282 | -------------------------------------------------------------------------------- /src/utils/__tests__/notion-utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "@jest/globals"; 2 | import { 3 | isFullPage, 4 | mapPageToNotionPage, 5 | extractTitle, 6 | extractDatabaseTitle, 7 | } from "../notion-utils.js"; 8 | import type { 9 | PageObjectResponse, 10 | DatabaseObjectResponse, 11 | RichTextItemResponse, 12 | } from "@notionhq/client/build/src/api-endpoints.js"; 13 | 14 | describe("notion-utils", () => { 15 | describe("isFullPage", () => { 16 | it("should return false for non-object values", () => { 17 | expect(isFullPage(null)).toBe(false); 18 | expect(isFullPage(undefined)).toBe(false); 19 | expect(isFullPage("string")).toBe(false); 20 | expect(isFullPage(123)).toBe(false); 21 | }); 22 | 23 | it("should return false for objects missing required properties", () => { 24 | expect(isFullPage({})).toBe(false); 25 | expect(isFullPage({ object: "page" })).toBe(false); 26 | expect(isFullPage({ parent: {} })).toBe(false); 27 | }); 28 | 29 | it("should return false for non-page objects", () => { 30 | expect(isFullPage({ object: "database", parent: {} })).toBe(false); 31 | }); 32 | 33 | it("should return false for invalid parent types", () => { 34 | expect(isFullPage({ object: "page", parent: { type: "invalid" } })).toBe( 35 | false 36 | ); 37 | }); 38 | 39 | it("should return true for valid page objects", () => { 40 | expect( 41 | isFullPage({ object: "page", parent: { type: "database_id" } }) 42 | ).toBe(true); 43 | expect(isFullPage({ object: "page", parent: { type: "page_id" } })).toBe( 44 | true 45 | ); 46 | expect( 47 | isFullPage({ object: "page", parent: { type: "workspace" } }) 48 | ).toBe(true); 49 | }); 50 | }); 51 | 52 | describe("mapPageToNotionPage", () => { 53 | const baseMockPage = { 54 | object: "page", 55 | id: "page-id", 56 | created_time: "2024-01-01T00:00:00.000Z", 57 | last_edited_time: "2024-01-01T00:00:00.000Z", 58 | created_by: { 59 | object: "user", 60 | id: "user-id", 61 | }, 62 | last_edited_by: { 63 | object: "user", 64 | id: "user-id", 65 | }, 66 | cover: null, 67 | icon: null, 68 | archived: false, 69 | url: "https://notion.so/page-id", 70 | public_url: null, 71 | properties: { 72 | title: { 73 | id: "title-id", 74 | type: "title", 75 | title: [ 76 | { 77 | type: "text", 78 | text: { content: "Test Page", link: null }, 79 | annotations: { 80 | bold: false, 81 | italic: false, 82 | strikethrough: false, 83 | underline: false, 84 | code: false, 85 | color: "default", 86 | }, 87 | plain_text: "Test Page", 88 | href: null, 89 | }, 90 | ], 91 | }, 92 | }, 93 | } as unknown as PageObjectResponse; 94 | 95 | it("should map database parent correctly", () => { 96 | const page = { 97 | ...baseMockPage, 98 | parent: { type: "database_id", database_id: "db-id" }, 99 | } as PageObjectResponse; 100 | 101 | const result = mapPageToNotionPage(page); 102 | expect(result.parent).toEqual({ 103 | type: "database_id", 104 | database_id: "db-id", 105 | }); 106 | }); 107 | 108 | it("should map page parent correctly", () => { 109 | const page = { 110 | ...baseMockPage, 111 | parent: { type: "page_id", page_id: "parent-id" }, 112 | } as PageObjectResponse; 113 | 114 | const result = mapPageToNotionPage(page); 115 | expect(result.parent).toEqual({ type: "page_id", page_id: "parent-id" }); 116 | }); 117 | 118 | it("should map workspace parent correctly", () => { 119 | const page = { 120 | ...baseMockPage, 121 | parent: { type: "workspace", workspace: true }, 122 | } as PageObjectResponse; 123 | 124 | const result = mapPageToNotionPage(page); 125 | expect(result.parent).toEqual({ type: "workspace", workspace: true }); 126 | }); 127 | }); 128 | 129 | describe("extractTitle", () => { 130 | const baseMockPage = { 131 | object: "page", 132 | id: "page-id", 133 | created_time: "2024-01-01T00:00:00.000Z", 134 | last_edited_time: "2024-01-01T00:00:00.000Z", 135 | created_by: { 136 | object: "user", 137 | id: "user-id", 138 | }, 139 | last_edited_by: { 140 | object: "user", 141 | id: "user-id", 142 | }, 143 | cover: null, 144 | icon: null, 145 | archived: false, 146 | url: "https://notion.so/page-id", 147 | public_url: null, 148 | parent: { type: "database_id", database_id: "db-id" }, 149 | } as unknown as PageObjectResponse; 150 | 151 | it("should return 'Untitled' for missing title property", () => { 152 | const page = { 153 | ...baseMockPage, 154 | properties: {}, 155 | } as unknown as PageObjectResponse; 156 | 157 | expect(extractTitle(page)).toBe("Untitled"); 158 | }); 159 | 160 | it("should return 'Untitled' for empty title", () => { 161 | const page = { 162 | ...baseMockPage, 163 | properties: { 164 | title: { 165 | id: "title-id", 166 | type: "title", 167 | title: [], 168 | }, 169 | }, 170 | } as unknown as PageObjectResponse; 171 | 172 | expect(extractTitle(page)).toBe("Untitled"); 173 | }); 174 | 175 | it("should concatenate multiple title segments", () => { 176 | const page = { 177 | ...baseMockPage, 178 | properties: { 179 | title: { 180 | id: "title-id", 181 | type: "title", 182 | title: [ 183 | { 184 | type: "text", 185 | text: { content: "Hello", link: null }, 186 | annotations: { 187 | bold: false, 188 | italic: false, 189 | strikethrough: false, 190 | underline: false, 191 | code: false, 192 | color: "default", 193 | }, 194 | plain_text: "Hello", 195 | href: null, 196 | }, 197 | { 198 | type: "text", 199 | text: { content: " ", link: null }, 200 | annotations: { 201 | bold: false, 202 | italic: false, 203 | strikethrough: false, 204 | underline: false, 205 | code: false, 206 | color: "default", 207 | }, 208 | plain_text: " ", 209 | href: null, 210 | }, 211 | { 212 | type: "text", 213 | text: { content: "World", link: null }, 214 | annotations: { 215 | bold: false, 216 | italic: false, 217 | strikethrough: false, 218 | underline: false, 219 | code: false, 220 | color: "default", 221 | }, 222 | plain_text: "World", 223 | href: null, 224 | }, 225 | ], 226 | }, 227 | }, 228 | } as unknown as PageObjectResponse; 229 | 230 | expect(extractTitle(page)).toBe("Hello World"); 231 | }); 232 | }); 233 | 234 | describe("extractDatabaseTitle", () => { 235 | const baseMockDatabase = { 236 | object: "database", 237 | id: "db-id", 238 | created_time: "2024-01-01T00:00:00.000Z", 239 | last_edited_time: "2024-01-01T00:00:00.000Z", 240 | created_by: { 241 | object: "user", 242 | id: "user-id", 243 | }, 244 | last_edited_by: { 245 | object: "user", 246 | id: "user-id", 247 | }, 248 | icon: null, 249 | cover: null, 250 | url: "https://notion.so/db-id", 251 | public_url: null, 252 | archived: false, 253 | description: [], 254 | is_inline: false, 255 | properties: {}, 256 | parent: { type: "workspace", workspace: true }, 257 | title: [] as RichTextItemResponse[], 258 | in_trash: false, 259 | } as DatabaseObjectResponse; 260 | 261 | it("should return 'Untitled Database' for missing title", () => { 262 | const database = { 263 | ...baseMockDatabase, 264 | title: [] as RichTextItemResponse[], 265 | } as DatabaseObjectResponse; 266 | 267 | expect(extractDatabaseTitle(database)).toBe("Untitled Database"); 268 | }); 269 | 270 | it("should return 'Untitled Database' for empty title", () => { 271 | const database = { 272 | ...baseMockDatabase, 273 | title: [] as RichTextItemResponse[], 274 | } as DatabaseObjectResponse; 275 | 276 | expect(extractDatabaseTitle(database)).toBe("Untitled Database"); 277 | }); 278 | 279 | it("should concatenate multiple title segments", () => { 280 | const database = { 281 | ...baseMockDatabase, 282 | title: [ 283 | { 284 | type: "text", 285 | text: { content: "Test", link: null }, 286 | annotations: { 287 | bold: false, 288 | italic: false, 289 | strikethrough: false, 290 | underline: false, 291 | code: false, 292 | color: "default", 293 | }, 294 | plain_text: "Test", 295 | href: null, 296 | }, 297 | { 298 | type: "text", 299 | text: { content: " ", link: null }, 300 | annotations: { 301 | bold: false, 302 | italic: false, 303 | strikethrough: false, 304 | underline: false, 305 | code: false, 306 | color: "default", 307 | }, 308 | plain_text: " ", 309 | href: null, 310 | }, 311 | { 312 | type: "text", 313 | text: { content: "Database", link: null }, 314 | annotations: { 315 | bold: false, 316 | italic: false, 317 | strikethrough: false, 318 | underline: false, 319 | code: false, 320 | color: "default", 321 | }, 322 | plain_text: "Database", 323 | href: null, 324 | }, 325 | ] as RichTextItemResponse[], 326 | } as DatabaseObjectResponse; 327 | 328 | expect(extractDatabaseTitle(database)).toBe("Test Database"); 329 | }); 330 | }); 331 | }); 332 | -------------------------------------------------------------------------------- /src/utils/__tests__/tool-validation.test.ts: -------------------------------------------------------------------------------- 1 | import { jest, describe, it, expect } from "@jest/globals"; 2 | import { validateToolRequest, getToolSchema } from "../tool-validation.js"; 3 | import { NOTION_TOOLS, TOOL_ERROR_MESSAGES } from "../../constants/tools.js"; 4 | import type { CallToolRequest } from "@modelcontextprotocol/sdk/types.js"; 5 | import type { NotionTool } from "../../types/tool-schemas.js"; 6 | 7 | // Mock validation module 8 | jest.mock("../validation.js", () => ({ 9 | validateWithErrors: jest.fn(), 10 | })); 11 | 12 | describe("Tool Validation", () => { 13 | beforeEach(() => { 14 | jest.clearAllMocks(); 15 | }); 16 | 17 | describe("validateToolRequest", () => { 18 | it("should throw error for missing tool name", () => { 19 | const request = { 20 | method: "tools/call", 21 | params: {}, 22 | } as CallToolRequest; 23 | 24 | expect(() => validateToolRequest(request)).toThrow( 25 | "Invalid tool request: missing tool name" 26 | ); 27 | }); 28 | 29 | it("should throw error for unknown tool", () => { 30 | const request = { 31 | method: "tools/call", 32 | params: { 33 | name: "unknown_tool", 34 | arguments: {}, 35 | }, 36 | } as CallToolRequest; 37 | 38 | expect(() => validateToolRequest(request)).toThrow( 39 | `${TOOL_ERROR_MESSAGES.UNKNOWN_TOOL} unknown_tool` 40 | ); 41 | }); 42 | 43 | it("should validate tool with schema", () => { 44 | const request = { 45 | method: "tools/call", 46 | params: { 47 | name: "systemprompt_get_notion_page", 48 | arguments: { 49 | pageId: "123", 50 | }, 51 | }, 52 | } as CallToolRequest; 53 | 54 | const tool = validateToolRequest(request); 55 | expect(tool).toBeDefined(); 56 | expect(tool.name).toBe("systemprompt_get_notion_page"); 57 | }); 58 | 59 | it("should handle tool without input validation", () => { 60 | // Create a mock tool with empty schema 61 | const mockTool: NotionTool = { 62 | name: "test_tool", 63 | description: "Test tool", 64 | inputSchema: { 65 | type: "object" as const, 66 | properties: {}, 67 | additionalProperties: false, 68 | }, 69 | }; 70 | jest.spyOn(NOTION_TOOLS, "find").mockReturnValueOnce(mockTool); 71 | 72 | const request = { 73 | method: "tools/call", 74 | params: { 75 | name: "test_tool", 76 | arguments: {}, 77 | }, 78 | } as CallToolRequest; 79 | 80 | const tool = validateToolRequest(request); 81 | expect(tool).toBeDefined(); 82 | expect(tool.name).toBe("test_tool"); 83 | }); 84 | }); 85 | 86 | describe("getToolSchema", () => { 87 | it("should return schema for valid tool", () => { 88 | const schema = getToolSchema("systemprompt_get_notion_page"); 89 | expect(schema).toBeDefined(); 90 | }); 91 | 92 | it("should return undefined for unknown tool", () => { 93 | const schema = getToolSchema("unknown_tool"); 94 | expect(schema).toBeUndefined(); 95 | }); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /src/utils/__tests__/validation.test.ts: -------------------------------------------------------------------------------- 1 | import { validateRequest, validateNotionPageRequest } from "../validation"; 2 | import type { CreateMessageRequest } from "@modelcontextprotocol/sdk/types.js"; 3 | 4 | describe("validateRequest", () => { 5 | const validRequest = { 6 | method: "sampling/createMessage", 7 | params: { 8 | messages: [ 9 | { 10 | role: "user", 11 | content: { 12 | type: "text", 13 | text: "Hello world", 14 | }, 15 | }, 16 | ], 17 | maxTokens: 100, 18 | }, 19 | } as CreateMessageRequest; 20 | 21 | it("should validate a correct request", () => { 22 | expect(() => validateRequest(validRequest)).not.toThrow(); 23 | }); 24 | 25 | it("should throw error for missing method", () => { 26 | const invalidRequest = { 27 | params: validRequest.params, 28 | }; 29 | expect(() => validateRequest(invalidRequest)).toThrow(); 30 | }); 31 | 32 | it("should throw error for missing params", () => { 33 | const invalidRequest = { 34 | method: "sampling/createMessage", 35 | }; 36 | expect(() => validateRequest(invalidRequest)).toThrow( 37 | "Request must have params" 38 | ); 39 | }); 40 | 41 | it("should throw error for empty messages array", () => { 42 | const invalidRequest = { 43 | ...validRequest, 44 | params: { 45 | ...validRequest.params, 46 | messages: [], 47 | }, 48 | }; 49 | expect(() => validateRequest(invalidRequest)).toThrow( 50 | "Request must have at least one message" 51 | ); 52 | }); 53 | 54 | it("should throw error for invalid message role", () => { 55 | const invalidRequest = { 56 | ...validRequest, 57 | params: { 58 | messages: [ 59 | { 60 | role: "invalid", 61 | content: { 62 | type: "text", 63 | text: "Hello", 64 | }, 65 | }, 66 | ], 67 | }, 68 | }; 69 | expect(() => validateRequest(invalidRequest)).toThrow( 70 | 'Message role must be either "user" or "assistant"' 71 | ); 72 | }); 73 | 74 | it("should throw error for invalid content type", () => { 75 | const invalidRequest = { 76 | ...validRequest, 77 | params: { 78 | messages: [ 79 | { 80 | role: "user", 81 | content: { 82 | type: "invalid", 83 | text: "Hello", 84 | }, 85 | }, 86 | ], 87 | }, 88 | }; 89 | expect(() => validateRequest(invalidRequest)).toThrow( 90 | 'Content type must be either "text" or "image"' 91 | ); 92 | }); 93 | 94 | it("should validate image content correctly", () => { 95 | const imageRequest = { 96 | ...validRequest, 97 | params: { 98 | messages: [ 99 | { 100 | role: "user", 101 | content: { 102 | type: "image", 103 | data: "base64data", 104 | mimeType: "image/png", 105 | }, 106 | }, 107 | ], 108 | }, 109 | }; 110 | expect(() => validateRequest(imageRequest)).not.toThrow(); 111 | }); 112 | }); 113 | 114 | describe("validateNotionPageRequest", () => { 115 | it("should validate a correct Notion page request", () => { 116 | const validRequest = { 117 | pageId: "12345678-1234-1234-1234-123456789012", 118 | properties: {}, 119 | }; 120 | expect(() => validateNotionPageRequest(validRequest)).not.toThrow(); 121 | }); 122 | 123 | it("should throw error for missing pageId", () => { 124 | const invalidRequest = { 125 | properties: {}, 126 | }; 127 | expect(() => validateNotionPageRequest(invalidRequest)).toThrow( 128 | "Missing required argument: pageId" 129 | ); 130 | }); 131 | 132 | it("should throw error for invalid pageId format", () => { 133 | const invalidRequest = { 134 | pageId: "invalid-id", 135 | properties: {}, 136 | }; 137 | expect(() => validateNotionPageRequest(invalidRequest)).toThrow( 138 | "Invalid page ID format" 139 | ); 140 | }); 141 | }); 142 | -------------------------------------------------------------------------------- /src/utils/mcp-mappers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GetPromptResult, 3 | ListResourcesResult, 4 | ListPromptsResult, 5 | ReadResourceResult, 6 | } from "@modelcontextprotocol/sdk/types.js"; 7 | import type { 8 | SystempromptPromptResponse, 9 | SystempromptBlockResponse, 10 | } from "../types/systemprompt.js"; 11 | 12 | /** 13 | * Maps input schema properties to MCP argument format. 14 | * Shared between single prompt and list prompt mappings. 15 | */ 16 | function mapPromptArguments(prompt: SystempromptPromptResponse) { 17 | return Object.entries(prompt.input.schema.properties || {}) 18 | .map(([name, schema]) => { 19 | if (typeof schema === "boolean") return null; 20 | if (typeof schema !== "object" || schema === null) return null; 21 | return { 22 | name, 23 | description: 24 | "description" in schema ? String(schema.description || "") : "", 25 | required: prompt.input.schema.required?.includes(name) || false, 26 | }; 27 | }) 28 | .filter((arg): arg is NonNullable => arg !== null); 29 | } 30 | 31 | /** 32 | * Maps a single prompt to the MCP GetPromptResult format. 33 | * Used when retrieving a single prompt's details. 34 | */ 35 | export function mapPromptToGetPromptResult( 36 | prompt: SystempromptPromptResponse 37 | ): GetPromptResult { 38 | return { 39 | name: prompt.metadata.title, 40 | description: prompt.metadata.description, 41 | messages: [ 42 | { 43 | role: "assistant", 44 | content: { 45 | type: "text", 46 | text: prompt.instruction.static, 47 | }, 48 | }, 49 | ], 50 | arguments: mapPromptArguments(prompt), 51 | tools: [], 52 | _meta: { prompt }, 53 | }; 54 | } 55 | 56 | /** 57 | * Maps an array of prompts to the MCP ListPromptsResult format. 58 | * Used when listing multiple prompts. 59 | */ 60 | export function mapPromptsToListPromptsResult( 61 | prompts: SystempromptPromptResponse[] 62 | ): ListPromptsResult { 63 | return { 64 | _meta: { prompts }, 65 | prompts: prompts.map((prompt) => ({ 66 | name: prompt.metadata.title, 67 | description: prompt.metadata.description, 68 | arguments: [], 69 | })), 70 | }; 71 | } 72 | 73 | /** 74 | * Maps a single block to the MCP ReadResourceResult format. 75 | * Used when retrieving a single block's details. 76 | */ 77 | export function mapBlockToReadResourceResult( 78 | block: SystempromptBlockResponse 79 | ): ReadResourceResult { 80 | return { 81 | contents: [ 82 | { 83 | uri: `resource:///block/${block.id}`, 84 | mimeType: "text/plain", 85 | text: block.content, 86 | }, 87 | ], 88 | _meta: {}, 89 | }; 90 | } 91 | 92 | /** 93 | * Maps an array of blocks to the MCP ListResourcesResult format. 94 | * Used when listing multiple blocks. 95 | */ 96 | export function mapBlocksToListResourcesResult( 97 | blocks: SystempromptBlockResponse[] 98 | ): ListResourcesResult { 99 | return { 100 | _meta: {}, 101 | resources: blocks.map((block) => ({ 102 | uri: `resource:///block/${block.id}`, 103 | name: block.metadata.title, 104 | description: block.metadata.description || undefined, 105 | mimeType: "text/plain", 106 | })), 107 | }; 108 | } 109 | -------------------------------------------------------------------------------- /src/utils/message-handlers.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CreateMessageResult, 3 | PromptMessage, 4 | } from "@modelcontextprotocol/sdk/types.js"; 5 | import { NotionService } from "../services/notion-service.js"; 6 | import { 7 | ERROR_MESSAGES, 8 | NOTION_CALLBACKS, 9 | XML_TAGS, 10 | } from "../constants/notion.js"; 11 | import { validateWithErrors } from "./validation.js"; 12 | import type { 13 | CreatePageParameters, 14 | UpdatePageParameters, 15 | BlockObjectRequest, 16 | } from "@notionhq/client/build/src/api-endpoints.js"; 17 | import type { JSONSchema7 } from "json-schema"; 18 | 19 | // Schema for validating Notion page requests 20 | const notionPageRequestSchema: JSONSchema7 = { 21 | type: "object", 22 | required: ["pageId"], 23 | properties: { 24 | pageId: { 25 | type: "string", 26 | pattern: "^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$", 27 | }, 28 | properties: { type: "object" }, 29 | }, 30 | }; 31 | 32 | interface NotionUpdateRequest { 33 | pageId: string; 34 | properties: NonNullable; 35 | children?: BlockObjectRequest[]; 36 | } 37 | 38 | /** 39 | * Extracts the pageId from a message array 40 | * @param messages Array of messages to search through 41 | * @returns The pageId if found, undefined otherwise 42 | */ 43 | export function extractPageId(messages: PromptMessage[]): string | undefined { 44 | const userMessage = messages.find((msg) => msg.role === "user"); 45 | if (!userMessage || userMessage.content.type !== "text") { 46 | return undefined; 47 | } 48 | return userMessage.content.text.match(/([^<]+)<\/pageId>/)?.[1]; 49 | } 50 | 51 | /** 52 | * Updates a user message with existing page content 53 | * @param messages Array of messages to update 54 | * @param blocks The page blocks to include 55 | */ 56 | export function updateUserMessageWithContent( 57 | messages: PromptMessage[], 58 | blocks: unknown 59 | ): void { 60 | const userMessageIndex = messages.findIndex((msg) => msg.role === "user"); 61 | if (userMessageIndex === -1) return; 62 | 63 | const userMessage = messages[userMessageIndex]; 64 | if (userMessage.content.type !== "text") return; 65 | 66 | messages[userMessageIndex] = { 67 | role: "user", 68 | content: { 69 | type: "text", 70 | text: userMessage.content.text.replace( 71 | XML_TAGS.REQUEST_PARAMS_CLOSE, 72 | XML_TAGS.EXISTING_CONTENT_TEMPLATE(JSON.stringify(blocks, null, 2)) 73 | ), 74 | }, 75 | }; 76 | } 77 | 78 | /** 79 | * Injects variables into text 80 | * @param text The text to inject variables into 81 | * @param variables The variables to inject 82 | * @returns The text with variables injected 83 | */ 84 | export function injectVariablesIntoText( 85 | text: string, 86 | variables: Record 87 | ): string { 88 | const matches = text.match(/{{([^}]+)}}/g); 89 | if (!matches) return text; 90 | 91 | const missingVariables = matches 92 | .map((match) => match.slice(2, -2)) 93 | .filter((key) => !(key in variables)); 94 | 95 | if (missingVariables.length > 0) { 96 | throw new Error( 97 | "Missing required variables: " + missingVariables.join(", ") 98 | ); 99 | } 100 | 101 | return text.replace(/{{([^}]+)}}/g, (_, key) => String(variables[key])); 102 | } 103 | 104 | /** 105 | * Injects variables into a message 106 | * @param message The message to inject variables into 107 | * @param variables The variables to inject 108 | * @returns The message with variables injected 109 | */ 110 | export function injectVariables( 111 | message: PromptMessage, 112 | variables: Record 113 | ): PromptMessage { 114 | if (message.content.type !== "text") return message; 115 | 116 | return { 117 | ...message, 118 | content: { 119 | type: "text", 120 | text: injectVariablesIntoText(message.content.text, variables), 121 | }, 122 | }; 123 | } 124 | 125 | /** 126 | * Handles a create page callback 127 | * @param result The LLM result 128 | * @returns The tool response 129 | */ 130 | export async function handleCreatePageCallback( 131 | result: CreateMessageResult 132 | ): Promise { 133 | if (result.content.type !== "text") { 134 | throw new Error(ERROR_MESSAGES.SAMPLING.EXPECTED_TEXT); 135 | } 136 | 137 | const createRequest = JSON.parse(result.content.text) as CreatePageParameters; 138 | const notion = NotionService.getInstance(); 139 | const page = await notion.createPage(createRequest); 140 | 141 | return { 142 | role: "assistant", 143 | model: result.model, 144 | content: { 145 | type: "text", 146 | text: JSON.stringify(page, null, 2), 147 | }, 148 | }; 149 | } 150 | 151 | /** 152 | * Handles an edit page callback 153 | * @param result The LLM result 154 | * @returns The tool response 155 | */ 156 | export async function handleEditPageCallback( 157 | result: CreateMessageResult 158 | ): Promise { 159 | if (result.content.type !== "text") { 160 | throw new Error(ERROR_MESSAGES.SAMPLING.EXPECTED_TEXT); 161 | } 162 | 163 | const pageRequest = JSON.parse(result.content.text) as NotionUpdateRequest; 164 | validateWithErrors(pageRequest, notionPageRequestSchema); 165 | 166 | const notion = NotionService.getInstance(); 167 | const page = await notion.updatePage({ 168 | pageId: pageRequest.pageId, 169 | properties: pageRequest.properties, 170 | children: pageRequest.children, 171 | }); 172 | 173 | return { 174 | role: "assistant", 175 | model: result.model, 176 | content: { 177 | type: "text", 178 | text: JSON.stringify(page, null, 2), 179 | }, 180 | }; 181 | } 182 | 183 | /** 184 | * Handles a callback based on its type 185 | * @param callback The callback type 186 | * @param result The LLM result 187 | * @returns The tool response 188 | */ 189 | export async function handleCallback( 190 | callback: string, 191 | result: CreateMessageResult 192 | ): Promise { 193 | switch (callback) { 194 | case NOTION_CALLBACKS.CREATE_PAGE: 195 | return handleCreatePageCallback(result); 196 | case NOTION_CALLBACKS.EDIT_PAGE: 197 | return handleEditPageCallback(result); 198 | default: 199 | console.warn(`${ERROR_MESSAGES.SAMPLING.UNKNOWN_CALLBACK} ${callback}`); 200 | return result; 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /src/utils/notion-utils.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | PageObjectResponse, 3 | DatabaseObjectResponse, 4 | TitlePropertyItemObjectResponse, 5 | RichTextItemResponse, 6 | } from "@notionhq/client/build/src/api-endpoints.d.ts"; 7 | import type { NotionPage, NotionParent } from "../types/notion.js"; 8 | 9 | export function isFullPage(obj: unknown): obj is PageObjectResponse { 10 | if (!obj || typeof obj !== "object") { 11 | return false; 12 | } 13 | 14 | if (!("object" in obj) || !("parent" in obj)) { 15 | return false; 16 | } 17 | 18 | if ((obj as any).object !== "page") { 19 | return false; 20 | } 21 | 22 | const parent = (obj as any).parent; 23 | if (!parent || typeof parent !== "object") { 24 | return false; 25 | } 26 | 27 | if (!("type" in parent)) { 28 | return false; 29 | } 30 | 31 | const parentType = parent.type; 32 | const isValid = 33 | parentType === "database_id" || 34 | parentType === "page_id" || 35 | parentType === "workspace"; 36 | 37 | return isValid; 38 | } 39 | 40 | export function mapPageToNotionPage(page: PageObjectResponse): NotionPage { 41 | let parent: NotionParent; 42 | 43 | if ("database_id" in page.parent) { 44 | parent = { 45 | type: "database_id", 46 | database_id: page.parent.database_id, 47 | }; 48 | } else if ("page_id" in page.parent) { 49 | parent = { 50 | type: "page_id", 51 | page_id: page.parent.page_id, 52 | }; 53 | } else if (page.parent.type === "workspace") { 54 | parent = { 55 | type: "workspace", 56 | workspace: true, 57 | }; 58 | } else { 59 | throw new Error("Invalid parent type"); 60 | } 61 | 62 | return { 63 | id: page.id, 64 | title: extractTitle(page), 65 | url: page.url, 66 | created_time: page.created_time, 67 | last_edited_time: page.last_edited_time, 68 | properties: page.properties, 69 | parent, 70 | }; 71 | } 72 | 73 | export function extractTitle(page: PageObjectResponse): string { 74 | const titleProperty = Object.entries(page.properties).find( 75 | ([_, prop]) => prop.type === "title" 76 | )?.[1] as TitlePropertyItemObjectResponse | undefined; 77 | 78 | if (!titleProperty?.title) return "Untitled"; 79 | 80 | const richTextItems = 81 | titleProperty.title as unknown as RichTextItemResponse[]; 82 | return richTextItems.map((item) => item.plain_text).join("") || "Untitled"; 83 | } 84 | 85 | export function extractDatabaseTitle(database: DatabaseObjectResponse): string { 86 | if (!database.title) return "Untitled Database"; 87 | 88 | const richTextItems = database.title as unknown as RichTextItemResponse[]; 89 | return ( 90 | richTextItems.map((item) => item.plain_text).join("") || "Untitled Database" 91 | ); 92 | } 93 | -------------------------------------------------------------------------------- /src/utils/tool-validation.ts: -------------------------------------------------------------------------------- 1 | import type { CallToolRequest } from "@modelcontextprotocol/sdk/types.js"; 2 | import { NOTION_TOOLS, TOOL_ERROR_MESSAGES } from "../constants/tools.js"; 3 | import type { NotionTool } from "../types/tool-schemas.js"; 4 | import type { JSONSchema7 } from "json-schema"; 5 | import { validateWithErrors } from "./validation.js"; 6 | 7 | /** 8 | * Validates a tool request and returns the tool configuration if valid 9 | */ 10 | export function validateToolRequest(request: CallToolRequest): NotionTool { 11 | if (!request.params?.name) { 12 | throw new Error("Invalid tool request: missing tool name"); 13 | } 14 | 15 | const tool = NOTION_TOOLS.find((t) => t.name === request.params.name); 16 | if (!tool) { 17 | throw new Error( 18 | `${TOOL_ERROR_MESSAGES.UNKNOWN_TOOL} ${request.params.name}` 19 | ); 20 | } 21 | 22 | // Validate arguments against the tool's schema if present 23 | if (tool.inputSchema && request.params.arguments) { 24 | validateWithErrors( 25 | request.params.arguments, 26 | tool.inputSchema as JSONSchema7 27 | ); 28 | } 29 | 30 | return tool; 31 | } 32 | 33 | /** 34 | * Gets the schema for a tool by name 35 | */ 36 | export function getToolSchema(toolName: string): JSONSchema7 | undefined { 37 | const tool = NOTION_TOOLS.find((t) => t.name === toolName); 38 | return tool?.inputSchema as JSONSchema7; 39 | } 40 | -------------------------------------------------------------------------------- /src/utils/validation.ts: -------------------------------------------------------------------------------- 1 | import type { ErrorObject } from "ajv"; 2 | import type { JSONSchema7 } from "json-schema"; 3 | import { Ajv } from "ajv"; 4 | 5 | export const ajv = new Ajv({ 6 | allErrors: true, 7 | strict: false, 8 | strictSchema: false, 9 | strictTypes: false, 10 | }); 11 | 12 | /** 13 | * Validates data against a schema and throws an error with details if validation fails 14 | */ 15 | export function validateWithErrors(data: unknown, schema: JSONSchema7): void { 16 | const validate = ajv.compile(schema); 17 | const valid = validate(data); 18 | 19 | if (!valid) { 20 | const errors = validate.errors 21 | ?.map((e: ErrorObject) => { 22 | if (e.keyword === "required") { 23 | const property = e.params.missingProperty; 24 | // Map property names to expected error messages 25 | const errorMap: Record = { 26 | parent: "Missing required argument: parent", 27 | database_id: "Missing required argument: database_id", 28 | title: "Missing required argument: title", 29 | pageId: "Missing required argument: pageId", 30 | params: "Request must have params", 31 | messages: "Request must have at least one message", 32 | content: "Message must have a content object", 33 | text: "Text content must have a string text field", 34 | data: "Image content must have a base64 data field", 35 | mimeType: "Image content must have a mimeType field", 36 | type: "Message content must have a type field", 37 | }; 38 | return errorMap[property] || `Missing required argument: ${property}`; 39 | } 40 | if (e.keyword === "minimum" && e.params.limit === 1) { 41 | if (e.instancePath === "/params/maxTokens") { 42 | return "maxTokens must be a positive number"; 43 | } 44 | if (e.instancePath === "/params/messages") { 45 | return "Request must have at least one message"; 46 | } 47 | } 48 | if (e.keyword === "maximum" && e.params.limit === 1) { 49 | if (e.instancePath === "/params/temperature") { 50 | return "temperature must be a number between 0 and 1"; 51 | } 52 | if (e.instancePath.includes("Priority")) { 53 | return "Model preference priorities must be numbers between 0 and 1"; 54 | } 55 | } 56 | if (e.keyword === "enum") { 57 | if (e.instancePath === "/params/includeContext") { 58 | return 'includeContext must be "none", "thisServer", or "allServers"'; 59 | } 60 | if (e.instancePath.includes("/role")) { 61 | return 'Message role must be either "user" or "assistant"'; 62 | } 63 | if (e.instancePath.includes("/type")) { 64 | return 'Content type must be either "text" or "image"'; 65 | } 66 | } 67 | if (e.keyword === "pattern" && e.instancePath === "/pageId") { 68 | return "Invalid page ID format"; 69 | } 70 | if (e.keyword === "type") { 71 | if (e.instancePath.includes("/text")) { 72 | return "Text content must have a string text field"; 73 | } 74 | } 75 | if (e.keyword === "minItems" && e.instancePath === "/params/messages") { 76 | return "Request must have at least one message"; 77 | } 78 | return e.message; 79 | }) 80 | .join(", "); 81 | throw new Error(errors); 82 | } 83 | } 84 | 85 | // Schema for validating sampling requests 86 | const samplingRequestSchema: JSONSchema7 = { 87 | type: "object", 88 | required: ["method", "params"], 89 | properties: { 90 | method: { type: "string", enum: ["sampling/createMessage"] }, 91 | params: { 92 | type: "object", 93 | required: ["messages"], 94 | properties: { 95 | messages: { 96 | type: "array", 97 | minItems: 1, 98 | items: { 99 | type: "object", 100 | required: ["role", "content"], 101 | properties: { 102 | role: { type: "string", enum: ["user", "assistant"] }, 103 | content: { 104 | type: "object", 105 | required: ["type"], 106 | properties: { 107 | type: { type: "string", enum: ["text", "image"] }, 108 | text: { type: "string" }, 109 | data: { type: "string" }, 110 | mimeType: { type: "string" }, 111 | }, 112 | allOf: [ 113 | { 114 | if: { properties: { type: { const: "text" } } }, 115 | then: { required: ["text"] }, 116 | }, 117 | { 118 | if: { properties: { type: { const: "image" } } }, 119 | then: { required: ["data", "mimeType"] }, 120 | }, 121 | ], 122 | }, 123 | }, 124 | }, 125 | }, 126 | maxTokens: { type: "number", minimum: 1 }, 127 | temperature: { type: "number", minimum: 0, maximum: 1 }, 128 | includeContext: { 129 | type: "string", 130 | enum: ["none", "thisServer", "allServers"], 131 | }, 132 | modelPreferences: { 133 | type: "object", 134 | properties: { 135 | costPriority: { type: "number", minimum: 0, maximum: 1 }, 136 | speedPriority: { type: "number", minimum: 0, maximum: 1 }, 137 | intelligencePriority: { type: "number", minimum: 0, maximum: 1 }, 138 | }, 139 | }, 140 | }, 141 | }, 142 | }, 143 | }; 144 | 145 | // Schema for validating Notion page requests 146 | const notionPageRequestSchema: JSONSchema7 = { 147 | type: "object", 148 | required: ["pageId"], 149 | properties: { 150 | pageId: { 151 | type: "string", 152 | pattern: "^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$", 153 | }, 154 | properties: { type: "object" }, 155 | }, 156 | }; 157 | 158 | /** 159 | * Validates a request against a schema 160 | */ 161 | export function validateRequest(request: unknown): void { 162 | validateWithErrors(request, samplingRequestSchema); 163 | } 164 | 165 | /** 166 | * Validates a Notion page request 167 | */ 168 | export function validateNotionPageRequest(request: unknown): void { 169 | validateWithErrors(request, notionPageRequestSchema); 170 | } 171 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "Node16", 5 | "moduleResolution": "Node16", 6 | "outDir": "./build", 7 | "rootDir": "./src", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "allowJs": true, 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "types": ["node", "jest"], 16 | "paths": { 17 | "@modelcontextprotocol/sdk": [ 18 | "./node_modules/@modelcontextprotocol/sdk/dist/index.d.ts" 19 | ], 20 | "@modelcontextprotocol/sdk/*": [ 21 | "./node_modules/@modelcontextprotocol/sdk/dist/*" 22 | ] 23 | } 24 | }, 25 | "include": ["src/**/*"], 26 | "exclude": [ 27 | "node_modules", 28 | "**/*.test.ts", 29 | "**/*.test.tsx", 30 | "**/__tests__/**", 31 | "**/__mocks__/**" 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["node", "jest", "@jest/globals"], 5 | "isolatedModules": true, 6 | "esModuleInterop": true, 7 | "allowJs": true, 8 | "checkJs": false, 9 | "noImplicitAny": false, 10 | "strict": false, 11 | "strictNullChecks": false, 12 | "strictFunctionTypes": false, 13 | "noImplicitThis": false, 14 | "noUnusedLocals": false, 15 | "noUnusedParameters": false, 16 | "noImplicitReturns": false, 17 | "noFallthroughCasesInSwitch": false, 18 | "allowUnreachableCode": true, 19 | "skipLibCheck": true 20 | }, 21 | "include": ["src/**/*", "**/*.test.ts", "**/*.test.tsx"], 22 | "exclude": ["node_modules"] 23 | } 24 | --------------------------------------------------------------------------------