spring-ai-playground

Safe Tool Specification

Version 1.0 · Status: stable for the 0.2.x line.

The Safe Tool Specification (this document) defines the on-disk JSON document format for a tool that Spring AI Playground’s Safe Local Execution Layer will load, validate, sandbox, and publish to Model Context Protocol clients. It is the artifact a tool author writes (directly or through Tool Studio’s form), the artifact the runtime reads to compute an enforced safety posture, and the artifact the audit log records on every invocation.

This document complements but does not replace:

1. Introduction

1.1 Scope

A Safe Tool Spec is a self-contained JSON document. It declares:

The spec is not concerned with how a tool is invoked through MCP, only with how a tool is defined. Invocation semantics belong to the MCP tools/list and tools/call schemas.

At a glance, a Safe Tool Spec is one JSON document that binds three concerns, which together earn a Local Pass before publish:

flowchart LR
    SPEC["Safe Tool Spec<br/>one JSON document"] --> ID["Identity<br/>name · description<br/>params"]
    SPEC --> CODE["Code<br/>JS + staticVariables"]
    SPEC --> SAFE["Safety posture<br/>sandboxOverrides<br/>→ Risk Level"]
    ID & CODE & SAFE --> PASS["Local Pass<br/>then /mcp"]

1.2 Terminology

The key words MUST, MUST NOT, SHOULD, SHOULD NOT, MAY, and OPTIONAL in this document are to be interpreted as described in RFC 2119 and RFC 8174 when, and only when, they appear in all capitals.

Throughout this document:

1.3 Conformance

A document conforms to this specification if:

  1. It parses as JSON (RFC 8259).
  2. Every field present validates against Section 16 JSON Schema.
  3. Every cross-field invariant defined in this document holds (notably the allow/deny disjointness in Section 10.1 and the env-var grammar in Section 7.2).

A resolver conforms if, given a conforming spec, it produces a toolSafety block that matches Section 10.3 and a Risk Level that matches the algorithm in Section 10.6.

A runtime conforms if it enforces the policy described by toolSafety - never more permissive, possibly less - and records what was actually enforced (see Section 11 audit contract).

1.4 Relationship to existing tool specs

Several schemas exist today to declare a tool an LLM can call: MCP tools/list, OpenAI function calling, Anthropic tool use, Google function declarations, and framework-internal formats like LangChain’s BaseTool or LlamaIndex’s FunctionTool. They are all narrower than this specification - they declare what the model is allowed to ask for, but leave how the tool runs and what guarantees apply outside the document. The Safe Tool Spec is built to carry both halves in one artifact.

Schema name + JSON-schema args Code body Safety posture Test value Persisted on disk
MCP tools/list - - - - (runtime emission only)
OpenAI function calling - - - -
Anthropic tool use - - - -
Google function declarations - - - -
LangChain BaseTool ✅ (Python class) partial (rate-limit / auth args) - - (the code is the spec)
LlamaIndex FunctionTool ✅ (Python callable) - - - (the code is the spec)
Safe Tool Spec (this doc) ✅ (JS string) ✅ (sandboxOverridestoolSafety) ✅ (testValue + Local Pass) ✅ (JSON file)

The pattern the other formats share: declare a function signature the model invokes, leave the implementation to host application code or framework conventions. The signature is the wire format the LLM consumes; the implementation lives outside the spec - in compiled code, in a framework’s registry, or in a hand-written request handler.

The gap they leave open:

1.4.1 How Safe Tool Spec composes with the wire formats

The Safe Tool Spec is not a replacement for MCP or function-calling schemas. It is a superset that the playground’s runtime projects down to those wire formats on the way out:

flowchart LR
    A["Safe Tool Spec<br/>(JSON on disk)"]
    R["SandboxPostureCalculator<br/>+ Local Pass gate"]
    M["MCP tools/list entry<br/>(name · description · JSON Schema)"]
    L["LLM tool call"]
    X["Runtime executes code<br/>under resolved toolSafety"]

    A -- "publish" --> R
    R -- "non-draft only" --> M
    M -- "wire" --> L
    L -- "tools/call" --> X
    X -- "audit toolSafety" --> A

What flows through each boundary:

The pattern is the same separation MCP itself draws: protocol vs. execution. MCP standardizes the wire; the Safe Tool Spec standardizes the on-disk artifact that produces the wire output, gates publication on Local Pass, and writes the enforcement record back into the audit log when the wire call returns.

1.4.2 What this specification is not

2. Document structure at a glance

A Safe Tool Spec is a JSON object that groups its fields into three conceptual blocks plus bookkeeping. The diagram below shows how the top-level fields cluster; Section 3 catalogues them in a single table.

