get_notebook_mcp_server 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +81 -0
- package/index.js +181 -0
- package/package.json +35 -0
- package/src/api/client.js +91 -0
- package/src/logger.js +19 -0
- package/src/utils/rateLimiter.js +82 -0
package/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Get Notes MCP Server
|
|
2
|
+
|
|
3
|
+
A Model Context Protocol (MCP) server for integrating with Get Notes API. This server provides tools to search and recall knowledge from your Get Notes knowledge base.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Knowledge Search**: AI-processed search that returns synthesized answers and references.
|
|
8
|
+
- **Knowledge Recall**: Raw recall of relevant notes and files.
|
|
9
|
+
- **Rate Limiting**: Built-in protection with QPS < 2 and Total Requests < 5000 limits.
|
|
10
|
+
- **Retry Mechanism**: Automatic retries for transient network errors (5xx).
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
1. Clone the repository
|
|
15
|
+
2. Install dependencies:
|
|
16
|
+
```bash
|
|
17
|
+
npm install
|
|
18
|
+
```
|
|
19
|
+
3. Create `.env` file from example:
|
|
20
|
+
```bash
|
|
21
|
+
cp .env.example .env
|
|
22
|
+
```
|
|
23
|
+
4. Configure your API key in `.env`:
|
|
24
|
+
```
|
|
25
|
+
GET_API_KEY=your_api_key_here
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
### Running the Server
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
node index.js
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Testing with MCP Inspector
|
|
37
|
+
|
|
38
|
+
You can test the MCP server interactively using the MCP Inspector:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npx @modelcontextprotocol/inspector node index.js
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Tools
|
|
45
|
+
|
|
46
|
+
#### `search_knowledge`
|
|
47
|
+
Search the knowledge base with AI processing.
|
|
48
|
+
|
|
49
|
+
**Parameters:**
|
|
50
|
+
- `question` (string, required): The question to ask.
|
|
51
|
+
- `topic_ids` (array<string>, required): List of knowledge base IDs.
|
|
52
|
+
- `deep_seek` (boolean): Enable deep thinking mode (default: true).
|
|
53
|
+
- `history` (array): Chat history for context.
|
|
54
|
+
|
|
55
|
+
#### `recall_knowledge`
|
|
56
|
+
Raw recall from knowledge base without AI synthesis.
|
|
57
|
+
|
|
58
|
+
**Parameters:**
|
|
59
|
+
- `question` (string, required): The question or query.
|
|
60
|
+
- `topic_id` (string, required): Knowledge base ID.
|
|
61
|
+
- `top_k` (number): Number of results to return (default: 10).
|
|
62
|
+
- `intent_rewrite` (boolean): Enable intent rewrite (default: false).
|
|
63
|
+
|
|
64
|
+
## Development
|
|
65
|
+
|
|
66
|
+
### Running Tests
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
npm test
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Project Structure
|
|
73
|
+
|
|
74
|
+
- `src/api`: API client implementation
|
|
75
|
+
- `src/utils`: Utility classes (RateLimiter, etc.)
|
|
76
|
+
- `tests`: Unit and integration tests
|
|
77
|
+
- `index.js`: Main MCP server entry point
|
|
78
|
+
|
|
79
|
+
## License
|
|
80
|
+
|
|
81
|
+
ISC
|
package/index.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import {
|
|
5
|
+
CallToolRequestSchema,
|
|
6
|
+
ListToolsRequestSchema,
|
|
7
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import dotenv from 'dotenv';
|
|
10
|
+
import { ApiClient } from './src/api/client.js';
|
|
11
|
+
import logger from './src/logger.js';
|
|
12
|
+
|
|
13
|
+
dotenv.config();
|
|
14
|
+
|
|
15
|
+
const API_KEY = process.env.GET_API_KEY;
|
|
16
|
+
if (!API_KEY) {
|
|
17
|
+
logger.error('GET_API_KEY environment variable is required');
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const apiClient = new ApiClient({ apiKey: API_KEY });
|
|
22
|
+
|
|
23
|
+
const server = new Server(
|
|
24
|
+
{
|
|
25
|
+
name: 'get-notes-mcp',
|
|
26
|
+
version: '1.0.0',
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
capabilities: {
|
|
30
|
+
tools: {},
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Tool Definitions
|
|
37
|
+
*/
|
|
38
|
+
const SEARCH_KNOWLEDGE_TOOL = {
|
|
39
|
+
name: 'search_knowledge',
|
|
40
|
+
description: 'Search knowledge base with AI processing. Returns synthesized answers and references.',
|
|
41
|
+
inputSchema: {
|
|
42
|
+
type: 'object',
|
|
43
|
+
properties: {
|
|
44
|
+
question: {
|
|
45
|
+
type: 'string',
|
|
46
|
+
description: 'The question to ask'
|
|
47
|
+
},
|
|
48
|
+
topic_ids: {
|
|
49
|
+
type: 'array',
|
|
50
|
+
items: { type: 'string' },
|
|
51
|
+
description: 'List of knowledge base IDs (currently supports only 1)'
|
|
52
|
+
},
|
|
53
|
+
deep_seek: {
|
|
54
|
+
type: 'boolean',
|
|
55
|
+
description: 'Enable deep thinking mode',
|
|
56
|
+
default: true
|
|
57
|
+
},
|
|
58
|
+
history: {
|
|
59
|
+
type: 'array',
|
|
60
|
+
description: 'Chat history for context',
|
|
61
|
+
items: {
|
|
62
|
+
type: 'object',
|
|
63
|
+
properties: {
|
|
64
|
+
content: { type: 'string' },
|
|
65
|
+
role: { type: 'string', enum: ['user', 'assistant'] }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
required: ['question', 'topic_ids']
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const RECALL_KNOWLEDGE_TOOL = {
|
|
75
|
+
name: 'recall_knowledge',
|
|
76
|
+
description: 'Raw recall from knowledge base without AI synthesis. Returns list of relevant notes/files.',
|
|
77
|
+
inputSchema: {
|
|
78
|
+
type: 'object',
|
|
79
|
+
properties: {
|
|
80
|
+
question: {
|
|
81
|
+
type: 'string',
|
|
82
|
+
description: 'The question or query'
|
|
83
|
+
},
|
|
84
|
+
topic_id: {
|
|
85
|
+
type: 'string',
|
|
86
|
+
description: 'Knowledge base ID'
|
|
87
|
+
},
|
|
88
|
+
top_k: {
|
|
89
|
+
type: 'number',
|
|
90
|
+
description: 'Number of results to return',
|
|
91
|
+
default: 10
|
|
92
|
+
},
|
|
93
|
+
intent_rewrite: {
|
|
94
|
+
type: 'boolean',
|
|
95
|
+
description: 'Enable intent rewrite',
|
|
96
|
+
default: false
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
required: ['question', 'topic_id']
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
104
|
+
return {
|
|
105
|
+
tools: [SEARCH_KNOWLEDGE_TOOL, RECALL_KNOWLEDGE_TOOL],
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
110
|
+
try {
|
|
111
|
+
const { name, arguments: args } = request.params;
|
|
112
|
+
|
|
113
|
+
switch (name) {
|
|
114
|
+
case 'search_knowledge': {
|
|
115
|
+
const params = args;
|
|
116
|
+
// Ensure topic_ids is array
|
|
117
|
+
if (!Array.isArray(params.topic_ids)) {
|
|
118
|
+
throw new Error('topic_ids must be an array');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const response = await apiClient.searchKnowledge(params);
|
|
122
|
+
// Handle stream or json response. For MCP tool, we typically wait for full response or handle stream if supported.
|
|
123
|
+
// The API guide says /stream is optional path, but client uses post /knowledge/search.
|
|
124
|
+
// If it returns stream, we might need to buffer it or use the non-stream version.
|
|
125
|
+
// For simplicity in this version, we assume non-stream JSON response unless stream param is explicitly handled.
|
|
126
|
+
// Actually the API guide shows stream response format. Let's assume we want the final answer.
|
|
127
|
+
// If the API returns a stream, axios might return a stream object.
|
|
128
|
+
|
|
129
|
+
// For now, let's assume standard JSON response if stream is not set to true in params.
|
|
130
|
+
// If the user wants stream, we might need a different approach, but MCP tools usually return text.
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
content: [
|
|
134
|
+
{
|
|
135
|
+
type: 'text',
|
|
136
|
+
text: JSON.stringify(response.data, null, 2),
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
case 'recall_knowledge': {
|
|
143
|
+
const params = args;
|
|
144
|
+
const response = await apiClient.recallKnowledge(params);
|
|
145
|
+
return {
|
|
146
|
+
content: [
|
|
147
|
+
{
|
|
148
|
+
type: 'text',
|
|
149
|
+
text: JSON.stringify(response.data, null, 2),
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
default:
|
|
156
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
157
|
+
}
|
|
158
|
+
} catch (error) {
|
|
159
|
+
logger.error('Tool execution error', { error: error.message });
|
|
160
|
+
return {
|
|
161
|
+
content: [
|
|
162
|
+
{
|
|
163
|
+
type: 'text',
|
|
164
|
+
text: `Error: ${error.message}`,
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
isError: true,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
async function runServer() {
|
|
173
|
+
const transport = new StdioServerTransport();
|
|
174
|
+
await server.connect(transport);
|
|
175
|
+
logger.info('Get Notes MCP Server running on stdio');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
runServer().catch((error) => {
|
|
179
|
+
logger.error('Fatal error running server', { error });
|
|
180
|
+
process.exit(1);
|
|
181
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "get_notebook_mcp_server",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "MCP server for Get Notes API integration",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"get-notes-mcp": "index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"index.js",
|
|
12
|
+
"src"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"mcp",
|
|
19
|
+
"model-context-protocol",
|
|
20
|
+
"get-notes",
|
|
21
|
+
"ai"
|
|
22
|
+
],
|
|
23
|
+
"author": "",
|
|
24
|
+
"license": "ISC",
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@modelcontextprotocol/sdk": "^1.22.0",
|
|
27
|
+
"axios": "^1.13.2",
|
|
28
|
+
"dotenv": "^17.2.3",
|
|
29
|
+
"winston": "^3.18.3",
|
|
30
|
+
"zod": "^4.1.12"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"jest": "^30.2.0"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import { globalRateLimiter } from '../utils/rateLimiter.js';
|
|
3
|
+
import logger from '../logger.js';
|
|
4
|
+
|
|
5
|
+
export class ApiClient {
|
|
6
|
+
constructor(config) {
|
|
7
|
+
this.baseUrl = config.baseUrl || 'https://open-api.biji.com/getnote/openapi';
|
|
8
|
+
this.apiKey = config.apiKey;
|
|
9
|
+
|
|
10
|
+
if (!this.apiKey) {
|
|
11
|
+
throw new Error('API Key is required');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
this.client = axios.create({
|
|
15
|
+
baseURL: this.baseUrl,
|
|
16
|
+
headers: {
|
|
17
|
+
'Content-Type': 'application/json',
|
|
18
|
+
'Connection': 'keep-alive',
|
|
19
|
+
'Authorization': `Bearer ${this.apiKey}`,
|
|
20
|
+
'X-OAuth-Version': '1'
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Add response interceptor for error logging
|
|
25
|
+
this.client.interceptors.response.use(
|
|
26
|
+
response => response,
|
|
27
|
+
error => {
|
|
28
|
+
logger.error('API Request Failed:', {
|
|
29
|
+
url: error.config?.url,
|
|
30
|
+
status: error.response?.status,
|
|
31
|
+
data: error.response?.data,
|
|
32
|
+
message: error.message
|
|
33
|
+
});
|
|
34
|
+
return Promise.reject(error);
|
|
35
|
+
}
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Execute a request with rate limiting and retries
|
|
41
|
+
* @param {Function} requestFn
|
|
42
|
+
* @param {number} retries
|
|
43
|
+
*/
|
|
44
|
+
async executeRequest(requestFn, retries = 3) {
|
|
45
|
+
return globalRateLimiter.schedule(async () => {
|
|
46
|
+
let lastError;
|
|
47
|
+
for (let i = 0; i < retries; i++) {
|
|
48
|
+
try {
|
|
49
|
+
return await requestFn();
|
|
50
|
+
} catch (error) {
|
|
51
|
+
lastError = error;
|
|
52
|
+
const isRetryable = !error.response || (error.response.status >= 500 && error.response.status < 600) || error.code === 'ECONNABORTED';
|
|
53
|
+
|
|
54
|
+
if (!isRetryable || i === retries - 1) {
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Exponential backoff: 1000ms, 2000ms, 4000ms
|
|
59
|
+
const delay = 1000 * Math.pow(2, i);
|
|
60
|
+
logger.warn(`Request failed, retrying in ${delay}ms... (${i + 1}/${retries})`);
|
|
61
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
throw lastError;
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Knowledge Search (AI Processed)
|
|
70
|
+
* @param {Object} params
|
|
71
|
+
* @returns {Promise<any>}
|
|
72
|
+
*/
|
|
73
|
+
async searchKnowledge(params) {
|
|
74
|
+
return this.executeRequest(() =>
|
|
75
|
+
this.client.post('/knowledge/search', params, {
|
|
76
|
+
responseType: params.stream ? 'stream' : 'json'
|
|
77
|
+
})
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Knowledge Recall (Raw)
|
|
83
|
+
* @param {Object} params
|
|
84
|
+
* @returns {Promise<any>}
|
|
85
|
+
*/
|
|
86
|
+
async recallKnowledge(params) {
|
|
87
|
+
return this.executeRequest(() =>
|
|
88
|
+
this.client.post('/knowledge/search/recall', params)
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
package/src/logger.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import winston from 'winston';
|
|
2
|
+
|
|
3
|
+
const logger = winston.createLogger({
|
|
4
|
+
level: 'info',
|
|
5
|
+
format: winston.format.combine(
|
|
6
|
+
winston.format.timestamp(),
|
|
7
|
+
winston.format.json()
|
|
8
|
+
),
|
|
9
|
+
transports: [
|
|
10
|
+
new winston.transports.Console({
|
|
11
|
+
format: winston.format.combine(
|
|
12
|
+
winston.format.colorize(),
|
|
13
|
+
winston.format.simple()
|
|
14
|
+
)
|
|
15
|
+
})
|
|
16
|
+
]
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export default logger;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate Limiter implementation
|
|
3
|
+
* Limits requests to:
|
|
4
|
+
* - QPS < 2 (Minimum 500ms interval between requests)
|
|
5
|
+
* - Total requests < 5000
|
|
6
|
+
*/
|
|
7
|
+
export class RateLimiter {
|
|
8
|
+
constructor(maxQps = 2, maxTotal = 5000) {
|
|
9
|
+
this.minInterval = 1000 / maxQps; // Minimum interval in ms
|
|
10
|
+
this.maxTotal = maxTotal;
|
|
11
|
+
this.requestCount = 0;
|
|
12
|
+
this.lastRequestTime = 0;
|
|
13
|
+
this.queue = [];
|
|
14
|
+
this.processing = false;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Execute a function with rate limiting
|
|
19
|
+
* @param {Function} fn - The async function to execute
|
|
20
|
+
* @returns {Promise<any>} - The result of the function
|
|
21
|
+
*/
|
|
22
|
+
async schedule(fn) {
|
|
23
|
+
if (this.requestCount >= this.maxTotal) {
|
|
24
|
+
throw new Error(`Total request limit of ${this.maxTotal} exceeded.`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
this.queue.push({ fn, resolve, reject });
|
|
29
|
+
this.processQueue();
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async processQueue() {
|
|
34
|
+
if (this.processing || this.queue.length === 0) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
this.processing = true;
|
|
39
|
+
|
|
40
|
+
while (this.queue.length > 0) {
|
|
41
|
+
if (this.requestCount >= this.maxTotal) {
|
|
42
|
+
const { reject } = this.queue.shift();
|
|
43
|
+
reject(new Error(`Total request limit of ${this.maxTotal} exceeded.`));
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const now = Date.now();
|
|
48
|
+
const timeSinceLastRequest = now - this.lastRequestTime;
|
|
49
|
+
|
|
50
|
+
if (timeSinceLastRequest < this.minInterval) {
|
|
51
|
+
const waitTime = this.minInterval - timeSinceLastRequest;
|
|
52
|
+
await new Promise(resolve => setTimeout(resolve, waitTime));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const { fn, resolve, reject } = this.queue.shift();
|
|
56
|
+
|
|
57
|
+
this.lastRequestTime = Date.now();
|
|
58
|
+
this.requestCount++;
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const result = await fn();
|
|
62
|
+
resolve(result);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
reject(error);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
this.processing = false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Get current stats
|
|
73
|
+
*/
|
|
74
|
+
getStats() {
|
|
75
|
+
return {
|
|
76
|
+
requestCount: this.requestCount,
|
|
77
|
+
queueLength: this.queue.length
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const globalRateLimiter = new RateLimiter(2, 5000);
|