Custom type adapters —
teach the agent your project's types.
An RQL type adapter tells the agent how one of your types should be read, shown, written, compared and called. You write it in C# in an Editor assembly, register it with an IRqlModule, and from then on rql-query, rql-mutate, rql-schema and every tool parameter of that type understand it.
What a type adapter is
RQL reflects your types automatically: public fields, readable properties and, in Unity, private [SerializeField] fields are members the agent can query and set. An adapter changes that reflection model and the value semantics for one type family — an exact type, a base class (it applies to subclasses), an interface, or an open generic such as List<>. It never changes the language or the plan format, so queries stay portable.
Every applicable adapter takes part, most specific first. Members are configured from the least specific adapter to the most specific, so an adapter for your component refines what Unity-MCP's Component and Object adapters already set up. Each capability is optional: an adapter that does not implement one defers to the next adapter, then to RQL's built-in behaviour.
When you need one
- A small struct the agent should write as a literal — a range, a grid coordinate, a fixed-point number: compact
"(a, b)"input and output with approximate equality (NumericTupleAdapter<T>). - An opaque type the reflector cannot see well — a handle, an id wrapper, a value stored in a native object:
Encodefor output,TryCoercefor input,Equal/Orderforwhereandsort. - A getter with side effects (it allocates, instantiates or loads):
ExplicitOnlykeeps it reachable by path but never reads it implicitly. - Back-pointers and shared assets that generic
recurseshould not follow:Reference. Noise the agent should never see:HideorOnly. - Domain concepts that are not plain members — a computed value, a rate stored as an interval, a dictionary behind an API: virtual members (
Add) with optional validation, and virtual maps (Map). - Domain operations the agent should be able to call:
Method/Function, invoked with thecallstage.
You do not need an adapter for ordinary public or [SerializeField] members of your MonoBehaviours and ScriptableObjects, or for Unity's vectors, colors, quaternions, materials and particle-system modules — Unity-MCP already handles those.
Capabilities
| Capability | API (RqlTypeAdapter / MemberTable) | Affects |
|---|---|---|
| Members | Members(MemberTable<T>): Add, Hide, Only, IncludeFields, ReadOnly, Reference, ExplicitOnly, Describe | paths, where, select, set, describe, recurse |
| Methods | Method, Function, Expose(name), HideMethods | call stage, schema |
| Virtual maps | Map<TValue>(name, keys, get, set?, validate?) | M["k"] reads, set M["k"] = v |
| Encode | Encode(value, EncodeContext) | output cells |
| Coerce | TryCoerce(value, target, kernel, out result, out reason) → NotHandled / Ok / Failed | set values, call arguments |
| Compare | Equal(value, other, kernel), Order(value, other, kernel) | ==, !=, <, sort, min / max |
| Identity / liveness | Id(value), Alive(value), Identity(name), Display(name) | $ref, ref("Type#id"), stale-ref |
| Write-back | CopyOnRead(names), WritesThrough() | getters that return copies, handle structs |
| Describe | Description, LiteralSchema | rql-schema / describe |
| Numeric structs | NumericTupleAdapter<T>: Width, Part, Make, Expected, optional ParseExtra | "(x, y)" / [x, y] / {x, y} in, "(x, y)" out |
Normative rules: spec §13; API overview: docs/type-adapters.md.
How to write one in Unity
- Put adapters in Editor code. Unity-MCP ships the RQL assemblies for the Editor only, so place adapters in an Editor-only asmdef that references
com.IvanMurzak.Unity.MCP.Editor(with theUNITY_MCP_READYdefine constraint, as Unity-MCP's own extensions do). Your runtime types need no RQL reference at all — that is why adapters exist. - Subclass
RqlTypeAdapter<T>and override only what you need. For vector-like structs, subclassNumericTupleAdapter<T>. - Write an
IRqlModulethat adds your adapters (and optional sources, functions and a guide). Unity-MCP discovers every publicIRqlModulewith a parameterless constructor throughTypeCachewhen it builds its RQL engine — there is no registration call. - Recompile. Check the result with
rql-schema("YourType"), then try it withrql-queryandrql-mutate(dry_runfirst).
Worked example
A spawner component with a custom FloatRange struct. Without an adapter the agent would have to read and write FloatRange as an object with two fields. With the adapters below, it writes "(1, 3)" or [1, 3], compares with ==, sees a computed aliveCount, gets a validated spawnsPerMinute next to the raw interval, and can call SpawnNow().
1. Runtime code (unchanged)
// Assets/Scripts/EnemySpawner.cs — ordinary runtime code, no RQL reference.
using System;
using UnityEngine;
[Serializable]
public struct FloatRange
{
public float min;
public float max;
}
public class EnemySpawner : MonoBehaviour
{
public FloatRange spawnDelay = new FloatRange { min = 1, max = 3 };
public float interval = 2f;
[SerializeField] private string debugNotes = "";
public int CountAlive() => transform.childCount;
public void SpawnNow() { /* instantiate one enemy under this transform */ }
}2. An Editor-only assembly
{
"name": "MyGame.Editor.Rql",
"references": [
"com.IvanMurzak.Unity.MCP.Editor"
],
"includePlatforms": [
"Editor"
],
"autoReferenced": true,
"defineConstraints": [
"UNITY_MCP_READY"
]
}3. The adapters (Editor)
// Assets/Editor/Rql/MyGameRqlAdapters.cs — Editor-only (Unity-MCP ships RQL for the Editor).
#nullable enable
using Rql.Execution;
namespace MyGame.Editor.Rql
{
// FloatRange in and out as "(min, max)" or [min, max]; {min: 1, max: 3} also works.
public sealed class FloatRangeAdapter : NumericTupleAdapter<FloatRange>
{
public override string Description => "FloatRange: write as \"(min, max)\" or [min, max]";
protected override string Expected => "(min, max) or [min, max]";
protected override int Width => 2;
protected override float Part(FloatRange v, int i) => i == 0 ? v.min : v.max;
protected override FloatRange? Make(float[] f) =>
f.Length == 2 ? new FloatRange { min = f[0], max = f[1] } : (FloatRange?)null;
}
// EnemySpawner: hide a noisy field, add computed members and a callable method.
public sealed class EnemySpawnerAdapter : RqlTypeAdapter<EnemySpawner>
{
public override string Description =>
"EnemySpawner: spawnsPerMinute (writable, > 0), aliveCount (read-only); call SpawnNow()";
protected override void Members(MemberTable<EnemySpawner> m) =>
m.Hide("debugNotes")
.Add("aliveCount", s => s.CountAlive(), description: "enemies currently alive under this spawner")
.Add("spawnsPerMinute",
s => 60f / s.interval,
(s, v) => s.interval = 60f / v,
validate: (s, v) => v > 0f ? null : "spawnsPerMinute must be greater than 0",
description: "spawn rate; writes interval")
.Method("SpawnNow", s => s.SpawnNow(), "spawn one enemy now");
}
}4. Register them with a module
// Assets/Editor/Rql/MyGameRqlModule.cs
#nullable enable
using Rql.Execution;
namespace MyGame.Editor.Rql
{
// Unity-MCP finds every public IRqlModule with a parameterless constructor (TypeCache),
// so declaring the class is the registration.
public sealed class MyGameRqlModule : IRqlModule
{
public int Order => 200; // core is configured first; Unity-MCP's own extensions use 100
public void Configure(RqlModuleContext ctx) =>
ctx.AddAdapter(new FloatRangeAdapter())
.AddAdapter(new EnemySpawnerAdapter())
.AddGuide("My game", "EnemySpawner: spawnDelay is a FloatRange, write \"(min, max)\". " +
"spawnsPerMinute must be > 0. call SpawnNow() spawns one enemy.");
}
}5. What the agent can now do
objects | get components | where $ is EnemySpawner | select gameObject.name spawnDelay spawnsPerMinute aliveCount
objects | get components | where $ is EnemySpawner | where spawnDelay == "(1, 3)" | count
objects | get components | where $ is EnemySpawner | set spawnDelay = [0.5, 2], spawnsPerMinute = 30
objects | get components | where $ is EnemySpawner | call SpawnNow()Validation runs before any write: set spawnsPerMinute = 0 fails with coercion-failed and the reason “spawnsPerMinute must be greater than 0”, and nothing changes. Each mutation is one Undo step in the Editor.
How to test it
rql-schema("EnemySpawner")listsaliveCount,spawnsPerMinuteandSpawnNow()with their descriptions;debugNotesno longer appears.rql-schema("FloatRange")shows the literal hint.rql-mutatewithdry_run: truereturns the receipt ($ref,member,old,new) without writing.- From a terminal:
unity-mcp-cli run-tool rql-query --input '{"query": "objects | get components | where $ is EnemySpawner | select spawnDelay"}'. - In an EditMode test (a test asmdef that references
com.IvanMurzak.Unity.MCP.Editor), run queries throughRqlUnity.Engine— the same engine the tools use.RqlUnity.Reset()rebuilds it so modules are discovered again.
// Assets/Editor/Tests/EnemySpawnerRqlTests.cs — EditMode test assembly (Editor only)
using com.IvanMurzak.Unity.MCP.Editor.Rql;
using NUnit.Framework;
using Rql;
using Rql.Execution;
using UnityEditor.SceneManagement;
using UnityEngine;
public class EnemySpawnerRqlTests
{
const string S = "objects | get components | where $ is EnemySpawner";
[SetUp]
public void SetUp()
{
EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
RqlUnity.Reset(); // fresh engine: modules are discovered again
new GameObject("Spawner").AddComponent<EnemySpawner>();
}
static QueryResult Q(string rql) => RqlUnity.Engine.Execute(rql);
static QueryResult M(string rql) =>
RqlUnity.Engine.Execute(rql, null, new QueryOptions { AllowMutation = true });
static string Table(QueryResult r) => AgentFormat.Render(r, OutputFormat.Table);
[Test]
public void FloatRangeReadsAsATuple()
{
var r = Q(S + " | select spawnDelay");
Assert.IsTrue(r.Ok, Table(r));
Assert.AreEqual("(1, 3)", r.Rows![0][0]!.ToJsonString().Trim('"'));
}
[Test]
public void ListLiteralWritesTheStruct()
{
var r = M(S + " | set spawnDelay = [0.5, 2]");
Assert.IsTrue(r.Ok, Table(r));
Assert.AreEqual(0.5f, Object.FindAnyObjectByType<EnemySpawner>().spawnDelay.min);
}
[Test]
public void ValidationRejectsBeforeAnyWrite()
{
var r = M(S + " | set spawnsPerMinute = 0");
Assert.IsFalse(r.Ok, Table(r));
Assert.AreEqual("coercion-failed", r.Diagnostics[0].Code, Table(r));
}
}Rules that keep adapters safe
- Validate in
validate(orTryCoerce), not in the setter: validation runs while the mutation is planned, before any write. A setter that throws triggers the rollback. - Make virtual setters round-trip their getter. The rollback writes the old value back through the setter.
- Virtual setters on value types are rejected, because they would write to a copy. Put them on the owning class.
- Configuring a member that does not exist (including
Hide/Onlywith a wrong name) fails the query withinternal-errorthat names the member. - Adapters never call each other. For nested values, ask the kernel (
kernel.TryCoerce(value, typeof(Color), …),kernel.Equal) so whichever adapter handles that type is used. - Never expose a getter with side effects as a normal member. Use
ExplicitOnlyorHide. engine.Types.Explain(type)prints the resolved adapter order when two adapters disagree.
Tailoring the agent to your project
- RQL modules and adapters teach the data layer your types. Since every Unity tool parameter is an RQL value, an adapter also changes how your custom tools receive and return those types.
AddGuideputs project-specific instructions and example queries into the agent'sunity-rqlskill.AddSourceandAddFunctiongive it entry points such as amygamesource.- Custom MCP tools (
[AiTool]methods) remain the right choice for multi-step workflows that are more than reading and setting members. The AI Tools Template scaffolds a package with an RQL module, an example adapter and tests. See the tools catalog and extensions.