dt-asset-mcp 1.0.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 +83 -0
- package/bin/asset-mcp.js +2 -0
- package/package.json +34 -0
- package/src/asset-bundle.js +72 -0
- package/src/backend.js +246 -0
- package/src/config.js +55 -0
- package/src/download-asset.js +156 -0
- package/src/oauth.js +127 -0
- package/src/server.js +422 -0
- package/src/token-store.js +33 -0
- package/src/update-asset.js +212 -0
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,83 @@
|
|
|
1
|
+
# dt-asset-mcp
|
|
2
|
+
|
|
3
|
+
在 Cursor 等支持 MCP 的 IDE 中连接资产管理平台(OAuth2 设备码登录)。
|
|
4
|
+
|
|
5
|
+
## 功能
|
|
6
|
+
|
|
7
|
+
- `mcp_auth`:`methods` / `start` / `poll` / `status` / `logout`(OAuth2 设备码)
|
|
8
|
+
- `auth_me`:当前用户
|
|
9
|
+
- `assets_search`:查询资产列表
|
|
10
|
+
- `assets_detail`:按 ID 查单条详情(须指定类型;能否查看由后端判定)
|
|
11
|
+
- `assets_download`:按 ID 将资产写入当前项目的 `.cursor/...` 目录(鉴权规则同 `assets_detail`)
|
|
12
|
+
- `assets_update`:更新资产;可传 `sourceDir` 由 MCP 自动把本地目录转换后提交更新
|
|
13
|
+
- `assets_create`:创建资产(权限由后端拦截)
|
|
14
|
+
|
|
15
|
+
## 使用
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npx -y dt-asset-mcp@latest
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
在 Cursor 的 MCP 配置里将启动命令设为上述命令,并配置下方环境变量。
|
|
22
|
+
|
|
23
|
+
## 环境变量
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
export ASSET_MCP_BASE_URL="http://localhost:8080"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## 登录(设备码)
|
|
30
|
+
|
|
31
|
+
推荐按下面的“用户可理解流程”执行:
|
|
32
|
+
|
|
33
|
+
1. 调用 `mcp_auth`,`{ "action": "start" }`
|
|
34
|
+
2. 把返回的 `verificationUriComplete` 原样发给用户,让用户点击并在浏览器完成授权
|
|
35
|
+
3. 用户回复“已授权”后,调用 `mcp_auth`,`{ "action": "poll" }`
|
|
36
|
+
4. 调用 `auth_me` 确认已登录
|
|
37
|
+
|
|
38
|
+
可直接发给用户的话术:
|
|
39
|
+
|
|
40
|
+
> 请点击此链接完成授权:`verificationUriComplete`。完成后回复“已授权”,我将继续登录。
|
|
41
|
+
|
|
42
|
+
可先调用 `{ "action": "methods" }` 查看说明。请在浏览器中登录,**不要**在对话里向 MCP 发送密码。
|
|
43
|
+
|
|
44
|
+
## `assets_search`
|
|
45
|
+
|
|
46
|
+
- `assetType`:`rule` | `skill` | `mcp` | `all`(默认 `all`)
|
|
47
|
+
- `keyword`:关键词,透传后端,与平台搜索一致;固定第 1 页,每类最多 10 条;`all` 时每类各最多 10 条
|
|
48
|
+
- `status`:仅 **`assetType` 为 rule/skill/mcp** 时参与请求;**`all` 时不传**(传入也会被忽略,避免「全资产」还套状态)
|
|
49
|
+
|
|
50
|
+
## `assets_detail`
|
|
51
|
+
|
|
52
|
+
- `assetType`:`rule` | `skill` | `mcp`(必填)
|
|
53
|
+
- `id`:资产 ID(必填,正整数)
|
|
54
|
+
|
|
55
|
+
无查看权限或资源不存在时,接口会失败;与 Web 端一致,由后端统一鉴权。
|
|
56
|
+
|
|
57
|
+
## `assets_download`
|
|
58
|
+
|
|
59
|
+
- `assetType`、`id`:同 `assets_detail`(无权限则无法拉取内容)
|
|
60
|
+
- `relativeDir`:可选,表示 **`.cursor` 下面的子路径**(不要带 `.cursor` 前缀);默认空,即直接 `{工作区}/.cursor/rules|skills|mcps/{资产名}/`
|
|
61
|
+
- **覆盖策略**:下载前会先删除本地目标目录及同名 `.zip`(若存在),不做版本比对,始终按本次拉取结果整包覆盖。
|
|
62
|
+
- **rule / skill**:无 ASSET_BUNDLE 时为单个 `.md`;有 bundle 时仅展开为多文件目录(不生成 `.zip`)
|
|
63
|
+
- **mcp**:写入 `config.json`、`tools.json`、`meta.md`
|
|
64
|
+
|
|
65
|
+
## `assets_create`
|
|
66
|
+
|
|
67
|
+
- `assetType`:`rule` | `skill` | `mcp`(必填)
|
|
68
|
+
- 可选:`name`、`description`、`version`、`tags`
|
|
69
|
+
- `sourceDir`:可选,本地目录(绝对路径或相对工程根;工程根固定为当前进程 `cwd`);传入后由 MCP 自动转换:
|
|
70
|
+
- **rule / skill**:目录文件转 `ASSET_BUNDLE_V1` 内容并写入 `content`
|
|
71
|
+
- **mcp**:读取 `config.json`、`tools.json`、`meta.md` 分别写入 `configJson`、`toolsJson`、`remark`
|
|
72
|
+
- 不传 `sourceDir` 时,可直接传 `content`(rule/skill)或 `type/configJson/toolsJson/remark`(mcp)
|
|
73
|
+
|
|
74
|
+
## `assets_update`
|
|
75
|
+
|
|
76
|
+
- **已发布**资产:后端只更新**草稿**;你作为作者/管理员在详情、或列表带 **`mine=true`** 时会**优先看到草稿**,所以体感像「一保存就变了」;**访客/公共列表**仍读**已发布版本**,真正对外替换要走平台「申请发布 → 审核」。
|
|
77
|
+
- 必填:`assetType`、`id`、`version`、`changeLog`
|
|
78
|
+
- 可选:`name`、`description`、`tags`
|
|
79
|
+
- `sourceDir`:可选,本地目录(绝对路径或相对工程根;工程根固定为当前进程 `cwd`);传入后由 MCP 自动转换:
|
|
80
|
+
- **rule / skill**:目录文件转 `ASSET_BUNDLE_V1` 内容并写入 `content`
|
|
81
|
+
- **mcp**:读取 `config.json`、`tools.json`、`meta.md` 分别写入 `configJson`、`toolsJson`、`remark`
|
|
82
|
+
- 不传 `sourceDir` 时,可直接传 `content`(rule/skill)或 `configJson/toolsJson/remark`(mcp)
|
|
83
|
+
- **mcp + sourceDir 覆盖语义**:以本地目录为唯一数据源,缺失文件会提交默认空值(`{}` / `[]` / 空串),不会回填远端旧内容。
|
package/bin/asset-mcp.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dt-asset-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"description": "MCP server for asset operations in Cursor",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "src/server.js",
|
|
8
|
+
"bin": {
|
|
9
|
+
"dt-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 && node --check src/config.js && node --check src/backend.js && node --check src/download-asset.js && node --check src/asset-bundle.js",
|
|
20
|
+
"prepublishOnly": "npm run check"
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"registry": "https://registry.npmjs.org/"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {},
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"mcp",
|
|
31
|
+
"cursor",
|
|
32
|
+
"asset"
|
|
33
|
+
]
|
|
34
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 与前端 assetBundle.ts 对齐:解析正文中的 ASSET_BUNDLE_V1 块。
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const MARKER_REGEX = /<!--\s*ASSET_BUNDLE_V1:([A-Za-z0-9+/=]+)\s*-->/
|
|
6
|
+
const MARKER_PREFIX = '<!-- ASSET_BUNDLE_V1:'
|
|
7
|
+
|
|
8
|
+
export function parseAssetBundle(raw, defaultEntryPath = 'ENTRY') {
|
|
9
|
+
const source = raw ?? ''
|
|
10
|
+
const match = source.match(MARKER_REGEX)
|
|
11
|
+
if (!match) {
|
|
12
|
+
return {
|
|
13
|
+
entryContent: source,
|
|
14
|
+
files: [],
|
|
15
|
+
folders: [],
|
|
16
|
+
entryPath: defaultEntryPath,
|
|
17
|
+
bundled: false,
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const marker = match[0]
|
|
22
|
+
const entryContent = source.replace(marker, '').trimEnd()
|
|
23
|
+
try {
|
|
24
|
+
const decoded = Buffer.from(match[1], 'base64').toString('utf8')
|
|
25
|
+
const payload = JSON.parse(decoded)
|
|
26
|
+
const files = Array.isArray(payload.files)
|
|
27
|
+
? payload.files
|
|
28
|
+
.filter(f => f && typeof f.path === 'string')
|
|
29
|
+
.map(f => ({ path: f.path, content: String(f.content ?? '') }))
|
|
30
|
+
: []
|
|
31
|
+
const folders = Array.isArray(payload.folders)
|
|
32
|
+
? payload.folders.filter(f => typeof f === 'string').map(f => f.trim()).filter(Boolean)
|
|
33
|
+
: []
|
|
34
|
+
if (!files.length) {
|
|
35
|
+
return { entryContent, files: [], folders, entryPath: defaultEntryPath, bundled: false }
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
entryContent,
|
|
39
|
+
files,
|
|
40
|
+
folders,
|
|
41
|
+
entryPath: payload.entryPath || files[0].path || defaultEntryPath,
|
|
42
|
+
bundled: true,
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
return {
|
|
46
|
+
entryContent,
|
|
47
|
+
files: [],
|
|
48
|
+
folders: [],
|
|
49
|
+
entryPath: defaultEntryPath,
|
|
50
|
+
bundled: false,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function buildAssetBundle(entryPath, files = [], folders = []) {
|
|
56
|
+
const safeFiles = files
|
|
57
|
+
.filter(f => f && typeof f.path === 'string' && f.path.trim())
|
|
58
|
+
.map(f => ({ path: f.path.trim(), content: String(f.content ?? '') }))
|
|
59
|
+
const safeFolders = folders
|
|
60
|
+
.filter(f => typeof f === 'string')
|
|
61
|
+
.map(f => f.trim())
|
|
62
|
+
.filter(Boolean)
|
|
63
|
+
const entry = safeFiles.find(f => f.path === entryPath) ?? safeFiles[0] ?? { path: entryPath, content: '' }
|
|
64
|
+
const payload = {
|
|
65
|
+
entryPath: entry.path,
|
|
66
|
+
files: safeFiles.length ? safeFiles : [{ path: entry.path, content: entry.content }],
|
|
67
|
+
folders: safeFolders,
|
|
68
|
+
}
|
|
69
|
+
const encoded = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64')
|
|
70
|
+
const body = entry.content.trimEnd()
|
|
71
|
+
return `${body}\n\n${MARKER_PREFIX}${encoded} -->`
|
|
72
|
+
}
|
package/src/backend.js
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
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
|
+
function parsePositiveId(raw) {
|
|
92
|
+
const n = typeof raw === 'string' ? parseInt(raw, 10) : Number(raw)
|
|
93
|
+
if (!Number.isFinite(n) || n < 1 || !Number.isInteger(n)) {
|
|
94
|
+
throw new Error('id 须为正整数')
|
|
95
|
+
}
|
|
96
|
+
return n
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 单条详情:走平台 GET /api/v1/assets/{type}/{id},可见性与角色由后端 SQL + 业务校验,无权限时 404/业务错误。
|
|
101
|
+
*/
|
|
102
|
+
export async function getAssetDetail(args = {}) {
|
|
103
|
+
const assetType = args.assetType
|
|
104
|
+
if (!['rule', 'skill', 'mcp'].includes(assetType)) {
|
|
105
|
+
throw new Error('assetType 须为 rule、skill 或 mcp')
|
|
106
|
+
}
|
|
107
|
+
const id = parsePositiveId(args.id)
|
|
108
|
+
const path = config.api.assetDetailPath(assetType, id)
|
|
109
|
+
return requestWithToken(path, { method: 'GET' })
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function searchAssets(args = {}) {
|
|
113
|
+
const assetType = args.assetType || 'all'
|
|
114
|
+
/** 全部类型并行查询时,不按 status 筛(各类型状态语义一致化成本高,且与「看全部」习惯一致) */
|
|
115
|
+
const queryArgs = assetType === 'all' ? { ...args, status: undefined } : args
|
|
116
|
+
const query = buildAssetListQuery(queryArgs)
|
|
117
|
+
|
|
118
|
+
if (assetType === 'rule') {
|
|
119
|
+
const pageData = await getWithQuery(config.api.assetsPath('rule'), query)
|
|
120
|
+
return {
|
|
121
|
+
assetType: 'rule',
|
|
122
|
+
page: clampPageResult(pageData, LIST_PAGE_SIZE),
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (assetType === 'skill') {
|
|
127
|
+
const pageData = await getWithQuery(config.api.assetsPath('skill'), query)
|
|
128
|
+
return {
|
|
129
|
+
assetType: 'skill',
|
|
130
|
+
page: clampPageResult(pageData, LIST_PAGE_SIZE),
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (assetType === 'mcp') {
|
|
135
|
+
const pageData = await getWithQuery(config.api.assetsPath('mcp'), query)
|
|
136
|
+
return {
|
|
137
|
+
assetType: 'mcp',
|
|
138
|
+
page: clampPageResult(pageData, LIST_PAGE_SIZE),
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (assetType === 'all') {
|
|
143
|
+
const [rules, skills, mcps] = await Promise.all([
|
|
144
|
+
getWithQuery(config.api.assetsPath('rule'), query),
|
|
145
|
+
getWithQuery(config.api.assetsPath('skill'), query),
|
|
146
|
+
getWithQuery(config.api.assetsPath('mcp'), query),
|
|
147
|
+
])
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
assetType: 'all',
|
|
151
|
+
rules: clampPageResult(rules, LIST_PAGE_SIZE),
|
|
152
|
+
skills: clampPageResult(skills, LIST_PAGE_SIZE),
|
|
153
|
+
mcps: clampPageResult(mcps, LIST_PAGE_SIZE),
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
throw new Error(`不支持的 assetType: ${assetType}`)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function createAsset(args) {
|
|
161
|
+
const common = {
|
|
162
|
+
description: args.description,
|
|
163
|
+
version: args.version || 'v1.0',
|
|
164
|
+
tags: Array.isArray(args.tags) ? args.tags : [],
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (args.assetType === 'rule') {
|
|
168
|
+
const payload = {
|
|
169
|
+
name: args.name,
|
|
170
|
+
...common,
|
|
171
|
+
content: args.content || '',
|
|
172
|
+
}
|
|
173
|
+
return requestWithToken(config.api.assetsPath('rule'), {
|
|
174
|
+
method: 'POST',
|
|
175
|
+
body: JSON.stringify(payload),
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (args.assetType === 'skill') {
|
|
180
|
+
const payload = {
|
|
181
|
+
name: args.name,
|
|
182
|
+
...common,
|
|
183
|
+
content: args.content || '',
|
|
184
|
+
}
|
|
185
|
+
return requestWithToken(config.api.assetsPath('skill'), {
|
|
186
|
+
method: 'POST',
|
|
187
|
+
body: JSON.stringify(payload),
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (args.assetType === 'mcp') {
|
|
192
|
+
const payload = {
|
|
193
|
+
name: args.name,
|
|
194
|
+
type: args.mcpType || args.type || 'local',
|
|
195
|
+
...common,
|
|
196
|
+
configJson: args.configJson || '{}',
|
|
197
|
+
toolsJson: args.toolsJson || '[]',
|
|
198
|
+
remark: args.remark || '',
|
|
199
|
+
}
|
|
200
|
+
return requestWithToken(config.api.assetsPath('mcp'), {
|
|
201
|
+
method: 'POST',
|
|
202
|
+
body: JSON.stringify(payload),
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
throw new Error(`不支持的 assetType: ${args.assetType}`)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function updateAsset(args = {}) {
|
|
210
|
+
const assetType = args.assetType
|
|
211
|
+
if (!['rule', 'skill', 'mcp'].includes(assetType)) {
|
|
212
|
+
throw new Error('assetType 须为 rule、skill 或 mcp')
|
|
213
|
+
}
|
|
214
|
+
const id = parsePositiveId(args.id)
|
|
215
|
+
if (!args.version || !String(args.version).trim()) {
|
|
216
|
+
throw new Error('version 不能为空')
|
|
217
|
+
}
|
|
218
|
+
if (!args.changeLog || !String(args.changeLog).trim()) {
|
|
219
|
+
throw new Error('changeLog 不能为空')
|
|
220
|
+
}
|
|
221
|
+
if (!args.name || !String(args.name).trim()) {
|
|
222
|
+
throw new Error('name 不能为空')
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const payload = {
|
|
226
|
+
name: String(args.name).trim(),
|
|
227
|
+
description: args.description ?? '',
|
|
228
|
+
version: String(args.version).trim(),
|
|
229
|
+
changeLog: String(args.changeLog).trim(),
|
|
230
|
+
tags: Array.isArray(args.tags) ? args.tags : [],
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (assetType === 'rule' || assetType === 'skill') {
|
|
234
|
+
payload.content = args.content ?? ''
|
|
235
|
+
} else {
|
|
236
|
+
payload.type = args.type || 'local'
|
|
237
|
+
payload.configJson = args.configJson ?? '{}'
|
|
238
|
+
payload.toolsJson = args.toolsJson ?? '[]'
|
|
239
|
+
payload.remark = args.remark ?? ''
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return requestWithToken(config.api.assetUpdatePath(assetType, id), {
|
|
243
|
+
method: 'PUT',
|
|
244
|
+
body: JSON.stringify(payload),
|
|
245
|
+
})
|
|
246
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
|
|
3
|
+
const DEFAULT_BASE_URL = 'http://localhost:8080'
|
|
4
|
+
|
|
5
|
+
function readEnv(name, fallback = '') {
|
|
6
|
+
const v = process.env[name]
|
|
7
|
+
if (!v) return fallback
|
|
8
|
+
return v.trim()
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function joinUrl(base, path) {
|
|
12
|
+
const b = base.endsWith('/') ? base.slice(0, -1) : base
|
|
13
|
+
const p = path.startsWith('/') ? path : `/${path}`
|
|
14
|
+
return `${b}${p}`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const config = {
|
|
18
|
+
baseUrl: readEnv('ASSET_MCP_BASE_URL', DEFAULT_BASE_URL),
|
|
19
|
+
clientId: readEnv('ASSET_MCP_CLIENT_ID', 'asset-mcp'),
|
|
20
|
+
scopes: readEnv('ASSET_MCP_SCOPES', 'assets.read assets.write'),
|
|
21
|
+
oauth: {
|
|
22
|
+
deviceCodePath: readEnv('ASSET_MCP_DEVICE_CODE_PATH', '/api/oauth/device/code'),
|
|
23
|
+
tokenPath: readEnv('ASSET_MCP_TOKEN_PATH', '/api/oauth/token'),
|
|
24
|
+
revokePath: readEnv('ASSET_MCP_REVOKE_PATH', '/api/oauth/revoke'),
|
|
25
|
+
},
|
|
26
|
+
api: {
|
|
27
|
+
authRefreshPath: '/api/v1/auth/refresh',
|
|
28
|
+
authLogoutPath: '/api/v1/auth/logout',
|
|
29
|
+
/** 统一资产:GET 列表 / POST 创建,路径变量为 rule | skill | mcp */
|
|
30
|
+
assetsPath: (assetType) => `/api/v1/assets/${assetType}`,
|
|
31
|
+
/** 单条详情:GET,权限由后端校验 */
|
|
32
|
+
assetDetailPath: (assetType, id) => `/api/v1/assets/${assetType}/${id}`,
|
|
33
|
+
/** 单条更新:PUT */
|
|
34
|
+
assetUpdatePath: (assetType, id) => `/api/v1/assets/${assetType}/${id}`,
|
|
35
|
+
/** 跨类型列表(query 可带 assetType / keyword / mine 等) */
|
|
36
|
+
assetsMarketPath: '/api/v1/assets',
|
|
37
|
+
mePath: '/api/v1/auth/me',
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function oauthUrl(path) {
|
|
42
|
+
return joinUrl(config.baseUrl, path)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function apiUrl(path) {
|
|
46
|
+
return joinUrl(config.baseUrl, path)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 下载写入、sourceDir 解析的根目录。
|
|
51
|
+
* 固定使用 process.cwd()(通常即当前项目根目录)。
|
|
52
|
+
*/
|
|
53
|
+
export function getWorkspaceRoot() {
|
|
54
|
+
return path.resolve(process.cwd())
|
|
55
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { parseAssetBundle } from './asset-bundle.js'
|
|
4
|
+
|
|
5
|
+
function sanitizeFilename(name, fallback) {
|
|
6
|
+
const normalized = String(name ?? '').trim().replace(/[\\/:*?"<>|]+/g, '-')
|
|
7
|
+
return normalized || fallback
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function normalizeExportPath(filePath, entryPath) {
|
|
11
|
+
if (filePath !== entryPath) return filePath
|
|
12
|
+
const slashIdx = filePath.lastIndexOf('/')
|
|
13
|
+
const dotIdx = filePath.lastIndexOf('.')
|
|
14
|
+
const hasExt = dotIdx > slashIdx
|
|
15
|
+
return hasExt ? filePath : `${filePath}.md`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function assertUnderRoot(rootDir, absPath) {
|
|
19
|
+
const root = path.resolve(rootDir)
|
|
20
|
+
const abs = path.resolve(absPath)
|
|
21
|
+
const rel = path.relative(root, abs)
|
|
22
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
23
|
+
throw new Error('路径超出工程根目录,拒绝写入')
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 将相对路径拆成安全段,禁止 .. */
|
|
28
|
+
function splitSafeRel(rel) {
|
|
29
|
+
return String(rel ?? '')
|
|
30
|
+
.split(/[/\\]+/)
|
|
31
|
+
.map(s => s.trim())
|
|
32
|
+
.filter(Boolean)
|
|
33
|
+
.map(seg => {
|
|
34
|
+
if (seg === '..' || seg === '.') throw new Error('非法相对路径')
|
|
35
|
+
if (seg.includes('/') || seg.includes('\\')) throw new Error('非法路径段')
|
|
36
|
+
return seg
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function resolveUnderRoot(rootDir, ...segments) {
|
|
41
|
+
let cur = path.resolve(rootDir)
|
|
42
|
+
for (const seg of segments) {
|
|
43
|
+
if (!seg) continue
|
|
44
|
+
cur = path.join(cur, seg)
|
|
45
|
+
assertUnderRoot(rootDir, cur)
|
|
46
|
+
}
|
|
47
|
+
return cur
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function buildMcpMeta(asset) {
|
|
51
|
+
const lines = [
|
|
52
|
+
`# ${asset.name ?? 'MCP'}`,
|
|
53
|
+
'',
|
|
54
|
+
asset.description ? `## 简介\n\n${asset.description}\n` : '',
|
|
55
|
+
asset.remark ? `## 备注\n\n${asset.remark}\n` : '',
|
|
56
|
+
`版本: ${asset.version ?? '-'}`,
|
|
57
|
+
`类型: ${asset.type ?? '-'}`,
|
|
58
|
+
]
|
|
59
|
+
return lines.filter(Boolean).join('\n')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function safeFileRel(rel) {
|
|
63
|
+
return String(rel)
|
|
64
|
+
.split(/[/\\]+/)
|
|
65
|
+
.map(s => s.trim())
|
|
66
|
+
.filter(Boolean)
|
|
67
|
+
.map(seg => {
|
|
68
|
+
if (seg === '..' || seg === '.') throw new Error('非法文件路径')
|
|
69
|
+
return seg
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 与工程习惯一致:规则 → rules,技能 → skills,MCP → mcps */
|
|
74
|
+
function typeFolder(assetType) {
|
|
75
|
+
if (assetType === 'rule') return 'rules'
|
|
76
|
+
if (assetType === 'skill') return 'skills'
|
|
77
|
+
if (assetType === 'mcp') return 'mcps'
|
|
78
|
+
throw new Error(`不支持的 assetType: ${assetType}`)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 将资产详情写入工作区目录(与前端导出语义接近)。
|
|
83
|
+
* 每次写入前会删除目标目录及同名的 `.zip`(若有),不做本地/远端版本比对,按本次拉取内容整包覆盖。
|
|
84
|
+
* rule/skill 的 bundle 仅展开为目录,不生成 zip。
|
|
85
|
+
* @returns {{ paths: string[], rootDir: string, format: string }}
|
|
86
|
+
*/
|
|
87
|
+
export async function exportAssetToWorkspace(asset, workspaceRoot, relativeDir) {
|
|
88
|
+
const root = path.resolve(workspaceRoot.trim())
|
|
89
|
+
assertUnderRoot(root, root)
|
|
90
|
+
|
|
91
|
+
/** 固定落在工作区 `.cursor/` 下;relativeDir 为其下的可选子目录(多级用 /) */
|
|
92
|
+
let dirParts = splitSafeRel(relativeDir ?? '')
|
|
93
|
+
|
|
94
|
+
const assetType = asset.assetType
|
|
95
|
+
const baseName = sanitizeFilename(asset.name, `${assetType}-${asset.id}`)
|
|
96
|
+
const tf = typeFolder(assetType)
|
|
97
|
+
const destRoot = resolveUnderRoot(root, '.cursor', ...dirParts, tf, baseName)
|
|
98
|
+
const siblingZipPath = resolveUnderRoot(root, '.cursor', ...dirParts, tf, `${baseName}.zip`)
|
|
99
|
+
|
|
100
|
+
if (assetType === 'rule' || assetType === 'skill') {
|
|
101
|
+
const defaultEntry = assetType === 'rule' ? 'RULE' : 'SKILL'
|
|
102
|
+
const content = asset.content ?? ''
|
|
103
|
+
const parsed = parseAssetBundle(content, defaultEntry)
|
|
104
|
+
|
|
105
|
+
await fs.rm(destRoot, { recursive: true, force: true })
|
|
106
|
+
await fs.rm(siblingZipPath, { recursive: true, force: true })
|
|
107
|
+
|
|
108
|
+
await fs.mkdir(destRoot, { recursive: true })
|
|
109
|
+
|
|
110
|
+
if (!parsed.bundled) {
|
|
111
|
+
const filePath = resolveUnderRoot(root, '.cursor', ...dirParts, tf, baseName, `${baseName}.md`)
|
|
112
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
|
113
|
+
await fs.writeFile(filePath, content, 'utf8')
|
|
114
|
+
return { paths: [filePath], rootDir: destRoot, format: 'single-markdown' }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (const folder of parsed.folders) {
|
|
118
|
+
if (!folder.trim()) continue
|
|
119
|
+
const parts = safeFileRel(folder)
|
|
120
|
+
const dir = resolveUnderRoot(root, '.cursor', ...dirParts, tf, baseName, ...parts)
|
|
121
|
+
await fs.mkdir(dir, { recursive: true })
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const written = []
|
|
125
|
+
for (const file of parsed.files) {
|
|
126
|
+
if (!file.path.trim()) continue
|
|
127
|
+
const rel = normalizeExportPath(file.path, parsed.entryPath)
|
|
128
|
+
const fparts = safeFileRel(rel)
|
|
129
|
+
const filePath = resolveUnderRoot(root, '.cursor', ...dirParts, tf, baseName, ...fparts)
|
|
130
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
|
131
|
+
await fs.writeFile(filePath, file.content ?? '', 'utf8')
|
|
132
|
+
written.push(filePath)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { paths: written, rootDir: destRoot, format: 'bundle' }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (assetType === 'mcp') {
|
|
139
|
+
await fs.rm(destRoot, { recursive: true, force: true })
|
|
140
|
+
await fs.rm(siblingZipPath, { recursive: true, force: true })
|
|
141
|
+
await fs.mkdir(destRoot, { recursive: true })
|
|
142
|
+
const paths = []
|
|
143
|
+
const p1 = resolveUnderRoot(root, '.cursor', ...dirParts, tf, baseName, 'config.json')
|
|
144
|
+
const p2 = resolveUnderRoot(root, '.cursor', ...dirParts, tf, baseName, 'tools.json')
|
|
145
|
+
const p3 = resolveUnderRoot(root, '.cursor', ...dirParts, tf, baseName, 'meta.md')
|
|
146
|
+
await fs.writeFile(p1, asset.configJson ?? '{}', 'utf8')
|
|
147
|
+
paths.push(p1)
|
|
148
|
+
await fs.writeFile(p2, asset.toolsJson ?? '[]', 'utf8')
|
|
149
|
+
paths.push(p2)
|
|
150
|
+
await fs.writeFile(p3, buildMcpMeta(asset), 'utf8')
|
|
151
|
+
paths.push(p3)
|
|
152
|
+
return { paths, rootDir: destRoot, format: 'mcp-json' }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
throw new Error(`不支持的 assetType: ${assetType}`)
|
|
156
|
+
}
|