@__shiyi/yinxiang-mcp 0.2.0
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/LICENSE +21 -0
- package/README.md +56 -0
- package/enml.mjs +52 -0
- package/index.mjs +181 -0
- package/package.json +45 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 HL
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# yinxiang-mcp
|
|
2
|
+
|
|
3
|
+
印象笔记(Yinxiang / Evernote China)的 MCP server。通过 stdio 提供笔记读写工具,支持 Markdown 与 ENML 互转。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx -y @__shiyi/yinxiang-mcp
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## 认证
|
|
12
|
+
|
|
13
|
+
Direct Token 模式,不走 OAuth。需要两个环境变量:
|
|
14
|
+
|
|
15
|
+
- `YINXIANG_DEV_TOKEN` — Developer Token,在 <https://dev.yinxiang.com> 申请
|
|
16
|
+
- `YINXIANG_NOTESTORE_URL` — NoteStore URL,如 `https://app.yinxiang.com/shard/s68/notestore`
|
|
17
|
+
|
|
18
|
+
## 接入 Claude
|
|
19
|
+
|
|
20
|
+
```json
|
|
21
|
+
{
|
|
22
|
+
"mcpServers": {
|
|
23
|
+
"yinxiang": {
|
|
24
|
+
"command": "npx",
|
|
25
|
+
"args": ["-y", "@__shiyi/yinxiang-mcp"],
|
|
26
|
+
"env": {
|
|
27
|
+
"YINXIANG_DEV_TOKEN": "<token>",
|
|
28
|
+
"YINXIANG_NOTESTORE_URL": "https://app.yinxiang.com/shard/sXX/notestore"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## 工具
|
|
36
|
+
|
|
37
|
+
- `list-notebooks` — 列出笔记本
|
|
38
|
+
- `find-notes`(`words`, `notebookGuid`, `maxResults`)— 搜索笔记
|
|
39
|
+
- `create-note`(`title`, `content`, `notebookGuid`)— 新建笔记,Markdown 转 ENML
|
|
40
|
+
- `get-note`(`guid`)— 读取笔记,ENML 转 Markdown
|
|
41
|
+
|
|
42
|
+
## 本地开发
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npm install
|
|
46
|
+
cp .env.example .env
|
|
47
|
+
npm run verify # 连通性验证
|
|
48
|
+
npm run test-rw # 读写 round-trip 测试
|
|
49
|
+
npm start
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## 限制
|
|
53
|
+
|
|
54
|
+
- `evernote@2.0.5`,EDAM API 偏旧
|
|
55
|
+
- Markdown 转换覆盖基础语法;任务列表、附件暂不支持
|
|
56
|
+
- MIT
|
package/enml.mjs
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file enml.mjs
|
|
3
|
+
* @brief Markdown ↔ ENML(Evernote Note Markup Language)双向转换。
|
|
4
|
+
*
|
|
5
|
+
* @details 基础版:marked(MD→HTML)+ turndown(HTML→MD),
|
|
6
|
+
* 覆盖 标题/段落/列表/代码块/加粗斜体/链接/图片。
|
|
7
|
+
* 任务列表(<en-todo>)、附件资源(resource)等高级特性后续迭代。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { marked } from 'marked';
|
|
11
|
+
import TurndownService from 'turndown';
|
|
12
|
+
|
|
13
|
+
const turndown = new TurndownService({
|
|
14
|
+
headingStyle: 'atx',
|
|
15
|
+
codeBlockStyle: 'fenced',
|
|
16
|
+
bulletListMarker: '-',
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @brief 清理 HTML 为 ENML 可接受的 XHTML 子集。
|
|
21
|
+
* @note 去掉 class/id/data-* 等属性(ENML 可能拒绝), void 标签自闭合。
|
|
22
|
+
* @param {string} html
|
|
23
|
+
* @returns {string}
|
|
24
|
+
*/
|
|
25
|
+
function sanitizeEnml(html) {
|
|
26
|
+
return html
|
|
27
|
+
.replace(/\s+(class|id|target|rel|data-[a-z-]+)="[^"]*"/gi, '')
|
|
28
|
+
.replace(/<br\s*>/gi, '<br/>')
|
|
29
|
+
.replace(/<hr\s*>/gi, '<hr/>')
|
|
30
|
+
.replace(/<img([^>]*?)>/gi, '<img$1/>');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @brief Markdown → 完整 ENML 文档。
|
|
35
|
+
* @param {string} md Markdown 源文
|
|
36
|
+
* @returns {string} ENML(含 XML 声明与 <en-note> 根)
|
|
37
|
+
*/
|
|
38
|
+
export function mdToEnml(md) {
|
|
39
|
+
const html = marked.parse(md);
|
|
40
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd">\n<en-note>${sanitizeEnml(html)}</en-note>`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @brief ENML 文档 → Markdown。
|
|
45
|
+
* @param {string} enml Evernote note.content
|
|
46
|
+
* @returns {string} Markdown
|
|
47
|
+
*/
|
|
48
|
+
export function enmlToMd(enml) {
|
|
49
|
+
const m = String(enml).match(/<en-note[^>]*>([\s\S]*?)<\/en-note>/i);
|
|
50
|
+
const inner = m ? m[1] : String(enml);
|
|
51
|
+
return turndown.turndown(inner).trim();
|
|
52
|
+
}
|
package/index.mjs
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @file index.mjs
|
|
4
|
+
* @brief 印象笔记 MCP Server (stdio), Direct Token 模式。
|
|
5
|
+
*
|
|
6
|
+
* @details Developer Token + NoteStore URL 直连。工具:
|
|
7
|
+
* list-notebooks / find-notes (只读)
|
|
8
|
+
* create-note / get-note (Markdown ↔ ENML 读写)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
12
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
13
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
14
|
+
import {
|
|
15
|
+
ListToolsRequestSchema,
|
|
16
|
+
CallToolRequestSchema,
|
|
17
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
18
|
+
import { mdToEnml, enmlToMd } from './enml.mjs';
|
|
19
|
+
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
const Evernote = require('evernote');
|
|
22
|
+
const { NoteStoreClient } = require('evernote/lib/stores');
|
|
23
|
+
|
|
24
|
+
const TOKEN = process.env.YINXIANG_DEV_TOKEN;
|
|
25
|
+
const NOTESTORE_URL = process.env.YINXIANG_NOTESTORE_URL;
|
|
26
|
+
|
|
27
|
+
if (!TOKEN || !NOTESTORE_URL) {
|
|
28
|
+
console.error('[yinxiang-mcp] 缺少环境变量 YINXIANG_DEV_TOKEN 或 YINXIANG_NOTESTORE_URL。请在 MCP 客户端的 env 中配置。');
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** @brief NoteStoreClient 单例, Direct Token 直连。 */
|
|
33
|
+
const noteStore = new NoteStoreClient({ token: TOKEN, url: NOTESTORE_URL });
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @brief 将执行错误封装为 MCP 错误结果。
|
|
37
|
+
* @param {unknown} err
|
|
38
|
+
* @returns {{ content: Array, isError: true }}
|
|
39
|
+
*/
|
|
40
|
+
function toolError(err) {
|
|
41
|
+
const msg = err && err.message ? err.message : String(err);
|
|
42
|
+
const edam = err && err.errorCode ? ` (EDAM errorCode=${err.errorCode})` : '';
|
|
43
|
+
return {
|
|
44
|
+
content: [{ type: 'text', text: `调用失败: ${msg}${edam}` }],
|
|
45
|
+
isError: true,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** @brief list-notebooks */
|
|
50
|
+
async function listNotebooks() {
|
|
51
|
+
const notebooks = await noteStore.listNotebooks();
|
|
52
|
+
const data = notebooks.map((nb) => ({ name: nb.name, guid: nb.guid }));
|
|
53
|
+
return {
|
|
54
|
+
content: [{ type: 'text', text: JSON.stringify({ count: data.length, notebooks: data }, null, 2) }],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** @brief find-notes */
|
|
59
|
+
async function findNotes({ words, notebookGuid, maxResults }) {
|
|
60
|
+
const filter = new Evernote.NoteStore.NoteFilter({
|
|
61
|
+
...(words ? { words } : {}),
|
|
62
|
+
...(notebookGuid ? { notebookGuid } : {}),
|
|
63
|
+
});
|
|
64
|
+
const spec = new Evernote.NoteStore.NotesMetadataResultSpec({
|
|
65
|
+
includeTitle: true,
|
|
66
|
+
includeCreated: true,
|
|
67
|
+
includeUpdated: true,
|
|
68
|
+
includeNotebookGuid: true,
|
|
69
|
+
includeTagGuids: true,
|
|
70
|
+
});
|
|
71
|
+
const max = Math.min(Math.max(Number(maxResults) || 10, 1), 50);
|
|
72
|
+
const result = await noteStore.findNotesMetadata(filter, 0, max, spec);
|
|
73
|
+
const notes = result.notes.map((n) => ({
|
|
74
|
+
title: n.title,
|
|
75
|
+
guid: n.guid,
|
|
76
|
+
notebookGuid: n.notebookGuid,
|
|
77
|
+
created: n.created,
|
|
78
|
+
updated: n.updated,
|
|
79
|
+
}));
|
|
80
|
+
return {
|
|
81
|
+
content: [{ type: 'text', text: JSON.stringify({ total: result.totalNotes, returned: notes.length, notes }, null, 2) }],
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** @brief create-note: Markdown content → ENML → createNote。 */
|
|
86
|
+
async function createNote({ title, content, notebookGuid }) {
|
|
87
|
+
const note = new Evernote.Types.Note({
|
|
88
|
+
title,
|
|
89
|
+
content: mdToEnml(content),
|
|
90
|
+
...(notebookGuid ? { notebookGuid } : {}),
|
|
91
|
+
});
|
|
92
|
+
const created = await noteStore.createNote(note);
|
|
93
|
+
return {
|
|
94
|
+
content: [{ type: 'text', text: JSON.stringify({ guid: created.guid, title: created.title }, null, 2) }],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** @brief get-note: getNote → ENML → Markdown。 */
|
|
99
|
+
async function getNote({ guid }) {
|
|
100
|
+
// @note 5 个非 token 参数须全传, 否则 makeProxyMine 校验失败。
|
|
101
|
+
const note = await noteStore.getNote(guid, true, false, false, false);
|
|
102
|
+
const md = enmlToMd(note.content);
|
|
103
|
+
return {
|
|
104
|
+
content: [{ type: 'text', text: `# ${note.title}\n\nguid: ${note.guid}\n\n---\n\n${md}` }],
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const server = new Server(
|
|
109
|
+
{ name: 'yinxiang-mcp', version: '0.2.0' },
|
|
110
|
+
{ capabilities: { tools: {} } },
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
/** @brief 工具清单。 */
|
|
114
|
+
server.setRequestHandler(ListToolsRequestSchema, () => ({
|
|
115
|
+
tools: [
|
|
116
|
+
{
|
|
117
|
+
name: 'list-notebooks',
|
|
118
|
+
description: '列出当前印象笔记账号的所有笔记本(名称与 guid)。',
|
|
119
|
+
inputSchema: { type: 'object', properties: {} },
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
name: 'find-notes',
|
|
123
|
+
description: '搜索笔记。可按搜索词(words)和/或笔记本(notebookGuid)过滤,返回标题等元数据。',
|
|
124
|
+
inputSchema: {
|
|
125
|
+
type: 'object',
|
|
126
|
+
properties: {
|
|
127
|
+
words: { type: 'string', description: '搜索词, 支持印象笔记搜索语法(可选)' },
|
|
128
|
+
notebookGuid: { type: 'string', description: '限定笔记本 guid(可选)' },
|
|
129
|
+
maxResults: { type: 'number', description: '最大返回条数(1-50), 默认 10', default: 10 },
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
name: 'create-note',
|
|
135
|
+
description: '创建一条笔记, content 为 Markdown(自动转 ENML)。',
|
|
136
|
+
inputSchema: {
|
|
137
|
+
type: 'object',
|
|
138
|
+
properties: {
|
|
139
|
+
title: { type: 'string', description: '笔记标题' },
|
|
140
|
+
content: { type: 'string', description: '正文, Markdown 格式' },
|
|
141
|
+
notebookGuid: { type: 'string', description: '目标笔记本 guid(可选, 默认进默认笔记本)' },
|
|
142
|
+
},
|
|
143
|
+
required: ['title', 'content'],
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
name: 'get-note',
|
|
148
|
+
description: '按 guid 读取一条笔记, 内容转回 Markdown。',
|
|
149
|
+
inputSchema: {
|
|
150
|
+
type: 'object',
|
|
151
|
+
properties: { guid: { type: 'string', description: '笔记 guid' } },
|
|
152
|
+
required: ['guid'],
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
}));
|
|
157
|
+
|
|
158
|
+
/** @brief 工具派发。 */
|
|
159
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
160
|
+
const { name, arguments: args } = request.params;
|
|
161
|
+
try {
|
|
162
|
+
switch (name) {
|
|
163
|
+
case 'list-notebooks':
|
|
164
|
+
return await listNotebooks();
|
|
165
|
+
case 'find-notes':
|
|
166
|
+
return await findNotes(args || {});
|
|
167
|
+
case 'create-note':
|
|
168
|
+
return await createNote(args || {});
|
|
169
|
+
case 'get-note':
|
|
170
|
+
return await getNote(args || {});
|
|
171
|
+
default:
|
|
172
|
+
return toolError(`未知工具: ${name}`);
|
|
173
|
+
}
|
|
174
|
+
} catch (err) {
|
|
175
|
+
return toolError(err);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const transport = new StdioServerTransport();
|
|
180
|
+
await server.connect(transport);
|
|
181
|
+
console.error('[yinxiang-mcp] 已启动 v0.2.0 (Direct Token, 4 工具, stdio)');
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@__shiyi/yinxiang-mcp",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "印象笔记中国版 MCP server (Direct Token, Markdown↔ENML)",
|
|
5
|
+
"private": false,
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"yinxiang-mcp": "index.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"index.mjs",
|
|
12
|
+
"enml.mjs",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"verify": "node --env-file=.env verify.mjs",
|
|
17
|
+
"start": "node index.mjs",
|
|
18
|
+
"test-rw": "node --env-file=.env test-rw.mjs"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"mcp",
|
|
22
|
+
"model-context-protocol",
|
|
23
|
+
"evernote",
|
|
24
|
+
"yinxiang",
|
|
25
|
+
"印象笔记",
|
|
26
|
+
"claude"
|
|
27
|
+
],
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=18"
|
|
30
|
+
},
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/HouiLei/yinxiang-mcp.git"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
41
|
+
"evernote": "^2.0.5",
|
|
42
|
+
"marked": "^18.0.5",
|
|
43
|
+
"turndown": "^7.2.4"
|
|
44
|
+
}
|
|
45
|
+
}
|