flowchart TB
    SPEC["Safe Tool Spec<br/>(JSON document)"]

    subgraph IDENTITY["① Identity - what the model sees"]
        direction LR
        I1["toolId"]
        I2["name"]
        I3["description"]
        I4["params[]"]
        I5["category · tags[]"]
    end

    subgraph CODE["② Code - what the runtime executes"]
        direction LR
        C1["code"]
        C2["codeType"]
        C3["staticVariables[]<br/>(${ENV_VAR} placeholders)"]
    end

    subgraph SAFETY["③ Safety - what the sandbox enforces"]
        direction LR
        S1["sandboxOverrides<br/>(author intent)"]
        S2["toolSafety<br/>(resolved posture)"]
        S3["draft"]
        S4["humanInTheLoop"]
    end

    BK["createTimestamp · updateTimestamp"]

    SPEC --- IDENTITY
    SPEC --- CODE
    SPEC --- SAFETY
    SPEC --- BK

The three blocks correspond to three of the four product-positioning words from Section 1.1: Identity is “for AI Agent Tools” (the model-visible surface), Code is “Execution Layer” (the JS the runtime actually runs), Safety is “Safe” (what the sandbox guarantees). Sections 4-9 cover Identity and Code, Section 10 is the entire Safety block, Sections 11-12 cover lifecycle and bookkeeping.

The literal JSON shape:

{
  "toolId":            "<UUID v5 derived from name>",
  "name":              "<slug>",
  "description":       "<model-visible description>",
  "category":          "<category enum>",
  "tags":              ["<cohort label>", "..."],
  "params":            [ /* ToolParamSpec, see Section 6 */ ],
  "staticVariables":   [ /* {key: value} entries, see Section 7 */ ],
  "code":              "<JavaScript action body>",
  "codeType":          "Javascript",
  "sandboxOverrides":  { /* author intent, see Section 10.1 */ },
  "toolSafety":        { /* resolved posture, see Section 10.3 */ },
  "draft":             true,
  "createTimestamp":   <epoch ms>,
  "updateTimestamp":   <epoch ms>
}

All fields listed above except code, name, and codeType MAY be omitted; defaults are defined per Section 3 below.

3. Top-level object

The spec is a JSON object. Each field is defined in its own section. Defaults in this table govern serialization; consumers reading a spec MUST apply the same defaults when a field is absent or null.

Field Type Required Default Section
toolId string (UUID) SHOULD derived (Section 4.1) Section 4
name string MUST - Section 4.2
description string SHOULD empty string Section 5
category string SHOULD null Section 9.1
tags array of string MAY [] Section 9.2
params array of ToolParamSpec MAY [] Section 6
staticVariables array of single-entry objects MAY [] Section 7
code string MUST - Section 8
codeType enum string MUST - (only "Javascript" today) Section 8.1
sandboxOverrides object MAY empty overrides (baseline) Section 10.1
toolSafety object SHOULD empty {} Section 10.3
humanInTheLoop object MAY null (= DISABLED) Section 10.7
draft boolean MAY true (catalog), false after Local Pass Section 11
createTimestamp integer (epoch ms) SHOULD now Section 12.2
updateTimestamp integer (epoch ms) SHOULD now Section 12.2

Unknown top-level fields MUST be preserved on round-trip (load → save) and MUST NOT cause validation failure. This is the extension point for future minor versions; see Section 14.

4. Identity

4.1 toolId

A stable string identifier, normally a UUID v5 derived deterministically from name against a fixed namespace defined by the implementation.

4.2 name

The MCP tool name. This is what models see in tools/list and what they invoke in tools/call.

Implementations MAY enforce a stricter slug regex; consumers reading a foreign spec MUST NOT reject a non-empty string solely on slug grounds.

5. Description

description is the model-visible prose attached to the tool. It is the primary signal a model uses for tool selection and SHOULD therefore describe (in order of decreasing importance):

  1. What the tool does in one clause.
  2. Which arguments are required and what they mean.
  3. The shape of the response.

Descriptions in the bundled catalog follow conventions worth borrowing:

A description MUST NOT contain secrets, host names with embedded credentials, or environment-variable values; the audit log captures description verbatim.

6. Parameters

params is an ordered array of ToolParamSpec objects. Order is preserved by the catalog reader, by the persistence layer (see Section 12), and on the wire when the MCP server emits the tool’s JSON Schema.

6.1 ToolParamSpec shape

{
  "name":        "city",
  "description": "Name of the city",
  "required":    true,
  "type":        "STRING",
  "testValue":   "Seoul"
}
Field Type Required Notes
name string MUST Slug; identifies the argument in the model’s tools/call payload
description string SHOULD Model-visible argument hint
required boolean MUST If true, the runtime refuses to execute without this argument
type STRING · INTEGER · NUMBER · BOOLEAN · OBJECT · ARRAY MUST Stored uppercase; see Section 6.2
testValue string MUST when required=true Sample value the Local Pass executes the tool with

6.2 Type enum and JSON Schema mapping

The type field is serialized in the spec document in uppercase ("STRING"). When the MCP server emits the tool’s JSON Schema for a model, it lowers the value to its JSON Schema spelling ("string"). The asymmetry is intentional: the spec document is the authoring artifact, and uppercase names match the Java enum that backs them; the JSON Schema is the wire format the LLM consumes.

Spec value JSON Schema value
STRING string
INTEGER integer
NUMBER number
BOOLEAN boolean
OBJECT object
ARRAY array

