Skip to main content

teamcraft/mcp/
mod.rs

1//! Model Context Protocol server: lets an engineer's AI assistant (Claude
2//! Code, Claude Desktop, any MCP client) work with the PDM data.
3//!
4//! Transport: *Streamable HTTP* without server-sent events – `POST
5//! /api/v1/mcp` with one JSON-RPC 2.0 message (or a batch) answers with
6//! `application/json`; notifications get `202 Accepted`. The server is
7//! stateless: no session id, every request carries the bearer token of the
8//! user (a personal access token), so every tool runs with that user's
9//! rights through the same `services::*` functions as the REST API.
10//! `teamcraft mcp` (see `main.rs`) bridges stdio clients to this endpoint.
11//!
12//! Tools are read-mostly (search, structure, changes, change requests,
13//! simulations, worklist, analyses). The few writes only prepare work – a
14//! draft change order, a new change request, a comment – and never submit,
15//! approve, release, triage or decide: those decisions stay with people.
16
17pub mod tools;
18
19use axum::{
20    body::Bytes,
21    extract::State,
22    http::{header, HeaderMap, StatusCode},
23    response::{IntoResponse, Response},
24};
25use serde_json::{json, Value};
26
27use crate::{
28    auth::{Actor, ApiCaller},
29    config::{LocalAgents, LOCAL_AGENT_HEADER, LOCAL_AGENT_HEADER_VALUE},
30    domain::TokenScope,
31    services::{
32        access_log::{AccessCtx, Channel},
33        project_policy::{self, Interface},
34    },
35    AppState,
36};
37
38/// Newest protocol revision spoken; offered when a client asks for another.
39pub const PROTOCOL_VERSION: &str = "2025-06-18";
40pub const SUPPORTED_VERSIONS: &[&str] = &[PROTOCOL_VERSION, "2025-03-26", "2024-11-05"];
41
42const PARSE_ERROR: i64 = -32700;
43const INVALID_REQUEST: i64 = -32600;
44const METHOD_NOT_FOUND: i64 = -32601;
45const INVALID_PARAMS: i64 = -32602;
46/// Most messages in one batch.
47const MAX_BATCH: usize = 50;
48
49/// Told to every client at `initialize`.
50pub const INSTRUCTIONS: &str = "TeamCraft is a PDM system for CAD data. Concepts: an item (part, assembly, \
51document, software) has revisions A, B, C …; a revision is in_work, in_review, released or obsolete and holds \
52files (each with versions) and BOM lines to child items. A user checks a revision out (lock) to change it. \
53Revisions are released only through change orders (draft → in_review → released) reviewed by other people; a \
54change releases a whole product at once, so unreleased BOM children and drawings must be part of it \
55(release readiness). Item numbers look like PRT-000012; revision codes are letters, `latest` is the newest.\n\
56A change request (ECR-000004) reports a problem or proposes a change on items before any change order; CAD \
57editors of the affected project groups triage it and accept, defer or reject it, and an accepted request is \
58implemented by a change order. A simulation (SIM-000003, item type simulation) is based on item revisions \
59and file versions and holds result values with limits (pass/fail); it is out of date when a basis got a newer \
60release, became obsolete or its simulated file a newer version.\n\n\
61Use the tools to answer questions about the data – find items, explain structures and where-used, \
62summarise what changed between revisions, prepare change orders, check a BOM before a release, summarise \
63reported problems, check simulations and compare their results, read the extracted text of files \
64(read_file_text), sum up what changing an item would touch (impact_summary) and open an item's 3D \
65preview for the user (open_3d_preview). Quote item, request and simulation numbers \
66and revisions exactly as returned. You can draft change orders, raise change requests and comment on them, \
67but only when the user asks; you cannot submit, approve or release change orders or triage and decide change \
68requests – tell the user what is left for them to do. Prompts: release_notes, review_change, release_check, \
69worklist_triage, triage_requests, simulation_status.\n\n\
70Items of restricted project groups you are not a member of are reported as not found; masked rows show as \
71'restricted'. Do not guess or retry.";
72
73fn response(id: Value, result: Value) -> Value {
74    json!({ "jsonrpc": "2.0", "id": id, "result": result })
75}
76
77fn error(id: Value, code: i64, message: impl Into<String>) -> Value {
78    json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message.into() } })
79}
80
81/// Told to a local agent the desktop app started (marked with
82/// [`LOCAL_AGENT_HEADER`]) when `TEAMCRAFT_LOCAL_AGENTS=off`.
83pub const LOCAL_AGENTS_OFF: &str =
84    "Local agents are turned off on this TeamCraft server by the administrator \
85(TEAMCRAFT_LOCAL_AGENTS=off). Use the company AI configured on the server instead: \
86Company AI in the desktop app's Agent view (Ask TeamCraft in the web UI).";
87
88/// Told to every other MCP client when `TEAMCRAFT_LOCAL_AGENTS=off`.
89pub const MCP_OFF: &str =
90    "The MCP endpoint is turned off on this TeamCraft server by the administrator \
91(TEAMCRAFT_LOCAL_AGENTS=off): AI agents outside the server are not allowed. Use the company AI \
92configured on the server instead: Ask TeamCraft in the web UI or Company AI in the desktop \
93app's Agent view.";
94
95/// Why a call must be refused under the local-agent policy, `None` = serve it.
96///
97/// With `off` the whole endpoint is refused for every token: the built-in
98/// assistant calls the tools in-process, so every HTTP MCP client is an agent
99/// outside the server (the desktop's Agent view, the `teamcraft mcp` bridge,
100/// an assistant set up by hand). [`LOCAL_AGENT_HEADER`] only picks the
101/// message; leaving it out changes nothing. Pure, for tests.
102pub fn local_agent_refusal(policy: LocalAgents, headers: &HeaderMap) -> Option<&'static str> {
103    if policy.allowed() {
104        return None;
105    }
106    let marked = headers.get_all(LOCAL_AGENT_HEADER).iter().any(|v| {
107        v.as_bytes()
108            .eq_ignore_ascii_case(LOCAL_AGENT_HEADER_VALUE.as_bytes())
109    });
110    Some(if marked { LOCAL_AGENTS_OFF } else { MCP_OFF })
111}
112
113/// `POST /api/v1/mcp`.
114pub async fn http(
115    State(st): State<AppState>,
116    ApiCaller { actor: me, scope }: ApiCaller,
117    log: AccessCtx,
118    headers: HeaderMap,
119    body: Bytes,
120) -> Response {
121    let log = log.through(Channel::Mcp);
122    // after ApiUser: without a valid token the answer stays 401
123    if let Some(message) = local_agent_refusal(st.config.local_agents, &headers) {
124        tracing::debug!(
125            user = %me.username,
126            "MCP call refused (TEAMCRAFT_LOCAL_AGENTS=off)"
127        );
128        return (
129            StatusCode::FORBIDDEN,
130            [(header::CONTENT_TYPE, "application/json")],
131            error(Value::Null, INVALID_REQUEST, message).to_string(),
132        )
133            .into_response();
134    }
135    // groups that switched MCP off look non-existent to every tool
136    let me = match project_policy::narrow(&st.db, me, &[Interface::Mcp]).await {
137        Ok(me) => me,
138        Err(e) => return e.into_response(),
139    };
140    let reply = match serde_json::from_slice::<Value>(&body) {
141        Err(e) => Some(error(Value::Null, PARSE_ERROR, format!("parse error: {e}"))),
142        Ok(Value::Array(batch)) if batch.is_empty() => {
143            Some(error(Value::Null, INVALID_REQUEST, "empty batch"))
144        }
145        Ok(Value::Array(batch)) if batch.len() > MAX_BATCH => {
146            Some(error(Value::Null, INVALID_REQUEST, "batch too large"))
147        }
148        Ok(Value::Array(batch)) => {
149            let mut replies = Vec::new();
150            for msg in batch {
151                if let Some(r) = handle(&st, &me, scope, &log, msg).await {
152                    replies.push(r);
153                }
154            }
155            (!replies.is_empty()).then_some(Value::Array(replies))
156        }
157        Ok(msg) => handle(&st, &me, scope, &log, msg).await,
158    };
159    match reply {
160        Some(r) => ([(header::CONTENT_TYPE, "application/json")], r.to_string()).into_response(),
161        None => StatusCode::ACCEPTED.into_response(),
162    }
163}
164
165/// `GET /api/v1/mcp`: this server opens no event stream.
166pub async fn http_get() -> Response {
167    (
168        StatusCode::METHOD_NOT_ALLOWED,
169        [(header::ALLOW, "POST")],
170        "TeamCraft's MCP endpoint answers POST requests only (no event stream).",
171    )
172        .into_response()
173}
174
175/// What a single JSON-RPC message asks for.
176#[derive(Debug, PartialEq)]
177pub enum Message {
178    /// A request: must be answered.
179    Request {
180        id: Value,
181        method: String,
182        params: Value,
183    },
184    /// A notification or a response to a request of ours: no answer.
185    Silent,
186    /// Malformed: answered with this error.
187    Invalid(Value),
188}
189
190/// Classifies one message (pure, no I/O).
191pub fn classify(msg: Value) -> Message {
192    let Value::Object(mut o) = msg else {
193        return Message::Invalid(error(
194            Value::Null,
195            INVALID_REQUEST,
196            "message must be an object",
197        ));
198    };
199    let id = o.remove("id");
200    // Only strings and numbers are valid ids.
201    if id
202        .as_ref()
203        .is_some_and(|v| !(v.is_string() || v.is_number()))
204    {
205        return Message::Invalid(error(
206            Value::Null,
207            INVALID_REQUEST,
208            "`id` must be a string or a number",
209        ));
210    }
211    let method = match o.remove("method") {
212        Some(Value::String(m)) => m,
213        Some(_) => {
214            return Message::Invalid(error(
215                id.unwrap_or(Value::Null),
216                INVALID_REQUEST,
217                "`method` must be a string",
218            ))
219        }
220        // Responses to requests of ours (there are none) are ignored.
221        None if o.contains_key("result") || o.contains_key("error") => return Message::Silent,
222        None => {
223            return Message::Invalid(error(
224                id.unwrap_or(Value::Null),
225                INVALID_REQUEST,
226                "missing `method`",
227            ))
228        }
229    };
230    match id {
231        // A notification (no id) never gets an answer.
232        None => Message::Silent,
233        Some(id) => Message::Request {
234            id,
235            method,
236            params: o.remove("params").unwrap_or(Value::Null),
237        },
238    }
239}
240
241/// The protocol revision answered to the one a client asks for.
242pub fn negotiate(asked: Option<&str>) -> &'static str {
243    asked
244        .and_then(|a| SUPPORTED_VERSIONS.iter().find(|v| **v == a))
245        .copied()
246        .unwrap_or(PROTOCOL_VERSION)
247}
248
249/// One JSON-RPC message in, at most one out (notifications get none). `log`
250/// is the request for the read log (channel MCP).
251pub async fn handle(
252    st: &AppState,
253    me: &Actor,
254    scope: TokenScope,
255    log: &AccessCtx,
256    msg: Value,
257) -> Option<Value> {
258    match classify(msg) {
259        Message::Silent => None,
260        Message::Invalid(reply) => Some(reply),
261        Message::Request { id, method, params } => {
262            Some(match request(st, me, scope, log, &method, &params).await {
263                Ok(result) => response(id, result),
264                Err((code, message)) => error(id, code, message),
265            })
266        }
267    }
268}
269
270async fn request(
271    st: &AppState,
272    me: &Actor,
273    scope: TokenScope,
274    log: &AccessCtx,
275    method: &str,
276    params: &Value,
277) -> Result<Value, (i64, String)> {
278    match method {
279        "initialize" => {
280            let version = negotiate(params.get("protocolVersion").and_then(Value::as_str));
281            Ok(json!({
282                "protocolVersion": version,
283                "capabilities": {
284                    "tools": { "listChanged": false },
285                    "prompts": { "listChanged": false },
286                },
287                "serverInfo": { "name": "teamcraft", "title": "TeamCraft PDM", "version": env!("CARGO_PKG_VERSION") },
288                "instructions": format!(
289                    "{INSTRUCTIONS}\n\nSigned in as {} ({}).{}",
290                    me.display_name,
291                    me.role.as_str(),
292                    if scope == TokenScope::ReadOnly { " This token is read-only: only the read-only tools are listed." } else { "" }
293                ),
294            }))
295        }
296        "ping" => Ok(json!({})),
297        "tools/list" => Ok(json!({ "tools": tools::definitions_for(scope) })),
298        "tools/call" => {
299            let name = params
300                .get("name")
301                .and_then(Value::as_str)
302                .ok_or((INVALID_PARAMS, "missing tool `name`".to_string()))?;
303            if !tools::exists(name) {
304                return Err((INVALID_PARAMS, format!("unknown tool `{name}`")));
305            }
306            if !scope.allows_tool(tools::is_read_only(name)) {
307                return Ok(tools::failure(scope.refusal()));
308            }
309            let args = match params.get("arguments") {
310                None | Some(Value::Null) => json!({}),
311                Some(a @ Value::Object(_)) => a.clone(),
312                Some(_) => return Err((INVALID_PARAMS, "`arguments` must be an object".into())),
313            };
314            Ok(tools::call_logged(st, me, name, &args, log).await)
315        }
316        "prompts/list" => Ok(json!({ "prompts": tools::prompts() })),
317        "prompts/get" => {
318            let name = params.get("name").and_then(Value::as_str).unwrap_or("");
319            let args = params.get("arguments").cloned().unwrap_or(json!({}));
320            tools::prompt(name, &args).map_err(|e| (INVALID_PARAMS, e))
321        }
322        "resources/list" => Ok(json!({ "resources": [] })),
323        "resources/templates/list" => Ok(json!({ "resourceTemplates": [] })),
324        _ => Err((METHOD_NOT_FOUND, format!("method `{method}` not found"))),
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    #[test]
333    fn local_agents_off_refuses_every_mcp_client() {
334        let mut marked = HeaderMap::new();
335        marked.insert(
336            LOCAL_AGENT_HEADER,
337            LOCAL_AGENT_HEADER_VALUE.parse().unwrap(),
338        );
339        let mut shouted = HeaderMap::new();
340        shouted.insert("X-TeamCraft-Client", "Desktop-Agent".parse().unwrap());
341        let mut other = HeaderMap::new();
342        other.insert(LOCAL_AGENT_HEADER, "cli".parse().unwrap());
343        let plain = HeaderMap::new();
344        // the header only picks the message: unmarked clients are refused too
345        assert_eq!(
346            local_agent_refusal(LocalAgents::Off, &marked),
347            Some(LOCAL_AGENTS_OFF)
348        );
349        assert_eq!(
350            local_agent_refusal(LocalAgents::Off, &shouted),
351            Some(LOCAL_AGENTS_OFF)
352        );
353        assert_eq!(local_agent_refusal(LocalAgents::Off, &other), Some(MCP_OFF));
354        assert_eq!(local_agent_refusal(LocalAgents::Off, &plain), Some(MCP_OFF));
355        for h in [&marked, &shouted, &other, &plain] {
356            assert_eq!(local_agent_refusal(LocalAgents::Allowed, h), None);
357        }
358        for m in [LOCAL_AGENTS_OFF, MCP_OFF] {
359            assert!(m.contains("TEAMCRAFT_LOCAL_AGENTS=off"), "{m}");
360            assert!(m.contains("Ask TeamCraft"), "{m}");
361        }
362    }
363
364    #[test]
365    fn requests_notifications_and_garbage_are_told_apart() {
366        let m = classify(json!({ "jsonrpc": "2.0", "id": 7, "method": "ping" }));
367        assert_eq!(
368            m,
369            Message::Request {
370                id: json!(7),
371                method: "ping".into(),
372                params: Value::Null
373            }
374        );
375        let m = classify(json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }));
376        assert_eq!(m, Message::Silent);
377        // a response from the client to a request of ours
378        assert_eq!(
379            classify(json!({ "jsonrpc": "2.0", "id": 1, "result": {} })),
380            Message::Silent
381        );
382        for bad in [
383            json!([1]),
384            json!("ping"),
385            json!({ "id": 1 }),
386            json!({ "id": 1, "method": 5 }),
387            json!({ "id": {"x": 1}, "method": "ping" }),
388        ] {
389            let Message::Invalid(reply) = classify(bad.clone()) else {
390                panic!("{bad} should be invalid");
391            };
392            assert_eq!(reply["error"]["code"], INVALID_REQUEST, "{bad}");
393        }
394        // the id of a malformed request is echoed when it is usable
395        let Message::Invalid(reply) = classify(json!({ "id": "a" })) else {
396            panic!()
397        };
398        assert_eq!(reply["id"], "a");
399    }
400
401    #[test]
402    fn protocol_versions_are_negotiated() {
403        assert_eq!(negotiate(Some("2025-03-26")), "2025-03-26");
404        assert_eq!(negotiate(Some("1999-01-01")), PROTOCOL_VERSION);
405        assert_eq!(negotiate(None), PROTOCOL_VERSION);
406    }
407}