@kk-2004/kfile-mcp 0.1.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/README.md ADDED
@@ -0,0 +1,165 @@
1
+ # kfile-mcp
2
+
3
+ 一个 stdio MCP server,把本地 MCP 客户端(workbuddy、Claude Desktop、Cursor、Cline 等)桥接到 **k-File** 的远程 SSE MCP 服务端,并自动完成 OAuth 网页授权与令牌管理。
4
+
5
+ 装好后首次运行会自动打开浏览器让你登录授权一次,之后全自动复用令牌——无需手动复制 token、无需配 Bearer header。
6
+
7
+ ## 工作原理
8
+
9
+ ```
10
+ 你的 MCP 客户端 ──stdio──▶ kfile-mcp ──SSE + Bearer──▶ k-File 后端
11
+
12
+ ├ 首次(无令牌):
13
+ │ 起本地回调 → 开浏览器授权 → 收 ?token= → 存文件
14
+ └ 之后: 直接读令牌文件连 SSE
15
+ ```
16
+
17
+ ## 前置要求
18
+
19
+ - **Node.js ≥ 18**
20
+ - 已部署的 k-File 后端(提供 `/mcp/sse` 与 `/admin/mcp/authorize`)
21
+ - k-File SUPER 已在「系统设置 → MCP 授权回调白名单」加入 `http://127.0.0.1:` 前缀(否则本地回调会被拒)
22
+
23
+ ## 配置(环境变量)
24
+
25
+ | 变量 | 必填 | 说明 | 默认 |
26
+ |---|---|---|---|
27
+ | `KFILE_HOST` | | k-File 后端地址,如 `http://localhost:9000`。不设置时连接默认服务 | `https://file.ksite.xin` |
28
+ | `KFILE_TOKEN` | | 直接指定令牌,跳过自动授权流程 | — |
29
+ | `KFILE_TOKEN_FILE` | | 令牌存储路径 | `~/.kfile-mcp/token.json` |
30
+ | `KFILE_CALLBACK_PORT` | | 本地授权回调端口 | 随机 |
31
+
32
+ ## 各客户端接入
33
+
34
+ ### workbuddy
35
+
36
+ 编辑 `~/.workbuddy/mcp.json`:
37
+
38
+ ```json
39
+ {
40
+ "mcpServers": {
41
+ "kfile": {
42
+ "command": "npx",
43
+ "args": ["-y", "@kk-2004/kfile-mcp"]
44
+ }
45
+ }
46
+ }
47
+ ```
48
+
49
+ ### Claude Desktop
50
+
51
+ `~/Library/Application Support/Claude/claude_desktop_config.json`(macOS):
52
+
53
+ ```json
54
+ {
55
+ "mcpServers": {
56
+ "kfile": {
57
+ "command": "npx",
58
+ "args": ["-y", "@kk-2004/kfile-mcp"]
59
+ }
60
+ }
61
+ }
62
+ ```
63
+
64
+ ### Cursor
65
+
66
+ `~/.cursor/mcp.json`:
67
+
68
+ ```json
69
+ {
70
+ "mcpServers": {
71
+ "kfile": {
72
+ "command": "npx",
73
+ "args": ["-y", "@kk-2004/kfile-mcp"]
74
+ }
75
+ }
76
+ }
77
+ ```
78
+
79
+ ### Cline
80
+
81
+ `~/.cline/cline_mcp_settings.json`:
82
+
83
+ ```json
84
+ {
85
+ "mcpServers": {
86
+ "kfile": {
87
+ "command": "npx",
88
+ "args": ["-y", "@kk-2004/kfile-mcp"],
89
+ "disabled": false,
90
+ "autoApprove": []
91
+ }
92
+ }
93
+ }
94
+ ```
95
+
96
+ ## 授权流程(在 agent 内完成,无需手动跑终端)
97
+
98
+ 配置好后重启客户端。bridge 启动时**不阻塞**,且**立即列出全部工具**(kfile_login、kfile_logout + k-File 业务工具)。agent 一开始就能看到完整能力清单——未登录时调用真实工具会返回"请先 kfile_login"。
99
+
100
+ 整体功能说明通过 MCP 的 server `instructions` 字段一次性传达给 agent(不重复写在每个工具描述里)。
101
+
102
+ ### 首次使用 / 令牌过期
103
+
104
+ 1. bridge 启动,所有工具可见。
105
+ 2. 调用 `kfile_login` → bridge 起本地回调 + 打开浏览器到 `<KFILE_AUTH_HOST>/admin/mcp/authorize`。
106
+ 3. 浏览器登录(若未登录)→ 点「授权并跳转」。
107
+ 4. 令牌通过 `?token=` 回调到本地,保存到 `~/.kfile-mcp/token.json`,bridge 连接 SSE。
108
+ 5. 之后即可调用 k-File 工具。
109
+
110
+ ### 之后使用
111
+
112
+ bridge 启动时若发现已存令牌,会**静默自动连接**,无需再调 `kfile_login`。
113
+
114
+ ### 退出
115
+
116
+ 调用 `kfile_logout` → 清除本地令牌 + 断开连接,回到未授权态。用于切换账号、令牌失效或安全考虑。
117
+
118
+ ## 故障排查
119
+
120
+ - **需要连接自部署后端**:在客户端配置的 `env` 里设置 `KFILE_HOST`,例如 `http://localhost:9000`。
121
+ - **调用真实工具返回"未登录"**:还没授权。调用 `kfile_login`(或重启客户端后它会自动用已存令牌)。
122
+ - **`kfile_login` 报 redirect_uri 不合法**:k-File SUPER 没在「系统设置 → MCP 授权回调白名单」放行 `http://127.0.0.1:`。
123
+ - **k-File 工具调用返回 401**:令牌过期或被吊销。调用 `kfile_logout` 再 `kfile_login` 重新授权。
124
+ - **无法自动打开浏览器**(无 GUI / SSH 环境):`kfile_login` 会打印授权链接,手动复制到浏览器打开即可。
125
+
126
+ ## 可用工具
127
+
128
+ bridge 启动即列出全部工具(整体功能见 server instructions):
129
+
130
+ | 工具 | 用途 |
131
+ |---|---|
132
+ | `kfile_login` | 授权登录(开浏览器),成功后可调用其他工具 |
133
+ | `kfile_logout` | 退出登录并清除令牌,回到未授权态 |
134
+ | `list_my_templates` | 列出可用项目模板 |
135
+ | `create_project` | 创建项目(支持模板回填,仅 SUPER) |
136
+ | `list_my_projects` | 列出有权限的项目 |
137
+ | `get_project_info` | 获取项目详情与用户填写链接 |
138
+ | `list_missing_submitters` | 查某项目未提交名单 |
139
+ | `create_archive_download_link` | 生成打包下载链接 |
140
+ | `ask_user_choice` | 向用户提问让其选项选择 |
141
+
142
+ ## 开发
143
+
144
+ ```bash
145
+ cd mcp-bridge
146
+ npm install
147
+ npm run build # 输出到 dist/
148
+ npm start # 本地运行(默认连接 https://file.ksite.xin)
149
+ ```
150
+
151
+ 发布:
152
+
153
+ ```bash
154
+ npm publish
155
+ ```
156
+
157
+ ## 安全
158
+
159
+ - 令牌等同你的管理员身份(角色 + 项目权限),有效期 6 个月,保存在 `~/.kfile-mcp/token.json`(权限 0600)。
160
+ - 如怀疑泄露:删除令牌文件 + 在 k-File「管理员与权限设置 → MCP 访问令牌」吊销。
161
+ - bridge 工具集仅含低危操作(创建 + 只读查询 + 未提交查询),不含文件上传/删除/修改项目。
162
+
163
+ ## License
164
+
165
+ MIT
package/dist/auth.js ADDED
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Token management & first-time OAuth-style authorization for k-File MCP.
3
+ *
4
+ * Flow:
5
+ * 1. Try to read token from KFILE_TOKEN env or the token file.
6
+ * 2. If no token: start a local HTTP callback server, open the browser to the
7
+ * k-File authorization page, receive the token via ?token=, persist it.
8
+ *
9
+ * k-File's authorization is non-standard OAuth: the server returns the plaintext
10
+ * token directly as a `?token=` query param on the redirect_uri (no code exchange).
11
+ */
12
+ import http from 'node:http';
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import os from 'node:os';
16
+ import { URL } from 'node:url';
17
+ import open from 'open';
18
+ export function defaultTokenFile() {
19
+ return process.env.KFILE_TOKEN_FILE || path.join(os.homedir(), '.kfile-mcp', 'token.json');
20
+ }
21
+ /** Read a previously stored token; returns null if missing/invalid. */
22
+ export function loadToken(host) {
23
+ // Manual override wins.
24
+ if (process.env.KFILE_TOKEN && process.env.KFILE_TOKEN.trim()) {
25
+ return process.env.KFILE_TOKEN.trim();
26
+ }
27
+ const file = defaultTokenFile();
28
+ try {
29
+ const raw = fs.readFileSync(file, 'utf8');
30
+ const data = JSON.parse(raw);
31
+ if (data && data.token)
32
+ return data.token;
33
+ }
34
+ catch {
35
+ /* no token yet */
36
+ }
37
+ return null;
38
+ }
39
+ /** Persist token to file (mode 0600). */
40
+ export function saveToken(token, host) {
41
+ const file = defaultTokenFile();
42
+ fs.mkdirSync(path.dirname(file), { recursive: true });
43
+ const payload = { token, host, savedAt: Date.now() };
44
+ fs.writeFileSync(file, JSON.stringify(payload, null, 2), { mode: 0o600 });
45
+ }
46
+ /**
47
+ * Ensure we have a token. If none is stored, run the authorization flow:
48
+ * start a local callback server, open browser to k-File authorize page,
49
+ * wait for the ?token= callback, persist, return the token.
50
+ *
51
+ * @param host k-File base URL (defaults to https://file.ksite.xin in the CLI)
52
+ */
53
+ export async function ensureToken(host) {
54
+ const existing = loadToken(host);
55
+ if (existing)
56
+ return existing;
57
+ return runAuthorizationFlow(host);
58
+ }
59
+ /**
60
+ * Run the OAuth-style web authorization flow: local callback server + browser.
61
+ * Returns the plaintext token once the user authorizes in the browser.
62
+ * Exported so the bridge's kfile_login tool can invoke it on-demand.
63
+ *
64
+ * @param host k-File SSE/API backend host (token saved against this)
65
+ * @param authHost host serving the authorization page (frontend).
66
+ * In dev this differs from `host` (e.g. 5174 vs 9000);
67
+ * in production they are the same. Defaults to `host`.
68
+ */
69
+ export function runAuthorizationFlow(host, authHost = host) {
70
+ return new Promise((resolve, reject) => {
71
+ const preferredPort = process.env.KFILE_CALLBACK_PORT ? parseInt(process.env.KFILE_CALLBACK_PORT, 10) : 0;
72
+ const server = http.createServer((req, res) => {
73
+ try {
74
+ const url = new URL(req.url || '/', `http://127.0.0.1`);
75
+ const token = url.searchParams.get('token');
76
+ if (!token) {
77
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
78
+ res.end(renderPage('error', '授权失败', '回调缺少 token 参数,请重新发起授权。'));
79
+ return;
80
+ }
81
+ saveToken(token, host);
82
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
83
+ res.end(renderPage('success', '授权成功', '令牌已安全保存,有效期 6 个月。现在可以关闭此页面并返回 agent 继续操作。'));
84
+ server.close();
85
+ // Log to stderr so it doesn't corrupt stdio JSON-RPC.
86
+ console.error('[kfile-mcp-bridge] 授权成功,令牌已保存。');
87
+ resolve(token);
88
+ }
89
+ catch (e) {
90
+ res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
91
+ res.end(renderPage('error', '服务器错误', '处理授权回调时发生内部错误,请重试。'));
92
+ }
93
+ });
94
+ server.on('error', (err) => {
95
+ reject(new Error(`无法启动本地回调服务: ${err.message}`));
96
+ });
97
+ server.listen(preferredPort, '127.0.0.1', () => {
98
+ const addr = server.address();
99
+ const port = typeof addr === 'object' && addr ? addr.port : preferredPort;
100
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
101
+ // 授权页是前端路由,用 authHost(开发环境前端 5174;生产同域)
102
+ const authUrl = `${authHost.replace(/\/$/, '')}/admin/mcp/authorize?redirect_uri=${encodeURIComponent(redirectUri)}`;
103
+ console.error(`[kfile-mcp-bridge] 首次使用,请在浏览器中完成授权:`);
104
+ console.error(`[kfile-mcp-bridge] ${authUrl}`);
105
+ open(authUrl).catch(() => {
106
+ // No GUI / open failed: user must copy the URL manually (already printed above).
107
+ console.error('[kfile-mcp-bridge] 无法自动打开浏览器,请手动复制上方链接到浏览器打开。');
108
+ });
109
+ });
110
+ // Safety timeout (5 min): avoid hanging forever if user never authorizes.
111
+ setTimeout(() => {
112
+ try {
113
+ server.close();
114
+ }
115
+ catch { }
116
+ reject(new Error('授权超时(5 分钟内未完成)。请重新运行。'));
117
+ }, 5 * 60 * 1000);
118
+ });
119
+ }
120
+ /** Delete the stored token file (forces re-authorization next run). */
121
+ export function clearToken() {
122
+ const file = defaultTokenFile();
123
+ try {
124
+ fs.unlinkSync(file);
125
+ }
126
+ catch { /* ignore */ }
127
+ }
128
+ /**
129
+ * Render a clean, styled result page for the authorization callback.
130
+ * type: 'success' | 'error'
131
+ */
132
+ function renderPage(type, title, message) {
133
+ const isSuccess = type === 'success';
134
+ const color = isSuccess ? '#10a37f' : '#ef4444';
135
+ const bgTint = isSuccess ? '#f0fdf4' : '#fef2f2';
136
+ const border = isSuccess ? '#bbf7d0' : '#fecaca';
137
+ // SVG icon: check or cross
138
+ const icon = isSuccess
139
+ ? '<path d="M20 6L9 17l-5-5"/>'
140
+ : '<path d="M18 6L6 18M6 6l12 12"/>';
141
+ return `<!DOCTYPE html>
142
+ <html lang="zh-CN">
143
+ <head>
144
+ <meta charset="utf-8">
145
+ <meta name="viewport" content="width=device-width, initial-scale=1">
146
+ <title>${title} - k-File MCP</title>
147
+ <style>
148
+ * { margin: 0; padding: 0; box-sizing: border-box; }
149
+ body {
150
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
151
+ background: #f7f7f8;
152
+ color: #202123;
153
+ display: flex;
154
+ align-items: center;
155
+ justify-content: center;
156
+ min-height: 100vh;
157
+ padding: 20px;
158
+ }
159
+ .card {
160
+ background: #fff;
161
+ border-radius: 16px;
162
+ box-shadow: 0 4px 24px rgba(0,0,0,0.06);
163
+ max-width: 440px;
164
+ width: 100%;
165
+ overflow: hidden;
166
+ text-align: center;
167
+ }
168
+ .icon-wrap {
169
+ width: 72px;
170
+ height: 72px;
171
+ margin: 40px auto 0;
172
+ border-radius: 50%;
173
+ background: ${bgTint};
174
+ border: 2px solid ${border};
175
+ display: flex;
176
+ align-items: center;
177
+ justify-content: center;
178
+ }
179
+ svg { width: 36px; height: 36px; stroke: ${color}; stroke-width: 2.5; fill: none; stroke-linecap: round; stroke-linejoin: round; }
180
+ h1 { font-size: 22px; font-weight: 600; margin: 20px 0 8px; color: ${color}; }
181
+ p { font-size: 14px; line-height: 1.6; color: #6e6e80; padding: 0 32px; margin-bottom: 28px; }
182
+ .brand { font-size: 12px; color: #b4b4b4; padding: 0 0 28px; letter-spacing: 0.5px; }
183
+ </style>
184
+ </head>
185
+ <body>
186
+ <div class="card">
187
+ <div class="icon-wrap"><svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">${icon}</svg></div>
188
+ <h1>${title}</h1>
189
+ <p>${message}</p>
190
+ <div class="brand">k-File MCP Bridge</div>
191
+ </div>
192
+ </body>
193
+ </html>`;
194
+ }
package/dist/bridge.js ADDED
@@ -0,0 +1,318 @@
1
+ /**
2
+ * stdio ↔ SSE bridge for k-File MCP, with in-protocol authorization.
3
+ *
4
+ * Design:
5
+ * - A server-level "instructions" string explains the whole MCP's purpose
6
+ * and the login flow ONCE, so tool descriptions stay concise (no repeat).
7
+ * - ALL tools (5 k-File tools + kfile_login + kfile_logout) are listed from
8
+ * startup; no dynamic discovery. Calling a real tool before login returns
9
+ * "请先 kfile_login".
10
+ * - kfile_login runs the OAuth browser flow + connects SSE.
11
+ * - kfile_logout deletes the token + disconnects.
12
+ *
13
+ * The k-File tool metadata (names/descriptions/schemas) is a static manifest;
14
+ * the actual call is forwarded to the remote SSE server (source of truth).
15
+ */
16
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
17
+ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
18
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
19
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
20
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
21
+ import { runAuthorizationFlow, loadToken, clearToken } from './auth.js';
22
+ /** Server-level instructions: explains the whole MCP once (not per-tool). */
23
+ const INSTRUCTIONS = [
24
+ 'k-File MCP —— 让你(AI agent)代管理员操作 k-File 文件收集系统。',
25
+ 'k-File 是一个文件收集系统:管理员创建"项目"定义收集规则(字段、文件类型、截止时间等),用户按规则提交文件,管理员可查询谁还没提交。',
26
+ '',
27
+ '可用能力:创建项目(支持套用模板)、查看有权限的项目、查询某项目未提交名单、向用户提问让其选项选择。',
28
+ '',
29
+ '鉴权:首次使用必须先调用 kfile_login 工具登录(会打开浏览器让用户授权);之后即可调用其他工具。用完可用 kfile_logout 退出。',
30
+ '权限由授权账号的角色决定:SUPER 可创建项目、看全部项目;ADMIN 只能看被分配给自己的项目、不能创建。',
31
+ '',
32
+ '重要约定:凡需用户在确定选项中选择(选模板/选项目/开关取值),优先调用 ask_user_choice 让用户选,而非让用户在输入框手输。',
33
+ '创建项目第一步必须先调用 ask_user_choice 询问“是否使用模板”,并把结果作为 useTemplate 传给 create_project。',
34
+ '用户选择使用模板后,必须展示模板列表让用户选择模板,不要让用户手填 templateId;选了模板后开关字段(allowResubmit 等)继承模板值,不要再问用户。useTemplate=false 时每个开关用 ask_user_choice(是/否) 询问、不读默认值。',
35
+ '创建项目必须先预览再确认:先调用 create_project 且不传 confirmed 或 confirmed=false,展示预览和 confirmationToken 后用 ask_user_choice 让用户确认/修改;用户确认后再以 confirmed=true 和原 confirmationToken 调用创建。',
36
+ ].join('\n');
37
+ const LOGIN_TOOL = {
38
+ name: 'kfile_login',
39
+ description: '授权并登录 k-File。首次使用或令牌过期时调用:会打开浏览器让用户登录授权,成功后即可调用其他工具。',
40
+ inputSchema: { type: 'object', properties: {}, required: [] },
41
+ };
42
+ const LOGOUT_TOOL = {
43
+ name: 'kfile_logout',
44
+ description: '退出登录并清除本地令牌。之后调用其他工具需重新 kfile_login。用于令牌失效、切换账号或安全考虑。',
45
+ inputSchema: { type: 'object', properties: {}, required: [] },
46
+ };
47
+ /** Tools provided by the k-File backend (static manifest). */
48
+ const KFILE_TOOLS = [
49
+ {
50
+ name: 'list_my_templates',
51
+ description: '列出当前用户有权限使用的项目模板,含可复用字段。用于 create_project 前选定 templateId。建议用 ask_user_choice 让用户选。',
52
+ inputSchema: { type: 'object', properties: {}, required: [] },
53
+ },
54
+ {
55
+ name: 'create_project',
56
+ description: '创建项目。第一步必须先用 ask_user_choice 问用户是否使用模板,并传 useTemplate。useTemplate=true 且未传 templateId 时会返回模板 options,必须用 ask_user_choice 展示给用户选择,不要让用户手填 templateId;useTemplate=false 时手填创建。必须先预览再确认:首次不传 confirmed 或 confirmed=false,只返回预览和 confirmationToken 不创建;展示给用户并让用户确认/修改,用户确认后再以 confirmed=true 和原 confirmationToken 调用才创建。如果参数修改,必须重新预览获取新 token。创建成功返回 submitUrl。仅 SUPER 可创建。',
57
+ inputSchema: {
58
+ type: 'object',
59
+ properties: {
60
+ name: { type: 'string', description: '项目名称(必填)' },
61
+ useTemplate: { type: 'boolean', description: '是否使用模板。创建项目第一步必须先问用户,并传入 true/false。' },
62
+ templateId: { type: 'number', description: '模板 ID。useTemplate=true 时必填,来自用户选择的模板。' },
63
+ startAt: { type: 'number', description: '开始时间 epoch 毫秒(可选)' },
64
+ endAt: { type: 'number', description: '截止时间 epoch 毫秒(可选)' },
65
+ fileSizeLimitBytes: { type: 'number', description: '单文件大小上限字节(可选,null=不限)' },
66
+ allowedFileTypes: { type: 'array', items: { type: 'string' }, description: '允许的文件扩展名,如 ["pdf","zip"](可选)' },
67
+ allowResubmit: { type: 'boolean', description: '可复用覆盖:是否允许重复提交(可选)' },
68
+ allowMultiFiles: { type: 'boolean', description: '可复用覆盖:是否允许多文件(可选)' },
69
+ allowOverdue: { type: 'boolean', description: '可复用覆盖:是否允许逾期提交(可选)' },
70
+ expectedUserFieldsJson: { type: 'string', description: '可复用覆盖:期望字段配置 JSON(可选)' },
71
+ pathFieldKey: { type: 'string', description: '可复用覆盖:上传路径字段 key(可选)' },
72
+ pathSegmentsJson: { type: 'string', description: '可复用覆盖:上传路径层级 JSON 数组(可选)' },
73
+ userSubmitStatusType: { type: 'string', description: '可复用覆盖:状态提示类型 info/warning/success/danger(可选)' },
74
+ userSubmitStatusText: { type: 'string', description: '可复用覆盖:状态提示文案(可选)' },
75
+ queryFieldKey: { type: 'string', description: '可复用覆盖:查询主键字段(可选)' },
76
+ allowedSubmitterKeysJson: { type: 'string', description: '可复用覆盖:允许提交者字段 key JSON 数组(可选)' },
77
+ allowedSubmitterListJson: { type: 'string', description: '可复用覆盖:允许提交名单 JSON(可选)' },
78
+ autoFileNamingEnabled: { type: 'boolean', description: '可复用覆盖:是否开启自动命名(可选)' },
79
+ autoFileNamingConfigJson: { type: 'string', description: '可复用覆盖:自动命名配置 JSON(可选)' },
80
+ confirmed: { type: 'boolean', description: '用户是否已确认创建。false/不传=只预览;true=确认后真正创建。' },
81
+ confirmationToken: { type: 'string', description: '预览返回的确认令牌。confirmed=true 时必须提供,且参数不能与预览时不同。' },
82
+ },
83
+ required: ['name'],
84
+ },
85
+ },
86
+ {
87
+ name: 'list_my_projects',
88
+ description: '列出当前用户有权限的项目(SUPER 全部;ADMIN 仅被分配的)。用于后续操作(如查询未提交者)前选定 projectId。建议用 ask_user_choice 让用户选。',
89
+ inputSchema: { type: 'object', properties: {}, required: [] },
90
+ },
91
+ {
92
+ name: 'get_project_info',
93
+ description: '获取某个项目详情,并返回用户填写链接 submitUrl。需项目管理权限。建议先用 list_my_projects 让用户选 projectId。',
94
+ inputSchema: {
95
+ type: 'object',
96
+ properties: { projectId: { type: 'number', description: '项目 ID' } },
97
+ required: ['projectId'],
98
+ },
99
+ },
100
+ {
101
+ name: 'list_missing_submitters',
102
+ description: '查询某项目尚未提交的允许提交者名单。需对该项目有管理权限(SUPER 或被分配的 ADMIN)。建议先用 list_my_projects + ask_user_choice 让用户选定 projectId。',
103
+ inputSchema: {
104
+ type: 'object',
105
+ properties: { projectId: { type: 'number', description: '项目 ID' } },
106
+ required: ['projectId'],
107
+ },
108
+ },
109
+ {
110
+ name: 'create_archive_download_link',
111
+ description: '为项目生成打包下载链接。复用后台打包分享逻辑:生成最新有效提交文件的预签名清单并创建 /share?s=... 下载页,访问链接即可打包下载。可选 fieldKey/fieldValue 按提交者字段前缀过滤,expireSeconds 默认 3600。需项目管理权限。',
112
+ inputSchema: {
113
+ type: 'object',
114
+ properties: {
115
+ projectId: { type: 'number', description: '项目 ID' },
116
+ fieldKey: { type: 'string', description: '可选:按提交者字段过滤的字段 key' },
117
+ fieldValue: { type: 'string', description: '可选:按提交者字段过滤的字段值前缀' },
118
+ expireSeconds: { type: 'number', description: '可选:链接有效期秒数,默认 3600' },
119
+ },
120
+ required: ['projectId'],
121
+ },
122
+ },
123
+ {
124
+ name: 'ask_user_choice',
125
+ description: '向用户提问让其从选项中选择,返回所选 value。凡需用户在确定选项中选择(选模板/选项目/开关取值)时优先用本工具,而非让用户在输入框手输。',
126
+ inputSchema: {
127
+ type: 'object',
128
+ properties: {
129
+ prompt: { type: 'string', description: '提问说明/标题,向用户清晰呈现要选什么' },
130
+ options: {
131
+ type: 'array',
132
+ description: '选项数组,每项 {value, label}',
133
+ items: { type: 'object', properties: { value: {}, label: { type: 'string' } } },
134
+ },
135
+ },
136
+ required: ['prompt', 'options'],
137
+ },
138
+ },
139
+ ];
140
+ const ALL_TOOLS = [LOGIN_TOOL, LOGOUT_TOOL, ...KFILE_TOOLS];
141
+ export async function runBridge(opts) {
142
+ const { host } = opts;
143
+ const authHost = (process.env.KFILE_AUTH_HOST || host).trim();
144
+ const server = new Server({ name: 'kfile-mcp-bridge', version: '0.1.0' }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
145
+ let remoteClient = null;
146
+ let remoteToolNames = new Set();
147
+ let activeToken = null;
148
+ /** Connect to k-File SSE with a token. */
149
+ async function connectRemote(token) {
150
+ const sseUrl = new URL(`${host.replace(/\/$/, '')}/mcp/sse`);
151
+ const authHeaders = { Authorization: `Bearer ${token}` };
152
+ const sseTransport = new SSEClientTransport(sseUrl, {
153
+ eventSourceInit: { fetch: authedFetch(authHeaders) },
154
+ requestInit: { headers: authHeaders },
155
+ });
156
+ const client = new Client({ name: 'kfile-mcp-bridge', version: '0.1.0' }, { capabilities: {} });
157
+ await client.connect(sseTransport);
158
+ remoteClient = client;
159
+ activeToken = token;
160
+ try {
161
+ const listed = await client.listTools();
162
+ remoteToolNames = new Set((listed.tools || []).map(tool => tool.name));
163
+ if (remoteToolNames.size === 0) {
164
+ remoteToolNames = new Set(KFILE_TOOLS.map(tool => tool.name));
165
+ console.error('[kfile-mcp-bridge] 远端工具列表为空,将使用本地清单。');
166
+ }
167
+ else {
168
+ console.error(`[kfile-mcp-bridge] 远端工具: ${Array.from(remoteToolNames).join(', ')}`);
169
+ }
170
+ }
171
+ catch (e) {
172
+ remoteToolNames = new Set(KFILE_TOOLS.map(tool => tool.name));
173
+ console.error(`[kfile-mcp-bridge] 远端工具列表读取失败,将使用本地清单: ${e?.message || e}`);
174
+ }
175
+ console.error('[kfile-mcp-bridge] 已连接 k-File SSE,工具可用。');
176
+ }
177
+ /** Disconnect (logout). */
178
+ function disconnectRemote() {
179
+ if (remoteClient) {
180
+ try {
181
+ remoteClient.close();
182
+ }
183
+ catch { }
184
+ remoteClient = null;
185
+ remoteToolNames = new Set();
186
+ activeToken = null;
187
+ }
188
+ }
189
+ // tools/list: ALWAYS return the full set, regardless of auth state.
190
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
191
+ return { tools: ALL_TOOLS };
192
+ });
193
+ // tools/call: route to login/logout or forward to remote.
194
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
195
+ const { name, arguments: args } = request.params;
196
+ if (name === 'kfile_login') {
197
+ if (remoteClient) {
198
+ return { content: [{ type: 'text', text: '已登录,无需重复授权。可直接调用其他工具。' }] };
199
+ }
200
+ try {
201
+ let token = loadToken(host);
202
+ if (!token) {
203
+ token = await runAuthorizationFlow(host, authHost);
204
+ }
205
+ await connectRemote(token);
206
+ return {
207
+ content: [{ type: 'text', text: '授权成功,已连接 k-File。现在可以调用 list_my_projects / create_project 等工具了。' }],
208
+ };
209
+ }
210
+ catch (e) {
211
+ return { content: [{ type: 'text', text: `授权失败:${e?.message || e}` }], isError: true };
212
+ }
213
+ }
214
+ if (name === 'kfile_logout') {
215
+ disconnectRemote();
216
+ clearToken();
217
+ return { content: [{ type: 'text', text: '已退出登录并清除令牌。需要重新使用时请调用 kfile_login。' }] };
218
+ }
219
+ // Any other tool: requires an active connection.
220
+ if (!remoteClient) {
221
+ return {
222
+ content: [{ type: 'text', text: '未登录。请先调用 kfile_login 工具完成授权后再使用此工具。' }],
223
+ isError: true,
224
+ };
225
+ }
226
+ const remoteName = resolveRemoteToolName(name, remoteToolNames);
227
+ if (!remoteName) {
228
+ return {
229
+ content: [{ type: 'text', text: `未知工具:${name}。远端可用工具:${Array.from(remoteToolNames).join(', ')}` }],
230
+ isError: true,
231
+ };
232
+ }
233
+ // Forward to the remote k-File server (source of truth).
234
+ const result = await remoteClient.callTool({
235
+ name: remoteName,
236
+ arguments: injectAccessToken(args, activeToken),
237
+ });
238
+ console.error(`[kfile-mcp-bridge] 工具 ${remoteName} 返回: ${summarizeResult(result)}`);
239
+ return normalizeToolResult(result);
240
+ });
241
+ // Wire up stdio. All tools are listed immediately.
242
+ const stdio = new StdioServerTransport();
243
+ await server.connect(stdio);
244
+ // If a token is already stored, auto-connect silently.
245
+ const existing = loadToken(host);
246
+ if (existing) {
247
+ try {
248
+ await connectRemote(existing);
249
+ }
250
+ catch (e) {
251
+ console.error(`[kfile-mcp-bridge] 已存令牌连接失败(可能已过期):${e?.message || e}`);
252
+ console.error('[kfile-mcp-bridge] 请调用 kfile_login 重新授权。');
253
+ }
254
+ }
255
+ else {
256
+ console.error('[kfile-mcp-bridge] 未授权。所有工具已列出,调用真实工具前请先 kfile_login。');
257
+ }
258
+ }
259
+ function injectAccessToken(args, token) {
260
+ const out = (args && typeof args === 'object' && !Array.isArray(args))
261
+ ? { ...args }
262
+ : {};
263
+ if (token) {
264
+ out.__kfile_access_token = token;
265
+ }
266
+ return out;
267
+ }
268
+ function resolveRemoteToolName(name, remoteToolNames) {
269
+ const unprefixed = name.replace(/^mcp__[^_]+__/, '');
270
+ if (remoteToolNames.size === 0) {
271
+ return unprefixed;
272
+ }
273
+ if (remoteToolNames.has(name)) {
274
+ return name;
275
+ }
276
+ if (remoteToolNames.has(unprefixed)) {
277
+ return unprefixed;
278
+ }
279
+ return null;
280
+ }
281
+ function normalizeToolResult(result) {
282
+ const content = Array.isArray(result?.content) ? result.content : [];
283
+ if (content.length > 0) {
284
+ return result;
285
+ }
286
+ if (result?.structuredContent !== undefined) {
287
+ return {
288
+ content: [{ type: 'text', text: JSON.stringify(result.structuredContent, null, 2) }],
289
+ structuredContent: result.structuredContent,
290
+ isError: Boolean(result?.isError),
291
+ };
292
+ }
293
+ if (result !== undefined) {
294
+ return {
295
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
296
+ isError: Boolean(result?.isError),
297
+ };
298
+ }
299
+ return { content: [{ type: 'text', text: 'null' }] };
300
+ }
301
+ function summarizeResult(result) {
302
+ try {
303
+ const text = JSON.stringify(result);
304
+ return text.length > 800 ? `${text.slice(0, 800)}...` : text;
305
+ }
306
+ catch {
307
+ return String(result);
308
+ }
309
+ }
310
+ /**
311
+ * fetch wrapper that injects the Authorization header, for the SSE handshake.
312
+ */
313
+ function authedFetch(headers) {
314
+ return async (input, init) => {
315
+ const merged = { ...(init || {}), headers: { ...(init?.headers || {}), ...headers } };
316
+ return globalThis.fetch(input, merged);
317
+ };
318
+ }
package/dist/index.js ADDED
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * kfile-mcp-bridge entry point.
4
+ *
5
+ * Starts immediately (no blocking on auth). Exposes a `kfile_login` tool the
6
+ * agent can call to authorize via browser. If a token is already stored, it
7
+ * auto-connects silently.
8
+ *
9
+ * Config (env vars):
10
+ * KFILE_HOST (optional) k-File base URL, default https://file.ksite.xin
11
+ * KFILE_TOKEN (optional) use this token directly (skip kfile_login)
12
+ * KFILE_TOKEN_FILE (optional) token file path, default ~/.kfile-mcp/token.json
13
+ * KFILE_CALLBACK_PORT (optional) local callback port, default random
14
+ */
15
+ import { runBridge } from './bridge.js';
16
+ import { saveToken } from './auth.js';
17
+ const DEFAULT_KFILE_HOST = 'https://file.ksite.xin';
18
+ function fail(msg) {
19
+ console.error(`[kfile-mcp-bridge] 错误:${msg}`);
20
+ process.exit(1);
21
+ }
22
+ async function main() {
23
+ const host = (process.env.KFILE_HOST || DEFAULT_KFILE_HOST).trim().replace(/\/$/, '');
24
+ if (!/^https?:\/\//i.test(host)) {
25
+ fail(`KFILE_HOST 必须是 http(s) URL,收到: ${host}`);
26
+ }
27
+ // If KFILE_TOKEN is set, persist it so the bridge auto-connects on startup.
28
+ if (process.env.KFILE_TOKEN && process.env.KFILE_TOKEN.trim()) {
29
+ saveToken(process.env.KFILE_TOKEN.trim(), host);
30
+ }
31
+ try {
32
+ // Blocks: serves stdio until the client disconnects.
33
+ await runBridge({ host });
34
+ }
35
+ catch (e) {
36
+ const msg = e?.message || String(e);
37
+ fail(`桥接失败:${msg}`);
38
+ }
39
+ }
40
+ main();
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@kk-2004/kfile-mcp",
3
+ "version": "0.1.0",
4
+ "description": "stdio MCP server that bridges local MCP clients to a k-File SSE MCP server, with automatic OAuth-style token management.",
5
+ "license": "MIT",
6
+ "author": "kk",
7
+ "type": "module",
8
+ "bin": {
9
+ "kfile-mcp": "dist/index.js"
10
+ },
11
+ "files": [
12
+ "dist"
13
+ ],
14
+ "scripts": {
15
+ "build": "tsc",
16
+ "dev": "tsc --watch",
17
+ "start": "node dist/index.js"
18
+ },
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "dependencies": {
23
+ "@modelcontextprotocol/sdk": "^1.0.4",
24
+ "open": "^10.1.0"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^22.10.0",
28
+ "typescript": "^5.7.0"
29
+ },
30
+ "keywords": [
31
+ "mcp",
32
+ "model-context-protocol",
33
+ "kfile",
34
+ "k-file",
35
+ "sse",
36
+ "stdio",
37
+ "bridge"
38
+ ]
39
+ }