~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/knotless

git that lives in your Atmosphere account

knotless.dev
1 collaborator

knotless 0.13.0 Latest 2 packages, 1 image

1
10

Pulls, issues, diffs and files are MCP resources

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:mcp/resources
.changeset/mcp-resources.mdnew+7−0
@@ -0,0 +1,7 @@
---
default: minor
---
# Pulls, issues, diffs and files are MCP resources
`knotless-mcp` and `/mcp` offer resource templates, so a client that reads MCP resources opens a pull, an issue, a pull's diff or a file by a URI like its page's, as `knotless://alice.test/proj/pull/<did>/<rkey>/diff`, answered as the matching tool answers.
crates/knotless-mcp/src/http.rs+120−39
@@ -34,8 +34,9 @@ use knotless::session::{self, Session};
use knotless::telemetry::{self, Fields, Level};
use knotless::{identity, keys, oauth};
use rmcp::model::{
CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ListToolsResult,
PaginatedRequestParams, ServerConfig, Tool,
CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock,
ListResourceTemplatesResult, ListToolsResult, PaginatedRequestParams,
ReadResourceRequestParams, ReadResourceResponse, ServerConfig, Tool,
};
use rmcp::service::RequestContext;
use rmcp::transport::streamable_http_server::session::never::NeverSessionManager;
@@ -143,9 +144,10 @@ impl Drop for Turn<'_> {
}
}
/// What the server tells its operator's counts of: `mcp.signin`, or
/// `mcp.tool` with the tool's name; whether it worked, how long it took,
/// and the account.
/// What the server tells its operator's counts of: `mcp.signin`, `mcp.tool`
/// with the tool's name, or `mcp.resource` with the name of the tool that
/// answers the resource read; whether it worked, how long it took, and the
/// account.
pub struct Event<'a> {
pub name: &'static str,
pub subject: &'a str,
@@ -610,13 +612,86 @@ async fn bearer(State(mcp): State<Arc<Mcp>>, mut req: Request, next: Next) -> Re
next.run(req).await
}
/// The tools, each call made as the account its token is for.
/// The tools and resources, each call made as the account its token is for.
#[derive(Clone)]
struct Remote {
mcp: Arc<Mcp>,
tools: Server,
}
/// How a call of one kind is logged and counted: its event's name, and
/// its log line's when it worked and when it failed.
struct Kind {
event: &'static str,
said: [&'static str; 2],
}
const TOOL: Kind = Kind {
event: "mcp.tool",
said: ["mcp tool", "mcp tool failed"],
};
const RESOURCE: Kind = Kind {
event: "mcp.resource",
said: ["mcp resource", "mcp resource failed"],
};
/// Whom a call is made as, and the request's trace, handed on: rmcp
/// answers in a task of its own, and what the call does is in the trace.
struct As {
did: String,
trace: String,
}
impl As {
fn of(context: &RequestContext<RoleServer>) -> As {
let parts = context.extensions.get::<Parts>();
let did = parts
.and_then(|p| p.extensions.get::<Caller>())
.map(|c| c.0.clone())
.unwrap_or_default();
let trace = parts.and_then(|p| p.extensions.get::<telemetry::Trace>());
let trace = telemetry::Trace::or_new(trace);
As { did, trace }
}
}
impl Remote {
/// A line a call, named for its tool, in the request's trace: at warn
/// when it failed, or was slow and not asked to wait; and the
/// operator's counts told of it.
async fn told(
&self,
kind: &Kind,
who: As,
tool: &str,
started: Instant,
waits: bool,
failure: Option<String>,
) {
let took = started.elapsed();
let f = Fields {
did: Some(&who.did),
tool: Some(tool),
error: failure.clone(),
duration_ms: Some(telemetry::millis(took)),
..Fields::default()
};
let warn = failure.is_some() || (telemetry::slow(took) && !waits);
let level = [Level::INFO, Level::WARN][usize::from(warn)];
let message = kind.said[usize::from(failure.is_some())];
telemetry::traced(who.trace, async { telemetry::note(level, message, &f) }).await;
self.mcp.tell(Event {
name: kind.event,
subject: tool,
ok: failure.is_none(),
took,
did: Some(&who.did),
error: failure.as_deref(),
});
}
}
impl ServerHandler for Remote {
fn get_info(&self) -> ServerConfig {
self.tools.get_info()
@@ -639,16 +714,8 @@ impl ServerHandler for Remote {
request: CallToolRequestParams,
context: RequestContext<RoleServer>,
) -> Result<CallToolResponse, McpError> {
let parts = context.extensions.get::<Parts>();
let did = parts
.and_then(|p| p.extensions.get::<Caller>())
.map(|c| c.0.clone())
.unwrap_or_default();
// rmcp calls the tool in a task of its own: the request's trace,
// handed on, so what the tool does is in it.
let trace = parts.and_then(|p| p.extensions.get::<telemetry::Trace>());
let trace = telemetry::Trace::or_new(trace);
let server = Server::new(self.mcp.ctx_for(&did));
let who = As::of(&context);
let server = Server::new(self.mcp.ctx_for(&who.did));
let (started, tool) = (Instant::now(), request.name.to_string());
let waits = request
.arguments
@@ -657,8 +724,11 @@ impl ServerHandler for Remote {
.and_then(serde_json::Value::as_u64)
.unwrap_or_default()
> 0;
let res = match self.mcp.turn(&did, waits, started) {
Ok(_turn) => telemetry::traced(trace.clone(), server.call_tool(request, context)).await,
let res = match self.mcp.turn(&who.did, waits, started) {
Ok(_turn) => {
let called = server.call_tool(request, context);
telemetry::traced(who.trace.clone(), called).await
}
Err(why) => Ok(CallToolResponse::Complete(CallToolResult::error(vec![
ContentBlock::text(why),
]))),
@@ -673,28 +743,39 @@ impl ServerHandler for Remote {
Ok(_) => None,
Err(err) => Some(err.message.to_string()),
};
let took = started.elapsed();
let f = Fields {
did: Some(&did),
tool: Some(&tool),
error: failure.clone(),
duration_ms: Some(telemetry::millis(took)),
..Fields::default()
self.told(&TOOL, who, &tool, started, waits, failure).await;
res
}
async fn list_resource_templates(
&self,
request: Option<PaginatedRequestParams>,
context: RequestContext<RoleServer>,
) -> Result<ListResourceTemplatesResult, McpError> {
self.tools.list_resource_templates(request, context).await
}
/// A resource read as a tool call is made, and counted as one, named
/// for the tool that answers it.
async fn read_resource(
&self,
request: ReadResourceRequestParams,
context: RequestContext<RoleServer>,
) -> Result<ReadResourceResponse, McpError> {
let who = As::of(&context);
let server = Server::new(self.mcp.ctx_for(&who.did));
let started = Instant::now();
let tool = crate::resources::parse(&request.uri).map_or("", |(tool, _)| tool);
let res = match self.mcp.turn(&who.did, false, started) {
Ok(_turn) => {
let read = server.read_resource(request, context);
telemetry::traced(who.trace.clone(), read).await
}
Err(why) => Err(McpError::invalid_request(why, None)),
};
// A line a call, named for its tool, in the request's trace: at
// warn when it failed, or was slow and not asked to wait.
let warn = failure.is_some() || (telemetry::slow(took) && !waits);
let level = [Level::INFO, Level::WARN][usize::from(warn)];
let message = ["mcp tool", "mcp tool failed"][usize::from(failure.is_some())];
telemetry::traced(trace, async { telemetry::note(level, message, &f) }).await;
self.mcp.tell(Event {
name: "mcp.tool",
subject: &tool,
ok: failure.is_none(),
took,
did: Some(&did),
error: failure.as_deref(),
});
let failure = res.as_ref().err().map(|err| err.message.to_string());
self.told(&RESOURCE, who, tool, started, false, failure)
.await;
res
}
}
crates/knotless-mcp/src/lib.rs+71−32
@@ -2,11 +2,13 @@
//!
//! Agents read files, trees, history, diffs and search results straight from
//! a repository's records, with no clone, and commit with no checkout. The
//! same tools create repositories and decide who may read and push them.
//! same tools create repositories and decide who may read and push them,
//! and pulls, issues, diffs and files are resources to read by URI too.
//! Everything runs as the account `knotless login` last logged in, or as
//! `KNOTLESS_IDENTIFIER`.
pub mod http;
mod resources;
use knotless::approvals;
use knotless::checks;
@@ -31,7 +33,11 @@ use knotless::theme::{self, Choice};
use knotless::triage;
use knotless::url::RepoUrl;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::{CallToolResponse, CallToolResult, ContentBlock};
use rmcp::model::{
CallToolResponse, CallToolResult, ContentBlock, Implementation, ListResourceTemplatesResult,
PaginatedRequestParams, ReadResourceRequestParams, ReadResourceResponse, ServerCapabilities,
ServerConfig,
};
use rmcp::{ErrorData as McpError, ServerHandler, schemars, tool, tool_handler, tool_router};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
@@ -969,7 +975,8 @@ pub struct Logs {
/// `x-trace-id` and each of its lines carry.
pub trace: Option<String>,
/// Only lines with this message: `request`, `outgoing`, `sql`, `mcp
/// tool`, `mcp tool failed`, `git`.
/// tool`, `mcp tool failed`, `mcp resource`, `mcp resource failed`,
/// `git`.
pub message: Option<String>,
/// Only lines at this level or above: `error`, `warn`, `info`, `debug`.
pub level: Option<String>,
@@ -1647,22 +1654,24 @@ impl Server {
description = "Read a file. Text comes back as is; binary files as base64. With `start` (and `end`), only those lines of a text file, from 1, as {start, end, lines (the file's count), text}: for a long file, search with `context` first, then read the part you need."
)]
async fn repo_read(&self, Parameters(p): Parameters<File>) -> Result<CallToolResult, McpError> {
answer(async {
let git = self.mirror(&p.repo).await?;
let rev = read::rev(p.rev.as_deref())?;
let (_, bytes) = read::blob(&git, &rev, &read::path(Some(&p.path))).await?;
Ok(match String::from_utf8(bytes) {
Ok(text) if p.start.is_some() || p.end.is_some() => {
serde_json::to_value(read::lines(&text, p.start.unwrap_or(1), p.end)?)?
}
Ok(text) => Value::String(text),
Err(err) => {
use base64::Engine;
json!({"base64": base64::engine::general_purpose::STANDARD.encode(err.into_bytes())})
}
})
}
.await)
answer(self.file(p).await)
}
/// A file, as `repo_read` answers it, and its resource too.
async fn file(&self, p: File) -> anyhow::Result<Value> {
let git = self.mirror(&p.repo).await?;
let rev = read::rev(p.rev.as_deref())?;
let (_, bytes) = read::blob(&git, &rev, &read::path(Some(&p.path))).await?;
Ok(match String::from_utf8(bytes) {
Ok(text) if p.start.is_some() || p.end.is_some() => {
serde_json::to_value(read::lines(&text, p.start.unwrap_or(1), p.end)?)?
}
Ok(text) => Value::String(text),
Err(err) => {
use base64::Engine;
json!({"base64": base64::engine::general_purpose::STANDARD.encode(err.into_bytes())})
}
})
}
#[tool(
@@ -2485,13 +2494,13 @@ impl Server {
&self,
Parameters(p): Parameters<PullDiff>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let diff = forge::pull_diff(&self.ctx, &repo_url(&p.repo)?, &p.uri).await?;
pull_diff_answer(&diff, &p.paths, WHOLE_DIFF)
}
.await,
)
answer(self.diff(p).await)
}
/// A pull's diff, as `pull_diff` answers it, and its resource too.
async fn diff(&self, p: PullDiff) -> anyhow::Result<Value> {
let diff = forge::pull_diff(&self.ctx, &repo_url(&p.repo)?, &p.uri).await?;
pull_diff_answer(&diff, &p.paths, WHOLE_DIFF)
}
#[tool(
@@ -4233,19 +4242,20 @@ impl Server {
}
}
#[tool_handler(
router = self.router(),
name = "knotless",
instructions = "Git repositories stored in atproto accounts. Name a repository as handle/name. Every tool is a `knotless` command: the tool <group>_<action> is `knotless <group> <action>`, with the command's flags as its parameters. Before changing a repository, read its AGENTS.md (or CLAUDE.md) with repo_read if it has one: how that repository wants changes made and checked.
/// What the server says of itself to a client that starts with it.
const INSTRUCTIONS: &str = "Git repositories stored in atproto accounts. Name a repository as handle/name. Every tool is a `knotless` command: the tool <group>_<action> is `knotless <group> <action>`, with the command's flags as its parameters. Before changing a repository, read its AGENTS.md (or CLAUDE.md) with repo_read if it has one: how that repository wants changes made and checked.
Repositories: repo_list, repo_create, repo_fork, repo_edit, repo_rename, repo_archive, repo_delete, repo_transfer, repo_accept, repo_share, repo_members, repo_star, repo_stars, repo_compact, repo_storage. Read with repo_tree, repo_read, repo_search, repo_blame, repo_log, repo_diff and repo_refs: no clone needed. Change files with repo_commit: no checkout needed. Branches and tags: branch_create, branch_delete, branch_rename, branch_protect (see branch_rules), tag_create, tag_delete.
Collaboration: pull_create (write it from pull_template), pull_list, pull_show, pull_diff, pull_edit, pull_close, pull_reopen, pull_label, pull_assign, pull_ask_review, pull_react, pull_retarget, pull_update_branch (bring its branch up to date with its base), pull_dismiss_review, pull_merge (with queue, into the merge queue: see queue_list and queue_leave) and pull_revert (undo a merged pull). Issues: issue_create (write it from issue_template), issue_list, issue_show, issue_edit, issue_close, issue_reopen, issue_label, issue_assign, issue_react. Comments, which review a pull too: comment_add, comment_edit, comment_delete, comment_resolve, comment_react. Labels: label_list, label_define, label_delete; triagers, accounts whose labels count: triager_list, triager_add, triager_remove, triager_eval.
Resources: a repository's pulls and issues, a pull or an issue, a pull's diff and a file at a revision can be read as MCP resources too, by URIs that mirror their pages on knotless.dev (as knotless://handle/name/pull/<did>/<rkey>: see the resource templates), each answered as the tool that reads it answers.
Workflows are YAML files under .knotless/workflows/, run by the runners a repository names (runner_list, runner_add, runner_remove, runner_minutes) on the commit a push or a pull brings: `on: {push: {refs: [refs/heads/*]}, pull: {base: [refs/heads/main], paths: [web/**]}}` (a file with no `on` runs on every push to a branch; `paths`, globs, runs it only on a change touching one, and passes it as skipped otherwise), `image` (the one container all the steps run in, in turn, so a step sees what earlier ones installed), `env`, `secrets` (names; never given to a pull; see secret_set, secret_list and secret_remove), and `steps: [{name, run, timeout}]`, each `run` a shell script, stopping at the first that fails. After a push, run_list says whether they passed, workflow_list which a ref has and what runs each, and run_rerun asks for them again, or, as owner, for one of a pull's workflows by name, or one at a ref, with variables: one with `on: {asked: {refs: [refs/heads/main]}}` runs only so. Agents, accounts whose pulls the runners check as code no one vouches for: agent_list, agent_add, agent_remove.
Moderation: mod_hide, mod_block, mod_blocks and mod_report. Accounts: account_follow, account_follows, account_followers, account_search, account_profile (how you show on knotless), account_email, account_email_add, account_email_remove (which addresses show commits as yours), account_delegate (let a gateway write for you) and account_delete_data. Themes for knotless's pages: theme_check, theme_publish, theme_use, theme_delete and theme_list. What others did that concerns you: notification_list; choose which you hear of with notification_settings, and watch a repository, or subscribe to or mute a pull or an issue, with notification_watch, notification_subscribe, notification_mute and notification_unsubscribe (see notification_subscriptions). Packages: release_publish a version from a repository, see its versions with release_list, read a file of one with release_read, and withdraw one with release_yank. Organizations: org_add, org_remove, org_join, org_leave, org_members, org_list and org_delete, once you have delegated to a gateway with account_delegate. Operators of a server: admin_takedown, admin_status, admin_reports, admin_stats and admin_logs. status says whether a server is well. An error naming a `knotless` command names a tool here: `knotless org join` is org_join, `knotless pull merge` is pull_merge, and so on, but for login, logout, upgrade, repo mirror, workflow pin and release download, which act on the machine they run on."
)]
Moderation: mod_hide, mod_block, mod_blocks and mod_report. Accounts: account_follow, account_follows, account_followers, account_search, account_profile (how you show on knotless), account_email, account_email_add, account_email_remove (which addresses show commits as yours), account_delegate (let a gateway write for you) and account_delete_data. Themes for knotless's pages: theme_check, theme_publish, theme_use, theme_delete and theme_list. What others did that concerns you: notification_list; choose which you hear of with notification_settings, and watch a repository, or subscribe to or mute a pull or an issue, with notification_watch, notification_subscribe, notification_mute and notification_unsubscribe (see notification_subscriptions). Packages: release_publish a version from a repository, see its versions with release_list, read a file of one with release_read, and withdraw one with release_yank. Organizations: org_add, org_remove, org_join, org_leave, org_members, org_list and org_delete, once you have delegated to a gateway with account_delegate. Operators of a server: admin_takedown, admin_status, admin_reports, admin_stats and admin_logs. status says whether a server is well. An error naming a `knotless` command names a tool here: `knotless org join` is org_join, `knotless pull merge` is pull_merge, and so on, but for login, logout, upgrade, repo mirror, workflow pin and release download, which act on the machine they run on.";
#[tool_handler(router = self.router())]
impl ServerHandler for Server {
async fn call_tool(
&self,
@@ -4264,6 +4274,35 @@ impl ServerHandler for Server {
}
Ok(res)
}
fn get_info(&self) -> ServerConfig {
let can = ServerCapabilities::builder()
.enable_tools()
.enable_resources()
.build();
ServerConfig::new(can)
.with_server_info(Implementation::new("knotless", env!("CARGO_PKG_VERSION")))
.with_instructions(INSTRUCTIONS.to_string())
}
async fn list_resource_templates(
&self,
_request: Option<PaginatedRequestParams>,
_context: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<ListResourceTemplatesResult, McpError> {
Ok(ListResourceTemplatesResult::with_all_items(
resources::templates(),
))
}
async fn read_resource(
&self,
request: ReadResourceRequestParams,
_context: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<ReadResourceResponse, McpError> {
let read = self.resource(&request.uri).await?;
Ok(read.into())
}
}
/// Serves MCP over stdin and stdout until the client hangs up; with
crates/knotless-mcp/src/resources.rsnew+188−0
@@ -0,0 +1,188 @@
//! MCP resources: a repository's pulls and issues, a pull or an issue with
//! its comments, a pull's diff and a file at a revision, each by a URI that
//! mirrors its page on knotless.dev under the `knotless://` scheme, and each
//! answered as the tool that reads it answers.
use crate::{File, Items, PullDiff, Server, Subject, failed};
use rmcp::ErrorData as McpError;
use rmcp::model::{ReadResourceResult, ResourceContents, ResourceTemplate};
use serde_json::Value;
const JSON: &str = "application/json";
const TEXT: &str = "text/plain";
/// A resource template, as `resources/templates/list` gives it.
struct Template {
uri: &'static str,
name: &'static str,
title: &'static str,
description: &'static str,
mime: &'static str,
}
const TEMPLATES: [Template; 6] = [
Template {
uri: "knotless://{owner}/{repo}/pulls",
name: "pulls",
title: "Pull requests",
description: "A repository's pull requests, newest first, as pull_list gives them. {owner} is a handle or DID.",
mime: JSON,
},
Template {
uri: "knotless://{owner}/{repo}/issues",
name: "issues",
title: "Issues",
description: "A repository's issues, newest first, as issue_list gives them.",
mime: JSON,
},
Template {
uri: "knotless://{owner}/{repo}/pull/{did}/{rkey}",
name: "pull",
title: "Pull request",
description: "A pull request with its comments and reviews, as pull_show gives it: the pull at://{did}/dev.knotless.pull/{rkey}, as its page names it.",
mime: JSON,
},
Template {
uri: "knotless://{owner}/{repo}/pull/{did}/{rkey}/diff",
name: "pull_diff",
title: "Pull request diff",
description: "The diff a pull request would apply, its lines numbered, as pull_diff gives it: a diff too long to give whole lists its files instead, as JSON, to read with pull_diff's paths.",
mime: TEXT,
},
Template {
uri: "knotless://{owner}/{repo}/issue/{did}/{rkey}",
name: "issue",
title: "Issue",
description: "An issue with its comments, as issue_show gives it: the issue at://{did}/dev.knotless.issue/{rkey}, as its page names it.",
mime: JSON,
},
Template {
uri: "knotless://{owner}/{repo}/file/{rev}/{+path}",
name: "file",
title: "File",
description: "A file at a branch, tag or commit, as repo_read gives it: text as it is, a binary file as a blob. A / in {rev}, as in a branch's name, is written %2F.",
mime: TEXT,
},
];
/// The resource templates.
pub(crate) fn templates() -> Vec<ResourceTemplate> {
let template = |t: &Template| {
ResourceTemplate::new(t.uri, t.name)
.with_title(t.title)
.with_description(t.description)
.with_mime_type(t.mime)
};
TEMPLATES.iter().map(template).collect()
}
/// What a resource's URI asks of the tool that answers it.
pub(crate) enum Read {
Items(Items, &'static str),
Show(Subject),
Diff(PullDiff),
File(File),
}
/// What `uri` asks for, and the tool that answers it: an error naming the
/// templates for a URI none of them makes.
pub(crate) fn parse(uri: &str) -> Result<(&'static str, Read), McpError> {
let unknown = || {
let all: Vec<&str> = TEMPLATES.iter().map(|t| t.uri).collect();
let said = format!(
"{uri} is no knotless resource: expected one of {}",
all.join(", ")
);
McpError::resource_not_found(said, None)
};
// Each part as the template's expansion encoded it, `{+path}`'s `/`
// left as they are.
let parts: Option<Vec<String>> = uri
.strip_prefix("knotless://")
.and_then(|rest| rest.split('/').map(decoded).collect());
let parts = parts.filter(|p| p.iter().all(|s| !s.is_empty()));
let parts = parts.ok_or_else(unknown)?;
let parts: Vec<&str> = parts.iter().map(String::as_str).collect();
let (repo, at) = (
|owner: &str, name: &str| format!("knotless://{owner}/{name}"),
|did: &str, kind: &str, rkey: &str| format!("at://{did}/dev.knotless.{kind}/{rkey}"),
);
Ok(match parts[..] {
[owner, name, "pulls"] => {
let p = Items {
repo: repo(owner, name),
query: None,
};
("pull_list", Read::Items(p, "pull"))
}
[owner, name, "issues"] => {
let p = Items {
repo: repo(owner, name),
query: None,
};
("issue_list", Read::Items(p, "issue"))
}
[owner, name, kind @ ("pull" | "issue"), did, rkey] => {
let p = Subject {
repo: repo(owner, name),
uri: at(did, kind, rkey),
};
let tool = ["pull_show", "issue_show"][usize::from(kind == "issue")];
(tool, Read::Show(p))
}
[owner, name, "pull", did, rkey, "diff"] => {
let p = PullDiff {
repo: repo(owner, name),
uri: at(did, "pull", rkey),
paths: Vec::new(),
};
("pull_diff", Read::Diff(p))
}
[owner, name, "file", rev, ref path @ ..] if !path.is_empty() => {
let p = File {
repo: repo(owner, name),
path: path.join("/"),
rev: Some(rev.to_string()),
start: None,
end: None,
};
("repo_read", Read::File(p))
}
_ => return Err(unknown()),
})
}
/// A URI's part, its percent-escapes decoded: none, if they are no UTF-8.
fn decoded(part: &str) -> Option<String> {
let text = percent_encoding::percent_decode_str(part).decode_utf8();
text.ok().map(std::borrow::Cow::into_owned)
}
impl Server {
/// The resource at `uri`, as the tool that reads it answers: a failure
/// as an error saying what the tool would.
pub(crate) async fn resource(&self, uri: &str) -> Result<ReadResourceResult, McpError> {
if let Some(why) = &self.refusal {
return Err(McpError::invalid_request(why.clone(), None));
}
let (_, read) = parse(uri)?;
let file = matches!(read, Read::File(_));
let answered = match read {
Read::Items(p, kind) => self.items(p, kind).await,
Read::Show(p) => self.show(p).await,
Read::Diff(p) => self.diff(p).await,
Read::File(p) => self.file(p).await,
};
let value = answered.map_err(|err| McpError::internal_error(failed(&err), None))?;
let contents = match value {
Value::String(text) => ResourceContents::text(text, uri),
// A binary file, which repo_read gives as base64.
value if file => {
let blob = value["base64"].as_str().unwrap_or_default();
ResourceContents::blob(blob, uri).with_mime_type("application/octet-stream")
}
value => ResourceContents::text(value.to_string(), uri).with_mime_type(JSON),
};
Ok(ReadResourceResult::new(vec![contents]))
}
}
crates/knotless-mcp/tests/http.rs+43−1
@@ -367,6 +367,7 @@ fn an_assistant_signs_in_and_works_as_its_user() {
.unwrap()
.contains("handle/name")
);
assert!(init["result"]["capabilities"]["resources"].is_object());
let (_, list) = rpc(&srv, Some(&access), "tools/list", json!({}));
assert!(list.to_string().contains("repo_create"), "{list}");
let (_, made) = rpc(
@@ -420,7 +421,31 @@ fn an_assistant_signs_in_and_works_as_its_user() {
json!({"name": "no_such_tool", "arguments": {}}),
);
assert!(none["error"].is_object(), "{none}");
// The operator's counts were told of the sign-ins and the tool calls.
// Resources, read as the user too.
let (_, templates) = rpc(&srv, Some(&access), "resources/templates/list", json!({}));
let templates = templates["result"]["resourceTemplates"].as_array().unwrap();
assert_eq!(templates.len(), 6, "{templates:?}");
let repo = format!("{}/over-http", allowed.handle);
let commit = json!({"name": "repo_commit", "arguments": {"repo": repo, "branch": "main", "message": "m", "changes": [{"path": "a.md", "content": "over\n"}]}});
let (_, committed) = rpc(&srv, Some(&access), "tools/call", commit);
assert_ne!(committed["result"]["isError"], true, "{committed}");
let file = format!("knotless://{repo}/file/main/a.md");
let (_, read) = rpc(&srv, Some(&access), "resources/read", json!({"uri": file}));
assert_eq!(read["result"]["contents"][0]["text"], "over\n", "{read}");
let (_, unknown) = rpc(
&srv,
Some(&access),
"resources/read",
json!({"uri": "knotless://nowhere"}),
);
assert!(
unknown["error"]["message"]
.as_str()
.unwrap()
.contains("expected one of")
);
// The operator's counts were told of the sign-ins, the tool calls and
// the resources read, each read named for the tool that answers it.
let me = &allowed.did;
let told = srv.events.lock().unwrap().clone();
let tools: Vec<&String> = told.iter().filter(|e| e.starts_with("mcp.tool")).collect();
@@ -431,6 +456,18 @@ fn an_assistant_signs_in_and_works_as_its_user() {
&format!("mcp.tool repo_list true {me}"),
&format!("mcp.tool repo_read false {me}"),
&format!("mcp.tool no_such_tool false {me}"),
&format!("mcp.tool repo_commit true {me}"),
]
);
let reads: Vec<&String> = told
.iter()
.filter(|e| e.starts_with("mcp.resource"))
.collect();
assert_eq!(
reads,
[
&format!("mcp.resource repo_read true {me}"),
&format!("mcp.resource false {me}"),
]
);
assert!(
@@ -691,4 +728,9 @@ fn open_to_anyone_but_those_turned_away() {
assert_eq!(waits["result"]["isError"], true, "{waits}");
let said = waits["result"]["content"][0]["text"].as_str().unwrap();
assert!(said.contains("this minute"), "{said}");
// A resource read is a call as a tool's is, and waits the same.
let file = json!({"uri": format!("knotless://{repo}/file/main/a")});
let (_, refused) = rpc(&srv, Some(access), "resources/read", file);
let said = refused["error"]["message"].as_str().unwrap();
assert!(said.contains("this minute"), "{said}");
}
crates/knotless-mcp/tests/mcp.rs+207−0
@@ -68,6 +68,20 @@ impl Client {
fn json(&self, tool: &str, args: Value) -> Value {
serde_json::from_str(&self.ok(tool, args)).unwrap()
}
/// The resource at `uri`, its one content as JSON (`mimeType`, and
/// `text` or `blob`), or the error it is answered with.
fn read(&self, uri: &str) -> Result<Value, rmcp::model::ErrorData> {
let params = rmcp::model::ReadResourceRequestParams::new(uri);
match self.rt.block_on(self.peer.read_resource(params)) {
Ok(read) => {
assert_eq!(read.contents.len(), 1, "{read:?}");
Ok(serde_json::to_value(&read.contents[0]).unwrap())
}
Err(rmcp::ServiceError::McpError(err)) => Err(err),
Err(err) => panic!("{uri}: {err}"),
}
}
}
/// Logged in as its account, as `knotless login` leaves a machine.
@@ -886,6 +900,13 @@ fn an_old_version_is_refused_and_a_new_one_told_of() {
let mine = json!({"handle": s.account.handle});
let (said, err) = Client::knowing(&s, &behind).call("repo_list", mine.clone());
assert!(err && said.contains("Compaction loses objects."), "{said}");
// Its resources too, however well formed the URI.
let file = format!("knotless://{}/x/file/main/a", s.account.handle);
let refused = Client::knowing(&s, &behind).read(&file).unwrap_err();
assert!(
refused.message.contains("Compaction loses objects."),
"{refused:?}"
);
// Told to go on anyway, it does, and says once that 999.0.0 is out.
s.set("KNOTLESS_IGNORE_MINIMUM", "1");
@@ -3016,3 +3037,189 @@ fn organizations_over_mcp() {
assert!(warning.contains("acts through no gateway"), "{left}");
assert!(s.records("dev.knotless.orgAcceptance").is_empty());
}
/// Pulls, issues, a pull's diff and files are resources as well as tools,
/// by URIs that mirror their pages, each read as its tool answers.
#[test]
fn agents_read_resources() {
let rt = tokio::runtime::Runtime::new().unwrap();
let (base, _appview, _proxy) = appview(&rt, "");
let mut owner = logged_in();
owner.set("KNOTLESS_APPVIEW", &base);
let c = Client::new(&owner);
let info = c.peer.peer_info().unwrap();
assert!(info.capabilities.resources.is_some(), "{info:?}");
assert!(info.capabilities.tools.is_some(), "{info:?}");
let templates = c.rt.block_on(c.peer.list_all_resource_templates()).unwrap();
let listed: Vec<(&str, &str, Option<&str>)> = templates
.iter()
.map(|t| {
assert!(t.title.is_some() && t.description.is_some(), "{t:?}");
(
t.name.as_str(),
t.uri_template.as_str(),
t.mime_type.as_deref(),
)
})
.collect();
let (json, text) = (Some("application/json"), Some("text/plain"));
assert_eq!(
listed,
[
("pulls", "knotless://{owner}/{repo}/pulls", json),
("issues", "knotless://{owner}/{repo}/issues", json),
("pull", "knotless://{owner}/{repo}/pull/{did}/{rkey}", json),
(
"pull_diff",
"knotless://{owner}/{repo}/pull/{did}/{rkey}/diff",
text
),
(
"issue",
"knotless://{owner}/{repo}/issue/{did}/{rkey}",
json
),
("file", "knotless://{owner}/{repo}/file/{rev}/{+path}", text),
]
);
// Only templates: there are too many pulls and files to list.
assert!(
c.rt.block_on(c.peer.list_all_resources())
.unwrap()
.is_empty()
);
let repo = c_repo(&c.json("repo_create", json!({"name": "resourced"})));
c.json(
"repo_commit",
json!({"repo": repo, "branch": "main", "message": "start", "changes": [
{"path": "README.md", "content": "hello\n"},
{"path": "logo.bin", "base64": "AAEC/w=="},
]}),
);
c.json("branch_create", json!({"repo": repo, "name": "topic/more"}));
c.json(
"repo_commit",
json!({"repo": repo, "branch": "topic/more", "message": "more", "changes": [
{"path": "docs/a b.md", "content": "on the branch\n"},
]}),
);
let opened = json!({"repo": repo, "base": "main", "head_repo": repo, "head_branch": "topic/more", "title": "More", "body": "more"});
let pull = c.json("pull_create", opened)["uri"]
.as_str()
.unwrap()
.to_string();
let issue = c.json("issue_create", json!({"repo": repo, "title": "Less"}))["uri"]
.as_str()
.unwrap()
.to_string();
c.json(
"comment_add",
json!({"repo": repo, "uri": pull, "body": "looks right"}),
);
// The lists from the appview, once it has the pull, its comment and
// the issue, so a tool and its resource read the same.
let all = |tool: &str| c.json(tool, json!({"repo": repo}));
wait_until(|| {
let shown = c.json("pull_show", json!({"repo": repo, "uri": pull}));
let pulls = all("pull_list");
pulls[0]["comments"] == 1
&& all("issue_list").as_array().unwrap().len() == 1
&& shown["comments"]
.as_array()
.is_some_and(|said| said.len() == 1)
});
// Each as its tool answers, JSON or text.
let page = |uri: &str| {
let parts: Vec<&str> = uri.strip_prefix("at://").unwrap().split('/').collect();
format!("{}/{}", parts[0], parts[2])
};
let url = format!("knotless://{repo}");
let as_tool = |uri: String, tool: &str, args: Value| {
let read = c.read(&uri).unwrap();
assert_eq!(read["uri"], uri.as_str());
let said = c.ok(tool, args);
match read["mimeType"].as_str().unwrap() {
"application/json" => {
let got: Value = serde_json::from_str(read["text"].as_str().unwrap()).unwrap();
assert_eq!(got, serde_json::from_str::<Value>(&said).unwrap(), "{uri}");
}
mime => {
assert_eq!(mime, "text/plain", "{uri}");
assert_eq!(read["text"], said.as_str(), "{uri}");
}
}
read
};
let subject = json!({"repo": repo, "uri": pull});
as_tool(format!("{url}/pulls"), "pull_list", json!({"repo": repo}));
as_tool(format!("{url}/issues"), "issue_list", json!({"repo": repo}));
let shown = as_tool(
format!("{url}/pull/{}", page(&pull)),
"pull_show",
subject.clone(),
);
assert!(shown["text"].as_str().unwrap().contains("looks right"));
let diff = as_tool(
format!("{url}/pull/{}/diff", page(&pull)),
"pull_diff",
subject,
);
assert!(
diff["text"]
.as_str()
.unwrap()
.contains(" 1 | +on the branch")
);
as_tool(
format!("{url}/issue/{}", page(&issue)),
"issue_show",
json!({"repo": repo, "uri": issue}),
);
// A branch's `/` in the revision, and a space in the path, escaped as a
// template's expansion escapes them; the owner by DID, its `:` too.
let did = owner.account.did.replace(':', "%3A");
let read = as_tool(
format!("knotless://{did}/resourced/file/topic%2Fmore/docs/a%20b.md"),
"repo_read",
json!({"repo": repo, "rev": "topic/more", "path": "docs/a b.md"}),
);
assert_eq!(read["text"], "on the branch\n");
// A binary file as a blob.
let logo = c.read(&format!("{url}/file/main/logo.bin")).unwrap();
assert_eq!(logo["blob"], "AAEC/w==");
assert_eq!(logo["mimeType"], "application/octet-stream");
// A read that fails says what the tool would.
let gone = c.read(&format!("{url}/file/main/gone.md")).unwrap_err();
let (said, err) = c.call("repo_read", json!({"repo": repo, "path": "gone.md"}));
assert!(err);
assert_eq!(gone.message, said);
// A URI no template makes names those that are.
for bad in [
format!("https://knotless.dev/{repo}/pulls"),
url.clone(),
format!("{url}/pulls/"),
format!("{url}/pull/{}/patch", page(&pull)),
format!("{url}/issue/{}/diff", page(&issue)),
format!("{url}/file/main"),
format!("{url}/file/main//a"),
format!("{url}/file/%FF/README.md"),
] {
let err = c.read(&bad).unwrap_err();
assert_eq!(
err.code,
rmcp::model::ErrorCode::RESOURCE_NOT_FOUND,
"{bad}"
);
assert!(
err.message.contains(&bad)
&& err
.message
.contains("knotless://{owner}/{repo}/file/{rev}/{+path}"),
"{err:?}"
);
}
}
crates/knotless-mcp/Cargo.toml+1−0
@@ -11,6 +11,7 @@ knotless.workspace = true
anyhow.workspace = true
axum.workspace = true
base64.workspace = true
percent-encoding = "2"
reqwest.workspace = true
rmcp = { version = "3.6", features = ["server", "transport-io", "transport-streamable-http-server", "macros"] }
schemars = "1"
deploy/README.md+4−2
@@ -250,7 +250,8 @@ KNOTLESS_APPVIEW=https://knotless.dev knotless admin stats
the `cli`, `bot`, `other`, from the `User-Agent`).
- **Events**, what the handlers saw, with how often each failed, its mean
time and the accounts that did it, in the days asked but at most the
last 30: `mcp.tool` by tool and `mcp.signin`; `web.login`;
last 30: `mcp.tool` by tool, `mcp.resource` by the tool that answers
the read, and `mcp.signin`; `web.login`;
`git.fetch` by repository, once per clone or fetch, as asked for
(`<handle or did>/<name>`); `cli.version` by the CLI's version;
`gateway.delegated`, `gateway.push`, `gateway.commit`, and
@@ -816,7 +817,8 @@ What serve logs:
events) each minute, for what saw any.
- `mcp tool failed` at warn, one a tool call that failed: `gen_ai.tool.name`,
`did`, `error` (what the tool answered, as the client saw it),
`duration_ms` and `trace_id`.
`duration_ms` and `trace_id`; `mcp resource failed` the same, of a
resource read, named for the tool that answers it.
- `mirror sync`, one each sync of a repository's mirror: `repo`,
`duration_ms`, and `records_ms`, the part spent reading its records. At
info, or warn when slow. Reads of the repository wait on a sync under
docs/guide/agents.md+24−3
@@ -21,11 +21,31 @@ Cursor (`.cursor/mcp.json`), take the same command in their MCP settings:
{ "mcpServers": { "knotless": { "command": "knotless-mcp" } } }
```
omp and Claude Code can instead install `knotless-mcp` with a knotless
skill as a plugin, from
[chadtmiller.com/agent-plugins](https://knotless.dev/chadtmiller.com/agent-plugins):
```sh
/marketplace add https://knotless.dev/chadtmiller.com/agent-plugins.git # omp
/marketplace install knotless@knotless
/plugin marketplace add https://knotless.dev/chadtmiller.com/agent-plugins.git # Claude Code
/plugin install knotless@knotless
```
Keep the `.git`: without it, Claude Code fetches the URL as a catalog's
JSON.
The server acts as the account `knotless login` stored last. To pin another
account, set `KNOTLESS_IDENTIFIER` in the server's `env`. Pulls and issues
on public repositories come from `https://knotless.dev`; to read them from
another `knotless-serve` with an index, set `KNOTLESS_APPVIEW` to it.
A client that reads MCP resources opens a pull, an issue, its diff or a
file by a URI like its page's, as `read
mcp://knotless://alice.test/proj/pull/did:plc:…/3abc…/diff` in omp or an
`@` mention in Claude Code. [Resources](../reference/mcp.md#resources)
lists the templates.
## Connect a remote client
Some assistants, such as claude.ai with its custom connectors, connect to a
@@ -45,9 +65,10 @@ use. The server keeps each account's session apart from every other's.
When your PDS ends the login the server holds for you, your assistant asks
you to sign in again, or shows knotless as disconnected: reconnect it.
Each account may have 8 tool calls running at once, 2 of them with a
`wait`, and start 120 a minute. A call past any of these answers as a tool
error saying to wait. An account that makes no token in 90 days is
Each account may have 8 tool calls or resource reads running at once, 2 of
them with a `wait`, and start 120 a minute. A tool call past any of these
answers as a tool error saying to wait, a resource read as an error saying
so. An account that makes no token in 90 days is
forgotten, and connects again by signing in.
## Run many agents at once
docs/reference/mcp.md+31−1
@@ -14,7 +14,8 @@ PDS turns down refreshing it, the server forgets the session, answers the
next call `401` and the next token refresh `invalid_grant`, so the assistant
has its user sign in again. A refresh that fails any other way is logged,
and the call goes on.
[Agents](../guide/agents.md) says how to connect a client.
[Agents](../guide/agents.md) says how to connect a client. Pulls, issues,
diffs and files are [resources](#resources) to read by URI too.
Over stdio, `knotless-mcp` asks its appview about versions once a day, as
the CLI does ([Command reference](cli.md#knotless)). With a newer release
@@ -304,6 +305,35 @@ They answer `{org, name, organization, inside, gateway?, entries: [{did, handle?
| `theme_delete` | `knotless theme delete` | deletes one of yours by URI, answering `{deleted}` |
| `theme_list` | `knotless theme list` | lists themes, everyone's or a `handle`'s, the latest first, or with `find` those whose name starts so, as `{themes: [{uri, name, creator, palette, createdAt}]}` |
## Resources
The same server offers resource templates (`resources/templates/list`), so
a client that reads MCP resources opens a pull, an issue, a diff or a file
by URI rather than by a tool call: omp as `read mcp://knotless://…`, Claude
Code as an `@` mention. A URI mirrors the thing's page on knotless.dev
under the `knotless://` scheme, `{owner}` a handle or DID and `{did}` and
`{rkey}` those of the pull's or issue's `at://` URI, as its page has them.
Each answers with what its tool answers, read as the account the tools act
as, and `--read-only` serves them all:
| Template | Answers as | Type |
| --- | --- | --- |
| `knotless://{owner}/{repo}/pulls` | `pull_list`, with no `query` | `application/json` |
| `knotless://{owner}/{repo}/issues` | `issue_list`, with no `query` | `application/json` |
| `knotless://{owner}/{repo}/pull/{did}/{rkey}` | `pull_show` of `at://{did}/dev.knotless.pull/{rkey}` | `application/json` |
| `knotless://{owner}/{repo}/pull/{did}/{rkey}/diff` | `pull_diff`, with no `paths`: the numbered diff, or, too long to give whole, its `said` and `files` as JSON | `text/plain` |
| `knotless://{owner}/{repo}/issue/{did}/{rkey}` | `issue_show` of `at://{did}/dev.knotless.issue/{rkey}` | `application/json` |
| `knotless://{owner}/{repo}/file/{rev}/{+path}` | `repo_read` at `rev`: text as it is, a binary file as a base64 `blob` (`application/octet-stream`) | `text/plain` |
A part is percent-escaped as a template's expansion escapes it: a branch
`topic/x` is `topic%2Fx` in `{rev}`, a DID's `:` may be `%3A`, and
`{+path}` keeps its `/`. `resources/list` lists none: there are too many
to list. A URI no template makes is a `-32002` (resource not found) error
naming the templates. A read that fails is an error with the message its
tool's would have, and one past the account's share of a server's calls
(see [Agents](../guide/agents.md#connect-a-remote-client)) says to wait.
Older than the appview asks for, a read fails as a tool does.
## Long files
An agent pays for what it sends and reads as tokens, so the tools let it
docs/observability.md+3−1
lexicons/dev/knotless/getLogs.json+2−0
lexicons/dev/knotless/getUsage.json+1−0
Cargo.lock+1−0