Skip to main content

tuitbot_mcp/
lib.rs

1//! MCP (Model Context Protocol) server for Tuitbot.
2//!
3//! Exposes Tuitbot operations as structured MCP tools over stdio transport,
4//! allowing AI agents to natively discover and call analytics, approval queue,
5//! content generation, scoring, and configuration operations.
6//!
7//! Six runtime profiles are available:
8//! - **`readonly`**: minimal read-only X tools (10). No DB, no LLM, no mutations.
9//! - **`api-readonly`**: broader read-only X tools (20). No DB, no LLM, no mutations.
10//! - **`write`** (default): standard operating profile. All typed tools including mutations.
11//! - **`admin`**: superset of write. Adds universal request tools. Explicit opt-in.
12//! - **`utility-readonly`**: flat toolkit surface — stateless reads + scoring + config. No workflow.
13//! - **`utility-write`**: flat toolkit surface — reads + writes + engages. No workflow, no policy gate.
14
15pub 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
45/// Run the MCP server with the specified profile.
46///
47/// Dispatches to the appropriate server implementation based on profile.
48pub 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
59// ── Shared init for write/admin profiles ────────────────────────────────
60
61/// Initialize shared state for write / admin profiles: DB, LLM, X client.
62async fn init_write_state(config: Config) -> anyhow::Result<Arc<AppState>> {
63    // Initialize database
64    let pool = storage::init_db(&config.storage.db_path).await?;
65
66    // Initialize MCP mutation rate limit
67    storage::rate_limits::init_mcp_rate_limit(&pool, config.mcp_policy.max_mutations_per_hour)
68        .await?;
69
70    // Try to create LLM provider (optional — content tools won't work without it)
71    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    // Log provider backend selection.
85    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    // ── Scraper backend: use LocalModeXClient, skip OAuth ───────────
100    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    // ── Official X API backend ──────────────────────────────────────
135    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
187/// Run the write-profile MCP server on stdio transport (standard operating profile).
188async 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    // Clean shutdown
203    pool.close().await;
204
205    Ok(())
206}
207
208/// Run the admin-profile MCP server on stdio transport (write + universal requests).
209async 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    // Clean shutdown
224    pool.close().await;
225
226    Ok(())
227}
228
229// ── Shared init for read-only profiles ──────────────────────────────────
230
231/// Initialize shared readonly state: load tokens, create X client, verify get_me.
232///
233/// Gracefully degrades when tokens are missing, expired, or `get_me()` fails.
234/// Non-X tools (config, scoring) remain functional in degraded mode.
235async fn init_readonly_state(
236    config: Config,
237    profile: Profile,
238) -> anyhow::Result<SharedReadonlyState> {
239    // Log provider backend selection.
240    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    // ── Scraper backend: use LocalModeXClient, skip OAuth ───────────
255    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    // ── Official X API backend ──────────────────────────────────────
287    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
346/// Run the readonly-profile MCP server on stdio transport (10 tools).
347async 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
362/// Run the api-readonly-profile MCP server on stdio transport (20 tools).
363async 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
378// ── Utility profile servers ─────────────────────────────────────────────
379
380/// Run the utility-readonly MCP server on stdio transport (flat toolkit reads).
381async 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
396/// Run the utility-write MCP server on stdio transport (flat toolkit reads + writes + engages).
397async 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    /// Regression test: `init_readonly_state` must not bail when
417    /// `provider_backend = "scraper"` and no browser session file exists.
418    /// This was the root cause of the MCP readonly crash reported by users.
419    #[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    /// Regression test: `init_readonly_state` must gracefully degrade when
433    /// `provider_backend = "x_api"` (default) and no token file exists.
434    #[tokio::test]
435    async fn init_readonly_x_api_no_tokens_does_not_fail() {
436        let config = Config::default(); // provider_backend defaults to ""  → x_api
437
438        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    /// Same regression check for api-readonly profile with scraper backend.
453    #[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    /// Utility-readonly profile should degrade gracefully without tokens.
467    #[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    /// Utility-write profile should degrade gracefully without tokens.
477    #[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    /// Scraper backend with utility-readonly profile.
488    #[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    /// Scraper backend with utility-write profile.
498    #[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    /// Readonly state has config accessible.
508    #[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        // Config should be accessible
517        assert_eq!(state.config.x_api.provider_backend, "scraper");
518    }
519
520    /// Profile display covers all variants.
521    #[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    /// Profile parse from string.
532    #[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    /// Profile parse unknown string.
549    #[test]
550    fn profile_parse_unknown_error() {
551        let result: Result<Profile, _> = "nonexistent".parse();
552        assert!(result.is_err());
553    }
554
555    // ── Provider backend parsing ───────────────────────────────────
556
557    #[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    // ── Provider backend display ───────────────────────────────────
595
596    #[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    // ── inject_provider_backend ────────────────────────────────────
607
608    #[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    // ── Profile equality and copy ──────────────────────────────────
633
634    #[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    // ── Readonly state x_available checks ──────────────────────────
655
656    #[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}