Skip to content

Tools & MCP

One derives clause serves three purposes: extraction, typed tool dispatch, and MCP export.

Defining tools

A tool is a named, described, schema-backed case class (typecast-tools):

import typecast.*
import typecast.tools.*

@desc("Look up the current weather for a city")
case class GetWeather(
    @desc("IATA city code") city: String,
    days: Int = 1 // defaults make arguments optional on the wire
) derives LlmTool

@desc("Book a meeting on the calendar")
case class BookMeeting(
    attendees: List[String],
    @desc("duration in minutes; must be positive") minutes: Int
) derives LlmTool
  • The wire-visible tool name is the kebab-cased type name (GetWeather"get-weather"); override with @toolName("...").
  • The type-level @desc is the tool description — prompt material, strongly recommended.
  • Argument schemas ride entirely on LlmSchema derivation; an existing given LlmSchema in scope (e.g. one carrying semantic validators) is reused.

Registering handlers and running

val tools = Tools(
  handle[GetWeather](w => ToolOutcome.Reply(weather.lookup(w.city, w.days))),
  handle[BookMeeting](m => ToolOutcome.Reply(calendar.book(m)))
    .validated("positive-minutes") { m =>
      if m.minutes > 0 then Vector.empty
      else Vector(FieldError(JsonPath.root / "minutes", s"${m.minutes} is not a positive duration"))
    }
)

val tc = TypeCast(transport, model = "claude-haiku-4-5")
val outcome = tc.withTools(tools).run(userMessage) // Either[ExtractionError, ToolRun]

Gotcha: withTools is an extension method — it requires import typecast.tools.* (typecast-core is the frozen base; the tools surface lives in its own module).

run is deliberately single-step: one tool selection (with retries as configured) + exactly one handler dispatch. Handlers never run on failed attempts — only on a fully validated terminal success. runWithReport returns the same ExtractionReport as any extraction. Multi-turn agent loops are out of scope for v1; compose run yourself (or use ToolRunner.runOnce for a one-shot functional shape).

A ToolRun carries the decoded call and what the handler returned; recover the static argument type by matching:

run.call.tool          // "get-weather"
run.call.args match
  case w: GetWeather  => ...
  case m: BookMeeting => ...
run.outcome            // ToolOutcome.Reply(text) | ToolOutcome.Data(js)

Design rationale: a tool call is just structured output

TypeCast has no native tool-call protocol. A registry of N tools compiles to one discriminated union schema — SUnion tagged by "tool", one variant per tool's argument object — and the model's reply travels through the exact same pipeline as any extraction: tolerant parse → schema-aligned repair → validation → strict decode → targeted retry.

That buys, with zero new machinery:

  • code fences / trailing commas / surrounding prose repaired at parse time,
  • fuzzy key and enum matching, unambiguous coercions, fuzzy tool-name resolution, and discriminator inference when the model omits "tool" but the argument shape uniquely identifies one variant,
  • unknown tools and malformed arguments reported as JSONPath-addressed errors that drive targeted retries.

Malformed tool arguments — a top agent-reliability complaint — get full repair/retry parity with extractions. Provider-native tool-use protocols are a documented future enhancement.

v1 limits: tool names must be unique (duplicates rejected at construction); argument types with colliding simple names share the definitions namespace (same caveat as LlmSchema).

MCP export

typecast-mcp exports a registry as a Model Context Protocol server in one line:

import typecast.mcp.McpServer

McpServer.from(tools).serveStdio() // blocks; serves newline-delimited JSON-RPC on stdin/stdout

McpServer.from(tools, name = "support-tools", version = "1.2.0") customizes the serverInfo; serve(in, out) targets arbitrary streams (that overload is what the test suite drives).

Scope: the stable core of MCP revision 2025-06-18 over stdio — initialize, ping, tools/list, tools/call — zero new dependencies, single-threaded, sequential. tools/call arguments go through the same parse → align → validate → decode path as everything else, so MCP clients get identical alignment and error rendering. Tool-execution failures return MCP's isError: true result (not a protocol error), per spec.

Plugging into Claude Code

Package a tiny main that builds your registry and serves stdio:

object McpMain:
  def main(args: Array[String]): Unit =
    McpServer.from(tools).serveStdio()

then register it in your project's .mcp.json (or via claude mcp add):

{
  "mcpServers": {
    "support-tools": {
      "command": "java",
      "args": ["-jar", "support-tools-mcp.jar"]
    }
  }
}

Claude Code will call tools/list to discover the derived schemas (names, @desc descriptions, argument types) and dispatch tools/call straight into your typed handlers.