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}