1use proc_macro::TokenStream;
2use quote::quote;
3use syn::{FnArg, ItemFn, parse_macro_input};
4
5#[proc_macro]
28pub fn tools(input: TokenStream) -> TokenStream {
29 let tools = parse_macro_input!(input as ToolsDefinition);
30
31 let tool_fns: Vec<_> = tools.functions.iter().collect();
33
34 let metadata_items: Vec<_> = tool_fns.iter().map(|func| {
36 let name = &func.sig.ident;
37 let name_str = name.to_string();
38
39 let description = extract_doc_comment(&func.attrs)
41 .map(|d| quote!(Some(#d.to_string())))
42 .unwrap_or(quote!(None));
43
44 let input_type = match func.sig.inputs.first() {
46 Some(FnArg::Typed(pat_type)) => &pat_type.ty,
47 _ => panic!("Tool function must have exactly one typed argument"),
48 };
49
50 quote! {
51 ::ftl_sdk::ToolMetadata {
52 name: #name_str.to_string(),
53 title: None,
54 description: #description,
55 input_schema: ::serde_json::to_value(::schemars::schema_for!(#input_type)).unwrap(),
56 output_schema: None,
57 annotations: None,
58 meta: None,
59 }
60 }
61 }).collect();
62
63 let routing_cases: Vec<_> = tool_fns.iter().map(|func| {
65 let name = &func.sig.ident;
66 let name_str = name.to_string();
67 let is_async = func.sig.asyncness.is_some();
68
69 let input_type = match func.sig.inputs.first() {
71 Some(FnArg::Typed(pat_type)) => &pat_type.ty,
72 _ => panic!("Tool function must have exactly one typed argument"),
73 };
74
75 let fn_call = if is_async {
76 quote!(#name(input).await)
77 } else {
78 quote!(#name(input))
79 };
80
81 quote! {
82 #name_str => {
83 match ::serde_json::from_slice::<#input_type>(body) {
84 Ok(input) => {
85 let response = #fn_call;
86 match ::serde_json::to_vec(&response) {
87 Ok(body) => Response::builder()
88 .status(200)
89 .header("Content-Type", "application/json")
90 .body(body)
91 .build(),
92 Err(e) => {
93 let error_response = ::ftl_sdk::ToolResponse::error(
94 format!("Failed to serialize response: {}", e)
95 );
96 Response::builder()
97 .status(500)
98 .header("Content-Type", "application/json")
99 .body(::serde_json::to_vec(&error_response).unwrap_or_default())
100 .build()
101 }
102 }
103 }
104 Err(e) => {
105 let error_response = ::ftl_sdk::ToolResponse::error(
106 format!("Invalid request body: {}", e)
107 );
108 Response::builder()
109 .status(400)
110 .header("Content-Type", "application/json")
111 .body(::serde_json::to_vec(&error_response).unwrap_or_default())
112 .build()
113 }
114 }
115 }
116 }
117 }).collect();
118
119 let output = quote! {
120 #(#tool_fns)*
122
123 #[::spin_sdk::http_component]
125 async fn handle_tool_component(req: ::spin_sdk::http::Request) -> ::spin_sdk::http::Response {
126 use ::spin_sdk::http::{Method, Response};
127
128 let path = req.path();
129
130 match req.method() {
131 &Method::Get if path == "/" => {
132 let tools = vec![
134 #(#metadata_items),*
135 ];
136
137 match ::serde_json::to_vec(&tools) {
138 Ok(body) => Response::builder()
139 .status(200)
140 .header("Content-Type", "application/json")
141 .body(body)
142 .build(),
143 Err(e) => Response::builder()
144 .status(500)
145 .body(format!("Failed to serialize metadata: {}", e))
146 .build()
147 }
148 }
149 &Method::Post => {
150 let tool_name = path.trim_start_matches('/');
152 let body = req.body();
153
154 match tool_name {
155 #(#routing_cases)*
156 _ => {
157 let error_response = ::ftl_sdk::ToolResponse::error(
158 format!("Tool '{}' not found", tool_name)
159 );
160 Response::builder()
161 .status(404)
162 .header("Content-Type", "application/json")
163 .body(::serde_json::to_vec(&error_response).unwrap_or_default())
164 .build()
165 }
166 }
167 }
168 _ => Response::builder()
169 .status(405)
170 .header("Allow", "GET, POST")
171 .body("Method not allowed")
172 .build()
173 }
174 }
175 };
176
177 output.into()
178}
179
180struct ToolsDefinition {
182 functions: Vec<ItemFn>,
183}
184
185impl syn::parse::Parse for ToolsDefinition {
186 fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
187 let mut functions = Vec::new();
188
189 while !input.is_empty() {
190 functions.push(input.parse::<ItemFn>()?);
191 }
192
193 if functions.is_empty() {
194 return Err(syn::Error::new(
195 input.span(),
196 "At least one tool function must be defined",
197 ));
198 }
199
200 Ok(ToolsDefinition { functions })
201 }
202}
203
204fn extract_doc_comment(attrs: &[syn::Attribute]) -> Option<String> {
206 attrs
207 .iter()
208 .filter_map(|attr| {
209 if attr.path().is_ident("doc")
210 && let syn::Meta::NameValue(nv) = &attr.meta
211 && let syn::Expr::Lit(lit) = &nv.value
212 && let syn::Lit::Str(s) = &lit.lit
213 {
214 let doc = s.value();
215 return Some(doc.trim_start_matches(' ').to_string());
217 }
218 None
219 })
220 .next()
221}