1pub mod contract;
16mod kernel;
17mod provider;
18mod requests;
19mod server;
20pub mod spec;
21mod state;
22mod tools;
23
24use std::sync::Arc;
25
26use rmcp::transport::stdio;
27use rmcp::ServiceExt;
28
29use tuitbot_core::config::Config;
30use tuitbot_core::llm;
31use tuitbot_core::startup;
32use tuitbot_core::storage;
33use tuitbot_core::x_api::{LocalModeXClient, NullXApiClient, XApiClient, XApiHttpClient};
34
35use server::{
36 AdminMcpServer, ApiReadonlyMcpServer, ReadonlyMcpServer, UtilityReadonlyMcpServer,
37 UtilityWriteMcpServer, WriteMcpServer,
38};
39use state::{AppState, ReadonlyState, SharedReadonlyState};
40use tools::idempotency::IdempotencyStore;
41
42pub use state::Profile;
43pub use tools::manifest::{generate_profile_manifest, ProfileManifest};
44
45pub async fn run_server(config: Config, profile: Profile) -> anyhow::Result<()> {
49 match profile {
50 Profile::Readonly => run_readonly_server(config).await,
51 Profile::ApiReadonly => run_api_readonly_server(config).await,
52 Profile::Write => run_write_server(config).await,
53 Profile::Admin => run_admin_server(config).await,
54 Profile::UtilityReadonly => run_utility_readonly_server(config).await,
55 Profile::UtilityWrite => run_utility_write_server(config).await,
56 }
57}
58
59async fn init_write_state(config: Config) -> anyhow::Result<Arc<AppState>> {
63 let pool = storage::init_db(&config.storage.db_path).await?;
65
66 storage::rate_limits::init_mcp_rate_limit(&pool, config.mcp_policy.max_mutations_per_hour)
68 .await?;
69
70 let llm_provider = match llm::factory::create_provider(&config.llm) {
72 Ok(provider) => {
73 tracing::info!(provider = provider.name(), "LLM provider initialized");
74 Some(provider)
75 }
76 Err(e) => {
77 tracing::warn!(
78 "LLM provider not available: {e}. Content generation tools will be disabled."
79 );
80 None
81 }
82 };
83
84 let backend = provider::parse_backend(&config.x_api.provider_backend);
86 match backend {
87 provider::ProviderBackend::XApi => {
88 tracing::info!(backend = "x_api", "Provider backend: official X API");
89 }
90 provider::ProviderBackend::Scraper => {
91 tracing::warn!(
92 backend = "scraper",
93 allow_mutations = config.x_api.scraper_allow_mutations,
94 "Provider backend: scraper (elevated risk)"
95 );
96 }
97 }
98
99 if backend == provider::ProviderBackend::Scraper {
101 let data_dir = tuitbot_core::startup::data_dir();
102 let client =
103 LocalModeXClient::with_session(config.x_api.scraper_allow_mutations, &data_dir).await;
104
105 let (x_client, authenticated_user_id): (Option<Box<dyn XApiClient>>, Option<String>) =
106 match client.get_me().await {
107 Ok(user) => {
108 tracing::info!(
109 username = %user.username, user_id = %user.id,
110 "Scraper client initialized (write profile)"
111 );
112 (Some(Box::new(client)), Some(user.id))
113 }
114 Err(e) => {
115 tracing::warn!(
116 "Scraper get_me() unavailable: {e}. \
117 Direct X tools will be disabled."
118 );
119 (Some(Box::new(client)), None)
120 }
121 };
122
123 return Ok(Arc::new(AppState {
124 pool,
125 config,
126 llm_provider,
127 x_client,
128 authenticated_user_id,
129 granted_scopes: vec![],
130 idempotency: Arc::new(IdempotencyStore::new()),
131 }));
132 }
133
134 let (x_client, authenticated_user_id, granted_scopes): (
136 Option<Box<dyn XApiClient>>,
137 Option<String>,
138 Vec<String>,
139 ) = match startup::load_tokens_from_file() {
140 Ok(tokens) if !tokens.is_expired() => {
141 let scopes = tokens.scopes.clone();
142 let client = XApiHttpClient::new(tokens.access_token);
143 client.set_pool(pool.clone()).await;
144 match client.get_me().await {
145 Ok(user) => {
146 tracing::info!(
147 username = %user.username,
148 user_id = %user.id,
149 scopes = ?scopes,
150 "X API client initialized"
151 );
152 (Some(Box::new(client)), Some(user.id), scopes)
153 }
154 Err(e) => {
155 tracing::warn!(
156 "X API client created but get_me() failed: {e}. \
157 Direct X tools will be disabled."
158 );
159 (Some(Box::new(client)), None, scopes)
160 }
161 }
162 }
163 Ok(_) => {
164 tracing::warn!(
165 "X API tokens expired. Direct X tools will be disabled. \
166 Run `tuitbot auth` to re-authenticate."
167 );
168 (None, None, vec![])
169 }
170 Err(e) => {
171 tracing::warn!("X API tokens not available: {e}. Direct X tools will be disabled.");
172 (None, None, vec![])
173 }
174 };
175
176 Ok(Arc::new(AppState {
177 pool,
178 config,
179 llm_provider,
180 x_client,
181 authenticated_user_id,
182 granted_scopes,
183 idempotency: Arc::new(IdempotencyStore::new()),
184 }))
185}
186
187async fn run_write_server(config: Config) -> anyhow::Result<()> {
189 let state = init_write_state(config).await?;
190 let pool = state.pool.clone();
191 let server = WriteMcpServer::new(state);
192
193 tracing::info!("Starting Tuitbot MCP server on stdio (write profile)");
194
195 let service = server
196 .serve(stdio())
197 .await
198 .map_err(|e| anyhow::anyhow!("Failed to start MCP server: {e}"))?;
199
200 service.waiting().await?;
201
202 pool.close().await;
204
205 Ok(())
206}
207
208async fn run_admin_server(config: Config) -> anyhow::Result<()> {
210 let state = init_write_state(config).await?;
211 let pool = state.pool.clone();
212 let server = AdminMcpServer::new(state);
213
214 tracing::info!("Starting Tuitbot MCP server on stdio (admin profile)");
215
216 let service = server
217 .serve(stdio())
218 .await
219 .map_err(|e| anyhow::anyhow!("Failed to start MCP server: {e}"))?;
220
221 service.waiting().await?;
222
223 pool.close().await;
225
226 Ok(())
227}
228
229async fn init_readonly_state(
236 config: Config,
237 profile: Profile,
238) -> anyhow::Result<SharedReadonlyState> {
239 let backend = provider::parse_backend(&config.x_api.provider_backend);
241 match backend {
242 provider::ProviderBackend::XApi => {
243 tracing::info!(backend = "x_api", "Provider backend: official X API");
244 }
245 provider::ProviderBackend::Scraper => {
246 tracing::warn!(
247 backend = "scraper",
248 allow_mutations = config.x_api.scraper_allow_mutations,
249 "Provider backend: scraper (elevated risk)"
250 );
251 }
252 }
253
254 if backend == provider::ProviderBackend::Scraper {
256 let data_dir = tuitbot_core::startup::data_dir();
257 let client =
258 LocalModeXClient::with_session(config.x_api.scraper_allow_mutations, &data_dir).await;
259
260 let (authenticated_user_id, x_available) = match client.get_me().await {
261 Ok(user) => {
262 tracing::info!(
263 username = %user.username, user_id = %user.id,
264 profile = %profile,
265 "Scraper client initialized ({profile} profile)"
266 );
267 (user.id, true)
268 }
269 Err(e) => {
270 tracing::warn!(
271 "Scraper get_me() unavailable: {e}. \
272 Non-X tools (get_config, score_tweet) are still available."
273 );
274 (String::new(), false)
275 }
276 };
277
278 return Ok(Arc::new(ReadonlyState {
279 config,
280 x_client: Box::new(client),
281 authenticated_user_id,
282 x_available,
283 }));
284 }
285
286 let (x_client, authenticated_user_id, x_available): (Box<dyn XApiClient>, String, bool) =
288 match startup::load_tokens_from_file() {
289 Ok(tokens) if !tokens.is_expired() => {
290 let client = XApiHttpClient::new(tokens.access_token);
291 match client.get_me().await {
292 Ok(user) => {
293 tracing::info!(
294 username = %user.username,
295 user_id = %user.id,
296 profile = %profile,
297 "X API client initialized ({profile} profile)"
298 );
299 (Box::new(client) as Box<dyn XApiClient>, user.id, true)
300 }
301 Err(e) => {
302 tracing::warn!(
303 "X API get_me() failed: {e}. X tools will return errors. \
304 Non-X tools (get_config, score_tweet) are still available."
305 );
306 (
307 Box::new(client) as Box<dyn XApiClient>,
308 String::new(),
309 false,
310 )
311 }
312 }
313 }
314 Ok(_) => {
315 tracing::warn!(
316 "X API tokens expired for {profile} profile. X tools will be unavailable. \
317 Run `tuitbot auth` to re-authenticate."
318 );
319 (
320 Box::new(NullXApiClient) as Box<dyn XApiClient>,
321 String::new(),
322 false,
323 )
324 }
325 Err(e) => {
326 tracing::warn!(
327 "X API tokens not available for {profile} profile: {e}. \
328 Non-X tools (get_config, score_tweet) are still available."
329 );
330 (
331 Box::new(NullXApiClient) as Box<dyn XApiClient>,
332 String::new(),
333 false,
334 )
335 }
336 };
337
338 Ok(Arc::new(ReadonlyState {
339 config,
340 x_client,
341 authenticated_user_id,
342 x_available,
343 }))
344}
345
346async fn run_readonly_server(config: Config) -> anyhow::Result<()> {
348 let state = init_readonly_state(config, Profile::Readonly).await?;
349 let server = ReadonlyMcpServer::new(state);
350
351 tracing::info!("Starting Tuitbot MCP server on stdio (readonly profile)");
352
353 let service = server
354 .serve(stdio())
355 .await
356 .map_err(|e| anyhow::anyhow!("Failed to start MCP server: {e}"))?;
357
358 service.waiting().await?;
359 Ok(())
360}
361
362async fn run_api_readonly_server(config: Config) -> anyhow::Result<()> {
364 let state = init_readonly_state(config, Profile::ApiReadonly).await?;
365 let server = ApiReadonlyMcpServer::new(state);
366
367 tracing::info!("Starting Tuitbot MCP server on stdio (api-readonly profile)");
368
369 let service = server
370 .serve(stdio())
371 .await
372 .map_err(|e| anyhow::anyhow!("Failed to start MCP server: {e}"))?;
373
374 service.waiting().await?;
375 Ok(())
376}
377
378async fn run_utility_readonly_server(config: Config) -> anyhow::Result<()> {
382 let state = init_readonly_state(config, Profile::UtilityReadonly).await?;
383 let server = UtilityReadonlyMcpServer::new(state);
384
385 tracing::info!("Starting Tuitbot MCP server on stdio (utility-readonly profile)");
386
387 let service = server
388 .serve(stdio())
389 .await
390 .map_err(|e| anyhow::anyhow!("Failed to start MCP server: {e}"))?;
391
392 service.waiting().await?;
393 Ok(())
394}
395
396async fn run_utility_write_server(config: Config) -> anyhow::Result<()> {
398 let state = init_readonly_state(config, Profile::UtilityWrite).await?;
399 let server = UtilityWriteMcpServer::new(state);
400
401 tracing::info!("Starting Tuitbot MCP server on stdio (utility-write profile)");
402
403 let service = server
404 .serve(stdio())
405 .await
406 .map_err(|e| anyhow::anyhow!("Failed to start MCP server: {e}"))?;
407
408 service.waiting().await?;
409 Ok(())
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415
416 #[tokio::test]
420 async fn init_readonly_scraper_no_session_does_not_fail() {
421 let mut config = Config::default();
422 config.x_api.provider_backend = "scraper".to_string();
423
424 let state = init_readonly_state(config, Profile::Readonly).await;
425 assert!(
426 state.is_ok(),
427 "init_readonly_state should succeed with scraper backend even without a session: {:?}",
428 state.err()
429 );
430 }
431
432 #[tokio::test]
435 async fn init_readonly_x_api_no_tokens_does_not_fail() {
436 let config = Config::default(); let state = init_readonly_state(config, Profile::Readonly).await;
439 assert!(
440 state.is_ok(),
441 "init_readonly_state should succeed without tokens (graceful degradation): {:?}",
442 state.err()
443 );
444
445 let state = state.unwrap();
446 assert!(
447 !state.x_available,
448 "x_available should be false when no tokens are present"
449 );
450 }
451
452 #[tokio::test]
454 async fn init_readonly_scraper_api_readonly_profile() {
455 let mut config = Config::default();
456 config.x_api.provider_backend = "scraper".to_string();
457
458 let state = init_readonly_state(config, Profile::ApiReadonly).await;
459 assert!(
460 state.is_ok(),
461 "api-readonly + scraper should not crash: {:?}",
462 state.err()
463 );
464 }
465
466 #[tokio::test]
468 async fn init_readonly_utility_readonly_no_tokens() {
469 let config = Config::default();
470 let state = init_readonly_state(config, Profile::UtilityReadonly).await;
471 assert!(state.is_ok());
472 let state = state.unwrap();
473 assert!(!state.x_available);
474 }
475
476 #[tokio::test]
478 async fn init_readonly_utility_write_no_tokens() {
479 let config = Config::default();
480 let state = init_readonly_state(config, Profile::UtilityWrite).await;
481 assert!(state.is_ok());
482 let state = state.unwrap();
483 assert!(!state.x_available);
484 assert!(state.authenticated_user_id.is_empty());
485 }
486
487 #[tokio::test]
489 async fn init_readonly_scraper_utility_readonly() {
490 let mut config = Config::default();
491 config.x_api.provider_backend = "scraper".to_string();
492
493 let state = init_readonly_state(config, Profile::UtilityReadonly).await;
494 assert!(state.is_ok());
495 }
496
497 #[tokio::test]
499 async fn init_readonly_scraper_utility_write() {
500 let mut config = Config::default();
501 config.x_api.provider_backend = "scraper".to_string();
502
503 let state = init_readonly_state(config, Profile::UtilityWrite).await;
504 assert!(state.is_ok());
505 }
506
507 #[tokio::test]
509 async fn readonly_state_config_accessible() {
510 let mut config = Config::default();
511 config.x_api.provider_backend = "scraper".to_string();
512
513 let state = init_readonly_state(config, Profile::Readonly)
514 .await
515 .unwrap();
516 assert_eq!(state.config.x_api.provider_backend, "scraper");
518 }
519
520 #[test]
522 fn profile_display_all_variants() {
523 assert_eq!(format!("{}", Profile::Readonly), "readonly");
524 assert_eq!(format!("{}", Profile::ApiReadonly), "api-readonly");
525 assert_eq!(format!("{}", Profile::Write), "write");
526 assert_eq!(format!("{}", Profile::Admin), "admin");
527 assert_eq!(format!("{}", Profile::UtilityReadonly), "utility-readonly");
528 assert_eq!(format!("{}", Profile::UtilityWrite), "utility-write");
529 }
530
531 #[test]
533 fn profile_parse_roundtrip() {
534 for profile in [
535 Profile::Readonly,
536 Profile::ApiReadonly,
537 Profile::Write,
538 Profile::Admin,
539 Profile::UtilityReadonly,
540 Profile::UtilityWrite,
541 ] {
542 let s = profile.to_string();
543 let parsed: Profile = s.parse().unwrap();
544 assert_eq!(parsed, profile);
545 }
546 }
547
548 #[test]
550 fn profile_parse_unknown_error() {
551 let result: Result<Profile, _> = "nonexistent".parse();
552 assert!(result.is_err());
553 }
554
555 #[test]
558 fn parse_backend_scraper() {
559 assert_eq!(
560 provider::parse_backend("scraper"),
561 provider::ProviderBackend::Scraper
562 );
563 }
564
565 #[test]
566 fn parse_backend_scraper_uppercase() {
567 assert_eq!(
568 provider::parse_backend("SCRAPER"),
569 provider::ProviderBackend::Scraper
570 );
571 }
572
573 #[test]
574 fn parse_backend_x_api() {
575 assert_eq!(
576 provider::parse_backend("x_api"),
577 provider::ProviderBackend::XApi
578 );
579 }
580
581 #[test]
582 fn parse_backend_empty_defaults_to_x_api() {
583 assert_eq!(provider::parse_backend(""), provider::ProviderBackend::XApi);
584 }
585
586 #[test]
587 fn parse_backend_unknown_defaults_to_x_api() {
588 assert_eq!(
589 provider::parse_backend("something_else"),
590 provider::ProviderBackend::XApi
591 );
592 }
593
594 #[test]
597 fn provider_backend_display_x_api() {
598 assert_eq!(provider::ProviderBackend::XApi.to_string(), "x_api");
599 }
600
601 #[test]
602 fn provider_backend_display_scraper() {
603 assert_eq!(provider::ProviderBackend::Scraper.to_string(), "scraper");
604 }
605
606 #[test]
609 fn inject_backend_into_json_with_meta() {
610 let input = r#"{"data":{},"meta":{"elapsed":5}}"#;
611 let result = provider::inject_provider_backend(input, "scraper");
612 let v: serde_json::Value = serde_json::from_str(&result).unwrap();
613 assert_eq!(v["meta"]["provider_backend"], "scraper");
614 assert_eq!(v["meta"]["elapsed"], 5);
615 }
616
617 #[test]
618 fn inject_backend_into_json_without_meta() {
619 let input = r#"{"data":{}}"#;
620 let result = provider::inject_provider_backend(input, "x_api");
621 let v: serde_json::Value = serde_json::from_str(&result).unwrap();
622 assert_eq!(v["meta"]["provider_backend"], "x_api");
623 }
624
625 #[test]
626 fn inject_backend_invalid_json_returns_input() {
627 let input = "not valid json";
628 let result = provider::inject_provider_backend(input, "x_api");
629 assert_eq!(result, "not valid json");
630 }
631
632 #[test]
635 fn profile_equality() {
636 assert_eq!(Profile::Write, Profile::Write);
637 assert_ne!(Profile::Write, Profile::Admin);
638 assert_ne!(Profile::Readonly, Profile::ApiReadonly);
639 }
640
641 #[test]
642 fn profile_clone() {
643 let p = Profile::UtilityWrite;
644 let p2 = p;
645 assert_eq!(p, p2);
646 }
647
648 #[test]
649 fn profile_debug_format() {
650 let debug = format!("{:?}", Profile::Admin);
651 assert_eq!(debug, "Admin");
652 }
653
654 #[tokio::test]
657 async fn readonly_state_x_not_available_without_tokens() {
658 let config = Config::default();
659 let state = init_readonly_state(config, Profile::Readonly)
660 .await
661 .unwrap();
662 assert!(!state.x_available);
663 assert!(state.authenticated_user_id.is_empty());
664 }
665
666 #[tokio::test]
667 async fn readonly_state_config_preserved() {
668 let mut config = Config::default();
669 config.x_api.provider_backend = "scraper".to_string();
670 config.business.product_name = "TestBrand".to_string();
671 let state = init_readonly_state(config, Profile::Readonly)
672 .await
673 .unwrap();
674 assert_eq!(state.config.business.product_name, "TestBrand");
675 }
676}