Skip to main content

douyin_cli/
mcp.rs

1use std::collections::HashMap;
2use std::io::{self, BufRead, Write};
3
4use serde_json::{json, Map, Value};
5
6use crate::err;
7use crate::insights::{self, TextRecord};
8use crate::openapi::{im_message_body, OpenApiClient, RequestSpec};
9use crate::settings;
10
11const PROTOCOL_VERSION: &str = "2025-11-25";
12const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
13
14pub fn run_stdio() -> Result<(), String> {
15    let stdin = io::stdin();
16    let mut stdout = io::stdout().lock();
17    for line in stdin.lock().lines() {
18        let line = line.map_err(err)?;
19        if line.trim().is_empty() {
20            continue;
21        }
22        let request: Value = match serde_json::from_str(&line) {
23            Ok(value) => value,
24            Err(error) => {
25                write_message(
26                    &mut stdout,
27                    &error_response(Value::Null, -32700, &format!("Parse error: {error}")),
28                )?;
29                continue;
30            }
31        };
32        if let Some(response) = handle_message(&request) {
33            write_message(&mut stdout, &response)?;
34        }
35    }
36    Ok(())
37}
38
39pub fn handle_message(request: &Value) -> Option<Value> {
40    if let Some(messages) = request.as_array() {
41        let responses: Vec<_> = messages.iter().filter_map(handle_message).collect();
42        return (!responses.is_empty()).then_some(Value::Array(responses));
43    }
44    let id = request.get("id").cloned()?;
45    let method = request.get("method").and_then(Value::as_str);
46    let result = match method {
47        Some("initialize") => Ok(initialize(request)),
48        Some("ping") => Ok(json!({})),
49        Some("tools/list") => Ok(json!({"tools": tools()})),
50        Some("tools/call") => call_tool(request),
51        Some(method) => {
52            return Some(error_response(
53                id,
54                -32601,
55                &format!("Method not found: {method}"),
56            ));
57        }
58        None => {
59            return Some(error_response(
60                id,
61                -32600,
62                "Invalid Request: missing method",
63            ));
64        }
65    };
66    Some(match result {
67        Ok(result) => json!({"jsonrpc": "2.0", "id": id, "result": result}),
68        Err(ToolError::Unknown(name)) => {
69            error_response(id, -32602, &format!("Unknown tool: {name}"))
70        }
71        Err(ToolError::Execution(message)) => json!({
72            "jsonrpc": "2.0",
73            "id": id,
74            "result": {
75                "content": [{"type": "text", "text": message}],
76                "isError": true
77            }
78        }),
79    })
80}
81
82fn initialize(request: &Value) -> Value {
83    let requested = request
84        .pointer("/params/protocolVersion")
85        .and_then(Value::as_str)
86        .unwrap_or(PROTOCOL_VERSION);
87    let version = match requested {
88        "2024-11-05" | "2025-03-26" | "2025-06-18" | "2025-11-25" => requested,
89        _ => PROTOCOL_VERSION,
90    };
91    json!({
92        "protocolVersion": version,
93        "capabilities": {"tools": {"listChanged": false}},
94        "serverInfo": {"name": "douyin", "version": SERVER_VERSION},
95        "instructions": "抖音开放平台 OpenAPI MCP 服务器。默认读取 douyin auth 保存的 access_token/open_id,也可以在工具参数中显式传入。"
96    })
97}
98
99fn tools() -> Vec<Value> {
100    vec![
101        tool("hot_words", "离线发现输入文本中的热词。基于可解释的频次启发式,不表示理解真实语义。", insights_schema(), true),
102        tool("hot_memes", "离线发现重复短句、口头禅、emoji 与固定表达。", insights_schema(), true),
103        tool("demand_discovery", "离线提取包含购买、求助、功能或问题意图信号的原句。", insights_schema(), true),
104        tool("auth_status", "查看本机是否已保存抖音开放平台授权信息。", json!({"type":"object","properties":{}}), true),
105        tool("userinfo", "获取官方授权用户信息。", auth_schema(json!({})), true),
106        tool("comment_list", "获取官方接口中的视频评论列表。", auth_schema(json!({
107            "item_id":{"type":"string"}, "cursor":{"type":"integer","default":0,"minimum":0},
108            "count":{"type":"integer","default":20,"minimum":1,"maximum":20},
109            "sort_type":{"type":"integer","enum":[0,1,2]}
110        })).with_required(&["item_id"]), true),
111        tool("comment_replies", "获取官方接口中的评论回复列表。", auth_schema(json!({
112            "item_id":{"type":"string"}, "comment_id":{"type":"string"}, "cursor":{"type":"integer","default":0,"minimum":0},
113            "count":{"type":"integer","default":20,"minimum":1,"maximum":20},
114            "sort_type":{"type":"integer","enum":[0,1,2]}
115        })).with_required(&["item_id", "comment_id"]), true),
116        tool("comment_reply", "通过官方 OpenAPI 回复视频或评论。", auth_schema(json!({
117            "item_id":{"type":"string"}, "content":{"type":"string","minLength":1,"maxLength":100}, "comment_id":{"type":"string"}
118        })).with_required(&["item_id", "content"]), false),
119        tool("im_message_send", "通过官方私信接口回复或首次进入会话。", auth_schema(json!({
120            "to_user_id":{"type":"string"},
121            "scene":{"type":"string","enum":["im_reply_msg","im_enter_direct_msg"],"default":"im_reply_msg"},
122            "msg_id":{"type":"string"}, "conversation_id":{"type":"string"},
123            "message_type":{"type":"string","enum":["text","image","video"],"default":"text"},
124            "text":{"type":"string","minLength":1,"maxLength":1000}, "media_id":{"type":"string"}, "item_id":{"type":"string"}
125        })).with_required(&["to_user_id", "msg_id", "conversation_id"]), false),
126        tool("openapi_request", "调用任意官方 OpenAPI 路径。", json!({
127            "type":"object",
128            "properties":{
129                "method":{"type":"string"}, "path":{"type":"string"}, "token":{"type":"string"},
130                "params":{"type":"object","additionalProperties":{"type":"string"}},
131                "json_body":{"type":["object","array"]}, "form":{"type":"object","additionalProperties":{"type":"string"}},
132                "headers":{"type":"object","additionalProperties":{"type":"string"}}
133            },
134            "required":["method","path"]
135        }), false),
136    ]
137}
138
139fn insights_schema() -> Value {
140    json!({
141        "type":"object",
142        "properties":{
143            "texts":{"type":"array","items":{"type":"string"}},
144            "top":{"type":"integer","default":20,"minimum":0},
145            "min_count":{"type":"integer","default":2,"minimum":1}
146        },
147        "required":["texts"]
148    })
149}
150
151fn tool(name: &str, description: &str, schema: Value, read_only: bool) -> Value {
152    json!({
153        "name": name,
154        "description": description,
155        "inputSchema": schema,
156        "annotations": {
157            "readOnlyHint": read_only,
158            "destructiveHint": false,
159            "idempotentHint": read_only,
160            "openWorldHint": true
161        }
162    })
163}
164
165trait SchemaExt {
166    fn with_required(self, required: &[&str]) -> Value;
167}
168
169impl SchemaExt for Value {
170    fn with_required(mut self, required: &[&str]) -> Value {
171        self["required"] = json!(required);
172        self
173    }
174}
175
176fn auth_schema(extra: Value) -> Value {
177    let mut properties = extra.as_object().cloned().unwrap_or_default();
178    properties.insert("token".to_owned(), json!({"type":"string"}));
179    properties.insert("open_id".to_owned(), json!({"type":"string"}));
180    json!({"type":"object", "properties": properties})
181}
182
183fn call_tool(request: &Value) -> Result<Value, ToolError> {
184    let name = request
185        .pointer("/params/name")
186        .and_then(Value::as_str)
187        .ok_or_else(|| ToolError::Execution("缺少工具名称".to_owned()))?;
188    let args = request
189        .pointer("/params/arguments")
190        .and_then(Value::as_object)
191        .cloned()
192        .unwrap_or_default();
193    let result = execute_tool(name, &args)?;
194    let text = serde_json::to_string(&result).map_err(execution)?;
195    Ok(json!({
196        "content": [{"type": "text", "text": text}],
197        "structuredContent": result,
198        "isError": false
199    }))
200}
201
202fn execute_tool(name: &str, args: &Map<String, Value>) -> Result<Value, ToolError> {
203    if matches!(name, "hot_words" | "hot_memes" | "demand_discovery") {
204        return execute_insights_tool(name, args);
205    }
206    if name == "auth_status" {
207        let saved = saved_openapi()?;
208        return Ok(json!({
209            "authorized": saved_string(&saved, "accessToken").is_some() && saved_string(&saved, "openId").is_some(),
210            "client_key_saved": saved_string(&saved, "clientKey").is_some(),
211            "open_id": saved_string(&saved, "openId"),
212            "scopes": saved.get("scopes").cloned().unwrap_or_else(|| json!([])),
213            "expires_in": saved.get("expiresIn").cloned().unwrap_or_else(|| json!(0))
214        }));
215    }
216    let client = OpenApiClient::new().map_err(ToolError::Execution)?;
217    match name {
218        "userinfo" => {
219            let (token, open_id) = resolve_auth(args)?;
220            request(
221                &client,
222                "GET",
223                "/oauth/userinfo/",
224                &token,
225                Some(HashMap::from([("open_id".to_owned(), open_id)])),
226                None,
227            )
228        }
229        "comment_list" => {
230            let (token, open_id) = resolve_auth(args)?;
231            let mut params = HashMap::from([
232                ("open_id".to_owned(), open_id),
233                ("item_id".to_owned(), required_string(args, "item_id")?),
234                (
235                    "cursor".to_owned(),
236                    bounded_integer(args, "cursor", 0, 0, i64::MAX)?.to_string(),
237                ),
238                (
239                    "count".to_owned(),
240                    bounded_integer(args, "count", 20, 1, 20)?.to_string(),
241                ),
242            ]);
243            insert_optional_integer(args, &mut params, "sort_type", 0, 2)?;
244            request(
245                &client,
246                "GET",
247                "/item/comment/list/",
248                &token,
249                Some(params),
250                None,
251            )
252        }
253        "comment_replies" => {
254            let (token, open_id) = resolve_auth(args)?;
255            let mut params = HashMap::from([
256                ("open_id".to_owned(), open_id),
257                ("item_id".to_owned(), required_string(args, "item_id")?),
258                (
259                    "comment_id".to_owned(),
260                    required_string(args, "comment_id")?,
261                ),
262                (
263                    "cursor".to_owned(),
264                    bounded_integer(args, "cursor", 0, 0, i64::MAX)?.to_string(),
265                ),
266                (
267                    "count".to_owned(),
268                    bounded_integer(args, "count", 20, 1, 20)?.to_string(),
269                ),
270            ]);
271            insert_optional_integer(args, &mut params, "sort_type", 0, 2)?;
272            request(
273                &client,
274                "GET",
275                "/item/comment/reply/list/",
276                &token,
277                Some(params),
278                None,
279            )
280        }
281        "comment_reply" => {
282            let (token, open_id) = resolve_auth(args)?;
283            let content = required_string(args, "content")?;
284            validate_text(&content, "content", 100, false)?;
285            let mut body = Map::from_iter([
286                (
287                    "item_id".to_owned(),
288                    json!(required_string(args, "item_id")?),
289                ),
290                ("content".to_owned(), json!(content)),
291            ]);
292            if let Some(value) = optional_string(args, "comment_id") {
293                body.insert("comment_id".to_owned(), json!(value));
294            }
295            request(
296                &client,
297                "POST",
298                "/item/comment/reply/",
299                &token,
300                Some(HashMap::from([("open_id".to_owned(), open_id)])),
301                Some(Value::Object(body)),
302            )
303        }
304        "im_message_send" => {
305            let (token, open_id) = resolve_auth(args)?;
306            let message_type =
307                optional_string(args, "message_type").unwrap_or_else(|| "text".to_owned());
308            let (code, kind, key, source, error) = match message_type.as_str() {
309                "text" => (1, "text", "text", "text", "message_type=text 需要 text"),
310                "image" => (
311                    2,
312                    "image",
313                    "media_id",
314                    "media_id",
315                    "message_type=image 需要 media_id",
316                ),
317                "video" => (
318                    3,
319                    "video",
320                    "item_id",
321                    "item_id",
322                    "message_type=video 需要 item_id",
323                ),
324                value => return Err(ToolError::Execution(format!("不支持的私信类型: {value}"))),
325            };
326            let value = optional_string(args, source)
327                .ok_or_else(|| ToolError::Execution(error.to_owned()))?;
328            if message_type == "text" {
329                validate_text(&value, "text", 1_000, true)?;
330            }
331            let scene = optional_string(args, "scene").unwrap_or_else(|| "im_reply_msg".to_owned());
332            if !matches!(scene.as_str(), "im_reply_msg" | "im_enter_direct_msg") {
333                return Err(ToolError::Execution(format!("不支持的私信场景: {scene}")));
334            }
335            let content = Value::Object(Map::from_iter([
336                ("msg_type".to_owned(), json!(code)),
337                (
338                    kind.to_owned(),
339                    Value::Object(Map::from_iter([(key.to_owned(), json!(value))])),
340                ),
341            ]));
342            let body = im_message_body(
343                &required_string(args, "to_user_id")?,
344                &scene,
345                &required_string(args, "msg_id")?,
346                &required_string(args, "conversation_id")?,
347                content,
348            );
349            request(
350                &client,
351                "POST",
352                "/im/send/msg/",
353                &token,
354                Some(HashMap::from([("open_id".to_owned(), open_id)])),
355                Some(body),
356            )
357        }
358        "openapi_request" => {
359            let saved = saved_openapi()?;
360            let token = optional_string(args, "token")
361                .or_else(|| saved_string(&saved, "accessToken"))
362                .ok_or_else(|| {
363                    ToolError::Execution(
364                        "调用 OpenAPI 需要 access-token 或 client-token".to_owned(),
365                    )
366                })?;
367            client
368                .request(RequestSpec {
369                    method: &required_string(args, "method")?,
370                    path: &required_string(args, "path")?,
371                    token: Some(&token),
372                    params: string_map(args, "params")?,
373                    json_body: args.get("json_body").cloned(),
374                    form: string_map(args, "form")?,
375                    headers: string_map(args, "headers")?,
376                    auth_required: true,
377                })
378                .map_err(ToolError::Execution)
379        }
380        value => Err(ToolError::Unknown(value.to_owned())),
381    }
382}
383
384fn execute_insights_tool(name: &str, args: &Map<String, Value>) -> Result<Value, ToolError> {
385    let texts = args
386        .get("texts")
387        .and_then(Value::as_array)
388        .ok_or_else(|| ToolError::Execution("texts 必须是字符串数组".to_owned()))?;
389    let records = texts
390        .iter()
391        .map(|value| {
392            value
393                .as_str()
394                .map(|text| TextRecord::new(text, None))
395                .ok_or_else(|| ToolError::Execution("texts 必须是字符串数组".to_owned()))
396        })
397        .collect::<Result<Vec<_>, _>>()?;
398    let top = non_negative_integer(args, "top", 20)?;
399    let min_count = positive_integer(args, "min_count", 2)?;
400    let result = insights::analyze(&records, top, min_count);
401    let key = match name {
402        "hot_words" => "hot_words",
403        "hot_memes" => "hot_memes",
404        "demand_discovery" => "demands",
405        _ => return Err(ToolError::Unknown(name.to_owned())),
406    };
407    Ok(json!({
408        "input_count": result["input_count"],
409        key: result[key]
410    }))
411}
412
413fn request(
414    client: &OpenApiClient,
415    method: &str,
416    path: &str,
417    token: &str,
418    params: Option<HashMap<String, String>>,
419    json_body: Option<Value>,
420) -> Result<Value, ToolError> {
421    client
422        .request(RequestSpec {
423            method,
424            path,
425            token: Some(token),
426            params,
427            json_body,
428            auth_required: true,
429            ..RequestSpec::default()
430        })
431        .map_err(ToolError::Execution)
432}
433
434fn resolve_auth(args: &Map<String, Value>) -> Result<(String, String), ToolError> {
435    let saved = saved_openapi()?;
436    let token = optional_string(args, "token")
437        .or_else(|| saved_string(&saved, "accessToken"))
438        .ok_or_else(|| {
439            ToolError::Execution("缺少 access_token,请先运行 douyin auth login".to_owned())
440        })?;
441    let open_id = optional_string(args, "open_id")
442        .or_else(|| saved_string(&saved, "openId"))
443        .ok_or_else(|| {
444            ToolError::Execution("缺少 open_id,请先运行 douyin auth login".to_owned())
445        })?;
446    Ok((token, open_id))
447}
448
449fn saved_openapi() -> Result<Map<String, Value>, ToolError> {
450    settings::load()
451        .map(|data| settings::openapi(&data))
452        .map_err(execution)
453}
454
455fn required_string(args: &Map<String, Value>, key: &str) -> Result<String, ToolError> {
456    optional_string(args, key).ok_or_else(|| ToolError::Execution(format!("缺少必填参数: {key}")))
457}
458
459fn optional_string(args: &Map<String, Value>, key: &str) -> Option<String> {
460    args.get(key)
461        .and_then(Value::as_str)
462        .filter(|value| !value.is_empty())
463        .map(str::to_owned)
464}
465
466fn saved_string(args: &Map<String, Value>, key: &str) -> Option<String> {
467    optional_string(args, key)
468}
469
470fn bounded_integer(
471    args: &Map<String, Value>,
472    key: &str,
473    default: i64,
474    minimum: i64,
475    maximum: i64,
476) -> Result<i64, ToolError> {
477    let value = args
478        .get(key)
479        .map(|value| {
480            value
481                .as_i64()
482                .ok_or_else(|| ToolError::Execution(format!("{key} 必须是整数")))
483        })
484        .transpose()?
485        .unwrap_or(default);
486    if !(minimum..=maximum).contains(&value) {
487        return Err(ToolError::Execution(format!(
488            "{key} 必须在 {minimum}..={maximum} 范围内"
489        )));
490    }
491    Ok(value)
492}
493
494fn insert_optional_integer(
495    args: &Map<String, Value>,
496    output: &mut HashMap<String, String>,
497    key: &str,
498    minimum: i64,
499    maximum: i64,
500) -> Result<(), ToolError> {
501    if args.contains_key(key) {
502        output.insert(
503            key.to_owned(),
504            bounded_integer(args, key, minimum, minimum, maximum)?.to_string(),
505        );
506    }
507    Ok(())
508}
509
510fn validate_text(
511    value: &str,
512    key: &str,
513    max_chars: usize,
514    forbid_links: bool,
515) -> Result<(), ToolError> {
516    let length = value.chars().count();
517    if length == 0 || length > max_chars {
518        return Err(ToolError::Execution(format!(
519            "{key} 长度必须为 1..={max_chars} 个字符(当前 {length})"
520        )));
521    }
522    if forbid_links && (value.contains("http://") || value.contains("https://")) {
523        return Err(ToolError::Execution(format!("{key} 不能包含链接")));
524    }
525    Ok(())
526}
527
528fn non_negative_integer(
529    args: &Map<String, Value>,
530    key: &str,
531    default: usize,
532) -> Result<usize, ToolError> {
533    let value = args
534        .get(key)
535        .map(|value| {
536            value
537                .as_u64()
538                .ok_or_else(|| ToolError::Execution(format!("{key} 必须是非负整数")))
539        })
540        .transpose()?
541        .unwrap_or(default as u64);
542    usize::try_from(value).map_err(execution)
543}
544
545fn positive_integer(args: &Map<String, Value>, key: &str, default: u64) -> Result<u64, ToolError> {
546    let value = args
547        .get(key)
548        .map(|value| {
549            value
550                .as_u64()
551                .filter(|value| *value > 0)
552                .ok_or_else(|| ToolError::Execution(format!("{key} 必须是正整数")))
553        })
554        .transpose()?
555        .unwrap_or(default);
556    Ok(value)
557}
558
559fn string_map(
560    args: &Map<String, Value>,
561    key: &str,
562) -> Result<Option<HashMap<String, String>>, ToolError> {
563    let Some(value) = args.get(key) else {
564        return Ok(None);
565    };
566    let object = value
567        .as_object()
568        .ok_or_else(|| ToolError::Execution(format!("{key} 必须是对象")))?;
569    object
570        .iter()
571        .map(|(key, value)| {
572            value
573                .as_str()
574                .map(|value| (key.clone(), value.to_owned()))
575                .ok_or_else(|| ToolError::Execution(format!("{key} 的值必须是字符串")))
576        })
577        .collect::<Result<HashMap<_, _>, _>>()
578        .map(Some)
579}
580
581fn execution(error: impl ToString) -> ToolError {
582    ToolError::Execution(error.to_string())
583}
584
585enum ToolError {
586    Unknown(String),
587    Execution(String),
588}
589
590fn error_response(id: Value, code: i64, message: &str) -> Value {
591    json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}})
592}
593
594fn write_message(writer: &mut impl Write, value: &Value) -> Result<(), String> {
595    serde_json::to_writer(&mut *writer, value).map_err(err)?;
596    writer.write_all(b"\n").map_err(err)?;
597    writer.flush().map_err(err)
598}
599
600#[cfg(test)]
601mod tests {
602    use super::handle_message;
603    use crate::test_support::present;
604    use serde_json::json;
605
606    #[test]
607    fn initialize_negotiates_supported_version() {
608        let response = present(handle_message(&json!({
609            "jsonrpc":"2.0", "id":1, "method":"initialize",
610            "params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1"}}
611        })));
612        assert_eq!(response["result"]["protocolVersion"], "2025-11-25");
613        assert_eq!(response["result"]["serverInfo"]["name"], "douyin");
614    }
615
616    #[test]
617    fn tools_list_exposes_openapi_and_offline_insights_tools() {
618        let response = present(handle_message(
619            &json!({"jsonrpc":"2.0","id":2,"method":"tools/list"}),
620        ));
621        let names: Vec<_> = present(response["result"]["tools"].as_array())
622            .iter()
623            .filter_map(|tool| tool["name"].as_str())
624            .collect();
625        for expected in [
626            "auth_status",
627            "userinfo",
628            "comment_list",
629            "comment_replies",
630            "comment_reply",
631            "im_message_send",
632            "openapi_request",
633            "hot_words",
634            "hot_memes",
635            "demand_discovery",
636        ] {
637            assert!(names.contains(&expected));
638        }
639
640        let Some(tools) = response["result"]["tools"].as_array() else {
641            panic!("tools/list result must contain an array");
642        };
643        let Some(direct_message) = tools.iter().find(|tool| tool["name"] == "im_message_send")
644        else {
645            panic!("im_message_send tool must be advertised");
646        };
647        assert_eq!(
648            direct_message["inputSchema"]["required"],
649            json!(["to_user_id", "msg_id", "conversation_id"])
650        );
651        assert_eq!(
652            direct_message["inputSchema"]["properties"]["message_type"]["enum"],
653            json!(["text", "image", "video"])
654        );
655        let Some(comment_list) = tools.iter().find(|tool| tool["name"] == "comment_list") else {
656            panic!("comment_list tool must be advertised");
657        };
658        assert_eq!(
659            comment_list["inputSchema"]["properties"]["count"]["maximum"],
660            20
661        );
662    }
663
664    #[test]
665    fn offline_insights_tool_call_does_not_require_authorization() {
666        let response = present(handle_message(&json!({
667            "jsonrpc":"2.0","id":5,"method":"tools/call","params":{
668                "name":"demand_discovery",
669                "arguments":{"texts":["求链接","求链接"],"top":5,"min_count":2}
670            }
671        })));
672        assert_eq!(
673            response["result"]["structuredContent"]["demands"][0]["text"],
674            "求链接"
675        );
676        assert_eq!(response["result"]["isError"], false);
677    }
678
679    #[test]
680    fn unknown_tool_is_protocol_error() {
681        let response = present(handle_message(&json!({
682            "jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"missing","arguments":{}}
683        })));
684        assert_eq!(response["error"]["code"], -32602);
685    }
686
687    #[test]
688    fn batch_omits_notification_responses() {
689        let response = present(handle_message(&json!([
690            {"jsonrpc":"2.0","method":"notifications/initialized"},
691            {"jsonrpc":"2.0","id":4,"method":"ping"}
692        ])));
693        assert_eq!(present(response.as_array()).len(), 1);
694        assert_eq!(response[0]["id"], 4);
695    }
696}