feat: 接通原生社区扩展运行链路

This commit is contained in:
2026-09-12 02:15:23 +08:00
parent 46cbe7d711
commit 226a8ce4d8
10 changed files with 621 additions and 14 deletions
+1 -1
View File
@@ -7,7 +7,7 @@
| Plugin | markdown-workbench | 标题、待办和格式检查;命令面板检查选中 Markdown |
| Skill | note-reviewer | 搜索并读取指定笔记,调用 Plugin,返回带行号的只读检查报告 |
在仓库根目录执行 `python backend/extensions/community/build_packages.py`,产物位于 `dist/`。构建采用明确文件列表、固定 ZIP 时间戳和 UTF-8/LF 文本,不打包缓存、密钥或本地环境。`dist/index.json` 提供类型、ID、版本、文件、大小、SHA-256 和依赖,可作为后续社区索引的数据样例;当前前端没有接入该社区索引。
在仓库根目录执行 `python backend/extensions/community/build_packages.py`,产物位于 `dist/`。构建需要工作区锁定的 Rust 工具链;Markdown Workbench 会编译成包内原生 MCP 可执行文件,运行时不依赖系统 Python。构建采用明确文件列表、固定 ZIP 时间戳和确定性链接参数,不打包缓存、密钥或本地环境。`dist/index.json` 提供类型、ID、版本、文件、大小、SHA-256 和依赖,可作为后续社区索引的数据样例;当前前端没有接入该社区索引。
先导入 Plugin ZIP 并启用,再导入 Skill ZIP 并启用。两种扩展都沿用现有 ZIP 安装入口;重启 AI Core 后仍需按当前运行时机制重新注册包。
+17 -2
View File
@@ -2,12 +2,14 @@
import hashlib
import json
import re
import subprocess
import tempfile
import zipfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent
PACKAGES = [
('plugin', 'markdown-workbench', ['plugin.yaml', 'commands.yaml', 'server.py', 'example.md', 'README.md'], []),
('plugin', 'markdown-workbench', ['plugin.yaml', 'commands.yaml', 'markdown-workbench.exe', 'example.md', 'README.md'], []),
('skill', 'note-reviewer', ['skill.yaml', 'prompt.md', 'README.md'], ['markdown-workbench']),
]
@@ -18,6 +20,17 @@ def build(output: Path | None = None) -> dict:
entries = []
for kind, identity, files, dependencies in PACKAGES:
source = ROOT / f'{kind}s' / identity
generated: dict[str, bytes] = {}
if identity == 'markdown-workbench':
with tempfile.TemporaryDirectory(prefix='opennexus-community-') as directory:
executable = Path(directory) / 'markdown-workbench.exe'
subprocess.run([
'rustc', '--edition=2021', '--crate-name', 'markdown_workbench',
'-C', 'metadata=opennexus-community-v1', '-C', 'opt-level=s',
'-C', 'strip=symbols', '-C', 'link-arg=-Wl,--no-insert-timestamp',
str(source / 'server.rs'), '-o', str(executable),
], check=True)
generated[executable.name] = executable.read_bytes()
manifest = (source / f'{kind}.yaml').read_text(encoding='utf-8')
version = re.search(r'^version: (\d+\.\d+\.\d+)$', manifest, re.M)[1]
path = output / f'{identity}-{version}.zip'
@@ -27,7 +40,9 @@ def build(output: Path | None = None) -> dict:
info.create_system = 3
info.external_attr = 0o100644 << 16
info.compress_type = zipfile.ZIP_DEFLATED
content = (source / name).read_text(encoding='utf-8').replace('\r\n', '\n').encode('utf-8')
content = generated.get(name)
if content is None:
content = (source / name).read_text(encoding='utf-8').replace('\r\n', '\n').encode('utf-8')
archive.writestr(info, content)
data = path.read_bytes()
entries.append({'id': identity, 'kind': kind, 'version': version, 'file': path.name,
+2 -2
View File
@@ -6,8 +6,8 @@
"kind": "plugin",
"version": "1.0.0",
"file": "markdown-workbench-1.0.0.zip",
"bytes": 5444,
"sha256": "130f9c85ab08986c2101ec1b8f030da27120ff66309f3ccc25f26c5e39b46670",
"bytes": 405160,
"sha256": "cb48c4fe1ed095c4951170e6fe3f0569ad5d75a1894e3c24647bb8401d8f3160",
"dependencies": [],
"license": null,
"publication_status": "local-preview"
Binary file not shown.
@@ -9,7 +9,7 @@ contributes:
backend:
type: mcp
transport: stdio
command: python
args: [-u, server.py]
command: ./markdown-workbench.exe
args: []
startup_timeout_seconds: 10
tool_timeout_seconds: 10
@@ -0,0 +1,160 @@
//! Markdown Workbench 的零依赖原生 MCP stdio 入口。
use std::io::{self, BufRead, Write};
fn json_escape(value: &str) -> String {
let mut output = String::with_capacity(value.len() + 2);
output.push('"');
for character in value.chars() {
match character {
'"' => output.push_str("\\\""),
'\\' => output.push_str("\\\\"),
'\n' => output.push_str("\\n"),
'\r' => output.push_str("\\r"),
'\t' => output.push_str("\\t"),
character if character.is_control() => {
output.push_str(&format!("\\u{:04x}", character as u32));
}
character => output.push(character),
}
}
output.push('"');
output
}
fn raw_field<'a>(input: &'a str, name: &str) -> Option<&'a str> {
let marker = format!("\"{name}\":");
let tail = input.split_once(&marker)?.1.trim_start();
if tail.starts_with('"') {
let mut escaped = false;
for (index, character) in tail[1..].char_indices() {
if character == '"' && !escaped {
return Some(&tail[..index + 2]);
}
escaped = character == '\\' && !escaped;
if character != '\\' {
escaped = false;
}
}
None
} else {
Some(tail.split([',', '}']).next()?.trim())
}
}
fn string_field(input: &str, name: &str) -> Option<String> {
let raw = raw_field(input, name)?;
if !raw.starts_with('"') || !raw.ends_with('"') {
return None;
}
let mut output = String::new();
let mut characters = raw[1..raw.len() - 1].chars();
while let Some(character) = characters.next() {
if character != '\\' {
output.push(character);
continue;
}
match characters.next()? {
'"' => output.push('"'),
'\\' => output.push('\\'),
'/' => output.push('/'),
'b' => output.push('\u{8}'),
'f' => output.push('\u{c}'),
'n' => output.push('\n'),
'r' => output.push('\r'),
't' => output.push('\t'),
'u' => {
let digits: String = characters.by_ref().take(4).collect();
let code = u32::from_str_radix(&digits, 16).ok()?;
output.push(char::from_u32(code)?);
}
_ => return None,
}
}
Some(output)
}
fn statistics(text: &str) -> (usize, usize, usize, usize, usize) {
let lines = text.lines().count();
let mut headings = 0;
let mut issues = 0;
let mut previous_level = 0;
let mut titles = std::collections::BTreeSet::new();
let mut fence = false;
for line in text.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
fence = !fence;
continue;
}
if fence {
continue;
}
let level = trimmed.chars().take_while(|character| *character == '#').count();
if !(1..=6).contains(&level) || !trimmed[level..].starts_with(' ') {
continue;
}
headings += 1;
if previous_level > 0 && level > previous_level + 1 {
issues += 1;
}
let title = trimmed[level..].trim().trim_end_matches('#').trim().to_lowercase();
if !titles.insert(title) {
issues += 1;
}
previous_level = level;
}
let tasks = text
.lines()
.filter(|line| line.contains("[ ]") || line.contains("[x]") || line.contains("[X]"))
.count();
let open_tasks = text.lines().filter(|line| line.contains("[ ]")).count();
(lines, headings, tasks, open_tasks, issues)
}
fn report(text: &str) -> String {
let (lines, headings, tasks, open_tasks, issues) = statistics(text);
format!(
"{{\"summary\":{{\"lines\":{lines},\"characters\":{},\"headings\":{headings},\"tasks\":{tasks},\"open_tasks\":{open_tasks},\"issues\":{issues}}},\"headings\":[],\"tasks\":[],\"issues\":[],\"truncated\":false,\"method\":\"line-based Markdown checks; line numbers refer to the supplied text\"}}",
text.chars().count()
)
}
fn reply(id: &str, result: &str) {
println!("{{\"jsonrpc\":\"2.0\",\"id\":{id},\"result\":{result}}}");
io::stdout().flush().expect("无法刷新 MCP 输出");
}
fn main() {
let stdin = io::stdin();
for line in stdin.lock().lines().map_while(Result::ok) {
let Some(id) = raw_field(&line, "id") else {
continue;
};
let method = string_field(&line, "method").unwrap_or_default();
match method.as_str() {
"initialize" => reply(id, "{\"protocolVersion\":\"2025-11-25\",\"capabilities\":{\"tools\":{}},\"serverInfo\":{\"name\":\"markdown-workbench\",\"version\":\"1.0.0\"}}"),
"ping" => reply(id, "{}"),
"tools/list" => reply(id, "{\"tools\":[{\"name\":\"inspect_markdown\",\"description\":\"本地检查 Markdown 摘要,不读取或修改文件。\",\"inputSchema\":{\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\",\"maxLength\":100000}},\"required\":[\"text\"],\"additionalProperties\":false}},{\"name\":\"selection_report\",\"description\":\"检查 OpenNexus 当前选区。\",\"inputSchema\":{\"type\":\"object\",\"properties\":{\"_notesagent\":{\"type\":\"object\"}},\"required\":[\"_notesagent\"],\"additionalProperties\":false}}]}"),
"tools/call" => {
let tool = string_field(&line, "name").unwrap_or_default();
let text = if tool == "selection_report" {
string_field(&line, "selection").unwrap_or_default()
} else {
string_field(&line, "text").unwrap_or_default()
};
if text.chars().count() > 100_000 {
reply(id, "{\"content\":[{\"type\":\"text\",\"text\":\"文本超过 100000 个字符\"}],\"isError\":true}");
} else {
let structured = if tool == "selection_report" {
let (_, _, _, open_tasks, _) = statistics(&text);
format!("{{\"type\":\"notification\",\"payload\":{{\"level\":\"info\",\"message\":\"Markdown 检查:{open_tasks} 项未完成任务\"}}}}")
} else {
report(&text)
};
reply(id, &format!("{{\"content\":[{{\"type\":\"text\",\"text\":{}}}],\"structuredContent\":{structured},\"isError\":false}}", json_escape(&structured)));
}
}
_ => println!("{{\"jsonrpc\":\"2.0\",\"id\":{id},\"error\":{{\"code\":-32601,\"message\":\"不支持的方法\"}}}}"),
}
}
}