Skip to content

Commit 2d1940a

Browse files
committed
updating to main
2 parents a5a321b + 0ed4fed commit 2d1940a

119 files changed

Lines changed: 15545 additions & 1241 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Cargo.lock

Lines changed: 230 additions & 29 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ uninlined_format_args = "allow"
1515
string_slice = "warn"
1616

1717
[workspace.dependencies]
18-
rmcp = { version = "0.15.0", features = ["schemars", "auth"] }
18+
rmcp = { version = "0.16", features = ["schemars", "auth"] }
1919
anyhow = "1.0"
2020
async-stream = "0.3"
2121
async-trait = "0.1"
@@ -38,7 +38,7 @@ lru = "0.16"
3838
once_cell = "1.20"
3939
rand = "0.8"
4040
regex = "1.12"
41-
reqwest = { version = "0.12.28", default-features = false, features = ["multipart"] }
41+
reqwest = { version = "0.13", default-features = false, features = ["multipart", "form"] }
4242
schemars = { default-features = false, version = "1.0" }
4343
serde = { version = "1.0", features = ["derive"] }
4444
serde_json = "1.0"

README.md

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,12 @@
55
_a local, extensible, open source AI agent that automates engineering tasks_
66

77
<p align="center">
8-
<a href="https://opensource.org/licenses/Apache-2.0">
9-
<img src="https://img.shields.io/badge/License-Apache_2.0-blue.svg">
10-
</a>
11-
<a href="https://discord.gg/goose-oss">
12-
<img src="https://img.shields.io/discord/1287729918100246654?logo=discord&logoColor=white&label=Join+Us&color=blueviolet" alt="Discord">
13-
</a>
14-
<a href="https://github.com/block/goose/actions/workflows/ci.yml">
15-
<img src="https://img.shields.io/github/actions/workflow/status/block/goose/ci.yml?branch=main" alt="CI">
16-
</a>
8+
<a href="https://opensource.org/licenses/Apache-2.0"
9+
><img src="https://img.shields.io/badge/License-Apache_2.0-blue.svg"></a>
10+
<a href="https://discord.gg/goose-oss"
11+
><img src="https://img.shields.io/discord/1287729918100246654?logo=discord&logoColor=white&label=Join+Us&color=blueviolet" alt="Discord"></a>
12+
<a href="https://github.com/block/goose/actions/workflows/ci.yml"
13+
><img src="https://img.shields.io/github/actions/workflow/status/block/goose/ci.yml?branch=main" alt="CI"></a>
1714
</p>
1815
</div>
1916

crates/goose-acp-macros/Cargo.toml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
[package]
2+
name = "goose-acp-macros"
3+
edition.workspace = true
4+
version.workspace = true
5+
authors.workspace = true
6+
license.workspace = true
7+
repository.workspace = true
8+
description.workspace = true
9+
10+
[lib]
11+
proc-macro = true
12+
13+
[dependencies]
14+
proc-macro2 = "1"
15+
quote = "1"
16+
syn = { version = "2", features = ["full", "extra-traits"] }
17+
18+
[lints]
19+
workspace = true

