asset-mcp 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
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,94 @@
1
+ # asset-mcp
2
+
3
+ 给 Cursor 使用的 MCP Server(OAuth 标准流程骨架,零依赖版本)。
4
+
5
+ ## 功能
6
+
7
+ - 暴露认证工具:`mcp_auth`(`methods/start/poll/status/logout`,仅 OAuth2 设备码)
8
+ - 暴露身份工具:`auth.me`
9
+ - 暴露查询工具:`assets.search`
10
+ - 暴露业务工具:`assets.create`
11
+ - 写操作权限由后端统一拦截(scope + RBAC + 资源级权限)
12
+
13
+ ## 运行
14
+
15
+ ```bash
16
+ cd asset-mcp
17
+ npm start
18
+ ```
19
+
20
+ ## 作为 npm 包使用
21
+
22
+ ```bash
23
+ npx -y asset-mcp
24
+ ```
25
+
26
+ ## 发布到 npm
27
+
28
+ ```bash
29
+ npm login
30
+ npm publish --access public
31
+ ```
32
+
33
+ > 如果包名已被占用,请先修改 `package.json` 里的 `name`(例如加组织作用域)。
34
+
35
+ ## 在 Cursor 中配置(示例)
36
+
37
+ 将 MCP 命令配置为:
38
+
39
+ ```bash
40
+ npx -y asset-mcp
41
+ ```
42
+
43
+ ## 环境变量
44
+
45
+ ```bash
46
+ export ASSET_MCP_BASE_URL="http://localhost:8080"
47
+ export ASSET_MCP_CLIENT_ID="asset-mcp"
48
+ export ASSET_MCP_SCOPES="assets.read assets.write"
49
+ ```
50
+
51
+ 可选项:
52
+
53
+ - `ASSET_MCP_DEVICE_CODE_PATH`(默认 `/oauth/device/code`)
54
+ - `ASSET_MCP_TOKEN_PATH`(默认 `/oauth/token`)
55
+ - `ASSET_MCP_REVOKE_PATH`(默认 `/oauth/revoke`)
56
+
57
+ ## 标准授权流程(Device Code)
58
+
59
+ 1. 先调用 `mcp_auth`,参数:`{ "action": "start" }`
60
+ 2. 按返回的 `verificationUri + userCode` 去浏览器授权
61
+ 3. 再调用 `mcp_auth`,参数:`{ "action": "poll" }`
62
+ 4. 成功后用 `auth.me` 验证登录身份
63
+ 5. 然后可调用 `assets.create`
64
+
65
+ 可选先调 `mcp_auth`:`{ "action": "methods" }` 查看步骤说明。
66
+
67
+ 说明:登录**仅**走 OAuth2 设备码;用户在浏览器授权页输入账号密码,**不要**通过 MCP 传密码。
68
+
69
+ ## 工具入参(assets.search)
70
+
71
+ - `assetType`: `"rule" | "skill" | "mcp" | "all"`(默认 `all`)
72
+ - `keyword`: 关键词
73
+
74
+ 说明:
75
+
76
+ - 查询条件只保留 `assetType + keyword`
77
+ - `keyword` 直接透传平台后端,因此搜索逻辑与平台当前页面一致
78
+ - 不开放分页参数,固定查询第 1 页、每类最多 10 条
79
+ - `assetType=all` 时:`rule/skill/mcp` 各返回最多 10 条
80
+
81
+ ## 工具入参(assets.create)
82
+
83
+ - `assetType`: `"rule" | "skill" | "mcp"`
84
+ - `name`: 资产名称
85
+ - `description`: 资产描述
86
+ - `version`: 可选,默认 `v1.0`
87
+ - `changeLog`: 可选,默认 `初始创建`
88
+ - `content`: 可选(rule/skill)
89
+ - `mcpType/configJson/toolsJson/remark`: 可选(mcp)
90
+
91
+ ## 说明
92
+
93
+ - token 存储在用户目录 `~/.asset-mcp/tokens.json`
94
+ - 建议上线时切换到系统钥匙串存储(如 macOS Keychain)
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import '../src/server.js'
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "asset-mcp",
3
+ "version": "0.2.1",
4
+ "license": "MIT",
5
+ "description": "MCP server for asset operations in Cursor",
6
+ "type": "module",
7
+ "main": "src/server.js",
8
+ "bin": {
9
+ "asset-mcp": "bin/asset-mcp.js"
10
+ },
11
+ "files": [
12
+ "bin",
13
+ "src",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "scripts": {
18
+ "start": "node src/server.js",
19
+ "check": "node --check src/server.js",
20
+ "prepublishOnly": "npm run check"
21
+ },
22
+ "publishConfig": {
23
+ "registry": "https://registry.npmjs.org/"
24
+ },
25
+ "engines": {
26
+ "node": ">=18"
27
+ },
28
+ "keywords": [
29
+ "mcp",
30
+ "cursor",
31
+ "asset"
32
+ ]
33
+ }
package/src/backend.js ADDED
@@ -0,0 +1,184 @@
1
+ import { apiUrl, config } from './config.js'
2
+ import { getValidAccessToken, refreshAccessToken } from './oauth.js'
3
+
4
+ async function parseJsonSafe(res) {
5
+ return res.json().catch(() => ({}))
6
+ }
7
+
8
+ async function requestWithToken(path, init = {}, retry = true) {
9
+ const token = await getValidAccessToken()
10
+ if (!token) {
11
+ throw new Error('未授权,请先调用 mcp_auth 完成登录')
12
+ }
13
+
14
+ const headers = {
15
+ 'content-type': 'application/json',
16
+ authorization: `Bearer ${token}`,
17
+ ...(init.headers || {}),
18
+ }
19
+
20
+ const res = await fetch(apiUrl(path), { ...init, headers })
21
+ if (res.status === 401 && retry) {
22
+ await refreshAccessToken()
23
+ return requestWithToken(path, init, false)
24
+ }
25
+
26
+ const body = await parseJsonSafe(res)
27
+ if (!res.ok) {
28
+ throw new Error(body.message || `后端请求失败(${res.status})`)
29
+ }
30
+ if (body && body.code !== undefined && body.code !== 0) {
31
+ throw new Error(body.message || '业务请求失败')
32
+ }
33
+ return body.data
34
+ }
35
+
36
+ function toQueryString(params = {}) {
37
+ const parts = []
38
+ for (const [key, val] of Object.entries(params)) {
39
+ if (val === undefined || val === null || val === '') continue
40
+ if (Array.isArray(val)) {
41
+ for (const item of val) {
42
+ if (item === undefined || item === null || item === '') continue
43
+ parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(item))}`)
44
+ }
45
+ continue
46
+ }
47
+ parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(val))}`)
48
+ }
49
+ return parts.join('&')
50
+ }
51
+
52
+ /** 列表固定第一页、每页条数(MCP 不提供分页参数) */
53
+ const LIST_PAGE = 1
54
+ const LIST_PAGE_SIZE = 10
55
+
56
+ /** 与后端 AssetQueryRequest 对齐的可选筛选(分页固定为第 1 页共 10 条) */
57
+ function buildAssetListQuery(args = {}) {
58
+ const q = {
59
+ keyword: args.keyword,
60
+ page: LIST_PAGE,
61
+ pageSize: LIST_PAGE_SIZE,
62
+ }
63
+ if (args.mine === true) q.mine = true
64
+ if (args.status != null && args.status !== '') q.status = Number(args.status)
65
+ if (args.categoryId != null && args.categoryId !== '') q.categoryId = Number(args.categoryId)
66
+ if (Array.isArray(args.categoryIds) && args.categoryIds.length > 0) {
67
+ q.categoryIds = args.categoryIds.map(Number).filter(n => !Number.isNaN(n))
68
+ }
69
+ if (args.type) q.type = args.type
70
+ return q
71
+ }
72
+
73
+ async function getWithQuery(path, query = {}) {
74
+ const qs = toQueryString(query)
75
+ const target = qs ? `${path}?${qs}` : path
76
+ return requestWithToken(target, { method: 'GET' })
77
+ }
78
+
79
+ function clampPageResult(pageData, max = 10) {
80
+ if (!pageData || !Array.isArray(pageData.records)) return pageData
81
+ return {
82
+ ...pageData,
83
+ records: pageData.records.slice(0, max),
84
+ }
85
+ }
86
+
87
+ export async function whoAmI() {
88
+ return requestWithToken(config.api.mePath, { method: 'GET' })
89
+ }
90
+
91
+ export async function searchAssets(args = {}) {
92
+ const assetType = args.assetType || 'all'
93
+ const query = buildAssetListQuery(args)
94
+
95
+ if (assetType === 'rule') {
96
+ const pageData = await getWithQuery(config.api.assetsPath('rule'), query)
97
+ return {
98
+ assetType: 'rule',
99
+ page: clampPageResult(pageData, LIST_PAGE_SIZE),
100
+ }
101
+ }
102
+
103
+ if (assetType === 'skill') {
104
+ const pageData = await getWithQuery(config.api.assetsPath('skill'), query)
105
+ return {
106
+ assetType: 'skill',
107
+ page: clampPageResult(pageData, LIST_PAGE_SIZE),
108
+ }
109
+ }
110
+
111
+ if (assetType === 'mcp') {
112
+ const pageData = await getWithQuery(config.api.assetsPath('mcp'), query)
113
+ return {
114
+ assetType: 'mcp',
115
+ page: clampPageResult(pageData, LIST_PAGE_SIZE),
116
+ }
117
+ }
118
+
119
+ if (assetType === 'all') {
120
+ const [rules, skills, mcps] = await Promise.all([
121
+ getWithQuery(config.api.assetsPath('rule'), query),
122
+ getWithQuery(config.api.assetsPath('skill'), query),
123
+ getWithQuery(config.api.assetsPath('mcp'), query),
124
+ ])
125
+
126
+ return {
127
+ assetType: 'all',
128
+ rules: clampPageResult(rules, LIST_PAGE_SIZE),
129
+ skills: clampPageResult(skills, LIST_PAGE_SIZE),
130
+ mcps: clampPageResult(mcps, LIST_PAGE_SIZE),
131
+ }
132
+ }
133
+
134
+ throw new Error(`不支持的 assetType: ${assetType}`)
135
+ }
136
+
137
+ export async function createAsset(args) {
138
+ const common = {
139
+ description: args.description,
140
+ version: args.version || 'v1.0',
141
+ tags: Array.isArray(args.tags) ? args.tags : [],
142
+ }
143
+
144
+ if (args.assetType === 'rule') {
145
+ const payload = {
146
+ name: args.name,
147
+ ...common,
148
+ content: args.content || '',
149
+ }
150
+ return requestWithToken(config.api.assetsPath('rule'), {
151
+ method: 'POST',
152
+ body: JSON.stringify(payload),
153
+ })
154
+ }
155
+
156
+ if (args.assetType === 'skill') {
157
+ const payload = {
158
+ name: args.name,
159
+ ...common,
160
+ content: args.content || '',
161
+ }
162
+ return requestWithToken(config.api.assetsPath('skill'), {
163
+ method: 'POST',
164
+ body: JSON.stringify(payload),
165
+ })
166
+ }
167
+
168
+ if (args.assetType === 'mcp') {
169
+ const payload = {
170
+ name: args.name,
171
+ type: args.mcpType || 'local',
172
+ ...common,
173
+ configJson: args.configJson || '{}',
174
+ toolsJson: args.toolsJson || '[]',
175
+ remark: args.remark || '',
176
+ }
177
+ return requestWithToken(config.api.assetsPath('mcp'), {
178
+ method: 'POST',
179
+ body: JSON.stringify(payload),
180
+ })
181
+ }
182
+
183
+ throw new Error(`不支持的 assetType: ${args.assetType}`)
184
+ }
package/src/config.js ADDED
@@ -0,0 +1,41 @@
1
+ const DEFAULT_BASE_URL = 'http://localhost:8080'
2
+
3
+ function readEnv(name, fallback = '') {
4
+ const v = process.env[name]
5
+ if (!v) return fallback
6
+ return v.trim()
7
+ }
8
+
9
+ function joinUrl(base, path) {
10
+ const b = base.endsWith('/') ? base.slice(0, -1) : base
11
+ const p = path.startsWith('/') ? path : `/${path}`
12
+ return `${b}${p}`
13
+ }
14
+
15
+ export const config = {
16
+ baseUrl: readEnv('ASSET_MCP_BASE_URL', DEFAULT_BASE_URL),
17
+ clientId: readEnv('ASSET_MCP_CLIENT_ID', 'asset-mcp'),
18
+ scopes: readEnv('ASSET_MCP_SCOPES', 'assets.read assets.write'),
19
+ oauth: {
20
+ deviceCodePath: readEnv('ASSET_MCP_DEVICE_CODE_PATH', '/oauth/device/code'),
21
+ tokenPath: readEnv('ASSET_MCP_TOKEN_PATH', '/oauth/token'),
22
+ revokePath: readEnv('ASSET_MCP_REVOKE_PATH', '/oauth/revoke'),
23
+ },
24
+ api: {
25
+ authRefreshPath: '/api/v1/auth/refresh',
26
+ authLogoutPath: '/api/v1/auth/logout',
27
+ /** 统一资产:GET 列表 / POST 创建,路径变量为 rule | skill | mcp */
28
+ assetsPath: (assetType) => `/api/v1/assets/${assetType}`,
29
+ /** 跨类型列表(query 可带 assetType / keyword / mine 等) */
30
+ assetsMarketPath: '/api/v1/assets',
31
+ mePath: '/api/v1/auth/me',
32
+ },
33
+ }
34
+
35
+ export function oauthUrl(path) {
36
+ return joinUrl(config.baseUrl, path)
37
+ }
38
+
39
+ export function apiUrl(path) {
40
+ return joinUrl(config.baseUrl, path)
41
+ }
package/src/oauth.js ADDED
@@ -0,0 +1,127 @@
1
+ import { config, oauthUrl } from './config.js'
2
+ import { clearTokens, readTokens, writeTokens } from './token-store.js'
3
+
4
+ function nowSec() {
5
+ return Math.floor(Date.now() / 1000)
6
+ }
7
+
8
+ function normalizeTokenResponse(data) {
9
+ const expiresIn = Number(data.expires_in || 900)
10
+ return {
11
+ accessToken: data.access_token,
12
+ refreshToken: data.refresh_token,
13
+ tokenType: data.token_type || 'Bearer',
14
+ scope: data.scope || config.scopes,
15
+ expiresAt: nowSec() + Math.max(60, expiresIn - 30),
16
+ }
17
+ }
18
+
19
+ function normalizePlatformLoginResponse(data) {
20
+ const expiresIn = Number(data.expiresIn || 900)
21
+ return {
22
+ accessToken: data.accessToken,
23
+ refreshToken: data.refreshToken,
24
+ tokenType: 'Bearer',
25
+ scope: config.scopes,
26
+ authMode: 'platform',
27
+ expiresAt: nowSec() + Math.max(60, expiresIn - 30),
28
+ }
29
+ }
30
+
31
+ async function postJson(url, body) {
32
+ const res = await fetch(url, {
33
+ method: 'POST',
34
+ headers: { 'content-type': 'application/json' },
35
+ body: JSON.stringify(body),
36
+ })
37
+
38
+ const data = await res.json().catch(() => ({}))
39
+ if (!res.ok) {
40
+ throw new Error(data.message || `OAuth 请求失败(${res.status})`)
41
+ }
42
+ if (data && data.code !== undefined && data.code !== 0) {
43
+ throw new Error(data.message || '业务请求失败')
44
+ }
45
+ return data
46
+ }
47
+
48
+ export async function startDeviceAuthorization() {
49
+ const data = await postJson(oauthUrl(config.oauth.deviceCodePath), {
50
+ client_id: config.clientId,
51
+ scope: config.scopes,
52
+ })
53
+ return {
54
+ deviceCode: data.device_code,
55
+ userCode: data.user_code,
56
+ verificationUri: data.verification_uri,
57
+ verificationUriComplete: data.verification_uri_complete,
58
+ expiresIn: data.expires_in,
59
+ interval: data.interval || 5,
60
+ }
61
+ }
62
+
63
+ export async function pollDeviceToken(deviceCode) {
64
+ const data = await postJson(oauthUrl(config.oauth.tokenPath), {
65
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
66
+ device_code: deviceCode,
67
+ client_id: config.clientId,
68
+ })
69
+ const tokens = normalizeTokenResponse(data)
70
+ tokens.authMode = 'oauth'
71
+ await writeTokens(tokens)
72
+ return tokens
73
+ }
74
+
75
+ export async function refreshAccessToken() {
76
+ const current = await readTokens()
77
+ if (!current?.refreshToken) {
78
+ throw new Error('没有可用的 refresh_token,请重新授权')
79
+ }
80
+ if (current.authMode === 'platform') {
81
+ const data = await postJson(oauthUrl(config.api.authRefreshPath), {
82
+ refreshToken: current.refreshToken,
83
+ })
84
+ const merged = normalizePlatformLoginResponse(data.data ?? data)
85
+ await writeTokens(merged)
86
+ return merged
87
+ }
88
+ const data = await postJson(oauthUrl(config.oauth.tokenPath), {
89
+ grant_type: 'refresh_token',
90
+ refresh_token: current.refreshToken,
91
+ client_id: config.clientId,
92
+ })
93
+ const merged = normalizeTokenResponse({
94
+ ...data,
95
+ refresh_token: data.refresh_token || current.refreshToken,
96
+ })
97
+ merged.authMode = 'oauth'
98
+ await writeTokens(merged)
99
+ return merged
100
+ }
101
+
102
+ export async function revokeAndClearTokens() {
103
+ const current = await readTokens()
104
+ if (current?.refreshToken && current.authMode !== 'platform') {
105
+ try {
106
+ await postJson(oauthUrl(config.oauth.revokePath), {
107
+ token: current.refreshToken,
108
+ client_id: config.clientId,
109
+ })
110
+ } catch {
111
+ // revoke 失败不阻止本地清理
112
+ }
113
+ }
114
+ await clearTokens()
115
+ }
116
+
117
+ export async function getValidAccessToken() {
118
+ const current = await readTokens()
119
+ if (!current?.accessToken) {
120
+ return null
121
+ }
122
+ if (typeof current.expiresAt === 'number' && current.expiresAt > nowSec()) {
123
+ return current.accessToken
124
+ }
125
+ const refreshed = await refreshAccessToken()
126
+ return refreshed.accessToken
127
+ }
package/src/server.js ADDED
@@ -0,0 +1,276 @@
1
+ import readline from 'node:readline'
2
+ import { createAsset, searchAssets, whoAmI } from './backend.js'
3
+ import { readTokens } from './token-store.js'
4
+ import { pollDeviceToken, revokeAndClearTokens, startDeviceAuthorization } from './oauth.js'
5
+
6
+ const TOOL_DEFS = [
7
+ {
8
+ name: 'mcp_auth',
9
+ description:
10
+ '认证仅支持 OAuth2 设备码:start→用户在浏览器授权→poll;可先 action=methods 看步骤。另有 status / logout。需后端 /oauth/device/code 与 /oauth/token。',
11
+ inputSchema: {
12
+ type: 'object',
13
+ properties: {
14
+ action: {
15
+ type: 'string',
16
+ enum: ['methods', 'start', 'poll', 'status', 'logout'],
17
+ description:
18
+ 'methods=登录步骤说明;start=申请设备码;poll=换取令牌;status=是否已登录;logout=退出',
19
+ },
20
+ deviceCode: { type: 'string', description: 'poll 时可省略(沿用最近一次 start)' },
21
+ },
22
+ required: ['action'],
23
+ },
24
+ },
25
+ {
26
+ name: 'auth.me',
27
+ description: '查看当前登录用户信息',
28
+ inputSchema: {
29
+ type: 'object',
30
+ properties: {},
31
+ required: [],
32
+ },
33
+ },
34
+ {
35
+ name: 'assets.search',
36
+ description:
37
+ '查询资产(rule/skill/mcp/all),GET /api/v1/assets/{assetType};固定每类最多 10 条(第 1 页),无分页参数',
38
+ inputSchema: {
39
+ type: 'object',
40
+ properties: {
41
+ assetType: { type: 'string', enum: ['rule', 'skill', 'mcp', 'all'] },
42
+ keyword: { type: 'string' },
43
+ mine: { type: 'boolean', description: 'true 仅查当前用户自己的资产' },
44
+ status: { type: 'integer', description: '1私有 2审核中 3已发布 4已拒绝' },
45
+ categoryId: { type: 'integer' },
46
+ categoryIds: { type: 'array', items: { type: 'integer' } },
47
+ type: { type: 'string', description: '仅 mcp 有效:local / remote' },
48
+ },
49
+ required: [],
50
+ },
51
+ },
52
+ {
53
+ name: 'assets.create',
54
+ description: '创建资产(权限最终由后端拦截)',
55
+ inputSchema: {
56
+ type: 'object',
57
+ properties: {
58
+ assetType: { type: 'string', enum: ['rule', 'skill', 'mcp'] },
59
+ name: { type: 'string', minLength: 1 },
60
+ description: { type: 'string', minLength: 1 },
61
+ version: { type: 'string' },
62
+ tags: { type: 'array', items: { type: 'string' } },
63
+ content: { type: 'string' },
64
+ mcpType: { type: 'string' },
65
+ configJson: { type: 'string' },
66
+ toolsJson: { type: 'string' },
67
+ remark: { type: 'string' },
68
+ },
69
+ required: [],
70
+ },
71
+ },
72
+ ]
73
+
74
+ let latestDeviceCode = null
75
+
76
+ function writeMessage(msg) {
77
+ process.stdout.write(`${JSON.stringify(msg)}\n`)
78
+ }
79
+
80
+ function makeTextResult(payload) {
81
+ const text = typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2)
82
+ return { content: [{ type: 'text', text }] }
83
+ }
84
+
85
+ function ok(id, result) {
86
+ return { jsonrpc: '2.0', id, result }
87
+ }
88
+
89
+ function err(id, message) {
90
+ return {
91
+ jsonrpc: '2.0',
92
+ id,
93
+ error: { code: -32000, message },
94
+ }
95
+ }
96
+
97
+ function authMethodsPayload() {
98
+ return {
99
+ ok: true,
100
+ action: 'methods',
101
+ message: '登录资产管理平台仅支持 OAuth2 设备授权(RFC 8628)',
102
+ steps: [
103
+ '① mcp_auth action=start → 取得 userCode、verificationUri(或 verificationUriComplete)',
104
+ '② 用户在浏览器打开链接,完成授权(若页面要求输入用户码,使用返回的 userCode)',
105
+ '③ mcp_auth action=poll → 换取 access_token 并写入本地凭证',
106
+ '④ 可选:auth.me 或 mcp_auth action=status 校验',
107
+ ],
108
+ hint: '用户可说「我要登录资产管理平台」。勿使用账号密码调用 MCP;账号密码仅在浏览器授权页由用户自行输入。',
109
+ }
110
+ }
111
+
112
+ async function handleAuthTool(args = {}) {
113
+ const action = args.action
114
+ if (action === 'methods') {
115
+ return makeTextResult(authMethodsPayload())
116
+ }
117
+
118
+ if (action === 'start') {
119
+ const s = await startDeviceAuthorization()
120
+ latestDeviceCode = s.deviceCode
121
+ return makeTextResult({
122
+ ok: true,
123
+ action: 'start',
124
+ verificationUri: s.verificationUri,
125
+ verificationUriComplete: s.verificationUriComplete,
126
+ userCode: s.userCode,
127
+ deviceCode: s.deviceCode,
128
+ expiresIn: s.expiresIn,
129
+ interval: s.interval,
130
+ hint: '请在浏览器完成授权后,再调用 mcp_auth(action=poll)',
131
+ })
132
+ }
133
+
134
+ if (action === 'poll') {
135
+ const deviceCode = args.deviceCode || latestDeviceCode
136
+ if (!deviceCode) {
137
+ return makeTextResult({
138
+ ok: false,
139
+ action: 'poll',
140
+ message: '缺少 deviceCode,请先调用 mcp_auth(action=start)',
141
+ })
142
+ }
143
+ const token = await pollDeviceToken(deviceCode)
144
+ return makeTextResult({
145
+ ok: true,
146
+ action: 'poll',
147
+ message: '授权成功,已保存凭证',
148
+ scope: token.scope,
149
+ expiresAt: token.expiresAt,
150
+ })
151
+ }
152
+
153
+ if (action === 'status') {
154
+ const tokens = await readTokens()
155
+ return makeTextResult({
156
+ ok: true,
157
+ action: 'status',
158
+ authorized: Boolean(tokens?.accessToken),
159
+ expiresAt: tokens?.expiresAt || null,
160
+ scope: tokens?.scope || null,
161
+ })
162
+ }
163
+
164
+ if (action === 'logout') {
165
+ await revokeAndClearTokens()
166
+ return makeTextResult({
167
+ ok: true,
168
+ action: 'logout',
169
+ message: '已退出并清理本地凭证',
170
+ })
171
+ }
172
+
173
+ throw new Error('mcp_auth action 不支持')
174
+ }
175
+
176
+ async function handleAssetsCreate(args = {}) {
177
+ const missingFields = []
178
+ if (!args.assetType) missingFields.push('assetType')
179
+ if (!args.name) missingFields.push('name')
180
+ if (!args.description) missingFields.push('description')
181
+
182
+ if (missingFields.length > 0) {
183
+ return makeTextResult({
184
+ ok: false,
185
+ needMoreInput: true,
186
+ missingFields,
187
+ hint: '请按顺序补充:assetType -> name -> description',
188
+ })
189
+ }
190
+
191
+ const id = await createAsset(args)
192
+ return makeTextResult({
193
+ ok: true,
194
+ message: '资产创建成功',
195
+ data: {
196
+ id,
197
+ assetType: args.assetType,
198
+ name: args.name,
199
+ version: args.version || 'v1.0',
200
+ },
201
+ })
202
+ }
203
+
204
+ async function handleAssetsSearch(args = {}) {
205
+ const data = await searchAssets(args)
206
+ return makeTextResult({
207
+ ok: true,
208
+ message: '查询成功',
209
+ data,
210
+ })
211
+ }
212
+
213
+ async function handleToolCall(name, args) {
214
+ if (name === 'mcp_auth') return handleAuthTool(args)
215
+ if (name === 'auth.me') return makeTextResult(await whoAmI())
216
+ if (name === 'assets.search') return handleAssetsSearch(args)
217
+ if (name === 'assets.create') return handleAssetsCreate(args)
218
+ throw new Error(`未知工具: ${name}`)
219
+ }
220
+
221
+ async function handleRequest(req) {
222
+ const { id, method, params } = req
223
+
224
+ if (method === 'initialize') {
225
+ return ok(id, {
226
+ protocolVersion: '2024-11-05',
227
+ capabilities: { tools: {} },
228
+ serverInfo: { name: 'asset-mcp', version: '0.2.0' },
229
+ })
230
+ }
231
+
232
+ if (method === 'notifications/initialized') return null
233
+
234
+ if (method === 'tools/list') {
235
+ return ok(id, { tools: TOOL_DEFS })
236
+ }
237
+
238
+ if (method === 'tools/call') {
239
+ const toolName = params?.name
240
+ const args = params?.arguments || {}
241
+ try {
242
+ return ok(id, await handleToolCall(toolName, args))
243
+ } catch (e) {
244
+ return err(id, e instanceof Error ? e.message : 'tools/call 执行失败')
245
+ }
246
+ }
247
+
248
+ return err(id, `不支持的方法: ${method}`)
249
+ }
250
+
251
+ const rl = readline.createInterface({
252
+ input: process.stdin,
253
+ crlfDelay: Infinity,
254
+ })
255
+
256
+ rl.on('line', async line => {
257
+ if (!line.trim()) return
258
+ let req
259
+ try {
260
+ req = JSON.parse(line)
261
+ } catch {
262
+ writeMessage(err(null, '无效 JSON'))
263
+ return
264
+ }
265
+
266
+ const resp = await handleRequest(req)
267
+ if (resp) writeMessage(resp)
268
+ })
269
+
270
+ process.on('uncaughtException', e => {
271
+ process.stderr.write(`[asset-mcp] uncaughtException: ${e.message}\n`)
272
+ })
273
+
274
+ process.on('unhandledRejection', e => {
275
+ process.stderr.write(`[asset-mcp] unhandledRejection: ${String(e)}\n`)
276
+ })
@@ -0,0 +1,33 @@
1
+ import fs from 'node:fs/promises'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+
5
+ const STORE_DIR = path.join(os.homedir(), '.asset-mcp')
6
+ const STORE_FILE = path.join(STORE_DIR, 'tokens.json')
7
+
8
+ async function ensureStoreDir() {
9
+ await fs.mkdir(STORE_DIR, { recursive: true })
10
+ }
11
+
12
+ export async function readTokens() {
13
+ try {
14
+ const raw = await fs.readFile(STORE_FILE, 'utf8')
15
+ return JSON.parse(raw)
16
+ } catch {
17
+ return null
18
+ }
19
+ }
20
+
21
+ export async function writeTokens(tokens) {
22
+ await ensureStoreDir()
23
+ const payload = JSON.stringify(tokens, null, 2)
24
+ await fs.writeFile(STORE_FILE, payload, { mode: 0o600 })
25
+ }
26
+
27
+ export async function clearTokens() {
28
+ try {
29
+ await fs.unlink(STORE_FILE)
30
+ } catch {
31
+ // ignore missing file
32
+ }
33
+ }