Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 46 additions & 35 deletions crates/goose-acp-macros/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
use proc_macro::TokenStream;
use quote::quote;
use syn::{
parse_macro_input, FnArg, GenericArgument, ImplItem, ItemImpl, Lit, Pat, PathArguments,
ReturnType, Type,
parse_macro_input, FnArg, GenericArgument, ImplItem, ItemImpl, Pat, PathArguments, ReturnType,
Type,
};

/// Marks an impl block as containing `#[custom_method("...")]`-annotated handlers.
/// Marks an impl block as containing `#[custom_method(RequestType)]`-annotated handlers.
///
/// The request type must derive `sacp::JsonRpcRequest` with a `#[request(method = "...")]`
/// attribute — the method name is extracted from that type at compile time, eliminating
/// duplication between the request struct and the handler.
///
/// Generates two methods on the impl:
///
/// 1. `handle_custom_request` — a dispatcher that:
/// - Uses each annotation string as the method name (include `_goose/` for goose-only methods)
/// - Uses `<RequestType as sacp::JsonRpcMessage>::matches_method` to match incoming methods
/// - Parses JSON params into the handler's typed parameter (if any)
/// - Serializes the handler's return value to JSON
///
Expand All @@ -25,11 +29,11 @@ use syn::{
///
/// ```ignore
/// // No params — called for requests with no/empty params
/// #[custom_method("session/list")]
/// async fn on_list_sessions(&self) -> Result<ListSessionsResponse, sacp::Error> { .. }
/// #[custom_method(GetExtensionsRequest)]
/// async fn on_get_extensions(&self) -> Result<GetExtensionsResponse, sacp::Error> { .. }
///
/// // Typed params — JSON params auto-deserialized
/// #[custom_method("session/get")]
/// #[custom_method(GetSessionRequest)]
/// async fn on_get_session(&self, req: GetSessionRequest) -> Result<GetSessionResponse, sacp::Error> { .. }
/// ```
///
Expand All @@ -40,15 +44,15 @@ pub fn custom_methods(_attr: TokenStream, item: TokenStream) -> TokenStream {

let mut routes: Vec<Route> = Vec::new();

// Collect all #[custom_method("...")] annotations and strip them.
// Collect all #[custom_method(RequestType)] annotations and strip them.
for item in &mut impl_block.items {
if let ImplItem::Fn(method) = item {
let mut route_name = None;
let mut request_type = None;
method.attrs.retain(|attr| {
if attr.path().is_ident("custom_method") {
if let Ok(meta_list) = attr.meta.require_list() {
if let Ok(Lit::Str(s)) = meta_list.parse_args::<Lit>() {
route_name = Some(s.value());
if let Ok(ty) = meta_list.parse_args::<Type>() {
request_type = Some(ty);
}
}
false // strip the attribute
Expand All @@ -57,15 +61,15 @@ pub fn custom_methods(_attr: TokenStream, item: TokenStream) -> TokenStream {
}
});

if let Some(name) = route_name {
if let Some(req_type) = request_type {
let fn_ident = method.sig.ident.clone();

let param_type = extract_param_type(&method.sig);
let return_type = extract_return_type(&method.sig);
let ok_type = extract_result_ok_type(&method.sig);

routes.push(Route {
method_name: name,
request_type: req_type,
fn_ident,
param_type,
return_type,
Expand All @@ -75,31 +79,31 @@ pub fn custom_methods(_attr: TokenStream, item: TokenStream) -> TokenStream {
}
}

// Generate the dispatch arms.
// Generate the dispatch arms using matches_method for routing.
let arms: Vec<_> = routes
.iter()
.map(|route| {
let method = &route.method_name;
let req_type = &route.request_type;
let fn_ident = &route.fn_ident;

match &route.param_type {
Some(_) => {
quote! {
#method => {
if <#req_type as sacp::JsonRpcMessage>::matches_method(method) {
let req = serde_json::from_value(params)
.map_err(|e| sacp::Error::invalid_params().data(e.to_string()))?;
let result = self.#fn_ident(req).await?;
serde_json::to_value(&result)
.map_err(|e| sacp::Error::internal_error().data(e.to_string()))
return serde_json::to_value(&result)
.map_err(|e| sacp::Error::internal_error().data(e.to_string()));
}
}
}
None => {
quote! {
#method => {
if <#req_type as sacp::JsonRpcMessage>::matches_method(method) {
let result = self.#fn_ident().await?;
serde_json::to_value(&result)
.map_err(|e| sacp::Error::internal_error().data(e.to_string()))
return serde_json::to_value(&result)
.map_err(|e| sacp::Error::internal_error().data(e.to_string()));
}
}
}
Expand All @@ -111,7 +115,7 @@ pub fn custom_methods(_attr: TokenStream, item: TokenStream) -> TokenStream {
let schema_entries: Vec<_> = routes
.iter()
.map(|route| {
let method = &route.method_name;
let req_type = &route.request_type;

let params_expr = if let Some(pt) = &route.param_type {
if is_json_value(pt) {
Expand All @@ -120,7 +124,12 @@ pub fn custom_methods(_attr: TokenStream, item: TokenStream) -> TokenStream {
quote! { Some(generator.subschema_for::<#pt>()) }
}
} else {
quote! { None }
// Even with no handler param, generate schema from the request type
if is_json_value(req_type) {
quote! { None }
} else {
quote! { Some(generator.subschema_for::<#req_type>()) }
}
};

let response_expr = if let Some(ok_ty) = &route.ok_type {
Expand All @@ -141,7 +150,8 @@ pub fn custom_methods(_attr: TokenStream, item: TokenStream) -> TokenStream {
quote! { Some(#name.to_string()) }
}
} else {
quote! { None }
let name = type_name(req_type);
quote! { Some(#name.to_string()) }
};

let response_name_expr = if let Some(ok_ty) = &route.ok_type {
Expand All @@ -156,12 +166,15 @@ pub fn custom_methods(_attr: TokenStream, item: TokenStream) -> TokenStream {
};

quote! {
crate::custom_requests::CustomMethodSchema {
method: #method.to_string(),
params_schema: #params_expr,
params_type_name: #params_name_expr,
response_schema: #response_expr,
response_type_name: #response_name_expr,
{
let dummy = <#req_type as Default>::default();
crate::custom_requests::CustomMethodSchema {
method: sacp::JsonRpcMessage::method(&dummy).to_string(),
params_schema: #params_expr,
params_type_name: #params_name_expr,
response_schema: #response_expr,
response_type_name: #response_name_expr,
}
}
}
})
Expand All @@ -174,10 +187,8 @@ pub fn custom_methods(_attr: TokenStream, item: TokenStream) -> TokenStream {
method: &str,
params: serde_json::Value,
) -> Result<serde_json::Value, sacp::Error> {
match method {
#(#arms)*
_ => Err(sacp::Error::method_not_found()),
}
#(#arms)*
Err(sacp::Error::method_not_found())
}
};

Expand All @@ -202,7 +213,7 @@ pub fn custom_methods(_attr: TokenStream, item: TokenStream) -> TokenStream {
}

struct Route {
method_name: String,
request_type: Type,
fn_ident: syn::Ident,
param_type: Option<Type>,
#[allow(dead_code)]
Expand Down
2 changes: 1 addition & 1 deletion crates/goose-acp/acp-meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
},
{
"method": "_goose/config/extensions",
"requestType": null,
"requestType": "GetExtensionsRequest",
"responseType": "GetExtensionsResponse"
}
]
Expand Down
43 changes: 28 additions & 15 deletions crates/goose-acp/acp-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@
"type": "string"
},
"config": {
"description": "Extension configuration (see ExtensionConfig variants: Stdio, StreamableHttp, Builtin, Platform)."
"description": "Extension configuration (see ExtensionConfig variants: Stdio, StreamableHttp, Builtin, Platform).",
"default": null
}
},
"required": [
"sessionId",
"config"
"sessionId"
],
"description": "Add an extension to an active session.",
"x-side": "agent",
Expand Down Expand Up @@ -69,6 +69,7 @@
"required": [
"tools"
],
"description": "Tools response.",
"x-side": "agent",
"x-method": "_goose/tools"
},
Expand Down Expand Up @@ -98,12 +99,11 @@
"type": "object",
"properties": {
"result": {
"description": "The resource result from the extension (MCP ReadResourceResult)."
"description": "The resource result from the extension (MCP ReadResourceResult).",
"default": null
}
},
"required": [
"result"
],
"description": "Resource read response.",
"x-side": "agent",
"x-method": "_goose/resource/read"
},
Expand Down Expand Up @@ -147,12 +147,10 @@
"type": "object",
"properties": {
"session": {
"description": "The session object with id, name, working_dir, timestamps, tokens, etc."
"description": "The session object with id, name, working_dir, timestamps, tokens, etc.",
"default": null
}
},
"required": [
"session"
],
"description": "Get a session response.",
"x-side": "agent",
"x-method": "session/get"
Expand Down Expand Up @@ -195,6 +193,7 @@
"required": [
"data"
],
"description": "Export session response.",
"x-side": "agent",
"x-method": "_goose/session/export"
},
Expand All @@ -216,15 +215,20 @@
"type": "object",
"properties": {
"session": {
"description": "The imported session object."
"description": "The imported session object.",
"default": null
}
},
"required": [
"session"
],
"description": "Import session response.",
"x-side": "agent",
"x-method": "_goose/session/import"
},
"GetExtensionsRequest": {
"type": "object",
"description": "List configured extensions and any warnings.",
"x-side": "agent",
"x-method": "_goose/config/extensions"
},
"GetExtensionsResponse": {
"type": "object",
"properties": {
Expand Down Expand Up @@ -340,6 +344,15 @@
],
"description": "Params for _goose/session/import",
"title": "ImportSessionRequest"
},
{
"allOf": [
{
"$ref": "#/$defs/GetExtensionsRequest"
}
],
"description": "Params for _goose/config/extensions",
"title": "GetExtensionsRequest"
}
]
},
Expand Down
Loading
Loading