# Call Workspace369 from your app over HTTPS

> Send JSON-RPC tools/list and tools/call requests to the Workspace369 MCP endpoint from your own server code with a personal access token, and handle every error.

- Product: Workspace369
- Documentation version: 2026.3
- Section: Build with AI
- Last updated: 2026-10-18
- Canonical source: https://docs.workspace369.com/v/2026.3/build-with-ai/call-workspace369-from-your-app-over-https/

---

Your own server code can call the same tools an AI app uses. You send an HTTPS POST to the Workspace369 MCP endpoint with a personal access token, and get one JSON answer back. This is the Model Context Protocol over HTTP, not a REST API: there is one URL, and the tool name goes in the request body.

Use this from a backend, a scheduled job, or a script. Do not call it from browser code.

## Before you begin

1. A workspace admin has turned MCP on. See [Enable MCP and manage connected apps](/v/2026.3/ai-connections/enable-mcp-and-manage-connected-apps/).
2. You have a personal access token with the scopes your calls need. See [Personal access tokens for scripts and agents](/v/2026.3/ai-connections/personal-access-tokens/).
3. The token is in an environment variable on your server, here called `WORKSPACE369_TOKEN`.

## How the endpoint works

- **URL:** `https://mcp.workspace369.com/mcp`. To pin requests to one workspace, use `https://mcp.workspace369.com/w/<workspaceId>/mcp`. A token for a different workspace is refused with a 403.
- **Method:** POST only. Any other method returns 405.
- **Stateless:** every request stands alone. There is no session to open or close, and each answer is a single JSON body.
- **Headers:** `Authorization: Bearer <token>`, `Content-Type: application/json`, and `Accept: application/json, text/event-stream`. The Accept header must list both types.
- **Body:** a JSON-RPC 2.0 message, up to 1 MB.
- **Token:** a personal access token starts with `mcp_pat_`.

## List the tools

`tools/list` returns every tool you can call, with its description and input schema.

```bash
curl -s https://mcp.workspace369.com/mcp \
  -H "Authorization: Bearer $WORKSPACE369_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
```

The answer has the form `{"jsonrpc":"2.0","id":1,"result":{"tools":[ ... ]}}`. Each tool has a `name`, a `description`, and an `inputSchema` that tells you which arguments it accepts. Read the schema before you call a tool. The same list is in the [MCP tools reference](/v/2026.3/ai-connections/mcp-tools-reference/).

## Call a tool

`tools/call` takes the tool `name` and its `arguments`. This call searches clients:

```bash
curl -s https://mcp.workspace369.com/mcp \
  -H "Authorization: Bearer $WORKSPACE369_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{
    "jsonrpc": "2.0",
    "id": 2,
    "method": "tools/call",
    "params": {
      "name": "clients_search",
      "arguments": { "query": "Acme", "limit": 10 }
    }
  }'
```

A successful answer looks like this:

```json
{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "content": [{ "type": "text", "text": "Found 1 client ..." }],
    "structuredContent": { "clients": [ ... ], "nextCursor": "..." }
  }
}
```

Read `result.structuredContent` in code. `result.content` is the same information as text for a person or model to read.

### Node.js

This helper works in Node 18 and later. It retries once after a rate limit and returns the structured result.

```js
const ENDPOINT = "https://mcp.workspace369.com/mcp";

let nextId = 1;

export async function callTool(name, args = {}) {
  for (let attempt = 0; attempt < 2; attempt++) {
    const res = await fetch(ENDPOINT, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.WORKSPACE369_TOKEN}`,
        "Content-Type": "application/json",
        Accept: "application/json, text/event-stream",
      },
      body: JSON.stringify({
        jsonrpc: "2.0",
        id: nextId++,
        method: "tools/call",
        params: { name, arguments: args },
      }),
    });

    if (res.status === 429) {
      const wait = Number(res.headers.get("Retry-After") ?? "5");
      await new Promise((r) => setTimeout(r, wait * 1000));
      continue;
    }
    if (res.status === 401) throw new Error("Token is invalid, revoked, or expired.");
    if (!res.ok) throw new Error(`Workspace369 returned HTTP ${res.status}`);

    const body = await res.json();
    if (body.error) throw new Error(`JSON-RPC error ${body.error.code}: ${body.error.message}`);

    const result = body.result;
    if (result.isError) {
      throw new Error(result.content?.[0]?.text ?? "The tool refused the call.");
    }
    if (result.structuredContent?.held_for_approval) {
      return { held: true, ...result.structuredContent };
    }
    return result.structuredContent;
  }
  throw new Error("Rate limited twice in a row.");
}
```

### Python

```python
import os
import time
import requests

ENDPOINT = "https://mcp.workspace369.com/mcp"
_next_id = 0


