diff --git a/CHANGELOG.md b/CHANGELOG.md index 10afe14..73d9c58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- [#53](https://github.com/portofcontext/pctx/issues/53) Improved code generation support for tools with no input schema or all optional input schemas + ### Fixed ## [v0.4.3] - 2026-01-27 diff --git a/Cargo.lock b/Cargo.lock index a41f77e..146c5d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4120,7 +4120,7 @@ dependencies = [ [[package]] name = "pctx_codegen" -version = "0.1.1" +version = "0.2.0" dependencies = [ "biome_formatter", "biome_js_formatter", diff --git a/crates/pctx/src/commands/mcp/dev/renderers.rs b/crates/pctx/src/commands/mcp/dev/renderers.rs index bd268c1..5ebe24c 100644 --- a/crates/pctx/src/commands/mcp/dev/renderers.rs +++ b/crates/pctx/src/commands/mcp/dev/renderers.rs @@ -480,7 +480,11 @@ fn render_tool_detail(f: &mut Frame, app: &App, area: Rect) { "Input Type:", Style::default().fg(SECONDARY).add_modifier(Modifier::BOLD), )])); - lines.push(Line::from(format!(" {}", tool.input_signature))); + if let Some(i) = &tool.input_signature() { + lines.push(Line::from(format!(" {i}"))); + } else { + lines.push(Line::from("void")); + } lines.push(Line::from("")); // Output type @@ -488,7 +492,7 @@ fn render_tool_detail(f: &mut Frame, app: &App, area: Rect) { "Output Type:", Style::default().fg(SECONDARY).add_modifier(Modifier::BOLD), )])); - lines.push(Line::from(format!(" {}", tool.output_signature))); + lines.push(Line::from(format!(" {}", tool.output_signature()))); lines.push(Line::from("")); // TypeScript types @@ -496,7 +500,7 @@ fn render_tool_detail(f: &mut Frame, app: &App, area: Rect) { "TypeScript Definition:", Style::default().fg(TERTIARY).add_modifier(Modifier::BOLD), )])); - for line in tool.types.lines() { + for line in tool.types().lines() { lines.push(Line::from(format!(" {line}"))); } diff --git a/crates/pctx_code_mode/Cargo.toml b/crates/pctx_code_mode/Cargo.toml index 678d000..8980de6 100644 --- a/crates/pctx_code_mode/Cargo.toml +++ b/crates/pctx_code_mode/Cargo.toml @@ -12,7 +12,7 @@ categories = ["development-tools", "api-bindings"] [dependencies] # local pctx_config = { version = "^0.1.3", path = "../pctx_config" } -pctx_codegen = { version = "^0.1.1", path = "../pctx_codegen" } +pctx_codegen = { version = "^0.2.0", path = "../pctx_codegen" } pctx_executor = { version = "^0.1.2", path = "../pctx_executor" } pctx_code_execution_runtime = { version = "^0.1.3", path = "../pctx_code_execution_runtime" } diff --git a/crates/pctx_code_mode/src/code_mode.rs b/crates/pctx_code_mode/src/code_mode.rs index ef23f25..7679eb2 100644 --- a/crates/pctx_code_mode/src/code_mode.rs +++ b/crates/pctx_code_mode/src/code_mode.rs @@ -160,7 +160,7 @@ impl CodeMode { Tool::new_mcp( &mcp_tool.name, mcp_tool.description.map(String::from), - input_schema, + Some(input_schema), output_schema, ) .map_err(|e| { @@ -220,17 +220,15 @@ impl CodeMode { } // convert callback config into tool - let input_schema = if let Some(i) = &callback.input_schema { - serde_json::from_value::(json!(i)).map_err(|e| { - Error::Message(format!( - "Failed parsing inputSchema as json schema for tool `{}`: {e}", - &callback.name - )) - })? - } else { - // TODO: better empty input schema support - serde_json::from_value::(json!({})).unwrap() - }; + let input_schema = serde_json::from_value::>(json!( + &callback.input_schema + )) + .map_err(|e| { + Error::Message(format!( + "Failed parsing inputSchema as json schema for tool `{}`: {e}", + &callback.name + )) + })?; let output_schema = if let Some(o) = &callback.output_schema { Some( serde_json::from_value::(json!(o)).map_err(|e| { @@ -372,9 +370,9 @@ impl CodeMode { name: t.fn_name.clone(), description: t.description.clone(), }, - input_type: t.input_signature.clone(), - output_type: t.output_signature.clone(), - types: t.types.clone(), + input_type: t.input_signature().unwrap_or_default(), + output_type: t.output_signature(), + types: t.types(), })); } } diff --git a/crates/pctx_codegen/Cargo.toml b/crates/pctx_codegen/Cargo.toml index e6836f8..c7b416e 100644 --- a/crates/pctx_codegen/Cargo.toml +++ b/crates/pctx_codegen/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pctx_codegen" -version = "0.1.1" +version = "0.2.0" edition = "2024" license = "MIT" description = "Code generation utilities for pctx" diff --git a/crates/pctx_codegen/src/tools.rs b/crates/pctx_codegen/src/tools.rs index 12e55c4..10ecc14 100644 --- a/crates/pctx_codegen/src/tools.rs +++ b/crates/pctx_codegen/src/tools.rs @@ -3,7 +3,12 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use tracing::debug; -use crate::{CodegenResult, case::Case, generate_docstring, typegen::generate_types_new}; +use crate::{ + CodegenResult, + case::Case, + generate_docstring, + typegen::{TypegenResult, generate_types}, +}; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ToolSet { @@ -53,23 +58,22 @@ namespace {namespace} {{ #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Tool { pub name: String, + pub fn_name: String, pub description: Option, - pub input_schema: RootSchema, - pub output_schema: Option, + pub variant: ToolVariant, - pub fn_name: String, - pub input_signature: String, - pub output_signature: String, - pub types: String, + pub input_schema: Option, + pub output_schema: Option, - pub variant: ToolVariant, + input_type: Option, + output_type: Option, } impl Tool { pub fn new_mcp( name: &str, description: Option, - input: RootSchema, + input: Option, output: Option, ) -> CodegenResult { Self::_new(name, description, input, output, ToolVariant::Mcp) @@ -78,7 +82,7 @@ impl Tool { pub fn new_callback( name: &str, description: Option, - input: RootSchema, + input: Option, output: Option, ) -> CodegenResult { Self::_new(name, description, input, output, ToolVariant::Callback) @@ -87,7 +91,7 @@ impl Tool { fn _new( name: &str, description: Option, - input: RootSchema, + input: Option, output: Option, variant: ToolVariant, ) -> CodegenResult { @@ -97,15 +101,16 @@ impl Tool { "Generating Typescript interface for tool: '{name}' -> function {fn_name}", ); - let input_types = generate_types_new(input.clone(), &format!("{fn_name}Input"))?; - let mut type_defs = input_types.types; - let output_signature = if let Some(o) = output.clone() { - let output_types = generate_types_new(o, &format!("{fn_name}Output"))?; - type_defs = format!("{type_defs}\n\n{}", output_types.types); - output_types.type_signature + let input_type = if let Some(i) = &input { + Some(generate_types(i.clone(), &format!("{fn_name}Input"))?) } else { - debug!("No output type listed, falling back on `any`"); - "any".to_string() + None + }; + + let output_type = if let Some(o) = output.clone() { + Some(generate_types(o, &format!("{fn_name}Output"))?) + } else { + None }; Ok(Self { @@ -114,32 +119,65 @@ impl Tool { input_schema: input, output_schema: output, fn_name, - input_signature: input_types.type_signature, - output_signature, - types: type_defs, + input_type, + output_type, variant, }) } + pub fn input_signature(&self) -> Option { + // No input schema -> no params for the generated function + self.input_type.as_ref().map(|i| i.type_signature.clone()) + } + + pub fn output_signature(&self) -> String { + // No output schema -> usually means not documented so output type fallback is `any`, not `void` + self.output_type + .as_ref() + .map(|o| o.type_signature.clone()) + .unwrap_or("any".into()) + } + + pub fn types(&self) -> String { + let mut type_defs = String::new(); + if let Some(i) = &self.input_type { + type_defs = i.types.clone(); + } + if let Some(o) = &self.output_type { + type_defs = format!("{type_defs}\n\n{}", &o.types); + } + + type_defs + } + pub fn fn_signature(&self, include_types: bool) -> String { let docstring_content = self.description.clone().unwrap_or_default(); - let types = if include_types && !self.types.is_empty() { - format!("{}\n\n", &self.types) - } else { - String::new() + let mut types = self.types(); + if include_types && !types.is_empty() { + types = format!("{types}\n\n"); + } + + let params = match &self.input_type { + Some(i) if i.all_optional => format!("input: {} = {{}}", &i.type_signature), + Some(i) => format!("input: {}", &i.type_signature), + None => String::default(), }; format!( - "{types}{docstring}\nexport async function {fn_name}(input: {input}): Promise<{output}>", + "{types}{docstring}\nexport async function {fn_name}({params}): Promise<{output}>", docstring = generate_docstring(&docstring_content), fn_name = &self.fn_name, - input = &self.input_signature, - output = &self.output_signature, + output = &self.output_signature(), ) } pub fn fn_impl(&self, toolset_name: &str) -> String { + let arguments = self + .input_schema + .as_ref() + .map(|_| format!("arguments: input,")) + .unwrap_or_default(); match self.variant { ToolVariant::Mcp => { format!( @@ -147,13 +185,13 @@ impl Tool { return await callMCPTool<{output}>({{ serverName: {name}, toolName: {tool}, - arguments: input, + {arguments} }}); }}", fn_sig = self.fn_signature(true), name = json!(toolset_name), tool = json!(&self.name), - output = &self.output_signature, + output = &self.output_signature(), ) } ToolVariant::Callback => { @@ -161,12 +199,12 @@ impl Tool { "{fn_sig} {{ return await invokeCallback<{output}>({{ id: {id}, - arguments: input, + {arguments} }}); }}", fn_sig = self.fn_signature(true), id = json!(format!("{toolset_name}.{}", &self.name)), - output = &self.output_signature, + output = &self.output_signature(), ) } } diff --git a/crates/pctx_codegen/src/typegen/mod.rs b/crates/pctx_codegen/src/typegen/mod.rs index ae513f5..bf90e97 100644 --- a/crates/pctx_codegen/src/typegen/mod.rs +++ b/crates/pctx_codegen/src/typegen/mod.rs @@ -1,8 +1,11 @@ mod schema_data; +use std::collections::HashSet; + use handlebars::Handlebars; use indexmap::IndexMap; use schemars::schema::{RootSchema, Schema}; +use serde::{Deserialize, Serialize}; use serde_json::json; use crate::{ @@ -12,19 +15,15 @@ use crate::{ static TYPES_TEMPLATE: &str = include_str!("./types.handlebars"); +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct TypegenResult { pub types_generated: usize, pub type_signature: String, + pub all_optional: bool, pub types: String, } -// TODO: rm in favour of new one -pub fn generate_types( - json_schema: serde_json::Value, - type_name: &str, -) -> CodegenResult { - let root_schema: RootSchema = serde_json::from_value(json_schema).unwrap(); - +pub fn generate_types(root_schema: RootSchema, type_name: &str) -> CodegenResult { // ensure all objects have type names let mut defs: SchemaDefinitions = IndexMap::new(); for (ref_key, s) in root_schema.definitions { @@ -47,34 +46,35 @@ pub fn generate_types( types: format_ts(&types), types_generated: to_generate.len(), type_signature: SchemaType::from(&schema).type_signature(true, &defs)?, + all_optional: is_all_optional(&schema, &defs)?, }) } -pub fn generate_types_new( - root_schema: RootSchema, - type_name: &str, -) -> CodegenResult { - // ensure all objects have type names - let mut defs: SchemaDefinitions = IndexMap::new(); - for (ref_key, s) in root_schema.definitions { - // TODO: clashing type names? - let type_name = Case::Pascal.sanitize(format!("{type_name} {ref_key}")); - defs.insert(ref_key, assign_type_names(s, &type_name)); +fn is_all_optional(schema: &Schema, defs: &SchemaDefinitions) -> CodegenResult { + // follow top schema until no longer ref + let mut schema_type = SchemaType::from(schema); + let mut visited = HashSet::new(); + loop { + if let SchemaType::Reference(ref_st) = &schema_type { + let is_new = visited.insert(ref_st.ref_key.clone()); + if is_new { + let followed = ref_st.follow(defs)?; + schema_type = SchemaType::from(followed) + } else { + // circular ref + break; + } + } else { + break; + } } - let schema = assign_type_names( - Schema::Object(root_schema.schema), - &Case::Pascal.sanitize(type_name), - ); - // collect and generate types with handlebars - let to_generate = ObjectSchemaData::collect(&schema, &defs)?; - let types = Handlebars::new() - .render_template(TYPES_TEMPLATE, &json!({"objects": to_generate})) - .unwrap(); - - Ok(TypegenResult { - types: format_ts(&types), - types_generated: to_generate.len(), - type_signature: SchemaType::from(&schema).type_signature(true, &defs)?, - }) + // "all optional" means {} is a valid default + // therefore all maps & objects with no required fields + // satisfy this + match schema_type { + SchemaType::Map(_) => Ok(true), + SchemaType::Object(obj_st) => Ok(obj_st.obj.required.is_empty()), + _ => Ok(false), + } } diff --git a/crates/pctx_codegen/src/utils.rs b/crates/pctx_codegen/src/utils.rs index f836fe4..0b82332 100644 --- a/crates/pctx_codegen/src/utils.rs +++ b/crates/pctx_codegen/src/utils.rs @@ -1,6 +1,4 @@ -use schemars::schema::{ - InstanceType, ObjectValidation, Schema, SchemaObject, SingleOrVec, SubschemaValidation, -}; +use schemars::schema::{InstanceType, Schema, SchemaObject, SingleOrVec, SubschemaValidation}; use serde_json::json; use crate::{ @@ -11,23 +9,11 @@ use crate::{ }, }; +/// returns standardized empty schema representing `any` pub fn anything_schema() -> Schema { Schema::Object(SchemaObject::default()) } -pub fn map_schema(value_schema: &Schema) -> Schema { - let obj = SchemaObject { - instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::Object))), - object: Some(Box::new(ObjectValidation { - additional_properties: Some(Box::new(value_schema.clone())), - ..Default::default() - })), - ..Default::default() - }; - - Schema::Object(obj) -} - /// gets description from schema, in the case of a ref, it will prioritize the first description it finds pub fn get_description( obj: &SchemaObject, diff --git a/crates/pctx_codegen/tests/fixtures/tools/all_optional_input.yml b/crates/pctx_codegen/tests/fixtures/tools/all_optional_input.yml new file mode 100644 index 0000000..9b970bb --- /dev/null +++ b/crates/pctx_codegen/tests/fixtures/tools/all_optional_input.yml @@ -0,0 +1,37 @@ +name: search_logs +description: "Search application logs with optional filters" + +input_schema: + type: object + properties: + query: + type: string + level: + type: string + enum: ["debug", "info", "warn", "error"] + limit: + type: integer + start_date: + type: string + +output_schema: + type: object + required: + - results + properties: + results: + type: array + items: + type: object + required: + - message + - timestamp + properties: + message: + type: string + timestamp: + type: string + level: + type: string + total_count: + type: integer diff --git a/crates/pctx_codegen/tests/fixtures/tools/basic.yml b/crates/pctx_codegen/tests/fixtures/tools/basic.yml new file mode 100644 index 0000000..5db9d97 --- /dev/null +++ b/crates/pctx_codegen/tests/fixtures/tools/basic.yml @@ -0,0 +1,26 @@ +name: get_weather +description: "Get the current weather for a given location" + +input_schema: + type: object + required: + - location + properties: + location: + type: string + units: + type: string + enum: ["celsius", "fahrenheit"] + +output_schema: + type: object + required: + - temperature + - condition + properties: + temperature: + type: number + condition: + type: string + humidity: + type: number diff --git a/crates/pctx_codegen/tests/fixtures/tools/nested_types.yml b/crates/pctx_codegen/tests/fixtures/tools/nested_types.yml new file mode 100644 index 0000000..63a2bed --- /dev/null +++ b/crates/pctx_codegen/tests/fixtures/tools/nested_types.yml @@ -0,0 +1,38 @@ +name: create_document +description: "Create a new document with metadata and content sections" + +input_schema: + type: object + required: + - title + - content + properties: + title: + type: string + content: + type: object + required: + - body + properties: + body: + type: string + format: + type: string + enum: ["markdown", "html", "plain"] + tags: + type: array + items: + type: string + +output_schema: + type: object + required: + - id + - created_at + properties: + id: + type: string + created_at: + type: string + url: + type: string diff --git a/crates/pctx_codegen/tests/fixtures/tools/no_input.yml b/crates/pctx_codegen/tests/fixtures/tools/no_input.yml new file mode 100644 index 0000000..e11eaf6 --- /dev/null +++ b/crates/pctx_codegen/tests/fixtures/tools/no_input.yml @@ -0,0 +1,12 @@ +name: list_files +description: "List all files in the current workspace" + +output_schema: + type: object + required: + - files + properties: + files: + type: array + items: + type: string diff --git a/crates/pctx_codegen/tests/fixtures/tools/no_input_or_output.yml b/crates/pctx_codegen/tests/fixtures/tools/no_input_or_output.yml new file mode 100644 index 0000000..c482636 --- /dev/null +++ b/crates/pctx_codegen/tests/fixtures/tools/no_input_or_output.yml @@ -0,0 +1,2 @@ +name: ping +description: "Health check ping" diff --git a/crates/pctx_codegen/tests/fixtures/tools/no_output.yml b/crates/pctx_codegen/tests/fixtures/tools/no_output.yml new file mode 100644 index 0000000..0e47700 --- /dev/null +++ b/crates/pctx_codegen/tests/fixtures/tools/no_output.yml @@ -0,0 +1,16 @@ +name: send_notification +description: "Send a notification to a user" + +input_schema: + type: object + required: + - user_id + - message + properties: + user_id: + type: string + message: + type: string + priority: + type: string + enum: ["low", "medium", "high"] diff --git a/crates/pctx_codegen/tests/snapshots/tool__test_all_optional_input__fn_impl.ts.snap b/crates/pctx_codegen/tests/snapshots/tool__test_all_optional_input__fn_impl.ts.snap new file mode 100644 index 0000000..84bedcc --- /dev/null +++ b/crates/pctx_codegen/tests/snapshots/tool__test_all_optional_input__fn_impl.ts.snap @@ -0,0 +1,40 @@ +--- +source: crates/pctx_codegen/tests/tool.rs +expression: impl_code +--- +export type SearchLogsInput = { + query?: string | undefined; + + level?: "debug" | "info" | "warn" | "error" | undefined; + + limit?: number | undefined; + + start_date?: string | undefined; +}; + +export type SearchLogsOutput = { + results: SearchLogsOutputResults[]; + + total_count?: number | undefined; +}; + +export type SearchLogsOutputResults = { + message: string; + + timestamp: string; + + level?: string | undefined; +}; + +/** + * Search application logs with optional filters + */ +export async function searchLogs( + input: SearchLogsInput = {}, +): Promise { + return await callMCPTool({ + serverName: "test_server", + toolName: "search_logs", + arguments: input, + }); +} diff --git a/crates/pctx_codegen/tests/snapshots/tool__test_basic__fn_impl.ts.snap b/crates/pctx_codegen/tests/snapshots/tool__test_basic__fn_impl.ts.snap new file mode 100644 index 0000000..a13a63e --- /dev/null +++ b/crates/pctx_codegen/tests/snapshots/tool__test_basic__fn_impl.ts.snap @@ -0,0 +1,30 @@ +--- +source: crates/pctx_codegen/tests/tool.rs +expression: impl_code +--- +export type GetWeatherInput = { + location: string; + + units?: "celsius" | "fahrenheit" | undefined; +}; + +export type GetWeatherOutput = { + temperature: number; + + condition: string; + + humidity?: number | undefined; +}; + +/** + * Get the current weather for a given location + */ +export async function getWeather( + input: GetWeatherInput, +): Promise { + return await callMCPTool({ + serverName: "test_server", + toolName: "get_weather", + arguments: input, + }); +} diff --git a/crates/pctx_codegen/tests/snapshots/tool__test_nested_types__fn_impl.ts.snap b/crates/pctx_codegen/tests/snapshots/tool__test_nested_types__fn_impl.ts.snap new file mode 100644 index 0000000..46e9825 --- /dev/null +++ b/crates/pctx_codegen/tests/snapshots/tool__test_nested_types__fn_impl.ts.snap @@ -0,0 +1,38 @@ +--- +source: crates/pctx_codegen/tests/tool.rs +expression: impl_code +--- +export type CreateDocumentInput = { + title: string; + + content: CreateDocumentInputContent; + + tags?: string[] | undefined; +}; + +export type CreateDocumentInputContent = { + body: string; + + format?: "markdown" | "html" | "plain" | undefined; +}; + +export type CreateDocumentOutput = { + id: string; + + created_at: string; + + url?: string | undefined; +}; + +/** + * Create a new document with metadata and content sections + */ +export async function createDocument( + input: CreateDocumentInput, +): Promise { + return await callMCPTool({ + serverName: "test_server", + toolName: "create_document", + arguments: input, + }); +} diff --git a/crates/pctx_codegen/tests/snapshots/tool__test_no_input__fn_impl.ts.snap b/crates/pctx_codegen/tests/snapshots/tool__test_no_input__fn_impl.ts.snap new file mode 100644 index 0000000..d67a6a0 --- /dev/null +++ b/crates/pctx_codegen/tests/snapshots/tool__test_no_input__fn_impl.ts.snap @@ -0,0 +1,16 @@ +--- +source: crates/pctx_codegen/tests/tool.rs +expression: impl_code +--- +export type ListFilesOutput = { + files: string[]; +}; + +/** + * List all files in the current workspace + */ +export async function listFiles(): Promise { + return await invokeCallback({ + id: "test_server.list_files", + }); +} diff --git a/crates/pctx_codegen/tests/snapshots/tool__test_no_input_or_output__fn_impl.ts.snap b/crates/pctx_codegen/tests/snapshots/tool__test_no_input_or_output__fn_impl.ts.snap new file mode 100644 index 0000000..9e2f431 --- /dev/null +++ b/crates/pctx_codegen/tests/snapshots/tool__test_no_input_or_output__fn_impl.ts.snap @@ -0,0 +1,12 @@ +--- +source: crates/pctx_codegen/tests/tool.rs +expression: impl_code +--- +/** + * Health check ping + */ +export async function ping(): Promise { + return await invokeCallback({ + id: "test_server.ping", + }); +} diff --git a/crates/pctx_codegen/tests/snapshots/tool__test_no_output__fn_impl.ts.snap b/crates/pctx_codegen/tests/snapshots/tool__test_no_output__fn_impl.ts.snap new file mode 100644 index 0000000..5724ce1 --- /dev/null +++ b/crates/pctx_codegen/tests/snapshots/tool__test_no_output__fn_impl.ts.snap @@ -0,0 +1,23 @@ +--- +source: crates/pctx_codegen/tests/tool.rs +expression: impl_code +--- +export type SendNotificationInput = { + user_id: string; + + message: string; + + priority?: "low" | "medium" | "high" | undefined; +}; + +/** + * Send a notification to a user + */ +export async function sendNotification( + input: SendNotificationInput, +): Promise { + return await invokeCallback({ + id: "test_server.send_notification", + arguments: input, + }); +} diff --git a/crates/pctx_codegen/tests/snapshots/tool__toolset__namespace_interface.ts.snap b/crates/pctx_codegen/tests/snapshots/tool__toolset__namespace_interface.ts.snap new file mode 100644 index 0000000..eddfb3f --- /dev/null +++ b/crates/pctx_codegen/tests/snapshots/tool__toolset__namespace_interface.ts.snap @@ -0,0 +1,58 @@ +--- +source: crates/pctx_codegen/tests/tool.rs +expression: toolset.namespace_interface(true) +--- +/** +* A collection of utility tools +*/ +namespace MyTools { + export type GetWeatherInput = { + location: string; + + units?: "celsius" | "fahrenheit" | undefined; +}; + + +export type GetWeatherOutput = { + temperature: number; + + condition: string; + + humidity?: number | undefined; +}; + + +/** +* Get the current weather for a given location +*/ +export async function getWeather(input: GetWeatherInput): Promise + +export type CreateDocumentInput = { + title: string; + + content: CreateDocumentInputContent; + + tags?: string[] | undefined; +}; + +export type CreateDocumentInputContent = { + body: string; + + format?: "markdown" | "html" | "plain" | undefined; +}; + + +export type CreateDocumentOutput = { + id: string; + + created_at: string; + + url?: string | undefined; +}; + + +/** +* Create a new document with metadata and content sections +*/ +export async function createDocument(input: CreateDocumentInput): Promise +} diff --git a/crates/pctx_codegen/tests/tool.rs b/crates/pctx_codegen/tests/tool.rs new file mode 100644 index 0000000..5027b43 --- /dev/null +++ b/crates/pctx_codegen/tests/tool.rs @@ -0,0 +1,91 @@ +use pctx_codegen::{RootSchema, Tool, ToolSet}; +use pctx_type_check_runtime::type_check; +use serde::Deserialize; + +const BASIC_TOOL: &str = include_str!("./fixtures/tools/basic.yml"); +const NESTED_TYPES_TOOL: &str = include_str!("./fixtures/tools/nested_types.yml"); +const NO_OUTPUT_TOOL: &str = include_str!("./fixtures/tools/no_output.yml"); +const NO_INPUT_TOOL: &str = include_str!("./fixtures/tools/no_input.yml"); +const NO_INPUT_OR_OUTPUT_TOOL: &str = include_str!("./fixtures/tools/no_input_or_output.yml"); +const ALL_OPTIONAL_INPUT_TOOL: &str = include_str!("./fixtures/tools/all_optional_input.yml"); + +#[derive(Debug, Deserialize)] +struct ToolFixture { + pub name: String, + pub description: Option, + pub input_schema: Option, + pub output_schema: Option, +} + +impl ToolFixture { + fn to_mcp_tool(&self) -> Tool { + Tool::new_mcp( + &self.name, + self.description.clone(), + self.input_schema.clone(), + self.output_schema.clone(), + ) + .expect("Tool::new_mcp failed") + } + + fn to_callback_tool(&self) -> Tool { + Tool::new_callback( + &self.name, + self.description.clone(), + self.input_schema.clone(), + self.output_schema.clone(), + ) + .expect("Tool::new_callback failed") + } +} + +fn load_fixture(yml: &str) -> ToolFixture { + serde_yaml::from_str(yml).expect("Failed to parse tool fixture YAML") +} + +// --- Tool tests --- + +macro_rules! tool_test { + ($test_name:ident, variant: $variant:ident, $fixture:expr) => { + #[tokio::test] + async fn $test_name() { + let fixture = load_fixture($fixture); + let tool = fixture.$variant(); + + let impl_code = pctx_codegen::format::format_ts(&tool.fn_impl("test_server")); + let check_res = type_check(&impl_code).await.expect("failed typecheck"); + + assert!( + check_res.success, + "tool fn_impl failed typecheck: {check_res:?}" + ); + insta::assert_snapshot!(format!("{}__fn_impl.ts", stringify!($test_name)), impl_code); + } + }; +} + +tool_test!(test_basic, variant: to_mcp_tool, BASIC_TOOL); +tool_test!(test_nested_types, variant: to_mcp_tool, NESTED_TYPES_TOOL); +tool_test!(test_no_output, variant: to_callback_tool, NO_OUTPUT_TOOL); +tool_test!(test_no_input, variant: to_callback_tool, NO_INPUT_TOOL); +tool_test!(test_no_input_or_output, variant: to_callback_tool, NO_INPUT_OR_OUTPUT_TOOL); +tool_test!(test_all_optional_input, variant: to_mcp_tool, ALL_OPTIONAL_INPUT_TOOL); + +// --- ToolSet tests --- + +#[test] +fn test_toolset_namespace() { + let basic = load_fixture(BASIC_TOOL); + let notif = load_fixture(NESTED_TYPES_TOOL); + + let toolset = ToolSet::new( + "my_tools", + "A collection of utility tools", + vec![basic.to_mcp_tool(), notif.to_callback_tool()], + ); + + insta::assert_snapshot!( + "toolset__namespace_interface.ts", + toolset.namespace_interface(true) + ); +} diff --git a/crates/pctx_codegen/tests/typegen.rs b/crates/pctx_codegen/tests/typegen.rs index 7f6de91..513a62e 100644 --- a/crates/pctx_codegen/tests/typegen.rs +++ b/crates/pctx_codegen/tests/typegen.rs @@ -1,10 +1,10 @@ -use pctx_codegen::case::Case; +use pctx_codegen::{RootSchema, case::Case}; use pctx_type_check_runtime::type_check; use serde::Deserialize; #[derive(Debug, Clone, Deserialize)] struct TypegenTest { - pub schema: serde_json::Value, + pub schema: RootSchema, pub tests: SchemaTests, } diff --git a/pctx-py/tests/scripts/manual_code_mode.py b/pctx-py/tests/scripts/manual_code_mode.py index 172c083..ed67142 100755 --- a/pctx-py/tests/scripts/manual_code_mode.py +++ b/pctx-py/tests/scripts/manual_code_mode.py @@ -14,6 +14,15 @@ def now_timestamp() -> float: return datetime.now().timestamp() +@tool +def search_logs(query: str = "", level: str = "info", limit: int = 100) -> list[dict]: + """Search application logs with optional filters""" + return [ + {"message": f"match for '{query}'", "level": level, "index": i} + for i in range(min(limit, 3)) + ] + + @tool("add", namespace="my_math") def add(a: float, b: float) -> float: """adds two numbers""" @@ -41,7 +50,7 @@ async def main(): async with Pctx( # url="https://....", # api_key="pctx_xxxx", - tools=[add, subtract, multiply, now_timestamp], + tools=[add, subtract, multiply, now_timestamp, search_logs], servers=[ { "name": "stripe", @@ -64,8 +73,11 @@ async def main(): let addval = await MyMath.add({a: 40, b: 2}); let subval = await MyMath.subtract({a: addval, b: 2}); let multval = await MyMath.multiply({a: subval, b: 2}); - let now = await Tools.nowTimestamp({}); + let now = await Tools.nowTimestamp(); let customers = await Stripe.listCustomers({}); + let logs = await Tools.searchLogs(); + let logs2 = await Tools.searchLogs({}); + let logs3 = await Tools.searchLogs({ query: "custom query" }); return { multval, now }; diff --git a/pctx-py/tests/test_integration.py b/pctx-py/tests/test_integration.py index 6bca20c..5db5bc8 100644 --- a/pctx-py/tests/test_integration.py +++ b/pctx-py/tests/test_integration.py @@ -307,7 +307,17 @@ def now_timestamp() -> float: """Returns current timestamp""" return datetime.now().timestamp() - async with Pctx(tools=[add_numbers, greet, now_timestamp]) as pctx: + @tool + def search_logs( + query: str = "", level: str = "info", limit: int = 100 + ) -> list[dict]: + """Search application logs with optional filters""" + return [ + {"message": f"match for '{query}'", "level": level, "index": i} + for i in range(min(limit, 3)) + ] + + async with Pctx(tools=[add_numbers, greet, now_timestamp, search_logs]) as pctx: # Verify tools are listed functions = await pctx.list_functions() function_names = [f"{f.namespace}.{f.name}" for f in functions.functions] @@ -321,15 +331,15 @@ def now_timestamp() -> float: assert "Tools.nowTimestamp" in function_names, ( f"now_timestamp tool should be registered, got: {function_names}" ) + assert "Tools.searchLogs" in function_names, ( + f"searchLogs tool should be registered, got: {function_names}" + ) # Test calling the add_numbers tool code = """ async function run() { - const sum = await Tools.addNumbers({ a: 10, b: 32 }); - console.log("Addition result:", sum); - const now = await Tools.nowTimestamp(null); - console.log("Now result:", now); - return { sum, now }; + const result = await Tools.addNumbers({ a: 10, b: 32 }); + return { sum: result }; } """ output = await pctx.execute(code) @@ -368,6 +378,50 @@ def now_timestamp() -> float: "Expected greeting to be 'Hi, Alice!'" ) + # Test calling the now_timestamp tool + code4 = """ + async function run() { + const result = await Tools.nowTimestamp(); + return { timestamp: result }; + } + """ + output4 = await pctx.execute(code4) + assert output4.success, "Fourth execution should succeed" + assert output4.output is not None, "output4 should have output" + assert isinstance(output4.output.get("timestamp"), float), ( + "Expected timestamp to be a float" + ) + + # Test calling search_logs - all optional params with defaults, and with explicit filters + code5 = """ + async function run() { + const noInput = await Tools.searchLogs(); + const empty = await Tools.searchLogs({}); + const filtered = await Tools.searchLogs({ query: "error", level: "error", limit: 1 }); + return { noInput, empty, filtered }; + } + """ + output5 = await pctx.execute(code5) + + assert output5.success, ( + f"search_logs should succeed. stderr: {output5.stderr}" + ) + assert output5.output is not None, "output5 should have output" + + for return_attr in ["noInput", "empty"]: + val = output5.output.get(return_attr) + assert len(val) == 3, ( + f"Expected 3 log entries with {return_attr}, got {len(val)}" + ) + assert val[0].get("level") == "info", "Expected default level 'info'" + + filtered = output5.output.get("filtered") + assert len(filtered) == 1, ( + f"Expected 1 log entry with limit=1, got {len(filtered)}" + ) + assert filtered[0].get("level") == "error", "Expected level 'error'" + assert "error" in filtered[0].get("message"), "Expected query in message" + except ConnectionError: pytest.fail( "Failed to connect to pctx server at http://localhost:8080.\n"