├── .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 | [](https://www.npmjs.com/package/systemprompt-mcp-notion)
4 | [](https://coveralls.io/github/Ejb503/systemprompt-mcp-notion?branch=main)
5 | [](https://twitter.com/tyingshoelaces_)
6 | [](https://discord.com/invite/wkAbSuPWpr)
7 | [](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 |
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 |
--------------------------------------------------------------------------------