use serde_json::Value;
const INDENT: &str = " ";
const MAX_REF_EXPANSIONS: usize = 32;
pub const DEFAULT_INPUT_SCHEMA_MAX_CHARS: usize = 16_000;
const TAG_KEYWORDS: [&str; 11] = [
"minimum",
"exclusiveMinimum",
"maximum",
"exclusiveMaximum",
"multipleOf",
"minLength",
"maxLength",
"pattern",
"format",
"minItems",
"maxItems",
];
const LOWER_BOUNDS: [&str; 4] = ["minimum", "exclusiveMinimum", "minLength", "minItems"];
const UPPER_BOUNDS: [&str; 4] = ["maximum", "exclusiveMaximum", "maxLength", "maxItems"];
const MAX_TAG_CHARS: usize = 80;
pub fn to_codemode_identifier(name: &str) -> String {
let mut identifier = String::new();
for ch in name.chars() {
let valid = if identifier.is_empty() {
ch.is_ascii_alphabetic() || ch == '_' || ch == '$'
} else {
ch.is_ascii_alphanumeric() || ch == '_' || ch == '$'
};
identifier.push(if valid { ch } else { '_' });
}
if identifier.is_empty() {
"_".to_string()
} else {
identifier
}
}
pub fn schema_to_type(schema: &Value, max_chars: Option<usize>) -> String {
let render = |tags: bool| {
let mut ctx = SchemaContext::new(schema);
ctx.tags = tags;
to_type(schema, &mut ctx)
};
let ty = render(true);
let Some(max) = max_chars else {
return ty;
};
if ty.chars().count() <= max {
return ty;
}
let untagged = render(false);
if untagged.chars().count() <= max {
untagged
} else {
"unknown".to_string()
}
}
pub fn render_tool_signature(name: &str, input: Option<&Value>, output: Option<&Value>) -> String {
let input = match input {
Some(schema) => schema_to_type(schema, Some(DEFAULT_INPUT_SCHEMA_MAX_CHARS)),
None => "unknown".to_string(),
};
let output = match output {
Some(schema) => schema_to_type(schema, None),
None => "unknown".to_string(),
};
format!(
"{}(args: {}): Promise<{}>;",
to_codemode_identifier(name),
input,
output
)
}
pub fn render_tool_sample(
name: &str,
description: &str,
guidelines: &[String],
input: Option<&Value>,
output: Option<&Value>,
) -> String {
let declaration = format!(
"declare const tools: {{ {} }};",
render_tool_signature(name, input, output)
);
let bullets: Vec<String> = guidelines
.iter()
.filter(|guideline| !guideline.trim().is_empty())
.map(|guideline| format!("- {}", guideline.trim()))
.collect();
let guidelines = if bullets.is_empty() {
String::new()
} else {
format!("\n\n{}", bullets.join("\n"))
};
format!(
"{}{guidelines}\n\ncodemode tool declaration:\n```ts\n{declaration}\n```",
description.trim(),
)
}
struct SchemaContext<'a> {
root: &'a Value,
resolving: Vec<String>,
expansions: usize,
tags: bool,
}
impl<'a> SchemaContext<'a> {
fn new(root: &'a Value) -> Self {
SchemaContext {
root,
resolving: Vec::new(),
expansions: 0,
tags: true,
}
}
}
fn resolve_ref<'a>(reference: &str, root: &'a Value) -> Option<&'a Value> {
if reference != "#" && !reference.starts_with("#/") {
return None;
}
let mut current = root;
for segment in reference[2..].split('/').filter(|s| !s.is_empty()) {
let key = segment.replace("~1", "/").replace("~0", "~");
current = current.as_object()?.get(&key)?;
}
Some(current)
}
fn union(types: Vec<String>) -> String {
let mut unique: Vec<String> = Vec::new();
for t in types {
if !unique.contains(&t) {
unique.push(t);
}
}
if unique.iter().any(|t| t == "unknown") {
return "unknown".to_string();
}
if unique.is_empty() {
return "never".to_string();
}
unique.join(" | ")
}
fn property_key(name: &str) -> String {
let mut chars = name.chars();
let valid = match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' || c == '$' => {
chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
}
_ => false,
};
if valid {
name.to_string()
} else {
Value::String(name.to_string()).to_string()
}
}
fn description_of(property: &Value) -> String {
property
.as_object()
.and_then(|o| o.get("description"))
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.unwrap_or_default()
}
fn array_type(schema: &serde_json::Map<String, Value>, ctx: &mut SchemaContext<'_>) -> String {
if let Some(items) = schema.get("items")
&& !items.is_array()
{
return format!("Array<{}>", to_type(items, ctx));
}
let tuple: &[Value] = match schema.get("prefixItems").and_then(|v| v.as_array()) {
Some(t) => t,
None => match schema.get("items").and_then(|v| v.as_array()) {
Some(t) => t,
None => &[],
},
};
if tuple.is_empty() {
return "unknown[]".to_string();
}
let parts: Vec<String> = tuple.iter().map(|item| to_type(item, ctx)).collect();
format!("[{}]", parts.join(", "))
}
fn constraint_tags(schema: &Value) -> String {
let Some(property) = schema.as_object() else {
return String::new();
};
let variants = property
.get("anyOf")
.and_then(Value::as_array)
.or_else(|| property.get("oneOf").and_then(Value::as_array));
let non_null: Vec<&Value> = variants
.map(|list| {
list.iter()
.filter(|variant| variant.get("type").and_then(Value::as_str) != Some("null"))
.collect()
})
.unwrap_or_default();
let mut branches: Vec<&serde_json::Map<String, Value>> = vec![property];
if non_null.len() == 1
&& let Some(branch) = non_null[0].as_object()
{
branches.push(branch);
}
let normalized: Vec<serde_json::Map<String, Value>> = branches
.iter()
.map(|branch| {
let mut out = (*branch).clone();
if out.get("exclusiveMinimum") == Some(&Value::Bool(true))
&& let Some(minimum) = out.get("minimum").cloned()
{
out.insert("exclusiveMinimum".to_string(), minimum);
out.remove("minimum");
}
if out.get("exclusiveMaximum") == Some(&Value::Bool(true))
&& let Some(maximum) = out.get("maximum").cloned()
{
out.insert("exclusiveMaximum".to_string(), maximum);
out.remove("maximum");
}
out
})
.collect();
let mut tags: Vec<String> = Vec::new();
let is_integer = |kind: Option<&Value>| match kind {
Some(Value::String(s)) => s == "integer",
Some(Value::Array(types)) => types.iter().any(|t| t.as_str() == Some("integer")),
_ => false,
};
if normalized
.iter()
.any(|branch| is_integer(branch.get("type")))
{
tags.push("@integer".to_string());
}
for keyword in TAG_KEYWORDS {
let values: Vec<&Value> = normalized.iter().filter_map(|b| b.get(keyword)).collect();
if LOWER_BOUNDS.contains(&keyword) || UPPER_BOUNDS.contains(&keyword) {
let numbers: Vec<f64> = values.iter().filter_map(|v| v.as_f64()).collect();
if numbers.is_empty() {
continue;
}
let bound = if LOWER_BOUNDS.contains(&keyword) {
numbers.iter().copied().fold(f64::NEG_INFINITY, f64::max)
} else {
numbers.iter().copied().fold(f64::INFINITY, f64::min)
};
tags.push(format!("@{keyword} {}", format_bound(bound)));
continue;
}
let mut seen: Vec<&Value> = Vec::new();
for value in values {
if !matches!(value, Value::Number(_) | Value::String(_)) || seen.contains(&value) {
continue;
}
seen.push(value);
tags.push(format!("@{keyword} {}", format_tag_value(value)));
}
}
if normalized
.iter()
.any(|branch| branch.get("uniqueItems") == Some(&Value::Bool(true)))
{
tags.push("@uniqueItems".to_string());
}
if let Some(default) = property.get("default").filter(|v| !v.is_null()) {
tags.push(format!("@default {default}"));
}
if property.get("deprecated") == Some(&Value::Bool(true)) {
tags.push("@deprecated".to_string());
}
tags.retain(|tag| tag.chars().count() <= MAX_TAG_CHARS);
tags.iter()
.map(|tag| escape_line_terminators(tag))
.collect::<Vec<_>>()
.join(" ")
}
fn format_bound(value: f64) -> String {
if value.fract() == 0.0 && value.abs() < 1e15 {
format!("{}", value as i64)
} else {
format!("{value}")
}
}
fn format_tag_value(value: &Value) -> String {
match value {
Value::String(s) => s.clone(),
other => other.to_string(),
}
}
fn escape_line_terminators(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for ch in text.chars() {
match ch {
'\r' | '\n' | '\u{2028}' | '\u{2029}' => out.push_str(&format!("\\u{:04x}", ch as u32)),
other => out.push(other),
}
}
out
}
fn object_type(schema: &serde_json::Map<String, Value>, ctx: &mut SchemaContext<'_>) -> String {
let empty = serde_json::Map::new();
let properties = schema
.get("properties")
.and_then(|v| v.as_object())
.unwrap_or(&empty);
let required: Vec<&str> = schema
.get("required")
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|v| v.as_str()).collect())
.unwrap_or_default();
let mut names: Vec<&String> = properties.keys().collect();
names.sort();
let mut members: Vec<String> = Vec::new();
let mut typed_names: Vec<&String> = Vec::new();
for name in &names {
let optional = if required.contains(&name.as_str()) {
""
} else {
"?"
};
members.push(format!(
"{}{optional}: {};",
property_key(name),
to_type(&properties[*name], ctx)
));
typed_names.push(name);
}
match schema.get("additionalProperties") {
Some(Value::Bool(false)) => {}
Some(Value::Bool(true)) => members.push("[key: string]: unknown;".to_string()),
Some(other) => members.push(format!("[key: string]: {};", to_type(other, ctx))),
None if names.is_empty() => members.push("[key: string]: unknown;".to_string()),
None => {}
}
if members.is_empty() {
return "{}".to_string();
}
let has_comments = typed_names.iter().any(|n| {
!description_of(&properties[*n]).is_empty()
|| (ctx.tags && !constraint_tags(&properties[*n]).is_empty())
});
if !has_comments {
return format!("{{ {} }}", members.join(" "));
}
let mut lines = vec!["{".to_string()];
for (index, name) in typed_names.iter().enumerate() {
for line in description_of(&properties[*name]).split(['\r', '\n', '\u{2028}', '\u{2029}']) {
if !line.trim().is_empty() {
lines.push(format!("{INDENT}// {}", line.trim()));
}
}
let tags = if ctx.tags {
constraint_tags(&properties[*name])
} else {
String::new()
};
if !tags.is_empty() {
lines.push(format!("{INDENT}// {tags}"));
}
lines.push(format!("{INDENT}{}", members[index]));
}
for member in members.iter().skip(typed_names.len()) {
lines.push(format!("{INDENT}{member}"));
}
lines.push("}".to_string());
lines.join("\n")
}
fn to_type(schema: &Value, ctx: &mut SchemaContext<'_>) -> String {
match schema {
Value::Bool(true) => return "unknown".to_string(),
Value::Bool(false) => return "never".to_string(),
Value::Object(_) => {}
_ => return "unknown".to_string(),
}
let obj = schema.as_object().expect("handled above");
if let Some(reference) = obj.get("$ref").and_then(|v| v.as_str()) {
if ctx.resolving.iter().any(|r| r == reference) || ctx.expansions >= MAX_REF_EXPANSIONS {
return "unknown".to_string();
}
let Some(target) = resolve_ref(reference, ctx.root) else {
return "unknown".to_string();
};
ctx.expansions += 1;
ctx.resolving.push(reference.to_string());
let rendered = to_type(&target.clone(), ctx);
ctx.resolving.pop();
return rendered;
}
if let Some(value) = obj.get("const") {
return value.to_string();
}
if let Some(values) = obj.get("enum").and_then(|v| v.as_array()) {
return union(values.iter().map(|v| v.to_string()).collect());
}
let variants = obj
.get("anyOf")
.and_then(|v| v.as_array())
.or_else(|| obj.get("oneOf").and_then(|v| v.as_array()));
if let Some(variants) = variants {
let parts: Vec<String> = variants.iter().map(|v| to_type(v, ctx)).collect();
return union(parts);
}
if let Some(parts) = obj.get("allOf").and_then(|v| v.as_array()) {
let rendered: Vec<String> = parts
.iter()
.map(|p| to_type(p, ctx))
.filter(|p| p != "unknown")
.collect();
if rendered.is_empty() {
return "unknown".to_string();
}
return rendered
.iter()
.map(|p| {
if p.contains(" | ") {
format!("({p})")
} else {
p.clone()
}
})
.collect::<Vec<_>>()
.join(" & ");
}
match obj.get("type") {
Some(Value::Array(types)) => {
let parts: Vec<String> = types
.iter()
.map(|t| {
let mut copy = obj.clone();
copy.insert("type".to_string(), t.clone());
to_type(&Value::Object(copy), ctx)
})
.collect();
union(parts)
}
Some(Value::String(t)) => match t.as_str() {
"string" => "string".to_string(),
"number" | "integer" => "number".to_string(),
"boolean" => "boolean".to_string(),
"null" => "null".to_string(),
"array" => array_type(obj, ctx),
"object" => object_type(obj, ctx),
_ => "unknown".to_string(),
},
_ => {
if obj.contains_key("properties")
|| obj.contains_key("additionalProperties")
|| obj.contains_key("required")
{
object_type(obj, ctx)
} else if obj.contains_key("items") || obj.contains_key("prefixItems") {
array_type(obj, ctx)
} else {
"unknown".to_string()
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn identifier_normalization() {
assert_eq!(
to_codemode_identifier("mcp__docs__search"),
"mcp__docs__search"
);
assert_eq!(to_codemode_identifier("my-tool"), "my_tool");
assert_eq!(to_codemode_identifier("9lives"), "_lives");
assert_eq!(to_codemode_identifier(""), "_");
}
#[test]
fn renders_primitives_literals_and_unions() {
assert_eq!(schema_to_type(&json!({"type": "string"}), None), "string");
assert_eq!(schema_to_type(&json!({"type": "integer"}), None), "number");
assert_eq!(
schema_to_type(&json!({"type": ["string", "null"]}), None),
"string | null"
);
assert_eq!(schema_to_type(&json!({"const": "a"}), None), "\"a\"");
assert_eq!(
schema_to_type(&json!({"enum": ["a", 1, null]}), None),
"\"a\" | 1 | null"
);
assert_eq!(
schema_to_type(
&json!({"anyOf": [{"type": "string"}, {"type": "number"}]}),
None
),
"string | number"
);
assert_eq!(
schema_to_type(&json!({"anyOf": [{"type": "string"}, {}]}), None),
"unknown"
);
assert_eq!(
schema_to_type(
&json!({"allOf": [{"anyOf": [{"type": "string"}, {"type": "number"}]}, {"const": 1}]}),
None
),
"(string | number) & 1"
);
assert_eq!(
schema_to_type(&json!({"$ref": "#/defs/x"}), None),
"unknown"
);
assert_eq!(schema_to_type(&json!(true), None), "unknown");
assert_eq!(schema_to_type(&json!(false), None), "never");
}
#[test]
fn renders_objects_on_one_line_sorted() {
assert_eq!(
schema_to_type(
&json!({
"type": "object",
"properties": {"city": {"type": "string"}, "max-lines": {"type": "number"}},
"required": ["city"],
"additionalProperties": false
}),
None
),
"{ city: string; \"max-lines\"?: number; }"
);
assert_eq!(
schema_to_type(
&json!({"type": "object", "additionalProperties": {"type": "number"}}),
None
),
"{ [key: string]: number; }"
);
assert_eq!(
schema_to_type(&json!({"type": "object"}), None),
"{ [key: string]: unknown; }"
);
assert_eq!(
schema_to_type(
&json!({"type": "object", "properties": {}, "additionalProperties": false}),
None
),
"{}"
);
}
#[test]
fn puts_property_descriptions_on_comment_lines() {
assert_eq!(
schema_to_type(
&json!({
"type": "object",
"properties": {
"weather": {
"type": "array",
"description": "look up weather for a given list of locations",
"items": {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}
}
},
"required": ["weather"]
}),
None
),
"{\n // look up weather for a given list of locations\n weather: Array<{ location: string; }>;\n}"
);
}
#[test]
fn renders_arrays_tuples_and_refs() {
assert_eq!(
schema_to_type(&json!({"type": "array", "items": {"type": "string"}}), None),
"Array<string>"
);
assert_eq!(
schema_to_type(
&json!({"type": "array", "prefixItems": [{"type": "string"}, {"type": "number"}]}),
None
),
"[string, number]"
);
assert_eq!(schema_to_type(&json!({"type": "array"}), None), "unknown[]");
assert_eq!(
schema_to_type(
&json!({
"$defs": {"name": {"type": "string"}},
"type": "object",
"properties": {"n": {"$ref": "#/$defs/name"}}
}),
None
),
"{ n?: string; }"
);
assert_eq!(
schema_to_type(
&json!({"$defs": {"n": {"properties": {"next": {"$ref": "#/$defs/n"}}}}, "$ref": "#/$defs/n"}),
None
),
"{ next?: unknown; }"
);
assert_eq!(
schema_to_type(&json!({"$ref": "https://example.com/x"}), None),
"unknown"
);
}
#[test]
fn long_input_type_becomes_unknown() {
let schema = json!({"type": "string"});
assert_eq!(schema_to_type(&schema, Some(3)), "unknown");
assert_eq!(schema_to_type(&schema, Some(100)), "string");
}
#[test]
fn puts_constraints_the_type_cannot_express_on_comment_lines() {
let schema = json!({
"type": "object",
"properties": {
"timeoutMs": {
"type": "integer",
"minimum": 0,
"maximum": 30000,
"default": 0,
"description": "Wait in milliseconds."
},
"id": {"type": "string", "pattern": "^[a-z]+$", "maxLength": 8},
"tags": {"type": "array", "items": {"type": "string"}, "maxItems": 5, "uniqueItems": true, "deprecated": true},
"name": {"type": "string"}
},
"required": ["id"]
});
assert_eq!(
schema_to_type(&schema, None),
[
"{",
" // @maxLength 8 @pattern ^[a-z]+$",
" id: string;",
" name?: string;",
" // @maxItems 5 @uniqueItems @deprecated",
" tags?: Array<string>;",
" // Wait in milliseconds.",
" // @integer @minimum 0 @maximum 30000 @default 0",
" timeoutMs?: number;",
"}",
]
.join("\n")
);
}
#[test]
fn reads_constraints_of_optional_fields_from_the_non_null_branch() {
let schema = json!({
"type": "object",
"properties": {
"limit": {
"anyOf": [{"type": "integer", "minimum": 1, "maximum": 100}, {"type": "null"}],
"default": null,
"title": "Limit"
}
}
});
assert_eq!(
schema_to_type(&schema, None),
"{\n // @integer @minimum 1 @maximum 100\n limit?: number | null;\n}"
);
let schema = json!({
"type": "object",
"properties": {
"n": {
"anyOf": [{"type": "integer", "minimum": 10, "maximum": 50, "format": "int32"}, {"type": "null"}],
"minimum": 0,
"maximum": 20,
"format": "port"
}
}
});
assert_eq!(
schema_to_type(&schema, None),
"{\n // @integer @minimum 10 @maximum 20 @format port @format int32\n n?: number | null;\n}"
);
}
#[test]
fn reads_draft04_boolean_exclusive_bounds() {
let schema = json!({
"type": "object",
"properties": {
"n": {"type": "integer", "minimum": 0, "exclusiveMinimum": true, "maximum": 10, "exclusiveMaximum": false},
"m": {"anyOf": [{"type": "number", "maximum": 5, "exclusiveMaximum": true}, {"type": "null"}], "maximum": 8}
}
});
assert_eq!(
schema_to_type(&schema, None),
[
"{",
" // @maximum 8 @exclusiveMaximum 5",
" m?: number | null;",
" // @integer @exclusiveMinimum 0 @maximum 10",
" n?: number;",
"}",
]
.join("\n")
);
}
#[test]
fn leaves_out_long_tags_and_escapes_line_terminators() {
let schema = json!({
"type": "object",
"properties": {"config": {"type": "object", "default": {"text": "x".repeat(100)}}}
});
assert_eq!(
schema_to_type(&schema, None),
"{ config?: { [key: string]: unknown; }; }"
);
let schema = json!({
"type": "object",
"properties": {
"sep": {"type": "string", "pattern": "^[\r\n\u{2028}\u{2029}]$", "default": "\u{2028}", "description": "a\u{2028}b"}
}
});
assert_eq!(
schema_to_type(&schema, None),
[
"{",
" // a",
" // b",
" // @pattern ^[\\u000d\\u000a\\u2028\\u2029]$ @default \"\\u2028\"",
" sep?: string;",
"}",
]
.join("\n")
);
}
#[test]
fn drops_constraint_tags_before_rendering_over_the_budget_as_unknown() {
let schema =
json!({"type": "object", "properties": {"n": {"type": "integer", "minimum": 1}}});
assert_eq!(
schema_to_type(&schema, None),
"{\n // @integer @minimum 1\n n?: number;\n}"
);
assert_eq!(schema_to_type(&schema, Some(20)), "{ n?: number; }");
assert_eq!(schema_to_type(&schema, Some(10)), "unknown");
}
#[test]
fn renders_signature_and_sample() {
assert_eq!(
render_tool_signature(
"my-tool",
Some(&json!({"type": "object", "properties": {"a": {"type": "string"}}})),
Some(&json!({"type": "boolean"}))
),
"my_tool(args: { a?: string; }): Promise<boolean>;"
);
assert_eq!(
render_tool_signature("bare", None, None),
"bare(args: unknown): Promise<unknown>;"
);
assert_eq!(
render_tool_sample(
"read",
"Read a file",
&[],
Some(&json!({ "type": "object" })),
None
),
"Read a file\n\ncodemode tool declaration:\n```ts\ndeclare const tools: { read(args: { [key: string]: unknown; }): Promise<unknown>; };\n```"
);
assert_eq!(
render_tool_sample(
"bash",
"Run a command",
&[
"Inspect PI_* environment variables.".to_string(),
" ".to_string(),
" Prefer short commands. ".to_string(),
],
Some(&json!({ "type": "object" })),
None
),
"Run a command\n\n- Inspect PI_* environment variables.\n- Prefer short commands.\n\ncodemode tool declaration:\n```ts\ndeclare const tools: { bash(args: { [key: string]: unknown; }): Promise<unknown>; };\n```"
);
}
}