1pub 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
38pub 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;
46const MAX_BATCH: usize = 50;
48
49pub 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
81pub 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
88pub 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
95pub 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
113pub 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 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 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
165pub 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#[derive(Debug, PartialEq)]
177pub enum Message {
178 Request {
180 id: Value,
181 method: String,
182 params: Value,
183 },
184 Silent,
186 Invalid(Value),
188}
189
190pub 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 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 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 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
241pub 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
249pub 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, ¶ms).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 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 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 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}