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