OBJECT and ARRAY MAY be used. Models sometimes serialize an object as a JSON-string into a STRING-typed param when the agent loop does not support nested schemas; tools accepting structured input SHOULD document both call patterns in description.

6.3 testValue contract

testValue is not metadata: it is the value the Local Pass actually runs the tool with. A spec whose testValues are placeholder garbage publishes a tool whose only validated execution path is garbage.

7. Static variables

staticVariables is the spec’s mechanism for server-side configuration: values the tool reads at execution time but the model never sees. It is the right place for API keys, account IDs, base URLs, and any other input the author controls but the agent does not.

7.1 Shape and ordering

"staticVariables": [
  { "naverClientId":     "${NAVER_CLIENT_ID}" },
  { "naverClientSecret": "${NAVER_CLIENT_SECRET}" }
]

staticVariables is an ordered list of single-entry objects, not a JSON object. Order is preserved on disk, in memory, and when the runtime constructs the variable bag passed to code. The ordered-list shape exists to permit duplicate keys (rare but legal - later wins on read), to keep deterministic diffs when specs are edited, and to make ${ENV_VAR} audit trails reproducible.

7.2 ${ENV_VAR} placeholder grammar

A value MAY embed environment variable references using the placeholder grammar \$\{([A-Z_]+[A-Z0-9_]*)}:

Resolution order (EnvVarResolver):

  1. System.getenv(name) - process environment.
  2. System.getProperty(name) - JVM system properties (fallback).
  3. Unresolved - the literal ${NAME} is left in place and the spec transitions to MISSING_REQUIREMENTS (Section 11.2).

The resolver MUST treat unset, empty, or whitespace-only values as missing. Implementations MAY layer additional resolution sources (a project-local secret store, a vault) ahead of the OS env, but the contract above is the floor: every conforming resolver MUST consult the OS env at minimum.

7.3 Secret storage

The Safe Tool Spec defines a resolution contract (Section 7.2), not a storage contract. The on-disk storage of resolved static-variable values is constrained to one rule:

Secret surface Storage model Encryption at rest Decryption scope
Static ${ENV_VAR} secrets (this section) OS environment / JVM properties None (the playground does not persist them) n/a - value is only in memory while the process holds it

Static-variable secrets are deliberately not persisted by the playground. The resolution model places trust at the host boundary: if the OS env (or JVM properties) holds the value, the playground reads it for the lifetime of one tool invocation, masks it on output (Section 7.4), and forgets it when the process exits. A spec’s staticVariables block records only the placeholder, never the resolved value.

Implementations of this specification SHOULD adopt the same posture: do not persist static-variable secrets at all, and if persisting other credentials (OAuth tokens, MCP-connection bearer tokens, …) on a separate surface, encrypt them with a host-bound or user-bound key so that disk-copy alone is not sufficient to recover plaintext. The reference runtime’s OAuth-token storage is documented at safety-architecture → Encrypted OAuth token storage - it is a separate surface and outside this specification.

7.4 Secret masking pipeline

Once resolved, a static-variable value is treated as a secret for the rest of its lifetime in the process. Masking is value-based, not placeholder-based - the runtime tracks the resolved string and substring-replaces every occurrence of it with *** on the way to any text egress.

The contract has two operations:

Operation Behavior
Collect Walk every ${NAME} reference in the template, resolve each via the env-var resolver (Section 7.2), and collect values of length ≥ 4 into a Set<String> of secrets. Values shorter than 4 characters MUST be excluded from the set to avoid masking incidental words.
Mask Substring-replace each member of the secret set with *** on the egress text. The replacement MUST be plain string substitution - no regex, no partial-prefix matching, no structural awareness of the surrounding text.

Properties of this pipeline that implementations MUST preserve:

Egress points a conformant implementation MUST cover:

A resolver-conformant runtime that adds new text-egress channels (Slack notifier, error reporter, telemetry sink) MUST extend the masking call to those channels as well. For the reference runtime’s wiring of these egress points (class names, call sites, mermaid), see safety-architecture → Secret masking.

7.5 Both injection paths are first-class

A spec may declare a staticVariables entry with a literal value ("clientId": "12345-abc") for a tool that does not need a secret, or with a ${ENV_VAR} placeholder for a tool that does. Catalog conventions strongly prefer placeholders for any value that looks like a secret - both because of the storage posture above and because masking only applies to values that came through a placeholder. A hard-coded secret literal is not automatically masked, since the masking pipeline has no way to distinguish “secret hard-coded in spec” from “URL fragment hard-coded in spec.” Consumers MUST NOT assume the placeholder vs literal distinction beyond what the value itself declares.

8. Code

code is the JavaScript action body. The runtime evaluates it in a sandboxed GraalVM Polyglot Context with all variables from params, staticVariables, and the host-injected safety.* helpers in scope.

8.1 codeType

codeType is an enum with a single accepted value today:

Value Meaning
Javascript The body in code is JavaScript executed by GraalJS, with ECMAScript 2024 syntax support.

codeType is enumerated rather than free-form to leave the door open for future runtimes (Python, Wasm) without ambiguous content sniffing.

8.2 Runtime contract

