chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
Pulls, issues, diffs and files are 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 | ||