diff --git a/codex-plugin/.agents/plugins/marketplace.json b/codex-plugin/.agents/plugins/marketplace.json new file mode 100644 index 00000000000..ae38399a347 --- /dev/null +++ b/codex-plugin/.agents/plugins/marketplace.json @@ -0,0 +1,12 @@ +{ + "name": "spacetimedb-plugins", + "interface": { "displayName": "SpacetimeDB" }, + "plugins": [ + { + "name": "spacetimedb", + "source": { "source": "local", "path": "./plugins/spacetimedb" }, + "policy": { "installation": "AVAILABLE", "authentication": "ON_USE" }, + "category": "Developer Tools" + } + ] +} diff --git a/codex-plugin/LICENSE b/codex-plugin/LICENSE new file mode 100644 index 00000000000..982d9c5e676 --- /dev/null +++ b/codex-plugin/LICENSE @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025 Clockwork Labs, Inc + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/codex-plugin/README.md b/codex-plugin/README.md new file mode 100644 index 00000000000..cdf5a78ff1a --- /dev/null +++ b/codex-plugin/README.md @@ -0,0 +1,118 @@ +# SpacetimeDB Codex Plugin + +A Codex plugin for building on SpacetimeDB: the core data model, the `spacetime` CLI workflow, and +per-language skills for modules and clients in Rust, C#, TypeScript, C++, Unity, and Unreal. It +also registers an MCP server, so the agent can list your databases, read schemas, run SQL, and +call reducers with your CLI login. + +Use this when an app needs a real-time backend: tables and reducers, subscriptions, typed client +bindings, multiplayer state, or a running database the agent should inspect and operate. + +## Install + +```bash +codex plugin marketplace add clockworklabs/SpacetimeDB --sparse .agents --sparse codex-plugin +codex plugin add spacetimedb@spacetimedb-plugins +``` + +The sparse flags fetch just the catalog and the plugin instead of the whole repository. + +From a local checkout, run them from the repository root with `.` as the source. Confirm with +`codex plugin list`. Update with `codex plugin marketplace upgrade`, then re-run the add command. + +## Example asks + +```text +Set up a new SpacetimeDB module in Rust. +Make the player table public and explain what that changes. +Wire a React client to my database with typed bindings. +List my SpacetimeDB databases and show the schema for chat. +Run SELECT * FROM message on my database. +My client sees no rows. Is the table private? +``` + +## The skills + +Eleven skills, each loaded on demand when its `description` matches the task: + +| Skill | Loads when | +| --- | --- | +| `concepts` | learning SpacetimeDB or making an architectural decision | +| `cli` | running the `spacetime` CLI | +| `mcp` | inspecting or operating a live database through the MCP tools | +| `rust-server` | writing tables and reducers in Rust | +| `csharp-server` | writing tables and reducers in C# | +| `typescript-server` | writing tables and reducers in TypeScript | +| `cpp-server` | writing tables and reducers in C++ | +| `typescript-client` | building a TypeScript or React client | +| `csharp-client` | building a C# or .NET client | +| `unity` | building a Unity client | +| `unreal` | building an Unreal Engine client | + +Two rules trip up agents most, and the `concepts` skill states them directly: tables are private +by default (clients cannot even see them), and clients change data only by calling reducers, never +by writing tables directly. The per-language skills cover the exact syntax, the common mistakes, +and the CLI workflow. + +## Live database tools (MCP) + +The plugin registers one MCP server, and it needs no configuration: + +```json +{ "spacetimedb": { "command": "spacetime", "args": ["mcp"] } } +``` + +Two pieces of the repository back this. The host serves MCP over HTTP at `/v1/mcp`, reusing the +same auth and internals as the rest of the API. The `spacetime mcp` subcommand is a stdio to HTTP +bridge to that route, because agents launch MCP servers over stdio. + +`spacetime mcp` with no arguments serves the host-wide `/v1/mcp` endpoint of your default server, +and the agent names a database per call: `list_databases` shows the ones you own, then +`get_schema`, `sql`, and `call` each take a `database` argument. Everything runs with your +`spacetime login` identity. + +To pin a single database instead, pass it (`"args": ["mcp", "mydb"]`) or set `SPACETIMEDB_DB_NAME`. +The tools then drop the `database` argument. + +To use the same server from Claude Desktop or Claude Code, add the command to your own config, +wrapped in a top-level `mcpServers` key, the same shape as this plugin's `.mcp.json`: + +```json +{ "mcpServers": { "spacetimedb": { "command": "spacetime", "args": ["mcp"] } } } +``` + +> Heads up: `spacetime mcp` is UNSTABLE and may not be in your released CLI yet. Build it from this +> repo if needed. If the command is missing, the MCP server simply fails to start and the skills +> keep working on their own. + +## Repo contents + +- `.agents/plugins/marketplace.json`: the catalog for installing from this directory. A matching + one at the repository root serves the `clockworklabs/SpacetimeDB` path. +- `plugins/spacetimedb/`: the plugin payload, holding the manifest, `.mcp.json`, `LICENSE`, + assets, and the skills. +- `plugins/spacetimedb/skills/`: a copy of the repository's `skills/` directory. + +## Privacy & data + +This plugin collects no data and sends no telemetry. The skills are plain text that the agent +reads, and they run nothing on your machine. The MCP server is the only part that uses the +network, and it connects only to the SpacetimeDB server you choose, using the login you already +have. + +## Maintaining + +The skills are a copy of the repository's `skills/`, because a plugin must be self-contained (a +symlink installs empty). After changing `skills/`, re-sync: + +```bash +# from SpacetimeDB/codex-plugin/ +rm -rf plugins/spacetimedb/skills && cp -R ../skills plugins/spacetimedb/skills +``` + +Then bump `version` in `plugin.json`, since installs are cached by version. Keep the two catalogs +identical apart from `source.path`, which must never point at `./`. + +## License + +Apache-2.0. See `LICENSE`. diff --git a/codex-plugin/plugins/spacetimedb/.codex-plugin/plugin.json b/codex-plugin/plugins/spacetimedb/.codex-plugin/plugin.json new file mode 100644 index 00000000000..100888ecf5d --- /dev/null +++ b/codex-plugin/plugins/spacetimedb/.codex-plugin/plugin.json @@ -0,0 +1,54 @@ +{ + "name": "spacetimedb", + "version": "0.1.0", + "description": "SpacetimeDB skills for Codex: the data model, the spacetime CLI, and server and client SDK guidance for Rust, C#, TypeScript, C++, Unity, and Unreal. Includes an MCP server that lists your databases, reads schemas, runs SQL, and calls reducers with your CLI login.", + "author": { + "name": "Clockwork Labs", + "url": "https://spacetimedb.com" + }, + "license": "Apache-2.0", + "homepage": "https://spacetimedb.com", + "repository": "https://github.com/clockworklabs/SpacetimeDB", + "keywords": [ + "spacetimedb", + "database", + "realtime", + "reducer", + "subscriptions", + "sql", + "wasm", + "multiplayer", + "rust", + "typescript", + "csharp", + "unity", + "unreal", + "mcp" + ], + "skills": "./skills/", + "mcpServers": "./.mcp.json", + "interface": { + "displayName": "SpacetimeDB", + "shortDescription": "Skills to build apps, tools to operate your databases", + "longDescription": "Packages SpacetimeDB's agent skills so Codex can build and operate SpacetimeDB apps: private-by-default tables, reducers as the write path, the spacetime CLI, and server and client SDK references for Rust, C#, TypeScript, C++, Unity, and Unreal. Includes an MCP server that serves your default host, so the agent can list your databases, read schemas, run SQL, and call reducers with your CLI identity.", + "developerName": "Clockwork Labs", + "category": "Developer Tools", + "capabilities": [ + "List databases", + "Read schemas", + "Run SQL", + "Call reducers", + "Module development", + "CLI workflows" + ], + "websiteURL": "https://spacetimedb.com", + "defaultPrompt": [ + "Set up a new SpacetimeDB module in Rust.", + "List my SpacetimeDB databases.", + "Show me the schema for my database." + ], + "composerIcon": "./assets/icon.png", + "logo": "./assets/logo.png", + "brandColor": "#060606" + } +} diff --git a/codex-plugin/plugins/spacetimedb/.mcp.json b/codex-plugin/plugins/spacetimedb/.mcp.json new file mode 100644 index 00000000000..c093ab2581a --- /dev/null +++ b/codex-plugin/plugins/spacetimedb/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "spacetimedb": { + "command": "spacetime", + "args": ["mcp"] + } + } +} diff --git a/codex-plugin/plugins/spacetimedb/LICENSE b/codex-plugin/plugins/spacetimedb/LICENSE new file mode 100644 index 00000000000..982d9c5e676 --- /dev/null +++ b/codex-plugin/plugins/spacetimedb/LICENSE @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025 Clockwork Labs, Inc + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/codex-plugin/plugins/spacetimedb/assets/icon.png b/codex-plugin/plugins/spacetimedb/assets/icon.png new file mode 100644 index 00000000000..6a4f9c56213 Binary files /dev/null and b/codex-plugin/plugins/spacetimedb/assets/icon.png differ diff --git a/codex-plugin/plugins/spacetimedb/assets/logo.png b/codex-plugin/plugins/spacetimedb/assets/logo.png new file mode 100644 index 00000000000..6a4f9c56213 Binary files /dev/null and b/codex-plugin/plugins/spacetimedb/assets/logo.png differ diff --git a/codex-plugin/plugins/spacetimedb/skills/cli/SKILL.md b/codex-plugin/plugins/spacetimedb/skills/cli/SKILL.md new file mode 100644 index 00000000000..534ad7ee4ec --- /dev/null +++ b/codex-plugin/plugins/spacetimedb/skills/cli/SKILL.md @@ -0,0 +1,189 @@ +--- +name: cli +description: SpacetimeDB CLI reference for initializing projects, building modules, publishing databases, querying data, and managing servers +license: Apache-2.0 +metadata: + author: clockworklabs + version: "2.0" + role: shared + language: all + cursor_globs: "**/*" + cursor_always_apply: false +triggers: + - spacetime init + - spacetime build + - spacetime publish + - spacetime dev + - spacetime sql + - spacetime call + - spacetime logs + - spacetime server + - spacetime login + - spacetime generate + - how do I use the CLI + - CLI command +--- + +# SpacetimeDB CLI + +Use this skill when the user needs help with the `spacetime` CLI tool - initializing projects, building modules, publishing databases, querying data, managing servers, or troubleshooting CLI issues. + +## Quick Reference + +### Project Initialization & Development + +```bash +# Initialize new project +spacetime init my-project --lang rust|csharp|typescript|cpp +spacetime init my-project --template + +# Build module +spacetime build # release build +spacetime build --debug # faster iteration, slower runtime + +# Dev mode (auto-rebuild, auto-publish, generates bindings) +spacetime dev +spacetime dev --client-lang typescript --module-bindings-path ./client/src/module_bindings + +# Generate client bindings +spacetime generate --lang typescript|csharp|rust --out-dir ./bindings --module-path ./server +spacetime generate --lang unrealcpp --uproject-dir ./MyGame --module-path ./server --unreal-module-name MyGame +``` + +### Publishing & Deployment + +```bash +# Publish to Maincloud (default) +spacetime publish my-database --yes + +# Publish to local server +spacetime publish my-database --server local --yes + +# Clear database and republish +spacetime publish my-database --delete-data=always --yes +``` + +### Database Interaction + +```bash +# SQL queries +spacetime sql my-database "SELECT * FROM users" +spacetime sql my-database --interactive # REPL mode + +# Call reducers (each argument is a separate positional arg) +spacetime call my-database my_reducer '"value"' '123' + +# Subscribe to changes +spacetime subscribe my-database "SELECT * FROM users" --num-updates 10 + +# View logs +spacetime logs my-database -f # follow logs +spacetime logs my-database -n 100 # up to 100 log lines + +# Describe schema +spacetime describe my-database --json +spacetime describe my-database table users --json +spacetime describe my-database reducer my_reducer --json +``` + +### Database Management + +```bash +# List databases +spacetime list + +# Delete database +spacetime delete my-database + +# Rename database +spacetime rename --to new-name +``` + +### Server Management + +```bash +# List configured servers +spacetime server list + +# Add server +spacetime server add local --url http://localhost:3000 --default +spacetime server add myserver --url https://my-spacetime.example.com + +# Set default server +spacetime server set-default local + +# Test connectivity +spacetime server ping local + +# Start local instance +spacetime start + +# Clear local data +spacetime server clear +``` + +### Authentication + +```bash +# Login (opens browser) +spacetime login + +# Login with token +spacetime login --token + +# Show login status +spacetime login show + +# Logout +spacetime logout +``` + +## Default Servers + +| Name | URL | Description | +|------|-----|-------------| +| `maincloud` | `https://maincloud.spacetimedb.com` | Production cloud (default) | +| `local` | `http://127.0.0.1:3000` | Local development server | + +## Common Flags + +| Flag | Short | Description | +|------|-------|-------------| +| `--server` | `-s` | Target server (nickname, hostname, or URL) | +| `--yes` | `-y` | Non-interactive mode (skip confirmations) | +| `--anonymous` | | Use anonymous identity | +| `--module-path` | `-p` | Path to module project | + +## Troubleshooting + +### "Not logged in" +```bash +spacetime login +# Or use --anonymous for public operations +``` + +### "Server not responding" +```bash +spacetime server ping +# For local: ensure spacetime start is running +``` + +### "Schema conflict" +```bash +# Clear data and republish +spacetime publish my-db --delete-data=always --yes +``` + +### "Build failed" +```bash +# Check Rust/C# toolchain +rustup show +# For Rust modules, ensure wasm32-unknown-unknown target +rustup target add wasm32-unknown-unknown +``` + +## Module Languages + +**Server-side (modules):** Rust, C#, TypeScript, C++ +**Client SDKs:** TypeScript, C#, Rust, Unreal Engine +**CLI `generate` targets:** TypeScript, C#, Rust, Unreal C++ diff --git a/codex-plugin/plugins/spacetimedb/skills/concepts/SKILL.md b/codex-plugin/plugins/spacetimedb/skills/concepts/SKILL.md new file mode 100644 index 00000000000..8ea6e8f9cfb --- /dev/null +++ b/codex-plugin/plugins/spacetimedb/skills/concepts/SKILL.md @@ -0,0 +1,108 @@ +--- +name: concepts +description: Understand SpacetimeDB architecture and core concepts. Use when learning SpacetimeDB or making architectural decisions. +license: Apache-2.0 +metadata: + author: clockworklabs + version: "2.0" + role: shared + language: all + cursor_globs: "**/*" + cursor_always_apply: true +--- + +# SpacetimeDB Core Concepts + +SpacetimeDB is a relational database that is also a server. It lets you upload application logic directly into the database as modules, eliminating the traditional web/game server layer entirely. Rust, C#, and C++ modules compile to WebAssembly, while TypeScript modules run on V8. + +--- + +## Critical Rules + +1. **Reducers are transactional.** They do not return data to callers. Use subscriptions to read data. +2. **Reducers must be deterministic.** Do not use filesystem, network, external clocks, or external random sources in reducers. Use the reducer context (`ctx`) for SpacetimeDB-provided timestamp and deterministic random values. +3. **Read data via tables/subscriptions**, not reducer return values. Clients get data through subscribed queries. +4. **Auto-increment IDs are not sequential.** Gaps are normal, do not use for ordering. Use timestamps or explicit sequence columns. +5. **`ctx.sender` is the authenticated principal.** Never trust identity passed as arguments. + +--- + +## Feature Implementation Checklist + +1. **Backend:** Define table(s) to store the data +2. **Backend:** Define reducer(s) to mutate the data +3. **Client:** Subscribe to the table(s) +4. **Client:** Call the reducer(s) from UI +5. **Client:** Render the data from the table(s) + +--- + +## Debugging Checklist + +1. Is SpacetimeDB server running? (`spacetime start`) +2. Is the module published? (`spacetime publish`) +3. Are client bindings generated? (`spacetime generate`) +4. Check server logs for errors (`spacetime logs `) +5. Is the reducer actually being called from the client? + +--- + +## Tables + +- **Private tables** (default): Only accessible by reducers and the database owner. +- **Public tables**: Exposed for client read access through subscriptions. Writes still require reducers. + +Organize data by access pattern, not by entity: + +``` +Player PlayerState PlayerStats +id <-- player_id player_id +name position_x total_kills + position_y total_deaths + velocity_x play_time +``` + +## Reducers + +Reducers are transactional functions that modify database state. They run atomically, cannot interact with the outside world, and do not return data to callers. See the language-specific server skills for syntax. + +## Event Tables + +Event tables broadcast reducer-specific data to clients. Rows are never stored in the client cache (`count()` returns 0, `iter()` yields nothing); only `onInsert` callbacks fire. + +## Subscriptions + +Subscriptions replicate database rows to clients in real-time. + +1. **Subscribe**: Register SQL queries describing needed data +2. **Receive initial data**: All matching rows are sent immediately +3. **Receive updates**: Real-time updates when subscribed rows change +4. **React to changes**: Use callbacks (`onInsert`, `onDelete`, `onUpdate`) + +Best practices: +- Group subscriptions by lifetime +- Subscribe before unsubscribing when updating subscriptions +- Avoid overlapping queries +- Use indexes for efficient queries + +## Modules + +Modules contain application logic that runs inside the database. + +- **Tables**: Define the data schema +- **Reducers**: Define callable functions that modify state +- **Event Tables**: Broadcast reducer-specific data to clients +- **Views**: Read-only functions that expose computed subsets of data to clients +- **Procedures**: (Unstable) Functions that can have side effects (HTTP requests, `ctx.withTx`) + +Server-side modules can be written in: Rust, C#, TypeScript, C++ + +Lifecycle: Write → Compile → Publish (`spacetime publish`) → Hot-swap (republish without disconnecting clients) + +## Identity + +- **Identity**: A long-lived, globally unique identifier for a user. +- **ConnectionId**: Identifies a specific client connection. +- Always use `ctx.sender` / `ctx.Sender` / `ctx.sender()` for authorization. + +SpacetimeDB works with many OIDC providers, including SpacetimeAuth (built-in), Auth0, Clerk, Keycloak, Google, and GitHub. diff --git a/codex-plugin/plugins/spacetimedb/skills/cpp-server/SKILL.md b/codex-plugin/plugins/spacetimedb/skills/cpp-server/SKILL.md new file mode 100644 index 00000000000..8d270e93080 --- /dev/null +++ b/codex-plugin/plugins/spacetimedb/skills/cpp-server/SKILL.md @@ -0,0 +1,277 @@ +--- +name: cpp-server +description: SpacetimeDB C++ server module SDK reference. Use when writing tables, reducers, or module logic in C++. +license: Apache-2.0 +metadata: + author: clockworklabs + version: "2.0" + role: server + language: cpp + cursor_globs: "**/*.cpp,**/*.h,**/*.hpp" + cursor_always_apply: true +--- + +# SpacetimeDB C++ SDK Reference + +## Imports + +```cpp +#include +using namespace SpacetimeDB; +``` + +## Tables + +Register structs with macros, then declare as tables: + +```cpp +struct Entity { + uint64_t id; + Identity owner; + std::string name; + bool active; +}; +SPACETIMEDB_STRUCT(Entity, id, owner, name, active) +SPACETIMEDB_TABLE(Entity, entity, Public) +FIELD_PrimaryKeyAutoInc(entity, id) +FIELD_Index(entity, name) +``` + +Options: +- `SPACETIMEDB_TABLE(Type, accessor, Public|Private)`: regular table +- `SPACETIMEDB_TABLE(Type, accessor, Public|Private, true)`: event table + +Field constraints: +- `FIELD_PrimaryKey(accessor, field)`: primary key +- `FIELD_PrimaryKeyAutoInc(accessor, field)`: primary key with auto-increment (use 0 on insert) +- `FIELD_Unique(accessor, field)`: unique constraint +- `FIELD_Index(accessor, field)`: btree index (enables `.filter()`) + +## Column Types + +| C++ type | Notes | +|----------|-------| +| `uint8_t` / `uint16_t` / `uint32_t` / `uint64_t` | unsigned integers | +| `SpacetimeDB::u128` / `SpacetimeDB::u256` | large unsigned integers | +| `int8_t` / `int16_t` / `int32_t` / `int64_t` | signed integers | +| `SpacetimeDB::i128` / `SpacetimeDB::i256` | large signed integers | +| `float` / `double` | floats | +| `bool` | boolean | +| `std::string` | text | +| `std::vector` | list/array | +| `std::optional` | nullable column | +| `Identity` | user identity | +| `ConnectionId` | connection handle | +| `Timestamp` | server timestamp (microseconds since epoch) | +| `TimeDuration` | duration in microseconds | +| `ScheduleAt` | for scheduled tables | + +## Indexes + +```cpp +// Single-column: +FIELD_Index(entity, name) +// Access: ctx.db[entity_name].filter("Alice") + +// Multi-column: +FIELD_NamedMultiColumnIndex(score, by_player_and_level, player_id, level) +``` + +Range queries (requires `#include `): +```cpp +ctx.db[user_age].filter(range_inclusive(uint8_t(18), uint8_t(65))); +ctx.db[user_age].filter(range_from(uint8_t(18))); +``` + +## Reducers + +All reducers return `ReducerResult`. Use `Ok()` or `Err(message)`: + +```cpp +SPACETIMEDB_REDUCER(create_entity, ReducerContext ctx, std::string name) { + if (name.empty()) { + return Err("Name cannot be empty"); + } + ctx.db[entity].insert(Entity{0, ctx.sender(), name, true}); + return Ok(); +} +``` + +## DB Operations + +```cpp +ctx.db[entity].insert(Entity{0, owner, "Sample", true}); // Insert (0 for autoInc) +ctx.db[entity_id].find(entityId); // Find by PK → std::optional +ctx.db[entity_identity].find(ctx.sender()); // Find by unique column +ctx.db[entity_name].filter("Alice"); // Filter by index → iterable +ctx.db[entity]; // All rows → iterable (range-for) +ctx.db[entity].count(); // Count rows + +// Update: find, mutate, update +if (auto e = ctx.db[entity_id].find(entityId)) { + e->name = "New Name"; + ctx.db[entity_id].update(*e); +} + +// Delete by primary key +ctx.db[entity_id].delete_by_key(entityId); +``` + +Note: Bracket notation `ctx.db[accessor]` is used for all table access. The accessor name comes from `SPACETIMEDB_TABLE` and `FIELD_*` macros. + +## Lifecycle Hooks + +```cpp +SPACETIMEDB_INIT(init, ReducerContext ctx) { + LOG_INFO("Database initializing..."); + return Ok(); +} + +SPACETIMEDB_CLIENT_CONNECTED(on_connect, ReducerContext ctx) { + LOG_INFO("Connected: " + ctx.sender().to_string()); + return Ok(); +} + +SPACETIMEDB_CLIENT_DISCONNECTED(on_disconnect, ReducerContext ctx) { + LOG_INFO("Disconnected: " + ctx.sender().to_string()); + return Ok(); +} +``` + +## Authentication & Timestamps + +```cpp +// Auth: ctx.sender() is the caller's Identity +if (row.owner != ctx.sender()) { + return Err("unauthorized"); +} + +// Server timestamps +ctx.db[item].insert(Item{0, ctx.sender(), ctx.timestamp}); + +// Timestamp arithmetic +Timestamp later = ctx.timestamp + TimeDuration::from_seconds(10); +``` + +## Reducer Context + +`ReducerContext` is the single source of sender identity, deterministic time, and deterministic randomness inside a reducer. Always go through `ctx` for these. Standard library clocks and random sources are not available in modules. + +```cpp +ctx.db[table] // Table access (bracket notation) +ctx.sender() // Caller's Identity +ctx.timestamp // Invocation timestamp +ctx.connection_id // std::optional +ctx.identity() // Module's own identity +ctx.rng() // Deterministic RNG +ctx.sender_auth() // AuthCtx with JWT claims +``` + +## Scheduled Tables + +```cpp +struct Reminder { + uint64_t scheduled_id; + ScheduleAt scheduled_at; + std::string message; +}; +SPACETIMEDB_STRUCT(Reminder, scheduled_id, scheduled_at, message) +SPACETIMEDB_TABLE(Reminder, reminder, Public) +FIELD_PrimaryKeyAutoInc(reminder, scheduled_id) +SPACETIMEDB_SCHEDULE(reminder, 1, send_reminder) // 1 = scheduled_at field index (0-based) + +SPACETIMEDB_REDUCER(send_reminder, ReducerContext ctx, Reminder arg) { + LOG_INFO("Reminder: " + arg.message); + return Ok(); +} + +// One-time: fires at a specific time +ctx.db[reminder].insert(Reminder{0, ScheduleAt::time(ctx.timestamp + TimeDuration::from_seconds(10)), "msg"}); +// Repeating: fires on an interval +ctx.db[reminder].insert(Reminder{0, ScheduleAt::interval(TimeDuration::from_seconds(5)), "msg"}); +``` + +## Custom Types + +```cpp +// Struct (product type): +struct Point { float x; float y; }; +SPACETIMEDB_STRUCT(Point, x, y) + +// Enum (sum type): +SPACETIMEDB_UNIT_TYPE(Active) +SPACETIMEDB_UNIT_TYPE(Inactive) +SPACETIMEDB_ENUM(PlayerStatus, + (Active, Active), + (Inactive, Inactive), + (Suspended, std::string) +) +``` + +## Logging + +```cpp +LOG_INFO("Message: " + msg); +LOG_WARN("Warning: " + msg); +LOG_ERROR("Error: " + msg); +LOG_DEBUG("Debug: " + msg); +LOG_PANIC("Fatal: " + msg); // terminates reducer +``` + +## Complete Example + +```cpp +#include +using namespace SpacetimeDB; + +struct Entity { + Identity identity; + std::string name; + bool active; +}; +SPACETIMEDB_STRUCT(Entity, identity, name, active) +SPACETIMEDB_TABLE(Entity, entity, Public) +FIELD_PrimaryKey(entity, identity) + +struct Record { + uint64_t id; + Identity owner; + uint32_t value; + Timestamp created_at; +}; +SPACETIMEDB_STRUCT(Record, id, owner, value, created_at) +SPACETIMEDB_TABLE(Record, record, Public) +FIELD_PrimaryKeyAutoInc(record, id) + +SPACETIMEDB_CLIENT_CONNECTED(on_connect, ReducerContext ctx) { + if (auto existing = ctx.db[entity_identity].find(ctx.sender())) { + existing->active = true; + ctx.db[entity_identity].update(*existing); + } + return Ok(); +} + +SPACETIMEDB_CLIENT_DISCONNECTED(on_disconnect, ReducerContext ctx) { + if (auto existing = ctx.db[entity_identity].find(ctx.sender())) { + existing->active = false; + ctx.db[entity_identity].update(*existing); + } + return Ok(); +} + +SPACETIMEDB_REDUCER(create_entity, ReducerContext ctx, std::string name) { + if (ctx.db[entity_identity].find(ctx.sender())) { + return Err("already exists"); + } + ctx.db[entity].insert(Entity{ctx.sender(), name, true}); + return Ok(); +} + +SPACETIMEDB_REDUCER(add_record, ReducerContext ctx, uint32_t value) { + if (!ctx.db[entity_identity].find(ctx.sender())) { + return Err("not found"); + } + ctx.db[record].insert(Record{0, ctx.sender(), value, ctx.timestamp}); + return Ok(); +} +``` diff --git a/codex-plugin/plugins/spacetimedb/skills/csharp-client/SKILL.md b/codex-plugin/plugins/spacetimedb/skills/csharp-client/SKILL.md new file mode 100644 index 00000000000..b1e7a0bc3a0 --- /dev/null +++ b/codex-plugin/plugins/spacetimedb/skills/csharp-client/SKILL.md @@ -0,0 +1,167 @@ +--- +name: csharp-client +description: SpacetimeDB C#/.NET client SDK reference. Use when building C# clients that connect to SpacetimeDB (console, desktop, or any .NET app). +license: Apache-2.0 +metadata: + author: clockworklabs + version: "2.0" + role: client + language: csharp + cursor_globs: "**/*.cs" + cursor_always_apply: true +--- + +# SpacetimeDB C# Client + +Install: `dotnet add package SpacetimeDB.ClientSDK` + +Generated bindings convert snake_case names to PascalCase, including row fields: a server column `trip_id` is `TripId` on client rows. + +## Connection + +```csharp +using SpacetimeDB; +using SpacetimeDB.Types; + +var conn = DbConnection.Builder() + .WithUri("http://localhost:3000") + .WithDatabaseName("my-database") + .WithToken(savedToken) + .OnConnect((conn, identity, token) => + { + Console.WriteLine($"Connected as: {identity}"); + // Save token for reconnection + File.WriteAllText("auth_token.txt", token); + + conn.SubscriptionBuilder() + .OnApplied(OnSubscriptionApplied) + .SubscribeToAllTables(); + }) + .OnConnectError(err => Console.Error.WriteLine($"Connection failed: {err}")) + .OnDisconnect((conn, err) => + { + if (err != null) Console.Error.WriteLine($"Disconnected: {err}"); + }) + .Build(); +``` + +## Event Loop (Critical) + +**`FrameTick()` must be called in your main loop.** The SDK queues all network messages and only processes them when you call `FrameTick()`. Without it, no callbacks fire. + +```csharp +while (running) +{ + conn.FrameTick(); + // Your application logic... + Thread.Sleep(16); // ~60fps +} +``` + +**Thread safety**: `FrameTick()` processes messages on the calling thread. Do NOT call it from a background thread. Do NOT access `conn.Db` from background threads. + +## Subscriptions + +```csharp +// Subscribe to all tables +conn.SubscriptionBuilder() + .OnApplied(ctx => Console.WriteLine("Subscription ready")) + .SubscribeToAllTables(); + +// Subscribe with typed query builder (recommended) +conn.SubscriptionBuilder() + .OnApplied(OnSubscriptionApplied) + .AddQuery(q => q.From.Player().Where(p => p.Level.Gte(5u))) + .AddQuery(q => q.From.GameState()) + .Subscribe(); + +// Or with raw SQL strings +conn.SubscriptionBuilder() + .OnApplied(OnSubscriptionApplied) + .Subscribe(new[] { + "SELECT * FROM player WHERE level >= 5", + "SELECT * FROM game_state" + }); +``` + +## Row Callbacks + +```csharp +conn.Db.Player.OnInsert += (EventContext ctx, Player player) => +{ + Console.WriteLine($"Player joined: {player.Name}"); +}; + +conn.Db.Player.OnDelete += (EventContext ctx, Player player) => +{ + Console.WriteLine($"Player left: {player.Name}"); +}; + +conn.Db.Player.OnUpdate += (EventContext ctx, Player oldPlayer, Player newPlayer) => +{ + Console.WriteLine($"Player updated: {newPlayer.Name}"); +}; +``` + +## Reading the Client Cache + +```csharp +// Find by primary key +if (conn.Db.Player.Id.Find(playerId) is Player player) +{ + Console.WriteLine($"Player: {player.Name}"); +} + +// Find by unique column +var me = conn.Db.Player.Identity.Find(myIdentity); + +// Filter by indexed column +foreach (var p in conn.Db.Player.Level.Filter(5)) +{ + Console.WriteLine($"Level 5: {p.Name}"); +} + +// Iterate all rows +foreach (var p in conn.Db.Player.Iter()) +{ + Console.WriteLine(p.Name); +} + +// Count +int total = conn.Db.Player.Count; +``` + +## Calling Reducers + +```csharp +conn.Reducers.CreatePlayer("Alice"); +conn.Reducers.MovePlayer(10.0f, 20.0f); +conn.Reducers.SendMessage("Hello!"); +``` + +Reducer calls return `void`; observe failures via the reducer callbacks below (`Status.Failed`). + +## Reducer Callbacks + +```csharp +conn.Reducers.OnSendMessage += (ReducerEventContext ctx, string text) => +{ + if (ctx.Event.Status is Status.Committed) + Console.WriteLine($"Message sent: {text}"); + else if (ctx.Event.Status is Status.Failed(var reason)) + Console.Error.WriteLine($"Send failed: {reason}"); +}; +``` + +## Identity + +```csharp +// Identities from OnConnect callback +Identity myIdentity; + +// Compare identities +if (player.Owner == myIdentity) { /* it's me */ } + +// Display +Console.WriteLine($"Identity: {identity}"); +``` diff --git a/codex-plugin/plugins/spacetimedb/skills/csharp-server/SKILL.md b/codex-plugin/plugins/spacetimedb/skills/csharp-server/SKILL.md new file mode 100644 index 00000000000..d88fd5ec0d2 --- /dev/null +++ b/codex-plugin/plugins/spacetimedb/skills/csharp-server/SKILL.md @@ -0,0 +1,257 @@ +--- +name: csharp-server +description: SpacetimeDB C# server module SDK reference. Use when writing tables, reducers, or module logic in C#. +license: Apache-2.0 +metadata: + author: clockworklabs + version: "2.0" + role: server + language: csharp + cursor_globs: "**/*.cs" + cursor_always_apply: true +--- + +# SpacetimeDB C# SDK Reference + +## Module Structure + +Reducers are static methods in a `static partial class`; tables are `public partial struct`s. This reference keeps everything in one `public static partial class Module`, which needs only `using SpacetimeDB;`: + +```csharp +using SpacetimeDB; + +public static partial class Module +{ + [SpacetimeDB.Table(Accessor = "ScoreRecord", Public = true)] + public partial struct ScoreRecord + { + [PrimaryKey] + [AutoInc] + public ulong Id; + public Identity Owner; + public uint Value; + } + + [SpacetimeDB.Reducer] + public static void AddRecord(ReducerContext ctx, uint value) + { + ctx.Db.ScoreRecord.Insert(new ScoreRecord { Id = 0, Owner = ctx.Sender, Value = value }); + } +} +``` + +## Tables + +`[SpacetimeDB.Table(...)]` on a `public partial struct`. `Accessor` should be PascalCase: + +```csharp +[SpacetimeDB.Table(Accessor = "Entity", Public = true)] +public partial struct Entity +{ + [PrimaryKey] + [AutoInc] + public ulong Id; + public Identity Owner; + public string Name; + public bool Active; +} +``` + +Options: `Accessor = "PascalCase"` (recommended), `Public = true`, `Scheduled = nameof(ReducerFn)`, `ScheduledAt = nameof(field)`, `Event = true` + +`ctx.Db` accessors use the `Accessor` name: `ctx.Db.Entity`, `ctx.Db.ScoreRecord`. + +## Column Types + +| C# type | Notes | +|---------|-------| +| `byte` / `ushort` / `uint` / `ulong` | unsigned integers | +| `U128` / `U256` | large unsigned integers (SpacetimeDB types) | +| `sbyte` / `short` / `int` / `long` | signed integers | +| `I128` / `I256` | large signed integers (SpacetimeDB types) | +| `float` / `double` | floats | +| `bool` | boolean | +| `string` | text | +| `List` | list/array | +| `Identity` | user identity | +| `ConnectionId` | connection handle | +| `Timestamp` | server timestamp (microseconds since epoch) | +| `TimeDuration` | duration in microseconds | +| `Uuid` | UUID | + +Optional columns: nullable types (`string? Nickname`, `uint? HighScore`) + +## Column Attributes + +The complete set of column attributes: + +```csharp +[PrimaryKey] // primary key +[AutoInc] // auto-increment (use 0 as placeholder on insert) +[Unique] // unique constraint; indexes the column, enables .Find() +[SpacetimeDB.Index.BTree] // btree index (enables .Filter() on this column) +``` + +## Indexes + +Write the index attribute fully qualified: `[SpacetimeDB.Index.BTree]`. Prefer inline for single-column; multi-column uses struct-level: + +```csharp +// Inline (preferred for single-column): +[SpacetimeDB.Index.BTree] +public ulong AuthorId; +// Access: ctx.Db.Post.AuthorId.Filter(authorId) + +// Multi-column (struct-level): +[SpacetimeDB.Table(Accessor = "Membership")] +[SpacetimeDB.Index.BTree(Accessor = "ByGroupUser", Columns = new[] { nameof(GroupId), nameof(UserId) })] +public partial struct Membership { public ulong GroupId; public Identity UserId; ... } +``` + +Prefer a multi-column index over filtering by one column and looping. + +## Reducers + +```csharp +[SpacetimeDB.Reducer] +public static void CreateEntity(ReducerContext ctx, string name, int age) +{ + ctx.Db.Entity.Insert(new Entity { Owner = ctx.Sender, Name = name, Age = age, Active = true }); +} + +// No arguments: +[SpacetimeDB.Reducer] +public static void DoReset(ReducerContext ctx) { ... } +``` + +## DB Operations + +```csharp +var row = ctx.Db.Entity.Insert(new Entity { Name = "Sample" }); // Insert; returns the row with AutoInc fields assigned +ctx.Db.Entity.Id.Find(entityId); // Find by PK → Entity? (nullable) +ctx.Db.Entity.Identity.Find(ctx.Sender); // Find by unique column → Entity? +if (ctx.Db.Entity.Id.Find(entityId) is { } entity) { ... } // unwrap Entity? before member access +ctx.Db.Item.AuthorId.Filter(authorId); // Filter by index → IEnumerable +ctx.Db.Entity.Iter(); // All rows → IEnumerable +ctx.Db.Entity.Count; // Count rows +ctx.Db.Entity.Id.Update(existing with { Name = newName }); // Update by PK +ctx.Db.Entity.Id.Delete(entityId); // Delete by PK +``` + +Note: Filter/Iter return enumerables. Use `.ToList()` if you need to sort or mutate. + +The pattern is `ctx.Db.{Accessor}.{ColumnName}.{Method}(value)` for all indexed column operations. + +## Lifecycle Hooks + +```csharp +[SpacetimeDB.Reducer(ReducerKind.Init)] +public static void OnInit(ReducerContext ctx) { ... } + +[SpacetimeDB.Reducer(ReducerKind.ClientConnected)] +public static void OnConnect(ReducerContext ctx) { ... } + +[SpacetimeDB.Reducer(ReducerKind.ClientDisconnected)] +public static void OnDisconnect(ReducerContext ctx) { ... } +``` + +## Views + +```csharp +// Anonymous view (same result for all clients): +[SpacetimeDB.View(Accessor = "ActiveUsers", Public = true)] +public static List ActiveUsers(AnonymousViewContext ctx) +{ + return ctx.Db.Entity.Iter().Where(e => e.Active).ToList(); +} + +// Per-user view: +[SpacetimeDB.View(Accessor = "MyProfile", Public = true)] +public static Entity? MyProfile(ViewContext ctx) +{ + return ctx.Db.Entity.Identity.Find(ctx.Sender) as Entity?; +} +``` + +## Reducer Context API + +`ReducerContext` (`ctx`) is the only source of sender identity, time, and randomness; stdlib clocks and RNG are unavailable in modules. + +```csharp +// Auth: ctx.Sender is the caller's Identity +if (row.Owner != ctx.Sender) + throw new Exception("unauthorized"); + +// Server timestamp (deterministic per reducer call) +ctx.Db.Item.Insert(new Item { CreatedAt = ctx.Timestamp, .. }); + +// Timestamp arithmetic +var expiry = ctx.Timestamp + new TimeDuration(delayMicros); + +// Deterministic RNG +int roll = ctx.Rng.Next(1, 7); // [1, 7): inclusive 1, exclusive 7 +double f = ctx.Rng.NextDouble(); // [0.0, 1.0) + +// Timestamp → milliseconds since epoch +timestamp.MicrosecondsSinceUnixEpoch / 1000 +``` + +## Scheduled Tables + +Declare the scheduled table and its reducer in the same `Module` class so `nameof(...)` resolves: + +```csharp +[SpacetimeDB.Table( + Accessor = "TickTimer", + Scheduled = nameof(Tick), + ScheduledAt = nameof(ScheduledAt), + Public = true +)] +public partial struct TickTimer +{ + [PrimaryKey] + [AutoInc] + public ulong ScheduledId; + public ScheduleAt ScheduledAt; +} + +[SpacetimeDB.Reducer] +public static void Tick(ReducerContext ctx, TickTimer timer) +{ + // timer row is auto-deleted after this reducer runs +} + +// One-time: fires once at a specific time +var at = new ScheduleAt.Time(ctx.Timestamp + new TimeDuration(10_000_000)); +// Repeating: fires on an interval +var at = new ScheduleAt.Interval(TimeSpan.FromSeconds(5)); + +ctx.Db.TickTimer.Insert(new TickTimer { ScheduledId = 0, ScheduledAt = at }); +``` + +## Custom Types + +```csharp +[SpacetimeDB.Type] +public enum Status { Online, Away, Offline } + +[SpacetimeDB.Type] +public partial struct Point { public float X; public float Y; } +``` + +Tagged enums (discriminated unions): a `partial record` with empty body and no constructor parameters. Payloads are `[Type] partial struct`s: + +```csharp +[SpacetimeDB.Type] +public partial struct Circle { public int Radius; } + +[SpacetimeDB.Type] +public partial struct Rectangle { public int Width; public int Height; } + +[SpacetimeDB.Type] +public partial record Shape : SpacetimeDB.TaggedEnum<(Circle Circle, Rectangle Rectangle)> { } + +// Construct variants via the generated nested constructors: +var a = new Shape.Circle(new Circle { Radius = 10 }); +var b = new Shape.Rectangle(new Rectangle { Width = 4, Height = 6 }); +``` diff --git a/codex-plugin/plugins/spacetimedb/skills/mcp/SKILL.md b/codex-plugin/plugins/spacetimedb/skills/mcp/SKILL.md new file mode 100644 index 00000000000..74b4024bddc --- /dev/null +++ b/codex-plugin/plugins/spacetimedb/skills/mcp/SKILL.md @@ -0,0 +1,114 @@ +--- +name: mcp +description: Operate a running SpacetimeDB database through MCP tools rather than the CLI - list databases, read schemas, run SQL, and call reducers. Use when the client exposes spacetimedb MCP tools and the task is to inspect or change data in a live database. +license: Apache-2.0 +metadata: + author: clockworklabs + version: "2.0" + role: shared + language: all + cursor_globs: "**/*" + cursor_always_apply: false +triggers: + - inspect the database + - query the database + - list my databases + - read the schema + - call a reducer + - use the MCP server + - spacetimedb MCP +--- + +# SpacetimeDB over MCP + +A SpacetimeDB host speaks MCP, so an MCP-aware client can operate a live database with tool calls +instead of shell commands. If the client exposes `spacetimedb` tools, use them for anything that +reads or changes a running database. + +Check the tool list before deciding they are missing. The tools are passive: they appear as +`spacetimedb.list_databases`, `spacetimedb.get_schema`, `spacetimedb.sql`, `spacetimedb.call`, and +`spacetimedb.ping`, and nothing announces them. + +## Which to reach for + +| Task | Use | +| --- | --- | +| List databases, read a schema, run SQL, call a reducer | **MCP tools** | +| init, build, publish, generate, start, logs | **`spacetime` CLI** (see the `cli` skill) | + +The MCP tools only operate a database that already exists. They cannot scaffold a project, compile a +module, publish, or generate bindings, so the two are complementary rather than alternatives. Prefer +the tools when they are present: they are typed, they return JSON, and the client can gate the +destructive ones. When no MCP client is attached, the equivalent CLI commands are correct. + +## The tools + +| Tool | Arguments | Returns | +| --- | --- | --- | +| `list_databases` | none | the databases **you own**, with identity and names | +| `get_schema` | `database` | tables and reducers as JSON | +| `sql` | `database`, `sql`, optional `confirmed` | rows as JSON | +| `call` | `database`, `reducer`, optional `args` (JSON array) | the reducer outcome | +| `ping` | optional `message` | a health check | + +`list_databases` lists only your own databases, so it is empty for an anonymous identity. Start there +when you do not know the database name. + +## Two shapes, so read `tools/list` first + +A server is either host-wide or scoped to one database. Do not assume which: + +**Host-wide** (`spacetime mcp` with no database, or `POST /v1/mcp`). Every data tool takes a required +`database` argument, a name or an identity, and `list_databases` is offered: + +```json +{ "name": "sql", "arguments": { "database": "mydb", "sql": "SELECT * FROM message" } } +``` + +**Scoped** (`spacetime mcp `, or `POST /v1/database//mcp`). The connection fixes the +database, so there is no `database` argument and no `list_databases`: + +```json +{ "name": "sql", "arguments": { "sql": "SELECT * FROM message" } } +``` + +## Rules that do not change + +The tools run with your identity, exactly as the HTTP API does. The model is the same as everywhere +else, so the `concepts` skill still governs: + +1. **Reducers are the write path.** Use `call` to change data. It runs in a transaction that either + fully commits or fully rolls back. +2. **SQL writes require ownership.** `sql` reads public tables; writing through it needs you to own + the database. Prefer `call`. +3. **Private tables are not client-readable.** `get_schema` still shows a private table's + declaration, so a `no such table` error from `sql` usually means the table is private rather than + missing. Access depends on your identity, so do not assume a private table is readable. +4. **Tool errors come back in band.** A failed reducer or a bad query returns a result with + `isError: true` and the message as text, not a transport failure. Read the text before retrying. + +## Inspecting a database + +``` +list_databases {} +get_schema { "database": "mydb" } +sql { "database": "mydb", "sql": "SELECT * FROM message" } +call { "database": "mydb", "reducer": "send_message", "args": ["hello"] } +``` + +Pass `"confirmed": true` to `sql` to wait for a durably confirmed read. + +## Troubleshooting + +| Message | Meaning | +| --- | --- | +| `database argument must be a string` | The server is host-wide and you omitted `database` | +| `unknown tool: list_databases` | The server is scoped to one database already | +| `` `x` not found `` | No such database on this server, or a name where an identity was meant | +| `no such table: x` | The table is private, or you are querying the wrong database | +| No `spacetimedb` tools at all | No MCP server is connected. Use the CLI instead (`cli` skill) | + +`spacetime mcp` is UNSTABLE and may not be in a released CLI yet, so a client that cannot start it +falls back to the CLI commands in the `cli` skill. + +Reference: https://spacetimedb.com/docs diff --git a/codex-plugin/plugins/spacetimedb/skills/rust-server/SKILL.md b/codex-plugin/plugins/spacetimedb/skills/rust-server/SKILL.md new file mode 100644 index 00000000000..f83a4387b1c --- /dev/null +++ b/codex-plugin/plugins/spacetimedb/skills/rust-server/SKILL.md @@ -0,0 +1,291 @@ +--- +name: rust-server +description: SpacetimeDB Rust server module SDK reference. Use when writing tables, reducers, or module logic in Rust. +license: Apache-2.0 +metadata: + author: clockworklabs + version: "2.0" + role: server + language: rust + cursor_globs: "**/*.rs" + cursor_always_apply: true +--- + +# SpacetimeDB Rust SDK Reference + +## Imports + +```rust +use spacetimedb::{ + reducer, table, Identity, ReducerContext, SpacetimeType, Table, + ConnectionId, ScheduleAt, TimeDuration, Timestamp, Uuid, +}; +``` + +**`Table` is required.** Without it, `ctx.db.*.insert()`, `.iter()`, `.find()` etc. won't compile (`no method named 'insert' found`). + +## Tables + +`#[spacetimedb::table(...)]` on a `pub struct`. `accessor` must be snake_case: + +```rust +#[spacetimedb::table(accessor = entity, public)] +pub struct Entity { + #[primary_key] + #[auto_inc] + pub id: u64, + pub owner: Identity, + pub name: String, + pub active: bool, +} +``` + +Options: `accessor = snake_case` (required), `public`, `scheduled(reducer_fn)`, `index(...)` + +`ctx.db` accessors use the `accessor` name (snake_case). + +## Column Types + +| Rust type | Notes | +|-----------|-------| +| `u8` / `u16` / `u32` / `u64` / `u128` | unsigned integers | +| `i8` / `i16` / `i32` / `i64` / `i128` | signed integers | +| `f32` / `f64` | floats | +| `bool` | boolean | +| `String` | text | +| `Vec` | list/array | +| `Identity` | user identity | +| `ConnectionId` | connection handle | +| `Timestamp` | server timestamp (microseconds since epoch) | +| `TimeDuration` | duration in microseconds | +| `Uuid` | UUID | +| `Option` | nullable column | + +## Column Attributes + +```rust +#[primary_key] // primary key +#[auto_inc] // auto-increment (use 0 as placeholder on insert) +#[unique] // unique constraint +#[index(btree)] // btree index (enables .filter() on this column) +``` + +## Indexes + +Prefer `#[index(btree)]` inline for single-column. Multi-column uses table-level: + +```rust +// Inline (preferred for single-column): +#[index(btree)] +pub author_id: u64, +// Access: ctx.db.post().author_id().filter(author_id) + +// Multi-column (table-level): +#[spacetimedb::table(accessor = membership, public, + index(accessor = by_group_user, btree(columns = [group_id, user_id])) +)] +pub struct Membership { pub group_id: u64, pub user_id: Identity, ... } +// Access: ctx.db.membership().by_group_user().filter((group_id, &user_id)) +``` + +When you frequently look up rows by multiple columns, prefer a multi-column index over filtering by one column and looping over the results. + +## Reducers + +```rust +#[spacetimedb::reducer] +pub fn create_entity(ctx: &ReducerContext, name: String) { + ctx.db.entity().insert(Entity { id: 0, owner: ctx.sender(), name, active: true }); +} + +// Reducers can return Result<(), String> or Result<(), E> where E: Display +#[spacetimedb::reducer] +pub fn validate_entity(ctx: &ReducerContext, name: String) -> Result<(), String> { + if name.is_empty() { + return Err("Name cannot be empty".to_string()); + } + ctx.db.entity().try_insert(Entity { id: 0, owner: ctx.sender(), name, active: true })?; + Ok(()) +} +``` + +Note: `insert()` panics on constraint violations. Use `try_insert()` with `?` when returning `Result`. + +## DB Operations + +```rust +ctx.db.entity().insert(Entity { id: 0, name: "Sample".into() }); // Insert (0 for autoInc) +ctx.db.entity().id().find(entity_id); // Find by PK → Option +ctx.db.entity().identity().find(ctx.sender()); // Find by unique column → Option +ctx.db.item().author_id().filter(author_id); // Filter by index → iterator +ctx.db.entity().iter(); // All rows → iterator +ctx.db.entity().count(); // Count rows +ctx.db.entity().id().update(Entity { name: new_name, ..existing }); // Update (override + spread) +ctx.db.entity().id().delete(entity_id); // Delete by PK +ctx.db.entity().name().delete("Alice"); // Delete by indexed column +``` + +Note: `iter()` and `filter()` return iterators. Collect to Vec if you need `.sort()`, `.filter()`, `.map()`. + +Range queries on btree indexes: `filter(18..=65)`, `filter(18..)`, `filter(..18)`. + +## Lifecycle Hooks + +```rust +#[spacetimedb::reducer(init)] +pub fn init(ctx: &ReducerContext) { ... } + +#[spacetimedb::reducer(client_connected)] +pub fn on_connect(ctx: &ReducerContext) { ... } + +#[spacetimedb::reducer(client_disconnected)] +pub fn on_disconnect(ctx: &ReducerContext) { ... } +``` + +## Views + +```rust +// Anonymous view (same result for all clients): +use spacetimedb::{view, AnonymousViewContext}; + +#[view(accessor = active_users, public)] +fn active_users(ctx: &AnonymousViewContext) -> Vec { + ctx.db.entity().iter().filter(|e| e.active).collect() +} + +// Per-user view (result varies by sender): +use spacetimedb::{view, ViewContext}; + +#[view(accessor = my_profile, public)] +fn my_profile(ctx: &ViewContext) -> Option { + ctx.db.entity().identity().find(ctx.sender()) +} +``` + +## Reducer Context API + +`ReducerContext` (`ctx`) is the only source of sender identity, time, and randomness; stdlib clocks and RNG are unavailable in modules. + +```rust +// Auth: ctx.sender() is the caller's Identity +if row.owner != ctx.sender() { + panic!("unauthorized"); + // or: return Err(anyhow::anyhow!("unauthorized")); +} + +// Server timestamp (deterministic per reducer call) +ctx.db.item().insert(Item { id: 0, owner: ctx.sender(), created_at: ctx.timestamp, .. }); + +// Timestamp arithmetic +let expiry = ctx.timestamp + TimeDuration::from_micros(delay_micros); + +// Deterministic RNG: ctx.random() for a single value, ctx.rng() for the rand::Rng trait +use spacetimedb::rand::Rng; +let n: u32 = ctx.random(); // random u32 +let roll: u32 = ctx.rng().gen_range(1..=6); // any rand::Rng method + +// Client: Timestamp → milliseconds since epoch +timestamp.to_micros_since_unix_epoch() / 1000 +``` + +## Scheduled Tables + +```rust +#[spacetimedb::table(accessor = tick_timer, scheduled(tick), public)] +pub struct TickTimer { + #[primary_key] + #[auto_inc] + scheduled_id: u64, + scheduled_at: spacetimedb::ScheduleAt, +} + +#[spacetimedb::reducer] +pub fn tick(ctx: &ReducerContext, timer: TickTimer) { + // timer row is auto-deleted after this reducer runs +} + +// One-time: fires once at a specific time +let at = ScheduleAt::Time(ctx.timestamp + std::time::Duration::from_secs(10)); +// Repeating: fires on an interval +let at = ScheduleAt::Interval(std::time::Duration::from_secs(5).into()); + +ctx.db.tick_timer().insert(TickTimer { scheduled_id: 0, scheduled_at: at }); +``` + +## Logging + +```rust +log::info!("Player connected: {:?}", ctx.sender()); +log::warn!("Low health: {}", hp); +log::error!("Failed to find entity"); +``` + +## Custom Types + +```rust +#[derive(SpacetimeType)] +pub enum Status { Online, Away, Offline } + +#[derive(SpacetimeType)] +pub struct Point { x: f32, y: f32 } +``` + +## Complete Example + +```rust +// src/lib.rs +use spacetimedb::{Identity, ReducerContext, SpacetimeType, Table, Timestamp}; + +#[spacetimedb::table(accessor = entity, public)] +pub struct Entity { + #[primary_key] + pub identity: Identity, + pub name: String, + pub active: bool, +} + +#[spacetimedb::table(accessor = record, public)] +pub struct Record { + #[primary_key] + #[auto_inc] + pub id: u64, + pub owner: Identity, + pub value: u32, + pub created_at: Timestamp, +} + +#[spacetimedb::reducer(client_connected)] +pub fn on_connect(ctx: &ReducerContext) { + if let Some(existing) = ctx.db.entity().identity().find(ctx.sender()) { + ctx.db.entity().identity().update(Entity { active: true, ..existing }); + } +} + +#[spacetimedb::reducer(client_disconnected)] +pub fn on_disconnect(ctx: &ReducerContext) { + if let Some(existing) = ctx.db.entity().identity().find(ctx.sender()) { + ctx.db.entity().identity().update(Entity { active: false, ..existing }); + } +} + +#[spacetimedb::reducer] +pub fn create_entity(ctx: &ReducerContext, name: String) { + if ctx.db.entity().identity().find(ctx.sender()).is_some() { + panic!("already exists"); + } + ctx.db.entity().insert(Entity { identity: ctx.sender(), name, active: true }); +} + +#[spacetimedb::reducer] +pub fn add_record(ctx: &ReducerContext, value: u32) { + if ctx.db.entity().identity().find(ctx.sender()).is_none() { + panic!("not found"); + } + ctx.db.record().insert(Record { + id: 0, + owner: ctx.sender(), + value, + created_at: ctx.timestamp, + }); +} +``` diff --git a/codex-plugin/plugins/spacetimedb/skills/typescript-client/SKILL.md b/codex-plugin/plugins/spacetimedb/skills/typescript-client/SKILL.md new file mode 100644 index 00000000000..3a31183133f --- /dev/null +++ b/codex-plugin/plugins/spacetimedb/skills/typescript-client/SKILL.md @@ -0,0 +1,117 @@ +--- +name: typescript-client +description: SpacetimeDB TypeScript/React client SDK reference. Use when building web clients that connect to SpacetimeDB. +license: Apache-2.0 +metadata: + author: clockworklabs + version: "2.0" + role: client + language: typescript + cursor_globs: "**/*.tsx,**/*.ts" + cursor_always_apply: true +--- + +# SpacetimeDB TypeScript Client + +Generated bindings convert snake_case names to camelCase, including row fields: a server column `trip_id` is `tripId` on client rows. + +## React: main.tsx + +```typescript +import React, { useEffect, useMemo } from 'react'; +import ReactDOM from 'react-dom/client'; +import { SpacetimeDBProvider } from 'spacetimedb/react'; +import { DbConnection } from './module_bindings'; +import { MODULE_NAME, SPACETIMEDB_URI } from './config'; +import App from './App'; + +function Root() { + const connectionBuilder = useMemo(() => + DbConnection.builder() + .withUri(SPACETIMEDB_URI) + .withDatabaseName(MODULE_NAME) + .withToken(localStorage.getItem('auth_token') || undefined), + [] + ); + return ( + + + + ); +} + +ReactDOM.createRoot(document.getElementById('root')!).render(); +``` + +## React: App.tsx + +```typescript +import { useTable, useSpacetimeDB } from 'spacetimedb/react'; +import { DbConnection, tables } from './module_bindings'; + +function App() { + const { isActive, identity: myIdentity, token, getConnection } = useSpacetimeDB(); + const conn = getConnection() as DbConnection | null; + + // Save auth token + useEffect(() => { if (token) localStorage.setItem('auth_token', token); }, [token]); + + // Subscribe when connected. Prefer typed query builders over raw SQL + useEffect(() => { + if (!conn || !isActive) return; + conn.subscriptionBuilder() + .onApplied(() => setSubscribed(true)) + .subscribe([tables.entity, tables.record]); + // Or with filters: tables.entity.where(r => r.active.eq(true)) + // Or raw SQL: 'SELECT * FROM entity' + }, [conn, isActive]); + + // Reactive data. Returns [rows, isReady] + const [entities, entitiesReady] = useTable(tables.entity); + const [records, recordsReady] = useTable(tables.record); + + // useTable with row callbacks + const [onlineUsers] = useTable( + tables.entity.where(r => r.active.eq(true)), + { + onInsert: (user) => console.log('User connected:', user.name), + onDelete: (user) => console.log('User disconnected:', user.name), + onUpdate: (oldUser, newUser) => console.log('Updated:', newUser.name), + } + ); + + // Call reducers with object syntax + conn?.reducers.addRecord({ data }).catch(console.error); + + // Compare identities + const isMe = row.owner.toHexString() === myIdentity?.toHexString(); +} +``` + +## Vanilla (non-React) + +```typescript +import { DbConnection, tables } from './module_bindings'; + +const conn = DbConnection.builder() + .withUri('wss://maincloud.spacetimedb.com') + .withDatabaseName('my_module') + .onConnect((ctx) => { + ctx.subscriptionBuilder() + .onApplied(() => console.log('Ready')) + .subscribe([tables.user, tables.message]); + }) + .build(); + +// Row callbacks +conn.db.user.onInsert((ctx, user) => console.log('Joined:', user.name)); +conn.db.user.onDelete((ctx, user) => console.log('Left:', user.name)); +conn.db.user.onUpdate((ctx, oldUser, newUser) => console.log('Updated:', newUser.name)); +``` + +## Gotchas + +- **`useTable` rows are `readonly`.** Copy before sorting/mutating, or it fails to type-check: + `const [rows] = useTable(tables.message); const sorted = [...rows].sort(...)`. +- **bigint in JSX.** ids/counts from `t.u64()`/`t.i64()` columns are `bigint`, which React + cannot render. Wrap it: `{Number(row.id)}` or `{String(count)}`. diff --git a/codex-plugin/plugins/spacetimedb/skills/typescript-server/SKILL.md b/codex-plugin/plugins/spacetimedb/skills/typescript-server/SKILL.md new file mode 100644 index 00000000000..2e9326b3150 --- /dev/null +++ b/codex-plugin/plugins/spacetimedb/skills/typescript-server/SKILL.md @@ -0,0 +1,248 @@ +--- +name: typescript-server +description: SpacetimeDB TypeScript server module SDK reference. Use when writing tables, reducers, or module logic in TypeScript. +license: Apache-2.0 +metadata: + author: clockworklabs + version: "2.0" + role: server + language: typescript + cursor_globs: "**/*.ts" + cursor_always_apply: true +--- + +# SpacetimeDB TypeScript SDK Reference + +## Module Structure + +Tables are built with `table()`, bound with `schema()`, and exported as default. Reducers and lifecycle hooks are `export const`: + +```typescript +import { schema, table, t } from 'spacetimedb/server'; + +const score_record = table( + { name: 'score_record', public: true }, + { + id: t.u64().primaryKey().autoInc(), + owner: t.identity(), + value: t.u32(), + } +); + +const spacetimedb = schema({ score_record }); // ONE object, not spread args +export default spacetimedb; + +export const addRecord = spacetimedb.reducer( + { value: t.u32() }, + (ctx, { value }) => { + ctx.db.score_record.insert({ id: 0n, owner: ctx.sender, value }); + } +); +``` + +## Imports + +`spacetimedb/server` is the only import path for server modules: + +```typescript +import { schema, table, t } from 'spacetimedb/server'; +import { SenderError } from 'spacetimedb/server'; +import { ScheduleAt } from 'spacetimedb'; // for scheduled tables only +``` + +## Tables + +`table(OPTIONS, COLUMNS)` takes two arguments. The `name` field MUST be snake_case: + +```typescript +const entity = table( + { name: 'entity', public: true }, + { + identity: t.identity().primaryKey(), + name: t.string(), + active: t.bool(), + } +); +``` + +Options: `name` (snake_case, recommended), `public: true`, `event: true`, `scheduled: (): any => reducerRef`, `indexes: [...]` + +`ctx.db` accessors are the keys passed to `schema({...})`, verbatim: `schema({ score_record })` → `ctx.db.score_record`. Use snake_case keys matching the table `name`. Client codegen converts case; server `ctx.db` does not. + +## Column Types + +Every column is a `t` builder value: + +| Builder | JS type | Notes | +|---------|---------|-------| +| `t.u64()` | bigint | Use `0n` literals | +| `t.i64()` | bigint | Use `0n` literals | +| `t.u32()` / `t.i32()` | number | | +| `t.f64()` / `t.f32()` | number | | +| `t.bool()` | boolean | | +| `t.string()` | string | | +| `t.identity()` | Identity | | +| `t.connectionId()` | ConnectionId | | +| `t.timestamp()` | Timestamp | | +| `t.timeDuration()` | TimeDuration | | +| `t.scheduleAt()` | ScheduleAt | | + +Modifiers (complete set): `.primaryKey()`, `.autoInc()`, `.unique()`, `.index('btree')` + +Optional columns: `nickname: t.option(t.string())` + +## Indexes + +Prefer inline `.index('btree')` for single-column. Use named indexes only for multi-column: + +```typescript +// Inline (preferred for single-column): +authorId: t.u64().index('btree'), +// Access: ctx.db.post.authorId.filter(authorId); + +// Multi-column (named): +indexes: [{ accessor: 'by_group_user', algorithm: 'btree', columns: ['groupId', 'userId'] }] +// Access: ctx.db.membership.by_group_user.filter([groupId, userId]); +``` + +Prefer a multi-column index over filtering by one column and looping. Filter takes an array in index column order; a prefix scan passes the leading value bare: `filter(groupId)`. + +The published module's **entry file must export the schema as default**. If you split tables +(`schema.ts`) from reducers/lifecycle (`index.ts`), re-export it from the entry: + +```typescript +// index.ts +export { default } from './schema'; // re-export the schema for the module entry +``` + +## Reducers + +Reducers are created with `spacetimedb.reducer(...)`; the export name becomes the reducer name: + +```typescript +export const createEntity = spacetimedb.reducer( + { name: t.string(), age: t.i32() }, + (ctx, { name, age }) => { + ctx.db.entity.insert({ identity: ctx.sender, name, age, active: true }); + } +); + +// No arguments, just the callback: +export const doReset = spacetimedb.reducer((ctx) => { ... }); +``` + +Reducer args accept any column type, including arrays of custom types: `{ splits: t.array(Split) }`. Do not pass JSON strings for structured data. + +## DB Operations + +```typescript +ctx.db.score_record.insert({ id: 0n, owner: ctx.sender, value: 1 }); // Insert (0n for autoInc) +ctx.db.score_record.id.find(recordId); // Find by PK → row | null +ctx.db.entity.identity.find(ctx.sender); // Find by unique column +[...ctx.db.post.authorId.filter(authorId)]; // Filter → spread to Array +[...ctx.db.entity.iter()]; // All rows → Array +ctx.db.score_record.id.update({ ...existing, value: 2 }); // Update (spread + override) +ctx.db.score_record.id.delete(recordId); // Delete by PK +``` + +Note: `iter()` and `filter()` return iterators. Spread to Array for `.sort()`, `.filter()`, `.map()`. + +## Lifecycle Hooks + +MUST be `export const`. Bare calls are silently ignored: + +```typescript +export const init = spacetimedb.init((ctx) => { ... }); +export const onConnect = spacetimedb.clientConnected((ctx) => { ... }); +export const onDisconnect = spacetimedb.clientDisconnected((ctx) => { ... }); +``` + +## Reducer Context API + +`ctx` is the only source of sender identity, time, and randomness; stdlib clocks and RNG are unavailable in modules. In helpers, type it as `ReducerCtx>`. + +```typescript +// Auth: ctx.sender is the caller's Identity +if (!row.owner.equals(ctx.sender)) throw new SenderError('unauthorized'); + +// ctx.connectionId: the per-connection id, NULLABLE (ConnectionId | null) — null-check before use. +// One Identity can hold several connections (multiple tabs/devices). +if (ctx.connectionId) { /* ... */ } + +// Server timestamp (deterministic per reducer call) +ctx.db.item.insert({ id: 0n, createdAt: ctx.timestamp }); + +// Deterministic RNG +const f: number = ctx.random(); // [0.0, 1.0) +const roll: number = ctx.random.integerInRange(1, 6); // inclusive +const bytes: Uint8Array = ctx.random.fill(new Uint8Array(16)); + +// Client: Timestamp → Date +new Date(Number(row.createdAt.microsSinceUnixEpoch / 1000n)); +``` + +Do not construct `Identity` values from strings (e.g. `'hex' as Identity`): serialization fails and kills the module. Identities come from `ctx.sender` or `t.identity()` columns. + +## Scheduled Tables + +```typescript +import { ScheduleAt } from 'spacetimedb'; // ScheduleAt comes from the root package + +const tick_timer = table({ + name: 'tick_timer', + scheduled: (): any => tick, // (): any => breaks circular dep +}, { + scheduled_id: t.u64().primaryKey().autoInc(), + scheduled_at: t.scheduleAt(), +}); + +export const tick = spacetimedb.reducer( + { timer: tick_timer.rowType }, + (ctx, { timer }) => { /* timer row auto-deleted after this runs */ } +); + +// One-time: ScheduleAt.time(ctx.timestamp.microsSinceUnixEpoch + delayMicros) +// Repeating: ScheduleAt.interval(60_000_000n) +// Read time back from a scheduleAt value (tagged union): +// const micros = at.tag === 'time' ? at.value : at.value.microsSinceUnixEpoch; // bigint +``` + +## Custom Types + +```typescript +// Product type (struct): +const Position = t.object('Position', { x: t.i32(), y: t.i32() }); +const entity = table({ name: 'entity' }, { + id: t.u64().primaryKey().autoInc(), + pos: Position, +}); + +// Sum type (tagged union): +const Shape = t.enum('Shape', { + circle: t.i32(), + rectangle: t.object('Rect', { w: t.i32(), h: t.i32() }), +}); +// Values: { tag: 'circle', value: 10 } +``` + +## Views + +A client subscribing to a view receives only the rows it returns. Use a per-user view +(keyed on `ctx.sender`) for per-viewer access control: deleting a row it depends on +(e.g. a membership row) automatically drops the rows it was exposing from that client. + +```typescript +// Anonymous view (same for all clients): +export const activeUsers = spacetimedb.anonymousView( + { name: 'active_users', public: true }, + t.array(entity.rowType), + (ctx) => [...ctx.db.entity.iter()].filter(e => e.active) +); + +// Per-user view (varies by ctx.sender): +export const myProfile = spacetimedb.view( + { name: 'my_profile', public: true }, + t.option(entity.rowType), + (ctx) => ctx.db.entity.identity.find(ctx.sender) ?? undefined +); +``` diff --git a/codex-plugin/plugins/spacetimedb/skills/unity/SKILL.md b/codex-plugin/plugins/spacetimedb/skills/unity/SKILL.md new file mode 100644 index 00000000000..61d523f1e97 --- /dev/null +++ b/codex-plugin/plugins/spacetimedb/skills/unity/SKILL.md @@ -0,0 +1,222 @@ +--- +name: unity +description: Integrate SpacetimeDB with Unity game projects. Use when building Unity clients with MonoBehaviour lifecycle, FrameTick, and PlayerPrefs token persistence. +license: Apache-2.0 +metadata: + author: clockworklabs + version: "2.0" + role: client + language: csharp + cursor_globs: "**/*.cs" + cursor_always_apply: false + tested_with: "SpacetimeDB 2.0, Unity 2022.3+" +--- + +# SpacetimeDB Unity Integration + +This skill covers Unity-specific patterns for connecting to SpacetimeDB. For server-side module development, see the `csharp-server` skill. + +--- + +## Installation + +Add via Unity Package Manager using the git URL: + +``` +https://github.com/clockworklabs/com.clockworklabs.spacetimedbsdk.git +``` + +**Window > Package Manager > + > Add package from git URL** + +--- + +## Generate Module Bindings + +```bash +spacetime generate --lang csharp --out-dir Assets/SpacetimeDB/module_bindings --module-path PATH_TO_MODULE +``` + +Place generated files in your Assets folder so Unity compiles them. + +--- + +## SpacetimeManager Singleton + +The core pattern for Unity integration. This MonoBehaviour manages the connection lifecycle. + +```csharp +using UnityEngine; +using SpacetimeDB; +using SpacetimeDB.Types; + +public class SpacetimeManager : MonoBehaviour +{ + private const string TOKEN_KEY = "SpacetimeAuthToken"; + private const string SERVER_URI = "http://localhost:3000"; + private const string DATABASE_NAME = "my-game"; + + public static SpacetimeManager Instance { get; private set; } + public DbConnection Connection { get; private set; } + public Identity LocalIdentity { get; private set; } + + void Awake() + { + if (Instance != null && Instance != this) { Destroy(gameObject); return; } + Instance = this; + DontDestroyOnLoad(gameObject); + } + + void Start() + { + string savedToken = PlayerPrefs.GetString(TOKEN_KEY, null); + + Connection = DbConnection.Builder() + .WithUri(SERVER_URI) + .WithDatabaseName(DATABASE_NAME) + .WithToken(savedToken) + .OnConnect(OnConnected) + .OnConnectError(err => Debug.LogError($"Connection failed: {err}")) + .OnDisconnect((conn, err) => { + if (err != null) Debug.LogError($"Disconnected: {err}"); + }) + .Build(); + } + + void Update() + { + Connection?.FrameTick(); + } + + void OnDestroy() + { + Connection?.Disconnect(); + } + + private void OnConnected(DbConnection conn, Identity identity, string authToken) + { + LocalIdentity = identity; + PlayerPrefs.SetString(TOKEN_KEY, authToken); + PlayerPrefs.Save(); + + Debug.Log($"Connected as: {identity}"); + + conn.SubscriptionBuilder() + .OnApplied(OnSubscriptionApplied) + .SubscribeToAllTables(); + } + + private void OnSubscriptionApplied(SubscriptionEventContext ctx) + { + Debug.Log("Subscription applied: game state loaded"); + } +} +``` + +--- + +## FrameTick (Critical) + +**`FrameTick()` must be called every frame in `Update()`.** The SDK queues all network messages and only processes them when you call `FrameTick()`. Without it, no callbacks fire and the client appears frozen. See the `Update()` method in the SpacetimeManager above. + +**Thread safety**: `FrameTick()` processes messages on the calling thread (the main thread in Unity). Do NOT call it from a background thread. Do NOT access `conn.Db` from background threads. + +--- + +## Row Callbacks for Game State + +Register callbacks to update Unity GameObjects when table data changes. + +```csharp +void RegisterCallbacks() +{ + Connection.Db.Player.OnInsert += (EventContext ctx, Player player) => { + SpawnPlayerObject(player); + }; + + Connection.Db.Player.OnDelete += (EventContext ctx, Player player) => { + DestroyPlayerObject(player.Id); + }; + + Connection.Db.Player.OnUpdate += (EventContext ctx, Player oldPlayer, Player newPlayer) => { + UpdatePlayerObject(newPlayer); + }; +} +``` + +Register these in `OnSubscriptionApplied` (after initial data is loaded) or in `Start()` before connecting. + +--- + +## Calling Reducers from UI + +```csharp +public class GameUI : MonoBehaviour +{ + public void OnMoveButtonClicked(Vector2 direction) + { + SpacetimeManager.Instance.Connection.Reducers.MovePlayer(direction.x, direction.y); + } + + public void OnSendChat(string message) + { + SpacetimeManager.Instance.Connection.Reducers.SendMessage(message); + } +} +``` + +### Reducer Callbacks + +```csharp +SpacetimeManager.Instance.Connection.Reducers.OnSendMessage += (ReducerEventContext ctx, string text) => { + if (ctx.Event.Status is Status.Committed) + Debug.Log($"Message sent: {text}"); + else if (ctx.Event.Status is Status.Failed(var reason)) + Debug.LogError($"Send failed: {reason}"); +}; +``` + +--- + +## Reading the Client Cache + +```csharp +// Find by primary key +if (Connection.Db.Player.Id.Find(playerId) is Player player) +{ + Debug.Log($"Player: {player.Name}"); +} + +// Iterate all +foreach (var p in Connection.Db.Player.Iter()) +{ + Debug.Log(p.Name); +} + +// Filter by index +foreach (var p in Connection.Db.Player.Level.Filter(5)) +{ + Debug.Log($"Level 5: {p.Name}"); +} + +// Count +int total = Connection.Db.Player.Count; +``` + +--- + +## Unity-Specific Considerations + +### Main Thread Only +All SpacetimeDB SDK calls (`FrameTick`, `conn.Db` access, reducer calls) must happen on the main thread. If you need to pass data to a background thread, copy it first in the callback. + +### Scene Loading +Use `DontDestroyOnLoad(gameObject)` on the SpacetimeManager to prevent the connection from being destroyed during scene transitions. Without it, the connection drops every time you load a new scene. + +### IL2CPP / AOT +The SpacetimeDB SDK uses code generation. If you encounter issues with IL2CPP builds: +- Ensure generated bindings are up to date +- Check that `link.xml` preserves SpacetimeDB types if you use assembly stripping + +### Token Persistence +Token save/load via `PlayerPrefs` is demonstrated in the SpacetimeManager singleton above. Persisting the server-issued token and passing it back on reconnect keeps the same identity; without a saved token the server issues a new identity in the `OnConnect` callback. This token does not expire and a lost one can't be recovered, so self-issued identities are for development. For production, authenticate with an OIDC provider such as SpacetimeAuth, which handles token lifecycle. + diff --git a/codex-plugin/plugins/spacetimedb/skills/unreal/SKILL.md b/codex-plugin/plugins/spacetimedb/skills/unreal/SKILL.md new file mode 100644 index 00000000000..1995bed1e5e --- /dev/null +++ b/codex-plugin/plugins/spacetimedb/skills/unreal/SKILL.md @@ -0,0 +1,443 @@ +--- +name: unreal +description: SpacetimeDB Unreal Engine client SDK reference. Use when building Unreal Engine clients that connect to SpacetimeDB. +license: Apache-2.0 +metadata: + author: clockworklabs + version: "2.0" + role: client + language: cpp + cursor_globs: "**/*.cpp,**/*.h" + cursor_always_apply: true +--- + +# SpacetimeDB Unreal Engine Integration + +This skill covers Unreal Engine-specific patterns for connecting to SpacetimeDB. For server-side module development, see the `rust-server` or `csharp-server` skills. + +--- + +## Installation + +Add the SpacetimeDB Unreal SDK as a plugin: + +1. Create a `Plugins` folder in your Unreal project root if it does not exist. +2. Copy the `SpacetimeDbSdk` folder into `Plugins/`. +3. Right-click your `.uproject` file and select **Generate Visual Studio project files**. +4. Add `"SpacetimeDbSdk"` to your module's `Build.cs`: + +```csharp +PublicDependencyModuleNames.AddRange(new string[] { "SpacetimeDbSdk" }); +``` + +--- + +## Generate Module Bindings + +```bash +spacetime generate --lang unrealcpp \ + --uproject-dir \ + --module-path \ + --unreal-module-name +``` + +This generates C++ bindings in `ModuleBindings/` inside your project. Include the generated header: + +```cpp +#include "ModuleBindings/SpacetimeDBClient.g.h" +``` + +Regenerate whenever you change module tables, reducers, or types. + +--- + +## GameManager Actor Pattern + +The recommended pattern is a singleton Actor that owns the connection. Enable ticking so `FrameTick` is called every frame. + +### Header (GameManager.h) + +```cpp +#pragma once +#include "CoreMinimal.h" +#include "GameFramework/Actor.h" +#include "ModuleBindings/SpacetimeDBClient.g.h" +#include "GameManager.generated.h" + +class UDbConnection; + +UCLASS() +class AGameManager : public AActor +{ + GENERATED_BODY() +public: + AGameManager(); + static AGameManager* Instance; + + UPROPERTY(EditAnywhere, Category="SpacetimeDB") + FString ServerUri = TEXT("127.0.0.1:3000"); + + UPROPERTY(EditAnywhere, Category="SpacetimeDB") + FString DatabaseName = TEXT("my-module"); + + UPROPERTY(BlueprintReadOnly, Category="SpacetimeDB") + UDbConnection* Conn = nullptr; + + UPROPERTY(BlueprintReadOnly, Category="SpacetimeDB") + FSpacetimeDBIdentity LocalIdentity; + +protected: + virtual void BeginPlay() override; + virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override; +public: + virtual void Tick(float DeltaTime) override; + +private: + UFUNCTION() void HandleConnect(UDbConnection* InConn, FSpacetimeDBIdentity Identity, const FString& Token); + UFUNCTION() void HandleConnectError(const FString& Error); + UFUNCTION() void HandleDisconnect(UDbConnection* InConn, const FString& Error); + UFUNCTION() void HandleSubscriptionApplied(FSubscriptionEventContext& Context); +}; +``` + +### Source (GameManager.cpp) + +```cpp +#include "GameManager.h" +#include "Connection/Credentials.h" + +AGameManager* AGameManager::Instance = nullptr; + +AGameManager::AGameManager() +{ + PrimaryActorTick.bCanEverTick = true; + PrimaryActorTick.bStartWithTickEnabled = true; +} + +void AGameManager::BeginPlay() +{ + Super::BeginPlay(); + Instance = this; + + FOnConnectDelegate ConnectDelegate; + BIND_DELEGATE_SAFE(ConnectDelegate, this, AGameManager, HandleConnect); + FOnDisconnectDelegate DisconnectDelegate; + BIND_DELEGATE_SAFE(DisconnectDelegate, this, AGameManager, HandleDisconnect); + FOnConnectErrorDelegate ConnectErrorDelegate; + BIND_DELEGATE_SAFE(ConnectErrorDelegate, this, AGameManager, HandleConnectError); + + UCredentials::Init(TEXT(".spacetime_token")); + FString Token = UCredentials::LoadToken(); + + UDbConnectionBuilder* Builder = UDbConnection::Builder() + ->WithUri(ServerUri) + ->WithDatabaseName(DatabaseName) + ->OnConnect(ConnectDelegate) + ->OnDisconnect(DisconnectDelegate) + ->OnConnectError(ConnectErrorDelegate); + + if (!Token.IsEmpty()) + { + Builder->WithToken(Token); + } + + Conn = Builder->Build(); +} + +void AGameManager::EndPlay(const EEndPlayReason::Type EndPlayReason) +{ + if (Conn) { Conn->Disconnect(); Conn = nullptr; } + if (Instance == this) { Instance = nullptr; } + Super::EndPlay(EndPlayReason); +} + +void AGameManager::Tick(float DeltaTime) +{ + if (Conn && Conn->IsActive()) + { + Conn->FrameTick(); + } +} + +void AGameManager::HandleConnect(UDbConnection* InConn, FSpacetimeDBIdentity Identity, const FString& Token) +{ + LocalIdentity = Identity; + UCredentials::SaveToken(Token); + + FOnSubscriptionApplied AppliedDelegate; + BIND_DELEGATE_SAFE(AppliedDelegate, this, AGameManager, HandleSubscriptionApplied); + Conn->SubscriptionBuilder() + ->OnApplied(AppliedDelegate) + ->SubscribeToAllTables(); +} + +void AGameManager::HandleConnectError(const FString& Error) +{ + UE_LOG(LogTemp, Error, TEXT("Connection error: %s"), *Error); +} + +void AGameManager::HandleDisconnect(UDbConnection* InConn, const FString& Error) +{ + UE_LOG(LogTemp, Warning, TEXT("Disconnected: %s"), *Error); +} + +void AGameManager::HandleSubscriptionApplied(FSubscriptionEventContext& Context) +{ + UE_LOG(LogTemp, Log, TEXT("Subscription applied - game state loaded")); +} +``` + +--- + +## FrameTick -- Critical + +**You must either call `Conn->FrameTick()` every frame in your Actor's `Tick()`, or call `Conn->SetAutoTicking(true)` once at startup.** The SDK queues all network messages and only processes them on tick. Without one of these, no callbacks fire and the client appears frozen. + +--- + +## Connection Builder + +Build a connection with the builder pattern. All builder methods return pointers for chaining with `->`. + +```cpp +UDbConnection* Conn = UDbConnection::Builder() + ->WithUri(TEXT("127.0.0.1:3000")) + ->WithDatabaseName(TEXT("my-module")) + ->WithToken(SavedToken) // optional + ->WithCompression(ESpacetimeDBCompression::Gzip) // optional + ->OnConnect(ConnectDelegate) + ->OnConnectError(ErrorDelegate) + ->OnDisconnect(DisconnectDelegate) + ->Build(); +``` + +### OnConnect callback signature + +```cpp +UFUNCTION() +void OnConnected(UDbConnection* Connection, FSpacetimeDBIdentity Identity, const FString& Token); +``` + +Save the `Token` for future reconnection. The `Identity` is the user's persistent identifier. + +--- + +## Subscribing to Tables + +After connecting, subscribe to receive table data: + +```cpp +// Subscribe to all public tables +Conn->SubscriptionBuilder() + ->OnApplied(AppliedDelegate) + ->SubscribeToAllTables(); + +// Subscribe to specific queries +TArray Queries = { TEXT("SELECT * FROM player"), TEXT("SELECT * FROM entity") }; +Conn->SubscriptionBuilder() + ->OnApplied(AppliedDelegate) + ->OnError(ErrorDelegate) + ->Subscribe(Queries); +``` + +### Subscription Handle + +`Subscribe` and `SubscribeToAllTables` return a `USubscriptionHandle*`: + +```cpp +USubscriptionHandle* Handle = Conn->SubscriptionBuilder()->...->Subscribe(Queries); +Handle->IsActive(); // true while subscription is live +Handle->Unsubscribe(); // cancel the subscription +Handle->UnsubscribeThen(OnEndDelegate); // cancel with callback +Handle->GetQuerySqls(); // get the SQL queries +``` + +--- + +## Reading the Client Cache + +Access tables through `Conn->Db`: + +```cpp +// Find by unique/primary key (returns by value; default-constructed if not found) +FUserType User = Conn->Db->User->Identity->Find(SomeIdentity); + +// Filter by BTree index +TArray LevelFive = Conn->Db->Player->Level->Filter(5); + +// Iterate all rows +TArray AllEntities = Conn->Db->Entity->Iter(); + +// Count +int32 Total = Conn->Db->Player->Count(); +``` + +--- + +## Row Callbacks + +Register callbacks on table objects. Callbacks use Unreal dynamic multicast delegates. + +```cpp +// OnInsert +Conn->Db->User->OnInsert.AddDynamic(this, &AMyActor::OnUserInsert); + +// OnDelete +Conn->Db->User->OnDelete.AddDynamic(this, &AMyActor::OnUserDelete); + +// OnUpdate (only fires for rows with a primary key) +Conn->Db->User->OnUpdate.AddDynamic(this, &AMyActor::OnUserUpdate); +``` + +### Callback signatures (must be UFUNCTION) + +```cpp +UFUNCTION() +void OnUserInsert(const FEventContext& Context, const FUserType& NewRow); + +UFUNCTION() +void OnUserDelete(const FEventContext& Context, const FUserType& DeletedRow); + +UFUNCTION() +void OnUserUpdate(const FEventContext& Context, const FUserType& OldRow, const FUserType& NewRow); +``` + +Register callbacks before connecting or in `HandleSubscriptionApplied`. + +--- + +## Calling Reducers + +Invoke reducers through `Conn->Reducers`: + +```cpp +Conn->Reducers->SendMessage(TEXT("Hello!")); +Conn->Reducers->SetName(TEXT("Alice")); +Conn->Reducers->MovePlayer(1.0f, 0.0f); +``` + +### Reducer Result Callbacks + +Observe when a reducer you called completes: + +```cpp +Conn->Reducers->OnSendMessage.AddDynamic(this, &AMyActor::OnSendMessageResult); +``` + +```cpp +UFUNCTION() +void OnSendMessageResult(const FReducerEventContext& Context, const FString& Text) +{ + UE_LOG(LogTemp, Log, TEXT("SendMessage result for: %s"), *Text); +} +``` + +These delegates fire only for reducer calls made by this connection, not for other clients' calls. + +--- + +## Delegate Binding with BIND_DELEGATE_SAFE + +Use the `BIND_DELEGATE_SAFE` macro to safely bind delegates to member functions: + +```cpp +FOnConnectDelegate ConnectDelegate; +BIND_DELEGATE_SAFE(ConnectDelegate, this, AMyActor, HandleConnect); +``` + +This is the recommended pattern for all SpacetimeDB delegate bindings in C++. + +--- + +## Identity and ConnectionId + +```cpp +// FSpacetimeDBIdentity -- 256-bit unique user identifier, persists across connections +FSpacetimeDBIdentity Identity; +Identity.ToHex(); + +// FSpacetimeDBConnectionId -- 128-bit per-session connection identifier +FSpacetimeDBConnectionId ConnId = Conn->GetConnectionId(); + +// From any context +FSpacetimeDBIdentity Id; +bool Found = Context.TryGetIdentity(Id); +FSpacetimeDBConnectionId CId = Context.GetConnectionId(); +``` + +--- + +## Token Persistence + +Use the built-in `UCredentials` helper to save and load tokens. Passing the saved server-issued token back on reconnect keeps the same identity. This token does not expire and a lost one can't be recovered, so self-issued identities are for development. For production, authenticate with an OIDC provider such as SpacetimeAuth, which handles token lifecycle. + +```cpp +UCredentials::Init(TEXT(".spacetime_token")); +FString Token = UCredentials::LoadToken(); +// ... after connect: +UCredentials::SaveToken(Token); +``` + +--- + +## Context Types + +All callbacks receive a context struct that provides access to `Db` and `Reducers`: + +| Type | Used In | +|------|---------| +| `FEventContext` | Table row callbacks (OnInsert, OnDelete, OnUpdate) | +| `FReducerEventContext` | Reducer result callbacks | +| `FSubscriptionEventContext` | Subscription lifecycle callbacks (OnApplied, OnError) | +| `FErrorContext` | Error callbacks | + +All inherit from `FContextBase` which provides: + +```cpp +Context.Db // URemoteTables* -- client cache +Context.Reducers // URemoteReducers* -- invoke reducers +Context.SubscriptionBuilder() // start a new subscription +``` + +--- + +## Blueprint Integration + +All core classes are Blueprint-accessible via `UFUNCTION(BlueprintCallable)` and `UPROPERTY(BlueprintReadOnly/BlueprintAssignable)`: + +- `UDbConnection::Builder()` and all builder methods are `BlueprintCallable`. +- Table callbacks (`OnInsert`, `OnDelete`, `OnUpdate`) are `BlueprintAssignable` delegates. +- Reducer invoke methods and result delegates are Blueprint-accessible. +- `Conn->Db` and `Conn->Reducers` are `BlueprintReadOnly` properties. +- Generated row types are `BlueprintType` USTRUCTs with `BlueprintReadWrite` properties. + +This means you can build the entire connection and callback flow in Blueprints without writing C++. + +--- + +## Unreal-Specific Considerations + +### Auto Ticking Alternative + +`UDbConnection` inherits from `FTickableGameObject`, but auto ticking is **off by default**. You have two options: + +```cpp +// Option 1: Call FrameTick() manually in your Actor's Tick() (shown in GameManager above) +void Tick(float DeltaTime) { Conn->FrameTick(); } + +// Option 2: Enable auto ticking. The SDK then processes messages every frame automatically +Conn->SetAutoTicking(true); +``` + +Pick one. Without either, no callbacks fire. + +### Compression + +```cpp +Builder->WithCompression(ESpacetimeDBCompression::Gzip); // default +Builder->WithCompression(ESpacetimeDBCompression::None); // no compression +``` + +### Generated Types + +Codegen produces USTRUCTs prefixed with `F` (e.g., `FUserType`, `FEntityType`) and table classes prefixed with `U` (e.g., `UUserTable`). Row types use `GENERATED_BODY()` and `UPROPERTY()` for full reflection support. diff --git a/crates/cli/src/api.rs b/crates/cli/src/api.rs index d40b03ee87e..37c1e9f9da2 100644 --- a/crates/cli/src/api.rs +++ b/crates/cli/src/api.rs @@ -31,6 +31,10 @@ impl Connection { ] .concat() } + + pub fn host_uri(&self, endpoint: &str) -> String { + [&self.host, "/v1/", endpoint].concat() + } } pub fn build_client(con: &Connection) -> Client { diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index def9a0928cb..9411e6f5e5e 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -28,6 +28,7 @@ pub fn get_subcommands() -> Vec { describe::cli(), dev::cli(), sql::cli(), + mcp::cli(), dns::cli(), generate::cli(), list::cli(), @@ -59,6 +60,7 @@ pub async fn exec_subcommand( "delete" => delete::exec(config, args).await, "logs" => logs::exec(config, args).await, "sql" => sql::exec(config, args).await, + "mcp" => mcp::exec(config, args).await, "rename" => dns::exec(config, args).await, "generate" => generate::exec(config, args).await, "list" => list::exec(config, args).await, diff --git a/crates/cli/src/subcommands/mcp.rs b/crates/cli/src/subcommands/mcp.rs new file mode 100644 index 00000000000..7d679c121c7 --- /dev/null +++ b/crates/cli/src/subcommands/mcp.rs @@ -0,0 +1,71 @@ +use crate::api::{build_client, Connection}; +use crate::common_args; +use crate::config::Config; +use crate::util::{auth_header_from_saved_token, database_identity, ResponseExt, UNSTABLE_WARNING}; +use anyhow::Context; +use clap::{Arg, ArgMatches}; +use spacetimedb_lib::Identity; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + +pub fn cli() -> clap::Command { + clap::Command::new("mcp") + .about(format!( + "Serve SpacetimeDB to MCP-aware agents and editors over stdio. {UNSTABLE_WARNING}" + )) + .arg(Arg::new("database").required(false).env("SPACETIMEDB_DB_NAME").help( + "The name or identity of a single database to serve. Falls back to the SPACETIMEDB_DB_NAME environment variable. Omit it to serve the whole server, where each tool takes a database argument instead", + )) + .arg(common_args::server().help("The nickname, host name or URL of the server hosting the database")) + .arg(common_args::anonymous()) + .after_help("Run `spacetime help mcp` for more detailed information.\n") +} + +pub async fn exec(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { + eprintln!("{UNSTABLE_WARNING}\n"); + + let database = args.get_one::("database"); + let server = args.get_one::("server").map(|s| s.as_ref()); + let anon_identity = args.get_flag("anon_identity"); + + let conn = Connection { + host: config.get_host_url(server)?, + auth_header: auth_header_from_saved_token(&config, anon_identity), + database_identity: match database { + Some(database) => database_identity(&config, database, server).await?, + None => Identity::ZERO, + }, + database: database.cloned().unwrap_or_default(), + }; + + let client = build_client(&conn); + let url = match database { + Some(_) => conn.db_uri("mcp"), + None => conn.host_uri("mcp"), + }; + eprintln!("Serving MCP over stdio, bridging to {url}"); + + let mut lines = BufReader::new(tokio::io::stdin()).lines(); + let mut stdout = tokio::io::stdout(); + while let Some(line) = lines.next_line().await? { + if line.trim().is_empty() { + continue; + } + let response = client + .post(url.as_str()) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(line) + .send() + .await + .with_context(|| format!("could not reach the SpacetimeDB host at {}", conn.host))?; + // a notification answered with empty 202 body + if response.status() == reqwest::StatusCode::ACCEPTED { + continue; + } + let body = response.ensure_content_type("application/json").await?.text().await?; + stdout.write_all(body.as_bytes()).await?; + stdout.write_all(b"\n").await?; + stdout.flush().await?; + } + + Ok(()) +} diff --git a/crates/cli/src/subcommands/mod.rs b/crates/cli/src/subcommands/mod.rs index ef51b66f055..af0d2e364c3 100644 --- a/crates/cli/src/subcommands/mod.rs +++ b/crates/cli/src/subcommands/mod.rs @@ -12,6 +12,7 @@ pub mod lock; pub mod login; pub mod logout; pub mod logs; +pub mod mcp; pub mod publish; pub mod repl; pub mod server; diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs index ea9b15c12da..2b199f07467 100644 --- a/crates/cli/src/util.rs +++ b/crates/cli/src/util.rs @@ -199,6 +199,15 @@ impl AuthHeader { } } +pub fn auth_header_from_saved_token(config: &Config, anon_identity: bool) -> AuthHeader { + let token = if anon_identity { + None + } else { + config.spacetimedb_token().cloned() + }; + AuthHeader { token } +} + pub const VALID_PROTOCOLS: [&str; 2] = ["http", "https"]; #[derive(Clone, Copy, PartialEq, Debug)] diff --git a/crates/client-api/src/routes/mcp.rs b/crates/client-api/src/routes/mcp.rs index 9f60644735b..03b2f8d9abf 100644 --- a/crates/client-api/src/routes/mcp.rs +++ b/crates/client-api/src/routes/mcp.rs @@ -8,6 +8,8 @@ use serde::Deserialize; use serde_json::{json, Value}; use spacetimedb::auth::identity::ConnectionAuthCtx; use spacetimedb::host::{FunctionArgs, ReducerOutcome}; +use spacetimedb::identity::Identity; +use spacetimedb::messages::control_db::Database; use spacetimedb_lib::db::raw_def::v9::RawModuleDefV9; use spacetimedb_lib::sats; @@ -41,13 +43,36 @@ pub struct McpParams { name_or_identity: NameOrIdentity, } -/// handle MCP JSON-RPC request +/// handle MCP JSON-RPC request for the database named in the URL pub async fn mcp( State(ctx): State, Path(McpParams { name_or_identity }): Path, Extension(auth): Extension, Json(request): Json, ) -> axum::response::Result +where + S: ControlStateDelegate + NodeDelegate + Authorization + Clone + 'static, +{ + handle_mcp(&ctx, Some(name_or_identity), auth, request).await +} + +pub async fn mcp_root( + State(ctx): State, + Extension(auth): Extension, + Json(request): Json, +) -> axum::response::Result +where + S: ControlStateDelegate + NodeDelegate + Authorization + Clone + 'static, +{ + handle_mcp(&ctx, None, auth, request).await +} + +async fn handle_mcp( + ctx: &S, + scope: Option, + auth: SpacetimeAuth, + request: Value, +) -> axum::response::Result where S: ControlStateDelegate + NodeDelegate + Authorization + Clone + 'static, { @@ -60,12 +85,13 @@ where return Ok(Json(jsonrpc_error(&id, INVALID_REQUEST, "invalid request: missing method")).into_response()); }; + let host_wide = scope.is_none(); let body = match method { - "initialize" => jsonrpc_result(&id, initialize_result()), + "initialize" => jsonrpc_result(&id, initialize_result(host_wide)), // protocol ping, distinct from the ping tool "ping" => jsonrpc_result(&id, json!({})), - "tools/list" => jsonrpc_result(&id, tools_list()), - "tools/call" => match tools_call(&ctx, name_or_identity, auth, request.get("params")).await { + "tools/list" => jsonrpc_result(&id, tools_list(host_wide)), + "tools/call" => match tools_call(ctx, scope, auth, request.get("params")).await { Ok(result) => jsonrpc_result(&id, result), Err((code, message)) => jsonrpc_error(&id, code, message), }, @@ -82,68 +108,104 @@ fn jsonrpc_error(id: &Value, code: i64, message: impl Into) -> Value { json!({ "jsonrpc": JSONRPC_VERSION, "id": id, "error": { "code": code, "message": message.into() } }) } -fn initialize_result() -> Value { +fn initialize_result(host_wide: bool) -> Value { + let instructions = if host_wide { + "Tools for the SpacetimeDB databases you can reach on this host. Every data tool takes a \ + `database` argument, either a name or an identity. Use list_databases to see the ones you \ + own, get_schema to see a database's tables and reducers, sql to query data, and call to \ + invoke a reducer. Reducers are the usual way to write, and SQL writes require ownership. \ + Everything runs with your identity, exactly as over the HTTP API." + } else { + "Tools for the addressed SpacetimeDB database: ping, get_schema, sql, and call. \ + Use get_schema to see its tables and reducers, sql to query data, and call to \ + invoke a reducer. Reducers are the usual way to write, and SQL writes require \ + ownership. Everything runs with your identity, exactly as over the HTTP API." + }; json!({ "protocolVersion": PROTOCOL_VERSION, "capabilities": { "tools": {} }, "serverInfo": { "name": "spacetimedb", "version": env!("CARGO_PKG_VERSION") }, - "instructions": "Tools for the addressed SpacetimeDB database: ping, get_schema, sql, and call. \ - Use get_schema to see its tables and reducers, sql to query data, and call to \ - invoke a reducer. Reducers are the usual way to write, and SQL writes require \ - ownership. Everything runs with your identity, exactly as over the HTTP API.", + "instructions": instructions, }) } -fn tools_list() -> Value { - json!({ - "tools": [ - { - "name": "ping", - "description": "Health check that echoes an optional message back.", - "inputSchema": { "type": "object", "properties": { "message": { "type": "string" } } }, - "annotations": { "readOnlyHint": true } - }, - { - "name": "get_schema", - "description": "Get the schema for this database as JSON, including its typespace, tables, and reducers.", - "inputSchema": { "type": "object", "properties": {} }, - "annotations": { "readOnlyHint": true } - }, - { - "name": "sql", - "description": "Run a SQL query against this database and return the rows as JSON. \ - Write queries require ownership of the database.", - "inputSchema": { - "type": "object", - "properties": { - "sql": { "type": "string", "description": "The SQL statement to execute." }, - "confirmed": { "type": "boolean", "description": "Wait for the read to be durably confirmed." } - }, - "required": ["sql"] - }, - "annotations": { "readOnlyHint": false, "destructiveHint": true } - }, - { - "name": "call", - "description": "Invoke a reducer with positional JSON arguments, for example [\"alice\"] or [42]. \ - The reducer runs with your identity and is the standard way to write.", - "inputSchema": { - "type": "object", - "properties": { - "reducer": { "type": "string", "description": "The name of the reducer to invoke." }, - "args": { "type": "array", "description": "A JSON array of arguments to the reducer, in order. Omit or pass [] for none." } - }, - "required": ["reducer"] - }, - "annotations": { "readOnlyHint": false, "destructiveHint": true } - } - ] - }) +fn database_property() -> Value { + json!({ "type": "string", "description": "The name or identity of the target database." }) +} + +fn input_schema(properties: Value, required: Vec<&str>) -> Value { + let mut schema = json!({ "type": "object", "properties": properties }); + if !required.is_empty() { + schema["required"] = json!(required); + } + schema +} + +fn tools_list(host_wide: bool) -> Value { + let mut get_schema_properties = json!({}); + let mut sql_properties = json!({ + "sql": { "type": "string", "description": "The SQL statement to execute." }, + "confirmed": { "type": "boolean", "description": "Wait for the read to be durably confirmed." } + }); + let mut call_properties = json!({ + "reducer": { "type": "string", "description": "The name of the reducer to invoke." }, + "args": { "type": "array", "description": "A JSON array of arguments to the reducer, in order. Omit or pass [] for none." } + }); + let mut get_schema_required = vec![]; + let mut sql_required = vec!["sql"]; + let mut call_required = vec!["reducer"]; + + if host_wide { + get_schema_properties["database"] = database_property(); + sql_properties["database"] = database_property(); + call_properties["database"] = database_property(); + get_schema_required.push("database"); + sql_required.insert(0, "database"); + call_required.insert(0, "database"); + } + + let mut tools = vec![]; + if host_wide { + tools.push(json!({ + "name": "list_databases", + "description": "List the databases you own on this host, with their identity and names.", + "inputSchema": { "type": "object", "properties": {} }, + "annotations": { "title": "List databases", "readOnlyHint": true, "destructiveHint": false, "openWorldHint": false } + })); + } + tools.push(json!({ + "name": "ping", + "description": "Health check that echoes an optional message back.", + "inputSchema": { "type": "object", "properties": { "message": { "type": "string" } } }, + "annotations": { "title": "Ping", "readOnlyHint": true, "destructiveHint": false, "openWorldHint": false } + })); + tools.push(json!({ + "name": "get_schema", + "description": "Get the schema for the database as JSON, including its typespace, tables, and reducers.", + "inputSchema": input_schema(get_schema_properties, get_schema_required), + "annotations": { "title": "Get schema", "readOnlyHint": true, "destructiveHint": false, "openWorldHint": false } + })); + tools.push(json!({ + "name": "sql", + "description": "Run a SQL query against the database and return the rows as JSON. \ + Write queries require ownership of the database.", + "inputSchema": input_schema(sql_properties, sql_required), + "annotations": { "title": "Run SQL", "readOnlyHint": false, "destructiveHint": true, "openWorldHint": false } + })); + tools.push(json!({ + "name": "call", + "description": "Invoke a reducer with positional JSON arguments, for example [\"alice\"] or [42]. \ + The reducer runs with your identity and is the standard way to write.", + "inputSchema": input_schema(call_properties, call_required), + "annotations": { "title": "Call reducer", "readOnlyHint": false, "destructiveHint": true, "openWorldHint": false } + })); + + json!({ "tools": tools }) } async fn tools_call( ctx: &S, - name_or_identity: NameOrIdentity, + scope: Option, auth: SpacetimeAuth, params: Option<&Value>, ) -> Result @@ -163,20 +225,24 @@ where Some(message) => format!("pong: {message}"), None => "pong".to_owned(), }), - "get_schema" => tool_get_schema(ctx, name_or_identity).await, + // offered only host-wide + "list_databases" if scope.is_none() => tool_list_databases(ctx, auth.claims.identity).await, + "get_schema" => tool_get_schema(ctx, target_database(&scope, arguments)?).await, "sql" => { + let target = target_database(&scope, arguments)?; let Some(sql) = arguments.and_then(|a| a.get("sql")).and_then(Value::as_str) else { return Err((INVALID_PARAMS, "sql argument must be a string".to_owned())); }; let confirmed = arguments.and_then(|a| a.get("confirmed")).and_then(Value::as_bool); - tool_sql(ctx, name_or_identity, auth, sql.to_owned(), confirmed).await + tool_sql(ctx, target, auth, sql.to_owned(), confirmed).await } "call" => { + let target = target_database(&scope, arguments)?; let Some(reducer) = arguments.and_then(|a| a.get("reducer")).and_then(Value::as_str) else { return Err((INVALID_PARAMS, "reducer argument must be a string".to_owned())); }; let args_json = reducer_args_json(arguments)?; - tool_call_reducer(ctx, name_or_identity, auth, reducer.to_owned(), args_json).await + tool_call_reducer(ctx, target, auth, reducer.to_owned(), args_json).await } other => return Err((INVALID_PARAMS, format!("unknown tool: {other}"))), }; @@ -187,6 +253,17 @@ where }) } +fn target_database(scope: &Option, arguments: Option<&Value>) -> Result { + if let Some(name_or_identity) = scope { + return Ok(name_or_identity.clone()); + } + let Some(database) = arguments.and_then(|a| a.get("database")).and_then(Value::as_str) else { + return Err((INVALID_PARAMS, "database argument must be a string".to_owned())); + }; + serde_json::from_value(Value::String(database.to_owned())) + .map_err(|e| (INVALID_PARAMS, format!("invalid database '{database}': {e}"))) +} + fn reducer_args_json(arguments: Option<&Value>) -> Result { match arguments.and_then(|a| a.get("args")) { None | Some(Value::Null) => Ok("[]".to_owned()), @@ -207,6 +284,34 @@ async fn execution_error_to_tool_result(err: ErrorResponse) -> Value { json!({ "content": [ { "type": "text", "text": text } ], "isError": true }) } +fn owned_by(databases: Vec, caller: Identity) -> Vec { + databases + .into_iter() + .filter(|database| database.owner_identity == caller) + .collect() +} + +/// list only caller own databases +async fn tool_list_databases(ctx: &S, caller: Identity) -> axum::response::Result +where + S: ControlStateDelegate, +{ + let owned = owned_by(ctx.get_databases().await.map_err(log_and_500)?, caller); + + let mut databases = Vec::new(); + for database in owned { + let names = ctx + .reverse_lookup(&database.database_identity) + .await + .map_err(log_and_500)?; + databases.push(json!({ + "identity": database.database_identity.to_hex().to_string(), + "names": names.iter().map(ToString::to_string).collect::>(), + })); + } + serde_json::to_string(&json!({ "databases": databases })).map_err(log_and_500) +} + async fn tool_get_schema(ctx: &S, name_or_identity: NameOrIdentity) -> axum::response::Result where S: ControlStateDelegate + NodeDelegate, @@ -302,15 +407,21 @@ mod tests { #[test] fn initialize_advertises_tools_and_identity() { - let info = initialize_result(); - assert_eq!(info["serverInfo"]["name"], "spacetimedb"); - assert_eq!(info["protocolVersion"], PROTOCOL_VERSION); - assert!(info["capabilities"]["tools"].is_object()); + for host_wide in [false, true] { + let info = initialize_result(host_wide); + assert_eq!(info["serverInfo"]["name"], "spacetimedb"); + assert_eq!(info["protocolVersion"], PROTOCOL_VERSION); + assert!(info["capabilities"]["tools"].is_object()); + assert!(info["instructions"].as_str().unwrap().contains("SpacetimeDB")); + } + + let host_wide = initialize_result(true); + assert!(host_wide["instructions"].as_str().unwrap().contains("database")); } #[test] fn tools_list_exposes_the_expected_tools() { - let listed = tools_list(); + let listed = tools_list(false); let names: Vec<&str> = listed["tools"] .as_array() .unwrap() @@ -337,6 +448,105 @@ mod tests { assert_eq!(sql_tool["annotations"]["readOnlyHint"], false); assert_eq!(call_tool["annotations"]["readOnlyHint"], false); + + // every tool carries human readable title so MCP clients can label it + let every_tool = [tools_list(false), tools_list(true)]; + for tool in every_tool.iter().flat_map(|listed| listed["tools"].as_array().unwrap()) { + let annotations = &tool["annotations"]; + assert!( + annotations["title"].as_str().is_some(), + "tool {} is missing annotations.title", + tool["name"] + ); + for hint in ["readOnlyHint", "destructiveHint", "openWorldHint"] { + assert!( + annotations[hint].is_boolean(), + "tool {} is missing annotations.{hint}", + tool["name"] + ); + } + assert_eq!( + annotations["openWorldHint"], false, + "tool {} must set openWorldHint to false", + tool["name"] + ); + // read only tool cannot be destructive + if annotations["readOnlyHint"] == true { + assert_eq!( + annotations["destructiveHint"], false, + "read-only tool {} must set destructiveHint to false", + tool["name"] + ); + } + } + } + + #[test] + fn host_wide_tools_take_a_database_argument() { + let listed = tools_list(true); + let tools = listed["tools"].as_array().unwrap().clone(); + let names: Vec<&str> = tools.iter().map(|tool| tool["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["list_databases", "ping", "get_schema", "sql", "call"]); + + let tool = |name: &str| tools.iter().find(|tool| tool["name"] == name).unwrap().clone(); + + for name in ["get_schema", "sql", "call"] { + let schema = tool(name)["inputSchema"].clone(); + assert!( + schema["properties"]["database"].is_object(), + "{name} is missing the database property" + ); + let required = schema["required"].as_array().unwrap(); + assert!( + required.iter().any(|arg| arg == "database"), + "{name} does not require database" + ); + } + assert_eq!(tool("sql")["inputSchema"]["required"], json!(["database", "sql"])); + assert_eq!(tool("call")["inputSchema"]["required"], json!(["database", "reducer"])); + + assert!(tool("ping")["inputSchema"]["properties"]["database"].is_null()); + assert!(tool("list_databases")["inputSchema"]["properties"]["database"].is_null()); + + for tool in &tools { + assert!(tool["annotations"]["title"].as_str().is_some()); + } + } + + #[test] + fn scoped_tools_omit_the_database_argument_and_list_tool() { + let listed = tools_list(false); + for tool in listed["tools"].as_array().unwrap() { + assert_ne!(tool["name"], "list_databases"); + assert!(tool["inputSchema"]["properties"]["database"].is_null()); + } + + let get_schema = listed["tools"] + .as_array() + .unwrap() + .iter() + .find(|tool| tool["name"] == "get_schema") + .unwrap() + .clone(); + assert!(get_schema["inputSchema"]["required"].is_null()); + } + + #[test] + fn target_database_prefers_the_url_scope_then_the_argument() { + let scoped: NameOrIdentity = serde_json::from_value(json!("mydb")).unwrap(); + + let target = target_database(&Some(scoped), Some(&json!({ "database": "other" }))).unwrap(); + assert_eq!(target.to_string(), "mydb"); + + let target = target_database(&None, Some(&json!({ "database": "mydb" }))).unwrap(); + assert_eq!(target.to_string(), "mydb"); + + let target = target_database(&None, Some(&json!({ "database": "0".repeat(64) }))).unwrap(); + assert!(matches!(target, NameOrIdentity::Identity(_))); + + assert!(target_database(&None, None).is_err()); + assert!(target_database(&None, Some(&json!({}))).is_err()); + assert!(target_database(&None, Some(&json!({ "database": 42 }))).is_err()); } #[test] @@ -397,4 +607,30 @@ mod tests { assert!(reducer_args_json(Some(&json!({ "args": "nope" }))).is_err()); assert!(reducer_args_json(Some(&json!({ "args": {} }))).is_err()); } + + #[test] + fn list_databases_shows_only_the_callers_own() { + use spacetimedb::messages::control_db::HostType; + use spacetimedb_lib::Hash; + + let caller = Identity::from_hex("11".repeat(32)).unwrap(); + let other = Identity::from_hex("22".repeat(32)).unwrap(); + let database = |owner, id| Database { + id, + database_identity: Default::default(), + owner_identity: owner, + host_type: HostType::Wasm, + initial_program: Hash::ZERO, + bootstrap_generation: 0, + }; + + let owned = owned_by( + vec![database(caller, 1), database(other, 2), database(caller, 3)], + caller, + ); + let ids: Vec = owned.iter().map(|database| database.id).collect(); + assert_eq!(ids, [1, 3], "a listing must never include another owner's database"); + + assert!(owned_by(vec![database(other, 2)], caller).is_empty()); + } } diff --git a/crates/client-api/src/routes/mod.rs b/crates/client-api/src/routes/mod.rs index 0a649db75b4..d8371132742 100644 --- a/crates/client-api/src/routes/mod.rs +++ b/crates/client-api/src/routes/mod.rs @@ -29,13 +29,20 @@ pub fn router( where S: NodeDelegate + ControlStateDelegate + Authorization + Clone + 'static, { - use axum::routing::get; + use axum::routing::{get, post}; let router = axum::Router::new() .nest("/database", database_routes.into_router(ctx.clone())) .nest("/identity", identity_routes.into_router()) .nest("/energy", energy::router()) .nest("/prometheus", prometheus::router()) .nest("/metrics", metrics::router()) + .route( + "/mcp", + post(mcp::mcp_root::).route_layer(axum::middleware::from_fn_with_state( + ctx.clone(), + crate::auth::anon_auth_middleware::, + )), + ) .route("/ping", get(ping)) .merge(extra); diff --git a/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md b/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md index b4748e0285f..2c56a3d264c 100644 --- a/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md +++ b/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md @@ -17,6 +17,7 @@ This document contains the help content for the `spacetime` command-line program * [`spacetime describe`↴](#spacetime-describe) * [`spacetime dev`↴](#spacetime-dev) * [`spacetime sql`↴](#spacetime-sql) +* [`spacetime mcp`↴](#spacetime-mcp) * [`spacetime rename`↴](#spacetime-rename) * [`spacetime generate`↴](#spacetime-generate) * [`spacetime list`↴](#spacetime-list) @@ -53,6 +54,7 @@ This document contains the help content for the `spacetime` command-line program * `describe` — Describe the structure of a database or entities within it. WARNING: This command is UNSTABLE and subject to breaking changes. * `dev` — Start development mode with auto-regenerate client module bindings, auto-rebuild, and auto-publish on file changes. * `sql` — Runs a SQL query on the database. WARNING: This command is UNSTABLE and subject to breaking changes. +* `mcp` — Serve SpacetimeDB to MCP-aware agents and editors over stdio. WARNING: This command is UNSTABLE and subject to breaking changes. * `rename` — Rename a database * `generate` — Generate client files for a spacetime module. * `list` — Lists the databases attached to an identity. WARNING: This command is UNSTABLE and subject to breaking changes. @@ -305,6 +307,26 @@ Runs a SQL query on the database. WARNING: This command is UNSTABLE and subject +## `spacetime mcp` + +Serve SpacetimeDB to MCP-aware agents and editors over stdio. WARNING: This command is UNSTABLE and subject to breaking changes. + +**Usage:** `spacetime mcp [OPTIONS] [database]` + +Run `spacetime help mcp` for more detailed information. + + +###### **Arguments:** + +* `` — The name or identity of a single database to serve. Falls back to the SPACETIMEDB_DB_NAME environment variable. Omit it to serve the whole server, where each tool takes a database argument instead + +###### **Options:** + +* `-s`, `--server ` — The nickname, host name or URL of the server hosting the database +* `--anonymous` — Perform this action with an anonymous identity + + + ## `spacetime rename` Rename a database diff --git a/skills/mcp/SKILL.md b/skills/mcp/SKILL.md new file mode 100644 index 00000000000..74b4024bddc --- /dev/null +++ b/skills/mcp/SKILL.md @@ -0,0 +1,114 @@ +--- +name: mcp +description: Operate a running SpacetimeDB database through MCP tools rather than the CLI - list databases, read schemas, run SQL, and call reducers. Use when the client exposes spacetimedb MCP tools and the task is to inspect or change data in a live database. +license: Apache-2.0 +metadata: + author: clockworklabs + version: "2.0" + role: shared + language: all + cursor_globs: "**/*" + cursor_always_apply: false +triggers: + - inspect the database + - query the database + - list my databases + - read the schema + - call a reducer + - use the MCP server + - spacetimedb MCP +--- + +# SpacetimeDB over MCP + +A SpacetimeDB host speaks MCP, so an MCP-aware client can operate a live database with tool calls +instead of shell commands. If the client exposes `spacetimedb` tools, use them for anything that +reads or changes a running database. + +Check the tool list before deciding they are missing. The tools are passive: they appear as +`spacetimedb.list_databases`, `spacetimedb.get_schema`, `spacetimedb.sql`, `spacetimedb.call`, and +`spacetimedb.ping`, and nothing announces them. + +## Which to reach for + +| Task | Use | +| --- | --- | +| List databases, read a schema, run SQL, call a reducer | **MCP tools** | +| init, build, publish, generate, start, logs | **`spacetime` CLI** (see the `cli` skill) | + +The MCP tools only operate a database that already exists. They cannot scaffold a project, compile a +module, publish, or generate bindings, so the two are complementary rather than alternatives. Prefer +the tools when they are present: they are typed, they return JSON, and the client can gate the +destructive ones. When no MCP client is attached, the equivalent CLI commands are correct. + +## The tools + +| Tool | Arguments | Returns | +| --- | --- | --- | +| `list_databases` | none | the databases **you own**, with identity and names | +| `get_schema` | `database` | tables and reducers as JSON | +| `sql` | `database`, `sql`, optional `confirmed` | rows as JSON | +| `call` | `database`, `reducer`, optional `args` (JSON array) | the reducer outcome | +| `ping` | optional `message` | a health check | + +`list_databases` lists only your own databases, so it is empty for an anonymous identity. Start there +when you do not know the database name. + +## Two shapes, so read `tools/list` first + +A server is either host-wide or scoped to one database. Do not assume which: + +**Host-wide** (`spacetime mcp` with no database, or `POST /v1/mcp`). Every data tool takes a required +`database` argument, a name or an identity, and `list_databases` is offered: + +```json +{ "name": "sql", "arguments": { "database": "mydb", "sql": "SELECT * FROM message" } } +``` + +**Scoped** (`spacetime mcp `, or `POST /v1/database//mcp`). The connection fixes the +database, so there is no `database` argument and no `list_databases`: + +```json +{ "name": "sql", "arguments": { "sql": "SELECT * FROM message" } } +``` + +## Rules that do not change + +The tools run with your identity, exactly as the HTTP API does. The model is the same as everywhere +else, so the `concepts` skill still governs: + +1. **Reducers are the write path.** Use `call` to change data. It runs in a transaction that either + fully commits or fully rolls back. +2. **SQL writes require ownership.** `sql` reads public tables; writing through it needs you to own + the database. Prefer `call`. +3. **Private tables are not client-readable.** `get_schema` still shows a private table's + declaration, so a `no such table` error from `sql` usually means the table is private rather than + missing. Access depends on your identity, so do not assume a private table is readable. +4. **Tool errors come back in band.** A failed reducer or a bad query returns a result with + `isError: true` and the message as text, not a transport failure. Read the text before retrying. + +## Inspecting a database + +``` +list_databases {} +get_schema { "database": "mydb" } +sql { "database": "mydb", "sql": "SELECT * FROM message" } +call { "database": "mydb", "reducer": "send_message", "args": ["hello"] } +``` + +Pass `"confirmed": true` to `sql` to wait for a durably confirmed read. + +## Troubleshooting + +| Message | Meaning | +| --- | --- | +| `database argument must be a string` | The server is host-wide and you omitted `database` | +| `unknown tool: list_databases` | The server is scoped to one database already | +| `` `x` not found `` | No such database on this server, or a name where an identity was meant | +| `no such table: x` | The table is private, or you are querying the wrong database | +| No `spacetimedb` tools at all | No MCP server is connected. Use the CLI instead (`cli` skill) | + +`spacetime mcp` is UNSTABLE and may not be in a released CLI yet, so a client that cannot start it +falls back to the CLI commands in the `cli` skill. + +Reference: https://spacetimedb.com/docs