use std::path::Path;
use serde_json::json;
use crate::rag::{RagHit, RagMetadata};
const DEFAULT_MCP_URL: &str = "https://mcp.tina4.com";
const MCP_TIMEOUT_SECS: u64 = 12;
const LONG_CONTEXT_TIMEOUT_SECS: u64 = 300;
pub const TOKEN_VAR: &str = "TINA4_MCP_TOKEN";
pub fn base_url() -> String {
std::env::var("TINA4_MCP_URL").unwrap_or_else(|_| DEFAULT_MCP_URL.to_string())
}
fn endpoint() -> String {
format!("{}/mcp", base_url().trim_end_matches('/'))
}
pub fn token(project_dir: &Path) -> Option<String> {
if let Ok(t) = std::env::var(TOKEN_VAR) {
let t = t.trim();
if !t.is_empty() {
return Some(t.to_string());
}
}
read_env_file_value(project_dir, TOKEN_VAR)
}
pub fn is_configured(project_dir: &Path) -> bool {
token(project_dir).is_some()
}
pub fn read_env_file_value(project_dir: &Path, key: &str) -> Option<String> {
let content = std::fs::read_to_string(project_dir.join(".env")).ok()?;
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((k, v)) = line.split_once('=') {
if k.trim() == key {
let v = v.trim().trim_matches('"').trim_matches('\'').trim();
if !v.is_empty() {
return Some(v.to_string());
}
}
}
}
None
}
pub fn save_token(project_dir: &Path, new_token: &str) -> std::io::Result<String> {
let new_token = new_token.trim();
let env_path = project_dir.join(".env");
let existing = std::fs::read_to_string(&env_path).unwrap_or_default();
let line = format!("{TOKEN_VAR}={new_token}");
let mut replaced = false;
let mut out: Vec<String> = Vec::new();
for l in existing.lines() {
if l.trim_start().starts_with(&format!("{TOKEN_VAR}=")) {
out.push(line.clone());
replaced = true;
} else {
out.push(l.to_string());
}
}
if !replaced {
out.push(line);
}
let mut body = out.join("\n");
if !body.ends_with('\n') {
body.push('\n');
}
std::fs::write(&env_path, body)?;
let last4: String = new_token.chars().rev().take(4).collect::<Vec<_>>().into_iter().rev().collect();
Ok(last4)
}
fn http_client() -> reqwest::Client {
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(MCP_TIMEOUT_SECS))
.build()
.expect("reqwest client build failed")
}
pub async fn tina4_context(project_dir: &Path, instruction: &str, language: &str) -> Vec<RagHit> {
let Some(tok) = token(project_dir) else {
return Vec::new(); };
if instruction.trim().is_empty() {
return Vec::new();
}
let req_body = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "tina4_context",
"arguments": { "instruction": instruction, "language": language }
}
});
let resp = match http_client()
.post(endpoint())
.header("Authorization", format!("Bearer {tok}"))
.header("Accept", "application/json, text/event-stream")
.json(&req_body)
.send()
.await
{
Ok(r) => r,
Err(e) => {
eprintln!("[mcp] tina4_context send failed: {e}");
return Vec::new();
}
};
if !resp.status().is_success() {
eprintln!("[mcp] tina4_context returned {}", resp.status());
return Vec::new();
}
let raw = match resp.text().await {
Ok(t) => t,
Err(e) => {
eprintln!("[mcp] tina4_context body read failed: {e}");
return Vec::new();
}
};
let Some(text) = extract_tool_text(&raw) else {
eprintln!("[mcp] tina4_context response had no tool text");
return Vec::new();
};
parse_context_into_hits(&text, language)
}
pub(crate) fn split_checksum(text: &str) -> (String, Option<String>) {
if let Some(marker) = text.rfind("---\nchecksum:") {
if let Some(cx) = text[marker..]
.split_whitespace()
.find(|t| t.starts_with("cx_"))
{
return (text[..marker].trim_end().to_string(), Some(cx.to_string()));
}
}
(text.to_string(), None)
}
pub async fn long_context_call(
base_url: &str,
token: &str,
question: &str,
context: &str,
checksum: &str,
) -> Option<(String, String)> {
if token.trim().is_empty() || question.trim().is_empty() {
return None;
}
let url = format!("{}/mcp", base_url.trim_end_matches('/'));
let mut arguments = serde_json::Map::new();
arguments.insert("question".into(), json!(question));
if !context.is_empty() {
arguments.insert("context".into(), json!(context));
}
if !checksum.is_empty() {
arguments.insert("checksum".into(), json!(checksum));
}
let req_body = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "long_context",
"arguments": arguments
}
});
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(LONG_CONTEXT_TIMEOUT_SECS))
.build()
.ok()?;
let resp = match client
.post(&url)
.header("Authorization", format!("Bearer {token}"))
.header("Accept", "application/json, text/event-stream")
.json(&req_body)
.send()
.await
{
Ok(r) => r,
Err(e) => {
eprintln!("[mcp] long_context send failed: {e}");
return None;
}
};
if !resp.status().is_success() {
eprintln!("[mcp] long_context returned {}", resp.status());
return None;
}
let raw = resp.text().await.ok()?;
let text = extract_tool_text(&raw)?;
let (answer, checksum) = split_checksum(&text);
if answer.trim().is_empty() {
None
} else {
Some((answer, checksum.unwrap_or_default()))
}
}
pub async fn tina4_chat_call(base_url: &str, token: &str, messages: serde_json::Value) -> Option<String> {
if token.trim().is_empty() {
return None;
}
let url = format!("{}/mcp", base_url.trim_end_matches('/'));
let req_body = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": { "name": "tina4_chat", "arguments": { "messages": messages } }
});
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(LONG_CONTEXT_TIMEOUT_SECS))
.build()
.ok()?;
let resp = match client
.post(&url)
.header("Authorization", format!("Bearer {token}"))
.header("Accept", "application/json, text/event-stream")
.json(&req_body)
.send()
.await
{
Ok(r) => r,
Err(e) => {
eprintln!("[mcp] tina4_chat send failed: {e}");
return None;
}
};
if !resp.status().is_success() {
eprintln!("[mcp] tina4_chat returned {}", resp.status());
return None;
}
let raw = resp.text().await.ok()?;
let text = extract_tool_text(&raw)?;
if text.trim().is_empty() {
None
} else {
Some(text)
}
}
fn extract_tool_text(raw: &str) -> Option<String> {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(raw) {
return tool_text_from_value(&v);
}
let mut found: Option<String> = None;
for line in raw.lines() {
let line = line.trim_start();
if let Some(rest) = line.strip_prefix("data:") {
let payload = rest.trim();
if payload.is_empty() || payload == "[DONE]" {
continue;
}
if let Ok(v) = serde_json::from_str::<serde_json::Value>(payload) {
if let Some(t) = tool_text_from_value(&v) {
found = Some(t); }
}
}
}
found
}
fn tool_text_from_value(v: &serde_json::Value) -> Option<String> {
if v.get("error").is_some() {
if let Some(msg) = v["error"]["message"].as_str() {
eprintln!("[mcp] tina4_context error: {msg}");
}
return None;
}
let content = v.get("result")?.get("content")?.as_array()?;
let mut out = String::new();
for block in content {
if let Some(t) = block.get("text").and_then(|t| t.as_str()) {
if !out.is_empty() {
out.push('\n');
}
out.push_str(t);
}
}
if out.trim().is_empty() {
None
} else {
Some(out)
}
}
fn parse_context_into_hits(text: &str, language: &str) -> Vec<RagHit> {
let mut hits: Vec<RagHit> = Vec::new();
let mut current_title: Option<String> = None;
let mut current_body = String::new();
let flush = |title: &Option<String>, body: &str, hits: &mut Vec<RagHit>| {
let body = body.trim();
if body.is_empty() {
return;
}
hits.push(RagHit {
text: body.to_string(),
metadata: RagMetadata {
title: title.clone().unwrap_or_default(),
source: "mcp.tina4.com".into(),
url: "https://mcp.tina4.com".into(),
language: language.to_string(),
chunk_index: hits.len() as u32,
},
distance: 0.0,
});
};
for line in text.lines() {
if let Some(h) = line.strip_prefix("### ") {
if current_title.is_some() {
flush(¤t_title, ¤t_body, &mut hits);
}
current_title = Some(h.trim().to_string());
current_body.clear();
} else if current_title.is_some() {
current_body.push_str(line);
current_body.push('\n');
}
}
if current_title.is_some() {
flush(¤t_title, ¤t_body, &mut hits);
}
if hits.is_empty() {
let body = text.trim();
if !body.is_empty() {
hits.push(RagHit {
text: body.to_string(),
metadata: RagMetadata {
title: "tina4_context".into(),
source: "mcp.tina4.com".into(),
url: "https://mcp.tina4.com".into(),
language: language.to_string(),
chunk_index: 0,
},
distance: 0.0,
});
}
}
hits
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn endpoint_appends_mcp_path() {
assert_eq!(endpoint(), "https://mcp.tina4.com/mcp");
}
#[test]
fn split_checksum_strips_trailer_and_extracts_token() {
let raw = "X is 42.\n\n---\nchecksum: cx_4c93a72dac1c54c238aabb42c5da7570 (pass back as `checksum` to append more context or re-query — accumulated 39 chars over 1 chunk(s))";
let (answer, cs) = split_checksum(raw);
assert_eq!(answer, "X is 42.");
assert_eq!(cs.as_deref(), Some("cx_4c93a72dac1c54c238aabb42c5da7570"));
}
#[test]
fn split_checksum_no_trailer_returns_text_unchanged() {
let (answer, cs) = split_checksum("Just an answer, no trailer.");
assert_eq!(answer, "Just an answer, no trailer.");
assert_eq!(cs, None);
}
#[test]
fn split_checksum_ignores_inline_mention_without_token() {
let text = "To verify, compare the checksum of each file.";
let (answer, cs) = split_checksum(text);
assert_eq!(answer, text);
assert_eq!(cs, None);
}
#[test]
fn split_checksum_uses_the_last_marker() {
let raw = "Example: ---\nchecksum: cx_deadbeef\nNow the real answer.\n\n---\nchecksum: cx_final0001 (…)";
let (answer, cs) = split_checksum(raw);
assert!(answer.ends_with("Now the real answer."));
assert_eq!(cs.as_deref(), Some("cx_final0001"));
}
#[test]
#[ignore]
fn wire_long_context_store_then_requery() {
let Ok(token) = std::env::var("TINA4_MCP_TOKEN") else {
eprintln!("skip: TINA4_MCP_TOKEN not set");
return;
};
let base = base_url();
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let (a1, c1) = long_context_call(
&base, &token,
"What number is X?",
"X is 99. wire-test-alpha marker.",
"",
).await.expect("first call failed");
assert!(a1.contains("99"), "answer should mention 99, got: {a1}");
assert!(c1.starts_with("cx_"), "expected a cx_ checksum, got: {c1}");
assert!(!a1.contains("checksum:"), "trailer leaked into answer: {a1}");
let (a2, c2) = long_context_call(
&base, &token, "What number is X?", "", &c1,
).await.expect("requery failed");
assert!(a2.contains("99"), "requery lost the stored context, got: {a2}");
assert_eq!(c2, c1, "re-query (no new context) must keep the same checksum");
});
}
#[test]
fn extract_text_from_plain_json() {
let raw = "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"### a\\ncode\"}]}}";
assert_eq!(extract_tool_text(raw).as_deref(), Some("### a\ncode"));
}
#[test]
fn extract_text_from_sse_frame() {
let raw = "event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"hello\"}]}}\n\n";
assert_eq!(extract_tool_text(raw).as_deref(), Some("hello"));
}
#[test]
fn extract_text_returns_none_on_jsonrpc_error() {
let raw = r#"{"jsonrpc":"2.0","id":null,"error":{"code":-32001,"message":"Unauthorized"}}"#;
assert_eq!(extract_tool_text(raw), None);
}
#[test]
fn parse_context_splits_by_section() {
let text = "Retrieved preamble to drop\n### file/one.ts\n```ts\nA\n```\n### file/two.ts\n```ts\nB\n```";
let hits = parse_context_into_hits(text, "nodejs");
assert_eq!(hits.len(), 2);
assert_eq!(hits[0].metadata.title, "file/one.ts");
assert_eq!(hits[0].metadata.chunk_index, 0);
assert!(hits[0].text.contains('A'));
assert!(!hits[0].text.contains("preamble")); assert_eq!(hits[1].metadata.title, "file/two.ts");
assert_eq!(hits[1].metadata.chunk_index, 1);
assert_eq!(hits[1].metadata.language, "nodejs");
}
#[test]
fn parse_context_single_hit_when_no_sections() {
let hits = parse_context_into_hits("just some text, no headers", "python");
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].metadata.source, "mcp.tina4.com");
}
#[test]
fn token_prefers_process_env_then_env_file() {
let dir = std::env::temp_dir().join(format!("tina4_mcp_tok_{}", std::process::id()));
let _ = fs::create_dir_all(&dir);
fs::write(dir.join(".env"), "TINA4_MCP_TOKEN=from_file\nOTHER=1\n").unwrap();
assert_eq!(read_env_file_value(&dir, "TINA4_MCP_TOKEN").as_deref(), Some("from_file"));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn save_token_upserts_and_preserves_other_lines() {
let dir = std::env::temp_dir().join(format!("tina4_mcp_save_{}", std::process::id()));
let _ = fs::create_dir_all(&dir);
fs::write(dir.join(".env"), "FOO=bar\nTINA4_MCP_TOKEN=old\nBAZ=qux\n").unwrap();
let last4 = save_token(&dir, "abcd1234567").unwrap();
assert_eq!(last4, "4567");
let body = fs::read_to_string(dir.join(".env")).unwrap();
assert!(body.contains("FOO=bar"));
assert!(body.contains("BAZ=qux"));
assert!(body.contains("TINA4_MCP_TOKEN=abcd1234567"));
assert!(!body.contains("TINA4_MCP_TOKEN=old"));
assert_eq!(body.matches("TINA4_MCP_TOKEN=").count(), 1);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn save_token_appends_when_absent() {
let dir = std::env::temp_dir().join(format!("tina4_mcp_app_{}", std::process::id()));
let _ = fs::create_dir_all(&dir);
fs::write(dir.join(".env"), "FOO=bar\n").unwrap();
save_token(&dir, "newtoken").unwrap();
let body = fs::read_to_string(dir.join(".env")).unwrap();
assert!(body.contains("FOO=bar"));
assert!(body.contains("TINA4_MCP_TOKEN=newtoken"));
let _ = fs::remove_dir_all(&dir);
}
}