1use crate::*;
2use rmcp::{
3 ErrorData, RoleServer, ServerHandler,
4 model::{
5 CacheScope, CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock,
6 ListToolsResult, PaginatedRequestParams, ServerCapabilities, ServerConfig, Tool,
7 ToolAnnotations,
8 },
9 service::RequestContext,
10};
11use scraper::{Html, Selector};
12
13const MODEL_PATTERN: &str = r"^[a-z0-9][a-z0-9._-]{0,127}$";
14
15fn tokenless_r1616(document: &Html) -> bool {
16 if document
17 .select(&Selector::parse("input[name=csrf_token]").unwrap())
18 .next()
19 .is_some()
20 {
21 return false;
22 }
23 let links: Vec<_> = document.select(&Selector::parse("body#page-issue > footer.usa-footer .usa-footer__secondary-section a[href='https://astheno.software/shale/']").unwrap()).collect();
24 matches!(links.as_slice(), [link] if text(*link) == "shale r1616-ga87d2f5.zig.0.16.0")
25}
26
27
28pub struct Backend {
29 pub(crate) origin: url::Url,
30 http: reqwest::Client,
31 slots: Semaphore,
32}
33impl Backend {
34 pub fn new(address: &str, http: reqwest::ClientBuilder) -> Result<Self> {
35 let origin = url::Url::parse(address)?;
36 if origin.scheme() != "https"
37 || origin.host_str().is_none()
38 || !origin.username().is_empty()
39 || origin.password().is_some()
40 || origin.path() != "/"
41 || origin.query().is_some()
42 || origin.fragment().is_some()
43 {
44 return Err(Error::new(500, "Set an HTTPS origin for Shale."));
45 }
46 Ok(Self {
47 origin,
48 http: http
49 .redirect(reqwest::redirect::Policy::none())
50 .retry(reqwest::retry::never())
51 .build()?,
52 slots: Semaphore::new(8),
53 })
54 }
55 async fn get(
56 &self,
57 path: &str,
58 session: Option<&str>,
59 ) -> Result<(StatusCode, HeaderMap, String)> {
60 self.request(Method::GET, self.origin.join(path)?, session, None)
61 .await
62 }
63 async fn request(
64 &self,
65 method: Method,
66 target: url::Url,
67 session: Option<&str>,
68 form: Option<&HashMap<String, String>>,
69 ) -> Result<(StatusCode, HeaderMap, String)> {
70 if target.origin() != self.origin.origin()
71 || !target.username().is_empty()
72 || target.password().is_some()
73 || target.fragment().is_some()
74 {
75 return Err(Error::new(400, "Open a page on this Shale instance."));
76 }
77 let _slot = tokio::time::timeout(Duration::from_secs(5), self.slots.acquire())
78 .await
79 .map_err(|_| Error::new(429, "Shale is busy. Try again in a moment."))??;
80 let mut request = self.http.request(method, target.clone());
81 if let Some(session) = session {
82 let mut cookie = axum::http::HeaderValue::from_str(&format!("SessionID={session}"))?;
83 cookie.set_sensitive(true);
84 request = request.header("cookie", cookie);
85 }
86 if let Some(form) = form {
87 request = request
88 .header("origin", self.origin.origin().ascii_serialization())
89 .header("referer", target.as_str())
90 .form(form);
91 }
92 let mut response = request
93 .send()
94 .await
95 .map_err(|_| Error::new(502, "Shale couldn't answer. Open it and check its status."))?;
96 let status = response.status();
97 let headers = response.headers().clone();
98 let mut body = Vec::new();
99 while let Some(chunk) = response.chunk().await.map_err(|_| {
100 Error::new(
101 502,
102 "The Shale response was interrupted. Open it to check the result.",
103 )
104 })? {
105 if body.len() + chunk.len() > 4 * 1024 * 1024 {
106 return Err(Error::new(
107 502,
108 "The Shale page is too large. Narrow the selection.",
109 ));
110 }
111 body.extend_from_slice(&chunk);
112 }
113 Ok((status, headers, String::from_utf8(body)?))
114 }
115 async fn page(&self, target: &url::Url, session: &str) -> Result<String> {
116 let (status, _, body) = self
117 .request(Method::GET, target.clone(), Some(session), None)
118 .await?;
119 match status {
120 StatusCode::OK => Ok(body),
121 StatusCode::BAD_REQUEST => Err(Error::new(
122 400,
123 "Shale rejected this request. Check the fields or use Shale's issue filter syntax.",
124 )),
125 StatusCode::FORBIDDEN | StatusCode::NOT_FOUND => Err(Error::new(
126 403,
127 "Shale doesn't allow access to this repository. Check your account's permissions.",
128 )),
129 status if status.is_redirection() || status == StatusCode::UNAUTHORIZED => {
130 Err(Error::new(401, "The Shale session expired. Link it again."))
131 }
132 _ => Err(Error::new(
133 502,
134 "Shale couldn't open this page. Check it in Shale.",
135 )),
136 }
137 }
138}
139
140fn document(html: &str, page: &str, repository: Option<&str>) -> Result<Html> {
141 let document = Html::parse_document(html);
142 if document
143 .select(&Selector::parse("body").unwrap())
144 .next()
145 .and_then(|body| body.attr("id"))
146 != Some(page)
147 || repository.is_some_and(|name| {
148 document
149 .select(&Selector::parse("meta[name='astheno.shale.repo.name']").unwrap())
150 .next()
151 .and_then(|meta| meta.attr("content"))
152 != Some(name)
153 })
154 {
155 return Err(Error::new(
156 502,
157 "Shale's page changed. Open it to check the result.",
158 ));
159 }
160 Ok(document)
161}
162fn text(element: scraper::ElementRef<'_>) -> String {
163 element.text().collect::<String>().trim().to_owned()
164}
165fn repository_path(origin: &url::Url, repository: &str, suffix: &[&str]) -> Result<url::Url> {
166 if repository.is_empty()
167 || repository.len() > 255
168 || repository
169 .split('/')
170 .any(|part| matches!(part, "" | "." | ".." | "-"))
171 || repository
172 .chars()
173 .any(|c| c.is_control() || c.is_whitespace() || "\\%?#".contains(c))
174 {
175 return Err(Error::new(
176 400,
177 "Choose a repository from this connection's access.",
178 ));
179 }
180 let mut target = origin.clone();
181 target
182 .path_segments_mut()
183 .unwrap()
184 .clear()
185 .extend(repository.split('/'))
186 .extend(suffix.iter().copied());
187 Ok(target)
188}
189fn session(app: &App, owner: &str) -> Result<String> {
190 let credential = mcp::get(
191 &app.mcp.db.lock().unwrap(),
192 &format!("shale-session:{owner}"),
193 )?;
194 if credential["origin"] != app.shale.origin.as_str() {
195 return Err(Error::new(
196 401,
197 "Link your Shale account from the dashboard's MCP tab.",
198 ));
199 }
200 credential["session"]
201 .as_str()
202 .map(str::to_owned)
203 .ok_or_else(|| Error::new(401, "Link your Shale account from the dashboard's MCP tab."))
204}
205pub(crate) async fn verified_session(app: &App, owner: &str) -> Result<String> {
206 let session = session(app, owner)?;
207 username(
208 &app.shale
209 .page(&app.shale.origin.join("/-/settings")?, &session)
210 .await?,
211 )?;
212 Ok(session)
213}
214pub async fn repositories(app: &App, owner: &str) -> Result<Vec<Value>> {
215 let session = verified_session(app, owner).await?;
216 let body = app.shale.page(&app.shale.origin, &session).await?;
217 let document = document(&body, "page-index", None)?;
218 let mut repositories = Vec::new();
219 for row in document.select(&Selector::parse(".grid-container table tbody tr").unwrap()) {
220 let cells: Vec<_> = row.select(&Selector::parse("td").unwrap()).collect();
221 if cells.len() != 3 {
222 return Err(Error::new(
223 502,
224 "Shale's repository list changed. Open Shale to browse it.",
225 ));
226 }
227 let link = cells[0]
228 .select(&Selector::parse("a").unwrap())
229 .next()
230 .ok_or_else(|| {
231 Error::new(
232 502,
233 "Shale's repository list changed. Open Shale to browse it.",
234 )
235 })?;
236 let name = text(link);
237 let target = app
238 .shale
239 .origin
240 .join(link.attr("href").unwrap_or_default())?;
241 if target != repository_path(&app.shale.origin, &name, &[""])?
242 || repositories.iter().any(|r: &Value| r["id"] == name)
243 {
244 return Err(Error::new(
245 502,
246 "Shale's repository list changed. Open Shale to browse it.",
247 ));
248 }
249 repositories.push(json!({"id":name,"name":name,"description":text(cells[1])}));
250 }
251 if repositories.len() > 1024 {
252 return Err(Error::new(
253 502,
254 "The repository list is too large. Open Shale to narrow it.",
255 ));
256 }
257 Ok(repositories)
258}
259fn issue(html: &str, repository: &str, id: Option<u64>) -> Result<Value> {
260 let document = document(html, "page-issue", Some(repository))?;
261 let spans: Vec<_> = document
262 .select(&Selector::parse("h1 > span").unwrap())
263 .collect();
264 let (issue_id, title) = if spans.len() == 2 {
265 (
266 text(spans[0])
267 .strip_prefix('#')
268 .and_then(|id| id.parse::<u64>().ok()),
269 Some(text(spans[1])),
270 )
271 } else if spans.is_empty() && tokenless_r1616(&document) {
272 let headings: Vec<_> = document.select(&Selector::parse("h1").unwrap()).collect();
273 if let [heading] = headings.as_slice() {
274 text(*heading)
275 .strip_prefix("Issue #")
276 .and_then(|text| text.split_once(": "))
277 .map(|(id, title)| (id.parse::<u64>().ok(), Some(title.to_owned())))
278 .unwrap_or((None, None))
279 } else {
280 (None, None)
281 }
282 } else {
283 (None, None)
284 };
285 let statuses: Vec<_> = document
286 .select(&Selector::parse("dl.sidebar dd span[class*='issuestatus-']").unwrap())
287 .filter_map(|status| {
288 status
289 .value()
290 .classes()
291 .find_map(|class| class.strip_prefix("issuestatus-"))
292 })
293 .collect();
294 let status = match statuses.as_slice() {
295 [status] => Some(*status),
296 _ => None,
297 };
298 if title.as_ref().is_none_or(|title| title.is_empty())
299 || issue_id.is_none_or(|n| n == 0 || id.is_some_and(|id| n != id))
300 || status.is_none()
301 {
302 return Err(Error::new(
303 502,
304 "Shale's issue page changed. Open the issue to check it.",
305 ));
306 }
307 let mut comments = Vec::new();
308 for comment in document.select(&Selector::parse("li.comment").unwrap()) {
309 let content = comment
310 .select(&Selector::parse(".markdown").unwrap())
311 .next()
312 .ok_or_else(|| {
313 Error::new(
314 502,
315 "Shale's comments changed. Open the issue to read them.",
316 )
317 })?;
318 let author = comment
319 .select(&Selector::parse(".n-card__header span.usa-link.va-middle-childs, .n-card__header a[href^='/~'], .n-card__header a[href^='https://github.com/'], .n-card__header a:not([href]).va-middle-childs").unwrap())
320 .next()
321 .map(text);
322 let time = comment
323 .select(&Selector::parse(".n-card__header span[title]").unwrap())
324 .next()
325 .and_then(|e| e.attr("title"));
326 comments.push(
327 json!({"id":comment.attr("id"),"author":author,"createdAt":time,"text":text(content)}),
328 );
329 }
330 let labels: Vec<_> = document
331 .select(&Selector::parse("dd.sidebar-labels a").unwrap())
332 .map(text)
333 .collect();
334 Ok(
335 json!({"repository":repository,"id":issue_id,"title":title.unwrap(),"status":status,"labels":labels,"comments":comments}),
336 )
337}
338fn labels(html: &str, repository: &str) -> Result<Value> {
339 let document = document(html, "page-labels", Some(repository))?;
340 let mut labels = Vec::new();
341 for row in document.select(&Selector::parse(".grid-container table tbody tr").unwrap()) {
342 let cells: Vec<_> = row.select(&Selector::parse("td").unwrap()).collect();
343 let label = row
344 .select(&Selector::parse("a.issuelabel[data-label-id][data-color]").unwrap())
345 .next();
346 if cells.len() != 3 || label.is_none() {
347 return Err(Error::new(
348 502,
349 "Shale's label list changed. Open its labels page.",
350 ));
351 }
352 let label = label.unwrap();
353 let id = label
354 .attr("data-label-id")
355 .and_then(|s| s.parse::<u64>().ok())
356 .filter(|id| *id > 0)
357 .ok_or_else(|| Error::new(502, "Shale's label IDs changed. Open its labels page."))?;
358 let open = text(cells[2])
359 .parse::<u64>()
360 .map_err(|_| Error::new(502, "Shale's label counts changed. Open its labels page."))?;
361 labels.push(json!({"id":id,"name":text(label),"description":text(cells[1]),"color":label.attr("data-color"),"openIssues":open}));
362 }
363 Ok(json!({"repository":repository,"labels":labels}))
364}
365
366fn current(app: &App, grant: &Value, credential: &str) -> Result<()> {
367 let db = app.mcp.db.lock().unwrap();
368 if mcp::get(&db, &format!("grant:{}", string(&grant["id"])))? != *grant
369 || mcp::get(&db, &format!("shale-session:{}", string(&grant["user"])))?["session"]
370 != credential
371 {
372 return Err(Error::new(
373 401,
374 "This connection changed or was revoked. Connect again.",
375 ));
376 }
377 Ok(())
378}
379
380#[derive(Clone)]
381struct Shale(Arc<App>);
382impl ServerHandler for Shale {
383 fn get_info(&self) -> ServerConfig {
384 ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
385 .with_instructions("Every write requires the actual model identifier in model and uses a durable ai-[owner]-[model] account on behalf of the connection owner. Never invent a model or supply a different owner. Label writes require the repository owner’s connection. Status writes require the owner’s connection or an explicit repository delegation. Issue text automatically receives an Assisted-by trailer matching model. Create confirmed tasks with status todo; use triage for unanswered questions (the default). Never retry an uncertain write without reading its outcome.")
386 }
387 async fn list_tools(
388 &self,
389 _: Option<PaginatedRequestParams>,
390 _: RequestContext<RoleServer>,
391 ) -> std::result::Result<ListToolsResult, ErrorData> {
392 let tools = [
393 ("list_repositories", "List repositories granted to this connection.", json!({}), json!([]), true),
394 ("list_issues", "List issues in one granted repository.", json!({"repository":{"type":"string"},"q":{"type":"string","maxLength":4096,"description":"Shale filters using is, status, sort, limit, label, or author prefixes, such as is:open. Plain text search is unsupported."}}), json!(["repository"]), true),
395 ("list_labels", "List repository label IDs, names, descriptions, colors, and open issue counts.", json!({"repository":{"type":"string"}}), json!(["repository"]), true),
396 ("create_label", "Create a repository label using its owner's connection.", json!({"repository":{"type":"string"},"name":{"type":"string","minLength":1,"maxLength":256},"description":{"type":"string","maxLength":4096},"color":{"type":"string","pattern":"^#[0-9a-fA-F]{6}$"}}), json!(["repository","name","description","color"]), false),
397 ("update_label", "Replace a label's name, description, and color using its owner's connection.", json!({"repository":{"type":"string"},"label_id":{"type":"integer","minimum":1},"name":{"type":"string","minLength":1,"maxLength":256},"description":{"type":"string","maxLength":4096},"color":{"type":"string","pattern":"^#[0-9a-fA-F]{6}$"}}), json!(["repository","label_id","name","description","color"]), false),
398 ("delete_label", "Delete a repository label using its owner's connection. Labels referenced by issue history cannot be deleted.", json!({"repository":{"type":"string"},"label_id":{"type":"integer","minimum":1}}), json!(["repository","label_id"]), false),
399 ("set_issue_labels", "Replace an issue's labels with repository label IDs using its owner's connection. An empty array clears them.", json!({"repository":{"type":"string"},"id":{"type":"integer","minimum":1},"labels":{"type":"array","items":{"type":"integer","minimum":1},"maxItems":100,"uniqueItems":true}}), json!(["repository","id","labels"]), false),
400 ("get_issue", "Read an issue with rendered comment text and labels.", json!({"repository":{"type":"string"},"id":{"type":"integer","minimum":1}}), json!(["repository","id"]), true),
401 ("create_issue", "Create an issue. If the outcome is unknown, inspect the repository before retrying.", json!({"repository":{"type":"string"},"title":{"type":"string","minLength":1,"maxLength":4096},"description":{"type":"string","maxLength":262144},"status":{"type":"string","enum":["todo","triage"],"default":"triage","description":"todo for confirmed tasks; triage for unanswered questions."}}), json!(["repository","title"]), false),
402 ("comment_issue", "Add a comment. If the outcome is unknown, inspect the issue before retrying.", json!({"repository":{"type":"string"},"id":{"type":"integer","minimum":1},"comment":{"type":"string","minLength":1,"maxLength":262144}}), json!(["repository","id","comment"]), false),
403 ("set_issue_status", "Change issue status using an available Shale status.", json!({"repository":{"type":"string"},"id":{"type":"integer","minimum":1},"status":{"type":"string","maxLength":64}}), json!(["repository","id","status"]), false),
404 ("set_issue_title", "Change an issue title.", json!({"repository":{"type":"string"},"id":{"type":"integer","minimum":1},"title":{"type":"string","minLength":1,"maxLength":4096}}), json!(["repository","id","title"]), false),
405 ].into_iter().map(|(name, description, properties, required, read)| {
406 let mut properties = properties;
407 let mut required = required;
408 if !read {
409 properties["model"] = json!({"type":"string","pattern":MODEL_PATTERN,"description":"Actual model identifier; determines the durable author account."});
410 required.as_array_mut().unwrap().push(json!("model"));
411 }
412 Tool::new(name, description, json!({"type":"object","properties":properties,"required":required,"additionalProperties":false}).as_object().unwrap().clone())
413 .with_annotations(ToolAnnotations::new().read_only(read).idempotent(read))
414 }).collect();
415 Ok(ListToolsResult {
416 tools,
417 ttl_ms: Some(0),
418 cache_scope: Some(CacheScope::Private),
419 ..Default::default()
420 })
421 }
422 async fn call_tool(
423 &self,
424 request: CallToolRequestParams,
425 context: RequestContext<RoleServer>,
426 ) -> std::result::Result<CallToolResponse, ErrorData> {
427 let result: Result<Value> = async {
428 let app = &self.0;
429 let grant = &context.extensions.get::<axum::http::request::Parts>()
430 .and_then(|p| p.extensions.get::<mcp::Grant>())
431 .ok_or_else(|| Error::new(401, "This connection expired. Connect again."))?.0;
432 let name = request.name.as_ref();
433 let arguments = request.arguments.unwrap_or_default();
434 let allowed: &[&str] = match name {
435 "list_repositories" => &[], "list_issues" => &["repository", "q"], "get_issue" => &["repository", "id"],
436 "list_labels" => &["repository"], "create_label" => &["repository", "name", "description", "color"],
437 "update_label" => &["repository", "label_id", "name", "description", "color"], "delete_label" => &["repository", "label_id"],
438 "set_issue_labels" => &["repository", "id", "labels"],
439 "create_issue" => &["repository", "title", "description", "status"], "comment_issue" => &["repository", "id", "comment"],
440 "set_issue_status" => &["repository", "id", "status"], "set_issue_title" => &["repository", "id", "title"],
441 _ => return Err(Error::new(404, "No tool with that name.")),
442 };
443 if arguments.keys().any(|key| key != "model" && !allowed.contains(&key.as_str())) {
444 return Err(Error::new(400, "Use the fields listed for this tool."));
445 }
446 let write = !matches!(name, "list_repositories" | "list_issues" | "list_labels" | "get_issue");
447 if !array(&grant["scopes"]).iter().any(|s| s == if write {"shale:write"} else {"shale:read"}) {
448 return Err(Error::new(403, "This connection allows reads only. Connect again to request issue editing."));
449 }
450 let model = if write {
451 Some(arguments.get("model").and_then(Value::as_str)
452 .filter(|id| regex::Regex::new(MODEL_PATTERN).unwrap().is_match(id))
453 .ok_or_else(|| Error::new(400, "Supply the actual model identifier in model."))?)
454 } else {
455 if arguments.contains_key("model") { return Err(Error::new(400, "Read tools do not need a model.")); }
456 None
457 };
458 let model = model.map(|id| if id == "qwen3.8-27b" { "qwen-3.8-27b" } else { id });
459 let credential = session(app, string(&grant["user"]))?;
460 current(app, grant, &credential)?;
461 if name == "list_repositories" {
462 let mut repositories = repositories(app, string(&grant["user"])).await?;
463 repositories.retain(|r| grant["resources"] == "all" || array(&grant["resources"]).contains(&r["id"]));
464 current(app, grant, &credential)?;
465 return Ok(json!({"repositories":repositories}));
466 }
467 let repository = arguments.get("repository").and_then(Value::as_str)
468 .filter(|r| grant["resources"] == "all" || array(&grant["resources"]).iter().any(|id| id == *r))
469 .ok_or_else(|| Error::new(403, "Choose a repository granted to this connection."))?;
470 if matches!(name, "create_label" | "update_label" | "delete_label" | "set_issue_labels") {
471 let mut fields = arguments.clone();
472 fields.remove("repository");
473 current(app, grant, &credential)?;
474 let result = host::call(json!({"operation":"shale.write","grant":grant["id"],"repository":repository,"action":name,"arguments":fields})).await.map_err(|error| if error.status >= 500 { Error::new(502, "The label write outcome is unknown. Check the labels in Shale before retrying.") } else { error })?;
475 current(app, grant, &credential).map_err(|_| Error::new(502, "The label write outcome is unknown. Check the labels in Shale before retrying."))?;
476 return Ok(result);
477 }
478 if name == "list_labels" {
479 let target = repository_path(&app.shale.origin, repository, &["issues", "labels"])?;
480 let result = labels(&app.shale.page(&target, &credential).await?, repository)?;
481 current(app, grant, &credential)?;
482 return Ok(result);
483 }
484 let mut target = repository_path(&app.shale.origin, repository, &["issues", ""])?;
485 let issue_id = if matches!(name, "list_issues" | "create_issue") { None } else {
486 Some(arguments.get("id").and_then(Value::as_u64).filter(|n| *n > 0)
487 .ok_or_else(|| Error::new(400, "Choose a positive issue ID."))?)
488 };
489 if let Some(id) = issue_id { target = repository_path(&app.shale.origin, repository, &["issues", &id.to_string()])?; }
490 if name == "create_issue" { target = repository_path(&app.shale.origin, repository, &["issues", "new"])?; }
491 if let Some(q) = arguments.get("q") {
492 let q = q.as_str().filter(|q| q.len() <= 4096).ok_or_else(|| Error::new(400, "Narrow the issue search."))?;
493 target.query_pairs_mut().append_pair("q", q);
494 }
495 let mut fields = HashMap::new();
496 for (key, max) in [("title", 4096), ("description", 262144), ("comment", 262144), ("status", 64)] {
497 if allowed.contains(&key) && !(key == "status" && name == "create_issue") {
498 let value = arguments.get(key).and_then(Value::as_str)
499 .filter(|v| v.len() <= max && (key == "description" || !v.trim().is_empty()))
500 .or_else(|| (key == "description" && !arguments.contains_key(key)).then_some(""))
501 .ok_or_else(|| Error::new(400, format!("Enter {key} within the tool's size limit.")))?;
502 fields.insert(key.to_owned(), value.to_owned());
503 }
504 }
505 if matches!(name, "create_issue" | "comment_issue") {
506 let model = model.unwrap();
507 let field = if name == "create_issue" { "description" } else { "comment" };
508 let body = fields.get_mut(field).unwrap();
509 if let Some(existing) = crate::shale_page::assisted_model(body) {
510 if existing != model { return Err(Error::new(400, "The model and Assisted-by trailer differ. Use the actual model identifier in both.")); }
511 } else {
512 *body = format!("{}\n\nAssisted-by: {model}", body.trim_end());
513 if crate::shale_page::assisted_model(body) != Some(model) { return Err(Error::new(400, "Close the code block before adding the model trailer.")); }
514 if body.len() > 262144 { return Err(Error::new(400, "Shorten the comment to leave room for its Assisted-by trailer.")); }
515 }
516 }
517 username(&app.shale.page(&app.shale.origin.join("/-/settings")?, &credential).await?)?;
518 let body = app.shale.page(&target, &credential).await?;
519 if name == "list_issues" {
520 let document = document(&body, "page-issues", Some(repository))?;
521 let mut issues = Vec::new();
522 for row in document.select(&Selector::parse(".grid-container table tbody tr").unwrap()) {
523 let cells: Vec<_> = row.select(&Selector::parse("td").unwrap()).collect();
524 if cells.len() != 6 { return Err(Error::new(502, "Shale's issue list changed. Open it in Shale.")); }
525 let id = text(cells[0]).strip_prefix('#').and_then(|s| s.parse::<u64>().ok()).filter(|n| *n > 0)
526 .ok_or_else(|| Error::new(502, "Shale's issue list changed. Open it in Shale."))?;
527 let status = cells[1].select(&Selector::parse("span[class]").unwrap())
528 .flat_map(|span| span.value().classes()).find_map(|class| class.strip_prefix("issuestatus-"))
529 .filter(|status| !status.is_empty())
530 .ok_or_else(|| Error::new(502, "Shale's issue list changed. Open it in Shale."))?;
531 issues.push(json!({"id":id,"title":text(cells[2]),"status":status,"author":text(cells[3]),
532 "modifiedAt":cells[4].select(&Selector::parse("span[title]").unwrap()).next().and_then(|e| e.attr("title")),
533 "createdAt":cells[5].select(&Selector::parse("span[title]").unwrap()).next().and_then(|e| e.attr("title"))}));
534 }
535 current(app, grant, &credential)?;
536 return Ok(json!({"repository":repository,"issues":issues}));
537 }
538 if !write {
539 let issue = issue(&body, repository, issue_id)?;
540 current(app, grant, &credential)?;
541 return Ok(json!({"issue":issue}));
542 }
543 if name == "create_issue" {
544 document(&body, "page-new-issue", Some(repository))?;
545 let status = match arguments.get("status").and_then(Value::as_str) {
546 None if !arguments.contains_key("status") => "needs_triage",
547 Some("triage") => "needs_triage",
548 Some("todo") => "todo",
549 _ => return Err(Error::new(400, "Choose todo or triage for a new issue.")),
550 };
551 fields.insert("status".into(), status.into());
552 } else { issue(&body, repository, issue_id)?; }
553 let mut fields = serde_json::to_value(fields)?;
554 if let Some(id) = issue_id { fields["id"] = json!(id); }
555 fields["model"] = json!(model.unwrap());
556 current(app, grant, &credential)?;
557 let result = host::call(json!({"operation":"shale.write","grant":grant["id"],"repository":repository,"action":name,"arguments":fields})).await
558 .map_err(|error| if error.status >= 500 { Error::new(502, "The write outcome is unknown. Check Shale before retrying.") } else { error })?;
559 let id = result["id"].as_u64().ok_or_else(|| Error::new(502, "The write outcome is unknown. Check Shale before retrying."))?;
560 let target = repository_path(&app.shale.origin, repository, &["issues", &id.to_string()])?;
561 let result = issue(&app.shale.page(&target, &credential).await?, repository, Some(id))?;
562 current(app, grant, &credential).map_err(|_| Error::new(502, "The write outcome is unknown. Check Shale before retrying."))?;
563 Ok(json!({"issue":result}))
564 }.await;
565 Ok(match result {
566 Ok(value) => CallToolResult::structured(value),
567 Err(error) => CallToolResult::error(vec![ContentBlock::text(error.message)]),
568 }
569 .into())
570 }
571}
572pub fn router(app: Arc<App>) -> Router {
573 let state = app.clone();
574 mcp::router(app, "shale", move || Ok(Shale(state.clone())))
575}
576
577fn session_cookie(headers: &HeaderMap) -> Result<String> {
578 let sessions: Vec<_> = headers
579 .get_all("set-cookie")
580 .iter()
581 .filter_map(|value| value.to_str().ok())
582 .filter_map(|value| value.split(';').next()?.strip_prefix("SessionID="))
583 .collect();
584 if sessions.len() != 1
585 || sessions[0].is_empty()
586 || sessions[0].len() > 4096
587 || !sessions[0]
588 .bytes()
589 .all(|b| matches!(b, 0x21 | 0x23..=0x2b | 0x2d..=0x3a | 0x3c..=0x5b | 0x5d..=0x7e))
590 {
591 return Err(Error::new(
592 502,
593 "Shale didn't return a session. Link it again.",
594 ));
595 }
596 Ok(sessions[0].to_owned())
597}
598fn fields(query: &str) -> Result<HashMap<String, String>> {
599 if query.len() > 16384 {
600 return Err(Error::new(
601 400,
602 "This sign-in response is too large. Link Shale again.",
603 ));
604 }
605 let mut fields = HashMap::new();
606 for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
607 if fields
608 .insert(key.into_owned(), value.into_owned())
609 .is_some()
610 {
611 return Err(Error::new(
612 400,
613 "This sign-in response has repeated fields. Link Shale again.",
614 ));
615 }
616 }
617 Ok(fields)
618}
619fn username(page: &str) -> Result<String> {
620 let document = Html::parse_document(page);
621 if document
622 .select(&Selector::parse("body#page-user-settings").unwrap())
623 .next()
624 .is_none()
625 {
626 return Err(Error::new(401, "The Shale session expired. Link it again."));
627 }
628 let names: Vec<_> = document
629 .select(&Selector::parse("kbd").unwrap())
630 .map(|element| element.text().collect::<String>())
631 .collect();
632 if names.len() != 1 || names[0].is_empty() {
633 return Err(Error::new(
634 502,
635 "Shale's account page changed. Open Shale to check your account.",
636 ));
637 }
638 Ok(names.into_iter().next().unwrap())
639}
640
641pub async fn manage(
642 app: Arc<App>,
643 method: &Method,
644 owner: &Value,
645 body: &Value,
646) -> Result<Response> {
647 let owner_id = string(&owner["id"]);
648 let key = format!("shale-session:{owner_id}");
649 match *method {
650 Method::GET => match repositories(&app, owner_id).await {
651 Ok(resources) => {
652 Ok(axum::Json(json!({"linked":true,"resources":resources})).into_response())
653 }
654 Err(error) if error.status == 401 => {
655 Ok(axum::Json(json!({"linked":false,"resources":[]})).into_response())
656 }
657 Err(error) => Err(error),
658 },
659 Method::POST => {
660 let pending = if let Some(id) = body["request"].as_str() {
661 let db = app.mcp.db.lock().unwrap();
662 let key = format!("pending:{}", mcp::hash(id));
663 let pending = mcp::get(&db, &key)?;
664 if pending["owner"] != owner_id || pending["resource"] != app.mcp.resource("shale")
665 {
666 return Err(Error::new(
667 403,
668 "Open your Shale connection request before linking.",
669 ));
670 }
671 Some(id.to_owned())
672 } else {
673 None
674 };
675 let (status, headers, _) = app.shale.get("/-/login", None).await?;
676 if status != StatusCode::FOUND {
677 return Err(Error::new(
678 502,
679 "Shale couldn't start sign-in. Open Shale and try again.",
680 ));
681 }
682 let authorization = url::Url::parse(
683 headers
684 .get("location")
685 .and_then(|v| v.to_str().ok())
686 .unwrap_or_default(),
687 )?;
688 let issuer = &app.auth.origin;
689 let parameters = fields(authorization.query().unwrap_or_default())?;
690 if authorization.origin() != issuer.origin()
691 || authorization.path() != "/auth/oidc/authorize"
692 || !authorization.username().is_empty()
693 || authorization.password().is_some()
694 || authorization.fragment().is_some()
695 || parameters.get("redirect_uri")
696 != Some(&app.shale.origin.join("/-/callback")?.to_string())
697 || parameters.get("response_type").map(String::as_str) != Some("code")
698 || parameters
699 .get("state")
700 .is_none_or(|s| s.is_empty() || s.len() > 1024)
701 {
702 return Err(Error::new(
703 502,
704 "Shale's sign-in destination doesn't match this instance. Check its OIDC settings.",
705 ));
706 }
707 let nonce = mcp::secret();
708 let mut db = app.mcp.db.lock().unwrap();
709 let tx = db.transaction()?;
710 tx.execute("DELETE FROM records WHERE substr(key,1,11)='shale-link:' AND json_extract(value,'$.owner')=?", [owner_id])?;
711 mcp::put(
712 &tx,
713 &format!("shale-link:{}", mcp::hash(&nonce)),
714 &json!({"owner":owner_id,"authorization":authorization.as_str(),"phase":"prepared","request":pending}),
715 600,
716 )?;
717 tx.commit()?;
718 Ok(axum::Json(json!({"redirect":app.shale.origin.join(&format!("/-/studio-mcp/{nonce}"))?.as_str()})).into_response())
719 }
720 Method::DELETE => {
721 let session = {
722 let mut db = app.mcp.db.lock().unwrap();
723 let tx = db.transaction()?;
724 let session = mcp::get(&tx, &key)?;
725 mcp::delete(&tx, &key)?;
726 for grant in mcp::list(&tx, "grant:")? {
727 if grant["user"] == owner_id && grant["resource"] == app.mcp.resource("shale") {
728 mcp::revoke(&tx, string(&grant["id"]))?;
729 }
730 }
731 tx.execute("DELETE FROM records WHERE substr(key,1,11)='shale-link:' AND json_extract(value,'$.owner')=?", [owner_id])?;
732 tx.commit()?;
733 session
734 };
735 if session["origin"] == app.shale.origin.as_str() {
736 app.shale
737 .get("/-/logout", session["session"].as_str())
738 .await?;
739 }
740 Ok(StatusCode::NO_CONTENT.into_response())
741 }
742 _ => Err(Error::new(405, "Link or unlink Shale from MCP settings.")),
743 }
744}
745
746pub async fn oauth(app: Arc<App>, request: Request) -> Response {
747 let callback = request.uri().path() == "/oauth/shale/callback";
748 let result: Result<Response> = async move {
749 if request.method() != Method::GET
750 || request.headers().get("host").and_then(|v| v.to_str().ok()) != Some(&app.shale.origin[url::Position::BeforeHost..url::Position::AfterPort])
751 || request.headers().get("origin").is_some_and(|v| v.to_str().ok() != Some(app.shale.origin.origin().ascii_serialization().as_str()))
752 {
753 return Err(Error::new(403, "Start Shale linking from your dashboard's MCP tab."));
754 }
755 if let Some(nonce) = request.uri().path().strip_prefix("/oauth/shale/link/") {
756 if nonce.len() != 43 || !nonce.bytes().all(|b| b.is_ascii_alphanumeric() || b"_-".contains(&b)) || request.uri().query().is_some() {
757 return Err(Error::new(400, "This Shale link is incomplete. Start linking again."));
758 }
759 let mut db = app.mcp.db.lock().unwrap();
760 let tx = db.transaction()?;
761 let key = format!("shale-link:{}", mcp::hash(nonce));
762 let mut link = mcp::get(&tx, &key)?;
763 if link.is_null() || link["phase"] != "prepared" {
764 return Err(Error::new(410, "This Shale link expired or was used. Start linking again."));
765 }
766 link["phase"] = json!("claimed");
767 tx.execute("UPDATE records SET value=? WHERE key=?", rusqlite::params![link.to_string(), key])?;
768 tx.commit()?;
769 return Ok((StatusCode::FOUND, [
770 ("location", string(&link["authorization"]).to_owned()),
771 ("set-cookie", format!("studio_mcp_shale_link={nonce}; Path=/-/callback; Secure; HttpOnly; SameSite=Lax; Max-Age=600")),
772 ]).into_response());
773 }
774 if !callback {
775 return Err(Error::new(404, "No Shale sign-in endpoint here."));
776 }
777 let cookies: Vec<_> = request.headers().get_all("cookie").iter()
778 .filter_map(|v| v.to_str().ok()).flat_map(|v| v.split(';'))
779 .filter_map(|part| part.trim().strip_prefix("studio_mcp_shale_link=")).collect();
780 if cookies.len() != 1 || cookies[0].len() != 43 {
781 return Err(Error::new(400, "This Shale link cookie is missing. Start linking again."));
782 }
783 let query = request.uri().query().unwrap_or_default().to_owned();
784 let parameters = fields(&query)?;
785 let pending_key = format!("shale-link:{}", mcp::hash(cookies[0]));
786 let link = {
787 let mut db = app.mcp.db.lock().unwrap();
788 let tx = db.transaction()?;
789 let mut link = mcp::get(&tx, &pending_key)?;
790 if link.is_null() || link["phase"] != "claimed" {
791 return Err(Error::new(410, "This Shale link expired or was used. Start linking again."));
792 }
793 let authorization = url::Url::parse(string(&link["authorization"]))?;
794 let expected = fields(authorization.query().unwrap_or_default())?;
795 if parameters.get("state") != expected.get("state") {
796 return Err(Error::new(403, "This response belongs to another Shale sign-in. Start linking again."));
797 }
798 link["phase"] = json!("processing");
799 tx.execute("UPDATE records SET value=? WHERE key=?", rusqlite::params![link.to_string(), pending_key])?;
800 tx.commit()?;
801 link
802 };
803 if parameters.get("code").is_none_or(|code| code.is_empty() || code.len() > 4096) || parameters.contains_key("error") {
804 return Err(Error::new(400, "Shale sign-in was declined or incomplete. Start linking again."));
805 }
806 let expected_username = oidc::username(&app.auth, string(&link["owner"]), "shale")?;
807 let (status, headers, _) = app.shale.get(&format!("/-/callback?{query}"), None).await?;
808 if !status.is_redirection() {
809 return Err(Error::new(502, "Shale couldn't finish sign-in. Link it again."));
810 }
811 let session = session_cookie(&headers)?;
812 let verified: Result<()> = async {
813 let (status, _, body) = app.shale.get("/-/settings", Some(&session)).await?;
814 if status != StatusCode::OK || username(&body)? != expected_username {
815 return Err(Error::new(403, "Sign in to Shale with the same account as your dashboard, then link it again."));
816 }
817 let owner = string(&link["owner"]);
818 let session_key = format!("shale-session:{owner}");
819 let previous = mcp::get(&app.mcp.db.lock().unwrap(), &session_key)?;
820 if previous["origin"] == app.shale.origin.as_str() && previous["session"] != session {
821 app.shale.get("/-/logout", previous["session"].as_str()).await?;
822 }
823 let mut db = app.mcp.db.lock().unwrap();
824 let tx = db.transaction()?;
825 if mcp::get(&tx, &pending_key)?["phase"] != "processing" {
826 return Err(Error::new(410, "This Shale link was cancelled. Start linking again."));
827 }
828 mcp::put(&tx, &session_key, &json!({"origin":app.shale.origin.as_str(),"session":session,"linkedAt":now()}), 0)?;
829 mcp::delete(&tx, &pending_key)?;
830 tx.commit()?;
831 Ok(())
832 }.await;
833 if let Err(error) = verified {
834 let _ = app.shale.get("/-/logout", Some(&session)).await;
835 return Err(error);
836 }
837 let target = app.mcp.origin.join(&match link["request"].as_str() {
838 Some(request) => format!("connect/{request}"),
839 None => "mcp/settings/shale".to_owned(),
840 })?;
841 Ok((StatusCode::SEE_OTHER, [("location", target.to_string())]).into_response())
842 }.await;
843 let mut response = match result {
844 Ok(response) => response,
845 Err(error) => error.into_response(),
846 };
847 response
848 .headers_mut()
849 .insert("cache-control", "no-store".parse().unwrap());
850 response
851 .headers_mut()
852 .insert("referrer-policy", "no-referrer".parse().unwrap());
853 if callback {
854 response.headers_mut().append(
855 "set-cookie",
856 "studio_mcp_shale_link=; Path=/-/callback; Secure; HttpOnly; SameSite=Lax; Max-Age=0"
857 .parse()
858 .unwrap(),
859 );
860 }
861 response
862}
863
864#[cfg(test)]
865mod tests {
866 use super::*;
867 #[test]
868 fn label_catalog_keeps_native_ids_and_rejects_wrong_pages() {
869 let page = "<meta name='astheno.shale.repo.name' content='chat'><body id=page-labels><div class=grid-container><table><tbody><tr><td><a class=issuelabel data-label-id=75 data-color='#123456'>Bug &amp; fix</a></td><td>Description</td><td>2</td></tr></tbody></table></div></body>";
870 let catalog = labels(page, "chat").unwrap();
871 assert_eq!(
872 catalog["labels"][0],
873 json!({"id":75,"name":"Bug & fix","description":"Description","color":"#123456","openIssues":2})
874 );
875 assert!(labels(page, "other").is_err());
876 assert!(
877 labels(
878 &page.replace("data-label-id=75", "data-label-id=bad"),
879 "chat"
880 )
881 .is_err()
882 );
883 }
884 #[test]
885 fn repository_names_cannot_change_origin_or_path_segments() {
886 let origin = url::Url::parse("https://shale.studio.test").unwrap();
887 for name in [
888 "",
889 ".",
890 "..",
891 "-",
892 "../other",
893 "one//two",
894 "one/../two",
895 "one/./two",
896 "one/-/two",
897 "one/",
898 "one\\two",
899 "%2e%2e",
900 "one?x",
901 "one#x",
902 "one\n",
903 "//foreign.test",
904 ] {
905 assert!(
906 repository_path(&origin, name, &["issues", "1"]).is_err(),
907 "{name:?}"
908 );
909 }
910 let path = repository_path(&origin, "雪☃", &["issues", "1"]).unwrap();
911 assert_eq!(path.origin(), origin.origin());
912 assert_eq!(path.path(), "/%E9%9B%AA%E2%98%83/issues/1");
913 let path = repository_path(
914 &origin,
915 "userscripts/discord-pluralkit-predict",
916 &["issues", "1"],
917 )
918 .unwrap();
919 assert_eq!(path.origin(), origin.origin());
920 assert_eq!(
921 path.path(),
922 "/userscripts/discord-pluralkit-predict/issues/1"
923 );
924 }
925 #[test]
926 fn issue_pages_must_match_repository_identity_and_issue_number() {
927 let page = "<meta name='astheno.shale.repo.name' content='owned'><body id=page-issue><h1><span>#3</span><span>Snow &amp; ☃</span></h1><dl class=sidebar><dd><span class=issuestatus-done><span>Done</span></span></dd></dl>";
928 assert_eq!(issue(page, "owned", Some(3)).unwrap()["title"], "Snow & ☃");
929 assert_eq!(issue(page, "owned", Some(3)).unwrap()["status"], "done");
930 let owner = format!(
931 "{page}<form><select name=status><option selected value=todo>Todo</option></select></form>"
932 );
933 assert_eq!(issue(&owner, "owned", Some(3)).unwrap()["status"], "done");
934 assert!(issue(page, "other", Some(3)).is_err());
935 assert!(issue(page, "owned", Some(4)).is_err());
936 assert!(issue(&page.replace("page-issue", "page-login"), "owned", Some(3)).is_err());
937 assert!(
938 issue(
939 &page.replace("issuestatus-done", "unknown"),
940 "owned",
941 Some(3)
942 )
943 .is_err()
944 );
945 assert!(
946 issue(
947 &format!(
948 "{page}<dl class=sidebar><dd><span class=issuestatus-todo></span></dd></dl>"
949 ),
950 "owned",
951 Some(3)
952 )
953 .is_err()
954 );
955 }
956 #[test]
957 fn verified_r1616_issue_heading_keeps_identity_checks() {
958 let page = "<head><meta name='astheno.shale.repo.name' content='owned'></head><body id=page-issue><h1>Issue #3: Snow &amp; ☃</h1><dl class=sidebar><dd><span class=issuestatus-done><span>Done</span></span></dd></dl><footer class=usa-footer><div class=usa-footer__secondary-section><a href='https://astheno.software/shale/'>shale r1616-ga87d2f5.zig.0.16.0</a></div></footer></body>";
959 assert_eq!(issue(page, "owned", Some(3)).unwrap()["title"], "Snow & ☃");
960 assert!(issue(page, "other", Some(3)).is_err());
961 assert!(issue(page, "owned", Some(4)).is_err());
962 assert!(
963 issue(
964 &page.replace("r1616-ga87d2f5.zig.0.16.0", "r1758-new"),
965 "owned",
966 Some(3)
967 )
968 .is_err()
969 );
970 assert!(issue(&page.replace("Issue #3", "Issue #0"), "owned", None).is_err());
971 }
972 #[test]
973 fn issue_comment_authors_include_external_guest_profiles() {
974 let page = "<head><meta name='astheno.shale.repo.name' content='owned'></head><body id=page-issue><h1><span>#3</span><span>Title</span></h1><dl class=sidebar><dd><span class=issuestatus-done>Done</span></dd></dl><ul><li class=comment id=c1><div class=n-card__header><a href='https://github.com/paperclover'>paperclover</a></div><div class=markdown>Comment</div></li><li class=comment id=c2><div class=n-card__header><a class='usa-link va-middle-childs'>Astheno user</a></div><div class=markdown>Comment</div></li></ul></body>";
975 let page = page.replace("</ul>", "<li class=comment id=c3><div class=n-card__header><span class='usa-link va-middle-childs'><span>GPT-6.1 Sol</span> <small>(on behalf of <a class='usa-link va-middle-childs' href='/~clover'>~clover</a>)</small></span></div><div class=markdown>Comment</div></li></ul>");
976 let parsed = issue(&page, "owned", Some(3)).unwrap();
977 assert_eq!(parsed["comments"][2]["author"], "GPT-6.1 Sol (on behalf of ~clover)");
978 assert_eq!(parsed["comments"][0]["author"], "paperclover");
979 assert_eq!(parsed["comments"][1]["author"], "Astheno user");
980 }
981
982 #[test]
983 fn account_identity_uses_html_text_and_rejects_login_or_changed_markup() {
984 assert_eq!(
985 username("<body id=page-user-settings><kbd>snow&amp;flake☃</kbd>").unwrap(),
986 "snow&flake☃"
987 );
988 for html in [
989 "<body><kbd>snow</kbd>",
990 "<body id=page-user-settings>",
991 "<body id=page-user-settings><kbd>snow</kbd><kbd>other</kbd>",
992 ] {
993 assert!(username(html).is_err());
994 }
995 }
996 #[test]
997 fn callback_fields_and_session_cookie_refuse_ambiguity_and_header_injection() {
998 assert!(fields("state=one&state=two").is_err());
999 assert!(fields(&"s".repeat(16385)).is_err());
1000 let mut headers = HeaderMap::new();
1001 headers.append(
1002 "set-cookie",
1003 "SessionID=owned:signature; Path=/; Secure; HttpOnly"
1004 .parse()
1005 .unwrap(),
1006 );
1007 assert_eq!(session_cookie(&headers).unwrap(), "owned:signature");
1008 headers.append("set-cookie", "SessionID=other; Path=/".parse().unwrap());
1009 assert!(session_cookie(&headers).is_err());
1010 for value in [
1011 "SessionID=",
1012 "SessionID=with space",
1013 "SessionID=bad,other",
1014 "SessionID=\"quoted\"",
1015 ] {
1016 let mut headers = HeaderMap::new();
1017 headers.insert("set-cookie", value.parse().unwrap());
1018 assert!(session_cookie(&headers).is_err());
1019 }
1020 }
1021}