# MCP server

The workspace MCP server: transport, OAuth discovery and authorization, the access model, and every tool with its arguments and required role.

Source: https://docs.omazy.ai/reference/platform/mcp-server/

The workspace MCP server lets an MCP client administer one business. It speaks
Streamable HTTP with JSON-RPC 2.0 request bodies.

```
POST https://mw.omazy.ai/api/v1/mcp
```

`POST` is the only method. `GET` returns 405: there is no server-initiated
stream.

For connecting a client, see
[Connect an AI assistant](/connect/mcp/). This page is the specification.

## Server identity

| Field | Value |
|---|---|
| `name` | `omazy-admin` |
| `title` | Omazy Workspace |
| `capabilities` | `tools` |

`description`, `websiteUrl` and `icons` are also returned, but only when the
negotiated protocol revision defines them. Older revisions get the three fields
above and nothing else, because sending fields a revision does not define is how
you break strict clients.

## Protocol negotiation

Send your revision in `initialize`. The server answers with the revision it will
actually use, which may be older than the one you asked for. Use the value in
the response, not the one you sent.

## Authorization

**Every method requires a token, including `initialize` and `ping`.** There is
no unauthenticated handshake.

This is deliberate and worth understanding if you are building a client.
Answering `200` to an unauthenticated `initialize` tells the client that no
authorization is needed here, so after it completes OAuth it has no auth
requirement recorded and never attaches the token it just obtained. The
connection then fails in a way that looks like the server rejected a valid key.

An unauthenticated call gets `401` with a challenge:

```http
WWW-Authenticate: Bearer realm="OAuth",
  resource_metadata="https://mw.omazy.ai/.well-known/oauth-protected-resource",
  error="invalid_token", error_description="Missing or invalid access token"
```

That header is the entry point. A client with no prior knowledge of this server
can start from the `401` alone.

### Discovery

Both documents sit at the origin root, per RFC 9728 and RFC 8414.

| Document | Path |
|---|---|
| Protected resource | `/.well-known/oauth-protected-resource` |
| Authorization server | `/.well-known/oauth-authorization-server` |

The protected-resource document names the resource, the authorization server,
and the scopes:

```json
{
  "resource": "https://mw.omazy.ai/api/v1/mcp",
  "resource_name": "Omazy Workspace",
  "authorization_servers": ["https://mw.omazy.ai"],
  "bearer_methods_supported": ["header"],
  "scopes_supported": ["mcp:read", "mcp:write"]
}
```

### OAuth endpoints

| Purpose | Endpoint |
|---|---|
| Dynamic registration (RFC 7591) | `/api/v1/oauth/register` |
| Authorize | `/api/v1/oauth/authorize` |
| Token | `/api/v1/oauth/token` |
| Revoke (RFC 7009) | `/api/v1/oauth/revoke` |

| Property | Value |
|---|---|
| Grant types | `authorization_code`, `refresh_token` |
| Response types | `code` |
| PKCE | `S256`, required |
| Client authentication | `none` (public clients) |
| Scopes | `mcp:read`, `mcp:write` |

Registration is open, and the `redirect_uri` allowlist is frozen at
registration and matched exactly. Hosted `https://` callbacks are accepted, as
are loopback `http://` on `127.0.0.1`, `::1` and `localhost`, and custom
schemes. Public plain-http and URIs carrying a fragment are rejected.

Authorization codes are single-use and expire in 60 seconds. Refresh tokens
rotate on use, and replaying a spent one revokes the entire token family
including its access key, on the assumption that a replay means a copy leaked.

### Access tokens

An issued access token is an ordinary workspace API key. The OAuth flow is a
minting front-end, not a second identity system, so the auth gate, the role
model and the request log are the same whether a key arrived through OAuth or
was minted by hand.

Keys last 7 days by default, 90 at the most, and are stored hashed. Refresh
before expiry or reconnect.

## The access model

Three things bound what a call can do. All three apply.

**Workspace scope.** A key is bound to one workspace, decided at approval time
from the approver's real membership. A client cannot widen it by asking, and a
tool that needs a workspace refuses an account-scoped key outright.

**Role.** Tools declare a minimum role, and the key carries the approver's
actual role. `RoleAccount` tools are reachable by any authenticated key,
`RoleMember` needs member or above, `RoleManager` needs manager or above.

**Scope allowlist.** A key may carry an explicit list of tool names. When
present, anything not on it is refused regardless of role. This is what
implements read-only, and it is the reason read-only is not expressed by role
alone: `workspace.create` is an account-scoped tool, so a member-role key could
otherwise still reach it.

A read-only connection is issued at member role carrying this allowlist:

```
workspace.list   workspace.get   app.list
knowledge.list   knowledge.search   account.readiness
agent.get   agent.brief   agent.brief_item_versions
widget.list   widget.config_get
```

Anything absent is unavailable, so an error in that list withdraws access rather
than granting it.