crates/goose-acp-macros/src/lib.rs

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
use proc_macro::TokenStream;
2+
use quote::quote;
3+
use syn::{
4+
parse_macro_input, FnArg, GenericArgument, ImplItem, ItemImpl, Lit, Pat, PathArguments,
5+
ReturnType, Type,
6+
};
7+
8+
/// Marks an impl block as containing `#[custom_method("...")]`-annotated handlers.
9+
///
10+
/// Generates two methods on the impl:
11+
///
12+
/// 1. `handle_custom_request` — a dispatcher that:
13+
/// - Prefixes each method name with `_goose/`
14+
/// - Parses JSON params into the handler's typed parameter (if any)
15+
/// - Serializes the handler's return value to JSON
16+
///
17+
/// 2. `custom_method_schemas` — returns a `Vec<CustomMethodSchema>` with
18+
/// JSON Schema for each method's params and response types. Types that
19+
/// implement `schemars::JsonSchema` get a full schema; `serde_json::Value`
20+
/// params/responses produce `None`.
21+
///
22+
/// # Handler signatures
23+
///
24+
/// Handlers may take zero or one parameter (beyond `&self`):
25+
///
26+
/// ```ignore
27+
/// // No params — called for requests with no/empty params
28+
/// #[custom_method("session/list")]
29+
/// async fn on_list_sessions(&self) -> Result<ListSessionsResponse, sacp::Error> { .. }
30+
///
31+
/// // Typed params — JSON params auto-deserialized
32+
/// #[custom_method("session/get")]
33+
/// async fn on_get_session(&self, req: GetSessionRequest) -> Result<GetSessionResponse, sacp::Error> { .. }
34+
/// ```
35+
///
36+
/// The return type must be `Result<T, sacp::Error>` where `T: Serialize`.
37+
#[proc_macro_attribute]
38+
pub fn custom_methods(_attr: TokenStream, item: TokenStream) -> TokenStream {
39+
let mut impl_block = parse_macro_input!(item as ItemImpl);
40+
41+
let mut routes: Vec<Route> = Vec::new();
42+
43+
// Collect all #[custom_method("...")] annotations and strip them.
44+
for item in &mut impl_block.items {
45+
if let ImplItem::Fn(method) = item {
46+
let mut route_name = None;
47+
method.attrs.retain(|attr| {
48+
if attr.path().is_ident("custom_method") {
49+
if let Ok(meta_list) = attr.meta.require_list() {
50+
if let Ok(Lit::Str(s)) = meta_list.parse_args::<Lit>() {
51+
route_name = Some(s.value());
52+
}
53+
}
54+
false // strip the attribute
55+
} else {
56+
true // keep other attributes
57+
}
58+
});
59+
60+
if let Some(name) = route_name {
61+
let fn_ident = method.sig.ident.clone();
62+
63+
let param_type = extract_param_type(&method.sig);
64+
let return_type = extract_return_type(&method.sig);
65+
let ok_type = extract_result_ok_type(&method.sig);
66+
67+
routes.push(Route {
68+
method_name: name,
69+
fn_ident,
70+
param_type,
71+
return_type,
72+
ok_type,
73+
});
74+
}
75+
}
76+
}
77+
78+
// Generate the dispatch arms.
79+
let arms: Vec<_> = routes
80+
.iter()
81+
.map(|route| {
82+
let full_method = format!("_goose/{}", route.method_name);
83+
let fn_ident = &route.fn_ident;
84+
85+
match &route.param_type {
86+
Some(_) => {
87+
quote! {
88+
#full_method => {
89+
let req = serde_json::from_value(params)
90+
.map_err(|e| sacp::Error::invalid_params().data(e.to_string()))?;
91+
let result = self.#fn_ident(req).await?;
92+
serde_json::to_value(&result)
93+
.map_err(|e| sacp::Error::internal_error().data(e.to_string()))
94+
}
95+
}
96+
}
97+
None => {
98+
quote! {
99+
#full_method => {
100+
let result = self.#fn_ident().await?;
101+
serde_json::to_value(&result)
102+
.map_err(|e| sacp::Error::internal_error().data(e.to_string()))
103+
}
104+
}
105+
}
106+
}
107+
})
108+
.collect();
109+
110+
// Generate schema entries for each route using SchemaGenerator for $ref dedup.
111+
let schema_entries: Vec<_> = routes
112+
.iter()
113+
.map(|route| {
114+
let full_method = format!("_goose/{}", route.method_name);
115+
116+
let params_expr = if let Some(pt) = &route.param_type {
117+
if is_json_value(pt) {
118+
quote! { None }
119+
} else {
120+
quote! { Some(generator.subschema_for::<#pt>()) }
121+
}
122+
} else {
123+
quote! { None }
124+
};
125+
126+
let response_expr = if let Some(ok_ty) = &route.ok_type {
127+
if is_json_value(ok_ty) {
128+
quote! { None }
129+
} else {
130+
quote! { Some(generator.subschema_for::<#ok_ty>()) }
131+
}
132+
} else {
133+
quote! { None }
134+
};
135+
136+
let params_name_expr = if let Some(pt) = &route.param_type {
137+
if is_json_value(pt) {
138+
quote! { None }
139+
} else {
140+
let name = type_name(pt);
141+
quote! { Some(#name.to_string()) }
142+
}
143+
} else {
144+
quote! { None }
145+
};
146+
147+
let response_name_expr = if let Some(ok_ty) = &route.ok_type {
148+
if is_json_value(ok_ty) {
149+
quote! { None }
150+
} else {
151+
let name = type_name(ok_ty);
152+
quote! { Some(#name.to_string()) }
153+
}
154+
} else {
155+
quote! { None }
156+
};
157+
158+
quote! {
159+
crate::custom_requests::CustomMethodSchema {
160+
method: #full_method.to_string(),
161+
params_schema: #params_expr,
162+
params_type_name: #params_name_expr,
163+
response_schema: #response_expr,
164+
response_type_name: #response_name_expr,
165+
}
166+
}
167+
})
168+
.collect();
169+
170+
// Generate the handle_custom_request method.
171+
let dispatcher = quote! {
172+
async fn handle_custom_request(
173+
&self,
174+
method: &str,
175+
params: serde_json::Value,
176+
) -> Result<serde_json::Value, sacp::Error> {
177+
match method {
178+
#(#arms)*
179+
_ => Err(sacp::Error::method_not_found()),
180+
}
181+
}
182+
};
183+
184+
// Generate the custom_method_schemas method.
185+
let schemas_fn = quote! {
186+
pub fn custom_method_schemas(generator: &mut schemars::SchemaGenerator) -> Vec<crate::custom_requests::CustomMethodSchema> {
187+
vec![
188+
#(#schema_entries),*
189+
]
190+
}
191+
};
192+
193+
// Append the generated methods to the impl block.
194+
let dispatcher_item: ImplItem =
195+
syn::parse2(dispatcher).expect("generated dispatcher must parse");
196+
impl_block.items.push(dispatcher_item);
197+
198+
let schemas_item: ImplItem = syn::parse2(schemas_fn).expect("generated schemas fn must parse");
199+
impl_block.items.push(schemas_item);
200+
201+
TokenStream::from(quote! { #impl_block })
202+
}
203+
204+
struct Route {
205+
method_name: String,
206+
fn_ident: syn::Ident,
207+
param_type: Option<Type>,
208+
#[allow(dead_code)]
209+
return_type: Option<Type>,
210+
ok_type: Option<Type>,
211+
}
212+
213+
/// Extract the type of the first non-self parameter, if any.
214+
fn extract_param_type(sig: &syn::Signature) -> Option<Type> {
215+
for input in &sig.inputs {
216+
if let FnArg::Typed(pat_type) = input {
217+
if let Pat::Ident(pat_ident) = &*pat_type.pat {
218+
if pat_ident.ident == "self" {
219+
continue;
220+
}
221+
}
222+
return Some((*pat_type.ty).clone());
223+
}
224+
}
225+
None
226+
}
227+
228+
/// Extract the full return type (e.g. `Result<T, E>`).
229+
fn extract_return_type(sig: &syn::Signature) -> Option<Type> {
230+
if let ReturnType::Type(_, ty) = &sig.output {
231+
Some((**ty).clone())
232+
} else {
233+
None
234+
}
235+
}
236+
237+
/// Extract `T` from `Result<T, E>` in the return type.
238+
fn extract_result_ok_type(sig: &syn::Signature) -> Option<Type> {
239+
let ty = match &sig.output {
240+
ReturnType::Type(_, ty) => ty,
241+
_ => return None,
242+
};
243+
244+
// Peel through the type to find a path ending in `Result`.
245+
if let Type::Path(type_path) = ty.as_ref() {
246+
let last_seg = type_path.path.segments.last()?;
247+
if last_seg.ident == "Result" {
248+
if let PathArguments::AngleBracketed(args) = &last_seg.arguments {
249+
// First generic argument is the Ok type.
250+
if let Some(GenericArgument::Type(ok_ty)) = args.args.first() {
251+
return Some(ok_ty.clone());
252+
}
253+
}
254+
}
255+
}
256+
None
257+
}
258+
259+
/// Extract the last segment name from a type path (e.g. `GetSessionRequest` from
260+
/// `crate::custom_requests::GetSessionRequest` or just `GetSessionRequest`).
261+
fn type_name(ty: &Type) -> String {
262+
if let Type::Path(type_path) = ty {
263+
if let Some(seg) = type_path.path.segments.last() {
264+
return seg.ident.to_string();
265+
}
266+
}
267+
quote::quote!(#ty).to_string()
268+
}
269+
270+
/// Check if a type is `serde_json::Value` (matches `Value` or `serde_json::Value`).
271+
fn is_json_value(ty: &Type) -> bool {
272+
if let Type::Path(type_path) = ty {
273+
let segments: Vec<_> = type_path
274+
.path
275+
.segments
276+
.iter()
277+
.map(|s| s.ident.to_string())
278+
.collect();
279+
let strs: Vec<&str> = segments.iter().map(|s| s.as_str()).collect();
280+
matches!(strs.as_slice(), ["serde_json", "Value"] | ["Value"])
281+
} else {
282+
false
283+
}
284+
}

crates/goose-acp/Cargo.toml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ description.workspace = true
1111
name = "goose-acp-server"
1212
path = "src/bin/server.rs"
1313

14+
[[bin]]
15+
name = "generate-acp-schema"
16+
path = "src/bin/generate_acp_schema.rs"
17+
1418
[lints]
1519
workspace = true
1620

@@ -33,13 +37,15 @@ url = { workspace = true }
3337
# HTTP server dependencies
3438
axum = { workspace = true, features = ["ws"] }
3539
clap = { workspace = true }
36-
serde = { workspace = true }
40+
serde = { workspace = true, features = ["derive"] }
3741
tower-http = { workspace = true, features = ["cors"] }
3842
tracing-subscriber = { workspace = true, features = ["env-filter", "json"] }
3943
async-stream = { workspace = true }
4044
bytes = { workspace = true }
4145
http-body-util = "0.1.3"
4246
uuid = { workspace = true, features = ["v7"] }
47+
schemars = { workspace = true, features = ["derive"] }
48+
goose-acp-macros = { version = "1.24.0", path = "../goose-acp-macros" }
4349

4450
[dev-dependencies]
4551
assert-json-diff = "2.0.2"

0 commit comments

Comments
 (0)