Open an aindrive drive as a read-only folder in Memona
The plugin reaches a drive through aindrive's remote MCP endpoint with the drive's access token. Opening a connection asks which tools the token may call, so it works while the drive's machine sleeps. Listing, stat and reading map to list_files, stat and read_file; a read is statted first, and a file whose length changed in between is refused as a conflict. Reads decode only the window asked for, straight from the reply pieces, so any 1 MiB slice of a file up to aindrive's 8 MiB limit fits Memona's per-call fuel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+142
@@ -0,0 +1,142 @@
|
||||
//! Where a drive answers, and how a Memona path names something in it.
|
||||
|
||||
use crate::sdk::ErrorCode;
|
||||
|
||||
/// The drive's MCP endpoint, from whichever drive address was pasted: the
|
||||
/// server URL aindrive's MCP panel shows (`…/mcp/d/<id>`) or the drive's own
|
||||
/// page (`…/d/<id>`, with or without a folder after it). Anything before the
|
||||
/// drive segments is kept, so a server hosted under a path still works.
|
||||
pub fn endpoint(input: &str) -> Result<String, ErrorCode> {
|
||||
let input = input.trim();
|
||||
let rest = input
|
||||
.strip_prefix("https://")
|
||||
.or_else(|| input.strip_prefix("http://"))
|
||||
.ok_or(ErrorCode::InvalidInput)?;
|
||||
let origin_end =
|
||||
input.len() - rest.len() + rest.find(['/', '?', '#']).unwrap_or(rest.len());
|
||||
let (origin, tail) = input.split_at(origin_end);
|
||||
if origin_end == input.len() - rest.len() {
|
||||
return Err(ErrorCode::InvalidInput);
|
||||
}
|
||||
let path = tail.split(['?', '#']).next().unwrap_or_default();
|
||||
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
|
||||
let marker = segments
|
||||
.iter()
|
||||
.position(|segment| *segment == "d")
|
||||
.ok_or(ErrorCode::InvalidInput)?;
|
||||
let id = segments.get(marker + 1).ok_or(ErrorCode::InvalidInput)?;
|
||||
if id.is_empty()
|
||||
|| id.len() > 64
|
||||
|| !id
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
|
||||
{
|
||||
return Err(ErrorCode::InvalidInput);
|
||||
}
|
||||
let prefix_end = match marker.checked_sub(1) {
|
||||
Some(before) if segments[before] == "mcp" => before,
|
||||
_ => marker,
|
||||
};
|
||||
let mut url = origin.to_owned();
|
||||
for segment in &segments[..prefix_end] {
|
||||
url.push('/');
|
||||
url.push_str(segment);
|
||||
}
|
||||
url.push_str("/mcp/d/");
|
||||
url.push_str(id);
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
/// A drive path is relative to the drive root, and the root itself is empty.
|
||||
pub fn drive_path(path: &str) -> &str {
|
||||
path.trim_matches('/')
|
||||
}
|
||||
|
||||
/// A name Memona can show as one path segment. The drive's device may hold
|
||||
/// names the host refuses, and one of those would fail the whole listing.
|
||||
pub fn showable(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name != "."
|
||||
&& name != ".."
|
||||
&& !name.contains(['/', '\\'])
|
||||
&& !name.chars().any(char::is_control)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{drive_path, endpoint, showable};
|
||||
use crate::sdk::ErrorCode;
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_mcp_panel_url_is_used_as_given() {
|
||||
assert_eq!(
|
||||
endpoint("https://aindrive.ainetwork.ai/mcp/d/KQHGpl8hFXq8"),
|
||||
Ok("https://aindrive.ainetwork.ai/mcp/d/KQHGpl8hFXq8".to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
endpoint(" https://aindrive.ainetwork.ai/mcp/d/KQHGpl8hFXq8/ \n"),
|
||||
Ok("https://aindrive.ainetwork.ai/mcp/d/KQHGpl8hFXq8".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_drive_page_address_finds_the_same_endpoint() {
|
||||
for page in [
|
||||
"https://aindrive.ainetwork.ai/d/KQHGpl8hFXq8",
|
||||
"https://aindrive.ainetwork.ai/d/KQHGpl8hFXq8?path=notes",
|
||||
"https://aindrive.ainetwork.ai/d/KQHGpl8hFXq8/notes/today.md",
|
||||
"https://aindrive.ainetwork.ai/d/KQHGpl8hFXq8/manage#members",
|
||||
] {
|
||||
assert_eq!(
|
||||
endpoint(page),
|
||||
Ok("https://aindrive.ainetwork.ai/mcp/d/KQHGpl8hFXq8".to_owned()),
|
||||
"{page}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_self_hosted_server_keeps_its_port_and_base_path() {
|
||||
assert_eq!(
|
||||
endpoint("http://localhost:3737/d/a_b-C"),
|
||||
Ok("http://localhost:3737/mcp/d/a_b-C".to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
endpoint("https://files.example/aindrive/mcp/d/abc"),
|
||||
Ok("https://files.example/aindrive/mcp/d/abc".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_address_without_a_drive_is_refused() {
|
||||
for input in [
|
||||
"",
|
||||
"aindrive.ainetwork.ai/d/abc",
|
||||
"ftp://aindrive.ainetwork.ai/d/abc",
|
||||
"https://",
|
||||
"https://aindrive.ainetwork.ai",
|
||||
"https://aindrive.ainetwork.ai/d/",
|
||||
"https://aindrive.ainetwork.ai/d/a%2Fb",
|
||||
"https://aindrive.ainetwork.ai/drives/abc",
|
||||
] {
|
||||
assert_eq!(endpoint(input), Err(ErrorCode::InvalidInput), "{input}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn memona_paths_become_drive_relative() {
|
||||
assert_eq!(drive_path("/"), "");
|
||||
assert_eq!(drive_path("/notes/today.md"), "notes/today.md");
|
||||
assert_eq!(drive_path("/notes/"), "notes");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn names_the_host_would_refuse_are_not_shown() {
|
||||
assert!(showable("작가노트_2026.md"));
|
||||
assert!(showable(".env"));
|
||||
assert!(!showable(""));
|
||||
assert!(!showable(".."));
|
||||
assert!(!showable("a\\b"));
|
||||
assert!(!showable("line\nbreak"));
|
||||
}
|
||||
}
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
//! Base64 decoding cheap enough for Memona's per-call fuel. A read hands over
|
||||
//! up to 1 MiB in one call, and a general decoder spends more instructions on
|
||||
//! that than the call is given. Here every two characters cost one table
|
||||
//! lookup, and the characters are read where they arrived, never copied:
|
||||
//! the host charges fuel for each byte a plugin copies.
|
||||
|
||||
use crate::sdk::ErrorCode;
|
||||
|
||||
const ALPHABET: &[u8; 64] =
|
||||
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
/// Set in a looked-up word when either character is not base64.
|
||||
const INVALID: u32 = 1 << 31;
|
||||
|
||||
/// Two tables indexed by a pair of characters, read as a little-endian `u16`:
|
||||
/// the group's first pair and its second pair. Each entry holds that pair's
|
||||
/// bits already in place in the group's three bytes, read as a little-endian
|
||||
/// word, so the two halves only need an OR.
|
||||
static PAIRS: [[u32; 1 << 16]; 2] = pairs();
|
||||
|
||||
const fn bytes_of(group: u32) -> u32 {
|
||||
((group >> 16) & 0xff) | (((group >> 8) & 0xff) << 8) | ((group & 0xff) << 16)
|
||||
}
|
||||
|
||||
const fn pairs() -> [[u32; 1 << 16]; 2] {
|
||||
let mut tables = [[INVALID; 1 << 16]; 2];
|
||||
let mut high = 0;
|
||||
while high < 64 {
|
||||
let mut low = 0;
|
||||
while low < 64 {
|
||||
let index = ALPHABET[high] as usize | (ALPHABET[low] as usize) << 8;
|
||||
let bits = (high as u32) << 6 | low as u32;
|
||||
tables[0][index] = bytes_of(bits << 12);
|
||||
tables[1][index] = bytes_of(bits);
|
||||
low += 1;
|
||||
}
|
||||
high += 1;
|
||||
}
|
||||
tables
|
||||
}
|
||||
|
||||
/// One group of four characters: its three bytes in the low 24 bits, and
|
||||
/// the invalid flag if any character was not base64.
|
||||
fn group(chars: [u8; 4]) -> u32 {
|
||||
let chars = u32::from_le_bytes(chars);
|
||||
PAIRS[0][(chars & 0xffff) as usize] | PAIRS[1][(chars >> 16) as usize]
|
||||
}
|
||||
|
||||
/// Decodes whole groups into `out`, three bytes for every four characters.
|
||||
/// Sixteen characters go through each step of the main loop, which measured
|
||||
/// a third cheaper in fuel per character than four at a time.
|
||||
fn groups(chars: &[u8], out: &mut [u8]) -> u32 {
|
||||
let wide = chars.chunks_exact(16);
|
||||
let (wide_out, rest_out) = out.split_at_mut(chars.len() / 16 * 12);
|
||||
let rest = wide.remainder().chunks_exact(4);
|
||||
let mut flags = 0;
|
||||
for (chars, bytes) in wide.zip(wide_out.chunks_exact_mut(12)) {
|
||||
let (Some(low), Some(high)) =
|
||||
(chars.first_chunk::<8>(), chars.last_chunk::<8>())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let low = u64::from_le_bytes(*low);
|
||||
let high = u64::from_le_bytes(*high);
|
||||
let [a, b, c, d] = [low, low >> 32, high, high >> 32]
|
||||
.map(|half| group((half as u32).to_le_bytes()));
|
||||
flags |= a | b | c | d;
|
||||
let front = u64::from(a) | u64::from(b) << 24 | u64::from(c) << 48;
|
||||
let back = c >> 16 | d << 8;
|
||||
bytes[..8].copy_from_slice(&front.to_le_bytes());
|
||||
bytes[8..].copy_from_slice(&back.to_le_bytes());
|
||||
}
|
||||
for (chars, bytes) in rest.zip(rest_out.chunks_exact_mut(3)) {
|
||||
if let Some(chars) = chars.first_chunk::<4>() {
|
||||
let word = group(*chars);
|
||||
flags |= word;
|
||||
bytes.copy_from_slice(&word.to_le_bytes()[..3]);
|
||||
}
|
||||
}
|
||||
flags
|
||||
}
|
||||
|
||||
/// Decodes standard base64 that arrived in several pieces, read as one run,
|
||||
/// into `out`. A group split between two pieces is joined on its own. The
|
||||
/// run holds whole groups and no padding; `last` decodes the final group.
|
||||
pub fn run<'a>(
|
||||
pieces: impl Iterator<Item = &'a [u8]>,
|
||||
out: &mut [u8],
|
||||
) -> Result<(), ErrorCode> {
|
||||
let mut written = 0;
|
||||
let mut carry = [0_u8; 4];
|
||||
let mut carried = 0;
|
||||
let mut flags = 0;
|
||||
for mut piece in pieces {
|
||||
if carried > 0 {
|
||||
let taken = piece.len().min(4 - carried);
|
||||
carry[carried..carried + taken].copy_from_slice(&piece[..taken]);
|
||||
carried += taken;
|
||||
piece = &piece[taken..];
|
||||
if carried < 4 {
|
||||
continue;
|
||||
}
|
||||
let target = out
|
||||
.get_mut(written..written + 3)
|
||||
.ok_or(ErrorCode::Internal)?;
|
||||
let word = group(carry);
|
||||
flags |= word;
|
||||
target.copy_from_slice(&word.to_le_bytes()[..3]);
|
||||
written += 3;
|
||||
}
|
||||
let whole = piece.len() / 4 * 4;
|
||||
let length = whole / 4 * 3;
|
||||
let target = out
|
||||
.get_mut(written..written + length)
|
||||
.ok_or(ErrorCode::Internal)?;
|
||||
flags |= groups(&piece[..whole], target);
|
||||
written += length;
|
||||
carried = piece.len() - whole;
|
||||
carry[..carried].copy_from_slice(&piece[whole..]);
|
||||
}
|
||||
if carried > 0 || written != out.len() || flags & INVALID != 0 {
|
||||
return Err(ErrorCode::Unavailable);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The bytes of a final group, which may end in one or two `=`.
|
||||
pub fn last(chars: [u8; 4]) -> Result<Vec<u8>, ErrorCode> {
|
||||
let padding = chars.iter().rev().take_while(|c| **c == b'=').count();
|
||||
if padding > 2 {
|
||||
return Err(ErrorCode::Unavailable);
|
||||
}
|
||||
let mut padded = chars;
|
||||
for char in &mut padded[4 - padding..] {
|
||||
*char = b'A';
|
||||
}
|
||||
let word = group(padded);
|
||||
if word & INVALID != 0 {
|
||||
return Err(ErrorCode::Unavailable);
|
||||
}
|
||||
Ok(word.to_le_bytes()[..3 - padding].to_vec())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{last, run};
|
||||
use crate::sdk::ErrorCode;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
|
||||
/// Decodes all of `encoded`, split at `cuts`, as the provider does.
|
||||
fn decode(encoded: &[u8], cuts: &[usize]) -> Result<Vec<u8>, ErrorCode> {
|
||||
if encoded.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let (body, tail) = encoded.split_at(encoded.len() - 4);
|
||||
let mut pieces = Vec::new();
|
||||
let mut from = 0;
|
||||
for cut in cuts.iter().map(|cut| (*cut).min(body.len())) {
|
||||
pieces.push(&body[from..cut.max(from)]);
|
||||
from = cut.max(from);
|
||||
}
|
||||
pieces.push(&body[from..]);
|
||||
let mut out = vec![0; body.len() / 4 * 3];
|
||||
run(pieces.into_iter(), &mut out)?;
|
||||
let tail: [u8; 4] = tail.try_into().map_err(|_| ErrorCode::Internal)?;
|
||||
out.extend(last(tail)?);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn every_length_and_split_decodes_as_a_general_decoder_would()
|
||||
-> Result<(), ErrorCode> {
|
||||
let bytes: Vec<u8> = (0..=255).rev().cycle().take(1000).collect();
|
||||
for length in 0..1000 {
|
||||
let encoded = STANDARD.encode(&bytes[..length]);
|
||||
let encoded = encoded.as_bytes();
|
||||
for cuts in [&[][..], &[1], &[5, 6, 7], &[17, 33], &[2, 4, 6, 8, 10]] {
|
||||
assert_eq!(decode(encoded, cuts)?, bytes[..length], "{length}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn anything_but_standard_base64_is_refused() {
|
||||
for bad in ["QUJ-QUJD", "QUJ_QUJD", "QU\nJQUJD", "QU=DQUJD", "Q===QUJD"] {
|
||||
assert_eq!(
|
||||
run([bad.as_bytes()].into_iter(), &mut [0; 6]),
|
||||
Err(ErrorCode::Unavailable),
|
||||
"{bad}"
|
||||
);
|
||||
}
|
||||
assert_eq!(last(*b"Q==="), Err(ErrorCode::Unavailable));
|
||||
assert_eq!(last(*b"QU=D"), Err(ErrorCode::Unavailable));
|
||||
assert_eq!(
|
||||
run([&b"QUJ"[..]].into_iter(), &mut [0; 0]),
|
||||
Err(ErrorCode::Unavailable)
|
||||
);
|
||||
}
|
||||
}
|
||||
+411
@@ -0,0 +1,411 @@
|
||||
//! An aindrive filesystem provider for Memona. It speaks to the drive's
|
||||
//! remote MCP endpoint through the host's HTTP, with the drive's access token.
|
||||
|
||||
mod address;
|
||||
mod decode;
|
||||
mod mcp;
|
||||
mod sdk;
|
||||
#[cfg(test)]
|
||||
mod session_tests;
|
||||
#[cfg(test)]
|
||||
mod test_host;
|
||||
|
||||
use sdk::{
|
||||
Capabilities, ChangePage, Entry, EntryKind, ErrorCode, FileInfo, Guest,
|
||||
GuestReader, GuestWriter, ListPage, Locator, Reader, Setting, WriteCondition,
|
||||
Writer,
|
||||
};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::json;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::ops::Range;
|
||||
|
||||
#[cfg(not(test))]
|
||||
use sdk::host;
|
||||
#[cfg(test)]
|
||||
use test_host as host;
|
||||
|
||||
const MAX_CHUNK: u32 = 1024 * 1024;
|
||||
/// The drive's device reads at most 8 MiB in one call and cuts the rest, so
|
||||
/// a longer file is refused before any of it is read.
|
||||
const MAX_READ: u64 = 8 * 1024 * 1024;
|
||||
/// A whole 8 MiB file in base64, with room for the reply around it.
|
||||
const MAX_REPLY: usize = 12 * 1024 * 1024;
|
||||
/// Without these the folder cannot even be browsed.
|
||||
const REQUIRED_TOOLS: [&str; 3] = ["list_files", "stat", "read_file"];
|
||||
|
||||
struct Session {
|
||||
endpoint: String,
|
||||
authorization: String,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
fn from_config(config: &[(String, Setting)]) -> Result<Self, ErrorCode> {
|
||||
let lookup = |wanted: &str| {
|
||||
config
|
||||
.iter()
|
||||
.find(|(key, _)| key == wanted)
|
||||
.and_then(|(_, value)| match value {
|
||||
Setting::Text(text) | Setting::Secret(text) => {
|
||||
let text = text.trim();
|
||||
(!text.is_empty()).then(|| text.to_owned())
|
||||
}
|
||||
Setting::Toggle(_) => None,
|
||||
})
|
||||
};
|
||||
let endpoint =
|
||||
address::endpoint(&lookup("url").ok_or(ErrorCode::InvalidInput)?)?;
|
||||
let token = lookup("token").ok_or(ErrorCode::InvalidInput)?;
|
||||
Ok(Self {
|
||||
endpoint,
|
||||
authorization: format!("Bearer {token}"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static SESSION: RefCell<Option<Session>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
struct Aindrive;
|
||||
|
||||
fn transport(error: host::HostError) -> ErrorCode {
|
||||
match error {
|
||||
host::HostError::Denied => ErrorCode::Denied,
|
||||
host::HostError::Cancelled => ErrorCode::Cancelled,
|
||||
host::HostError::InvalidInput => ErrorCode::InvalidInput,
|
||||
host::HostError::Internal => ErrorCode::Internal,
|
||||
host::HostError::Unavailable => ErrorCode::Unavailable,
|
||||
}
|
||||
}
|
||||
|
||||
/// The drive-relative path a locator names; the drive root is empty.
|
||||
fn route(target: &Locator) -> String {
|
||||
let Locator::Path(path) = target;
|
||||
address::drive_path(path).to_owned()
|
||||
}
|
||||
|
||||
fn header(name: &str, value: &str) -> host::Header {
|
||||
host::Header {
|
||||
name: name.to_owned(),
|
||||
value: value.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends one JSON-RPC body to the drive and returns the reply in the pieces
|
||||
/// the host handed over. They are not joined here: Memona's host charges
|
||||
/// fuel for every byte a plugin copies, and a file's reply joined whole would
|
||||
/// spend most of a call's budget before any of it was decoded.
|
||||
fn post(body: &[u8]) -> Result<Vec<Vec<u8>>, ErrorCode> {
|
||||
let (url, authorization) = SESSION.with_borrow(|session| {
|
||||
session
|
||||
.as_ref()
|
||||
.map(|session| (session.endpoint.clone(), session.authorization.clone()))
|
||||
.ok_or(ErrorCode::Internal)
|
||||
})?;
|
||||
let call = host::http(&host::Request {
|
||||
method: host::Method::Post,
|
||||
url,
|
||||
headers: vec![
|
||||
header("content-type", "application/json"),
|
||||
header("accept", "application/json, text/event-stream"),
|
||||
header("authorization", &authorization),
|
||||
],
|
||||
})
|
||||
.map_err(transport)?;
|
||||
for piece in body.chunks(MAX_CHUNK as usize) {
|
||||
call.push(piece).map_err(transport)?;
|
||||
}
|
||||
let head = call.respond().map_err(transport)?;
|
||||
mcp::status(head.status)?;
|
||||
let mut pieces = Vec::new();
|
||||
let mut total = 0;
|
||||
loop {
|
||||
let piece = call.pull(MAX_CHUNK).map_err(transport)?;
|
||||
if piece.is_empty() {
|
||||
return Ok(pieces);
|
||||
}
|
||||
total += piece.len();
|
||||
if total > MAX_REPLY {
|
||||
return Err(ErrorCode::Unavailable);
|
||||
}
|
||||
pieces.push(piece);
|
||||
}
|
||||
}
|
||||
|
||||
/// A reply small enough to read as one buffer, which it usually already is.
|
||||
fn joined(mut pieces: Vec<Vec<u8>>) -> Vec<u8> {
|
||||
match pieces.len() {
|
||||
1 => pieces.pop().unwrap_or_default(),
|
||||
_ => pieces.concat(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Calls one of the drive's tools and reads its structured answer.
|
||||
fn tool<T: DeserializeOwned>(
|
||||
name: &str,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<T, ErrorCode> {
|
||||
mcp::reply(&joined(post(&mcp::call(name, arguments))?))
|
||||
}
|
||||
|
||||
fn info_of(entry: &mcp::Entry) -> FileInfo {
|
||||
FileInfo {
|
||||
kind: if entry.is_dir {
|
||||
EntryKind::Directory
|
||||
} else {
|
||||
EntryKind::File
|
||||
},
|
||||
// A folder's size is the device's directory record, not its contents.
|
||||
size: if entry.is_dir { 0 } else { entry.size },
|
||||
revision: entry.revision(),
|
||||
}
|
||||
}
|
||||
|
||||
/// One file read whole from the drive and handed over in pieces, decoded
|
||||
/// from the reply as each piece is asked for.
|
||||
struct Download {
|
||||
reply: Vec<Vec<u8>>,
|
||||
/// Where the base64 contents sit in the reply, read as one run.
|
||||
encoded: Range<usize>,
|
||||
size: u64,
|
||||
position: Cell<u64>,
|
||||
revision: Option<String>,
|
||||
}
|
||||
|
||||
impl Download {
|
||||
/// Where the characters of group `index`, three bytes each, start.
|
||||
fn chars(&self, index: usize) -> usize {
|
||||
self.encoded.start + index * 4
|
||||
}
|
||||
|
||||
/// The bytes of one group, the last of which may be padded.
|
||||
fn group(&self, index: usize) -> Result<Vec<u8>, ErrorCode> {
|
||||
let mut chars = [0_u8; 4];
|
||||
let mut filled = 0;
|
||||
let at = self.chars(index);
|
||||
for slice in mcp::slices(&self.reply, at..at + 4) {
|
||||
chars[filled..filled + slice.len()].copy_from_slice(slice);
|
||||
filled += slice.len();
|
||||
}
|
||||
let bytes = decode::last(chars)?;
|
||||
let final_group = self.encoded.len() / 4 - 1;
|
||||
if filled != 4 || (index != final_group && bytes.len() != 3) {
|
||||
return Err(ErrorCode::Unavailable);
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Decodes at most `wanted` bytes from byte `start`. A start inside a
|
||||
/// group of three answers only the rest of that group, a short piece the
|
||||
/// host reads past, so every later piece starts where a group does and no
|
||||
/// decoded bytes are shifted into place.
|
||||
fn decode(&self, start: u64, wanted: usize) -> Result<Vec<u8>, ErrorCode> {
|
||||
let first = usize::try_from(start / 3).map_err(|_| ErrorCode::Internal)?;
|
||||
let skip = (start % 3) as usize;
|
||||
if skip != 0 {
|
||||
let bytes = self.group(first)?;
|
||||
let rest = bytes.get(skip..).unwrap_or_default();
|
||||
return Ok(rest[..rest.len().min(wanted)].to_vec());
|
||||
}
|
||||
let final_group = self.encoded.len() / 4 - 1;
|
||||
let end = first + wanted.div_ceil(3);
|
||||
let whole = end.min(final_group);
|
||||
let mut bytes = Vec::with_capacity((end - first) * 3);
|
||||
bytes.resize((whole - first) * 3, 0);
|
||||
let chars = self.chars(first)..self.chars(whole);
|
||||
decode::run(mcp::slices(&self.reply, chars), &mut bytes)?;
|
||||
if end > final_group {
|
||||
bytes.extend(self.group(final_group)?);
|
||||
}
|
||||
bytes.truncate(wanted);
|
||||
Ok(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
impl GuestReader for Download {
|
||||
fn pull(&self, limit: u32) -> Result<Vec<u8>, ErrorCode> {
|
||||
let start = self.position.get();
|
||||
let wanted = u64::from(limit.min(MAX_CHUNK)).min(self.size - start);
|
||||
if wanted == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let bytes = self.decode(start, wanted as usize)?;
|
||||
self.position.set(start + bytes.len() as u64);
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn revision(&self) -> Option<String> {
|
||||
self.revision.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts reading one file. `read_file` has no offset and no revision, so
|
||||
/// the file is statted first and read whole. If its length then disagrees
|
||||
/// with the stat, the file changed in between, and the read is refused as a
|
||||
/// conflict rather than paired with the wrong revision.
|
||||
fn download(target: &Locator, offset: u64) -> Result<Download, ErrorCode> {
|
||||
let path = route(target);
|
||||
let entry: mcp::Entry = tool("stat", &json!({ "path": path }))?;
|
||||
if entry.is_dir {
|
||||
return Err(ErrorCode::InvalidInput);
|
||||
}
|
||||
if entry.size > MAX_READ {
|
||||
return Err(ErrorCode::Unsupported);
|
||||
}
|
||||
let (reply, encoded) = if offset >= entry.size {
|
||||
(Vec::new(), 0..0)
|
||||
} else {
|
||||
let length = usize::try_from(entry.size.div_ceil(3) * 4)
|
||||
.map_err(|_| ErrorCode::Unsupported)?;
|
||||
let reply = post(&mcp::call(
|
||||
"read_file",
|
||||
&json!({ "path": path, "encoding": "base64" }),
|
||||
))?;
|
||||
let encoded = mcp::contents(&reply, length)?;
|
||||
(reply, encoded)
|
||||
};
|
||||
Ok(Download {
|
||||
reply,
|
||||
encoded,
|
||||
size: entry.size,
|
||||
position: Cell::new(offset.min(entry.size)),
|
||||
revision: entry.revision(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Writing is not offered yet, so no transfer is ever started.
|
||||
enum Upload {}
|
||||
|
||||
impl GuestWriter for Upload {
|
||||
fn push(&self, _data: Vec<u8>) -> Result<(), ErrorCode> {
|
||||
match *self {}
|
||||
}
|
||||
|
||||
fn finish(&self) -> Result<Option<FileInfo>, ErrorCode> {
|
||||
match *self {}
|
||||
}
|
||||
}
|
||||
|
||||
impl Guest for Aindrive {
|
||||
type Reader = Download;
|
||||
type Writer = Upload;
|
||||
|
||||
/// Asking which tools the token may call checks the address and the token
|
||||
/// without waking the drive's device, so a connection can be saved while
|
||||
/// that machine sleeps; its files answer once it is back.
|
||||
fn open(
|
||||
_provider_id: String,
|
||||
config: Vec<(String, Setting)>,
|
||||
) -> Result<Capabilities, ErrorCode> {
|
||||
SESSION.set(None);
|
||||
SESSION.set(Some(Session::from_config(&config)?));
|
||||
let probe = post(mcp::TOOLS).and_then(|pieces| mcp::tools(&joined(pieces)));
|
||||
let tools = match probe {
|
||||
Ok(tools) => tools,
|
||||
Err(error) => {
|
||||
SESSION.set(None);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
if !REQUIRED_TOOLS
|
||||
.iter()
|
||||
.all(|required| tools.iter().any(|tool| tool == required))
|
||||
{
|
||||
SESSION.set(None);
|
||||
return Err(ErrorCode::Denied);
|
||||
}
|
||||
Ok(Capabilities {
|
||||
write: false,
|
||||
mkdir: false,
|
||||
rename: false,
|
||||
remove: false,
|
||||
conditional_write: false,
|
||||
watch: false,
|
||||
virtual_directories: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn close() {
|
||||
SESSION.set(None);
|
||||
}
|
||||
|
||||
/// The stat tool refuses the drive root, which always exists, so the root
|
||||
/// is listed instead: that still tells whether the device can be reached.
|
||||
fn stat(target: Locator) -> Result<FileInfo, ErrorCode> {
|
||||
let path = route(&target);
|
||||
if path.is_empty() {
|
||||
let _: mcp::Listing = tool("list_files", &json!({ "path": "" }))?;
|
||||
return Ok(FileInfo {
|
||||
kind: EntryKind::Directory,
|
||||
size: 0,
|
||||
revision: None,
|
||||
});
|
||||
}
|
||||
let entry: mcp::Entry = tool("stat", &json!({ "path": path }))?;
|
||||
Ok(info_of(&entry))
|
||||
}
|
||||
|
||||
/// aindrive returns a whole folder at once, so the page is taken from it.
|
||||
fn list(
|
||||
target: Locator,
|
||||
cursor: Option<String>,
|
||||
limit: u32,
|
||||
) -> Result<ListPage, ErrorCode> {
|
||||
let start: usize = match cursor {
|
||||
Some(value) => value.parse().map_err(|_| ErrorCode::InvalidInput)?,
|
||||
None => 0,
|
||||
};
|
||||
let listing: mcp::Listing =
|
||||
tool("list_files", &json!({ "path": route(&target) }))?;
|
||||
let children: Vec<Entry> = listing
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|entry| address::showable(&entry.name))
|
||||
.map(|entry| Entry {
|
||||
name: entry.name.clone(),
|
||||
info: info_of(entry),
|
||||
})
|
||||
.collect();
|
||||
let end = start.saturating_add(limit as usize).min(children.len());
|
||||
let entries = children.get(start..end).unwrap_or_default().to_vec();
|
||||
Ok(ListPage {
|
||||
entries,
|
||||
cursor: (end < children.len()).then(|| end.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
fn open_read(target: Locator, offset: u64) -> Result<Reader, ErrorCode> {
|
||||
Ok(Reader::new(download(&target, offset)?))
|
||||
}
|
||||
|
||||
fn open_write(
|
||||
_target: Locator,
|
||||
_condition: WriteCondition,
|
||||
_size_hint: Option<u64>,
|
||||
) -> Result<Writer, ErrorCode> {
|
||||
Err(ErrorCode::Unsupported)
|
||||
}
|
||||
|
||||
fn mkdir(_target: Locator) -> Result<FileInfo, ErrorCode> {
|
||||
Err(ErrorCode::Unsupported)
|
||||
}
|
||||
|
||||
fn rename(
|
||||
_target: Locator,
|
||||
_destination: String,
|
||||
) -> Result<FileInfo, ErrorCode> {
|
||||
Err(ErrorCode::Unsupported)
|
||||
}
|
||||
|
||||
fn remove(_target: Locator) -> Result<(), ErrorCode> {
|
||||
Err(ErrorCode::Unsupported)
|
||||
}
|
||||
|
||||
fn poll(_cursor: Option<String>) -> Result<ChangePage, ErrorCode> {
|
||||
Err(ErrorCode::Unsupported)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
sdk::export!(Aindrive with_types_in sdk);
|
||||
+427
@@ -0,0 +1,427 @@
|
||||
//! The drive's remote MCP endpoint: one JSON-RPC `tools/call` per request,
|
||||
//! answered either as JSON or as a single server-sent event.
|
||||
|
||||
use crate::sdk::ErrorCode;
|
||||
use serde::Deserialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::value::RawValue;
|
||||
use std::borrow::Cow;
|
||||
use std::ops::Range;
|
||||
|
||||
/// Lists the tools this token may call. It never reaches the drive's device,
|
||||
/// so it checks the address and the token even while the device is offline.
|
||||
pub const TOOLS: &[u8] = br#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#;
|
||||
|
||||
/// The body of one tool call. The endpoint keeps no session, so every call
|
||||
/// stands alone without an `initialize` first.
|
||||
pub fn call(tool: &str, arguments: &serde_json::Value) -> Vec<u8> {
|
||||
serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": { "name": tool, "arguments": arguments },
|
||||
})
|
||||
.to_string()
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
/// What an HTTP status means before any body is read. A 404 is the drive
|
||||
/// itself missing: paths that are not there arrive as tool errors instead.
|
||||
pub fn status(code: u16) -> Result<(), ErrorCode> {
|
||||
match code {
|
||||
200..=299 => Ok(()),
|
||||
401 | 403 => Err(ErrorCode::Denied),
|
||||
404 => Err(ErrorCode::NotFound),
|
||||
_ => Err(ErrorCode::Unavailable),
|
||||
}
|
||||
}
|
||||
|
||||
/// The message of a reply sent the way the MCP SDK sends one: a single
|
||||
/// event, the whole message on its one `data:` line. It is borrowed as it
|
||||
/// stands, without walking a file's worth of bytes to find where it ends.
|
||||
fn single_event(body: &[u8]) -> Option<&[u8]> {
|
||||
body
|
||||
.strip_prefix(b"event: message\n")
|
||||
.unwrap_or(body)
|
||||
.strip_prefix(b"data: ")?
|
||||
.strip_suffix(b"\n\n")
|
||||
}
|
||||
|
||||
/// The JSON-RPC message in any other reply: plain JSON, or the last event of
|
||||
/// a stream, whose `data:` lines are joined as server-sent events define.
|
||||
fn message(body: &[u8]) -> Result<Cow<'_, [u8]>, ErrorCode> {
|
||||
if body.trim_ascii_start().starts_with(b"{") {
|
||||
return Ok(Cow::Borrowed(body));
|
||||
}
|
||||
let mut event: Vec<&[u8]> = Vec::new();
|
||||
let mut last = Vec::new();
|
||||
for line in body.split(|byte| *byte == b'\n') {
|
||||
let line = line.strip_suffix(b"\r").unwrap_or(line);
|
||||
if line.is_empty() && !event.is_empty() {
|
||||
last = std::mem::take(&mut event);
|
||||
}
|
||||
if let Some(data) = line.strip_prefix(b"data:") {
|
||||
event.push(data.strip_prefix(b" ").unwrap_or(data));
|
||||
}
|
||||
}
|
||||
if !event.is_empty() {
|
||||
last = event;
|
||||
}
|
||||
match last.as_slice() {
|
||||
[] => Err(ErrorCode::Unavailable),
|
||||
[only] => Ok(Cow::Borrowed(only)),
|
||||
many => Ok(Cow::Owned(many.join(&b'\n'))),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(bound = "T: DeserializeOwned")]
|
||||
struct Envelope<T> {
|
||||
result: Option<T>,
|
||||
error: Option<serde::de::IgnoredAny>,
|
||||
}
|
||||
|
||||
/// A tool result, read in the same pass as its envelope: a file's contents
|
||||
/// are its largest part, and every extra pass over them costs fuel.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(bound = "T: DeserializeOwned")]
|
||||
struct Outcome<T> {
|
||||
#[serde(default, rename = "isError")]
|
||||
is_error: bool,
|
||||
#[serde(default)]
|
||||
content: Vec<Text>,
|
||||
#[serde(rename = "structuredContent")]
|
||||
structured: Option<T>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Text {
|
||||
#[serde(default)]
|
||||
text: String,
|
||||
}
|
||||
|
||||
fn envelope<T: DeserializeOwned>(body: &[u8]) -> Result<T, ErrorCode> {
|
||||
let envelope: Envelope<T> =
|
||||
match single_event(body).map(serde_json::from_slice) {
|
||||
Some(Ok(envelope)) => envelope,
|
||||
_ => serde_json::from_slice(&message(body)?)
|
||||
.map_err(|_| ErrorCode::Unavailable)?,
|
||||
};
|
||||
if envelope.error.is_some() {
|
||||
return Err(ErrorCode::InvalidInput);
|
||||
}
|
||||
envelope.result.ok_or(ErrorCode::Unavailable)
|
||||
}
|
||||
|
||||
/// A tool's structured answer, or the error it reported. A JSON-RPC error,
|
||||
/// as opposed to a tool error, means the request itself was malformed.
|
||||
pub fn reply<T: DeserializeOwned>(body: &[u8]) -> Result<T, ErrorCode> {
|
||||
let outcome: Outcome<T> = envelope(body)?;
|
||||
if outcome.is_error {
|
||||
let text = outcome.content.first().map(|part| part.text.as_str());
|
||||
return Err(failure(text.unwrap_or_default()));
|
||||
}
|
||||
outcome.structured.ok_or(ErrorCode::Unavailable)
|
||||
}
|
||||
|
||||
/// The names of the tools a `tools/list` answer offers.
|
||||
pub fn tools(body: &[u8]) -> Result<Vec<String>, ErrorCode> {
|
||||
#[derive(Deserialize)]
|
||||
struct Listed {
|
||||
tools: Vec<Tool>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct Tool {
|
||||
name: String,
|
||||
}
|
||||
let listed: Listed = envelope(body)?;
|
||||
Ok(listed.tools.into_iter().map(|tool| tool.name).collect())
|
||||
}
|
||||
|
||||
/// One entry of a listing or a stat, as the drive's device reported it.
|
||||
#[derive(Deserialize)]
|
||||
pub struct Entry {
|
||||
pub name: String,
|
||||
#[serde(rename = "isDir")]
|
||||
pub is_dir: bool,
|
||||
#[serde(default)]
|
||||
pub size: u64,
|
||||
/// Kept as the device wrote it: a revision is compared as text, and a
|
||||
/// float printed again could be spelled differently.
|
||||
#[serde(default, rename = "mtimeMs")]
|
||||
mtime: Option<Box<RawValue>>,
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
/// The revision aindrive compares a conditional write against,
|
||||
/// `m<mtimeMs>-s<size>`. A folder has none.
|
||||
pub fn revision(&self) -> Option<String> {
|
||||
if self.is_dir {
|
||||
return None;
|
||||
}
|
||||
let mtime = self
|
||||
.mtime
|
||||
.as_deref()
|
||||
.map(RawValue::get)
|
||||
.filter(|text| *text != "null")
|
||||
.unwrap_or("0");
|
||||
Some(format!("m{mtime}-s{}", self.size))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Listing {
|
||||
pub entries: Vec<Entry>,
|
||||
}
|
||||
|
||||
/// A whole file, as `read_file` returns it. The device stops at 8 MiB and
|
||||
/// says so with `truncated` rather than failing.
|
||||
#[derive(Deserialize)]
|
||||
pub struct Contents {
|
||||
pub content: String,
|
||||
pub encoding: String,
|
||||
#[serde(default)]
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
||||
haystack
|
||||
.windows(needle.len())
|
||||
.position(|window| window == needle)
|
||||
}
|
||||
|
||||
/// The parts of `pieces`, read as one run, that `range` covers.
|
||||
pub fn slices(
|
||||
pieces: &[Vec<u8>],
|
||||
range: Range<usize>,
|
||||
) -> impl Iterator<Item = &[u8]> {
|
||||
let mut offset = 0;
|
||||
pieces.iter().filter_map(move |piece| {
|
||||
let from = offset;
|
||||
offset += piece.len();
|
||||
let start = range.start.max(from);
|
||||
let end = range.end.min(offset);
|
||||
(start < end).then(|| &piece[start - from..end - from])
|
||||
})
|
||||
}
|
||||
|
||||
/// Where the base64 contents sit in a `read_file` reply, read as one run,
|
||||
/// found without reading through them. They are nearly the whole reply, and
|
||||
/// parsing them as a JSON string would spend a call's fuel on bytes base64
|
||||
/// never escapes. The stat says how long they must be; the rest of the reply,
|
||||
/// with them left out, is still read whole to check it was a complete base64
|
||||
/// read.
|
||||
pub fn contents(
|
||||
pieces: &[Vec<u8>],
|
||||
length: usize,
|
||||
) -> Result<Range<usize>, ErrorCode> {
|
||||
const SECTION: &[u8] = br#""structuredContent":"#;
|
||||
const FIELD: &[u8] = br#""content":""#;
|
||||
let head = pieces.first().map(Vec::as_slice).unwrap_or_default();
|
||||
let start =
|
||||
find(&head[..head.len().min(64 * 1024)], SECTION).and_then(|at| {
|
||||
let from = at + SECTION.len();
|
||||
let near = &head[from..head.len().min(from + 1024)];
|
||||
find(near, FIELD).map(|field| from + field + FIELD.len())
|
||||
});
|
||||
let Some(start) = start else {
|
||||
// A failed read carries no contents and is small enough to join.
|
||||
let _: Contents = reply(&pieces.concat())?;
|
||||
return Err(ErrorCode::Unavailable);
|
||||
};
|
||||
// Contents of another length are another version of the file than the
|
||||
// one statted, so the revision in hand does not describe them.
|
||||
let end = start.saturating_add(length);
|
||||
let total = pieces.iter().map(Vec::len).sum();
|
||||
if slices(pieces, end..total)
|
||||
.next()
|
||||
.and_then(|rest| rest.first())
|
||||
!= Some(&b'"')
|
||||
{
|
||||
return Err(ErrorCode::Conflict);
|
||||
}
|
||||
let mut rest = head[..start].to_vec();
|
||||
for slice in slices(pieces, end..total) {
|
||||
rest.extend_from_slice(slice);
|
||||
}
|
||||
let contents: Contents = reply(&rest)?;
|
||||
if !contents.content.is_empty()
|
||||
|| contents.encoding != "base64"
|
||||
|| contents.truncated
|
||||
{
|
||||
return Err(ErrorCode::Unavailable);
|
||||
}
|
||||
Ok(start..end)
|
||||
}
|
||||
|
||||
/// What a tool error means to a filesystem caller. aindrive writes them as
|
||||
/// `[code] message`, and an `internal` one carries the device's own error,
|
||||
/// so the device's errno names are read too. Messages are never shown on:
|
||||
/// they can hold the absolute path on the owner's machine.
|
||||
fn failure(text: &str) -> ErrorCode {
|
||||
let (code, message) = text
|
||||
.strip_prefix('[')
|
||||
.and_then(|rest| rest.split_once("] "))
|
||||
.unwrap_or(("internal", text));
|
||||
match code {
|
||||
"not_found" => ErrorCode::NotFound,
|
||||
"forbidden" => ErrorCode::Denied,
|
||||
"invalid_params" if message.starts_with("conflict") => ErrorCode::Conflict,
|
||||
"invalid_params" => ErrorCode::InvalidInput,
|
||||
_ => device(message),
|
||||
}
|
||||
}
|
||||
|
||||
fn device(message: &str) -> ErrorCode {
|
||||
const KNOWN: &[(&str, ErrorCode)] = &[
|
||||
("ENOENT", ErrorCode::NotFound),
|
||||
("EACCES", ErrorCode::Denied),
|
||||
("EPERM", ErrorCode::Denied),
|
||||
("reserved path", ErrorCode::Denied),
|
||||
("EEXIST", ErrorCode::Conflict),
|
||||
("ENOTEMPTY", ErrorCode::Conflict),
|
||||
("EISDIR", ErrorCode::InvalidInput),
|
||||
("is a directory", ErrorCode::InvalidInput),
|
||||
("ENOTDIR", ErrorCode::InvalidInput),
|
||||
("ENAMETOOLONG", ErrorCode::InvalidInput),
|
||||
("path too long", ErrorCode::InvalidInput),
|
||||
];
|
||||
KNOWN
|
||||
.iter()
|
||||
.find(|(needle, _)| message.contains(needle))
|
||||
.map_or(ErrorCode::Unavailable, |(_, code)| *code)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Entry, Listing, call, reply, status, tools};
|
||||
use crate::sdk::ErrorCode;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize, PartialEq)]
|
||||
struct Answer {
|
||||
ok: bool,
|
||||
}
|
||||
|
||||
fn tool_error(text: &str) -> Vec<u8> {
|
||||
let message = serde_json::json!({
|
||||
"result": {
|
||||
"_meta": {},
|
||||
"content": [{ "type": "text", "text": text }],
|
||||
"isError": true,
|
||||
},
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
});
|
||||
format!("event: message\ndata: {message}\n\n").into_bytes()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_call_names_the_tool_and_its_arguments() -> Result<(), ErrorCode> {
|
||||
let body = call("list_files", &serde_json::json!({ "path": "notes" }));
|
||||
let sent: serde_json::Value =
|
||||
serde_json::from_slice(&body).map_err(|_| ErrorCode::Internal)?;
|
||||
assert_eq!(sent["method"], "tools/call");
|
||||
assert_eq!(sent["params"]["name"], "list_files");
|
||||
assert_eq!(sent["params"]["arguments"]["path"], "notes");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_event_stream_and_plain_json_read_the_same()
|
||||
-> Result<(), ErrorCode> {
|
||||
let json = br#"{"result":{"content":[{"type":"text","text":"ok"}],"structuredContent":{"ok":true}},"jsonrpc":"2.0","id":1}"#;
|
||||
let event = [b"event: message\r\ndata: ", &json[..], b"\r\n\r\n"].concat();
|
||||
assert_eq!(reply::<Answer>(json)?, Answer { ok: true });
|
||||
assert_eq!(reply::<Answer>(&event)?, Answer { ok: true });
|
||||
let split = b"event: message\ndata: {\"result\":\ndata: {\"structuredContent\":{\"ok\":true}}}\n\n";
|
||||
assert_eq!(reply::<Answer>(split)?, Answer { ok: true });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_errors_keep_missing_denied_and_conflicting_apart() {
|
||||
let cases = [
|
||||
("[not_found] no entry at notes/a.md", ErrorCode::NotFound),
|
||||
(
|
||||
"[forbidden] forbidden (need editor, have viewer)",
|
||||
ErrorCode::Denied,
|
||||
),
|
||||
("[forbidden] payment required for paid", ErrorCode::Denied),
|
||||
(
|
||||
"[invalid_params] conflict: the file changed since that revision",
|
||||
ErrorCode::Conflict,
|
||||
),
|
||||
("[invalid_params] invalid path: ..", ErrorCode::InvalidInput),
|
||||
("[internal] agent offline", ErrorCode::Unavailable),
|
||||
(
|
||||
"[internal] ENOENT: no such file or directory, scandir '/Users/me/x'",
|
||||
ErrorCode::NotFound,
|
||||
),
|
||||
(
|
||||
"[internal] ENOTDIR: not a directory",
|
||||
ErrorCode::InvalidInput,
|
||||
),
|
||||
("[internal] is a directory", ErrorCode::InvalidInput),
|
||||
("something unexpected", ErrorCode::Unavailable),
|
||||
];
|
||||
for (text, expected) in cases {
|
||||
assert_eq!(reply::<Answer>(&tool_error(text)), Err(expected), "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_rejected_request_is_not_mistaken_for_a_reply() {
|
||||
let rejected =
|
||||
br#"{"jsonrpc":"2.0","id":1,"error":{"code":-32602,"message":"bad"}}"#;
|
||||
assert_eq!(reply::<Answer>(rejected), Err(ErrorCode::InvalidInput));
|
||||
assert_eq!(reply::<Answer>(b"<html>"), Err(ErrorCode::Unavailable));
|
||||
assert_eq!(reply::<Answer>(b""), Err(ErrorCode::Unavailable));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_tool_list_names_what_the_token_may_call() -> Result<(), ErrorCode>
|
||||
{
|
||||
let body = br#"event: message
|
||||
data: {"result":{"tools":[{"name":"list_files","inputSchema":{}},{"name":"read_file"}]},"jsonrpc":"2.0","id":1}
|
||||
|
||||
"#;
|
||||
assert_eq!(tools(body)?, vec!["list_files", "read_file"]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_revision_spells_the_modification_time_as_the_device_did()
|
||||
-> Result<(), ErrorCode> {
|
||||
let listing: Listing = serde_json::from_str(
|
||||
r#"{"entries":[
|
||||
{"name":"a.md","isDir":false,"size":1837,"mtimeMs":1791726579102.7002},
|
||||
{"name":"b.md","isDir":false,"size":5,"mtimeMs":1791726579100},
|
||||
{"name":"c.md","isDir":false,"size":0},
|
||||
{"name":"notes","isDir":true,"size":96,"mtimeMs":1791726603429.636}
|
||||
]}"#,
|
||||
)
|
||||
.map_err(|_| ErrorCode::Internal)?;
|
||||
let revisions: Vec<_> =
|
||||
listing.entries.iter().map(Entry::revision).collect();
|
||||
assert_eq!(
|
||||
revisions,
|
||||
[
|
||||
Some("m1791726579102.7002-s1837".to_owned()),
|
||||
Some("m1791726579100-s5".to_owned()),
|
||||
Some("m0-s0".to_owned()),
|
||||
None,
|
||||
]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn statuses_keep_the_token_apart_from_the_drive() {
|
||||
assert_eq!(status(200), Ok(()));
|
||||
assert_eq!(status(401), Err(ErrorCode::Denied));
|
||||
assert_eq!(status(403), Err(ErrorCode::Denied));
|
||||
assert_eq!(status(404), Err(ErrorCode::NotFound));
|
||||
assert_eq!(status(502), Err(ErrorCode::Unavailable));
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
//! Rust guest bindings for the versioned Memona filesystem component contract,
|
||||
//! generated from the copy of the canonical WIT under `wit/`.
|
||||
|
||||
wit_bindgen::generate!({
|
||||
path: "wit",
|
||||
world: "filesystem",
|
||||
pub_export_macro: true,
|
||||
});
|
||||
|
||||
pub use exports::memona::filesystem::provider::{
|
||||
Capabilities, ChangePage, Entry, EntryKind, ErrorCode, FileInfo, Guest,
|
||||
GuestReader, GuestWriter, ListPage, Locator, Reader, WriteCondition, Writer,
|
||||
};
|
||||
pub use memona::filesystem::host;
|
||||
pub use memona::filesystem::host::Setting;
|
||||
@@ -0,0 +1,287 @@
|
||||
//! The provider against an in-memory aindrive. Live QA separately runs the
|
||||
//! compiled component through Memona's host against the real service.
|
||||
|
||||
use super::{Aindrive, SESSION, download, test_host};
|
||||
use crate::sdk::{EntryKind, ErrorCode, Guest, GuestReader, Locator, Setting};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const ENDPOINT: &str = "https://aindrive.ainetwork.ai/mcp/d/KQHGpl8hFXq8";
|
||||
|
||||
fn config() -> Vec<(String, Setting)> {
|
||||
vec![
|
||||
(
|
||||
"url".to_owned(),
|
||||
Setting::Text("https://aindrive.ainetwork.ai/d/KQHGpl8hFXq8".to_owned()),
|
||||
),
|
||||
(
|
||||
"token".to_owned(),
|
||||
Setting::Secret(" aind_pat_secret\n".to_owned()),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn at(path: &str) -> Locator {
|
||||
Locator::Path(path.to_owned())
|
||||
}
|
||||
|
||||
fn sample() -> test_host::Drive {
|
||||
let mut files = BTreeMap::new();
|
||||
for (path, bytes, mtime) in [
|
||||
("PAGE.md", &b"# Studio"[..], "1791731214739.939"),
|
||||
("notes/작가노트_2026.md", &b"notes"[..], "1791726579100"),
|
||||
("notes/odd\\name.md", &b"x"[..], "1"),
|
||||
("artworks.tsv", &b"title\tyear"[..], "1791726579103.648"),
|
||||
] {
|
||||
files.insert(path.to_owned(), test_host::file(bytes, mtime));
|
||||
}
|
||||
test_host::Drive {
|
||||
files,
|
||||
folders: vec!["outputs".to_owned()],
|
||||
..test_host::Drive::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn opening_checks_the_token_against_the_drive_endpoint()
|
||||
-> Result<(), ErrorCode> {
|
||||
let drive = test_host::install(sample());
|
||||
let capabilities = Aindrive::open("aindrive".to_owned(), config())?;
|
||||
assert!(!capabilities.write && !capabilities.remove && !capabilities.watch);
|
||||
let drive = drive.borrow();
|
||||
let [seen] = drive.seen.as_slice() else {
|
||||
return Err(ErrorCode::Internal);
|
||||
};
|
||||
assert_eq!(seen.url, ENDPOINT);
|
||||
assert_eq!(seen.method, "tools/list");
|
||||
assert_eq!(
|
||||
seen.authorization.as_deref(),
|
||||
Some("Bearer aind_pat_secret")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_refused_token_or_a_missing_drive_leaves_no_connection() {
|
||||
for (status, expected) in
|
||||
[(401, ErrorCode::Denied), (404, ErrorCode::NotFound)]
|
||||
{
|
||||
let _drive = test_host::install(test_host::Drive {
|
||||
status: Some(status),
|
||||
..sample()
|
||||
});
|
||||
assert_eq!(
|
||||
Aindrive::open("aindrive".to_owned(), config()).err(),
|
||||
Some(expected)
|
||||
);
|
||||
assert!(SESSION.with_borrow(Option::is_none));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_token_that_cannot_read_files_is_refused() {
|
||||
let _drive = test_host::install(test_host::Drive {
|
||||
tools: Some(vec!["search"]),
|
||||
..sample()
|
||||
});
|
||||
assert_eq!(
|
||||
Aindrive::open("aindrive".to_owned(), config()).err(),
|
||||
Some(ErrorCode::Denied)
|
||||
);
|
||||
assert!(SESSION.with_borrow(Option::is_none));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_config_without_a_token_or_drive_is_invalid() {
|
||||
let _drive = test_host::install(sample());
|
||||
let only_url = config().into_iter().take(1).collect();
|
||||
let only_token = config().into_iter().skip(1).collect();
|
||||
for config in [only_url, only_token] {
|
||||
assert_eq!(
|
||||
Aindrive::open("aindrive".to_owned(), config).err(),
|
||||
Some(ErrorCode::InvalidInput)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn listing_pages_through_a_folder_and_hides_unshowable_names()
|
||||
-> Result<(), ErrorCode> {
|
||||
let _drive = test_host::install(sample());
|
||||
Aindrive::open("aindrive".to_owned(), config())?;
|
||||
let first = Aindrive::list(at("/"), None, 2)?;
|
||||
let names: Vec<_> = first.entries.iter().map(|e| e.name.as_str()).collect();
|
||||
assert_eq!(names, ["PAGE.md", "artworks.tsv"]);
|
||||
assert_eq!(
|
||||
first.entries[0].info.revision.as_deref(),
|
||||
Some("m1791731214739.939-s8")
|
||||
);
|
||||
let rest = Aindrive::list(at("/"), first.cursor, 2)?;
|
||||
let names: Vec<_> = rest.entries.iter().map(|e| e.name.as_str()).collect();
|
||||
assert_eq!(names, ["notes", "outputs"]);
|
||||
assert!(rest.cursor.is_none());
|
||||
assert!(matches!(rest.entries[0].info.kind, EntryKind::Directory));
|
||||
assert_eq!(rest.entries[0].info.size, 0);
|
||||
assert!(rest.entries[0].info.revision.is_none());
|
||||
|
||||
let notes = Aindrive::list(at("/notes/"), None, 10)?;
|
||||
let names: Vec<_> = notes.entries.iter().map(|e| e.name.as_str()).collect();
|
||||
assert_eq!(names, ["작가노트_2026.md"]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn listing_a_file_or_a_missing_folder_says_which() -> Result<(), ErrorCode>
|
||||
{
|
||||
let _drive = test_host::install(sample());
|
||||
Aindrive::open("aindrive".to_owned(), config())?;
|
||||
assert_eq!(
|
||||
Aindrive::list(at("/PAGE.md"), None, 10).err(),
|
||||
Some(ErrorCode::InvalidInput)
|
||||
);
|
||||
assert_eq!(
|
||||
Aindrive::list(at("/gone"), None, 10).err(),
|
||||
Some(ErrorCode::NotFound)
|
||||
);
|
||||
assert_eq!(
|
||||
Aindrive::list(at("/"), Some("x".to_owned()), 10).err(),
|
||||
Some(ErrorCode::InvalidInput)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stat_reports_the_root_files_and_folders() -> Result<(), ErrorCode> {
|
||||
let _drive = test_host::install(sample());
|
||||
Aindrive::open("aindrive".to_owned(), config())?;
|
||||
let root = Aindrive::stat(at("/"))?;
|
||||
assert!(matches!(root.kind, EntryKind::Directory));
|
||||
let page = Aindrive::stat(at("/PAGE.md"))?;
|
||||
assert!(matches!(page.kind, EntryKind::File));
|
||||
assert_eq!(page.size, 8);
|
||||
assert_eq!(page.revision.as_deref(), Some("m1791731214739.939-s8"));
|
||||
assert!(matches!(
|
||||
Aindrive::stat(at("/outputs"))?.kind,
|
||||
EntryKind::Directory
|
||||
));
|
||||
assert_eq!(
|
||||
Aindrive::stat(at("/missing.md")).err(),
|
||||
Some(ErrorCode::NotFound)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reads all of a file from `offset`, as the host does, in pulls of
|
||||
/// `limit` bytes that may come back shorter.
|
||||
fn read_from(
|
||||
path: &str,
|
||||
offset: u64,
|
||||
limit: u32,
|
||||
) -> Result<(Vec<u8>, Option<String>), ErrorCode> {
|
||||
let reader = download(&at(path), offset)?;
|
||||
let mut read = Vec::new();
|
||||
loop {
|
||||
let piece = reader.pull(limit)?;
|
||||
assert!(piece.len() <= limit as usize);
|
||||
if piece.is_empty() {
|
||||
return Ok((read, reader.revision()));
|
||||
}
|
||||
read.extend_from_slice(&piece);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reads_start_anywhere_and_never_exceed_the_limit()
|
||||
-> Result<(), ErrorCode> {
|
||||
// Longer than one of the test host's 4 KiB reply pieces, so groups of
|
||||
// base64 characters are split between pieces.
|
||||
let bytes: Vec<u8> = (0..=255).cycle().take(20_000).collect();
|
||||
let mut drive = sample();
|
||||
drive
|
||||
.files
|
||||
.insert("data.bin".to_owned(), test_host::file(&bytes, "5"));
|
||||
let _drive = test_host::install(drive);
|
||||
Aindrive::open("aindrive".to_owned(), config())?;
|
||||
for offset in [0_u64, 1, 2, 3, 3070, 3071, 10_000, 19_998, 19_999] {
|
||||
for limit in [1, 7, 4096, 1024 * 1024] {
|
||||
let (read, revision) = read_from("/data.bin", offset, limit)?;
|
||||
assert_eq!(read, bytes[offset as usize..], "{offset} by {limit}");
|
||||
assert_eq!(revision.as_deref(), Some("m5-s20000"));
|
||||
}
|
||||
}
|
||||
assert!(read_from("/data.bin", 20_000, 10)?.0.is_empty());
|
||||
assert!(read_from("/data.bin", 50_000, 10)?.0.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn files_shorter_than_a_group_and_empty_files_read_whole()
|
||||
-> Result<(), ErrorCode> {
|
||||
let mut drive = sample();
|
||||
for (name, bytes) in [("one", &b"a"[..]), ("two", b"ab"), ("empty", b"")] {
|
||||
drive
|
||||
.files
|
||||
.insert(name.to_owned(), test_host::file(bytes, "1"));
|
||||
}
|
||||
let _drive = test_host::install(drive);
|
||||
Aindrive::open("aindrive".to_owned(), config())?;
|
||||
assert_eq!(read_from("/one", 0, 10)?.0, b"a");
|
||||
assert_eq!(read_from("/two", 1, 10)?.0, b"b");
|
||||
assert_eq!(read_from("/empty", 0, 10)?.0, b"");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_file_changed_between_stat_and_read_is_a_conflict()
|
||||
-> Result<(), ErrorCode> {
|
||||
let _drive = test_host::install(test_host::Drive {
|
||||
change_on_read: Some(test_host::file(b"# Studio, longer now", "2")),
|
||||
..sample()
|
||||
});
|
||||
Aindrive::open("aindrive".to_owned(), config())?;
|
||||
assert_eq!(
|
||||
download(&at("/PAGE.md"), 0).err(),
|
||||
Some(ErrorCode::Conflict)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn folders_missing_files_and_oversized_files_are_not_read()
|
||||
-> Result<(), ErrorCode> {
|
||||
let mut drive = sample();
|
||||
drive.files.insert(
|
||||
"large.bin".to_owned(),
|
||||
test_host::file(&vec![0; 8 * 1024 * 1024 + 1], "9"),
|
||||
);
|
||||
let drive = test_host::install(drive);
|
||||
Aindrive::open("aindrive".to_owned(), config())?;
|
||||
assert_eq!(
|
||||
download(&at("/notes"), 0).err(),
|
||||
Some(ErrorCode::InvalidInput)
|
||||
);
|
||||
assert_eq!(
|
||||
download(&at("/missing.md"), 0).err(),
|
||||
Some(ErrorCode::NotFound)
|
||||
);
|
||||
drive.borrow_mut().seen.clear();
|
||||
assert_eq!(
|
||||
download(&at("/large.bin"), 0).err(),
|
||||
Some(ErrorCode::Unsupported)
|
||||
);
|
||||
let tools: Vec<_> = drive
|
||||
.borrow()
|
||||
.seen
|
||||
.iter()
|
||||
.map(|seen| seen.tool.clone())
|
||||
.collect();
|
||||
assert_eq!(tools, [Some("stat".to_owned())]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nothing_is_requested_before_a_connection_opens() {
|
||||
let drive = test_host::install(sample());
|
||||
Aindrive::close();
|
||||
assert_eq!(Aindrive::stat(at("/")).err(), Some(ErrorCode::Internal));
|
||||
assert!(drive.borrow().seen.is_empty());
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
//! An in-memory aindrive behind the generated synchronous `Guest` API. It
|
||||
//! answers tool calls the way the drive's MCP endpoint does, as one
|
||||
//! server-sent event; live QA covers Memona's real HTTP host and the service.
|
||||
|
||||
pub use crate::sdk::host::{Header, HostError, Method, Request, Response};
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use serde_json::{Value, json};
|
||||
use std::cell::RefCell;
|
||||
use std::collections::BTreeMap;
|
||||
use std::rc::Rc;
|
||||
|
||||
pub struct File {
|
||||
pub bytes: Vec<u8>,
|
||||
/// Written into replies verbatim, the way the device's float prints.
|
||||
pub mtime: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Drive {
|
||||
/// Drive-relative paths. A folder is any prefix of a file, or listed here.
|
||||
pub files: BTreeMap<String, File>,
|
||||
pub folders: Vec<String>,
|
||||
/// The tools `tools/list` offers; every tool by default.
|
||||
pub tools: Option<Vec<&'static str>>,
|
||||
/// The status every request answers with, when not 200.
|
||||
pub status: Option<u16>,
|
||||
/// Replaces a file between a stat and the read that follows it.
|
||||
pub change_on_read: Option<File>,
|
||||
pub seen: Vec<Seen>,
|
||||
}
|
||||
|
||||
pub struct Seen {
|
||||
pub url: String,
|
||||
pub authorization: Option<String>,
|
||||
pub method: String,
|
||||
pub tool: Option<String>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static ACTIVE: RefCell<Option<Rc<RefCell<Drive>>>> =
|
||||
const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
pub fn install(drive: Drive) -> Rc<RefCell<Drive>> {
|
||||
let drive = Rc::new(RefCell::new(drive));
|
||||
ACTIVE.set(Some(drive.clone()));
|
||||
drive
|
||||
}
|
||||
|
||||
pub fn file(bytes: &[u8], mtime: &'static str) -> File {
|
||||
File {
|
||||
bytes: bytes.to_vec(),
|
||||
mtime,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn http(request: &Request) -> Result<Exchange, HostError> {
|
||||
if !matches!(request.method, Method::Post) {
|
||||
return Err(HostError::InvalidInput);
|
||||
}
|
||||
let drive = ACTIVE
|
||||
.with_borrow(Clone::clone)
|
||||
.ok_or(HostError::Internal)?;
|
||||
Ok(Exchange {
|
||||
drive,
|
||||
request: request.clone(),
|
||||
body: RefCell::new(Vec::new()),
|
||||
reply: RefCell::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
pub struct Exchange {
|
||||
drive: Rc<RefCell<Drive>>,
|
||||
request: Request,
|
||||
body: RefCell<Vec<u8>>,
|
||||
reply: RefCell<Option<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl Exchange {
|
||||
pub fn push(&self, data: &[u8]) -> Result<(), HostError> {
|
||||
if data.len() > 1024 * 1024 || self.reply.borrow().is_some() {
|
||||
return Err(HostError::InvalidInput);
|
||||
}
|
||||
self.body.borrow_mut().extend_from_slice(data);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn respond(&self) -> Result<Response, HostError> {
|
||||
let call: Value = serde_json::from_slice(&self.body.borrow())
|
||||
.map_err(|_| HostError::InvalidInput)?;
|
||||
let mut drive = self.drive.borrow_mut();
|
||||
let authorization = self
|
||||
.request
|
||||
.headers
|
||||
.iter()
|
||||
.find(|header| header.name == "authorization")
|
||||
.map(|header| header.value.clone());
|
||||
drive.seen.push(Seen {
|
||||
url: self.request.url.clone(),
|
||||
authorization,
|
||||
method: call["method"].as_str().unwrap_or_default().to_owned(),
|
||||
tool: call["params"]["name"].as_str().map(str::to_owned),
|
||||
});
|
||||
let (status, reply) = match drive.status {
|
||||
Some(status) => (status, b"{\"error\":\"refused\"}".to_vec()),
|
||||
None => (200, event(&drive.answer(&call))),
|
||||
};
|
||||
*self.reply.borrow_mut() = Some(reply);
|
||||
Ok(Response {
|
||||
status,
|
||||
headers: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Hands the reply over in pieces smaller than asked for, so a caller
|
||||
/// that stops at the first short piece loses the rest.
|
||||
pub fn pull(&self, limit: u32) -> Result<Vec<u8>, HostError> {
|
||||
let mut reply = self.reply.borrow_mut();
|
||||
let reply = reply.as_mut().ok_or(HostError::InvalidInput)?;
|
||||
let count = reply.len().min(limit as usize).min(4096);
|
||||
Ok(reply.drain(..count).collect())
|
||||
}
|
||||
}
|
||||
|
||||
fn event(message: &Value) -> Vec<u8> {
|
||||
format!("event: message\ndata: {message}\n\n").into_bytes()
|
||||
}
|
||||
|
||||
fn success(structured: Value) -> Value {
|
||||
json!({
|
||||
"result": {
|
||||
"_meta": { "ai.aindrive/a2ui": [] },
|
||||
"content": [{ "type": "text", "text": "ok" }],
|
||||
"structuredContent": structured,
|
||||
},
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
})
|
||||
}
|
||||
|
||||
fn failure(text: &str) -> Value {
|
||||
json!({
|
||||
"result": {
|
||||
"_meta": {},
|
||||
"content": [{ "type": "text", "text": text }],
|
||||
"isError": true,
|
||||
},
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
})
|
||||
}
|
||||
|
||||
impl Drive {
|
||||
fn answer(&mut self, call: &Value) -> Value {
|
||||
if call["method"] == "tools/list" {
|
||||
let tools = self.tools.clone().unwrap_or_else(|| {
|
||||
vec![
|
||||
"list_files",
|
||||
"read_file",
|
||||
"write_file",
|
||||
"delete_path",
|
||||
"stat",
|
||||
]
|
||||
});
|
||||
let tools: Vec<Value> =
|
||||
tools.iter().map(|name| json!({ "name": name })).collect();
|
||||
return json!({ "result": { "tools": tools }, "jsonrpc": "2.0", "id": 1 });
|
||||
}
|
||||
let arguments = &call["params"]["arguments"];
|
||||
let path = arguments["path"].as_str().unwrap_or_default().to_owned();
|
||||
match call["params"]["name"].as_str().unwrap_or_default() {
|
||||
"list_files" => match self.children(&path) {
|
||||
Some(entries) => success(json!({ "entries": entries })),
|
||||
None if self.files.contains_key(&path) => {
|
||||
failure("[internal] ENOTDIR: not a directory, scandir '/Users/me'")
|
||||
}
|
||||
None => failure("[internal] ENOENT: no such file or directory"),
|
||||
},
|
||||
"stat" if path.is_empty() => failure("[invalid_params] path required"),
|
||||
"stat" => match self.entry(&path) {
|
||||
Some(entry) => success(entry),
|
||||
None => failure(&format!("[not_found] no entry at {path}")),
|
||||
},
|
||||
"read_file" => {
|
||||
if let Some(changed) = self.change_on_read.take() {
|
||||
self.files.insert(path.clone(), changed);
|
||||
}
|
||||
match self.files.get(&path) {
|
||||
Some(file) => success(json!({
|
||||
"method": "read",
|
||||
"content": STANDARD.encode(&file.bytes),
|
||||
"encoding": "base64",
|
||||
"truncated": false,
|
||||
})),
|
||||
None if self.is_folder(&path) => failure("[internal] is a directory"),
|
||||
None => failure("[internal] ENOENT: no such file or directory"),
|
||||
}
|
||||
}
|
||||
_ => failure("[internal] unknown tool"),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_folder(&self, path: &str) -> bool {
|
||||
path.is_empty()
|
||||
|| self.folders.iter().any(|folder| folder == path)
|
||||
|| self
|
||||
.files
|
||||
.keys()
|
||||
.any(|file| file.starts_with(&format!("{path}/")))
|
||||
}
|
||||
|
||||
fn entry(&self, path: &str) -> Option<Value> {
|
||||
let name = path.rsplit('/').next().unwrap_or(path);
|
||||
if let Some(file) = self.files.get(path) {
|
||||
let entry = format!(
|
||||
r#"{{"name":{},"path":{},"isDir":false,"size":{},"mtimeMs":{}}}"#,
|
||||
json!(name),
|
||||
json!(path),
|
||||
file.bytes.len(),
|
||||
file.mtime,
|
||||
);
|
||||
return serde_json::from_str(&entry).ok();
|
||||
}
|
||||
self.is_folder(path).then(|| {
|
||||
json!({ "name": name, "path": path, "isDir": true, "size": 96,
|
||||
"mtimeMs": 1_791_726_544_422.142_f64 })
|
||||
})
|
||||
}
|
||||
|
||||
fn children(&self, path: &str) -> Option<Vec<Value>> {
|
||||
if !self.is_folder(path) {
|
||||
return None;
|
||||
}
|
||||
let prefix = if path.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("{path}/")
|
||||
};
|
||||
let mut names: Vec<&str> = self
|
||||
.files
|
||||
.keys()
|
||||
.chain(self.folders.iter())
|
||||
.filter_map(|item| item.strip_prefix(&prefix))
|
||||
.filter_map(|rest| rest.split('/').next())
|
||||
.filter(|name| !name.is_empty())
|
||||
.collect();
|
||||
names.dedup();
|
||||
names.sort_unstable();
|
||||
names.dedup();
|
||||
Some(
|
||||
names
|
||||
.into_iter()
|
||||
.filter_map(|name| self.entry(&format!("{prefix}{name}")))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user