Skip to main content

jev_repl/
mcp.rs

1//! jev as an MCP server: the one-shot commands, offered to an agent as tools.
2//!
3//! This half is pure — a JSON-RPC message in, a JSON-RPC message out — so the protocol can be
4//! tested without a pipe. Everything that touches the network goes through [`Host::ask`], which
5//! the caller supplies; [`crate::serve`] is the part that puts it on stdin and stdout.
6
7use std::future::Future;
8use std::pin::Pin;
9use std::sync::Arc;
10
11use serde_json::{Map, Value, json};
12
13use crate::cost::Rates;
14use crate::evaluate::{self, Outcome, ReportOptions};
15use crate::headless;
16use crate::session::Session;
17use crate::skill::SKILL_MD;
18use crate::{cost, presets, sketch};
19
20/// The protocol revision this server speaks.
21pub const PROTOCOL_VERSION: &str = "2025-06-18";
22
23/// Revisions a client may ask for and still be understood; anything else gets ours back.
24pub const KNOWN_PROTOCOLS: &[&str] = &["2025-06-18", "2025-03-26", "2024-11-05"];
25
26/// The name the server reports at `initialize`, and the prefix on every tool.
27pub const SERVER_NAME: &str = "jev";
28
29/// JSON-RPC error codes, the ones this server can actually raise.
30pub const PARSE_ERROR: i64 = -32700;
31pub const INVALID_REQUEST: i64 = -32600;
32pub const METHOD_NOT_FOUND: i64 = -32601;
33pub const INVALID_PARAMS: i64 = -32602;
34
35/// What the host got back from one send: the answers `jev eval` scores, plus the raw body when
36/// there was one, so `jev_ask` can hand it over for a script to read.
37#[derive(Debug, Clone)]
38pub struct Sent {
39    pub outcome: Outcome,
40    pub raw: Option<Value>,
41}
42
43impl Sent {
44    pub fn failed(error: impl Into<String>) -> Self {
45        Self {
46            outcome: Outcome::Failed {
47                error: error.into(),
48            },
49            raw: None,
50        }
51    }
52}
53
54/// Send one session and come back with its answers, or with why it did not.
55///
56/// Boxed rather than generic: the server hands this to spawned tasks and to the eval runner, and
57/// one `Arc` is easier to pass around than a type parameter threaded through every function.
58pub type Ask =
59    Arc<dyn Fn(Session) -> Pin<Box<dyn Future<Output = Sent> + Send>> + Send + Sync + 'static>;
60
61/// What the host does for the tools that need more than text: send a request, price it, name a model.
62#[derive(Clone)]
63pub struct Host {
64    /// The version reported at `initialize`.
65    pub version: String,
66    /// The model a page that pins none is sent with.
67    pub model: String,
68    /// Whether answers come from the API. False means every answer is simulated.
69    pub live: bool,
70    /// Token prices, when the host was given any.
71    pub rates: Option<Rates>,
72    pub ask: Ask,
73}
74
75/// What a tool call comes back with: text, and whether it describes a failure.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct ToolResult {
78    pub text: String,
79    pub is_error: bool,
80}
81
82impl ToolResult {
83    fn ok(text: impl Into<String>) -> Self {
84        Self {
85            text: text.into(),
86            is_error: false,
87        }
88    }
89
90    fn failed(text: impl Into<String>) -> Self {
91        Self {
92            text: text.into(),
93            is_error: true,
94        }
95    }
96
97    pub fn to_value(&self) -> Value {
98        let mut value = json!({ "content": [{ "type": "text", "text": self.text }] });
99        if self.is_error {
100            value["isError"] = Value::Bool(true);
101        }
102        value
103    }
104}
105
106const PAGE_DESCRIPTION: &str = "The request as a jev sketch page, or a raw /v1/systemone request body. \
107     jev_notation has the notation.";
108
109/// Every tool this server offers, in the order an agent should reach for them.
110pub fn tools() -> Value {
111    let page = json!({ "type": "string", "description": PAGE_DESCRIPTION });
112    let state = json!({
113        "type": "string",
114        "description": "Judge this text instead of the state written on the page.",
115    });
116    let model = json!({
117        "type": "string",
118        "description":
119            "The model to ask. Defaults to the one the page pins, then to the server's default.",
120    });
121    let threshold = json!({
122        "type": "number",
123        "description": "What counts as a yes for a noul, from 0 to 1. Defaults to 0.5.",
124    });
125    let price = json!({
126        "type": "string",
127        "description": "Dollars per million tokens, input then output, as \"0.20/1.00\".",
128    });
129    json!([
130        {
131            "name": "jev_notation",
132            "title": "jev notation",
133            "description":
134                "The jev sketch notation and workflow: how to write a page of questions, what each \
135                 kind of question answers with, and how to check, price, run and score one. Read \
136                 this before writing a page, and again when one will not parse.",
137            "inputSchema": { "type": "object", "properties": {}, "additionalProperties": false },
138        },
139        {
140            "name": "jev_check",
141            "title": "Check a page",
142            "description":
143                "Parse a page and report every problem with a line number, or say what it parsed \
144                 into. Sends nothing and costs nothing — use it on every page before running one.",
145            "inputSchema": {
146                "type": "object",
147                "properties": { "page": page },
148                "required": ["page"],
149                "additionalProperties": false,
150            },
151        },
152        {
153            "name": "jev_request",
154            "title": "Request body",
155            "description":
156                "The exact JSON body this page would POST to /v1/systemone. Sends nothing.",
157            "inputSchema": {
158                "type": "object",
159                "properties": { "page": page, "state": state, "model": model },
160                "required": ["page"],
161                "additionalProperties": false,
162            },
163        },
164        {
165            "name": "jev_cost",
166            "title": "Estimate cost",
167            "description":
168                "Estimated tokens for this page, per question and on both sides of the wire, \
169                 priced when rates are given. Sends nothing. Check this before a run over many \
170                 cases.",
171            "inputSchema": {
172                "type": "object",
173                "properties": { "page": page, "state": state, "model": model, "price": price },
174                "required": ["page"],
175                "additionalProperties": false,
176            },
177        },
178        {
179            "name": "jev_ask",
180            "title": "Ask the questions",
181            "description":
182                "Send the page and return one answer per question. Spends money when the server \
183                 holds an API key; without one every answer is simulated noise and must not be \
184                 reported as judgement.",
185            "inputSchema": {
186                "type": "object",
187                "properties": {
188                    "page": page,
189                    "state": state,
190                    "model": model,
191                    "threshold": threshold,
192                    "json": {
193                        "type": "boolean",
194                        "description": "Return the raw response body instead of the answer page.",
195                    },
196                },
197                "required": ["page"],
198                "additionalProperties": false,
199            },
200        },
201        {
202            "name": "jev_eval",
203            "title": "Score a page",
204            "description":
205                "Run a page over labelled cases and score the answers: accuracy per question, a \
206                 confusion table, a threshold sweep for each noul. One request per case, so price \
207                 it first.",
208            "inputSchema": {
209                "type": "object",
210                "properties": {
211                    "page": page,
212                    "cases": {
213                        "type": "string",
214                        "description":
215                            "JSON Lines, one labelled state per line: \
216                             {\"state\": \"...\", \"expect\": {\"is_urgent\": true}}.",
217                    },
218                    "model": model,
219                    "threshold": threshold,
220                    "price": price,
221                    "concurrency": {
222                        "type": "integer",
223                        "description": "How many cases are in the air at once. Defaults to 4.",
224                        "minimum": 1,
225                    },
226                    "json": {
227                        "type": "boolean",
228                        "description": "Return the report as JSON instead of a table.",
229                    },
230                },
231                "required": ["page", "cases"],
232                "additionalProperties": false,
233            },
234        },
235        {
236            "name": "jev_code",
237            "title": "Page as code",
238            "description":
239                "The page as a working Rust program against typesafe-ai-sdk. Start here instead of \
240                 writing a client by hand.",
241            "inputSchema": {
242                "type": "object",
243                "properties": { "page": page, "model": model, "threshold": threshold },
244                "required": ["page"],
245                "additionalProperties": false,
246            },
247        },
248        {
249            "name": "jev_presets",
250            "title": "Ready-made pages",
251            "description":
252                "Worked pages to start from — support triage, content moderation, lead \
253                 qualification and reply grading — each as a sketch page ready to edit.",
254            "inputSchema": {
255                "type": "object",
256                "properties": {
257                    "name": {
258                        "type": "string",
259                        "description": "One preset by name. Omit for all of them.",
260                    },
261                },
262                "additionalProperties": false,
263            },
264        },
265    ])
266}
267
268/// The line every simulated answer is stamped with, so nobody reports noise as judgement.
269const SIMULATED: &str = "\nSimulated answers: deterministic noise, not judgement. \
270                         The server has no TYPESAFE_API_KEY, so nothing was sent.";
271
272fn string_arg(args: &Value, name: &str) -> Result<String, String> {
273    match args.get(name) {
274        None | Some(Value::Null) => Err(format!("{name} is required.")),
275        Some(Value::String(text)) => Ok(text.clone()),
276        Some(_) => Err(format!("{name} must be a string.")),
277    }
278}
279
280fn optional_string(args: &Value, name: &str) -> Result<Option<String>, String> {
281    match args.get(name) {
282        None | Some(Value::Null) => Ok(None),
283        Some(Value::String(text)) => Ok(Some(text.clone())),
284        Some(_) => Err(format!("{name} must be a string.")),
285    }
286}
287
288fn threshold_arg(args: &Value) -> Result<f64, String> {
289    let value = match args.get("threshold") {
290        None | Some(Value::Null) => return Ok(0.5),
291        Some(Value::Number(n)) => n.as_f64().unwrap_or(f64::NAN),
292        Some(_) => return Err("threshold must be a number.".to_owned()),
293    };
294    if !(0.0..=1.0).contains(&value) {
295        return Err("threshold must be from 0 to 1.".to_owned());
296    }
297    Ok(value)
298}
299
300fn bool_arg(args: &Value, name: &str) -> Result<bool, String> {
301    match args.get(name) {
302        None | Some(Value::Null) => Ok(false),
303        Some(Value::Bool(value)) => Ok(*value),
304        Some(_) => Err(format!("{name} must be true or false.")),
305    }
306}
307
308fn concurrency_arg(args: &Value) -> Result<usize, String> {
309    let workers = match args.get("concurrency") {
310        None | Some(Value::Null) => return Ok(4),
311        Some(Value::Number(n)) => n.as_i64().unwrap_or(0),
312        Some(_) => return Err("concurrency must be a number.".to_owned()),
313    };
314    if workers < 1 {
315        return Err("concurrency must be a whole number of 1 or more.".to_owned());
316    }
317    Ok(workers as usize)
318}
319
320/// The rates a call was given, falling back to the host's.
321fn rates_arg(args: &Value, host: &Host) -> Result<Option<Rates>, String> {
322    match optional_string(args, "price")? {
323        None => Ok(host.rates),
324        Some(text) => cost::parse_rates(&text).map(Some),
325    }
326}
327
328/// The page, loaded, with the overrides a call may carry applied.
329fn session_arg(args: &Value, host: &Host) -> Result<(Session, String), String> {
330    let mut session = headless::load(&string_arg(args, "page")?)?;
331    if let Some(state) = optional_string(args, "state")? {
332        session.state = Value::String(state);
333    }
334    if let Some(model) = optional_string(args, "model")? {
335        session.model = Some(model);
336    }
337    let model = session.model.clone().unwrap_or_else(|| host.model.clone());
338    Ok((session, model))
339}
340
341async fn ask(args: &Value, host: &Host) -> Result<ToolResult, String> {
342    let (session, model) = session_arg(args, host)?;
343    if let Some(why) = headless::sendable(&session) {
344        return Err(why);
345    }
346    let threshold = threshold_arg(args)?;
347    let rates = rates_arg(args, host)?;
348    let json_wanted = bool_arg(args, "json")?;
349
350    let sent = (host.ask)(session.clone()).await;
351    let (answers, usage) = match sent.outcome {
352        Outcome::Failed { error } => return Ok(ToolResult::failed(error)),
353        Outcome::Ok { answers, usage } => (answers, usage),
354    };
355    if json_wanted {
356        return Ok(ToolResult::ok(headless::answers_json(
357            &answers,
358            &model,
359            sent.raw.as_ref(),
360        )));
361    }
362    let mut text = headless::answers_text(&answers, threshold);
363    text.push_str(&headless::usage_text(
364        &session,
365        &model,
366        rates,
367        usage.as_ref(),
368    ));
369    if !host.live {
370        text.push_str(SIMULATED);
371    }
372    Ok(ToolResult::ok(text))
373}
374
375async fn score(args: &Value, host: &Host) -> Result<ToolResult, String> {
376    let (session, model) = session_arg(args, host)?;
377    let cases = evaluate::parse_cases(&string_arg(args, "cases")?, &session)?;
378    if cases.is_empty() {
379        return Err("cases is empty: nothing to score.".to_owned());
380    }
381    let threshold = threshold_arg(args)?;
382    let rates = rates_arg(args, host)?;
383    let concurrency = concurrency_arg(args)?;
384    let json_wanted = bool_arg(args, "json")?;
385
386    let send = Arc::clone(&host.ask);
387    let outcomes = evaluate::run(
388        &session,
389        &cases,
390        move |one: Session| {
391            let send = Arc::clone(&send);
392            async move { send(one).await.outcome }
393        },
394        concurrency,
395    )
396    .await;
397    let report = evaluate::report(
398        &session,
399        &cases,
400        &outcomes,
401        ReportOptions {
402            model: &model,
403            threshold,
404            rates,
405        },
406    );
407    if json_wanted {
408        let body = serde_json::to_string_pretty(&evaluate::report_json(&report))
409            .map_err(|e| e.to_string())?;
410        return Ok(ToolResult::ok(format!("{body}\n")));
411    }
412    let mut text = evaluate::report_text(&report);
413    if !host.live {
414        text.push_str(SIMULATED);
415    }
416    Ok(ToolResult::ok(text))
417}
418
419/// A preset as the page it builds, with its name and what it is for above it.
420fn preset_page(preset: &presets::Preset) -> String {
421    format!(
422        "# {} — {}\n\n{}",
423        preset.name,
424        preset.about,
425        sketch::render(&presets::to_session(preset))
426    )
427}
428
429fn preset_pages(args: &Value) -> Result<ToolResult, String> {
430    match optional_string(args, "name")? {
431        Some(name) => {
432            let Some(preset) = presets::find(&name) else {
433                let names = presets::PRESETS
434                    .iter()
435                    .map(|p| p.name)
436                    .collect::<Vec<_>>()
437                    .join(", ");
438                return Err(format!("no preset {name:?}; there is {names}."));
439            };
440            Ok(ToolResult::ok(preset_page(preset)))
441        }
442        None => Ok(ToolResult::ok(
443            presets::PRESETS
444                .iter()
445                .map(preset_page)
446                .collect::<Vec<_>>()
447                .join("\n\n"),
448        )),
449    }
450}
451
452/// Run one tool. Argument problems come back as an error result, not as a JSON-RPC failure.
453pub async fn call(name: &str, args: &Value, host: &Host) -> ToolResult {
454    let outcome = match name {
455        "jev_notation" => Ok(ToolResult::ok(SKILL_MD)),
456        "jev_check" => string_arg(args, "page").map(|page| match headless::check_text(&page) {
457            Ok(summary) => ToolResult::ok(format!("{summary}\n")),
458            Err(problems) => ToolResult::failed(problems),
459        }),
460        "jev_request" => session_arg(args, host)
461            .map(|(session, model)| ToolResult::ok(headless::request_text(&session, &model))),
462        "jev_cost" => rates_arg(args, host).and_then(|rates| {
463            session_arg(args, host).map(|(session, model)| {
464                ToolResult::ok(headless::cost_text(&session, &model, rates))
465            })
466        }),
467        "jev_ask" => ask(args, host).await,
468        "jev_eval" => score(args, host).await,
469        "jev_code" => threshold_arg(args).and_then(|threshold| {
470            session_arg(args, host).map(|(session, model)| {
471                ToolResult::ok(headless::code_text(&session, &model, threshold))
472            })
473        }),
474        "jev_presets" => preset_pages(args),
475        other => {
476            return ToolResult::failed(format!("No tool named {other:?}. tools/list has them."));
477        }
478    };
479    match outcome {
480        Ok(result) => result,
481        Err(why) => ToolResult::failed(format!("{name}: {why}")),
482    }
483}
484
485fn reply(id: Value, result: Value) -> Value {
486    json!({ "jsonrpc": "2.0", "id": id, "result": result })
487}
488
489fn fault(id: Value, code: i64, message: String) -> Value {
490    json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } })
491}
492
493/// What `initialize` answers with: what we speak, what we can do, who we are.
494fn greeting(params: &Value, host: &Host) -> Value {
495    let asked = params.get("protocolVersion").and_then(Value::as_str);
496    let version = match asked {
497        Some(asked) if KNOWN_PROTOCOLS.contains(&asked) => asked,
498        _ => PROTOCOL_VERSION,
499    };
500    let instructions = format!(
501        "jev shapes and sends TypeSafe AI System One questions. Call jev_notation first to learn \
502         the page notation, jev_check to make sure a page parses, jev_cost before anything large, \
503         then jev_ask or jev_eval.{}",
504        if host.live {
505            ""
506        } else {
507            " This server has no API key: every answer is simulated."
508        }
509    );
510    json!({
511        "protocolVersion": version,
512        "capabilities": { "tools": { "listChanged": false } },
513        "serverInfo": { "name": SERVER_NAME, "title": "jev", "version": host.version },
514        "instructions": instructions,
515    })
516}
517
518/// Answer one JSON-RPC message.
519///
520/// Returns `None` for a notification — a message with no `id` gets no reply, which is the one rule
521/// of the protocol that a hand-written server usually gets wrong.
522pub async fn handle(message: &Value, host: &Host) -> Option<Value> {
523    let Some(object) = message.as_object() else {
524        return Some(fault(
525            Value::Null,
526            INVALID_REQUEST,
527            "Expected a JSON-RPC object.".to_owned(),
528        ));
529    };
530    let id = match object.get("id") {
531        None | Some(Value::Null) => None,
532        Some(id) => Some(id.clone()),
533    };
534    let Some(method) = object.get("method").and_then(Value::as_str) else {
535        return id.map(|id| fault(id, INVALID_REQUEST, "No method named.".to_owned()));
536    };
537    // Nothing this server keeps state for; the handshake's `initialized` is the usual one.
538    let id = id?;
539    let empty = Value::Object(Map::new());
540    let params = object.get("params").unwrap_or(&empty);
541
542    Some(match method {
543        "initialize" => reply(id, greeting(params, host)),
544        "ping" => reply(id, json!({})),
545        "tools/list" => reply(id, json!({ "tools": tools() })),
546        "tools/call" => {
547            let Some(name) = params.get("name").and_then(Value::as_str) else {
548                return Some(fault(
549                    id,
550                    INVALID_PARAMS,
551                    "tools/call needs a tool name.".to_owned(),
552                ));
553            };
554            let args = params.get("arguments").unwrap_or(&empty).clone();
555            reply(id, call(name, &args, host).await.to_value())
556        }
557        "resources/list" => reply(id, json!({ "resources": [] })),
558        "prompts/list" => reply(id, json!({ "prompts": [] })),
559        other => fault(id, METHOD_NOT_FOUND, format!("Unknown method {other:?}.")),
560    })
561}
562
563/// One line of stdio: parse it, answer it, hand back the line to write — or nothing.
564pub async fn handle_line(line: &str, host: &Host) -> Option<String> {
565    if line.trim().is_empty() {
566        return None;
567    }
568    let message: Value = match serde_json::from_str(line) {
569        Ok(value) => value,
570        Err(e) => {
571            return Some(
572                fault(
573                    Value::Null,
574                    PARSE_ERROR,
575                    format!("Could not parse the message: {e}"),
576                )
577                .to_string(),
578            );
579        }
580    };
581    handle(&message, host).await.map(|value| value.to_string())
582}