diff --git a/README.md b/README.md index a6f917bec..1dbc01606 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,9 @@ Everything runs on your infrastructure, so you decide what leaves it. OAuth2, RB [Response mapping reference](docs/tool-definition.md#3-response-mapping-optional) +**Codebound prototype:** save and run JavaScript against authorized REST read tools and +return a compact JSON result from the app. [Script runner guide](docs/codebound.md) + --- ## Use it from Claude, ChatGPT, Copilot and Gemini diff --git a/docs/codebound-mcp.md b/docs/codebound-mcp.md new file mode 100644 index 000000000..59b1cfe9f --- /dev/null +++ b/docs/codebound-mcp.md @@ -0,0 +1,147 @@ +# Let an AI write and run Codebound scripts + +An MCP client can now discover connector schemas, submit its own JavaScript, +save a useful workflow, and reuse it. You do not need a script for every tool +or connector: a script represents a task and can combine several connector +reads before returning a small result. + +The AI client writes the source. AnythingMCP executes it in the existing +Codebound sandbox; this feature does not call a model or require an LLM API key +on the backend. + +## Connect a client + +1. Follow the [local setup and test guide](codebound-testing.md) to start the app + and create the sample `setup_demo_get_post` tool if needed. +2. In **Build → MCP Servers**, open the server with your connector assigned. +3. Connect your MCP client using the server's displayed endpoint, normally + `http://localhost:4000/mcp/` for local development. Use the existing + OAuth sign-in flow, or generate a per-user key under the server's **API keys** + section and configure it in your client's `X-API-Key` or bearer header. +4. Refresh the client's tool list. Reconnect if it caches schemas. Look for the + six `codebound_` tools below. + +Codebound requires an identified workspace member. It is not exposed by the +anonymous demo, anonymous access, or instance-level static credentials. + +The same tools are available on the shared `/mcp` endpoint in both its fixed +and direct modes. On an unpinned shared connection, call `codebound_list_tools` +with `{}` first to discover reachable servers, then supply `serverId` in later +calls. On a per-server endpoint or a server-pinned key, `serverId` can be omitted. + +## Available tools + +| Tool | Purpose | +| --- | --- | +| `codebound_list_tools` | Discover reachable servers or a server's permitted REST read tools, descriptions, and input/output schemas. Supports `query`, `offset`, and `limit`. | +| `codebound_run` | Execute supplied `code`, `input`, and `allowedTools` without saving. | +| `codebound_save_script` | Create a reusable workflow; update an existing one by supplying `scriptId` and its reviewed `revision`. Editors/admins only. | +| `codebound_list_scripts` | List accessible saved workflows and revisions for one server. Follow `nextOffset` to scan further pages. | +| `codebound_get_script` | Read saved source, defaults, allowlist, and current revision before editing or reusing it. | +| `codebound_run_saved` | Run a saved ID and reviewed revision; optionally override `input` for this run. | + +Saved scripts use `codebound_run_saved`; this version does not register a +separate MCP tool name for every saved script. Scripts created through MCP also +appear in the **Build → Codebound** UI. UI-created scripts are available through +MCP when their server and tool allowlist are within the current connection. + +## Try it with an AI + +With the demo connector assigned to the connected server, send this to your MCP +client: + +> Use Codebound to inspect my permitted connector tool schemas. Write and run a +> JavaScript workflow that calls setup_demo_get_post and returns only its numeric +> ID and title. Validate those response fields. Then save the successful workflow +> as Post summary and run the saved version again. Write the script yourself. + +The expected sequence is discovery → generated script → execution → save → reuse. +Your client's normal permissions for reading, running, and saving tools apply. + +You can also exercise the same sequence in MCP Inspector's **Tools** view. +For a per-server endpoint, first call `codebound_list_tools` with: + +```json +{} +``` + +Then call `codebound_run` with: + +```json +{ + "code": "const post = await tools.call('setup_demo_get_post', {}); if (!post || typeof post.id !== 'number' || typeof post.title !== 'string') throw new Error('Unexpected post response'); return { id: post.id, title: post.title };", + "input": {}, + "allowedTools": ["setup_demo_get_post"] +} +``` + +The result contains `result`, `calls`, `durationMs`, and `runId`. Only the script's +final JSON reaches the client; the full intermediate post remains inside the run. + +Call `codebound_save_script` with the same fields plus `name` and optionally +`description`. Its response includes the saved `id` and `revision`. Use the +actual values from that response when calling `codebound_run_saved`: + +```json +{ + "scriptId": "", + "revision": 1 +} +``` + +Omitting `input` reuses saved defaults. Supplying it overrides defaults only for +that run. To edit a workflow, fetch it with `codebound_get_script`, then submit +the updated source/defaults/allowlist to `codebound_save_script` with its +`scriptId` and current `revision`. A stale version returns an error and must be +reopened, rather than silently overwriting or running new source. + +## Response contracts and permissions + +Tool output schemas may be `null`. In that case, the agent can inspect a bounded +sample or the response's field names, then validate the fields its script uses. +It should target the actual filtered connector response. A contract mismatch +should throw a clear error rather than return an incorrect business answer. + +The connection's OAuth grant, pinned API-key server, endpoint server, active +membership, and current tool roles all constrain access. Arguments cannot change +the authenticated identity or widen the connection. These checks run again +before every nested connector call, including calls from saved scripts. Reused +stateful sessions use each request's current client scope. + +Saved-script discovery/opening also requires its tools to remain available to +the caller. Inaccessible workflows are omitted from listings. A scan page can +therefore contain no items but still have `nextOffset`; continue with that offset. +Saving a workflow grants no connector access to its author or anyone reusing it. + +The existing limits apply: 5-second worker wall time, 64 MiB QuickJS heap, +20 sequential connector reads, 1 MiB cumulative connector replies, and 32 KiB +final JSON. Tools are limited to eligible REST reads; imports, direct networking, +host access, nested scripts, and MCP built-in calls are unavailable inside scripts. +An already-started connector read can finish after timeout; its result is ignored. +Schema/source discovery responses are capped at 256 KiB; narrow the search or +lower the page limit if necessary. + +Cloud and self-hosted deployments share this behavior. Existing workspace license +checks still apply; no authentication scheme, connector type, or schema migration +was added for this integration. The shared endpoint keeps its existing connector +helper tools and adds the six Codebound tools for identified callers. + +## Verification + +From `packages/backend`, run the focused suite with local PostgreSQL available +and the local `.env` exported as described in the setup guide: + +```bash +RUN_CODEBOUND_DB=1 npm test -- --runInBand codebound +``` + +This exercises real MCP SDK discovery/invocation, real QuickJS execution, +save/reuse, endpoint scopes, API-key pinning, grant revocation, live role changes, +viewer write denials, error redaction, concurrent session scopes, and PostgreSQL +persistence/isolation. The MCP client tests use in-memory transports and +deterministic connector fixtures; they do not depend on an external AI account. + +A separate local HTTP smoke check also verified real dashboard sign-in, a +server-pinned per-user MCP key, schema discovery, run/save/reuse, dashboard +interoperability, the shared endpoint, and denial of another server. Its temporary +workspace and fixture records were removed after the check. diff --git a/docs/codebound-testing.md b/docs/codebound-testing.md new file mode 100644 index 000000000..cd1c65ad3 --- /dev/null +++ b/docs/codebound-testing.md @@ -0,0 +1,334 @@ +# Set up and test Codebound locally + +Use this guide to verify saving, opening, running, editing, and deleting a +Codebound script. The walkthrough uses a small local API with predictable data. +If `setup_demo_get_post` already works in your connector, you can reuse it and +skip the local fixture and connector creation steps. + +Use the checkout or branch containing the Codebound changes, including migration +`20261017000000_add_codebound_scripts`. The commands use your current checkout +path; replace it if testing on another machine. + +## 1. Prepare the application + +You need Node.js **22.12 or newer**, npm **9 or newer**, and Docker with Compose. +Start Docker Desktop before the database commands. + +For a **new installation only**, run `./setup.sh` from the repository root first. +Choose **Local development**, **localhost**, and PostgreSQL port **5433**. Let it +create `.env`, install dependencies, start PostgreSQL, and generate Prisma. +Continue with the commands below. Keep `.env` uncommitted. + +For **your existing installation**, keep the current `.env`. From the repository +root, start PostgreSQL: + +```bash +cd /Users/gone_snow/anythingmcp +docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d postgres +``` + +If dependencies are missing or have changed since your last installation, run +`npm ci` from that same directory. This installs the locked QuickJS dependency +used by the script runner. + +Apply migrations and generate the Prisma client: + +```bash +cd /Users/gone_snow/anythingmcp/packages/backend +set -a +. ../../.env +set +a +npx prisma migrate deploy +npx prisma generate +``` + +The local `DATABASE_URL` must point to `localhost:5433`, using the credentials +generated by setup. Do not replace your working secrets with example values. +The migration `20261017000000_add_codebound_scripts` should be applied, or Prisma +should report that no pending migrations remain. + +Do not rerun setup over your existing configuration just to test this feature. + +## 2. Start the local demo API + +Skip this step if you already have a working `setup_demo_get_post` tool. + +In a separate terminal, run this Node script and leave it running: + +```bash +node --input-type=module <<'JS' +import { createServer } from 'node:http'; + +createServer((request, response) => { + response.setHeader('Content-Type', 'application/json'); + if (request.method !== 'GET' || request.url !== '/posts/1') { + response.writeHead(404); + response.end(JSON.stringify({ error: 'Not found' })); + return; + } + response.end(JSON.stringify({ + id: 1, + title: 'Codebound demo post', + body: 'This intermediate field should not appear in the script result.', + })); +}).listen(4100, '127.0.0.1', () => { + process.stdout.write('Demo API ready at http://127.0.0.1:4100/posts/1\n'); +}); +JS +``` + +Paste the entire block, including the first and last lines. The command stays +running while the API listens. Keep this terminal open until testing is finished. + +In another terminal, check it: + +```bash +curl --fail http://127.0.0.1:4100/posts/1 +``` + +Expected response: + +```json +{ + "id": 1, + "title": "Codebound demo post", + "body": "This intermediate field should not appear in the script result." +} +``` + +For this local fixture, the connector needs permission to reach loopback. In +the repository-root `.env`, add `127.0.0.1` to `SSRF_ALLOWED_HOSTS`. If the setting +does not exist, add: + +```dotenv +SSRF_ALLOWED_HOSTS=127.0.0.1 +``` + +Keep any existing hosts in that comma-separated list. Restart the application +after editing `.env`. This lets the trusted connector reach your demo API; +scripts themselves still have no direct network access. + +## 3. Start the application and sign in + +In an application terminal: + +```bash +cd /Users/gone_snow/anythingmcp +npm run dev +``` + +Keep that terminal open. The frontend runs at [localhost:3000](http://localhost:3000) +and the backend at [localhost:4000](http://localhost:4000). + +Sign in with your existing account. For a new installation, register the first +account to become administrator. If the sign-up screen requests email +verification, configure working SMTP through setup and complete verification; +the local fixture itself needs no external API account. + +Use an **administrator or editor** for the save/edit/delete walkthrough. + +## 4. Add the demo connector and assign it to a server + +Skip connector creation if your existing demo tool already works. + +1. Open **Build → Connectors** and create a **REST API** connector. +2. Set the connector name to `Codebound Demo`, Base URL to + `http://127.0.0.1:4100`, and Authentication to **None**. Leave the optional + OpenAPI specification URL empty. Click **Create connector**. +3. On its detail page, choose **Import Tools → JSON Definition**. Paste the + following array, then click **Import**: + +```json +[ + { + "name": "setup_demo_get_post", + "description": "Read the fixed local demo post for Codebound testing.", + "parameters": { + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "endpointMapping": { + "method": "GET", + "path": "/posts/1" + }, + "outputSchema": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "title": { "type": "string" }, + "body": { "type": "string" } + }, + "required": ["id", "title", "body"] + } + } +] +``` + +4. Make sure the connector is active and the imported tool is enabled. Expand + the tool's test panel and use **Run Test** with `{}`. It should return the + post above. Resolve connector errors here before testing a script. +5. Open **Build → MCP Servers**. Use an existing active server, or choose + **New MCP Server**, name it `Codebound Demo`, and click **Create**. +6. Open that server and tick `Codebound Demo` under **Assigned connectors**. + Assignments save immediately; wait for **Saved**. If a connector assignment + dialog appeared earlier, you can use it instead of assigning twice. + +Codebound currently lists permitted REST `GET`, `HEAD`, and `static` tools. A +write tool or a tool from an unassigned connector will not appear. + +## 5. Run and save your first script + +Open **Build → Codebound**, or visit +[localhost:3000/codebound](http://localhost:3000/codebound). + +1. Choose the MCP server containing your demo connector. +2. Check `setup_demo_get_post` under **Allowed tools for this script**. +3. Set **Script name** to `Post summary` and **Description** to + `Return the post ID and title, with optional uppercase output`. +4. Paste this into **JavaScript**: + +```javascript +const post = await tools.call('setup_demo_get_post', {}); +if (!post || typeof post.id !== 'number' || typeof post.title !== 'string') { + throw new Error('Expected a post with a numeric id and a title.'); +} +return { + id: post.id, + title: input.uppercase ? post.title.toUpperCase() : post.title, +}; +``` + +5. Set **Parameters (JSON)** to: + +```json +{ "uppercase": false } +``` + +6. Click **Run script**. With the local fixture, expect: + +```json +{ "id": 1, "title": "Codebound demo post" } +``` + +The result should show **1 tool calls**. The API's `body` field should be absent +from the final result. An existing demo connector may return a different title. + +7. Click **Save script**. Expect **Script saved.** and an **Open** button for + `Post summary` in the saved-script list. + +## 6. Check the saved-script lifecycle + +| Action | Expected result | +| --- | --- | +| Refresh the browser, then click **Open** for `Post summary` | Name, description, source, tool allowlist, and default parameters are restored. | +| Click **Run script** without changing source | The saved script returns the same compact result. | +| Change parameters to `{ "uppercase": true }` and run | Title becomes `CODEBOUND DEMO POST`; the stored defaults stay unchanged until you save. | +| Open the script again; accept discarding unsaved edits if prompted | The saved default `{ "uppercase": false }` returns. | +| Add `source: 'saved-script'` to the returned object, then click **Save changes** | The saved version is updated. Reload, open, and run: the result includes `source`. | +| Stop the application with Ctrl+C, run `npm run dev` again, reload, and open the script | It remains saved in PostgreSQL. Keep the fixture terminal running for connector calls. | +| Click **Delete** for the script, then **Confirm delete** | The saved entry disappears. Refreshing does not bring it back. | + +Delete only your test script. Deleting an MCP server also deletes its linked +scripts, so keep the server while testing persistence. + +To check concurrent editing, save a script and open it in **two browser tabs**. +In tab A, change its description and save. In tab B, change its description and +save without reopening. Expect **Script changed. Reopen it before saving.** +Tab B's draft should remain in its editor. Copy any changes you want to keep +before reopening the current version. + +## 7. Check execution failures and recovery + +Use **New script** for these checks, so your saved example stays intact. You do +not need to save a draft to run it. + +| Draft to run | Expected result | +| --- | --- | +| `return await tools.call('not_in_allowlist', {});` | An allowlist error; no connector call is dispatched. | +| `while (true) {}` | An execution/time-limit error within approximately five seconds, plus request overhead. | +| `return { process: typeof process, require: typeof require, fetch: typeof fetch };` | All three values are the string `"undefined"`. | +| `return 'x'.repeat(32769);` | An error because output exceeds 32 KiB. | +| Parameters `[]` rather than a JSON object | **Parameters must be a JSON object.** before execution. | +| `return { ok: true };` after a failure | A successful run, showing the runner recovered. Reset invalid parameters to `{}` first. | + +Viewers can open and run saved scripts using their own permitted tools, but +cannot save or delete them. The PostgreSQL tests below check workspace isolation +using separate temporary tenant fixtures; the browser tests check viewer controls. + +## 8. Run automated tests + +Run these separately from manual testing. Stop the regular application with +**Ctrl+C** before Playwright: its Next server uses the same `.next` directory. +The standalone demo fixture can also be stopped; automated tests supply their +own connector fixture or mock responses. + +Backend runtime, permission, and CRUD tests: + +```bash +cd /Users/gone_snow/anythingmcp/packages/backend +npm test -- --runInBand codebound +``` + +Expect **76 passing tests** and the PostgreSQL tests skipped by default. +Local HTTP fixture tests need permission to listen on localhost when run inside +a restricted coding-agent sandbox. + +Include real PostgreSQL persistence, tenant isolation, concurrent writes, and +foreign-key checks: + +```bash +cd /Users/gone_snow/anythingmcp/packages/backend +set -a +. ../../.env +set +a +RUN_CODEBOUND_DB=1 npm test -- --runInBand codebound +``` + +Expect **81 passing tests**. These require the migrated local database at +localhost:5433 and create/remove their own temporary records. + +Browser tests, with the regular dev server stopped: + +```bash +cd /Users/gone_snow/anythingmcp/packages/frontend +npx playwright install --with-deps chromium +npm run test:e2e -- codebound.spec.ts +``` + +Expect **7 passing Chromium tests**. Playwright starts Next on port **3100**; +the browser tests mock API responses and do not need a running backend. Browser +installation may need additional system permissions. A failed installation is +a missing prerequisite, not a passed test. + +These counts come from the implementation verification recorded in the +[Codebound guide](codebound.md#verification-on-17-october-2026). Broader lint, +typecheck, build, catalog, and full-suite commands are recorded there too. + +## Troubleshooting + +| Symptom | What to check | +| --- | --- | +| The sidebar has no Codebound entry | Use the checkout containing the local changes, restart `npm run dev`, and reload the browser. | +| Prisma reports a missing `codebound_scripts` table or stale client | Apply `prisma migrate deploy`, run `prisma generate` with the local `.env` exported, then restart the backend. | +| Prisma cannot reach PostgreSQL | Docker must be running; start the Compose `postgres` service and check that `DATABASE_URL` uses localhost:5433. | +| Save controls are absent | Sign in as an editor or administrator. | +| No tools appear | Check active server, active/ready connector, enabled tool, server assignment, REST read method, and your role's tool access. | +| The connector cannot reach 127.0.0.1:4100 | Keep the demo API running, check it with curl, and add 127.0.0.1 to the local `SSRF_ALLOWED_HOSTS` before restarting the app. | +| Save or Run reports that the script changed | Reopen the current version and review it before saving or running again. | +| The runner remains busy after a timeout | An already-started connector read can finish in the background; its run slot stays occupied until it finishes. | +| Playwright reports a Next.js lock | Stop the regular dev server before starting the port-3100 test server. | +| Email verification fails because SMTP is missing | Reuse your existing verified account, or configure working SMTP for registration/verification. | + +The application feature behaves the same in Cloud and self-hosted deployments: +saved scripts are scoped to their workspace, and execution retains the caller's +tool permissions and existing license checks. The terminal, database, SMTP, and +loopback setup above is for local self-hosted development only. + +To have an agent write and submit its own scripts, or to exercise execution and +save/reuse in Inspector, follow [AI-authored scripts through MCP](codebound-mcp.md). +The six Codebound tools are available on authenticated per-server and shared +connections. You can test them without manually creating a script per connector. + +When finished, stop `npm run dev` and the local API with **Ctrl+C**. Keep the +PostgreSQL data volume so saved scripts remain available for the next session. diff --git a/docs/codebound.md b/docs/codebound.md new file mode 100644 index 000000000..cdbd931dc --- /dev/null +++ b/docs/codebound.md @@ -0,0 +1,202 @@ +# Codebound script runner + +For setup commands and a complete manual walkthrough, see +[Set up and test Codebound locally](codebound-testing.md). + +Codebound saves JavaScript workflows and runs them against authorized connector +tools, returning a compact JSON result. Open **Build → Codebound**, choose an +active MCP server, select the tools a run may call, then click **Run script**. +Give the script a name and description and click **Save script** to reuse it. +The connector must already be assigned to the server and ready to use. + +For the setup demo connector, the starter script is: + +```javascript +const post = await tools.call('setup_demo_get_post', {}); +if (!post || typeof post.id !== 'number' || typeof post.title !== 'string') { + throw new Error('Expected a post with a numeric id and a title.'); +} +return { id: post.id, title: post.title }; +``` + +The editor shows the available tools' descriptions and input/output schemas. +An absent output schema is shown as `null`; check a representative response +and validate the fields your script needs. `tools.call` returns the executor's +filtered, structured JSON response, without MCP text footers. Scripts can use +`input`, the JSON parameters entered beside the code. Await each call before +starting another. Only the final return value is displayed. + +## Supported scope + +The saved-script list supports **Open**, **New script**, and **Delete** with a +confirmation. Open a script, edit it, and click **Save changes**. Saved records +include the name, description, MCP server, JavaScript, default parameters, and +tool allowlist. They persist in PostgreSQL across page reloads and backend +restarts. The list is paginated and excludes source and parameters until opened. + +Running an opened script uses its stored source, server, and allowlist. The +parameters box can override its defaults for one run. If source, server, or +allowlist have unsaved changes, **Run script** tests that draft; **Save changes** +updates the reusable script. Save also persists the parameters as new defaults. +Opening another script or starting a new one prompts before discarding edits. + +Edits use a revision counter: an outdated editor receives HTTP 409 instead of +overwriting another user's changes. Running an opened script also checks its +reviewed revision. Reopen it after a conflict to review the current source. +Deleting a script removes access to it; deleting its server also deletes linked +scripts. Runs already in progress use the snapshot loaded when the run began. + +An AI client can now write scripts, execute them, save workflows, and reuse +saved IDs through six MCP tools. See [AI-authored scripts through MCP](codebound-mcp.md) +for client setup and examples. Marketplace bundles, separate MCP tool names for +each saved script, and the quotation workflow remain later milestones. + +Both Cloud and self-hosted deployments use the same page and authenticated API. +The existing workspace license check still applies. Server assignment, active +connectors, organization membership, and role-based tool access restrict calls +in both modes. Editors and administrators can create, edit, and delete scripts. +Viewers can read and run them within their current tool permissions. A saved +allowlist is a restriction, not a grant: another author's script never expands +the caller's connector access. Both deployments use the same storage and API; +there is no edition-specific behavior. + +### Local development database + +The `20261017000000_add_codebound_scripts` migration adds `codebound_scripts` +and a composite foreign key that enforces the server's organization. Apply it +to the development database on localhost:5433, from `packages/backend`: + +```bash +set -a +. ../../.env +set +a +npx prisma migrate deploy +npx prisma generate +``` + +For this prototype, eligible tools are REST `GET`, `HEAD`, and `static` tools +whose read-only annotation is true. Other transports and writes are excluded. +The read-only classification depends on the connector's configured HTTP method; +it cannot establish whether an upstream API abuses GET for mutations. + +## Execution boundary and limits + +Each run creates a fresh QuickJS WebAssembly runtime in a worker. User code runs +only in QuickJS. Node, environment variables, files, direct networking, timers, +and package imports are unavailable. Only serialized JSON crosses the broker. +Credentials remain in the existing connector executor, which retains network +rules, response transforms, secret redaction, and tool invocation auditing. +Script reads bypass the executor's response cache so each read is audited and +cannot consume a shared cache entry from a different caller. + +| Budget | Limit | +| --- | --- | +| Worker wall time, including connector waits | 5 seconds | +| QuickJS heap / stack | 64 MiB / 512 KiB | +| Connector calls | 20, sequential | +| Parameters / arguments per call | 64 KiB each | +| Cumulative serialized tool replies | 1 MiB | +| Returned JSON | 32 KiB | +| Script source | 32,768 characters | +| Active runs per backend process | 4 total, 1 per organization | + +The host terminates the worker at the deadline and rejects late results. +Existing connector engines do not accept an abort signal: a read already sent +upstream may finish after the script stops. No further reads are dispatched. +The organization's run slot stays occupied until that read finishes. Limits +apply per backend process; this is not a distributed resource quota. + +Every started parent run produces a `CODEBOUND_RUN` security event containing +the run ID, caller, server, status, duration, and dispatched call count. Nested +invocations carry `Codebound run ` in their intent field. Parent events +omit source, parameters, results, and raw exceptions. Connector errors shown to +scripts are generic; inspect the normal tool invocation log for details. +Runs of saved scripts also include the script ID and revision in the parent +event. Create, update, and delete produce `CODEBOUND_SCRIPT_CREATED`, +`CODEBOUND_SCRIPT_UPDATED`, and `CODEBOUND_SCRIPT_DELETED` security events, +containing metadata without source or parameters. + +## API and verification + +Dashboard JWT authentication is required: + +- `GET /api/codebound/servers/:serverId/tools`: permitted tool descriptions and schemas. +- `POST /api/codebound/servers/:serverId/run`: `{ code, input, allowedTools }`. + Returns `{ runId, result, calls, durationMs }` or an HTTP error. +- `GET /api/codebound/scripts?limit=20&offset=0`: `{ items, total }` for this + workspace; `limit` is capped at 200. +- `POST /api/codebound/scripts`: `{ name, description, mcpServerId, code, input, + allowedTools }` creates a saved script. +- `GET /api/codebound/scripts/:id`: opens its source and defaults. +- `PUT /api/codebound/scripts/:id`: replaces editable fields using the create + body plus the reviewed `revision`; returns the incremented revision. +- `DELETE /api/codebound/scripts/:id`: removes it. +- `POST /api/codebound/scripts/:id/run`: `{ input, revision }`; source, server, + and allowlist overrides are rejected. Returns the usual run result. + +The caller's workspace and user identity come from authentication. Script +parameters cannot override them. Every nested call rechecks current permissions +and server assignments, validates arguments with the same schema conversion +used by MCP, then invokes the existing executor pinned to the resolved connector. + +Run backend boundary/permission tests from `packages/backend`: + +```bash +npm test -- --runInBand codebound +``` + +After applying the migration, also exercise real persistence, concurrent writes, +tenant denials, the database foreign key, and deletion against local PostgreSQL. +With the local `.env` exported as above, run: + +```bash +RUN_CODEBOUND_DB=1 npm test -- --runInBand codebound +``` + +The database tests create two temporary tenant fixtures and remove them after +the run. They require localhost:5433 and are skipped without the explicit flag. + +Run UI tests from `packages/frontend`, with the regular development server +stopped so Playwright can start Next on port 3100: + +```bash +npm run test:e2e -- codebound.spec.ts +``` + +The UI tests mock API responses. Backend tests exercise real QuickJS execution, +including isolation, forbidden host APIs, timeouts, quotas, role/tenant denials, +and recovery. An integration test runs the broker, executor, and REST engine +against a local HTTP fixture, checking response filtering and both audit levels. +They are prototype evidence; they do not certify production isolation or a live +third-party integration. + +### Verification on 17 October 2026 + +| Working directory | Command | Result | +| --- | --- | --- | +| `packages/backend` | `npm test -- --runInBand` | 664 suites / 19,826 tests passed; 1 suite / 1 test failed in unchanged `response-images.util.spec.ts`; 5 suites / 1,454 tests skipped | +| `packages/backend` | `npm test -- --runInBand response-images.util.spec` | All 14 tests passed when run separately | +| `packages/backend` | `npm test -- --runInBand codebound` | 76 tests; PostgreSQL tests are opt-in | +| `packages/backend` | `RUN_CODEBOUND_DB=1 npm test -- --runInBand codebound` | 81 tests passed, including MCP invocation and PostgreSQL persistence/isolation | +| `packages/backend` | `npx prisma validate` / `npx prisma migrate deploy` | Schema valid; new migration applied to local development PostgreSQL | +| `packages/backend` | `npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --exit-code` | No difference detected | +| `packages/backend` | `npm run lint:check` | Passed; 14 existing warnings | +| `packages/backend` | `npx tsc --noEmit -p tsconfig.json` | Passed | +| `packages/backend` | `npm run build` | Passed, including Prisma generation | +| `packages/frontend` | `npm run lint` | Passed; 26 existing warnings | +| `packages/frontend` | `npx tsc --noEmit -p tsconfig.json` | Passed | +| `packages/frontend` | `npx playwright install --with-deps chromium` | Passed | +| `packages/frontend` | `npm run test:e2e -- codebound.spec.ts` | 7 Chromium tests passed, including saved-script CRUD, viewer controls, and pagination recovery | +| Repository root | `node scripts/validate-adapters.mjs` | 490 adapters passed; 1,458 existing non-blocking warnings | +| Repository root | `node scripts/check-runtime-deps.mjs` | Passed | + +The full backend run reports a `RangeError: Maximum call stack size exceeded` in +the unchanged image-response test for a 12 MiB aggregate result. That suite passes +when run separately; the full run is not a pass. All 81 focused Codebound tests +pass on the final source, including the five opt-in PostgreSQL tests. + +Earlier UI and persistence verification also required access outside the +restricted execution sandbox for localhost listeners and browser installation. +The frontend is unchanged by the MCP integration; its results above are from the +saved-script UI implementation. Regular development servers and Playwright's +server were stopped after verification. diff --git a/package-lock.json b/package-lock.json index afd05558d..284ca44c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2760,6 +2760,48 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/@jitl/quickjs-ffi-types": { + "version": "0.31.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-ffi-types/-/quickjs-ffi-types-0.31.0.tgz", + "integrity": "sha512-1yrgvXlmXH2oNj3eFTrkwacGJbmM0crwipA3ohCrjv52gBeDaD7PsTvFYinlAnqU8iPME3LGP437yk05a2oejw==", + "license": "MIT" + }, + "node_modules/@jitl/quickjs-wasmfile-debug-asyncify": { + "version": "0.31.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-asyncify/-/quickjs-wasmfile-debug-asyncify-0.31.0.tgz", + "integrity": "sha512-YkdzQdr1uaftFhgEnTRjTTZHk2SFZdpWO7XhOmRVbi6CEVsH9g5oNF8Ta1q3OuSJHRwwT8YsuR1YzEiEIJEk6w==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.31.0" + } + }, + "node_modules/@jitl/quickjs-wasmfile-debug-sync": { + "version": "0.31.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-sync/-/quickjs-wasmfile-debug-sync-0.31.0.tgz", + "integrity": "sha512-8XvloaaWBONqcHXYs5tWOjdhQVxzULilIfB2hvZfS6S+fI4m2+lFiwQy7xeP8ExHmiZ7D8gZGChNkdLgjGfknw==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.31.0" + } + }, + "node_modules/@jitl/quickjs-wasmfile-release-asyncify": { + "version": "0.31.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-asyncify/-/quickjs-wasmfile-release-asyncify-0.31.0.tgz", + "integrity": "sha512-uz0BbQYTxNsFkvkurd7vk2dOg57ElTBLCuvNtRl4rgrtbC++NIndD5qv2+AXb6yXDD3Uy1O2PCwmoaH0eXgEOg==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.31.0" + } + }, + "node_modules/@jitl/quickjs-wasmfile-release-sync": { + "version": "0.31.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-sync/-/quickjs-wasmfile-release-sync-0.31.0.tgz", + "integrity": "sha512-hYduecOByj9AsAfsJhZh5nA6exokmuFC8cls39+lYmTCGY51bgjJJJwReEu7Ff7vBWaQCL6TeDdVlnp2WYz0jw==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.31.0" + } + }, "node_modules/@jmespath-community/jmespath": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@jmespath-community/jmespath/-/jmespath-1.3.0.tgz", @@ -21121,6 +21163,31 @@ "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "license": "MIT" }, + "node_modules/quickjs-emscripten": { + "version": "0.31.0", + "resolved": "https://registry.npmjs.org/quickjs-emscripten/-/quickjs-emscripten-0.31.0.tgz", + "integrity": "sha512-K7Yt78aRPLjPcqv3fIuLW1jW3pvwO21B9pmFOolsjM/57ZhdVXBr51GqJpalgBlkPu9foAvhEAuuQPnvIGvLvQ==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-wasmfile-debug-asyncify": "0.31.0", + "@jitl/quickjs-wasmfile-debug-sync": "0.31.0", + "@jitl/quickjs-wasmfile-release-asyncify": "0.31.0", + "@jitl/quickjs-wasmfile-release-sync": "0.31.0", + "quickjs-emscripten-core": "0.31.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/quickjs-emscripten-core": { + "version": "0.31.0", + "resolved": "https://registry.npmjs.org/quickjs-emscripten-core/-/quickjs-emscripten-core-0.31.0.tgz", + "integrity": "sha512-oQz8p0SiKDBc1TC7ZBK2fr0GoSHZKA0jZIeXxsnCyCs4y32FStzCW4d1h6E1sE0uHDMbGITbk2zhNaytaoJwXQ==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.31.0" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -25074,6 +25141,7 @@ "pg": "^8.23.0", "pino": "^10.3.1", "pino-http": "^11.0.0", + "quickjs-emscripten": "0.31.0", "reflect-metadata": "^0.2.2", "rxjs": "7.8.1", "soap": "^1.13.0", diff --git a/packages/backend/package.json b/packages/backend/package.json index 6c40a8847..f3b321097 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -75,6 +75,7 @@ "pg": "^8.23.0", "pino": "^10.3.1", "pino-http": "^11.0.0", + "quickjs-emscripten": "0.31.0", "reflect-metadata": "^0.2.2", "rxjs": "7.8.1", "soap": "^1.13.0", diff --git a/packages/backend/prisma/migrations/20261017000000_add_codebound_scripts/migration.sql b/packages/backend/prisma/migrations/20261017000000_add_codebound_scripts/migration.sql new file mode 100644 index 000000000..71dd784f6 --- /dev/null +++ b/packages/backend/prisma/migrations/20261017000000_add_codebound_scripts/migration.sql @@ -0,0 +1,31 @@ +-- A script's server must belong to its organization, including direct DB writes. +CREATE UNIQUE INDEX "mcp_server_configs_id_organization_id_key" + ON "mcp_server_configs"("id", "organization_id"); + +CREATE TABLE "codebound_scripts" ( + "id" TEXT NOT NULL, + "organization_id" TEXT NOT NULL, + "mcp_server_id" TEXT NOT NULL, + "name" VARCHAR(120) NOT NULL, + "description" TEXT NOT NULL DEFAULT '', + "code" TEXT NOT NULL, + "input" JSONB NOT NULL DEFAULT '{}', + "allowed_tools" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + "revision" INTEGER NOT NULL DEFAULT 1, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "codebound_scripts_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "codebound_scripts_organization_id_updated_at_id_idx" + ON "codebound_scripts"("organization_id", "updated_at", "id"); +CREATE INDEX "codebound_scripts_mcp_server_id_organization_id_idx" + ON "codebound_scripts"("mcp_server_id", "organization_id"); + +ALTER TABLE "codebound_scripts" ADD CONSTRAINT "codebound_scripts_organization_id_fkey" + FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") + ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "codebound_scripts" ADD CONSTRAINT "codebound_scripts_mcp_server_id_organization_id_fkey" + FOREIGN KEY ("mcp_server_id", "organization_id") + REFERENCES "mcp_server_configs"("id", "organization_id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/backend/prisma/schema.prisma b/packages/backend/prisma/schema.prisma index 5290f6be3..64d71ac1c 100644 --- a/packages/backend/prisma/schema.prisma +++ b/packages/backend/prisma/schema.prisma @@ -30,6 +30,7 @@ model Organization { members OrganizationMember[] connectors Connector[] mcpServers McpServerConfig[] + codeboundScripts CodeboundScript[] roles Role[] mcpApiKeys McpApiKey[] invitationTokens InvitationToken[] @@ -600,12 +601,38 @@ model McpServerConfig { apiKeys McpApiKey[] toolInvocations ToolInvocation[] kgSkills KgSkillSuggestion[] + codeboundScripts CodeboundScript[] + @@unique([id, organizationId]) @@unique([organizationId, slug]) @@index([organizationId]) @@map("mcp_server_configs") } +// ── Saved Codebound Scripts ──────────────────────────────────────────────── + +model CodeboundScript { + id String @id @default(cuid()) + organizationId String @map("organization_id") + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + mcpServerId String @map("mcp_server_id") + // The composite FK prevents binding a script to another tenant's server. + mcpServer McpServerConfig @relation(fields: [mcpServerId, organizationId], references: [id, organizationId], onDelete: Cascade) + + name String @db.VarChar(120) + description String @default("") @db.Text + code String @db.Text + input Json @default("{}") + allowedTools String[] @default([]) @map("allowed_tools") + revision Int @default(1) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([organizationId, updatedAt, id]) + @@index([mcpServerId, organizationId]) + @@map("codebound_scripts") +} + // ── MCP Server ↔ Connector (many-to-many) ─────────────────────────────────── model McpServerConnector { diff --git a/packages/backend/src/app.module.ts b/packages/backend/src/app.module.ts index 17f8a2112..d7346fba9 100644 --- a/packages/backend/src/app.module.ts +++ b/packages/backend/src/app.module.ts @@ -11,6 +11,7 @@ import { mcpStrategy } from './mcp-server/mcp-strategy'; import { AuthModule } from './auth/auth.module'; import { ConnectorsModule } from './connectors/connectors.module'; import { McpServerModule } from './mcp-server/mcp-server.module'; +import { CodeboundModule } from './codebound/codebound.module'; import { UsersModule } from './users/users.module'; import { AuditModule } from './audit/audit.module'; @@ -161,6 +162,7 @@ if (useOAuth) { ConnectorsModule, AdaptersModule, McpServerModule, + CodeboundModule, OrganizationsModule, AuditModule, diff --git a/packages/backend/src/codebound/codebound-mcp.service.spec.ts b/packages/backend/src/codebound/codebound-mcp.service.spec.ts new file mode 100644 index 000000000..2cdb6dd1b --- /dev/null +++ b/packages/backend/src/codebound/codebound-mcp.service.spec.ts @@ -0,0 +1,333 @@ +import { CodeboundMcpService } from './codebound-mcp.service'; +import { CodeboundConnection } from '../mcp-server/codebound-tools'; + +const connection: CodeboundConnection = { + userId: 'u1', + email: 'a@example.com', + organizationId: 'org-a', + authMethod: 'mcp_api_key', + apiKeyName: 'agent', +}; +const servers = [ + { id: 's1', name: 'First', organizationId: 'org-a' }, + { id: 's2', name: 'Second', organizationId: 'org-a' }, + { id: 's3', name: 'Other workspace', organizationId: 'org-b' }, +]; + +function setup() { + const prisma = { + organizationMember: { + findMany: jest + .fn() + .mockResolvedValue([{ organizationId: 'org-a', role: 'EDITOR' }]), + }, + mcpServerConfig: { + findMany: jest.fn(async ({ where }: any) => + servers.filter( + (server) => + where.organizationId.in.includes(server.organizationId) && + where.AND.every((condition: any) => + typeof condition.id === 'string' + ? condition.id === server.id + : condition.id.in.includes(server.id), + ), + ), + ), + }, + codeboundScript: { findMany: jest.fn().mockResolvedValue([]) }, + }; + const grants = { resolve: jest.fn().mockResolvedValue(null) }; + const runner = { + listTools: jest.fn().mockResolvedValue([ + { + name: 'get_post', + description: 'Read a post', + inputSchema: {}, + outputSchema: null, + }, + ]), + run: jest.fn().mockResolvedValue({ result: { id: 1 }, calls: 1 }), + }; + const saved = { + id: 'script-1', + mcpServerId: 's1', + code: 'return input;', + input: { default: true }, + allowedTools: ['get_post'], + revision: 1, + }; + const scripts = { + get: jest.fn().mockResolvedValue(saved), + create: jest.fn().mockResolvedValue(saved), + update: jest.fn().mockResolvedValue({ ...saved, revision: 2 }), + run: jest.fn().mockResolvedValue({ result: 1 }), + }; + const registry = { register: jest.fn() }; + const service = new CodeboundMcpService( + prisma as any, + grants as any, + runner as any, + scripts as any, + registry as any, + ); + return { service, prisma, grants, runner, scripts, registry }; +} +const run = { + serverId: 's1', + code: 'return await tools.call("get_post", {});', + input: {}, + allowedTools: ['get_post'], +}; + +describe('Codebound MCP connection scope', () => { + it('registers the feature without importing the MCP module back into itself', () => { + const { service, registry } = setup(); + service.onModuleInit(); + expect(registry.register).toHaveBeenCalledWith(service); + }); + + it('discovers only active-member servers and requires explicit choice on shared /mcp', async () => { + const { service, runner } = setup(); + expect(await service.call(connection, 'codebound_list_tools', {})).toEqual( + expect.objectContaining({ + servers: [ + { id: 's1', name: 'First' }, + { id: 's2', name: 'Second' }, + ], + }), + ); + await expect( + service.call(connection, 'codebound_run', { + code: 'return 1;', + allowedTools: [], + }), + ).rejects.toThrow('Choose a serverId'); + expect(runner.run).not.toHaveBeenCalled(); + }); + + it('returns real tool contracts with bounded pages and unknown output schemas', async () => { + const { service } = setup(); + expect( + await service.call( + { ...connection, endpointServerId: 's1' }, + 'codebound_list_tools', + {}, + ), + ).toEqual({ + server: { id: 's1', name: 'First' }, + tools: [ + { + name: 'get_post', + description: 'Read a post', + inputSchema: {}, + outputSchema: null, + }, + ], + nextOffset: null, + }); + expect( + await service.call(connection, 'codebound_list_tools', { + serverId: 's1', + query: 'missing', + }), + ).toEqual(expect.objectContaining({ tools: [] })); + }); + + it.each(['pinnedServerId', 'endpointServerId'] as const)( + 'never lets arguments override %s', + async (key) => { + const { service, runner } = setup(); + await expect( + service.call({ ...connection, [key]: 's2' }, 'codebound_run', run), + ).rejects.toThrow('not available'); + expect(runner.run).not.toHaveBeenCalled(); + }, + ); + + it('intersects the API-key pin with an OAuth grant instead of widening either', async () => { + const { service, grants, runner } = setup(); + grants.resolve.mockResolvedValue({ + mode: 'servers', + servers: [{ id: 's2', organizationId: 'org-a' }], + }); + await expect( + service.call( + { ...connection, pinnedServerId: 's1', oauthClientId: 'client' }, + 'codebound_run', + run, + ), + ).rejects.toThrow('not available'); + expect(runner.run).not.toHaveBeenCalled(); + }); + + it('uses the granted organization and its current membership role', async () => { + const { service, grants, prisma, runner } = setup(); + grants.resolve.mockResolvedValue({ + mode: 'organization', + organizationId: 'org-b', + }); + prisma.organizationMember.findMany.mockResolvedValue([ + { organizationId: 'org-b', role: 'VIEWER' }, + ]); + await service.call( + { ...connection, oauthClientId: 'client-b' }, + 'codebound_run', + { ...run, serverId: 's3' }, + ); + expect(grants.resolve).toHaveBeenCalledWith('client-b', 'u1'); + expect(runner.run).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: 'org-b', + role: 'VIEWER', + authMethod: 'mcp_api_key', + apiKeyName: 'agent', + }), + 's3', + expect.anything(), + undefined, + expect.any(Function), + ); + }); + + it('does not fall back when a grant is revoked', async () => { + const { service, grants, runner } = setup(); + grants.resolve.mockResolvedValue({ mode: 'none' }); + await expect( + service.call(connection, 'codebound_run', run), + ).rejects.toThrow('no workspace access'); + expect(runner.run).not.toHaveBeenCalled(); + }); + + it('re-resolves grants in the callback passed to every nested call', async () => { + const { service, grants, runner } = setup(); + await service.call(connection, 'codebound_run', run); + const check = (runner.run.mock.calls as any)[0][4]; + grants.resolve.mockResolvedValue({ mode: 'none' }); + await expect(check()).rejects.toThrow('no workspace access'); + }); + + it('refuses deactivated membership even if a token claims ADMIN', async () => { + const { service, prisma, runner } = setup(); + prisma.organizationMember.findMany.mockResolvedValue([]); + await expect( + service.call(connection, 'codebound_run', run), + ).rejects.toThrow('not available'); + expect(runner.run).not.toHaveBeenCalled(); + }); + + it('rejects hidden tool names and invalid/duplicate allowlists before running', async () => { + const { service, runner } = setup(); + for (const allowedTools of [ + ['secret_other_tool'], + ['get_post', 'get_post'], + ]) { + await expect( + service.call(connection, 'codebound_run', { ...run, allowedTools }), + ).rejects.toThrow(); + } + expect(runner.run).not.toHaveBeenCalled(); + }); + + it('rejects identity overrides and malformed arguments', async () => { + const { service, runner } = setup(); + for (const args of [ + { ...run, organizationId: 'org-b' }, + { ...run, input: [] }, + { ...run, limit: 999 }, + { ...run, code: '' }, + ]) { + await expect( + service.call(connection, 'codebound_run', args), + ).rejects.toThrow('Invalid arguments'); + } + expect(runner.run).not.toHaveBeenCalled(); + }); + + it('saves under the trusted server and only updates a reviewed accessible script', async () => { + const { service, scripts } = setup(); + const args = { ...run, name: 'Post summary' }; + const saved = await service.call(connection, 'codebound_save_script', args); + expect(saved).not.toHaveProperty('code'); + expect(saved).not.toHaveProperty('input'); + expect(scripts.create).toHaveBeenCalledWith( + expect.objectContaining({ organizationId: 'org-a', role: 'EDITOR' }), + expect.objectContaining({ mcpServerId: 's1', name: 'Post summary' }), + ); + await expect( + service.call(connection, 'codebound_save_script', { + ...args, + scriptId: 'script-1', + }), + ).rejects.toThrow('reviewed revision'); + await service.call(connection, 'codebound_save_script', { + ...args, + scriptId: 'script-1', + revision: 1, + }); + expect(scripts.get).toHaveBeenCalledWith( + expect.anything(), + 'script-1', + 's1', + ); + expect(scripts.update).toHaveBeenCalledWith( + expect.anything(), + 'script-1', + expect.objectContaining({ revision: 1, mcpServerId: 's1' }), + ); + }); + + it('omits saved workflows with inaccessible tools without revealing their source', async () => { + const { service, prisma } = setup(); + prisma.codeboundScript.findMany.mockResolvedValue([ + { id: 'visible', name: 'Visible', allowedTools: ['get_post'] }, + { id: 'hidden', name: 'Hidden', allowedTools: ['private_tool'] }, + ]); + expect( + await service.call(connection, 'codebound_list_scripts', { + serverId: 's1', + }), + ).toEqual({ + items: [{ id: 'visible', name: 'Visible' }], + nextOffset: null, + }); + expect(prisma.codeboundScript.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { organizationId: 'org-a', mcpServerId: 's1' }, + }), + ); + }); + + it('refuses opening or running saved workflows with newly denied tools', async () => { + const { service, scripts } = setup(); + scripts.get.mockResolvedValue({ + id: 'hidden', + allowedTools: ['private_tool'], + } as any); + for (const name of ['codebound_get_script', 'codebound_run_saved']) { + await expect( + service.call(connection, name, { + serverId: 's1', + scriptId: 'hidden', + ...(name === 'codebound_run_saved' ? { revision: 1 } : {}), + }), + ).rejects.toThrow('outside this connection'); + } + expect(scripts.run).not.toHaveBeenCalled(); + }); + + it('reuses stored defaults and passes the connection restriction to saved execution', async () => { + const { service, scripts } = setup(); + await service.call(connection, 'codebound_run_saved', { + serverId: 's1', + scriptId: 'script-1', + revision: 1, + }); + expect(scripts.run).toHaveBeenCalledWith( + expect.anything(), + 'script-1', + { input: { default: true }, revision: 1 }, + expect.any(Function), + 's1', + ); + }); +}); diff --git a/packages/backend/src/codebound/codebound-mcp.service.ts b/packages/backend/src/codebound/codebound-mcp.service.ts new file mode 100644 index 000000000..2b9cd995e --- /dev/null +++ b/packages/backend/src/codebound/codebound-mcp.service.ts @@ -0,0 +1,316 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, + OnModuleInit, +} from '@nestjs/common'; +import { plainToInstance, ClassConstructor } from 'class-transformer'; +import { validate } from 'class-validator'; +import { z } from 'zod'; +import { PrismaService } from '../common/prisma.service'; +import { McpConnectionGrantService } from '../mcp-servers/mcp-connection-grant.service'; +import { + CODEBOUND_TOOLS, + CodeboundConnection, + CodeboundMcpProvider, + CodeboundRegistry, +} from '../mcp-server/codebound-tools'; +import { CodeboundService, ScriptCaller } from './codebound.service'; +import { SavedScriptsService } from './saved-scripts.service'; +import { RunScriptDto, SaveScriptDto, UpdateScriptDto } from './codebound.dto'; + +@Injectable() +export class CodeboundMcpService implements CodeboundMcpProvider, OnModuleInit { + constructor( + private readonly prisma: PrismaService, + private readonly grants: McpConnectionGrantService, + private readonly runner: CodeboundService, + private readonly scripts: SavedScriptsService, + private readonly registry: CodeboundRegistry, + ) {} + + onModuleInit() { + this.registry.register(this); + } + + private async reachable(connection: CodeboundConnection, id?: string) { + if (!connection.userId || !connection.organizationId) + throw new ForbiddenException( + 'An identified workspace member is required.', + ); + if ( + id && + ((connection.endpointServerId && connection.endpointServerId !== id) || + (connection.pinnedServerId && connection.pinnedServerId !== id)) + ) { + throw new NotFoundException( + 'MCP server is not available on this connection.', + ); + } + // Re-resolve grants and membership on every operation and nested call. + // A revoked grant is never treated as the absence of a grant. + const grant = await this.grants.resolve( + connection.oauthClientId, + connection.userId, + ); + if (grant?.mode === 'none') + throw new ForbiddenException('This connection has no workspace access.'); + const organizationIds = + grant?.mode === 'organization' + ? [grant.organizationId] + : grant?.mode === 'servers' + ? [...new Set(grant.servers.map((server) => server.organizationId))] + : [connection.organizationId]; + const memberships = await this.prisma.organizationMember.findMany({ + where: { + userId: connection.userId, + organizationId: { in: organizationIds }, + deactivatedAt: null, + }, + select: { organizationId: true, role: true }, + }); + const roles = new Map( + memberships.map((member) => [member.organizationId, member.role]), + ); + const filters = [ + id, + connection.endpointServerId, + connection.pinnedServerId, + ].filter((value): value is string => !!value); + const servers = await this.prisma.mcpServerConfig.findMany({ + where: { + organizationId: { in: [...roles.keys()] }, + isActive: true, + AND: [ + ...filters.map((serverId) => ({ id: serverId })), + ...(grant?.mode === 'servers' + ? [{ id: { in: grant.servers.map((server) => server.id) } }] + : []), + ], + }, + select: { id: true, name: true, organizationId: true }, + orderBy: { id: 'asc' }, + take: 200, + }); + return servers.map((server) => ({ + ...server, + role: roles.get(server.organizationId)!, + })); + } + + private async target(connection: CodeboundConnection, requestedId?: string) { + const id = + requestedId ?? connection.endpointServerId ?? connection.pinnedServerId; + if (!id) + throw new BadRequestException( + 'Choose a serverId from codebound_list_tools first.', + ); + const server = (await this.reachable(connection, id))[0]; + if (!server) + throw new NotFoundException( + 'MCP server is not available on this connection.', + ); + const caller: ScriptCaller = { + sub: connection.userId, + email: connection.email, + organizationId: server.organizationId, + role: server.role, + authMethod: connection.authMethod, + apiKeyName: connection.apiKeyName, + }; + return { server, caller }; + } + + private async dto( + type: ClassConstructor, + value: Record, + ): Promise { + const dto = plainToInstance(type, value); + if ( + (await validate(dto, { whitelist: true, forbidNonWhitelisted: true })) + .length + ) { + throw new BadRequestException( + 'Invalid script fields. Check names, source, input, allowlist and revision.', + ); + } + return dto; + } + + async call( + connection: CodeboundConnection, + name: string, + args: Record, + ): Promise { + const definition = CODEBOUND_TOOLS.find((tool) => tool.name === name); + if (!definition) throw new NotFoundException('Codebound tool not found.'); + const parsed = z.strictObject(definition.schema).safeParse(args); + if (!parsed.success) + throw new BadRequestException( + 'Invalid arguments for this Codebound tool.', + ); + const data = parsed.data as Record; + const serverId = data.serverId as string | undefined; + if ( + name === 'codebound_list_tools' && + !serverId && + !connection.endpointServerId && + !connection.pinnedServerId + ) { + const servers = await this.reachable(connection); + return { + servers: servers.map(({ id, name }) => ({ id, name })), + next: 'Choose serverId and call codebound_list_tools again for tool schemas.', + }; + } + const { server, caller } = await this.target(connection, serverId); + const checkConnection = async () => { + await this.target(connection, server.id); + }; + const tools = await this.runner.listTools(caller, server.id); + const permitted = new Set(tools.map((tool) => tool.name)); + const canUse = (allowlist: string[]) => + allowlist.every((tool) => permitted.has(tool)); + const requireTools = (allowlist: string[]) => { + if (!canUse(allowlist)) + throw new ForbiddenException( + "A script tool is unavailable or outside this connection's permissions.", + ); + }; + + if (name === 'codebound_list_tools') { + const query = ((data.query as string | undefined) ?? '').toLowerCase(); + const matches = tools.filter((tool) => + `${tool.name} ${tool.description}`.toLowerCase().includes(query), + ); + const offset = data.offset as number; + const limit = data.limit as number; + return { + server: { id: server.id, name: server.name }, + tools: matches.slice(offset, offset + limit), + nextOffset: offset + limit < matches.length ? offset + limit : null, + }; + } + + if (name === 'codebound_run') { + const dto = await this.dto(RunScriptDto, { + code: data.code, + input: data.input, + allowedTools: data.allowedTools, + }); + requireTools(dto.allowedTools); + return this.runner.run( + caller, + server.id, + dto, + undefined, + checkConnection, + ); + } + + if (name === 'codebound_list_scripts') { + const offset = data.offset as number; + const limit = data.limit as number; + const candidates = await this.prisma.codeboundScript.findMany({ + where: { + organizationId: caller.organizationId, + mcpServerId: server.id, + }, + select: { + id: true, + name: true, + description: true, + revision: true, + updatedAt: true, + allowedTools: true, + }, + orderBy: [{ updatedAt: 'desc' }, { id: 'asc' }], + skip: offset, + take: limit, + }); + return { + items: candidates + .filter((script) => canUse(script.allowedTools)) + .map(({ allowedTools: _allowlist, ...summary }) => summary), + nextOffset: candidates.length === limit ? offset + limit : null, + }; + } + + if (name === 'codebound_save_script') { + const values = { + name: data.name, + description: data.description, + mcpServerId: server.id, + code: data.code, + input: data.input, + allowedTools: data.allowedTools, + }; + requireTools(data.allowedTools as string[]); + if (data.scriptId) { + if (!data.revision) + throw new BadRequestException( + 'Updating requires the reviewed revision from codebound_get_script.', + ); + const existing = await this.scripts.get( + caller, + data.scriptId as string, + server.id, + ); + requireTools(existing.allowedTools); + const dto = await this.dto(UpdateScriptDto, { + ...values, + revision: data.revision, + }); + return this.summary( + await this.scripts.update(caller, existing.id, dto), + ); + } + if (data.revision !== undefined) + throw new BadRequestException( + 'A revision is only valid when updating a scriptId.', + ); + return this.summary( + await this.scripts.create( + caller, + await this.dto(SaveScriptDto, values), + ), + ); + } + + const saved = await this.scripts.get( + caller, + data.scriptId as string, + server.id, + ); + requireTools(saved.allowedTools); + if (name === 'codebound_get_script') return saved; + return this.scripts.run( + caller, + saved.id, + { + input: (data.input ?? saved.input) as Record, + revision: data.revision as number, + }, + checkConnection, + server.id, + ); + } + + private summary(script: { + id: string; + name: string; + mcpServerId: string; + revision: number; + }) { + // The author already has the source. Do not echo it and all default data + // back into the model context merely to confirm persistence. + return { + saved: true, + id: script.id, + name: script.name, + mcpServerId: script.mcpServerId, + revision: script.revision, + }; + } +} diff --git a/packages/backend/src/codebound/codebound.controller.ts b/packages/backend/src/codebound/codebound.controller.ts new file mode 100644 index 000000000..85c237308 --- /dev/null +++ b/packages/backend/src/codebound/codebound.controller.ts @@ -0,0 +1,42 @@ +import { + Body, + Controller, + Get, + Param, + Post, + Req, + UseGuards, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { RunScriptDto } from './codebound.dto'; +import { CodeboundService, ScriptCaller } from './codebound.service'; + +@ApiTags('Codebound') +@ApiBearerAuth() +@UseGuards(AuthGuard('jwt')) +@Controller('api/codebound/servers/:serverId') +export class CodeboundController { + constructor(private readonly codebound: CodeboundService) {} + + @Get('tools') + @ApiOperation({ summary: 'List REST read tools available to a script run' }) + tools( + @Req() req: { user: ScriptCaller }, + @Param('serverId') serverId: string, + ) { + return this.codebound.listTools(req.user, serverId); + } + + @Post('run') + @ApiOperation({ + summary: 'Run an isolated JavaScript body with authorized connector reads', + }) + run( + @Req() req: { user: ScriptCaller }, + @Param('serverId') serverId: string, + @Body() dto: RunScriptDto, + ) { + return this.codebound.run(req.user, serverId, dto); + } +} diff --git a/packages/backend/src/codebound/codebound.dto.ts b/packages/backend/src/codebound/codebound.dto.ts new file mode 100644 index 000000000..71d42e4de --- /dev/null +++ b/packages/backend/src/codebound/codebound.dto.ts @@ -0,0 +1,65 @@ +import { + ArrayMaxSize, + ArrayUnique, + IsArray, + IsObject, + IsInt, + IsString, + Matches, + MaxLength, + Min, + MinLength, +} from 'class-validator'; +import { Transform } from 'class-transformer'; + +export class RunScriptDto { + @IsString() + @MinLength(1) + @MaxLength(32768) + code: string; + + @IsObject() + input: Record; + + @IsArray() + @ArrayMaxSize(20) + @ArrayUnique() + @IsString({ each: true }) + @MaxLength(200, { each: true }) + allowedTools: string[]; +} + +export class SaveScriptDto extends RunScriptDto { + @IsString() + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @MinLength(1) + @MaxLength(120) + @Matches(/\S/, { message: 'name must contain a non-whitespace character' }) + name: string; + + @IsString() + @MaxLength(2000) + description: string; + + @IsString() + @MinLength(1) + @MaxLength(100) + mcpServerId: string; +} + +export class UpdateScriptDto extends SaveScriptDto { + @IsInt() + @Min(1) + revision: number; +} + +export class RunSavedScriptDto { + @IsObject() + input: Record; + + // The version the user reviewed. A changed script must be re-opened before + // running it rather than silently executing a collaborator's new source. + @IsInt() + @Min(1) + revision: number; +} diff --git a/packages/backend/src/codebound/codebound.integration.spec.ts b/packages/backend/src/codebound/codebound.integration.spec.ts new file mode 100644 index 000000000..17e615ef2 --- /dev/null +++ b/packages/backend/src/codebound/codebound.integration.spec.ts @@ -0,0 +1,119 @@ +import { createServer, Server } from 'node:http'; +import { AddressInfo } from 'node:net'; +import { RestEngine } from '../connectors/engines/rest.engine'; +import { DynamicMcpTools } from '../mcp-server/dynamic-mcp-tools'; +import { RegisteredTool } from '../mcp-server/tool-registry'; +import { CodeboundService } from './codebound.service'; + +describe('Codebound through the REST executor', () => { + let server: Server; + let origin: string; + const requests: string[] = []; + + beforeAll(async () => { + server = createServer((request, response) => { + requests.push(request.url ?? ''); + response.setHeader('Content-Type', 'application/json'); + response.end( + JSON.stringify({ + id: 1, + title: 'Demo post', + body: 'Intermediate data', + private: 'Hidden by the response mapping', + }), + ); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + }); + afterAll( + () => new Promise((resolve) => server?.close(() => resolve())), + ); + + it('makes a real connector read, filters its response, and audits both levels', async () => { + const tool: RegisteredTool = { + id: 'tool-a', + connectorId: 'connector-a', + organizationId: 'org-a', + name: 'setup_demo_get_post', + description: 'Read a demo post', + parameters: { type: 'object', properties: {} }, + connectorType: 'REST', + connectorConfig: { baseUrl: origin, authType: 'NONE' }, + endpointMapping: { method: 'GET', path: '/posts/1' }, + responseMapping: { transform: { exclude: ['private'] } }, + }; + const registry = { + getAllTools: () => [tool], + getTool: () => tool, + getToolForOrg: () => tool, + }; + const audit = { logInvocation: jest.fn().mockResolvedValue(undefined) }; + const security = { log: jest.fn().mockResolvedValue(undefined) }; + const license = { + checkLicenseActive: jest.fn().mockResolvedValue(undefined), + }; + const prisma = { + mcpServerConfig: { + findFirst: jest.fn().mockResolvedValue({ + connectors: [{ connectorId: tool.connectorId }], + }), + }, + }; + const executor = new DynamicMcpTools( + registry as any, + audit as any, + {} as any, + license as any, + { isCloud: () => false } as any, + prisma as any, + new RestEngine({} as any, {} as any), + {} as any, + {} as any, + {} as any, + {} as any, + { scheduleObservationalIngest: jest.fn() } as any, + ); + const service = new CodeboundService( + prisma as any, + { getAllowedToolIds: async () => null } as any, + registry as any, + executor, + license as any, + security as any, + ); + const output = await service.run( + { sub: 'user-a', email: 'a@example.com', organizationId: 'org-a' }, + 'server-a', + { + allowedTools: [tool.name], + input: {}, + code: ` + const post = await tools.call('setup_demo_get_post', {}); + if ('private' in post) throw new Error('Response mapping was bypassed'); + return { id: post.id, title: post.title }; + `, + }, + ); + expect(output.result).toEqual({ id: 1, title: 'Demo post' }); + expect(requests).toEqual(['/posts/1']); + expect(audit.logInvocation).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: 'org-a', + toolId: tool.id, + intent: `Codebound run ${output.runId}`, + }), + ); + expect(security.log).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + runId: output.runId, + status: 'SUCCESS', + }), + }), + ); + }); +}); diff --git a/packages/backend/src/codebound/codebound.module.ts b/packages/backend/src/codebound/codebound.module.ts new file mode 100644 index 000000000..92f6492d5 --- /dev/null +++ b/packages/backend/src/codebound/codebound.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { McpServerModule } from '../mcp-server/mcp-server.module'; +import { LicenseModule } from '../license/license.module'; +import { CodeboundController } from './codebound.controller'; +import { CodeboundService } from './codebound.service'; +import { SavedScriptsController } from './saved-scripts.controller'; +import { SavedScriptsService } from './saved-scripts.service'; +import { CodeboundMcpService } from './codebound-mcp.service'; +import { McpServersModule } from '../mcp-servers/mcp-servers.module'; + +@Module({ + imports: [McpServerModule, McpServersModule, LicenseModule], + controllers: [CodeboundController, SavedScriptsController], + providers: [CodeboundService, SavedScriptsService, CodeboundMcpService], +}) +export class CodeboundModule {} diff --git a/packages/backend/src/codebound/codebound.service.spec.ts b/packages/backend/src/codebound/codebound.service.spec.ts new file mode 100644 index 000000000..b2994852f --- /dev/null +++ b/packages/backend/src/codebound/codebound.service.spec.ts @@ -0,0 +1,241 @@ +import { CodeboundService } from './codebound.service'; +import { RegisteredTool } from '../mcp-server/tool-registry'; +import { validate } from 'class-validator'; +import { RunScriptDto } from './codebound.dto'; + +describe('Codebound authorization and execution', () => { + const caller = { + sub: 'user-a', + email: 'a@example.com', + organizationId: 'org-a', + }; + const tool = { + id: 'tool-a', + name: 'setup_demo_get_post', + connectorId: 'connector-a', + organizationId: 'org-a', + connectorType: 'REST', + description: 'Demo post', + parameters: { type: 'object', properties: {} }, + endpointMapping: { method: 'GET', path: '/posts/1' }, + connectorConfig: { baseUrl: 'https://example.com', authType: 'NONE' }, + } as RegisteredTool; + + function setup() { + const prisma = { + mcpServerConfig: { + findFirst: jest + .fn() + .mockResolvedValue({ connectors: [{ connectorId: 'connector-a' }] }), + }, + }; + const roles = { getAllowedToolIds: jest.fn().mockResolvedValue(null) }; + const registry = { getAllTools: jest.fn().mockReturnValue([tool]) }; + const executor = { + executeTool: jest.fn().mockResolvedValue({ + content: [{ type: 'text', text: '{"raw":"should not be used"}' }], + structured: { id: 1, title: 'Filtered', body: 'Data stays inside run' }, + }), + }; + const license = { + checkLicenseActive: jest.fn().mockResolvedValue(undefined), + }; + const security = { log: jest.fn().mockResolvedValue(undefined) }; + const service = new CodeboundService( + prisma as any, + roles as any, + registry as any, + executor as any, + license as any, + security as any, + ); + const dto = { + code: 'const p = await tools.call("setup_demo_get_post", {}); return {id: p.id, title: p.title};', + input: {}, + allowedTools: [tool.name], + }; + return { + service, + prisma, + roles, + registry, + executor, + license, + security, + dto, + }; + } + + it('passes trusted identity to the executor and uses its filtered structured result', async () => { + const { service, executor, security, prisma, dto } = setup(); + const result = await service.run(caller, 'server-a', dto); + expect(result.result).toEqual({ id: 1, title: 'Filtered' }); + expect(prisma.mcpServerConfig.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'server-a', organizationId: 'org-a', isActive: true }, + }), + ); + expect(executor.executeTool).toHaveBeenCalledWith( + tool.name, + {}, + expect.objectContaining({ + userId: 'user-a', + organizationId: 'org-a', + connectorIds: ['connector-a'], + mcpServerId: 'server-a', + skipCache: true, + intent: `Codebound run ${result.runId}`, + }), + ); + expect(security.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'CODEBOUND_RUN', + metadata: expect.objectContaining({ + runId: result.runId, + status: 'SUCCESS', + calls: 1, + }), + }), + ); + expect(JSON.stringify(security.log.mock.calls)).not.toContain('Filtered'); + }); + + it('does not return connector credentials in tool discovery', async () => { + const { service, registry } = setup(); + registry.getAllTools.mockReturnValue([ + { + ...tool, + connectorConfig: { + ...tool.connectorConfig, + envVars: { API_KEY: 'sensitive' }, + }, + }, + ]); + expect( + JSON.stringify(await service.listTools(caller, 'server-a')), + ).not.toContain('sensitive'); + }); + + it('refuses a guessed server in another workspace', async () => { + const { service, prisma, executor, dto } = setup(); + prisma.mcpServerConfig.findFirst.mockResolvedValue(null); + await expect(service.run(caller, 'server-b', dto)).rejects.toThrow( + 'not found', + ); + expect(executor.executeTool).not.toHaveBeenCalled(); + }); + + it.each(['tenant', 'role', 'assignment', 'write', 'not-ready', 'allowlist'])( + 'blocks %s violations before dispatch', + async (reason) => { + const { service, roles, registry, executor, dto } = setup(); + if (reason === 'tenant') + registry.getAllTools.mockReturnValue([ + { ...tool, organizationId: 'org-b' }, + ]); + if (reason === 'role') roles.getAllowedToolIds.mockResolvedValue([]); + if (reason === 'assignment') + registry.getAllTools.mockReturnValue([ + { ...tool, connectorId: 'connector-b' }, + ]); + if (reason === 'write') + registry.getAllTools.mockReturnValue([ + { + ...tool, + endpointMapping: { method: 'POST' }, + annotations: { readOnlyHint: true }, + }, + ]); + if (reason === 'not-ready') + registry.getAllTools.mockReturnValue([ + { ...tool, setupStatus: 'needs_authorization' }, + ]); + if (reason === 'allowlist') dto.allowedTools = []; + await expect(service.run(caller, 'server-a', dto)).rejects.toThrow(); + expect(executor.executeTool).not.toHaveBeenCalled(); + }, + ); + + it('rechecks roles after each nested call', async () => { + const { service, roles, executor, dto } = setup(); + roles.getAllowedToolIds + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValue([]); + dto.code = + 'await tools.call("setup_demo_get_post", {}); return await tools.call("setup_demo_get_post", {});'; + await expect(service.run(caller, 'server-a', dto)).rejects.toThrow( + 'access is denied', + ); + expect(executor.executeTool).toHaveBeenCalledTimes(1); + }); + + it('rejects invalid tool parameters before dispatch', async () => { + const { service, registry, executor, dto } = setup(); + registry.getAllTools.mockReturnValue([ + { + ...tool, + parameters: { + type: 'object', + properties: { id: { type: 'number' } }, + required: ['id'], + }, + }, + ]); + await expect(service.run(caller, 'server-a', dto)).rejects.toThrow( + 'input schema', + ); + expect(executor.executeTool).not.toHaveBeenCalled(); + }); + + it('keeps the existing license gate', async () => { + const { service, license, executor, dto } = setup(); + license.checkLicenseActive.mockRejectedValue(new Error('License inactive')); + await expect(service.run(caller, 'server-a', dto)).rejects.toThrow( + 'License inactive', + ); + expect(executor.executeTool).not.toHaveBeenCalled(); + }); + + it('does not disclose connector error payloads', async () => { + const { service, executor, dto } = setup(); + executor.executeTool.mockResolvedValue({ + isError: true, + content: [{ type: 'text', text: 'secret' }], + } as any); + await expect(service.run(caller, 'server-a', dto)).rejects.toThrow( + 'Connector call failed.', + ); + }); + + it('limits simultaneous runs per workspace and recovers after errors', async () => { + const { service, dto } = setup(); + const first = service.run(caller, 'server-a', { + ...dto, + code: 'throw new Error("test");', + }); + await expect(service.run(caller, 'server-a', dto)).rejects.toThrow('busy'); + await expect(first).rejects.toThrow('test'); + expect((await service.run(caller, 'server-a', dto)).result).toEqual({ + id: 1, + title: 'Filtered', + }); + }); + + it('validates the HTTP request shape with DTO decorators', async () => { + const valid = Object.assign(new RunScriptDto(), { + code: 'return input;', + input: {}, + allowedTools: [], + }); + expect(await validate(valid)).toEqual([]); + const invalid = Object.assign(new RunScriptDto(), { + code: '', + input: [], + allowedTools: ['a', 'a'], + }); + expect( + (await validate(invalid)).map((error) => error.property).sort(), + ).toEqual(['allowedTools', 'code', 'input']); + }); +}); diff --git a/packages/backend/src/codebound/codebound.service.ts b/packages/backend/src/codebound/codebound.service.ts new file mode 100644 index 000000000..4c616222d --- /dev/null +++ b/packages/backend/src/codebound/codebound.service.ts @@ -0,0 +1,230 @@ +import { + BadRequestException, + HttpException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { z } from 'zod'; +import { randomUUID } from 'node:crypto'; +import { PrismaService } from '../common/prisma.service'; +import { RolesService } from '../roles/roles.service'; +import { DynamicMcpTools } from '../mcp-server/dynamic-mcp-tools'; +import { + isListable, + RegisteredTool, + ToolRegistry, +} from '../mcp-server/tool-registry'; +import { deriveToolAnnotations } from '../mcp-server/tool-annotations'; +import { + jsonSchemaToZodShape, + stripEnvVarParams, +} from '../mcp-server/tool-schema.util'; +import { LicenseGuardService } from '../license/license-guard.service'; +import { SecurityEventService } from '../audit/security-event.service'; +import { RunScriptDto } from './codebound.dto'; +import { RUN_TIMEOUT_MS, runSandbox, ScriptError } from './sandbox'; +import { isCodeboundTool } from '../mcp-server/codebound-tools'; + +export interface ScriptCaller { + sub: string; + email: string; + organizationId: string; + role?: string; + authMethod?: string; + apiKeyName?: string; +} + +@Injectable() +export class CodeboundService { + private readonly activeOrganizations = new Set(); + + constructor( + private readonly prisma: PrismaService, + private readonly roles: RolesService, + private readonly registry: ToolRegistry, + private readonly executor: DynamicMcpTools, + private readonly license: LicenseGuardService, + private readonly securityEvents: SecurityEventService, + ) {} + + private async toolsFor( + caller: ScriptCaller, + serverId: string, + ): Promise { + // Tenant filtering happens in the query, before resolving any connectors. + const server = await this.prisma.mcpServerConfig.findFirst({ + where: { + id: serverId, + organizationId: caller.organizationId, + isActive: true, + }, + select: { + connectors: { + where: { + connector: { + organizationId: caller.organizationId, + isActive: true, + }, + }, + select: { connectorId: true }, + }, + }, + }); + if (!server) throw new NotFoundException('Active MCP server not found.'); + const allowedIds = await this.roles.getAllowedToolIds( + caller.sub, + caller.organizationId, + ); + const connectorIds = new Set( + server.connectors.map((entry) => entry.connectorId), + ); + return this.registry.getAllTools().filter( + (tool) => + tool.organizationId === caller.organizationId && + !isCodeboundTool(tool.name) && + connectorIds.has(tool.connectorId) && + (allowedIds === null || allowedIds.includes(tool.id)) && + isListable(tool) && + // First milestone: only REST reads/static responses. Hints alone are + // advisory, so also check the actual configured transport and method. + tool.connectorType === 'REST' && + ['GET', 'HEAD', 'static'].includes(tool.endpointMapping.method) && + deriveToolAnnotations(tool).readOnlyHint === true, + ); + } + + async listTools(caller: ScriptCaller, serverId: string) { + const tools = await this.toolsFor(caller, serverId); + return tools + .filter( + (tool, index) => + tools.findIndex((entry) => entry.name === tool.name) === index, + ) + .map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: stripEnvVarParams( + tool.parameters, + tool.connectorConfig.envVars, + ), + outputSchema: tool.outputSchema ?? null, + })); + } + + async run( + caller: ScriptCaller, + serverId: string, + dto: RunScriptDto, + savedScript?: { id: string; revision: number }, + checkConnection?: () => Promise, + ) { + // Per-process backpressure, also bounding uninterruptible upstream reads. + if ( + this.activeOrganizations.has(caller.organizationId) || + this.activeOrganizations.size >= 4 + ) { + throw new HttpException('Script runner is busy. Try again shortly.', 429); + } + this.activeOrganizations.add(caller.organizationId); + let runningCalls = 0; + let scriptFinished = false; + const release = () => { + if (scriptFinished && runningCalls === 0) + this.activeOrganizations.delete(caller.organizationId); + }; + const runId = randomUUID(); + const started = Date.now(); + let status = 'ERROR'; + let calls = 0; + try { + await checkConnection?.(); + await this.license.checkLicenseActive(caller.organizationId); + await this.toolsFor(caller, serverId); + const allowlist = new Set(dto.allowedTools); + const output = await runSandbox( + dto.code, + dto.input, + async (name, args) => { + runningCalls++; + try { + await checkConnection?.(); + if (!allowlist.has(name)) + throw new ScriptError("Tool is not in this run's allowlist."); + // Recheck current assignments and permissions for EVERY nested call. + const tool = (await this.toolsFor(caller, serverId)).find( + (entry) => entry.name === name, + ); + if (!tool) + throw new ScriptError('Tool is unavailable or access is denied.'); + if (scriptFinished || Date.now() >= started + RUN_TIMEOUT_MS) + throw new ScriptError('Script time limit exceeded.'); + const schema = stripEnvVarParams( + tool.parameters, + tool.connectorConfig.envVars, + ); + const parsed = z + .object(jsonSchemaToZodShape(schema)) + .safeParse(args); + if (!parsed.success) + throw new ScriptError( + 'Tool arguments do not match its input schema.', + ); + calls++; + const result = await this.executor.executeTool(name, parsed.data, { + userId: caller.sub, + userEmail: caller.email, + organizationId: caller.organizationId, + authMethod: caller.authMethod ?? 'jwt', + apiKeyName: caller.apiKeyName, + mcpServerId: serverId, + connectorIds: [tool.connectorId], + // Always audit script reads and avoid shared response cache entries. + skipCache: true, + intent: `Codebound run ${runId}`, + }); + if (result.isError) + throw new ScriptError( + 'Connector call failed. Check the tool invocation log.', + ); + if (result.structured !== undefined) return result.structured; + try { + return JSON.parse(result.content[0].text); + } catch { + throw new ScriptError('Connector did not return JSON data.'); + } + } finally { + runningCalls--; + release(); + } + }, + ); + status = 'SUCCESS'; + return { runId, ...output, durationMs: Date.now() - started }; + } catch (error) { + if (error instanceof ScriptError) + throw new BadRequestException(error.message); + throw error; + } finally { + scriptFinished = true; + release(); + // Record metadata only: neither source, inputs, intermediate data, nor + // results belong in the parent security event. + await this.securityEvents.log({ + event: 'CODEBOUND_RUN', + actorType: 'USER', + actorUserId: caller.sub, + organizationId: caller.organizationId, + metadata: { + runId, + serverId, + status, + calls, + durationMs: Date.now() - started, + ...(savedScript + ? { savedScriptId: savedScript.id, revision: savedScript.revision } + : {}), + }, + }); + } + } +} diff --git a/packages/backend/src/codebound/sandbox-worker.ts b/packages/backend/src/codebound/sandbox-worker.ts new file mode 100644 index 000000000..7c0ced908 --- /dev/null +++ b/packages/backend/src/codebound/sandbox-worker.ts @@ -0,0 +1,95 @@ +// The worker runs only this trusted harness. User source is evaluated solely +// inside QuickJS/WASM; no Node object or function crosses that boundary. +export const SANDBOX_WORKER = String.raw` +const { parentPort, workerData } = require('node:worker_threads'); +const { getQuickJS } = require(workerData.quickjsPath); + +(async () => { + const QuickJS = await getQuickJS(); + const runtime = QuickJS.newRuntime(); + runtime.setMemoryLimit(64 * 1024 * 1024); + runtime.setMaxStackSize(512 * 1024); + runtime.setInterruptHandler(() => Date.now() >= workerData.deadline); + // No module loader, network, process, filesystem, timers, or Node globals. + const vm = runtime.newContext(); + const pending = new Map(); + let sequence = 0; + let finished = false; + const finish = (message) => { + if (finished) return; + finished = true; + parentPort.postMessage(message); + }; + const pump = () => { + if (finished) return; + const jobs = runtime.executePendingJobs(); + if (jobs.error) { + jobs.error.dispose(); + finish({ type: 'error', error: 'Script execution failed or exceeded its resource limit.' }); + } + }; + const call = vm.newFunction('call', (payload) => { + if (vm.typeof(payload) !== 'string') throw new Error('Invalid tool call.'); + const text = vm.getString(payload); + if (Buffer.byteLength(text) > 65536) throw new Error('Tool arguments exceed 64 KiB.'); + const deferred = vm.newPromise(); + const id = ++sequence; + pending.set(id, deferred); + parentPort.postMessage({ type: 'call', id, payload: text }); + return deferred.handle; + }); + const done = vm.newFunction('done', (result) => { + if (vm.typeof(result) !== 'string') { + finish({ type: 'error', error: 'Return a JSON value.' }); + } else { + const json = vm.getString(result); + if (Buffer.byteLength(json) > 32768) finish({ type: 'error', error: 'Script output exceeds 32 KiB.' }); + else finish({ type: 'result', json }); + } + }); + const fail = vm.newFunction('fail', (message) => { + const text = vm.typeof(message) === 'string' ? vm.getString(message) : 'Script failed.'; + finish({ type: 'error', error: text.slice(0, 500) }); + }); + vm.setProp(vm.global, '__call', call); + vm.setProp(vm.global, '__done', done); + vm.setProp(vm.global, '__fail', fail); + call.dispose(); done.dispose(); fail.dispose(); + parentPort.on('message', (message) => { + if (finished) return; + const deferred = pending.get(message.id); + if (!deferred) return; + pending.delete(message.id); + // Resolve with serialized data; guest JSON.parse owns the resulting object. + const value = vm.newString(message.json); + deferred.resolve(value); + value.dispose(); + deferred.dispose(); + pump(); + }); + // Capture the bridge and JSON intrinsics before running user code, then hide + // bridge globals. User code receives only input and tools.call(name, args). + const bootstrap = vm.evalCode( + '(function(call, done, fail, parse, stringify) {' + + 'delete globalThis.__call; delete globalThis.__done; delete globalThis.__fail;' + + 'const tools = Object.freeze({call: async (name, args) => {' + + 'const reply = parse(await call(stringify({name, args})));' + + 'if (!reply.ok) throw new Error(reply.error); return reply.value;' + + '}});' + + 'const input = parse(' + JSON.stringify(workerData.inputJson) + ');' + + '(async function(input, tools) { "use strict";\n' + workerData.code + + '\n})(input, tools).then(value => { try { done(stringify(value)); }' + + 'catch (_) { fail("Return a JSON-serializable value."); } },' + + 'error => { try { fail(String(error)); } catch (_) { fail("Script failed."); } });' + + '})(__call, __done, __fail, JSON.parse, JSON.stringify);', + 'codebound.js' + ); + if (bootstrap.error) { + bootstrap.error.dispose(); + finish({ type: 'error', error: 'Script syntax error or resource limit exceeded.' }); + } else { + bootstrap.value.dispose(); + pump(); + } +})().catch(() => parentPort.postMessage({ type: 'error', error: 'Sandbox could not start.' })); +`; diff --git a/packages/backend/src/codebound/sandbox.spec.ts b/packages/backend/src/codebound/sandbox.spec.ts new file mode 100644 index 000000000..6d478ea25 --- /dev/null +++ b/packages/backend/src/codebound/sandbox.spec.ts @@ -0,0 +1,175 @@ +import { runSandbox } from './sandbox'; + +describe('Codebound QuickJS sandbox', () => { + const call = jest.fn(async () => ({ + id: 1, + title: 'Demo', + body: 'Large intermediate response', + })); + beforeEach(() => call.mockClear()); + + it('awaits a connector and returns only the selected fields', async () => { + const result = await runSandbox( + ` + const post = await tools.call('setup_demo_get_post', input); + return { id: post.id, title: post.title }; + `, + { postId: 1 }, + call, + ); + expect(result).toEqual({ result: { id: 1, title: 'Demo' }, calls: 1 }); + expect(call).toHaveBeenCalledWith('setup_demo_get_post', { postId: 1 }); + }); + + it('supports sequential calls and JSON parameters', async () => { + expect( + await runSandbox( + ` + const first = await tools.call('a', {}); + const second = await tools.call('b', {}); + return first.id + second.id + input.extra; + `, + { extra: 3 }, + call, + ), + ).toEqual({ result: 5, calls: 2 }); + }); + + it('provides no host capabilities, including through a Function constructor', async () => { + const { result } = await runSandbox( + `return { + process: typeof process, require: typeof require, fetch: typeof fetch, + Buffer: typeof Buffer, timers: typeof setTimeout, + bridge: typeof __call, + escape: Function('return typeof process')() + };`, + {}, + call, + ); + expect(Object.values(result as object)).toEqual(Array(7).fill('undefined')); + await expect( + runSandbox(`return import('node:fs');`, {}, call), + ).rejects.toThrow(); + }); + + it('does not share globals between concurrent or sequential runs', async () => { + const results = await Promise.all([ + runSandbox('globalThis.secret = "tenant-a"; return secret;', {}, call), + runSandbox('return typeof secret;', {}, call), + ]); + expect(results.map((item) => item.result)).toEqual([ + 'tenant-a', + 'undefined', + ]); + expect((await runSandbox('return typeof secret;', {}, call)).result).toBe( + 'undefined', + ); + }); + + it.each([ + ['synchronous loop', 'while (true) {}'], + ['loop after await', 'await tools.call("a", {}); while (true) {}'], + ['microtask loop', 'while (true) { await Promise.resolve(); }'], + ['unresolved promise', 'await new Promise(() => {});'], + ])('stops a %s and permits the next run', async (_name, code) => { + await expect(runSandbox(code, {}, call, 300)).rejects.toThrow(); + expect((await runSandbox('return 42;', {}, call)).result).toBe(42); + }); + + it('stops excessive allocation', async () => { + await expect( + runSandbox( + 'const a = []; while (true) a.push(new Array(100000).fill(1));', + {}, + call, + ), + ).rejects.toThrow(); + }); + + it('limits tool calls even if the guest catches errors', async () => { + await expect( + runSandbox( + `for (let i = 0; i < 30; i++) { + try { await tools.call('a', {}); } catch (_) {} + } return true;`, + {}, + call, + ), + ).rejects.toThrow('20 tool call limit'); + expect(call).toHaveBeenCalledTimes(20); + }); + + it('requires sequential calls', async () => { + await expect( + runSandbox( + `return Promise.all([tools.call('a', {}), tools.call('b', {})]);`, + {}, + async () => new Promise(() => {}), + ), + ).rejects.toThrow('Await each tool call'); + }); + + it('bounds output and cumulative intermediate data', async () => { + await expect( + runSandbox('return "x".repeat(32769);', {}, call), + ).rejects.toThrow('32 KiB'); + await expect( + runSandbox('return await tools.call("a", {});', {}, async () => + 'x'.repeat(1024 * 1024), + ), + ).rejects.toThrow('1 MiB'); + }); + + it('rejects oversized arguments and input', async () => { + await expect( + runSandbox( + 'return await tools.call("a", {x: "é".repeat(40000)});', + {}, + call, + ), + ).rejects.toThrow('64 KiB'); + expect(() => + runSandbox('return input;', { data: 'x'.repeat(65536) }, call), + ).toThrow('64 KiB'); + }); + + it('rejects invalid JSON outputs and syntax', async () => { + for (const code of [ + 'return undefined;', + 'return 1n;', + 'const a = {}; a.a = a; return a;', + 'const = nope;', + ]) { + await expect(runSandbox(code, {}, call)).rejects.toThrow(); + } + }); + + it('does not expose raw host errors', async () => { + await expect( + runSandbox('return await tools.call("a", {});', {}, async () => { + throw new Error('Credential secret-value'); + }), + ).rejects.toThrow('Connector call failed.'); + }); + + it('discards late results after timeout and makes no subsequent calls', async () => { + let complete!: (value: unknown) => void; + const slow = jest.fn( + () => + new Promise((resolve) => { + complete = resolve; + }), + ); + await expect( + runSandbox( + 'await tools.call("a", {}); return tools.call("b", {});', + {}, + slow, + 300, + ), + ).rejects.toThrow(); + complete({ id: 1 }); + await new Promise((resolve) => setImmediate(resolve)); + expect(slow).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/backend/src/codebound/sandbox.ts b/packages/backend/src/codebound/sandbox.ts new file mode 100644 index 000000000..2b64e0180 --- /dev/null +++ b/packages/backend/src/codebound/sandbox.ts @@ -0,0 +1,126 @@ +import { Worker } from 'node:worker_threads'; +import { SANDBOX_WORKER } from './sandbox-worker'; + +export const RUN_TIMEOUT_MS = 5000; +export const MAX_CALLS = 20; +export const MAX_RESPONSE_BYTES = 1024 * 1024; + +export class ScriptError extends Error {} + +/** Fresh worker and fresh QuickJS heap per run, including after failures. */ +export function runSandbox( + code: string, + input: Record, + callTool: (name: string, args: Record) => Promise, + timeoutMs = RUN_TIMEOUT_MS, +): Promise<{ result: unknown; calls: number }> { + const inputJson = JSON.stringify(input); + if (Buffer.byteLength(inputJson) > 65536) + throw new ScriptError('Input exceeds 64 KiB.'); + return new Promise((resolve, reject) => { + const worker = new Worker(SANDBOX_WORKER, { + eval: true, + workerData: { + code, + inputJson, + deadline: Date.now() + timeoutMs, + quickjsPath: require.resolve('quickjs-emscripten'), + }, + resourceLimits: { maxOldGenerationSizeMb: 128, stackSizeMb: 4 }, + // Do not inherit ts-node loaders or other application startup flags. + execArgv: [], + }); + let settled = false; + let calls = 0; + let responseBytes = 0; + let inFlight = false; + const finish = (error?: ScriptError, result?: unknown) => { + if (settled) return; + settled = true; + clearTimeout(timer); + void worker.terminate().then(() => { + if (error) reject(error); + else resolve({ result, calls }); + }); + }; + const timer = setTimeout( + () => finish(new ScriptError('Script exceeded the 5 second time limit.')), + timeoutMs, + ); + worker.on('error', () => + finish( + new ScriptError('Sandbox resource limit exceeded or worker failed.'), + ), + ); + worker.on('exit', () => { + if (!settled) finish(new ScriptError('Sandbox exited without a result.')); + }); + worker.on('message', async (message) => { + if (settled) return; + if (message.type === 'result') { + try { + finish(undefined, JSON.parse(message.json)); + } catch { + finish(new ScriptError('Return a JSON value.')); + } + return; + } + if (message.type === 'error') { + finish(new ScriptError(message.error)); + return; + } + if (message.type !== 'call') return; + if (++calls > MAX_CALLS) + return finish( + new ScriptError('Script exceeded the 20 tool call limit.'), + ); + // Existing connector engines cannot cancel a request. Limit the prototype + // to sequential reads; after termination, never dispatch or deliver more. + if (inFlight) + return finish( + new ScriptError('Await each tool call before starting the next.'), + ); + inFlight = true; + let reply: { ok: boolean; value?: unknown; error?: string }; + try { + const { name, args } = JSON.parse(message.payload); + if ( + typeof name !== 'string' || + !args || + typeof args !== 'object' || + Array.isArray(args) + ) { + throw new ScriptError( + 'tools.call requires a tool name and an arguments object.', + ); + } + reply = { ok: true, value: await callTool(name, args) }; + } catch (error) { + // Connector failures must never disclose credentials or raw exceptions. + reply = { + ok: false, + error: + error instanceof ScriptError + ? error.message + : 'Connector call failed.', + }; + } finally { + inFlight = false; + } + if (settled) return; + try { + const json = JSON.stringify(reply); + responseBytes += Buffer.byteLength(json); + if (responseBytes > MAX_RESPONSE_BYTES) + return finish( + new ScriptError('Tool responses exceed the 1 MiB run limit.'), + ); + worker.postMessage({ id: message.id, json }); + } catch { + finish( + new ScriptError('Connector returned data that cannot be serialized.'), + ); + } + }); + }); +} diff --git a/packages/backend/src/codebound/saved-scripts.controller.ts b/packages/backend/src/codebound/saved-scripts.controller.ts new file mode 100644 index 000000000..a2e15e1ed --- /dev/null +++ b/packages/backend/src/codebound/saved-scripts.controller.ts @@ -0,0 +1,79 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + Query, + Req, + UseGuards, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { PaginationQueryDto } from '../common/pagination.dto'; +import { ScriptCaller } from './codebound.service'; +import { + RunSavedScriptDto, + SaveScriptDto, + UpdateScriptDto, +} from './codebound.dto'; +import { SavedScriptsService } from './saved-scripts.service'; + +@ApiTags('Codebound') +@ApiBearerAuth() +@UseGuards(AuthGuard('jwt')) +@Controller('api/codebound/scripts') +export class SavedScriptsController { + constructor(private readonly scripts: SavedScriptsService) {} + + @Get() + @ApiOperation({ summary: 'List saved scripts in the current workspace' }) + list( + @Req() req: { user: ScriptCaller }, + @Query() pagination: PaginationQueryDto, + ) { + return this.scripts.list(req.user, pagination); + } + + @Post() + @ApiOperation({ summary: 'Save a reusable script in the current workspace' }) + create(@Req() req: { user: ScriptCaller }, @Body() dto: SaveScriptDto) { + return this.scripts.create(req.user, dto); + } + + @Get(':id') + @ApiOperation({ summary: 'Open a saved script' }) + get(@Req() req: { user: ScriptCaller }, @Param('id') id: string) { + return this.scripts.get(req.user, id); + } + + @Put(':id') + @ApiOperation({ summary: 'Update a saved script at its reviewed revision' }) + update( + @Req() req: { user: ScriptCaller }, + @Param('id') id: string, + @Body() dto: UpdateScriptDto, + ) { + return this.scripts.update(req.user, id, dto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete a saved script' }) + delete(@Req() req: { user: ScriptCaller }, @Param('id') id: string) { + return this.scripts.delete(req.user, id); + } + + @Post(':id/run') + @ApiOperation({ + summary: 'Execute a saved script using current caller permissions', + }) + run( + @Req() req: { user: ScriptCaller }, + @Param('id') id: string, + @Body() dto: RunSavedScriptDto, + ) { + return this.scripts.run(req.user, id, dto); + } +} diff --git a/packages/backend/src/codebound/saved-scripts.db.spec.ts b/packages/backend/src/codebound/saved-scripts.db.spec.ts new file mode 100644 index 000000000..6f29d280f --- /dev/null +++ b/packages/backend/src/codebound/saved-scripts.db.spec.ts @@ -0,0 +1,213 @@ +import { randomUUID } from 'node:crypto'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from '../generated/prisma/client'; +import { SavedScriptsService } from './saved-scripts.service'; +import { ScriptCaller } from './codebound.service'; + +// Opt in against the local development DB after applying migrations. These +// tests create their own tenant fixtures and remove only those fixtures. +const databaseTests = + process.env.RUN_CODEBOUND_DB === '1' ? describe : describe.skip; +databaseTests('Saved scripts in PostgreSQL', () => { + let db: PrismaClient; + let service: SavedScriptsService; + let a: ScriptCaller; + let b: ScriptCaller; + let serverA: string; + let serverB: string; + const organizations: string[] = []; + const users: string[] = []; + const runner = { run: jest.fn().mockResolvedValue({ result: { ok: true } }) }; + const license = { + checkLicenseActive: jest.fn().mockResolvedValue(undefined), + }; + const audit = { log: jest.fn().mockResolvedValue(undefined) }; + const client = () => + new PrismaClient({ + adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }), + }); + const createService = (prisma: PrismaClient) => + new SavedScriptsService( + prisma as any, + runner as any, + license as any, + audit as any, + ); + const body = () => ({ + name: 'Persistent summary', + description: 'Database test fixture', + mcpServerId: serverA, + code: 'return { answer: input.value };', + input: { value: 42 }, + allowedTools: [], + }); + + beforeAll(async () => { + const url = new URL(process.env.DATABASE_URL ?? ''); + if ( + !['localhost', '127.0.0.1'].includes(url.hostname) || + url.port !== '5433' + ) { + throw new Error( + 'RUN_CODEBOUND_DB requires local development PostgreSQL on port 5433.', + ); + } + db = client(); + await db.$connect(); + const seed = async () => { + const org = await db.organization.create({ + data: { name: `Codebound test ${randomUUID()}` }, + }); + organizations.push(org.id); + const user = await db.user.create({ + data: { + email: `codebound-${randomUUID()}@example.invalid`, + role: 'ADMIN', + organizationId: org.id, + }, + }); + users.push(user.id); + const server = await db.mcpServerConfig.create({ + data: { userId: user.id, organizationId: org.id, name: 'Test server' }, + }); + return { + caller: { + sub: user.id, + email: user.email, + organizationId: org.id, + role: 'ADMIN', + }, + server: server.id, + }; + }; + const first = await seed(); + const second = await seed(); + a = first.caller; + serverA = first.server; + b = second.caller; + serverB = second.server; + service = createService(db); + }); + + afterEach(async () => { + if (db && organizations.length) + await db.codeboundScript.deleteMany({ + where: { organizationId: { in: organizations } }, + }); + }); + afterAll(async () => { + if (!db) return; + try { + await db.user.deleteMany({ + where: { id: { in: users }, organizationId: { in: organizations } }, + }); + await db.organization.deleteMany({ + where: { id: { in: organizations } }, + }); + } finally { + await db.$disconnect(); + } + }); + + it('persists code, parameters and capabilities across database connections', async () => { + const saved = await service.create(a, body()); + const secondConnection = client(); + try { + const reopened = await createService(secondConnection).get(a, saved.id); + expect(reopened).toEqual( + expect.objectContaining({ ...body(), revision: 1 }), + ); + expect((await service.list(a, {})).total).toBe(1); + } finally { + await secondConnection.$disconnect(); + } + }); + + it('denies cross-tenant list/open/edit/delete/run even with a known ID', async () => { + const saved = await service.create(a, body()); + expect(await service.list(b, {})).toEqual({ items: [], total: 0 }); + await expect(service.get(b, saved.id)).rejects.toThrow('Script not found'); + await expect( + service.update(b, saved.id, { + ...body(), + mcpServerId: serverB, + revision: 1, + }), + ).rejects.toThrow('Script not found'); + await expect(service.delete(b, saved.id)).rejects.toThrow( + 'Script not found', + ); + await expect( + service.run(b, saved.id, { input: {}, revision: 1 }), + ).rejects.toThrow('Script not found'); + expect((await service.get(a, saved.id)).revision).toBe(1); + }); + + it('accepts one of two concurrent writes and rejects the stale writer', async () => { + const saved = await service.create(a, body()); + const results = await Promise.allSettled([ + service.update(a, saved.id, { + ...body(), + code: 'return 1;', + revision: 1, + }), + service.update(a, saved.id, { + ...body(), + code: 'return 2;', + revision: 1, + }), + ]); + expect( + results.filter((result) => result.status === 'fulfilled'), + ).toHaveLength(1); + const failure = results.find( + (result) => result.status === 'rejected', + ) as PromiseRejectedResult; + expect(failure.reason.getStatus()).toBe(409); + const current = await service.get(a, saved.id); + expect(current.revision).toBe(2); + await expect( + service.run(a, saved.id, { input: {}, revision: 1 }), + ).rejects.toThrow('Reopen it before running'); + }); + + it('enforces the tenant/server boundary in the database as well as the API', async () => { + await expect( + service.create(a, { ...body(), mcpServerId: serverB }), + ).rejects.toThrow('Active MCP server not found'); + await expect( + db.codeboundScript.create({ + data: { + ...body(), + organizationId: a.organizationId, + mcpServerId: serverB, + }, + }), + ).rejects.toMatchObject({ code: 'P2003' }); + }); + + it('deletes scripts permanently and cascades when their server is removed', async () => { + const saved = await service.create(a, body()); + await service.delete(a, saved.id); + await expect(service.get(a, saved.id)).rejects.toThrow('Script not found'); + await expect( + service.run(a, saved.id, { input: {}, revision: 1 }), + ).rejects.toThrow('Script not found'); + const temporary = await db.mcpServerConfig.create({ + data: { + userId: a.sub, + organizationId: a.organizationId, + name: 'Temporary test server', + slug: randomUUID(), + }, + }); + const linked = await service.create(a, { + ...body(), + mcpServerId: temporary.id, + }); + await db.mcpServerConfig.deleteMany({ + where: { id: temporary.id, organizationId: a.organizationId }, + }); + await expect(service.get(a, linked.id)).rejects.toThrow('Script not found'); + }); +}); diff --git a/packages/backend/src/codebound/saved-scripts.service.spec.ts b/packages/backend/src/codebound/saved-scripts.service.spec.ts new file mode 100644 index 000000000..8d6e025f6 --- /dev/null +++ b/packages/backend/src/codebound/saved-scripts.service.spec.ts @@ -0,0 +1,278 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { SavedScriptsService } from './saved-scripts.service'; +import { + RunSavedScriptDto, + SaveScriptDto, + UpdateScriptDto, +} from './codebound.dto'; + +describe('Saved Codebound scripts', () => { + const caller = { + sub: 'user-a', + email: 'a@example.com', + organizationId: 'org-a', + role: 'EDITOR', + }; + const dto: SaveScriptDto = { + name: 'Post summary', + description: 'A reusable script', + mcpServerId: 'server-a', + code: 'return input;', + input: { limit: 1 }, + allowedTools: ['get_post'], + }; + const script = { + ...dto, + id: 'script-a', + revision: 1, + createdAt: new Date(), + updatedAt: new Date(), + }; + + function setup() { + const prisma = { + mcpServerConfig: { + findFirst: jest.fn().mockResolvedValue({ id: 'server-a' }), + }, + codeboundScript: { + findFirst: jest.fn().mockResolvedValue(script), + findFirstOrThrow: jest + .fn() + .mockResolvedValue({ ...script, revision: 2 }), + findMany: jest.fn().mockResolvedValue([script]), + count: jest.fn().mockResolvedValue(1), + create: jest.fn().mockResolvedValue(script), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + deleteMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + $transaction: jest.fn(), + }; + prisma.$transaction.mockImplementation(async (work) => + typeof work === 'function' ? work(prisma) : Promise.all(work), + ); + const runner = { run: jest.fn().mockResolvedValue({ result: 42 }) }; + const license = { + checkLicenseActive: jest.fn().mockResolvedValue(undefined), + }; + const audit = { log: jest.fn().mockResolvedValue(undefined) }; + const service = new SavedScriptsService( + prisma as any, + runner as any, + license as any, + audit as any, + ); + return { prisma, runner, license, audit, service }; + } + + it('saves only explicitly accepted fields in the authenticated workspace', async () => { + const { service, prisma, audit } = setup(); + await service.create(caller, { + ...dto, + name: ' Post summary ', + organizationId: 'org-b', + } as SaveScriptDto); + expect(prisma.codeboundScript.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: { ...dto, organizationId: 'org-a' }, + }), + ); + expect(prisma.mcpServerConfig.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'server-a', organizationId: 'org-a', isActive: true }, + }), + ); + expect(audit.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'CODEBOUND_SCRIPT_CREATED', + organizationId: 'org-a', + actorUserId: 'user-a', + }), + ); + expect(JSON.stringify(audit.log.mock.calls)).not.toContain('return input'); + }); + + it('lists summaries with bounded pagination and a tenant-scoped count', async () => { + const { service, prisma } = setup(); + expect(await service.list(caller, {})).toEqual({ + items: [script], + total: 1, + }); + expect(prisma.codeboundScript.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { organizationId: 'org-a' }, + skip: 0, + take: 20, + }), + ); + expect( + prisma.codeboundScript.findMany.mock.calls[0][0].select.code, + ).toBeUndefined(); + expect(prisma.codeboundScript.count).toHaveBeenCalledWith({ + where: { organizationId: 'org-a' }, + }); + }); + + it('does not open a guessed script from another tenant', async () => { + const { service, prisma } = setup(); + prisma.codeboundScript.findFirst.mockResolvedValue(null); + await expect(service.get(caller, 'other-id')).rejects.toThrow( + 'Script not found', + ); + expect(prisma.codeboundScript.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'other-id', organizationId: 'org-a' }, + }), + ); + }); + + it.each(['create', 'update', 'delete'] as const)( + 'denies viewer %s before touching storage', + async (operation) => { + const { service, prisma } = setup(); + const viewer = { ...caller, role: 'VIEWER' }; + const action = + operation === 'create' + ? service.create(viewer, dto) + : operation === 'update' + ? service.update(viewer, 'script-a', { ...dto, revision: 1 }) + : service.delete(viewer, 'script-a'); + await expect(action).rejects.toThrow('editor or administrator'); + expect(prisma.codeboundScript.findFirst).not.toHaveBeenCalled(); + expect(prisma.codeboundScript.create).not.toHaveBeenCalled(); + expect(prisma.codeboundScript.updateMany).not.toHaveBeenCalled(); + expect(prisma.codeboundScript.deleteMany).not.toHaveBeenCalled(); + }, + ); + + it('refuses an inactive or foreign server without writing', async () => { + const { service, prisma } = setup(); + prisma.mcpServerConfig.findFirst.mockResolvedValue(null); + await expect(service.create(caller, dto)).rejects.toThrow( + 'Active MCP server not found', + ); + expect(prisma.codeboundScript.create).not.toHaveBeenCalled(); + }); + + it('updates atomically at the reviewed revision', async () => { + const { service, prisma } = setup(); + expect( + (await service.update(caller, 'script-a', { ...dto, revision: 1 })) + .revision, + ).toBe(2); + expect(prisma.codeboundScript.updateMany).toHaveBeenCalledWith({ + where: { id: 'script-a', organizationId: 'org-a', revision: 1 }, + data: { ...dto, revision: { increment: 1 } }, + }); + expect(prisma.codeboundScript.findFirstOrThrow).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'script-a', organizationId: 'org-a' }, + }), + ); + }); + + it('rejects stale writes without returning another revision as a successful save', async () => { + const { service, prisma } = setup(); + prisma.codeboundScript.updateMany.mockResolvedValue({ count: 0 }); + await expect( + service.update(caller, 'script-a', { ...dto, revision: 1 }), + ).rejects.toThrow('Reopen it before saving'); + expect(prisma.codeboundScript.findFirstOrThrow).not.toHaveBeenCalled(); + }); + + it('returns not found when a script disappears during an update', async () => { + const { service, prisma } = setup(); + prisma.codeboundScript.updateMany.mockResolvedValue({ count: 0 }); + prisma.codeboundScript.findFirst + .mockResolvedValueOnce(script) + .mockResolvedValueOnce(null); + await expect( + service.update(caller, 'script-a', { ...dto, revision: 1 }), + ).rejects.toThrow('Script not found'); + }); + + it('scopes deletion by organization and makes a repeated delete fail', async () => { + const { service, prisma, audit } = setup(); + expect(await service.delete(caller, 'script-a')).toEqual({ deleted: true }); + expect(prisma.codeboundScript.deleteMany).toHaveBeenCalledWith({ + where: { id: 'script-a', organizationId: 'org-a' }, + }); + expect(audit.log).toHaveBeenCalledWith( + expect.objectContaining({ event: 'CODEBOUND_SCRIPT_DELETED' }), + ); + prisma.codeboundScript.deleteMany.mockResolvedValue({ count: 0 }); + await expect(service.delete(caller, 'script-a')).rejects.toThrow( + 'Script not found', + ); + }); + + it('runs stored source and capabilities with current caller identity and per-run input', async () => { + const { service, runner } = setup(); + const viewer = { ...caller, role: 'VIEWER' }; + await service.run(viewer, script.id, { input: { limit: 2 }, revision: 1 }); + expect(runner.run).toHaveBeenCalledWith( + viewer, + 'server-a', + { + code: dto.code, + input: { limit: 2 }, + allowedTools: dto.allowedTools, + }, + { id: script.id, revision: 1 }, + ); + }); + + it('rejects changed saved source before execution', async () => { + const { service, runner } = setup(); + await expect( + service.run(caller, script.id, { input: {}, revision: 5 }), + ).rejects.toThrow('Reopen it before running'); + expect(runner.run).not.toHaveBeenCalled(); + }); + + it('preserves connector authorization failures from the runner', async () => { + const { service, runner } = setup(); + runner.run.mockRejectedValue(new Error('Tool access denied')); + await expect( + service.run(caller, script.id, { input: {}, revision: 1 }), + ).rejects.toThrow('Tool access denied'); + }); + + it('rejects oversized default parameters and an inactive license', async () => { + const { service, prisma, license } = setup(); + await expect( + service.create(caller, { ...dto, input: { value: 'é'.repeat(40000) } }), + ).rejects.toThrow('64 KiB'); + expect(prisma.codeboundScript.create).not.toHaveBeenCalled(); + license.checkLicenseActive.mockRejectedValue(new Error('License inactive')); + await expect(service.create(caller, dto)).rejects.toThrow( + 'License inactive', + ); + }); + + it('validates save/update/run DTOs and rejects source overrides on saved execution', async () => { + expect(await validate(plainToInstance(SaveScriptDto, dto))).toEqual([]); + expect( + (await validate(plainToInstance(UpdateScriptDto, dto))).map( + (error) => error.property, + ), + ).toContain('revision'); + const whitespace = plainToInstance(SaveScriptDto, { ...dto, name: ' ' }); + expect( + (await validate(whitespace)).map((error) => error.property), + ).toContain('name'); + const override = plainToInstance(RunSavedScriptDto, { + input: {}, + revision: 1, + code: 'return 999;', + }); + expect( + ( + await validate(override, { + whitelist: true, + forbidNonWhitelisted: true, + }) + ).map((error) => error.property), + ).toContain('code'); + }); +}); diff --git a/packages/backend/src/codebound/saved-scripts.service.ts b/packages/backend/src/codebound/saved-scripts.service.ts new file mode 100644 index 000000000..12d8690f8 --- /dev/null +++ b/packages/backend/src/codebound/saved-scripts.service.ts @@ -0,0 +1,230 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Prisma } from '../generated/prisma/client'; +import { PrismaService } from '../common/prisma.service'; +import { SecurityEventService } from '../audit/security-event.service'; +import { LicenseGuardService } from '../license/license-guard.service'; +import { PaginationQueryDto } from '../common/pagination.dto'; +import { CodeboundService, ScriptCaller } from './codebound.service'; +import { + RunSavedScriptDto, + SaveScriptDto, + UpdateScriptDto, +} from './codebound.dto'; + +const SUMMARY = { + id: true, + name: true, + description: true, + mcpServerId: true, + revision: true, + createdAt: true, + updatedAt: true, +} satisfies Prisma.CodeboundScriptSelect; +const FULL = { + ...SUMMARY, + code: true, + input: true, + allowedTools: true, +} satisfies Prisma.CodeboundScriptSelect; + +@Injectable() +export class SavedScriptsService { + constructor( + private readonly prisma: PrismaService, + private readonly runner: CodeboundService, + private readonly license: LicenseGuardService, + private readonly audit: SecurityEventService, + ) {} + + private assertCanWrite(caller: ScriptCaller) { + if (!['ADMIN', 'EDITOR'].includes(caller.role ?? '')) { + throw new ForbiddenException( + 'An editor or administrator can save and delete scripts.', + ); + } + } + + private async assertServer(caller: ScriptCaller, id: string) { + const server = await this.prisma.mcpServerConfig.findFirst({ + where: { id, organizationId: caller.organizationId, isActive: true }, + select: { id: true }, + }); + if (!server) throw new NotFoundException('Active MCP server not found.'); + } + + private scriptData(dto: SaveScriptDto) { + if (!dto.name.trim()) + throw new BadRequestException('Script name is required.'); + if (Buffer.byteLength(JSON.stringify(dto.input)) > 65536) { + throw new BadRequestException('Default parameters exceed 64 KiB.'); + } + return { + name: dto.name.trim(), + description: dto.description, + mcpServerId: dto.mcpServerId, + code: dto.code, + input: dto.input as Prisma.InputJsonObject, + allowedTools: dto.allowedTools, + }; + } + + async list(caller: ScriptCaller, pagination: PaginationQueryDto) { + const where = { organizationId: caller.organizationId }; + const [items, total] = await this.prisma.$transaction([ + this.prisma.codeboundScript.findMany({ + where, + select: SUMMARY, + orderBy: [{ updatedAt: 'desc' }, { id: 'asc' }], + skip: pagination.offset ?? 0, + take: pagination.limit ?? 20, + }), + this.prisma.codeboundScript.count({ where }), + ]); + return { items, total }; + } + + async get(caller: ScriptCaller, id: string, serverId?: string) { + const script = await this.prisma.codeboundScript.findFirst({ + where: { + id, + organizationId: caller.organizationId, + ...(serverId ? { mcpServerId: serverId } : {}), + }, + select: FULL, + }); + if (!script) throw new NotFoundException('Script not found.'); + return script; + } + + private async record( + event: string, + caller: ScriptCaller, + script: { id: string; mcpServerId: string; revision: number }, + ) { + await this.audit.log({ + event, + actorType: 'USER', + actorUserId: caller.sub, + organizationId: caller.organizationId, + metadata: { + scriptId: script.id, + serverId: script.mcpServerId, + revision: script.revision, + }, + }); + } + + async create(caller: ScriptCaller, dto: SaveScriptDto) { + this.assertCanWrite(caller); + await this.license.checkLicenseActive(caller.organizationId); + const data = this.scriptData(dto); + await this.assertServer(caller, dto.mcpServerId); + try { + const script = await this.prisma.codeboundScript.create({ + data: { ...data, organizationId: caller.organizationId }, + select: FULL, + }); + await this.record('CODEBOUND_SCRIPT_CREATED', caller, script); + return script; + } catch (error) { + // A server may have been removed between validation and the FK write. + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2003' + ) { + throw new NotFoundException('MCP server is no longer available.'); + } + throw error; + } + } + + async update(caller: ScriptCaller, id: string, dto: UpdateScriptDto) { + this.assertCanWrite(caller); + await this.license.checkLicenseActive(caller.organizationId); + const data = this.scriptData(dto); + // Resolve the script in this workspace before considering the replacement server. + await this.get(caller, id); + await this.assertServer(caller, dto.mcpServerId); + try { + const script = await this.prisma.$transaction(async (tx) => { + const updated = await tx.codeboundScript.updateMany({ + where: { + id, + organizationId: caller.organizationId, + revision: dto.revision, + }, + data: { ...data, revision: { increment: 1 } }, + }); + if (updated.count === 0) { + const current = await tx.codeboundScript.findFirst({ + where: { id, organizationId: caller.organizationId }, + select: { id: true }, + }); + if (!current) throw new NotFoundException('Script not found.'); + throw new ConflictException( + 'Script changed. Reopen it before saving.', + ); + } + // Keep the read in the transaction so the response matches this revision. + return tx.codeboundScript.findFirstOrThrow({ + where: { id, organizationId: caller.organizationId }, + select: FULL, + }); + }); + await this.record('CODEBOUND_SCRIPT_UPDATED', caller, script); + return script; + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2003' + ) { + throw new NotFoundException('MCP server is no longer available.'); + } + throw error; + } + } + + async delete(caller: ScriptCaller, id: string) { + this.assertCanWrite(caller); + const script = await this.get(caller, id); + const deleted = await this.prisma.codeboundScript.deleteMany({ + where: { id, organizationId: caller.organizationId }, + }); + if (deleted.count === 0) throw new NotFoundException('Script not found.'); + await this.record('CODEBOUND_SCRIPT_DELETED', caller, script); + return { deleted: true }; + } + + async run( + caller: ScriptCaller, + id: string, + dto: RunSavedScriptDto, + checkConnection?: () => Promise, + serverId?: string, + ) { + const script = await this.get(caller, id, serverId); + if (dto.revision !== script.revision) + throw new ConflictException('Script changed. Reopen it before running.'); + // Source, server and capabilities come exclusively from the stored record. + // The runner rechecks the caller's live connector permissions for each call. + const args = [ + caller, + script.mcpServerId, + { + code: script.code, + input: dto.input, + allowedTools: script.allowedTools, + }, + { id: script.id, revision: script.revision }, + ] as const; + return checkConnection + ? this.runner.run(...args, checkConnection) + : this.runner.run(...args); + } +} diff --git a/packages/backend/src/mcp-server/codebound-endpoint.spec.ts b/packages/backend/src/mcp-server/codebound-endpoint.spec.ts new file mode 100644 index 000000000..6bc73482a --- /dev/null +++ b/packages/backend/src/mcp-server/codebound-endpoint.spec.ts @@ -0,0 +1,494 @@ +import { Client } from '@modelcontextprotocol/client'; +import { InMemoryTransport, McpServer } from '@modelcontextprotocol/server'; +import { CodeboundService } from '../codebound/codebound.service'; +import { SavedScriptsService } from '../codebound/saved-scripts.service'; +import { CodeboundMcpService } from '../codebound/codebound-mcp.service'; +import { McpEndpointController } from './mcp-endpoint.controller'; +import { ToolRegistry, RegisteredTool } from './tool-registry'; +import { CODEBOUND_TOOLS, CodeboundRegistry } from './codebound-tools'; + +const user = { + sub: 'u1', + email: 'a@example.com', + organizationId: 'org-a', + authMethod: 'jwt', + azp: 'client-a', +}; +const serverA = { + id: 's1', + name: 'Demo', + organizationId: 'org-a', + version: '1', + isActive: true, +}; +const base = { + name: 'setup_demo_get_post', + description: 'Read demo post', + parameters: { type: 'object', properties: {} }, + connectorType: 'REST', + connectorConfig: { + baseUrl: 'https://example.com', + authType: 'NONE', + envVars: { API_KEY: 'stored-secret-not-for-the-agent' }, + }, + endpointMapping: { method: 'GET', path: '/posts/1' }, +}; + +function build(role = 'EDITOR') { + const registry = new ToolRegistry(); + registry.registerTool({ + ...base, + id: 'foreign', + connectorId: 'conn-b', + organizationId: 'org-b', + } as RegisteredTool); + registry.registerTool({ + ...base, + id: 'own', + connectorId: 'conn-a', + organizationId: 'org-a', + } as RegisteredTool); + const rows = new Map(); + const project = (row: any, select: any) => + row && + Object.fromEntries( + Object.keys(select) + .filter((key) => select[key]) + .map((key) => [key, row[key]]), + ); + const matches = (row: any, where: any) => + Object.entries(where).every(([key, value]) => row[key] === value); + const prisma = { + organizationMember: { + findMany: jest + .fn() + .mockResolvedValue([{ organizationId: 'org-a', role }]), + }, + mcpServerConfig: { + findFirst: jest.fn(async ({ where }: any) => + where.id === 's1' && where.organizationId === 'org-a' + ? { ...serverA, connectors: [{ connectorId: 'conn-a' }] } + : null, + ), + findMany: jest.fn(async ({ where }: any) => + where.organizationId.in.includes('org-a') && + where.AND.every((filter: any) => + typeof filter.id === 'string' + ? filter.id === 's1' + : filter.id.in.includes('s1'), + ) + ? [serverA] + : [], + ), + }, + codeboundScript: { + create: jest.fn(async ({ data, select }: any) => { + const row = { + ...data, + id: 'script-1', + revision: 1, + createdAt: new Date(), + updatedAt: new Date(), + }; + rows.set(row.id, row); + return project(row, select); + }), + findFirst: jest.fn(async ({ where, select }: any) => + project( + [...rows.values()].find((row) => matches(row, where)), + select, + ), + ), + findMany: jest.fn(async ({ where, select, skip, take }: any) => + [...rows.values()] + .filter((row) => matches(row, where)) + .slice(skip, skip + take) + .map((row) => project(row, select)), + ), + }, + }; + const roles = { getAllowedToolIds: jest.fn().mockResolvedValue(null) }; + const executor = { + executeTool: jest + .fn() + .mockResolvedValue({ + content: [{ type: 'text', text: '{}' }], + structured: { + id: 1, + title: 'Demo post', + body: 'Large intermediate response', + }, + }), + }; + const license = { + checkLicenseActive: jest.fn().mockResolvedValue(undefined), + }; + const security = { log: jest.fn().mockResolvedValue(undefined) }; + const runner = new CodeboundService( + prisma as any, + roles as any, + registry, + executor as any, + license as any, + security as any, + ); + const scripts = new SavedScriptsService( + prisma as any, + runner, + license as any, + security as any, + ); + const bridge = new CodeboundRegistry(); + const grants = { + resolve: jest.fn().mockResolvedValue(null), + clientRedirectUris: jest.fn().mockResolvedValue([]), + }; + new CodeboundMcpService( + prisma as any, + grants as any, + runner, + scripts, + bridge, + ).onModuleInit(); + const servers = { + findById: jest.fn().mockResolvedValue(serverA), + getConnectorIds: jest.fn().mockResolvedValue(['conn-a']), + getVisibleContent: jest + .fn() + .mockResolvedValue({ connectors: [], resources: [] }), + isUserInOrganization: jest.fn().mockResolvedValue(true), + getConnectorSummaries: jest + .fn() + .mockResolvedValue([{ id: 'conn-a', name: 'Demo', hasGuide: false }]), + getSharedGuide: jest.fn().mockResolvedValue(undefined), + getServerNamesByOrg: jest.fn().mockResolvedValue([serverA]), + }; + const sessionManager = { get: jest.fn(), touch: jest.fn() }; + const controller = new McpEndpointController( + servers as any, + registry, + executor as any, + roles as any, + { + captureIntentEnabled: async () => false, + isEnabled: async () => false, + } as any, + sessionManager as any, + grants as any, + {} as any, + undefined, + bridge, + ); + let built: McpServer; + (controller as any).serveStateless = jest.fn( + async ( + _req: unknown, + _res: unknown, + _body: unknown, + factory: () => McpServer, + ) => { + built = factory(); + }, + ); + const connect = async (shared = false, identity = user) => { + const req: any = { + user: { ...identity }, + body: { + method: + shared && process.env.MCP_SHARED_ENDPOINT_TOOLS === 'direct' + ? 'tools/call' + : 'initialize', + params: { name: 'codebound_list_tools' }, + }, + headers: { host: 'localhost' }, + }; + if (shared) await controller.handleGlobalPost(req, {} as any); + else await controller.handlePost('s1', req, {} as any, req.body); + const [clientSide, serverSide] = InMemoryTransport.createLinkedPair(); + await built!.connect(serverSide); + const client = new Client({ name: 'codebound-test', version: '1' }); + await client.connect(clientSide); + return { client, server: built! }; + }; + return { + connect, + controller, + executor, + security, + roles, + grants, + sessionManager, + bridge, + }; +} + +async function call( + client: Client, + name: string, + args: Record = {}, +) { + const result: any = await client.callTool({ name, arguments: args }); + return { error: !!result.isError, data: JSON.parse(result.content[0].text) }; +} +const code = `const post = await tools.call('setup_demo_get_post', {}); return { id: post.id, title: input.uppercase ? post.title.toUpperCase() : post.title };`; + +describe('AI-authored Codebound through MCP', () => { + const environment = { + shared: process.env.MCP_SHARED_ENDPOINT_TOOLS, + stateful: process.env.MCP_STATEFUL_SESSIONS, + json: process.env.MCP_STREAMABLE_JSON_RESPONSE, + }; + beforeEach(() => { + process.env.MCP_SHARED_ENDPOINT_TOOLS = 'fixed'; + process.env.MCP_STATEFUL_SESSIONS = 'false'; + process.env.MCP_STREAMABLE_JSON_RESPONSE = 'true'; + }); + afterAll(() => { + for (const [name, value] of [ + ['MCP_SHARED_ENDPOINT_TOOLS', environment.shared], + ['MCP_STATEFUL_SESSIONS', environment.stateful], + ['MCP_STREAMABLE_JSON_RESPONSE', environment.json], + ]) { + if (value === undefined) delete process.env[name!]; + else process.env[name!] = value; + } + }); + + it.each([false, true])( + 'discovers schemas and runs real QuickJS through %s shared endpoint', + async (shared) => { + const { connect, executor } = build(); + const { client, server } = await connect(shared); + try { + expect( + (await client.listTools()).tools.map((tool) => tool.name), + ).toEqual( + expect.arrayContaining(CODEBOUND_TOOLS.map((tool) => tool.name)), + ); + const discovered = await call( + client, + 'codebound_list_tools', + shared ? { serverId: 's1' } : {}, + ); + expect(discovered.data.tools.map((tool: any) => tool.name)).toEqual([ + 'setup_demo_get_post', + ]); + expect(JSON.stringify(discovered)).not.toContain('stored-secret'); + const output = await call(client, 'codebound_run', { + ...(shared ? { serverId: 's1' } : {}), + code, + input: { uppercase: true }, + allowedTools: ['setup_demo_get_post'], + }); + expect(output.error).toBe(false); + expect(output.data.result).toEqual({ id: 1, title: 'DEMO POST' }); + expect(executor.executeTool).toHaveBeenCalledWith( + 'setup_demo_get_post', + {}, + expect.objectContaining({ + organizationId: 'org-a', + connectorIds: ['conn-a'], + authMethod: 'jwt', + }), + ); + } finally { + await client.close(); + await server.close(); + } + }, + ); + + it('saves an AI-written workflow and reuses stored defaults and source by revision', async () => { + const { connect, security } = build(); + const { client, server } = await connect(); + try { + const saved = await call(client, 'codebound_save_script', { + name: 'AI summary', + code, + input: { uppercase: true }, + allowedTools: ['setup_demo_get_post'], + }); + expect(saved.error).toBe(false); + expect((await call(client, 'codebound_list_scripts')).data.items).toEqual( + [expect.objectContaining({ id: 'script-1', revision: 1 })], + ); + const opened = await call(client, 'codebound_get_script', { + scriptId: saved.data.id, + }); + expect(opened.data.code).toBe(code); + const output = await call(client, 'codebound_run_saved', { + scriptId: saved.data.id, + revision: 1, + }); + expect(output.data.result).toEqual({ id: 1, title: 'DEMO POST' }); + expect(security.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'CODEBOUND_RUN', + metadata: expect.objectContaining({ + savedScriptId: 'script-1', + revision: 1, + }), + }), + ); + expect( + ( + await call(client, 'codebound_run_saved', { + scriptId: saved.data.id, + revision: 2, + }) + ).error, + ).toBe(true); + } finally { + await client.close(); + await server.close(); + } + }); + + it('lets viewers run scripts but refuses saving them', async () => { + const { connect } = build('VIEWER'); + const { client, server } = await connect(); + try { + expect( + ( + await call(client, 'codebound_run', { + code: 'return 42;', + allowedTools: [], + }) + ).data.result, + ).toBe(42); + expect( + ( + await call(client, 'codebound_save_script', { + name: 'Denied', + code: 'return 42;', + allowedTools: [], + }) + ).error, + ).toBe(true); + } finally { + await client.close(); + await server.close(); + } + }); + + it.each(['role', 'grant'])( + 'stops nested reads when the %s is revoked after the first read', + async (reason) => { + const { connect, roles, grants, executor } = build(); + const { client, server } = await connect(); + executor.executeTool.mockImplementationOnce(async () => { + if (reason === 'role') roles.getAllowedToolIds.mockResolvedValue([]); + else grants.resolve.mockResolvedValue({ mode: 'none' }); + return { + content: [{ type: 'text', text: '{}' }], + structured: { id: 1, title: 'Demo', body: '' }, + }; + }); + try { + const result = await call(client, 'codebound_run', { + code: 'await tools.call("setup_demo_get_post", {}); return await tools.call("setup_demo_get_post", {});', + allowedTools: ['setup_demo_get_post'], + }); + expect(result.error).toBe(true); + expect(executor.executeTool).toHaveBeenCalledTimes(1); + } finally { + await client.close(); + await server.close(); + } + }, + ); + + it('publishes and dispatches the same tools on the shared direct endpoint', async () => { + process.env.MCP_SHARED_ENDPOINT_TOOLS = 'direct'; + const { controller, connect } = build(); + const res: any = { status: jest.fn().mockReturnThis(), json: jest.fn() }; + await controller.handleGlobalPost( + { + user: { ...user }, + body: { id: 1, method: 'tools/list' }, + headers: {}, + } as any, + res, + ); + expect( + res.json.mock.calls[0][0].result.tools.map((tool: any) => tool.name), + ).toEqual(expect.arrayContaining(CODEBOUND_TOOLS.map((tool) => tool.name))); + const { client, server } = await connect(true); + try { + expect( + ( + await call(client, 'codebound_run', { + serverId: 's1', + code: 'return { ok: true };', + allowedTools: [], + }) + ).data.result, + ).toEqual({ ok: true }); + } finally { + await client.close(); + await server.close(); + } + }); + + it('does not expose script execution on anonymous/static public surfaces', async () => { + const { connect } = build(); + const { client, server } = await connect(false, { + authMethod: 'none', + } as any); + try { + expect( + (await client.listTools()).tools.some((tool) => + tool.name.startsWith('codebound_'), + ), + ).toBe(false); + } finally { + await client.close(); + await server.close(); + } + }); + + it("uses each stateful request's client grant when invoking a reused handler", async () => { + process.env.MCP_STATEFUL_SESSIONS = 'true'; + const { controller, sessionManager, bridge } = build(); + const call = jest.fn().mockResolvedValue({ ok: true }); + bridge.register({ call }); + const reused = bridge.get()!; + sessionManager.get.mockReturnValue({ + serverId: 's1', + organizationId: 'org-a', + principalKey: 'sub:u1', + transport: { + handleRequest: async () => { + await reused.call( + { + userId: 'u1', + organizationId: 'org-a', + email: '', + authMethod: 'jwt', + oauthClientId: 'old-client', + }, + 'codebound_list_tools', + {}, + ); + }, + }, + }); + await controller.handlePost( + 's1', + { + user: { ...user, azp: 'current-client' }, + headers: { 'mcp-session-id': 'session' }, + } as any, + {} as any, + {}, + ); + expect(call).toHaveBeenCalledWith( + expect.objectContaining({ + oauthClientId: 'current-client', + endpointServerId: 's1', + }), + 'codebound_list_tools', + {}, + ); + }); +}); diff --git a/packages/backend/src/mcp-server/codebound-tools.spec.ts b/packages/backend/src/mcp-server/codebound-tools.spec.ts new file mode 100644 index 000000000..ffcc9bc3d --- /dev/null +++ b/packages/backend/src/mcp-server/codebound-tools.spec.ts @@ -0,0 +1,116 @@ +import { Client } from '@modelcontextprotocol/client'; +import { InMemoryTransport, McpServer } from '@modelcontextprotocol/server'; +import { ForbiddenException } from '@nestjs/common'; +import { + CODEBOUND_TOOLS, + CodeboundConnection, + CodeboundRegistry, + codeboundEntries, +} from './codebound-tools'; + +const connection: CodeboundConnection = { + userId: 'u1', + email: 'a@example.com', + organizationId: 'o1', + authMethod: 'jwt', + endpointServerId: 's1', + oauthClientId: 'client-a', +}; + +describe('Codebound MCP tool contracts', () => { + it('publishes real SDK schemas and separates execution from persistence annotations', async () => { + const call = jest.fn().mockResolvedValue({ result: { answer: 42 } }); + const server = new McpServer({ name: 'spec', version: '1' }); + for (const entry of codeboundEntries({ call }, connection)) + entry.register(server); + const [clientSide, serverSide] = InMemoryTransport.createLinkedPair(); + await server.connect(serverSide); + const client = new Client({ name: 'spec', version: '1' }); + await client.connect(clientSide); + try { + const tools = (await client.listTools()).tools; + expect(tools.map((tool) => tool.name)).toEqual( + CODEBOUND_TOOLS.map((tool) => tool.name), + ); + expect( + tools.find((tool) => tool.name === 'codebound_run')?.annotations + ?.readOnlyHint, + ).toBe(true); + expect( + tools.find((tool) => tool.name === 'codebound_save_script')?.annotations + ?.readOnlyHint, + ).toBe(false); + const result: any = await client.callTool({ + name: 'codebound_run', + arguments: { code: 'return 42;', allowedTools: [] }, + }); + expect(JSON.parse(result.content[0].text)).toEqual({ + result: { answer: 42 }, + }); + expect(call).toHaveBeenCalledWith( + connection, + 'codebound_run', + expect.objectContaining({ code: 'return 42;' }), + ); + } finally { + await client.close(); + await server.close(); + } + }); + + it('returns bounded errors without serializing raw host exceptions', async () => { + for (const error of [ + new Error('password=private-secret'), + new ForbiddenException('Tool access denied'), + ]) { + const entry = codeboundEntries( + { + call: async () => { + throw error; + }, + }, + connection, + )[0]; + const result = await entry.call({}); + expect(result.isError).toBe(true); + expect(result.content[0].text).not.toContain('private-secret'); + expect(result.content[0].text).toContain( + error instanceof ForbiddenException + ? 'Tool access denied' + : 'Codebound request failed', + ); + } + const result = await codeboundEntries( + { call: async () => 'x'.repeat(262144) }, + connection, + )[0].call({}); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('256 KiB'); + }); + + it('binds reused session handlers to the current client without leaking concurrent scopes', async () => { + const registry = new CodeboundRegistry(); + const call = jest.fn(async (ctx: CodeboundConnection) => { + await Promise.resolve(); + return ctx.oauthClientId; + }); + registry.register({ call }); + const reused = codeboundEntries(registry.get()!, connection)[0]; + const results = await Promise.all([ + registry.withConnection( + { ...connection, oauthClientId: 'client-b' }, + () => reused.call({}), + ), + registry.withConnection( + { ...connection, oauthClientId: 'client-c' }, + () => reused.call({}), + ), + ]); + expect(results.map((result) => JSON.parse(result.content[0].text))).toEqual( + ['client-b', 'client-c'], + ); + expect(JSON.parse((await reused.call({})).content[0].text)).toBe( + 'client-a', + ); + }); +}); diff --git a/packages/backend/src/mcp-server/codebound-tools.ts b/packages/backend/src/mcp-server/codebound-tools.ts new file mode 100644 index 000000000..7db00f83a --- /dev/null +++ b/packages/backend/src/mcp-server/codebound-tools.ts @@ -0,0 +1,213 @@ +import { HttpException, Injectable } from '@nestjs/common'; +import { McpServer } from '@modelcontextprotocol/server'; +import { z } from 'zod'; +import { AsyncLocalStorage } from 'node:async_hooks'; + +/** Identity supplied only by MCP authentication, never by tool arguments. */ +export interface CodeboundConnection { + userId: string; + email: string; + organizationId: string; + authMethod: string; + apiKeyName?: string; + oauthClientId?: string; + pinnedServerId?: string; + endpointServerId?: string; +} + +const server = { + serverId: z + .string() + .min(1) + .max(100) + .optional() + .describe( + 'MCP server ID. Omit on a per-server endpoint. On shared /mcp, first call codebound_list_tools to find server IDs.', + ), +}; +const input = z.record(z.string(), z.unknown()); +const script = { + code: z + .string() + .min(1) + .max(32768) + .describe( + 'JavaScript function body. Read input, await tools.call(name, args), and return JSON. No imports, fetch, Node APIs or timers. Await connector calls sequentially.', + ), + input: input + .default({}) + .describe('JSON parameters. When saving, these become the defaults.'), + allowedTools: z + .array(z.string().min(1).max(200)) + .max(20) + .describe( + 'Only these connector tool names may be called. Obtain names and schemas with codebound_list_tools. No nested scripts or MCP built-ins.', + ), +}; + +export const CODEBOUND_TOOLS = [ + { + name: 'codebound_list_tools', + readOnly: true, + description: + 'Discover connector tools for writing a Codebound script. On shared /mcp without serverId, lists reachable servers. With a server, returns permitted REST read tools and input/output schemas. A null output schema is unknown; inspect a small sample and validate the response fields your script uses.', + schema: { + ...server, + query: z.string().max(200).optional(), + offset: z.number().int().min(0).default(0), + limit: z.number().int().min(1).max(50).default(20), + }, + }, + { + name: 'codebound_run', + readOnly: true, + description: + 'Execute AI-written JavaScript near the connectors and return only its final JSON result. Uses the current connection permissions and sandbox: 5 seconds, 20 sequential REST reads, 64 MiB guest memory, 32 KiB result. Does not save the script. Use tools.call only for names from codebound_list_tools.', + schema: { ...server, ...script }, + }, + { + name: 'codebound_save_script', + readOnly: false, + description: + 'Save a tested workflow for reuse in Codebound. Editors/admins only. Provide scriptId and its reviewed revision to update; omit both to create. Saves source and default input, without granting tool permissions. Obtain the current source/revision with codebound_get_script before editing.', + schema: { + ...server, + ...script, + name: z.string().trim().min(1).max(120), + description: z.string().max(2000).default(''), + scriptId: z.string().min(1).max(100).optional(), + revision: z.number().int().min(1).optional(), + }, + }, + { + name: 'codebound_list_scripts', + readOnly: true, + description: + 'List reusable scripts for one reachable MCP server, restricted to this connection and permitted read tools. Returns summaries and revisions, not source. Follow nextOffset even if a page is empty.', + schema: { + ...server, + offset: z.number().int().min(0).default(0), + limit: z.number().int().min(1).max(50).default(20), + }, + }, + { + name: 'codebound_get_script', + readOnly: true, + description: + "Read a saved workflow source, default parameters, allowlist, and revision before reusing or editing it. Limited to this connection's server and current permitted tools.", + schema: { ...server, scriptId: z.string().min(1).max(100) }, + }, + { + name: 'codebound_run_saved', + readOnly: true, + description: + 'Run a reviewed saved workflow by ID and revision. Source and tool allowlist come from storage; optional input overrides the stored defaults for this run. Reopen after a revision conflict. Saving a script never grants access to its connector tools.', + schema: { + ...server, + scriptId: z.string().min(1).max(100), + revision: z.number().int().min(1), + input: input.optional(), + }, + }, +] as const; + +export interface CodeboundMcpProvider { + call( + connection: CodeboundConnection, + name: string, + args: Record, + ): Promise; +} + +/** Same startup bridge pattern as SharedSetupRegistry, avoiding a module cycle. */ +@Injectable() +export class CodeboundRegistry { + private provider: CodeboundMcpProvider | null = null; + private readonly current = new AsyncLocalStorage(); + register(provider: CodeboundMcpProvider) { + this.provider = provider; + } + get(): CodeboundMcpProvider | null { + const provider = this.provider; + return provider + ? { + call: (connection, name, args) => + provider.call(this.current.getStore() ?? connection, name, args), + } + : null; + } + withConnection( + connection: CodeboundConnection, + run: () => Promise, + ): Promise { + return this.current.run(connection, run); + } +} + +export const isCodeboundTool = (name: string) => + CODEBOUND_TOOLS.some((tool) => tool.name === name); + +export function codeboundEntries( + provider: CodeboundMcpProvider, + connection: CodeboundConnection, +) { + return CODEBOUND_TOOLS.map((tool) => { + const annotations = { + readOnlyHint: tool.readOnly, + destructiveHint: !tool.readOnly, + idempotentHint: false, + openWorldHint: + tool.name === 'codebound_run' || tool.name === 'codebound_run_saved', + }; + const inputSchema = z.toJSONSchema(z.strictObject(tool.schema), { + target: 'draft-7', + }); + const call = async (args: Record) => { + try { + const value = await provider.call(connection, tool.name, args); + const text = JSON.stringify(value); + if (Buffer.byteLength(text) > 262144) + throw new HttpException( + 'Result exceeds 256 KiB. Narrow the query or lower the page limit.', + 400, + ); + return { content: [{ type: 'text' as const, text }] }; + } catch (error) { + // Only explicit application errors are public. Prisma/transport errors + // may contain credentials; never serialize raw exceptions or arguments. + const message = + error instanceof HttpException + ? error.message.slice(0, 500) + : 'Codebound request failed.'; + return { + isError: true, + content: [ + { type: 'text' as const, text: JSON.stringify({ error: message }) }, + ], + }; + } + }; + return { + name: tool.name, + description: tool.description, + inputSchema, + annotations, + call, + sig: JSON.stringify([ + tool.name, + inputSchema, + connection.endpointServerId, + ]), + register: (mcpServer: McpServer) => + mcpServer.registerTool( + tool.name, + { + description: tool.description, + inputSchema: tool.schema, + annotations, + }, + call, + ), + }; + }); +} diff --git a/packages/backend/src/mcp-server/dynamic-mcp-tools.spec.ts b/packages/backend/src/mcp-server/dynamic-mcp-tools.spec.ts index df3b91280..049740c31 100644 --- a/packages/backend/src/mcp-server/dynamic-mcp-tools.spec.ts +++ b/packages/backend/src/mcp-server/dynamic-mcp-tools.spec.ts @@ -185,6 +185,20 @@ describe('DynamicMcpTools — actionable hint on upstream errors', () => { }); describe('DynamicMcpTools — response cache', () => { + it('bypasses cache reads and writes for an audited script call', async () => { + const { executor, redis, audit, restEngine } = build(makeTool({ ...SELECT_TRANSFORM, cacheTtl: 300 }), { + cached: JSON.stringify({ tenant: 'another caller' }), + }); + const result = await executor.executeTool('list_devices', {}, { + organizationId: 'org-script', connectorIds: ['conn-1'], skipCache: true, + }); + expect(redis.get).not.toHaveBeenCalled(); + expect(redis.set).not.toHaveBeenCalled(); + expect(restEngine.execute).toHaveBeenCalledTimes(1); + expect(audit.logInvocation).toHaveBeenCalledTimes(1); + expect(result.structured).toEqual(MAPPED); + }); + it('caches the raw response, not the rendered text', async () => { const { executor, redis } = build(makeTool({ ...SELECT_TRANSFORM, cacheTtl: 300 })); await executor.executeTool('list_devices', {}); diff --git a/packages/backend/src/mcp-server/dynamic-mcp-tools.ts b/packages/backend/src/mcp-server/dynamic-mcp-tools.ts index a9fc2a0c8..5eb38849f 100644 --- a/packages/backend/src/mcp-server/dynamic-mcp-tools.ts +++ b/packages/backend/src/mcp-server/dynamic-mcp-tools.ts @@ -221,6 +221,8 @@ export class DynamicMcpTools { mcpServerName?: string; connectorIds?: string[]; intent?: string; + /** Script reads must always execute and audit in their own caller scope. */ + skipCache?: boolean; }, ): Promise<{ content: ToolResultContent; @@ -295,7 +297,7 @@ export class DynamicMcpTools { // after up to cacheTtl seconds. const responseMapping = tool.responseMapping as ResponseMapping | undefined; const cacheTtl = responseMapping?.cacheTtl; - if (cacheTtl && cacheTtl > 0) { + if (!context?.skipCache && cacheTtl && cacheTtl > 0) { const cacheKey = this.buildCacheKey(toolName, params); const cached = await this.redisService.get(cacheKey); if (cached) { @@ -502,7 +504,7 @@ export class DynamicMcpTools { void this.kgService.scheduleObservationalIngest(context?.organizationId); // Cache the raw response if cacheTtl is set (shaping happens on read). - if (cacheTtl && cacheTtl > 0) { + if (!context?.skipCache && cacheTtl && cacheTtl > 0) { const cacheKey = this.buildCacheKey(toolName, params); // The audit log above got the bare body; the cache needs the headers // too, or a cached page would come back without its next cursor. diff --git a/packages/backend/src/mcp-server/mcp-endpoint.controller.ts b/packages/backend/src/mcp-server/mcp-endpoint.controller.ts index cbdfff499..85c6c7289 100644 --- a/packages/backend/src/mcp-server/mcp-endpoint.controller.ts +++ b/packages/backend/src/mcp-server/mcp-endpoint.controller.ts @@ -29,6 +29,7 @@ import { McpSessionManager } from '../mcp-servers/mcp-session.manager'; import { processGauges } from '../common/process-vitals'; import { ToolRegistry, RegisteredTool, isListable } from './tool-registry'; import { SharedSetupRegistry } from './shared-setup'; +import { CodeboundConnection, CodeboundRegistry, codeboundEntries, isCodeboundTool } from './codebound-tools'; import { McpConnectionGrantService, ResolvedGrant, @@ -174,6 +175,7 @@ interface ToolSetParams { captureIntent: boolean; kgEnabled: boolean; invocationContext: InvocationContext; + codeboundConnection?: CodeboundConnection; /** Optional text appended as a trailing content block to every successful * tool result (used by the public demo to say where the answer came from). */ resultFooter?: string; @@ -234,8 +236,38 @@ export class McpEndpointController { private readonly grants: McpConnectionGrantService, private readonly kgSkills: KgSkillService, @Optional() private readonly sharedSetup?: SharedSetupRegistry, + @Optional() private readonly codebound?: CodeboundRegistry, ) {} + private codeboundConnection(user: any, endpointServerId?: string, organizationId?: string): CodeboundConnection | undefined { + const orgId = organizationId ?? user?.organizationId; + if (!user?.sub || !orgId) return undefined; + return { + userId: user.sub, email: user.email ?? user.user_data?.email ?? '', organizationId: orgId, + authMethod: user.authMethod ?? 'jwt', apiKeyName: user.apiKeyName, + oauthClientId: oauthClientId(user), pinnedServerId: user.mcpServerId, endpointServerId, + }; + } + + private codeboundFor(user: any) { + const provider = this.codebound?.get(); + const connection = this.codeboundConnection(user); + return provider && connection ? codeboundEntries(provider, connection) : []; + } + + private async serveCodeboundCall(req: Request, res: Response): Promise { + const body = (req as any).body; + if (!body || Array.isArray(body) || body.method !== 'tools/call' || !isCodeboundTool(body.params?.name)) return false; + const entries = this.codeboundFor((req as any).user); + if (!entries.length) return false; + await this.serveStateless(req, res, body, () => { + const server = new McpServer({ name: 'AnythingMCP Codebound', version: APP_VERSION }); + for (const entry of entries) entry.register(server); + return server; + }, 'Codebound shared /mcp'); + return true; + } + // Streamable-HTTP response framing. Default: SSE-framed responses // (`text/event-stream`), the spec-standard envelope that also carries // streaming and server-initiated notifications. Set @@ -284,6 +316,7 @@ export class McpEndpointController { await this.serveSharedToolset(req, res); return; } + if (await this.serveCodeboundCall(req, res)) return; if (this.refuseHiddenToolCall(req, res, visible)) return; if (this.answerToolsList(req, res, visible)) return; await mcpHttpTransport.httpHandlers.handlePost(req, res); @@ -447,6 +480,7 @@ export class McpEndpointController { { instructions: sharedToolsetInstructions(profile) }, ); registerSharedToolset(mcpServer, scopeTools, deps, profile); + for (const entry of this.codeboundFor(user)) entry.register(mcpServer); return mcpServer; }, 'shared /mcp', @@ -681,6 +715,8 @@ export class McpEndpointController { (req as { visibleTools?: RegisteredTool[] }).visibleTools ?? []; const seen = new Set(); const listed: Array> = []; + const codebound = this.codeboundFor((req as any).user); + for (const entry of codebound) seen.add(entry.name); for (const tool of tools) { if (!isListable(tool)) continue; // Two reachable connectors may expose the same name (a grant spanning @@ -705,6 +741,7 @@ export class McpEndpointController { }); } + listed.push(...codebound.map(({ name, description, inputSchema, annotations }) => ({ name, description, inputSchema, annotations }))); const payload = { jsonrpc: '2.0', id: body.id ?? null, @@ -1109,6 +1146,7 @@ export class McpEndpointController { captureIntent, kgEnabled, invocationContext, + codeboundConnection: this.codeboundConnection(user, serverId, mcpServerConfig.organizationId), }; // Register this server's tool surface onto the per-request McpServer. @@ -1307,7 +1345,16 @@ export class McpEndpointController { this.sessionManager.touch(sessionId, Date.now()); try { - await session.transport.handleRequest(req, res, body); + const connection = this.codeboundConnection((req as any).user, serverId, session.organizationId ?? undefined); + // Session handlers are long-lived. Bind Codebound to THIS request's + // OAuth client/key scope rather than the token that opened the session. + if (connection && this.codebound) { + await this.codebound.withConnection(connection, async () => { + await session.transport.handleRequest(req, res, body); + }); + } else { + await session.transport.handleRequest(req, res, body); + } } catch (error: any) { this.logger.error( `Error routing MCP request to session ${sessionId}: ${error.message}`, @@ -1353,7 +1400,7 @@ export class McpEndpointController { this.sessionManager.add({ sessionId: sid, serverId, - organizationId: invocationContext.organizationId ?? null, + organizationId: serverOrganizationId, principalKey, transport, mcpServer, @@ -1451,6 +1498,7 @@ export class McpEndpointController { captureIntent, kgEnabled, invocationContext, + codeboundConnection: this.codeboundConnection(user, serverId, serverOrganizationId), }); const signature = this.aggregateSig(entries); if (signature === session.signature) return; // nothing changed here @@ -1498,8 +1546,10 @@ export class McpEndpointController { invocationContext, resultFooter, } = params; - const entries: ToolEntry[] = []; - const registeredNames = new Set(); + const provider = this.codebound?.get(); + const entries: ToolEntry[] = provider && params.codeboundConnection + ? codeboundEntries(provider, params.codeboundConnection) : []; + const registeredNames = new Set(entries.map((entry) => entry.name)); for (const tool of serverTools) { // A connector that is not set up yet would only produce errors. diff --git a/packages/backend/src/mcp-server/mcp-server.module.ts b/packages/backend/src/mcp-server/mcp-server.module.ts index 8b359a660..c07999e05 100644 --- a/packages/backend/src/mcp-server/mcp-server.module.ts +++ b/packages/backend/src/mcp-server/mcp-server.module.ts @@ -19,6 +19,7 @@ import { GraphqlSchemaService } from '../connectors/engines/graphql-schema.servi import { McpServersModule } from '../mcp-servers/mcp-servers.module'; import { LicenseModule } from '../license/license.module'; import { SharedSetupRegistry } from './shared-setup'; +import { CodeboundRegistry } from './codebound-tools'; const ENGINES = [ RestEngine, @@ -32,7 +33,7 @@ const ENGINES = [ @Module({ imports: [McpServersModule, LicenseModule], controllers: [McpEndpointController, WellKnownOAuthController, RegistryCatchUpController], - providers: [SharedSetupRegistry, McpServerService, ToolRegistry, DynamicMcpTools, McpCombinedAuthGuard, McpPrincipalRateLimitGuard, OAuth2TokenService, LoginTokenService, GraphqlSchemaService, ...ENGINES], - exports: [McpServerService, ToolRegistry, SharedSetupRegistry, OAuth2TokenService], + providers: [SharedSetupRegistry, CodeboundRegistry, McpServerService, ToolRegistry, DynamicMcpTools, McpCombinedAuthGuard, McpPrincipalRateLimitGuard, OAuth2TokenService, LoginTokenService, GraphqlSchemaService, ...ENGINES], + exports: [McpServerService, ToolRegistry, SharedSetupRegistry, CodeboundRegistry, OAuth2TokenService, DynamicMcpTools], }) export class McpServerModule {} diff --git a/packages/backend/src/mcp-server/shared-setup.ts b/packages/backend/src/mcp-server/shared-setup.ts index 10ca960bc..d0d40c6c9 100644 --- a/packages/backend/src/mcp-server/shared-setup.ts +++ b/packages/backend/src/mcp-server/shared-setup.ts @@ -4,8 +4,9 @@ import type { ReturnAssistant } from './shared-toolset'; /** * Connector setup through the shared `/mcp` endpoint. * - * The shared endpoint serves a fixed list of eight tools (it is what the - * Claude directory reviewed). Setting up connectors from a chat is offered + * The shared endpoint's connector interface serves eight fixed helper tools. + * The controller adds Codebound tools separately for identified callers. + * Setting up connectors from a chat is offered * BEHIND those tools, as a virtual "AnythingMCP Setup" connector whose tools * are found with anythingmcp_search_tools and run with run_read_tool / * run_write_tool, exactly like a workspace's own tools. diff --git a/packages/backend/src/mcp-server/shared-toolset.ts b/packages/backend/src/mcp-server/shared-toolset.ts index 530644bfd..5d97f02a8 100644 --- a/packages/backend/src/mcp-server/shared-toolset.ts +++ b/packages/backend/src/mcp-server/shared-toolset.ts @@ -8,15 +8,16 @@ import type { ToolResultContent } from './dynamic-mcp-tools'; import { isMuseHost, knownClientFor } from '../auth/auth-page'; /** - * The fixed tool set of the shared `/mcp` endpoint. + * The fixed connector tool set of the shared `/mcp` endpoint. * * `/mcp` is one URL for every workspace, and a directory listing reviews one * tool list that must be the same for everyone who installs it. So the shared - * endpoint no longer lists a workspace's own tools: it lists these eight, with + * endpoint no longer lists a workspace's own tools: this module lists eight helpers, with * the same names, descriptions, schemas and annotations for every caller, and * the workspace's tools are searched, described and run THROUGH them. Every * lookup and every run stays inside the caller's scope, computed exactly as - * before (connection grant, then MCP role); nothing here widens it. + * before (connection grant, then MCP role); nothing here widens it. The + * controller adds the Codebound tools separately for identified callers. * * `/mcp/` is unaffected and keeps exposing a server's tools * directly, for clients that want the full list up front. diff --git a/packages/frontend/src/app/codebound/page.tsx b/packages/frontend/src/app/codebound/page.tsx new file mode 100644 index 000000000..eba8a5691 --- /dev/null +++ b/packages/frontend/src/app/codebound/page.tsx @@ -0,0 +1,656 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { AppShell } from '@/components/app-shell'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { useAuth } from '@/lib/auth-context'; +import { + codebound, + mcpServers, + type SavedScript, + type SavedScriptSummary, + type ScriptTool, +} from '@/lib/api'; + +const EXAMPLE = `const post = await tools.call("setup_demo_get_post", {}); +if (!post || typeof post.id !== "number" || typeof post.title !== "string") { + throw new Error("Expected a post with a numeric id and a title."); +} +return { id: post.id, title: post.title };`; +const fieldClass = + 'w-full rounded-lg border border-[var(--border)] bg-[var(--surface)] p-3 text-sm'; +const PAGE_SIZE = 20; + +export default function CodeboundPage() { + const { token, user } = useAuth(); + const canWrite = user?.role !== 'VIEWER'; + const [servers, setServers] = useState<{ id: string; name: string }[]>([]); + const [serverId, setServerId] = useState(''); + const [tools, setTools] = useState([]); + const [allowedTools, setAllowedTools] = useState([]); + const [code, setCode] = useState(EXAMPLE); + const [input, setInput] = useState('{}'); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [selected, setSelected] = useState(null); + const [scripts, setScripts] = useState([]); + const [total, setTotal] = useState(0); + const [offset, setOffset] = useState(0); + const [refresh, setRefresh] = useState(0); + const [listLoading, setListLoading] = useState(true); + const [loading, setLoading] = useState(true); + const [loadingTools, setLoadingTools] = useState(false); + const [busy, setBusy] = useState<'run' | 'save' | 'open' | 'delete' | null>( + null, + ); + const [error, setError] = useState(''); + const [notice, setNotice] = useState(''); + const [dirty, setDirty] = useState(false); + const [deleteTarget, setDeleteTarget] = useState( + null, + ); + const [output, setOutput] = useState + > | null>(null); + const selectedId = selected?.id; + + useEffect(() => { + if (!token) return; + let stale = false; + mcpServers + .list(token) + .then((items) => { + if (stale) return; + const active = items.filter((server) => server.isActive); + setServers(active); + setTools([]); + setAllowedTools([]); + setOutput(null); + setLoadingTools(active.length > 0); + setServerId(active[0]?.id ?? ''); + }) + .catch((error: Error) => { + if (!stale) setError(error.message); + }) + .finally(() => { + if (!stale) setLoading(false); + }); + return () => { + stale = true; + }; + }, [token]); + + useEffect(() => { + if (!token) return; + let stale = false; + codebound + .list(token, offset) + .then((page) => { + if (!stale) { + setScripts(page.items); + setTotal(page.total); + if (offset > 0 && offset >= page.total) + setOffset( + Math.max( + 0, + Math.floor((Math.max(page.total, 1) - 1) / PAGE_SIZE) * + PAGE_SIZE, + ), + ); + } + }) + .catch((error: Error) => { + if (!stale) setError(error.message); + }) + .finally(() => { + if (!stale) setListLoading(false); + }); + return () => { + stale = true; + }; + }, [token, offset, refresh]); + + useEffect(() => { + if (!token || !serverId) return; + let stale = false; + codebound + .tools(serverId, token) + .then((items) => { + if (stale) return; + setTools(items); + if (!selectedId) + setAllowedTools( + items.some((tool) => tool.name === 'setup_demo_get_post') + ? ['setup_demo_get_post'] + : [], + ); + }) + .catch((error: Error) => { + if (!stale) { + setTools([]); + setError(error.message); + } + }) + .finally(() => { + if (!stale) setLoadingTools(false); + }); + return () => { + stale = true; + }; + }, [token, serverId, selectedId]); + + useEffect(() => { + if (!dirty) return; + const warn = (event: BeforeUnloadEvent) => { + event.preventDefault(); + }; + window.addEventListener('beforeunload', warn); + return () => window.removeEventListener('beforeunload', warn); + }, [dirty]); + + function parameters(): Record { + const parsed: unknown = JSON.parse(input); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) + throw new Error('Parameters must be a JSON object.'); + return parsed as Record; + } + function reloadList() { + setListLoading(true); + setRefresh((value) => value + 1); + } + function discardChanges() { + return !dirty || window.confirm('Discard unsaved changes?'); + } + function newScript() { + if (!discardChanges()) return; + if (selectedId && serverId) setLoadingTools(true); + setSelected(null); + setName(''); + setDescription(''); + setCode(EXAMPLE); + setInput('{}'); + setOutput(null); + setError(''); + setNotice(''); + setDirty(false); + } + async function openScript(id: string) { + if (!token || !discardChanges()) return; + setBusy('open'); + setError(''); + setNotice(''); + try { + const script = await codebound.get(id, token); + if (script.id !== selectedId || script.mcpServerId !== serverId) + setLoadingTools(true); + setSelected(script); + setName(script.name); + setDescription(script.description); + setServerId(script.mcpServerId); + setCode(script.code); + setInput(JSON.stringify(script.input, null, 2)); + setAllowedTools(script.allowedTools); + setOutput(null); + setDirty(false); + } catch (error) { + setError( + error instanceof Error ? error.message : 'Could not open script.', + ); + } finally { + setBusy(null); + } + } + async function save() { + if (!token) return; + setBusy('save'); + setError(''); + setNotice(''); + try { + const body = { + name: name.trim(), + description, + mcpServerId: serverId, + code, + input: parameters(), + allowedTools, + }; + const saved = selected + ? await codebound.update( + selected.id, + { ...body, revision: selected.revision }, + token, + ) + : await codebound.create(body, token); + if (!selectedId) setLoadingTools(true); + setSelected(saved); + setName(saved.name); + setDirty(false); + setNotice('Script saved.'); + setOffset(0); + reloadList(); + } catch (error) { + setError( + error instanceof Error ? error.message : 'Could not save script.', + ); + } finally { + setBusy(null); + } + } + const runsSavedSource = + selected && + selected.code === code && + selected.mcpServerId === serverId && + JSON.stringify(selected.allowedTools) === JSON.stringify(allowedTools); + async function run() { + if (!token) return; + setBusy('run'); + setError(''); + setOutput(null); + setNotice(''); + try { + const params = parameters(); + setOutput( + selected && runsSavedSource + ? await codebound.runSaved( + selected.id, + params, + selected.revision, + token, + ) + : await codebound.run( + serverId, + { code, input: params, allowedTools }, + token, + ), + ); + } catch (error) { + setError(error instanceof Error ? error.message : 'Script failed.'); + } finally { + setBusy(null); + } + } + async function remove() { + if (!token || !deleteTarget) return; + setBusy('delete'); + setError(''); + try { + await codebound.delete(deleteTarget.id, token); + if (selectedId === deleteTarget.id) { + setSelected(null); + setName(''); + setDescription(''); + setCode(EXAMPLE); + setInput('{}'); + setOutput(null); + setDirty(false); + if (serverId) setLoadingTools(true); + } + setDeleteTarget(null); + setNotice('Script deleted.'); + if (scripts.length === 1 && offset > 0) + setOffset(Math.max(0, offset - PAGE_SIZE)); + reloadList(); + } catch (error) { + setError( + error instanceof Error ? error.message : 'Could not delete script.', + ); + } finally { + setBusy(null); + } + } + const unavailable = allowedTools.filter( + (name) => !tools.some((tool) => tool.name === name), + ); + + return ( + +
+ +
+

Saved scripts ({total})

+
+ + +
+
+ {scripts.length === 0 && ( +

+ {listLoading + ? 'Loading saved scripts…' + : 'No saved scripts yet. Name your script and save it below.'} +

+ )} + {scripts.map((script) => ( +
+
+

{script.name}

+

+ {script.description} +

+
+ + {canWrite && ( + + )} +
+ ))} + {total > PAGE_SIZE && ( +
+ + + {offset + 1}–{Math.min(offset + PAGE_SIZE, total)} of {total} + + +
+ )} +
+ {deleteTarget && ( + +

Delete “{deleteTarget.name}”? This removes the saved script.

+
+ + +
+
+ )} +

+ Call tools with await tools.call(name, args), read + parameters from input, and return a JSON value. + {selected + ? ` Editing “${selected.name}”.` + : ' Start a new script below.'} + {dirty && ' Unsaved changes.'} +

+ {!canWrite && ( +

+ Viewers can open and run scripts. An editor or administrator can + save and delete them. +

+ )} + + +