def call_tool(name, arguments=None):
    global _next_id
    for _ in range(2):
        _next_id += 1
        res = requests.post(
            ENDPOINT,
            headers={
                "Authorization": f"Bearer {os.environ['WORKSPACE369_TOKEN']}",
                "Content-Type": "application/json",
                "Accept": "application/json, text/event-stream",
            },
            json={
                "jsonrpc": "2.0",
                "id": _next_id,
                "method": "tools/call",
                "params": {"name": name, "arguments": arguments or {}},
            },
            timeout=60,
        )
        if res.status_code == 429:
            time.sleep(int(res.headers.get("Retry-After", "5")))
            continue
        if res.status_code == 401:
            raise RuntimeError("Token is invalid, revoked, or expired.")
        res.raise_for_status()

        body = res.json()
        if "error" in body:
            raise RuntimeError(f"JSON-RPC error: {body['error']}")
        result = body["result"]
        if result.get("isError"):
            raise RuntimeError(result["content"][0]["text"])
        structured = result.get("structuredContent") or {}
        if structured.get("held_for_approval"):
            return {"held": True, **structured}
        return structured
    raise RuntimeError("Rate limited twice in a row.")
```

## Handle errors

There are two layers of errors. HTTP status codes describe the request. The `isError` flag on a result describes the tool call.

| What you see | What it means | What to do |
| --- | --- | --- |
| HTTP 401, `{"error":"unauthorized"}` | The token is missing, invalid, revoked, or expired. | Create a new token. Do not retry. |
| HTTP 403, `{"error":"forbidden_origin"}` | The request came from a browser page on a website that is not allowed. | Call from server code instead. |
| HTTP 403, `{"error":"workspace_mismatch"}` | You used a `/w/<workspaceId>/mcp` URL for a different workspace than the token. | Use the URL of the token's own workspace. |
| HTTP 405 | The request was not a POST. | Use POST. |
| HTTP 429, `{"error":"rate_limited"}` | You went over the rate limit. | Wait for the number of seconds in the `Retry-After` header, then retry. |
| HTTP 503, `{"error":"service_unavailable"}` | MCP is switched off for the whole service. | Retry later. |
| HTTP 400 with a JSON-RPC `error` | The body is not valid JSON. | Fix the request. |
| HTTP 406 with a JSON-RPC `error` | The Accept header does not list both `application/json` and `text/event-stream`. | Send both. |
| HTTP 200, `result.isError` is `true` | The tool refused or failed. The reason is in `result.content[0].text`. | Show or log the reason. Do not retry unchanged. |
| HTTP 200, `held_for_approval` is `true` | The call is waiting for a person to approve it. | See the next section. |

Typical `isError` reasons are "MCP is not enabled for this workspace", "Insufficient scope", a missing permission, a record that was not found, an opted-out client, and an out-of-allowance message when the workspace cannot pay for the call. A refused or failed call is not charged.

### Calls held for approval

Tools that send something to a person are held until someone approves them in the Workspace369 app. A held call is not an error. The result looks like this:

```json
{
  "structuredContent": {
    "held_for_approval": true,
    "approval_id": "...",
    "approval_url": "https://app.workspace369.com/settings/approvals/...",
    "approval_expires_at": "2026-10-18T15:00:00.000Z"
  }
}
```

Nothing has been sent. Do not call the tool again for the same request, because a second call would create a second request. Instead:

1. Show the person the `approval_url`, or tell them to open **Approvals** in Workspace369.
2. Poll `approvals_get` with `{ "approval_id": "..." }`. It is free. Its `status` is `pending`, `executing`, `approved_done`, `declined`, `expired`, `failed`, or `interrupted`, and `outcome` holds the result once the call has run.
3. If the status is `interrupted`, do not repeat the call. It may already have gone out. Ask the person to check the app.

A request expires after 1 hour. The full list of tools that are held is in [How MCP access is controlled and billed](/v/2026.3/ai-connections/mcp-permissions-tokens-and-audit/#sends-and-approval).

## Rate limits

One connection may make 120 calls per minute. The whole workspace may make 600 calls per minute. A call over the limit gets a 429 with `Retry-After` and is not counted against the next minute. If you need many records, ask for larger pages with the tool's `limit` and `cursor` arguments instead of many small calls.

Write tools accept an optional `idempotency_key`. Send the same key again to avoid doing the same thing twice after a network error.

## Check your result

1. Run the `tools/list` request. You should see a list of tools.
2. Run a read-only `tools/call`, such as `clients_search`. You should see `structuredContent`.
3. Revoke the token and run the request again. You should get a 401.
4. Open **AI Connections (MCP)** in Settings and find your calls in the **Activity Log**.
