Typescript SDK for ACP extension methods (#7319)

This commit is contained in:
Jack Amadeo
2026-02-18 21:08:26 -05:00
committed by GitHub
parent 4b3eef0c9a
commit f425ea7474
23 changed files with 3740 additions and 34 deletions
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "goose-acp-macros"
edition.workspace = true
version.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
description.workspace = true
[lib]
proc-macro = true
[dependencies]
proc-macro2 = "1"
quote = "1"
syn = { version = "2", features = ["full", "extra-traits"] }
[lints]
workspace = true
+284
View File
@@ -0,0 +1,284 @@
use proc_macro::TokenStream;
use quote::quote;
use syn::{
parse_macro_input, FnArg, GenericArgument, ImplItem, ItemImpl, Lit, Pat, PathArguments,
ReturnType, Type,
};
/// Marks an impl block as containing `#[custom_method("...")]`-annotated handlers.
///
/// Generates two methods on the impl:
///
/// 1. `handle_custom_request` — a dispatcher that:
/// - Prefixes each method name with `_goose/`
/// - Parses JSON params into the handler's typed parameter (if any)
/// - Serializes the handler's return value to JSON
///
/// 2. `custom_method_schemas` — returns a `Vec<CustomMethodSchema>` with
/// JSON Schema for each method's params and response types. Types that
/// implement `schemars::JsonSchema` get a full schema; `serde_json::Value`
/// params/responses produce `None`.
///
/// # Handler signatures
///
/// Handlers may take zero or one parameter (beyond `&self`):
///
/// ```ignore
/// // No params — called for requests with no/empty params
/// #[custom_method("session/list")]
/// async fn on_list_sessions(&self) -> Result<ListSessionsResponse, sacp::Error> { .. }
///
/// // Typed params — JSON params auto-deserialized
/// #[custom_method("session/get")]
/// async fn on_get_session(&self, req: GetSessionRequest) -> Result<GetSessionResponse, sacp::Error> { .. }
/// ```
///
/// The return type must be `Result<T, sacp::Error>` where `T: Serialize`.
#[proc_macro_attribute]
pub fn custom_methods(_attr: TokenStream, item: TokenStream) -> TokenStream {
let mut impl_block = parse_macro_input!(item as ItemImpl);
let mut routes: Vec<Route> = Vec::new();
// Collect all #[custom_method("...")] annotations and strip them.
for item in &mut impl_block.items {
if let ImplItem::Fn(method) = item {
let mut route_name = 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());
}
}
false // strip the attribute
} else {
true // keep other attributes
}
});
if let Some(name) = route_name {
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,
fn_ident,
param_type,
return_type,
ok_type,
});
}
}
}
// Generate the dispatch arms.
let arms: Vec<_> = routes
.iter()
.map(|route| {
let full_method = format!("_goose/{}", route.method_name);
let fn_ident = &route.fn_ident;
match &route.param_type {
Some(_) => {
quote! {
#full_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()))
}
}
}
None => {
quote! {
#full_method => {
let result = self.#fn_ident().await?;
serde_json::to_value(&result)
.map_err(|e| sacp::Error::internal_error().data(e.to_string()))
}
}
}
}
})
.collect();
// Generate schema entries for each route using SchemaGenerator for $ref dedup.
let schema_entries: Vec<_> = routes
.iter()
.map(|route| {
let full_method = format!("_goose/{}", route.method_name);
let params_expr = if let Some(pt) = &route.param_type {
if is_json_value(pt) {
quote! { None }
} else {
quote! { Some(generator.subschema_for::<#pt>()) }
}
} else {
quote! { None }
};
let response_expr = if let Some(ok_ty) = &route.ok_type {
if is_json_value(ok_ty) {
quote! { None }
} else {
quote! { Some(generator.subschema_for::<#ok_ty>()) }
}
} else {
quote! { None }
};
let params_name_expr = if let Some(pt) = &route.param_type {
if is_json_value(pt) {
quote! { None }
} else {
let name = type_name(pt);
quote! { Some(#name.to_string()) }
}
} else {
quote! { None }
};
let response_name_expr = if let Some(ok_ty) = &route.ok_type {
if is_json_value(ok_ty) {
quote! { None }
} else {
let name = type_name(ok_ty);
quote! { Some(#name.to_string()) }
}
} else {
quote! { None }
};
quote! {
crate::custom_requests::CustomMethodSchema {
method: #full_method.to_string(),
params_schema: #params_expr,
params_type_name: #params_name_expr,
response_schema: #response_expr,
response_type_name: #response_name_expr,
}
}
})
.collect();
// Generate the handle_custom_request method.
let dispatcher = quote! {
async fn handle_custom_request(
&self,
method: &str,
params: serde_json::Value,
) -> Result<serde_json::Value, sacp::Error> {
match method {
#(#arms)*
_ => Err(sacp::Error::method_not_found()),
}
}
};
// Generate the custom_method_schemas method.
let schemas_fn = quote! {
pub fn custom_method_schemas(generator: &mut schemars::SchemaGenerator) -> Vec<crate::custom_requests::CustomMethodSchema> {
vec![
#(#schema_entries),*
]
}
};
// Append the generated methods to the impl block.
let dispatcher_item: ImplItem =
syn::parse2(dispatcher).expect("generated dispatcher must parse");
impl_block.items.push(dispatcher_item);
let schemas_item: ImplItem = syn::parse2(schemas_fn).expect("generated schemas fn must parse");
impl_block.items.push(schemas_item);
TokenStream::from(quote! { #impl_block })
}
struct Route {
method_name: String,
fn_ident: syn::Ident,
param_type: Option<Type>,
#[allow(dead_code)]
return_type: Option<Type>,
ok_type: Option<Type>,
}
/// Extract the type of the first non-self parameter, if any.
fn extract_param_type(sig: &syn::Signature) -> Option<Type> {
for input in &sig.inputs {
if let FnArg::Typed(pat_type) = input {
if let Pat::Ident(pat_ident) = &*pat_type.pat {
if pat_ident.ident == "self" {
continue;
}
}
return Some((*pat_type.ty).clone());
}
}
None
}
/// Extract the full return type (e.g. `Result<T, E>`).
fn extract_return_type(sig: &syn::Signature) -> Option<Type> {
if let ReturnType::Type(_, ty) = &sig.output {
Some((**ty).clone())
} else {
None
}
}
/// Extract `T` from `Result<T, E>` in the return type.
fn extract_result_ok_type(sig: &syn::Signature) -> Option<Type> {
let ty = match &sig.output {
ReturnType::Type(_, ty) => ty,
_ => return None,
};
// Peel through the type to find a path ending in `Result`.
if let Type::Path(type_path) = ty.as_ref() {
let last_seg = type_path.path.segments.last()?;
if last_seg.ident == "Result" {
if let PathArguments::AngleBracketed(args) = &last_seg.arguments {
// First generic argument is the Ok type.
if let Some(GenericArgument::Type(ok_ty)) = args.args.first() {
return Some(ok_ty.clone());
}
}
}
}
None
}
/// Extract the last segment name from a type path (e.g. `GetSessionRequest` from
/// `crate::custom_requests::GetSessionRequest` or just `GetSessionRequest`).
fn type_name(ty: &Type) -> String {
if let Type::Path(type_path) = ty {
if let Some(seg) = type_path.path.segments.last() {
return seg.ident.to_string();
}
}
quote::quote!(#ty).to_string()
}
/// Check if a type is `serde_json::Value` (matches `Value` or `serde_json::Value`).
fn is_json_value(ty: &Type) -> bool {
if let Type::Path(type_path) = ty {
let segments: Vec<_> = type_path
.path
.segments
.iter()
.map(|s| s.ident.to_string())
.collect();
let strs: Vec<&str> = segments.iter().map(|s| s.as_str()).collect();
matches!(strs.as_slice(), ["serde_json", "Value"] | ["Value"])
} else {
false
}
}
+7 -1
View File
@@ -11,6 +11,10 @@ description.workspace = true
name = "goose-acp-server"
path = "src/bin/server.rs"
[[bin]]
name = "generate-acp-schema"
path = "src/bin/generate_acp_schema.rs"
[lints]
workspace = true
@@ -33,13 +37,15 @@ url = { workspace = true }
# HTTP server dependencies
axum = { workspace = true, features = ["ws"] }
clap = { workspace = true }
serde = { workspace = true }
serde = { workspace = true, features = ["derive"] }
tower-http = { workspace = true, features = ["cors"] }
tracing-subscriber = { workspace = true, features = ["env-filter", "json"] }
async-stream = { workspace = true }
bytes = { workspace = true }
http-body-util = "0.1.3"
uuid = { workspace = true, features = ["v7"] }
schemars = { workspace = true, features = ["derive"] }
goose-acp-macros = { version = "1.24.0", path = "../goose-acp-macros" }
[dev-dependencies]
assert-json-diff = "2.0.2"
+59
View File
@@ -0,0 +1,59 @@
{
"methods": [
{
"method": "extensions/add",
"requestType": "AddExtensionRequest",
"responseType": "EmptyResponse"
},
{
"method": "extensions/remove",
"requestType": "RemoveExtensionRequest",
"responseType": "EmptyResponse"
},
{
"method": "tools",
"requestType": "GetToolsRequest",
"responseType": "GetToolsResponse"
},
{
"method": "resource/read",
"requestType": "ReadResourceRequest",
"responseType": "ReadResourceResponse"
},
{
"method": "working_dir/update",
"requestType": "UpdateWorkingDirRequest",
"responseType": "EmptyResponse"
},
{
"method": "session/list",
"requestType": null,
"responseType": "ListSessionsResponse"
},
{
"method": "session/get",
"requestType": "GetSessionRequest",
"responseType": "GetSessionResponse"
},
{
"method": "session/delete",
"requestType": "DeleteSessionRequest",
"responseType": "EmptyResponse"
},
{
"method": "session/export",
"requestType": "ExportSessionRequest",
"responseType": "ExportSessionResponse"
},
{
"method": "session/import",
"requestType": "ImportSessionRequest",
"responseType": "ImportSessionResponse"
},
{
"method": "config/extensions",
"requestType": null,
"responseType": "GetExtensionsResponse"
}
]
}
+520
View File
@@ -0,0 +1,520 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "GooseExtensions",
"$defs": {
"AddExtensionRequest": {
"type": "object",
"properties": {
"session_id": {
"type": "string"
},
"config": {
"description": "Extension configuration (see ExtensionConfig variants: Stdio, StreamableHttp, Builtin, Platform)."
}
},
"required": [
"session_id",
"config"
],
"description": "Add an extension to an active session.\nMethod: `_agent/extensions/add`",
"x-side": "agent",
"x-method": "extensions/add"
},
"EmptyResponse": {
"type": "object",
"description": "Empty success response for operations that return no data.",
"x-side": "agent"
},
"RemoveExtensionRequest": {
"type": "object",
"properties": {
"session_id": {
"type": "string"
},
"name": {
"type": "string"
}
},
"required": [
"session_id",
"name"
],
"description": "Remove an extension from an active session.\nMethod: `_agent/extensions/remove`",
"x-side": "agent",
"x-method": "extensions/remove"
},
"GetToolsRequest": {
"type": "object",
"properties": {
"session_id": {
"type": "string"
}
},
"required": [
"session_id"
],
"description": "List all tools available in a session.\nMethod: `_agent/tools`",
"x-side": "agent",
"x-method": "tools"
},
"GetToolsResponse": {
"type": "object",
"properties": {
"tools": {
"type": "array",
"items": true,
"description": "Array of tool info objects with `name`, `description`, `parameters`, and optional `permission`."
}
},
"required": [
"tools"
],
"x-side": "agent",
"x-method": "tools"
},
"ReadResourceRequest": {
"type": "object",
"properties": {
"session_id": {
"type": "string"
},
"uri": {
"type": "string"
},
"extension_name": {
"type": "string"
}
},
"required": [
"session_id",
"uri",
"extension_name"
],
"description": "Read a resource from an extension.\nMethod: `_agent/resource/read`",
"x-side": "agent",
"x-method": "resource/read"
},
"ReadResourceResponse": {
"type": "object",
"properties": {
"result": {
"description": "The resource result from the extension (MCP ReadResourceResult)."
}
},
"required": [
"result"
],
"x-side": "agent",
"x-method": "resource/read"
},
"UpdateWorkingDirRequest": {
"type": "object",
"properties": {
"session_id": {
"type": "string"
},
"working_dir": {
"type": "string"
}
},
"required": [
"session_id",
"working_dir"
],
"description": "Update the working directory for a session.\nMethod: `_agent/working_dir/update`",
"x-side": "agent",
"x-method": "working_dir/update"
},
"ListSessionsResponse": {
"type": "object",
"properties": {
"sessions": {
"type": "array",
"items": true
}
},
"required": [
"sessions"
],
"description": "List all sessions.\nMethod: `_session/list`",
"x-side": "agent",
"x-method": "session/list"
},
"GetSessionRequest": {
"type": "object",
"properties": {
"session_id": {
"type": "string"
},
"include_messages": {
"type": "boolean",
"default": false
}
},
"required": [
"session_id"
],
"description": "Get a session by ID.\nMethod: `_session/get`",
"x-side": "agent",
"x-method": "session/get"
},
"GetSessionResponse": {
"type": "object",
"properties": {
"session": {
"description": "The session object with id, name, working_dir, timestamps, tokens, etc."
}
},
"required": [
"session"
],
"description": "Get a session response.",
"x-side": "agent",
"x-method": "session/get"
},
"DeleteSessionRequest": {
"type": "object",
"properties": {
"session_id": {
"type": "string"
}
},
"required": [
"session_id"
],
"description": "Delete a session.\nMethod: `_session/delete`",
"x-side": "agent",
"x-method": "session/delete"
},
"ExportSessionRequest": {
"type": "object",
"properties": {
"session_id": {
"type": "string"
}
},
"required": [
"session_id"
],
"description": "Export a session as a JSON string.\nMethod: `_session/export`",
"x-side": "agent",
"x-method": "session/export"
},
"ExportSessionResponse": {
"type": "object",
"properties": {
"data": {
"type": "string"
}
},
"required": [
"data"
],
"x-side": "agent",
"x-method": "session/export"
},
"ImportSessionRequest": {
"type": "object",
"properties": {
"data": {
"type": "string"
}
},
"required": [
"data"
],
"description": "Import a session from a JSON string.\nMethod: `_session/import`",
"x-side": "agent",
"x-method": "session/import"
},
"ImportSessionResponse": {
"type": "object",
"properties": {
"session": {
"description": "The imported session object."
}
},
"required": [
"session"
],
"x-side": "agent",
"x-method": "session/import"
},
"GetExtensionsResponse": {
"type": "object",
"properties": {
"extensions": {
"type": "array",
"items": true,
"description": "Array of ExtensionEntry objects with `enabled` flag and config details."
},
"warnings": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"extensions",
"warnings"
],
"description": "List configured extensions and any warnings.\nMethod: `_config/extensions`",
"x-side": "agent",
"x-method": "config/extensions"
},
"ExtRequest": {
"properties": {
"id": {
"type": "string"
},
"method": {
"type": "string"
},
"params": {
"anyOf": [
{
"anyOf": [
{
"allOf": [
{
"$ref": "#/$defs/AddExtensionRequest"
}
],
"description": "Params for _goose/extensions/add",
"title": "AddExtensionRequest"
},
{
"allOf": [
{
"$ref": "#/$defs/RemoveExtensionRequest"
}
],
"description": "Params for _goose/extensions/remove",
"title": "RemoveExtensionRequest"
},
{
"allOf": [
{
"$ref": "#/$defs/GetToolsRequest"
}
],
"description": "Params for _goose/tools",
"title": "GetToolsRequest"
},
{
"allOf": [
{
"$ref": "#/$defs/ReadResourceRequest"
}
],
"description": "Params for _goose/resource/read",
"title": "ReadResourceRequest"
},
{
"allOf": [
{
"$ref": "#/$defs/UpdateWorkingDirRequest"
}
],
"description": "Params for _goose/working_dir/update",
"title": "UpdateWorkingDirRequest"
},
{
"allOf": [
{
"$ref": "#/$defs/GetSessionRequest"
}
],
"description": "Params for _goose/session/get",
"title": "GetSessionRequest"
},
{
"allOf": [
{
"$ref": "#/$defs/DeleteSessionRequest"
}
],
"description": "Params for _goose/session/delete",
"title": "DeleteSessionRequest"
},
{
"allOf": [
{
"$ref": "#/$defs/ExportSessionRequest"
}
],
"description": "Params for _goose/session/export",
"title": "ExportSessionRequest"
},
{
"allOf": [
{
"$ref": "#/$defs/ImportSessionRequest"
}
],
"description": "Params for _goose/session/import",
"title": "ImportSessionRequest"
}
]
},
{
"description": "Untyped params",
"type": [
"object",
"null"
]
}
]
}
},
"required": [
"id",
"method"
],
"type": "object",
"x-docs-ignore": true
},
"ExtResponse": {
"anyOf": [
{
"properties": {
"id": {
"type": "string"
},
"result": {
"anyOf": [
{
"anyOf": [
{
"allOf": [
{
"$ref": "#/$defs/EmptyResponse"
}
],
"title": "EmptyResponse"
},
{
"allOf": [
{
"$ref": "#/$defs/GetToolsResponse"
}
],
"title": "GetToolsResponse"
},
{
"allOf": [
{
"$ref": "#/$defs/ReadResourceResponse"
}
],
"title": "ReadResourceResponse"
},
{
"allOf": [
{
"$ref": "#/$defs/ListSessionsResponse"
}
],
"title": "ListSessionsResponse"
},
{
"allOf": [
{
"$ref": "#/$defs/GetSessionResponse"
}
],
"title": "GetSessionResponse"
},
{
"allOf": [
{
"$ref": "#/$defs/ExportSessionResponse"
}
],
"title": "ExportSessionResponse"
},
{
"allOf": [
{
"$ref": "#/$defs/ImportSessionResponse"
}
],
"title": "ImportSessionResponse"
},
{
"allOf": [
{
"$ref": "#/$defs/GetExtensionsResponse"
}
],
"title": "GetExtensionsResponse"
}
]
},
{
"description": "Untyped result"
}
]
}
},
"required": [
"id"
],
"title": "Success",
"type": "object"
},
{
"properties": {
"error": {
"type": "object",
"properties": {
"code": {
"type": "integer"
},
"message": {
"type": "string"
},
"data": {}
},
"required": [
"code",
"message"
]
},
"id": {
"type": "string"
}
},
"required": [
"id",
"error"
],
"title": "Error",
"type": "object"
}
],
"x-docs-ignore": true
}
},
"anyOf": [
{
"allOf": [
{
"$ref": "#/$defs/ExtRequest"
}
],
"description": "Extension request (client → agent)",
"title": "Request"
},
{
"allOf": [
{
"$ref": "#/$defs/ExtResponse"
}
],
"description": "Extension response (agent → client)",
"title": "Response"
}
]
}
@@ -0,0 +1,188 @@
use goose_acp::server::GooseAcpAgent;
use schemars::SchemaGenerator;
use serde_json::{json, Map, Value};
use std::collections::{BTreeSet, HashMap};
use std::env;
use std::fs;
use std::path::PathBuf;
fn main() {
let mut generator = SchemaGenerator::default();
let methods = GooseAcpAgent::custom_method_schemas(&mut generator);
// Collect $defs from the generator (all types referenced via subschema_for).
let mut defs: Map<String, Value> = generator
.take_definitions(true)
.into_iter()
.map(|(k, v)| (k, serde_json::to_value(v).unwrap_or(json!({}))))
.collect();
// Strip the `_goose/` prefix to get the bare method name for x-method.
fn bare_method(full: &str) -> &str {
full.strip_prefix("_goose/").unwrap_or(full)
}
// Track which types map to which methods so we can detect shared types.
let mut type_methods: HashMap<String, Vec<String>> = HashMap::new();
for m in &methods {
let method = bare_method(&m.method).to_string();
if let Some(name) = &m.params_type_name {
type_methods
.entry(name.clone())
.or_default()
.push(method.clone());
}
if let Some(name) = &m.response_type_name {
type_methods
.entry(name.clone())
.or_default()
.push(method.clone());
}
}
// Annotate $defs entries with x-method/x-side. Only set x-method for types
// used by exactly one method (shared types like EmptyResponse skip x-method).
for (name, methods_list) in &type_methods {
if let Some(def) = defs.get_mut(name) {
if let Some(obj) = def.as_object_mut() {
obj.insert("x-side".into(), json!("agent"));
if methods_list.len() == 1 {
obj.insert("x-method".into(), json!(methods_list[0]));
}
}
}
}
// Build ExtRequest.params and ExtResponse.result anyOf arrays,
// deduplicating response variants (e.g. EmptyResponse appears once).
let mut request_variants: Vec<Value> = Vec::new();
let mut response_variants: Vec<Value> = Vec::new();
let mut seen_response_types: BTreeSet<String> = BTreeSet::new();
for m in &methods {
if let Some(name) = &m.params_type_name {
request_variants.push(json!({
"allOf": [{ "$ref": format!("#/$defs/{name}") }],
"description": format!("Params for {}", m.method),
"title": name,
}));
}
if let Some(name) = &m.response_type_name {
if seen_response_types.insert(name.clone()) {
response_variants.push(json!({
"allOf": [{ "$ref": format!("#/$defs/{name}") }],
"title": name,
}));
}
}
}
// Build ExtRequest — mirrors AgentRequest structure.
defs.insert(
"ExtRequest".into(),
json!({
"properties": {
"id": { "type": "string" },
"method": { "type": "string" },
"params": {
"anyOf": [
{ "anyOf": request_variants },
{ "description": "Untyped params", "type": ["object", "null"] },
]
}
},
"required": ["id", "method"],
"type": "object",
"x-docs-ignore": true,
}),
);
// Build ExtResponse — mirrors AgentResponse structure.
defs.insert(
"ExtResponse".into(),
json!({
"anyOf": [
{
"properties": {
"id": { "type": "string" },
"result": {
"anyOf": [
{ "anyOf": response_variants },
{ "description": "Untyped result" },
]
}
},
"required": ["id"],
"title": "Success",
"type": "object",
},
{
"properties": {
"error": {
"type": "object",
"properties": {
"code": { "type": "integer" },
"message": { "type": "string" },
"data": {}
},
"required": ["code", "message"],
},
"id": { "type": "string" },
},
"required": ["id", "error"],
"title": "Error",
"type": "object",
}
],
"x-docs-ignore": true,
}),
);
// Assemble the root schema document.
let root = json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "GooseExtensions",
"$defs": defs,
"anyOf": [
{
"allOf": [{ "$ref": "#/$defs/ExtRequest" }],
"description": "Extension request (client → agent)",
"title": "Request",
},
{
"allOf": [{ "$ref": "#/$defs/ExtResponse" }],
"description": "Extension response (agent → client)",
"title": "Response",
}
],
});
let json_str = serde_json::to_string_pretty(&root).expect("failed to serialize schema");
let package_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
let package_path = PathBuf::from(&package_dir);
let schema_path = package_path.join("acp-schema.json");
fs::write(&schema_path, format!("{json_str}\n")).expect("failed to write schema file");
eprintln!("Generated ACP schema at {}", schema_path.display());
// Build meta.json with method→type mappings (consumed by TS codegen).
let method_entries: Vec<Value> = methods
.iter()
.map(|m| {
json!({
"method": bare_method(&m.method),
"requestType": m.params_type_name,
"responseType": m.response_type_name,
})
})
.collect();
let meta = json!({ "methods": method_entries });
let meta_str = serde_json::to_string_pretty(&meta).expect("failed to serialize meta");
let meta_path = package_path.join("acp-meta.json");
fs::write(&meta_path, format!("{meta_str}\n")).expect("failed to write meta file");
eprintln!("Generated ACP meta at {}", meta_path.display());
println!("{json_str}");
}
+130
View File
@@ -0,0 +1,130 @@
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// Schema descriptor for a single custom method, produced by the
/// `#[custom_methods]` macro's generated `custom_method_schemas()` function.
///
/// `params_schema` / `response_schema` hold `$ref` pointers or inline schemas
/// produced by `SchemaGenerator::subschema_for`. All referenced types are
/// collected in the generator's `$defs` map.
///
/// `params_type_name` / `response_type_name` carry the Rust struct name so the
/// binary can key `$defs` entries and annotate them with `x-method` / `x-side`.
#[derive(Debug, Serialize)]
pub struct CustomMethodSchema {
pub method: String,
pub params_schema: Option<schemars::Schema>,
pub params_type_name: Option<String>,
pub response_schema: Option<schemars::Schema>,
pub response_type_name: Option<String>,
}
/// Add an extension to an active session.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct AddExtensionRequest {
pub session_id: String,
/// Extension configuration (see ExtensionConfig variants: Stdio, StreamableHttp, Builtin, Platform).
pub config: serde_json::Value,
}
/// Remove an extension from an active session.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct RemoveExtensionRequest {
pub session_id: String,
pub name: String,
}
/// List all tools available in a session.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct GetToolsRequest {
pub session_id: String,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct GetToolsResponse {
/// Array of tool info objects with `name`, `description`, `parameters`, and optional `permission`.
pub tools: Vec<serde_json::Value>,
}
/// Read a resource from an extension.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ReadResourceRequest {
pub session_id: String,
pub uri: String,
pub extension_name: String,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct ReadResourceResponse {
/// The resource result from the extension (MCP ReadResourceResult).
pub result: serde_json::Value,
}
/// Update the working directory for a session.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct UpdateWorkingDirRequest {
pub session_id: String,
pub working_dir: String,
}
/// Get a session by ID.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct GetSessionRequest {
pub session_id: String,
#[serde(default)]
pub include_messages: bool,
}
/// Get a session response.
#[derive(Debug, Serialize, JsonSchema)]
pub struct GetSessionResponse {
/// The session object with id, name, working_dir, timestamps, tokens, etc.
pub session: serde_json::Value,
}
/// List all sessions.
#[derive(Debug, Serialize, JsonSchema)]
pub struct ListSessionsResponse {
pub sessions: Vec<serde_json::Value>,
}
/// Delete a session.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct DeleteSessionRequest {
pub session_id: String,
}
/// Export a session as a JSON string.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ExportSessionRequest {
pub session_id: String,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct ExportSessionResponse {
pub data: String,
}
/// Import a session from a JSON string.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ImportSessionRequest {
pub data: String,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct ImportSessionResponse {
/// The imported session object.
pub session: serde_json::Value,
}
/// List configured extensions and any warnings.
#[derive(Debug, Serialize, JsonSchema)]
pub struct GetExtensionsResponse {
/// Array of ExtensionEntry objects with `enabled` flag and config details.
pub extensions: Vec<serde_json::Value>,
pub warnings: Vec<String>,
}
/// Empty success response for operations that return no data.
#[derive(Debug, Serialize, JsonSchema)]
pub struct EmptyResponse {}
+1
View File
@@ -1,6 +1,7 @@
#![recursion_limit = "256"]
mod adapters;
pub mod custom_requests;
pub mod server;
pub mod server_factory;
pub mod transport;
+198 -1
View File
@@ -1,3 +1,4 @@
use crate::custom_requests::*;
use anyhow::Result;
use fs_err as fs;
use goose::agents::extension::{Envs, PLATFORM_EXTENSIONS};
@@ -17,6 +18,7 @@ use goose::providers::base::Provider;
use goose::providers::provider_registry::ProviderConstructor;
use goose::session::session_manager::SessionType;
use goose::session::{Session, SessionManager};
use goose_acp_macros::custom_methods;
use rmcp::model::{CallToolResult, RawContent, ResourceContents, Role};
use sacp::schema::{
AgentCapabilities, AuthMethod, AuthenticateRequest, AuthenticateResponse, BlobResourceContents,
@@ -994,6 +996,192 @@ impl GooseAcpAgent {
}
}
#[custom_methods]
impl GooseAcpAgent {
#[custom_method("extensions/add")]
async fn on_add_extension(
&self,
req: AddExtensionRequest,
) -> Result<EmptyResponse, sacp::Error> {
let config: ExtensionConfig = serde_json::from_value(req.config)
.map_err(|e| sacp::Error::invalid_params().data(format!("bad config: {e}")))?;
let agent = self.get_agent_for_session(&req.session_id).await?;
agent
.add_extension(config, &req.session_id)
.await
.map_err(|e| sacp::Error::internal_error().data(e.to_string()))?;
Ok(EmptyResponse {})
}
#[custom_method("extensions/remove")]
async fn on_remove_extension(
&self,
req: RemoveExtensionRequest,
) -> Result<EmptyResponse, sacp::Error> {
let agent = self.get_agent_for_session(&req.session_id).await?;
agent
.remove_extension(&req.name, &req.session_id)
.await
.map_err(|e| sacp::Error::internal_error().data(e.to_string()))?;
Ok(EmptyResponse {})
}
#[custom_method("tools")]
async fn on_get_tools(&self, req: GetToolsRequest) -> Result<GetToolsResponse, sacp::Error> {
let agent = self.get_agent_for_session(&req.session_id).await?;
let tools = agent.list_tools(&req.session_id, None).await;
let tools_json = tools
.into_iter()
.map(|t| serde_json::to_value(&t))
.collect::<Result<Vec<_>, _>>()
.map_err(|e| sacp::Error::internal_error().data(e.to_string()))?;
Ok(GetToolsResponse { tools: tools_json })
}
#[custom_method("resource/read")]
async fn on_read_resource(
&self,
req: ReadResourceRequest,
) -> Result<ReadResourceResponse, sacp::Error> {
let agent = self.get_agent_for_session(&req.session_id).await?;
let cancel_token = CancellationToken::new();
let result = agent
.extension_manager
.read_resource(&req.session_id, &req.uri, &req.extension_name, cancel_token)
.await
.map_err(|e| sacp::Error::internal_error().data(e.to_string()))?;
let result_json = serde_json::to_value(&result)
.map_err(|e| sacp::Error::internal_error().data(e.to_string()))?;
Ok(ReadResourceResponse {
result: result_json,
})
}
#[custom_method("working_dir/update")]
async fn on_update_working_dir(
&self,
req: UpdateWorkingDirRequest,
) -> Result<EmptyResponse, sacp::Error> {
let working_dir = req.working_dir.trim().to_string();
if working_dir.is_empty() {
return Err(sacp::Error::invalid_params().data("working directory cannot be empty"));
}
let path = std::path::PathBuf::from(&working_dir);
if !path.exists() || !path.is_dir() {
return Err(sacp::Error::invalid_params().data("invalid directory path"));
}
self.session_manager
.update(&req.session_id)
.working_dir(path)
.apply()
.await
.map_err(|e| sacp::Error::internal_error().data(e.to_string()))?;
Ok(EmptyResponse {})
}
#[custom_method("session/list")]
async fn on_list_sessions(&self) -> Result<ListSessionsResponse, sacp::Error> {
let sessions = self
.session_manager
.list_sessions()
.await
.map_err(|e| sacp::Error::internal_error().data(e.to_string()))?;
let sessions_json = sessions
.into_iter()
.map(|s| serde_json::to_value(&s))
.collect::<Result<Vec<_>, _>>()
.map_err(|e| sacp::Error::internal_error().data(e.to_string()))?;
Ok(ListSessionsResponse {
sessions: sessions_json,
})
}
#[custom_method("session/get")]
async fn on_get_session(
&self,
req: GetSessionRequest,
) -> Result<GetSessionResponse, sacp::Error> {
let session = self
.session_manager
.get_session(&req.session_id, req.include_messages)
.await
.map_err(|e| sacp::Error::internal_error().data(e.to_string()))?;
let session_json = serde_json::to_value(&session)
.map_err(|e| sacp::Error::internal_error().data(e.to_string()))?;
Ok(GetSessionResponse {
session: session_json,
})
}
#[custom_method("session/delete")]
async fn on_delete_session(
&self,
req: DeleteSessionRequest,
) -> Result<EmptyResponse, sacp::Error> {
self.session_manager
.delete_session(&req.session_id)
.await
.map_err(|e| sacp::Error::internal_error().data(e.to_string()))?;
Ok(EmptyResponse {})
}
#[custom_method("session/export")]
async fn on_export_session(
&self,
req: ExportSessionRequest,
) -> Result<ExportSessionResponse, sacp::Error> {
let data = self
.session_manager
.export_session(&req.session_id)
.await
.map_err(|e| sacp::Error::internal_error().data(e.to_string()))?;
Ok(ExportSessionResponse { data })
}
#[custom_method("session/import")]
async fn on_import_session(
&self,
req: ImportSessionRequest,
) -> Result<ImportSessionResponse, sacp::Error> {
let session = self
.session_manager
.import_session(&req.data)
.await
.map_err(|e| sacp::Error::internal_error().data(e.to_string()))?;
let session_json = serde_json::to_value(&session)
.map_err(|e| sacp::Error::internal_error().data(e.to_string()))?;
Ok(ImportSessionResponse {
session: session_json,
})
}
#[custom_method("config/extensions")]
async fn on_get_extensions(&self) -> Result<GetExtensionsResponse, sacp::Error> {
let extensions = goose::config::extensions::get_all_extensions();
let warnings = goose::config::extensions::get_warnings();
let extensions_json = extensions
.into_iter()
.map(|e| serde_json::to_value(&e))
.collect::<Result<Vec<_>, _>>()
.map_err(|e| sacp::Error::internal_error().data(e.to_string()))?;
Ok(GetExtensionsResponse {
extensions: extensions_json,
warnings,
})
}
async fn get_agent_for_session(&self, session_id: &str) -> Result<Arc<Agent>, sacp::Error> {
self.sessions
.lock()
.await
.get(session_id)
.map(|s| Arc::clone(&s.agent))
.ok_or_else(|| {
sacp::Error::invalid_params().data(format!("no active session: {session_id}"))
})
}
}
pub struct GooseAcpHandler {
pub agent: Arc<GooseAcpAgent>,
}
@@ -1061,7 +1249,9 @@ impl JrMessageHandler for GooseAcpHandler {
self.agent.on_cancel(notif).await
})
.await
// HACK: sacp doesn't support session/set_model yet, so we handle it as untyped JSON.
// Handle methods not yet in the sacp typed API.
// - session/set_model: typed support pending in sacp
// - _<method>: custom requests that will eventually route to goose-server
.otherwise({
let agent = self.agent.clone();
|message: MessageCx| async move {
@@ -1079,6 +1269,13 @@ impl JrMessageHandler for GooseAcpHandler {
request_cx.respond(json)?;
Ok(())
}
MessageCx::Request(req, request_cx) if req.method.starts_with('_') => {
match agent.handle_custom_request(&req.method, req.params).await {
Ok(json) => request_cx.respond(json)?,
Err(e) => request_cx.respond_with_error(e)?,
}
Ok(())
}
_ => Err(sacp::Error::method_not_found()),
}
}
@@ -0,0 +1,170 @@
#[allow(dead_code)]
mod common_tests;
use common_tests::fixtures::server::ClientToAgentConnection;
use common_tests::fixtures::{run_test, Connection, Session, TestConnectionConfig};
use goose_test_support::ExpectedSessionId;
use common_tests::fixtures::OpenAiFixture;
/// Send an untyped custom request and return the result or error.
async fn send_custom(
cx: &sacp::JrConnectionCx<sacp::ClientToAgent>,
method: &str,
params: serde_json::Value,
) -> Result<serde_json::Value, sacp::Error> {
let msg = sacp::UntypedMessage::new(method, params).unwrap();
cx.send_request(msg).block_task().await
}
#[test]
fn test_custom_session_list() {
run_test(async {
let openai = OpenAiFixture::new(vec![], ExpectedSessionId::default()).await;
let mut conn = ClientToAgentConnection::new(TestConnectionConfig::default(), openai).await;
let (session, _models) = conn.new_session().await;
let session_id = session.session_id().0.clone();
// Verify the session exists via _session/get
let get_result = send_custom(
conn.cx(),
"_goose/session/get",
serde_json::json!({ "session_id": session_id }),
)
.await;
assert!(
get_result.is_ok(),
"session should exist via get: {:?}",
get_result
);
let get_response = get_result.unwrap();
assert_eq!(
get_response
.get("session")
.and_then(|s| s.get("id"))
.and_then(|v| v.as_str()),
Some(session_id.as_ref()),
);
// Verify _session/list returns a valid response
// Note: list_sessions uses INNER JOIN on messages, so a fresh session
// with no messages won't appear. We just verify the call succeeds.
let result = send_custom(conn.cx(), "_goose/session/list", serde_json::json!({})).await;
assert!(result.is_ok(), "expected ok, got: {:?}", result);
let response = result.unwrap();
let sessions = response.get("sessions").expect("missing 'sessions' field");
assert!(sessions.is_array(), "sessions should be array");
});
}
#[test]
fn test_custom_session_get() {
run_test(async {
let openai = OpenAiFixture::new(vec![], ExpectedSessionId::default()).await;
let mut conn = ClientToAgentConnection::new(TestConnectionConfig::default(), openai).await;
let (session, _models) = conn.new_session().await;
let session_id = session.session_id().0.clone();
let result = send_custom(
conn.cx(),
"_goose/session/get",
serde_json::json!({
"session_id": session_id,
}),
)
.await;
assert!(result.is_ok(), "expected ok, got: {:?}", result);
let response = result.unwrap();
let returned_session = response.get("session").expect("missing 'session' field");
assert_eq!(
returned_session.get("id").and_then(|v| v.as_str()),
Some(session_id.as_ref())
);
});
}
#[test]
fn test_custom_session_delete() {
run_test(async {
let openai = OpenAiFixture::new(vec![], ExpectedSessionId::default()).await;
let mut conn = ClientToAgentConnection::new(TestConnectionConfig::default(), openai).await;
let (session, _models) = conn.new_session().await;
let session_id = session.session_id().0.clone();
let result = send_custom(
conn.cx(),
"_goose/session/delete",
serde_json::json!({ "session_id": session_id }),
)
.await;
assert!(result.is_ok(), "delete failed: {:?}", result);
let result = send_custom(
conn.cx(),
"_goose/session/get",
serde_json::json!({ "session_id": session_id }),
)
.await;
assert!(result.is_err(), "expected error for deleted session");
});
}
#[test]
fn test_custom_get_tools() {
run_test(async {
let openai = OpenAiFixture::new(vec![], ExpectedSessionId::default()).await;
let mut conn = ClientToAgentConnection::new(TestConnectionConfig::default(), openai).await;
let (session, _models) = conn.new_session().await;
let session_id = session.session_id().0.clone();
let result = send_custom(
conn.cx(),
"_goose/tools",
serde_json::json!({ "session_id": session_id }),
)
.await;
assert!(result.is_ok(), "expected ok, got: {:?}", result);
let response = result.unwrap();
let tools = response.get("tools").expect("missing 'tools' field");
assert!(tools.is_array(), "tools should be array");
});
}
#[test]
fn test_custom_get_extensions() {
run_test(async {
let openai = OpenAiFixture::new(vec![], ExpectedSessionId::default()).await;
let conn = ClientToAgentConnection::new(TestConnectionConfig::default(), openai).await;
let result =
send_custom(conn.cx(), "_goose/config/extensions", serde_json::json!({})).await;
assert!(result.is_ok(), "expected ok, got: {:?}", result);
let response = result.unwrap();
assert!(
response.get("extensions").is_some(),
"missing 'extensions' field"
);
assert!(
response.get("warnings").is_some(),
"missing 'warnings' field"
);
});
}
#[test]
fn test_custom_unknown_method() {
run_test(async {
let openai = OpenAiFixture::new(vec![], ExpectedSessionId::default()).await;
let conn = ClientToAgentConnection::new(TestConnectionConfig::default(), openai).await;
let result = send_custom(conn.cx(), "_unknown/method", serde_json::json!({})).await;
assert!(result.is_err(), "expected method_not_found error");
});
}
+7
View File
@@ -34,6 +34,13 @@ pub struct ClientToAgentSession {
notify: Arc<Notify>,
}
impl ClientToAgentConnection {
#[allow(dead_code)]
pub fn cx(&self) -> &JrConnectionCx<ClientToAgent> {
&self.cx
}
}
#[async_trait]
impl Connection for ClientToAgentConnection {
type Session = ClientToAgentSession;