## Tools

26 tools. `app` is optional everywhere it appears and defaults to the
workspace's default app.

### Business and apps

| Tool | Role | Required | Does |
|---|---|---|---|
| `workspace.list` | account | | Businesses the account belongs to. |
| `workspace.get` | member | | The business this key is scoped to. |
| `workspace.create` | account | `business_name` | New business, default app, draft agent. |
| `app.list` | member | | Apps in the business. |
| `account.readiness` | member | | Onboarding readiness score. |

### Agent

| Tool | Role | Required | Does |
|---|---|---|---|
| `agent.get` | member | | Full agent config: identity, live prompt, model config, capabilities, suggestion pills, and which layer owns the prompt. |
| `agent.update` | manager | | Agent metadata. Refuses `system_prompt` on a Brief-managed agent. |
| `agent.suggestions_set` | manager | `rules` | Replace the answer suggestion pills. Live immediately. |

### Agent Brief

The Brief is the versioned source the system prompt is composed from. Edits are
staged; nothing reaches the live agent until you publish.

| Tool | Role | Required | Does |
|---|---|---|---|
| `agent.brief` | member | | Items, composed text, published text, and `dirty`. |
| `agent.brief_item_set` | manager | | Create a block, or edit one by `item_id`. |
| `agent.brief_item_enable` | manager | `item_id`, `enabled` | Include or exclude a block. |
| `agent.brief_item_versions` | member | `item_id` | Prior versions of a block, newest first. |
| `agent.brief_item_restore` | manager | `item_id`, `version` | Roll a block back. |
| `agent.brief_item_delete` | manager | `item_id` | Delete a block and its history. |
| `agent.brief_publish` | manager | | Compose the enabled blocks into the live prompt. |

### Knowledge, help centre, catalog

| Tool | Role | Required | Does |
|---|---|---|---|
| `knowledge.list` | member | | Documents on the agent. |
| `knowledge.search` | member | `query` | Retrieval over the knowledge base. |
| `knowledge.add` | manager | `kind`, `title` | Add a document. Indexed on publish. |
| `helpcenter.add_article` | manager | `title`, `body` | Add a help article. |
| `catalog.add_product` | manager | `name` | Add a product. |

### Widget

| Tool | Role | Required | Does |
|---|---|---|---|
| `widget.list` | member | | Deployments, with ids for the config tools. |
| `widget.config_get` | member | `widget_id` | The editable config document. |
| `widget.config_set` | manager | `widget_id`, `config` | Replace the config. Draft unless `publish` is true. |
| `widget.create` | manager | | Create and publish a widget, returning the install snippet. |

`widget.config_set` replaces the whole document. Read the current one, apply
your edits, send it all back.

### Onboarding

| Tool | Role | Required | Does |
|---|---|---|---|
| `intake.propose` | manager | `website_url` | Read a website and propose brand, persona and FAQs. |
| `onboard_business` | manager | `website_url` | Run the onboarding pass end to end. |

## The prompt is generated, not stored

`agents.system_prompt` is a derived column on any agent that has Brief items.
Publishing composes the enabled blocks and overwrites it wholesale.

A direct write to the prompt therefore survives only until the next publish. The
change looks applied, the agent answers differently for a while, and then it
reverts with no record of what it replaced.

`agent.get` reports which layer owns the prompt:

```json
{
  "prompt": {
    "characters": 6037,
    "source": "brief",
    "brief_items": 11,
    "warning": "system_prompt is composed from the Brief and will be overwritten on the next publish..."
  }
}
```

`source` is `brief` or `direct`. When it is `brief`, `agent.update` refuses a
`system_prompt` write and names the tools to use instead. It also refuses when
it cannot determine the answer, because an irreversible overwrite is not a safe
default for an unknown. Pass `force: true` to override, accepting that the next
publish undoes it and the Brief is not updated to match.

The supported path is `agent.brief_item_set`, then `agent.brief_publish`.

## Failures

Protocol failures come back as JSON-RPC errors: `-32700` parse error, `-32601`
unknown method, `-32602` unknown tool, `-32001` unauthorized.

Tool failures do not. They return a normal result carrying `isError`, which is
what lets a model read the message and correct itself:

```json
{
  "content": [{ "type": "text", "text": "this agent's system_prompt is composed from 11 Brief items..." }],
  "isError": true
}
```

The message is prose, meant to be read. A stable failure code is recorded
against every call in the activity log, but it is **not** returned in the
response, so there is currently nothing machine-readable to branch on. Treat
`isError` as the signal and the text as the explanation.

## Observability

Every call is recorded with its tool, outcome, failure code and duration.
Arguments are never stored.

Owners and managers can read this in the console under **Settings → Connected
AI**, alongside each connection's last-used time and call count. A connection
showing zero calls has never reached the server, which distinguishes a client
that failed to connect from one that connected and did nothing.