8.3 The safety.* helper surface

When the resolved posture grants the corresponding capability, the runtime exposes the following helpers. The version tag in toolSafety.runtime.helpers[] (Section 10.3) records which helpers the spec was authored against; any new major version (e.g. safety.fs/v2) is a breaking change at the helper level and MUST trigger a spec version bump.

Helper Required posture Purpose
safety.fs/v1 (read group) capabilities.fileRead = true readText, list, exists, stat, grep, lineCount, slice, cut, sort, find - all rooted at fsBasePath with path-escape protection
safety.fs/v1 (write) capabilities.fileWrite = true writeText only
safety.parser/v1 (or tool-safety-helpers/v1#parser) always available Jsoup HTML, SnakeYAML load, RFC 4180 CSV, DTD/XXE-hardened XML - see Section 8.4 for the per-helper contract and known security caveats
safety.http/v1 capabilities.network.mode != "blocked" Outbound HTTP via fetch with the SSRF four-layer guard active in strict mode (in allowlist mode only the explicit host allow-list is enforced - no IP/DNS-rebind guard)
tool-safety-helpers/v1#crypto always available The crypto.subtle API and related primitives
tool-safety-helpers/v1#encoding always available atob / btoa plus TextEncoder / TextDecoder

Two helper-string conventions are in active use. Both are normative and may be mixed within a single spec:

Tools authored against v1 MUST list every helper group they use in toolSafety.runtime.helpers; a runtime MAY refuse to publish a spec that references a helper version it cannot provide.

8.4 Parser helpers

The four parser entry points live under safety.parser.* and are exposed whenever the runtime declares safety.parser/v1 (or tool-safety-helpers/v1#parser) in its helper set:

Call Behavior
safety.parser.html(input) Jsoup parse with default settings. ⚠ Returns the host org.jsoup.nodes.Document directly (not wrapped in a plain proxy tree like XML / CSV / YAML); JS code can call jsoup methods on the returned object. Implementations MAY wrap the return to match the proxy-tree convention. See safety-architecture → safety.parser.html returns host Document.
safety.parser.yaml(input) SnakeYAML load. ⚠ Reference runtime uses default Constructor (not SafeConstructor) - !!class.name tags trigger class instantiation; implementations SHOULD use SafeConstructor, and consumers MUST treat untrusted YAML input as security-relevant. See safety-architecture → safety.parser.yaml constructor choice.
safety.parser.csv(input, opts?) RFC 4180 CSV with optional {header, delimiter}
safety.parser.xml(input) DTD/XXE-hardened DocumentBuilder

9. Categorization

9.1 category

category is a single-string label used for UI grouping in the catalog browser. It is not enforced as an enum at the document level - consumers MUST accept arbitrary string values - but the bundled catalog defines and uses the following 13 values:

TEXT · DATA · DATETIME · MATH · ENCODING · CRYPTO · SECURITY · FILE · WEB · PRODUCTIVITY · MESSAGING · AI · CUSTOM

Catalog-conformant authors SHOULD pick from the list above. Authors publishing private specs MAY introduce new categories; consumers presenting an unknown category MUST render it as a string verbatim.

9.2 tags

tags are cohort labels distinct from category. Where category answers “what does the tool do?tags answers “what cohort does it belong to?

9.3 Locale rule

Specs published in a multilingual catalog MUST follow these locale rules. The rules apply uniformly to every non-English locale (Korean, Japanese, Chinese, Arabic, Hebrew, Thai, …) so that machine-readable fields stay English while human-targeted examples can carry locale-bound content:

10. Safety

The two sandbox-related blocks below are the core of this specification, plus a third per-call approval block (humanInTheLoop, Section 10.7). The first two look similar but serve opposite directions:

Block Direction Editable by Stored verbatim
sandboxOverrides Author intent (declarative) Tool Studio’s Sandbox & Capabilities pane Yes
toolSafety Runtime enforcement (resolved) Computed by the resolver Yes (informational)

Implementations MUST treat sandboxOverrides as the author’s declared widening of the baseline; the resolver MUST compute toolSafety from sandboxOverrides + the configured baseline policy.

10.1 sandboxOverrides shape

"sandboxOverrides": {
  "addAllowClasses":    [],
  "removeAllowClasses": [],
  "addDenyClasses":     [],
  "removeDenyClasses":  [],
  "networkMode":        "allowlist",
  "hostsAllow":         ["api.upbit.com"],
  "fileRead":           null,
  "fileWrite":          null,
  "fsBasePath":         null
}
Field Type Tristate? Meaning of absent / null
addAllowClasses array of Java class names no empty array - baseline allowlist unchanged
removeAllowClasses array of Java class names no empty array - baseline allowlist unchanged
addDenyClasses array of Java class names no empty array - baseline denylist unchanged
removeDenyClasses array of Java class names no empty array - baseline denylist unchanged
networkMode enum (Section 10.4) yes inherit baseline (default = blocked)
hostsAllow array of hostnames no empty - no hosts; ["*"] is the wildcard sentinel
fileRead boolean OR null yes inherit baseline (default = false)
fileWrite boolean OR null yes inherit baseline (default = false)
fsBasePath string OR null yes inherit baseline path

Notes:

10.2 Resolution algorithm

The reference resolver (SandboxPostureCalculator) computes the enforced posture from sandboxOverrides plus the configured baseline. The two inputs flow through merge and tristate-coalesce steps and emerge as the toolSafety block:

flowchart LR
    OV["sandboxOverrides<br/>(author intent)"]
    BL["baseline policy<br/>(application.yaml)"]
    CALC["Resolver<br/>(compute toolSafety)"]

    subgraph STEPS["Resolution"]
        direction TB
        M1["1 · merge allow/deny<br/>(baseline ∪ add) - remove"]
        M2["2 · disjointness check<br/>allow ∩ deny = ∅"]
        M3["3 · tristate coalesce<br/>networkMode · fileRead · fileWrite · fsBasePath"]
        M4["4 · resolve hosts<br/>(when networkMode = allowlist)"]
    end

    TS["toolSafety block<br/>(audit-logged on every call)"]

    OV --> CALC
    BL --> CALC
    CALC --> STEPS
    STEPS --> TS

Pseudocode:

input:  overrides : SandboxOverrides
        baseline  : { allowClasses, denyClasses, fsBasePath, networkMode, allowedHosts, fileRead, fileWrite }

step 1  effectiveAllow = (baseline.allow ∪ overrides.addAllow) - overrides.removeAllow
step 2  effectiveDeny  = (baseline.deny  ∪ overrides.addDeny ) - overrides.removeDeny
step 3  if effectiveAllow ∩ effectiveDeny ≠ ∅ → reject (resolver error)
step 4  effectiveNetwork = overrides.networkMode ?? baseline.networkMode      (tristate)
step 5  effectiveHosts   = overrides.hostsAllow ∪ baseline.allowedHosts        when network=allowlist; else []
step 6  effectiveFileR   = overrides.fileRead   ?? baseline.fileRead           (tristate)
step 7  effectiveFileW   = overrides.fileWrite  ?? baseline.fileWrite          (tristate)
step 8  effectiveBase    = overrides.fsBasePath ?? baseline.fsBasePath
step 9  populate toolSafety = {
            version: "1.0",
            runtime: { id, minVersion, ecmaVersion, javaInterop, helpers, console },
            category: { source, id },
            capabilities: {
              network: { mode: effectiveNetwork, hosts: effectiveHosts },
              fileRead: effectiveFileR,
              fileWrite: effectiveFileW
            }
        }

The algorithm is monotonic with respect to risk: nothing in sandboxOverrides can make the baseline less permissive than its already-allowed reach (that would be a no-op or a reduction). Removals from the baseline denylist are escalations; removals from the baseline allowlist are restrictions. See Section 10.6 for how this drives Risk Level.

10.3 toolSafety shape

"toolSafety": {
  "version": "1.0",
  "runtime": {
    "id":            "spring-ai-playground/polyglot-js",
    "minVersion":    "0.2.0",
    "ecmaVersion":   "2024",
    "javaInterop":   false,
    "helpers":       ["safety.http/v1"],
    "console":       true
  },
  "category": {
    "source": "builtin",
    "id":     "WEB"
  },
  "capabilities": {
    "network": { "mode": "allowlist", "hosts": ["api.upbit.com"] },
    "fileRead":  false,
    "fileWrite": false
  }
}
Path Type Notes
version string Spec-schema version this block was written against. Today: "1.0".
runtime.id string Stable runtime identifier. Today: "spring-ai-playground/polyglot-js".
runtime.minVersion string (semver) Minimum Spring AI Playground version that can execute the tool
runtime.ecmaVersion string "2024" for v1
runtime.javaInterop boolean Whether the tool reaches into host JVM classes
runtime.helpers array of "<namespace>/v<n>" strings Versioned helper surface the spec relies on
runtime.console boolean Whether console.log is bound (output still passes env-var masking)
category.source string "builtin" for catalog specs, "user" for Tool Studio specs, or a custom origin
category.id string Resolved category (see Section 9.1)
capabilities.network.mode enum (Section 10.4) Resolved network mode
capabilities.network.hosts array of hostnames Resolved egress allow list
capabilities.fileRead boolean Resolved read capability
capabilities.fileWrite boolean Resolved write capability

toolSafety is the auditable record of what the runtime is committed to enforce. The audit log records this block per invocation; downstream consumers SHOULD treat it as authoritative for “what posture was active at this call.”

Implementation note. In the reference Spring AI Playground runtime (v0.2.x), toolSafety is written by Tool Studio at publish-time but is not re-derived on every load - the persisted block is the writer’s last snapshot. Downstream consumers that need byte-fresh policy MUST re-run the resolver against sandboxOverrides rather than trusting toolSafety for enforcement decisions on a foreign spec.

10.4 Network mode behavioral table

capabilities.network.mode takes one of four values. Each defines a distinct fetch behavior. The SSRF four-layer guard (DNS pinning, IP-range filter, redirect-chain pinning, response-body size cap) is active in strict only. allowlist enforces an explicit host allow-list - internal-network hosts may be included - but does not perform IP-range / DNS-rebind guarding; open bypasses everything. For a local single-user tool this is the right split: use allowlist for hosts you trust (vendor APIs, an internal service) and strict for untrusted public hosts:

Mode fetch exposed? Host gate SSRF guard When to use
blocked no n/a n/a Tool does no network - the safe default; the fetch global is not installed at all.
allowlist yes only hosts in capabilities.network.hosts (internal-network hosts allowed) off - host allow-list only, no IP/DNS-rebind guard Tool talks to one or more trusted hosts (vendor APIs or an internal service). For untrusted public hosts use strict.
strict yes any public host active Tool talks to arbitrary public hosts but the playground enforces SSRF guards on every request.
open yes any host including private networks bypassed Strongly discouraged; should never appear in a published catalog spec. Authoring private tools on a trusted host only.

The default at the baseline level is blocked. Authors who do not declare networkMode in sandboxOverrides publish a tool that cannot reach the network.

10.5 File access behavioral table

fileRead fileWrite safety.fs/v1 exposed? Notes
false (or null inheriting false) false not exposed The helper is not even installed in the runtime’s safety object.
true false exposed, read-only group writeText throws; all other fs.* work, scoped under fsBasePath.
false true exposed, write-only Only writeText works; all other fs.* throw.
true true exposed, full All fs.* work.

fsBasePath is the root the helper enforces. Any path argument the tool passes to fs.* is resolved relative to fsBasePath and then re-normalized; arguments that escape (.. traversal) MUST be refused with a SECURITY JsHelperException.

10.6 Risk Level

toolSafety is human-readable; Risk Level is the UI-friendly distillation. Levels run from L0 (no detected risk) to L5 (escape-class allowed). The reference resolver computes Risk Level as a monotonic max-merge:

risk := L0
if capabilities.network.mode == "allowlist":
    risk := max(risk, hosts contains "*" ? L4 : L3)
elif capabilities.network.mode == "strict":  risk := max(risk, L3)
elif capabilities.network.mode == "open":    risk := max(risk, L4)
if fileWrite:                                risk := max(risk, L4)
elif fileRead:                               risk := max(risk, L3)

for cls in (baseline.deny - sandboxOverrides.removeDenyClasses):
    if cls matches System|Runtime|Process|ProcessBuilder:  risk := max(risk, L5)
if |removed-from-baseline-deny| ≥ 3:                       risk := max(risk, L4)
elif |removed-from-baseline-deny| ≥ 1:                     risk := max(risk, L3)

for cls in (sandboxOverrides.addAllowClasses - baseline.allow):
    if cls is critical (System / Runtime / Process):       risk := max(risk, L5)
    elif cls is FileWrite-related:                         risk := max(risk, L5)
    elif cls is reflection / network / FileRead-related:   risk := max(risk, L4)
    else:                                                  risk := max(risk, L3)

The Risk Level is computed for UI badging and audit-log decoration. Implementations MUST NOT store the computed level in toolSafety itself - Risk Level is a view on the posture, not a property of it. If the algorithm changes, recomputing yields a different answer from the same toolSafety; this is intentional.

10.7 Human-in-the-loop approval { #human-in-the-loop }

The optional humanInTheLoop block declares whether a tool call must be confirmed by a person at call time. It is independent of the sandbox (which decides what a tool may do) and of the Risk Level (which is observational): this block decides whether a specific call runs at all. Where the sandbox and risk score are evaluated before publication, this gate fires on every invocation.

{
  "humanInTheLoop": {
    "mode": "REQUIRED",
    "promptTemplate": "Allow '{toolName}' to run with {args}?"
  }
}
Field Type Required Meaning
mode enum no DISABLED · REQUIRED. Absent or the whole block nullDISABLED.
promptTemplate string | null no Approval-prompt text. {toolName} and {args} are substituted at call time (flat substitution - {args} is the whole argument map; there is no dotted-path form like {args.path}). Null ⇒ a built-in default prompt.

The two modes:

Enabling this block does not change the tool’s computed Risk Level - the two are orthogonal. The runtime enforcement (the two gates, the loopback de-duplication, and the fail-safe behavior) is specified in Human-in-the-Loop Approval; this section only defines the on-disk shape.

11. Lifecycle

11.1 States

A spec is in exactly one of the following states at any time:

stateDiagram-v2
    [*] --> DRAFT : new spec / import from catalog

    DRAFT --> ACTIVE : Local Pass earned<br/>+ env vars resolved
    DRAFT --> MISSING_REQUIREMENTS : draft cleared<br/>but env vars missing
    DRAFT --> TEST_FAILED : Local Pass attempted<br/>and failed (reserved)

    MISSING_REQUIREMENTS --> ACTIVE : env vars set
    MISSING_REQUIREMENTS --> DRAFT : draft flag re-raised

    ACTIVE --> DRAFT : draft flag re-raised<br/>(e.g. spec edit)
    ACTIVE --> MISSING_REQUIREMENTS : env var unset at runtime

    TEST_FAILED --> DRAFT : edit + retry

    DRAFT : not exposed via MCP
    MISSING_REQUIREMENTS : not exposed via MCP
    TEST_FAILED : not exposed via MCP
    ACTIVE : exposed via built-in MCP server
State Condition MCP exposure
DRAFT draft == true (or spec == null) not exposed
MISSING_REQUIREMENTS any ${ENV_VAR} referenced by staticVariables resolves to unset / empty / whitespace-only not exposed
ACTIVE draft == false AND every env-var reference resolves exposed via the built-in MCP server
TEST_FAILED reserved not exposed

TEST_FAILED is reserved for future use; the reference resolver never returns it from the current calculator. Drafts MAY exist with arbitrary or empty toolSafety - the runtime does not enforce posture invariants until the spec is published.

11.2 Env-var requirement check

Before publishing, the runtime walks every staticVariables value, extracts each ${VAR} placeholder, and verifies the OS environment defines a non-blank value for it. The check uses the placeholder grammar from Section 7.2.

11.3 Local Pass - the publish gate

draft flips from true to false only when the spec earns its Local Pass: a successful test run with the declared testValues, executed in the same sandbox the published tool will run in, with the resolved toolSafety posture in effect.

11.4 Audit contract

Every invocation MUST record (at minimum):

The audit record is the source of truth for “what was actually enforced.” Implementations MAY append additional fields (cid, request id, MCP client metadata).

12. Persistence

12.1 File layout

The reference implementation persists user-authored specs into a single bundle file under the playground’s home directory:

~/spring-ai-playground/tool/save/toolSpecsMcpSetting.json

The bundle file contains both the spec list and the MCP server settings:

{
  "toolSpecs": [ /* spec, spec, ... */ ],
  "toolMcpServerSetting": { /* MCP transport + autoAdd flag */ }
}

Specs that originate from the bundled catalog (src/main/resources/tool/default-tool-specs-*.json) are excluded from the bundle on save - they are reloaded from the classpath on startup, with user overrides matched by toolId and merged on top.

Implementations are free to choose a different file layout (one file per spec, sharded by category, database-backed) as long as the round-trip JSON shape of each spec conforms to this specification.

12.2 Atomic write contract

Writers MUST commit changes atomically:

  1. Serialize the bundle to a sibling temp file (toolSpecsMcpSetting.json.tmp).
  2. renameSync the temp file over the target. POSIX rename guarantees atomicity within the same filesystem.

Readers MUST read after the rename completes - a writer that crashes mid-rename leaves the previous bundle intact.

createTimestamp is set once when the spec is first written; updateTimestamp is updated on every subsequent persist. Both are epoch milliseconds.

12.3 Catalog mirror invariant (build-time)

The Spring AI Playground build ships the catalog twice:

The two MUST be byte-identical. The build is responsible for enforcing this (the reference build uses prepare-resources.mjs); the spec format itself is silent on it. Catalog publishers consuming this spec independently MAY omit the mirror requirement.

13. Versioning policy

The version namespace lives in toolSafety.version (today: "1.0"). The bump rules:

Helper-level versions (safety.fs/v1safety.fs/v2) are independent of the spec version; they bump the helper-namespace number when their JS API surface changes. A spec MAY mix v1 and v2 helpers from different namespaces.

14. Extension points

Unknown top-level fields MUST be preserved on round-trip. This is the dedicated extension surface - a future minor version can introduce new fields without invalidating today’s documents.

Implementations adding their own fields SHOULD:

Custom additions inside sandboxOverrides, toolSafety, or params[] are out of scope for this version - those blocks have closed shapes today. Future minor versions may open named extension sub-objects within them.

15. Validation and error model

Validation has three layers:

  1. Document validation - does the spec parse and conform to the JSON Schema (Section 16)?
  2. Cross-field validation - do the invariants in Section 6 (requiredtestValue present), Section 7 (env-var grammar), Section 10.1 (allow/deny disjointness) hold?
  3. Runtime validation - does the resolver accept the spec, and does the Local Pass succeed?

Validation errors SHOULD be reported with at least:

The reference runtime classifies helper errors as INVALID_INPUT, HELPER_RUNTIME, or SECURITY; the spec layer adds the four codes above.

16. JSON Schema

A normative JSON Schema 2020-12 document is bundled alongside this page:

safe-tool-spec.schema.json{download}

Validate any candidate spec by loading the schema and checking it with a 2020-12 compatible validator (ajv, jsonschema, python-jsonschema).

17. Canonical examples

The bundled catalog ships every example variant below. Each is shown abbreviated; the full version is in src/main/resources/tool/default-tool-specs-*.json.

17.1 Pure compute - base64

No network, no filesystem, no env. The baseline sandboxOverrides (all-null) is sufficient.

{
  "toolId": "e30d037d-20cf-55f2-b43a-1b89560417da",
  "name": "base64",
  "description": "Encodes UTF-8 text to base64, or decodes base64 back to UTF-8 text. Use mode='encode' (default) or 'decode'.",
  "category": "ENCODING",
  "tags": ["util"],
  "params": [
    { "name": "text", "type": "STRING", "required": true,  "testValue": "hello world", "description": "Text to encode/decode" },
    { "name": "mode", "type": "STRING", "required": false, "testValue": "encode",      "description": "encode | decode" }
  ],
  "staticVariables": [],
  "code": "/* ... */",
  "codeType": "Javascript",
  "sandboxOverrides": {},
  "toolSafety": {
    "version": "1.0",
    "runtime": { "id": "spring-ai-playground/polyglot-js", "javaInterop": false, "helpers": [], "console": true },
    "capabilities": { "network": { "mode": "blocked", "hosts": [] }, "fileRead": false, "fileWrite": false }
  },
  "draft": false
}

Risk Level: L0.

17.2 Single-host network - getUpbitTicker

allowlist mode with one host. No env-backed secret; the upstream API is unauthenticated.

{
  "name": "getUpbitTicker",
  "category": "WEB",
  "tags": ["korea"],
  "params": [{ "name": "markets", "type": "STRING", "required": true, "testValue": "KRW-BTC,KRW-ETH",
              "description": "Comma-separated KRW markets (e.g. 'KRW-BTC,KRW-ETH')" }],
  "staticVariables": [],
  "sandboxOverrides": {
    "networkMode": "allowlist",
    "hostsAllow":  ["api.upbit.com"]
  },
  "toolSafety": {
    "version": "1.0",
    "runtime":  { "id": "spring-ai-playground/polyglot-js", "javaInterop": false, "helpers": ["safety.http/v1"], "console": true },
    "capabilities": { "network": { "mode": "allowlist", "hosts": ["api.upbit.com"] }, "fileRead": false, "fileWrite": false }
  }
}

Risk Level: L3 (non-wildcard allowlist).

17.3 Env-backed multi-secret - searchNaver

Two env-backed credentials, allowlist mode.

{
  "name": "searchNaver",
  "category": "WEB",
  "tags": ["korea"],
  "params": [
    { "name": "query", "type": "STRING", "required": true, "testValue": "스프링 AI",
      "description": "Korean queries typical (e.g. '스프링 AI'); other languages also accepted." }
  ],
  "staticVariables": [
    { "naverClientId":     "${NAVER_CLIENT_ID}" },
    { "naverClientSecret": "${NAVER_CLIENT_SECRET}" }
  ],
  "sandboxOverrides": {
    "networkMode": "allowlist",
    "hostsAllow":  ["openapi.naver.com"]
  }
}

Without both env vars set, the spec sits in MISSING_REQUIREMENTS and is not exposed (Section 11.2).

17.4 Strict-mode external HTTP - extractPageContent

Tool fetches arbitrary user-supplied URLs; SSRF guard runs in strict mode.

{
  "name": "extractPageContent",
  "category": "WEB",
  "tags": ["util"],
  "params": [
    { "name": "url", "type": "STRING", "required": true, "testValue": "https://example.com" }
  ],
  "sandboxOverrides": { "networkMode": "strict" },
  "humanInTheLoop": {
    "mode": "REQUIRED",
    "promptTemplate": "The assistant wants to fetch and read a web page. Allow '{toolName}' with {args}?"
  }
}

Risk Level: L3 (strict). This is the bundled extractPageContent, which ships humanInTheLoop.mode = REQUIRED - every call is confirmed before the fetch runs (Section 10.7).

17.5 Filesystem read - readTextFile

No network, scoped read access to the configured fsBasePath.

{
  "name": "readTextFile",
  "category": "FILE",
  "params": [
    { "name": "path", "type": "STRING", "required": true, "testValue": "README.md" }
  ],
  "sandboxOverrides": { "fileRead": true }
}

Risk Level: L3 (read only).

17.6 Filesystem write - writeTextFile

Write access. Highest risk level the bundled catalog ships.

{
  "name": "writeTextFile",
  "category": "FILE",
  "params": [
    { "name": "path",    "type": "STRING", "required": true, "testValue": "notes.txt" },
    { "name": "content", "type": "STRING", "required": true, "testValue": "hello" }
  ],
  "sandboxOverrides": { "fileWrite": true }
}

Risk Level: L4 (write).

17.7 Object-typed argument - evalExpression

Demonstrates OBJECT parameter type. Models that cannot pass nested JSON drop down to STRING and pre-serialize.

{
  "name": "evalExpression",
  "category": "MATH",
  "params": [
    { "name": "expr",      "type": "STRING", "required": true,  "testValue": "x + 2 * y" },
    { "name": "variables", "type": "OBJECT", "required": false, "testValue": "{\"x\":3,\"y\":4}",
      "description": "Variable bindings (JSON-stringified object: {\"x\":3,\"y\":4})" }
  ]
}

17.8 Draft - unpublished

A spec freshly imported from the catalog ships with draft: true. Until activation (by preset + rules), it remains invisible to MCP.

{ "name": "experimentalThing", "code": "/* ... */", "codeType": "Javascript", "draft": true }

18. References

19. Document history

Version Date Notes
1.0 2026-05-20 Initial publication. Codifies the shape shipping in Spring AI Playground 0.2.0-M7.