Skip to content

MCP server for AI assistants

The EthicsPortal MCP server lets an AI assistant work with EthicsPortal for a person who approves it. It offers the operations of the API as tools of the Model Context Protocol (MCP). The assistant acts as that person, with their current role, only in the organisations they choose. Report content is never available.

The MCP server is available to every organisation that has API access. It opened on 11 November 2026.

  • EthicsPortal has tested it with Claude Code. Other assistants that support remote MCP servers with OAuth are expected to work. Write to support@ethicsportal.eu if one does not.
  • If API access is suspended for an organisation, the person cannot choose it on the consent screen, and its tools answer not_found.
  • If EthicsPortal switches assistant connections off, the MCP endpoint and every OAuth address on this page answer 404.

The REST API and webhooks do not depend on the MCP server. Choose an API token for scripts and server integrations.

A person adds the server to their assistant once. They need no EthicsPortal account first: a person with no account signs up while they connect.

Assistant How to add the server
Claude Code Run claude mcp add --transport http ethicsportal https://secure.ethicsportal.eu/mcp. Then open /mcp, choose ethicsportal and Authenticate
Cursor Add the server to ~/.cursor/mcp.json, as shown below. Sign in when Cursor asks
Claude Settings → Connectors → Add custom connector. Enter the address https://secure.ethicsportal.eu/mcp
Any other assistant Add https://secure.ethicsportal.eu/mcp as a remote MCP server or custom connector in the assistant’s settings
{ "mcpServers": { "ethicsportal": { "url": "https://secure.ethicsportal.eu/mcp" } } }

The assistant then opens a browser window. The person signs in or signs up there, reads the consent screen and presses Connect.

An assistant cannot use a server that nobody added. A coding assistant can add the server itself, because it can run the command or change its configuration. A chat assistant cannot: the person adds the connector in its settings. The setup skill gives an assistant these steps, and the steps that follow, in a form it can read.

Property Value
Endpoint https://secure.ethicsportal.eu/mcp
Transport Streamable HTTP, POST only
Request One JSON-RPC message per request. A batch is refused
Response One JSON document, or no body. The server opens no stream
Session None. The server issues no Mcp-Session-Id
Authorization Authorization: Bearer with an OAuth access token issued for this endpoint
Features Tools only. No prompts and no resources

Send Content-Type: application/json and Accept: application/json, text/event-stream with every request.

Two lifecycles work:

  • Revision 2026-07-28, without a handshake. Each request carries its protocol revision, and server/discover describes the server.
  • Revisions 2025-11-25 and 2025-06-18, with the initialize handshake. Send the MCP-Protocol-Version header on the requests that follow. A client that asks for 2026-07-28 in initialize is offered 2025-11-25.

The server returns short instructions for the assistant in both lifecycles. They tell it to start with get_identity.

Terminal window
curl --fail-with-body https://secure.ethicsportal.eu/mcp \
-H "Authorization: Bearer $ETHICSPORTAL_MCP_TOKEN" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: tools/call' \
-H 'Mcp-Name: get_identity' \
--data '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_identity","arguments":{},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'

Most clients do not build these requests themselves. Add the endpoint address as a remote MCP server in the assistant. The assistant must support OAuth 2.1 with PKCE and Dynamic Client Registration.

A connection is made with OAuth 2.1. The client is a public client: it has no secret. The steps are discovery, registration, the authorization request, and the token exchange.

A request without a usable token answers 401. The WWW-Authenticate header names the protected resource metadata (RFC 9728):

WWW-Authenticate: Bearer scope="reports:read channel:read channel:write webhooks:read webhooks:write audit_log:read members:read members:write documents:read billing:read billing:write organizations:write organizations:create", resource_metadata="https://secure.ethicsportal.eu/.well-known/oauth-protected-resource/mcp"
Terminal window
curl --fail-with-body https://secure.ethicsportal.eu/.well-known/oauth-protected-resource/mcp
{
"resource": "https://secure.ethicsportal.eu/mcp",
"authorization_servers": ["https://secure.ethicsportal.eu"],
"scopes_supported": [
"reports:read",
"channel:read",
"channel:write",
"webhooks:read",
"webhooks:write",
"audit_log:read",
"members:read",
"members:write",
"documents:read",
"billing:read",
"billing:write",
"organizations:write",
"organizations:create"
],
"bearer_methods_supported": ["header"]
}

scopes_supported lists every scope a connection can hold, here and in the authorization server metadata. The same document is also at /.well-known/oauth-protected-resource. The authorization server metadata (RFC 8414) is at the issuer:

Terminal window
curl --fail-with-body https://secure.ethicsportal.eu/.well-known/oauth-authorization-server

The response includes these fields:

{
"issuer": "https://secure.ethicsportal.eu",
"authorization_endpoint": "https://secure.ethicsportal.eu/oauth/authorize",
"token_endpoint": "https://secure.ethicsportal.eu/oauth/token",
"revocation_endpoint": "https://secure.ethicsportal.eu/oauth/revoke",
"registration_endpoint": "https://secure.ethicsportal.eu/oauth/register",
"scopes_supported": [
"reports:read",
"channel:read",
"channel:write",
"webhooks:read",
"webhooks:write",
"audit_log:read",
"members:read",
"members:write",
"documents:read",
"billing:read",
"billing:write",
"organizations:write",
"organizations:create"
],
"response_types_supported": ["code"],
"response_modes_supported": ["query"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"token_endpoint_auth_methods_supported": ["none"],
"code_challenge_methods_supported": ["S256"],
"authorization_response_iss_parameter_supported": true,
"resource_indicators_supported": true
}

There is no token introspection endpoint.

Clients register themselves with Dynamic Client Registration (RFC 7591). Client ID Metadata Documents are not supported yet.

Terminal window
curl --fail-with-body https://secure.ethicsportal.eu/oauth/register \
-H 'Content-Type: application/json' \
--data '{"client_name":"Example assistant","redirect_uris":["https://assistant.example/callback"],"token_endpoint_auth_method":"none","grant_types":["authorization_code","refresh_token"],"response_types":["code"]}'

A 201 response contains the client_id. There is no client secret.

{
"client_id": "CLIENT_ID",
"client_id_issued_at": 1794268800,
"client_name": "Example assistant",
"redirect_uris": ["https://assistant.example/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
}
Field Accepted
redirect_uris Required. One to ten addresses, 4,000 characters in total
client_name Optional. Up to 100 characters, without control or invisible characters. Without a name, the host of the first redirect address is used
token_endpoint_auth_method Omitted, or none
grant_types Omitted, or only authorization_code and refresh_token
response_types Omitted, or only code

Every other field is ignored and is not stored.

A redirect address must use https, or http on a loopback host: localhost, 127.0.0.1 or ::1. It must not contain user information or a fragment, and it must not point to an EthicsPortal host. Custom URL schemes are refused. The only exception is the exact address cursor://anysphere.cursor-mcp/oauth/callback; no other address with that scheme is accepted. A refused address answers 400 with invalid_redirect_uri. Any other refused field answers 400 with invalid_client_metadata.

A registration that has no connection is deleted when it is 30 days old. If a client ID is no longer recognised, register again.

Send the person’s browser to the authorization endpoint:

https://secure.ethicsportal.eu/oauth/authorize
?response_type=code
&client_id=CLIENT_ID
&redirect_uri=https%3A%2F%2Fassistant.example%2Fcallback
&code_challenge=CODE_CHALLENGE
&code_challenge_method=S256
&resource=https%3A%2F%2Fsecure.ethicsportal.eu%2Fmcp
&scope=reports%3Aread%20channel%3Aread
&state=STATE
Parameter Rule
response_type code only
redirect_uri A registered address. For a loopback address, the port can differ from the registered one
code_challenge, code_challenge_method PKCE is required, with S256 only. plain is refused
resource Required. Exactly https://secure.ethicsportal.eu/mcp
scope Optional. Without it, the request asks for every scope a connection can hold. See Scopes
response_mode Omitted, or query

A request that breaks one of these rules is refused on an error page in EthicsPortal. The browser is not sent back to the client, so the client receives no error redirect.

  1. The person signs in to EthicsPortal if they are not signed in, and completes a fresh-authentication challenge. A person with no account creates one at this step: they confirm their email address, accept the account terms and enter their name. Then they return to the same request.
  2. The consent screen shows the name of the app and the host of its redirect address. It states that the app chose both and that EthicsPortal has not verified them.
  3. It lists the permissions the app asks for, in plain language, and the limits of a connection.
  4. It lists the organisations the person can connect. These are the organisations where the person is an active member and API access is on. The person ticks at least one.
  5. Connect sends the browser back to the redirect address with code, state and iss. Cancel sends it back with error=access_denied.

Consent is asked every time. An earlier connection never approves a new request by itself.

After each approval, the person receives one email. It names the app and its address, the time, the organisations, the permissions and the end date, and it links to Connected apps, where the person can end the connection.

A person who has no organisation in EthicsPortal can connect an assistant, and the assistant creates their first organisation.

  1. The authorization request must include organizations:create. A request that follows the metadata includes it.
  2. The consent screen shows no list of organisations. It says that the app can create one, and that the person accepts its Data Processing Agreement afterwards.
  3. The connection reaches no organisation at first. get_identity returns an empty organizations list.
  4. create_organization creates the organisation with the person as its owner, and adds it to the connection.
  5. The owner accepts the organisation’s Data Processing Agreement in a browser, at https://secure.ethicsportal.eu/organizations/{organization_id}. Until then, the organisation’s tools answer agreement_required. The assistant cannot accept it.
  6. After that, the assistant can configure the whistleblowing channel, invite members and create a checkout link, with the scopes the person approved.

The tool results guide the assistant through steps 3 to 5. The empty get_identity result, the create_organization result and every agreement_required answer carry a next_step text: what to do next, and the address the person must open. The text is in the result’s content and in structuredContent.next_step. The REST API does not return this field.

The authorization code is valid for one minute and for one use.

Terminal window
curl --fail-with-body https://secure.ethicsportal.eu/oauth/token \
--data-urlencode 'grant_type=authorization_code' \
--data-urlencode "code=$CODE" \
--data-urlencode "client_id=$CLIENT_ID" \
--data-urlencode 'redirect_uri=https://assistant.example/callback' \
--data-urlencode "code_verifier=$CODE_VERIFIER" \
--data-urlencode 'resource=https://secure.ethicsportal.eu/mcp'
{
"access_token": "ACCESS_TOKEN",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "REFRESH_TOKEN",
"scope": "reports:read channel:read",
"created_at": 1794268800
}

The access token works at the MCP endpoint only. The REST API refuses it with 401. A resource other than the MCP endpoint answers invalid_target.

Terminal window
curl --fail-with-body https://secure.ethicsportal.eu/oauth/token \
--data-urlencode 'grant_type=refresh_token' \
--data-urlencode "refresh_token=$REFRESH_TOKEN" \
--data-urlencode "client_id=$CLIENT_ID"

Each refresh returns a new access token and a new refresh token. The earlier access token and refresh token stop working at once. Store the new pair before you use it.

  • A refresh cannot change the scopes. If you send scope, it must name exactly the scopes of the connection, or the answer is invalid_scope.
  • A refresh token works once. There is no period in which the earlier one is still accepted.
  • Other grant types answer unsupported_grant_type.

To end a connection from the client, revoke its current access token or refresh token (RFC 7009):

Terminal window
curl --fail-with-body https://secure.ethicsportal.eu/oauth/revoke \
--data-urlencode "token=$REFRESH_TOKEN" \
--data-urlencode "client_id=$CLIENT_ID"

A client can revoke only its own tokens. Another client’s token answers 403.

Item Lifetime
Authorization code One minute, one use
Access token One hour
Refresh token One use. Each refresh returns a new one
Connection 90 days from the approval. A refresh does not extend it

A client that runs in a web page on another origin can connect. The MCP endpoint, both metadata documents and the registration, token and revocation endpoints answer with Access-Control-Allow-Origin: * and answer an OPTIONS preflight with 204. They never use cookies, and they send no Access-Control-Allow-Credentials header. The MCP endpoint and the protected resource metadata expose the WWW-Authenticate and Retry-After headers to the page.

The consent screen has no such headers. Open it as a page in the person’s browser.

When a connection ends, its access tokens answer 401 and its refresh tokens answer invalid_grant. The person must approve a new authorization request.

  • 90 days have passed since the approval.
  • The person disconnects the app under Connected apps.
  • An administrator removes the connection from the last organisation it reaches.
  • The person’s membership is deactivated in the last organisation it reaches.
  • The client revokes its current access token or refresh token.
  • A refresh token is used a second time. This includes a client that repeats a refresh request after a lost response.
  • An authorization code is exchanged a second time with its PKCE verifier.
  • The person approves a new request from the same app. The new connection replaces the earlier one, and the 90 days start again.

A connection stops working, without ending, while the person’s account cannot authenticate or while assistant connections are switched off.

A connection acts as the person who approved it. Every tool call checks that person’s current role and case permissions again, as described in Case permissions. A connection never does more than the person can do in the web application.

  • It reaches only the organisations the person ticked. Any other organisation answers not_found, also when the person is a member of it.
  • An organisation that the person joins later is not added. The one exception is an organisation created with the create_organization tool of that connection.
  • When the person’s membership in an organisation is deactivated, that organisation is removed from the connection.
  • The person’s account terms and the organisation’s agreement must be accepted, as for the API. Otherwise the tool answers agreement_required.

Tool descriptions come from the API reference. Where a description says “personal token”, it applies to a connection in the same way.

A connection uses the scopes of the API. The scope table describes each one. A connection can hold these:

reports:read, channel:read, channel:write, webhooks:read, webhooks:write, audit_log:read, members:read, members:write, documents:read, billing:read, billing:write, organizations:write, organizations:create

  • invitations:respond cannot be granted. Accepting an invitation joins an organisation that the person did not choose on the consent screen. A request that asks for it is refused.
  • get_identity and submit_feedback need no scope.
  • members:write lets the app send invitations with any role, in the organisations where the person is an administrator. A person who is invited as an administrator can read reports in the web application after they accept. The consent screen warns the person when an app asks for this scope, and each invitation is recorded in the audit trail as made through an AI assistant.
  • The metadata advertises every scope, so a client that follows it asks for all of them in the first authorization request. Most assistants make the person sign in again for each scope added later. A client can ask for fewer scopes. The consent screen always shows every scope the client asks for.

A connection that asked for fewer scopes can ask for more later. When a tool needs a scope the connection does not have, nothing runs. The whole HTTP response is 403:

WWW-Authenticate: Bearer error="insufficient_scope", error_description="This tool needs the webhooks:write scope.", scope="reports:read channel:read webhooks:write", resource_metadata="https://secure.ethicsportal.eu/.well-known/oauth-protected-resource/mcp"
  1. Read scope. It names the scopes the connection already has and the missing one.
  2. Send a new authorization request with exactly that scope.
  3. The person approves on the consent screen. The organisations of the earlier connection are ticked already.
  4. Exchange the new code, then call the tool again with the new access token.

The new connection replaces the earlier one. The earlier tokens stop working, the 90 days start again, and the person receives a new email.

The server offers one tool for each API operation, with the exceptions listed below. Tool definitions are generated from the OpenAPI schema, so a tool has the same fields, rules and errors as its operation. Follow the link of a tool to its page in the API reference.

  • The tool name is the operationId of the operation in snake case: listReports becomes list_reports.
  • The tool title and description are the summary and description of the operation.
  • Tools that read are marked read-only. Tools that send PATCH or DELETE are marked destructive and idempotent.
  • tools/list returns the tools in the order of the OpenAPI schema.
ToolAPI operationScope
get_identityDiscover organisationsNone
get_channelRead whistleblowing channel metadatachannel:read
list_reportsList report metadatareports:read
get_reportRead report metadatareports:read
create_organizationCreate an organisationorganizations:create
get_channel_configurationRead channel configurationchannel:write
update_channel_configurationConfigure a whistleblowing channelchannel:write
list_webhooksList webhookswebhooks:read
create_webhookCreate a webhookwebhooks:write
get_webhookRead a webhookwebhooks:read
update_webhookUpdate a webhookwebhooks:write
delete_webhookDelete a webhookwebhooks:write
rotate_webhook_secretRotate a webhook's signing secretwebhooks:write
test_webhookSend a test eventwebhooks:write
list_webhook_deliveriesList webhook deliverieswebhooks:read
retry_webhook_deliveryRetry a failed deliverywebhooks:write
list_audit_trailList the audit trailaudit_log:read
list_report_audit_trailList a report's audit trailaudit_log:read
list_membersList membersmembers:read
list_invitationsList pending invitationsmembers:read
create_invitationInvite a membermembers:write
revoke_invitationRevoke an invitationmembers:write
resend_invitationResend an invitationmembers:write
update_organizationRename the organisationorganizations:write
list_documentsList documentsdocuments:read
get_billing_statusRead billing statusbilling:read
create_checkout_linkCreate a subscription checkout linkbilling:write
create_billing_portal_linkCreate a billing portal linkbilling:write
submit_feedbackSend product feedbackNone
  • Path parameters, such as organization_id, are record IDs. Take them from get_identity and from earlier results. A value that is not a record ID is refused before anything runs.
  • Query parameters, such as limit, cursor and status, are arguments with the same names.
  • The request body keeps its shape. create_webhook takes a webhook object, as the API does.
  • idempotency_key is an argument of every tool whose operation accepts the Idempotency-Key header.
  • Arguments that the tool does not declare are refused.

An Idempotency-Key header that the client sends to the MCP endpoint does not reach the operation: use the argument. Conditional requests with If-None-Match are not available through tools.

The params of a tools/call request:

{
"name": "list_reports",
"arguments": { "organization_id": "gHiJkl", "status": "received", "limit": 50 }
}

A successful call returns the JSON response of the API in structuredContent. The same JSON is in content as text. An operation that returns no content answers with the text Done.

When the API refuses the call, the result has isError: true, and structuredContent holds the error body of the API. The HTTP status of the MCP response is still 200, so read error.code:

{
"content": [
{
"type": "text",
"text": "{\"error\":{\"code\":\"validation_failed\",\"fields\":[\"url\"],\"feedback_url\":\"https://secure.ethicsportal.eu/me/feedback.json\"}}"
}
],
"structuredContent": {
"error": {
"code": "validation_failed",
"fields": ["url"],
"feedback_url": "https://secure.ethicsportal.eu/me/feedback.json"
}
},
"isError": true
}

feedback_url is the address of the product feedback operation. Through MCP, use the submit_feedback tool for the same purpose.

Through a connection, get_identity returns type connection, the name of the app as name, and last_four null. Its expires_at is the end of the connection, and organizations lists only the organisations the connection reaches.

  • Pagination. A list returns next_cursor. Call the tool again with cursor set to that value until it is null. Pagination.
  • Safe retries. Send idempotency_key with every tool that accepts it, and send the same key again when you retry. The rules are those of the API: Safe retries. Keys belong to the connection. A new approval creates a new connection, so earlier keys do not carry over.
  • Rate limits of the endpoint. Each connection can send 120 MCP requests per minute. Requests without a usable token are limited to 120 per minute per source address. Above the limit the HTTP response is 429 with Retry-After: 60.
  • Rate limits of the API. Tool calls also count toward the API limits: 60 per minute per connection and 300 per minute per organisation. Above these, the tool result is an error with the code rate_limited.
API operationWhy it has no tool
List my invitations It needs invitations:respond, which no connection can hold.
Accept an invitation It needs invitations:respond, which no connection can hold.
Decline an invitation It needs invitations:respond, which no connection can hold.
Download the compliance report It returns a file, and a file does not fit a tool result. list_documents returns its download link.
Download the compliance certificate It returns a file, and a file does not fit a tool result. list_documents returns its download link.
Download the whistleblowing policy It returns a file, and a file does not fit a tool result. list_documents returns its download link.
Download the privacy notice It returns a file, and a file does not fit a tool result. list_documents returns its download link.
Download the poster It returns a file, and a file does not fit a tool result. list_documents returns its download link.
Download the case handler manual It returns a file, and a file does not fit a tool result. list_documents returns its download link.

list_documents returns the address of each document. An access token of a connection cannot download it, because the API refuses that token. A signed-in person can open the address in a browser, and a script can download it with an API token.

Billing tools return a link to a Stripe-hosted page for a person to open. They never take a payment.

The MCP server has the same data boundary as the API. Report content, reporter identity, messages, notes and attachments are never available. No tool reads report content, and no tool submits or changes a report.

A tool returns the response of its API operation, so every field that the API excludes is also absent here. Excluded fields are omitted entirely. They are never returned as REDACTED, and there is no expansion option.

No tool returns report content. One permission can give another person access to reports in the web application: see members:write under Scopes.

Operational metadata can still identify a case inside your organisation. A tool result leaves EthicsPortal and goes to the assistant that the person connected. Administrators can see and remove these connections under Connected apps.

  • The person sees their connections in account settings under Connected apps. Each entry shows the app, its address, the organisations, the permissions, the dates and the last activity. Disconnect ends the connection in every organisation.
  • An administrator sees every connection that reaches the organisation under Organisation → Integrations → Connected apps, with the member who connected it. Remove access removes that organisation from the connection. The person’s other organisations are not affected.
  • The audit trail. Where the API records a request in the audit trail, it records the same tool call. The entry names the person and is marked AI assistant, with the name and address of the app. In the audit trail API, the actor’s kind is mcp. Approving, removing and ending a connection is recorded in the audit trail of each organisation it reaches.

HTTP responses of the MCP endpoint:

Status Meaning Action
400 The body is not one JSON-RPC request, or the protocol revision is not supported Send one request per POST. The error lists the supported revisions
401 There is no token, or the token cannot be used: it is unknown, expired or issued for another resource, or its connection has ended Refresh the access token. If the refresh answers invalid_grant, start a new authorization request
403 The tool needs a scope the connection does not have Ask for a further scope
404 Assistant connections are switched off. In the 2026-07-28 lifecycle, also a JSON-RPC method that the server does not offer, such as prompts/list Try again later, or write to support. Use tools only
405 The method is GET or DELETE. The response has Allow: POST Send POST
406, 415 The Accept or Content-Type header is not accepted Send the headers shown under Endpoint and transport
413 The request body is larger than 1 MB Send a smaller request
429 The connection or the source address sent too many requests Wait for the Retry-After interval

A tool error arrives with HTTP 200 and isError: true. The codes are those of the API errors. The most frequent:

Code Meaning
not_found The record does not exist, the person cannot access it, the organisation is not part of the connection, or API access is suspended
forbidden The person’s current role does not allow the action
agreement_required The person or the organisation must accept an agreement in the web application
validation_failed Correct the fields named in fields
rate_limited Wait one minute

OAuth endpoints:

Where Answer Meaning
Registration 400 invalid_redirect_uri A redirect address is missing or not accepted
Registration 400 invalid_client_metadata The client is not a public client, or a field is not accepted
Registration, token, revocation 413 The request body is larger than 16 KB
Authorization Error page The request is not valid. The browser is not sent back to the client
Authorization Page “Assistant connections are not available” The person has no organisation with API access, and the request does not include organizations:create
Token invalid_grant The code or refresh token is unknown, expired or already used, the PKCE verifier is wrong, or the connection has ended
Token invalid_target resource is not the MCP endpoint
Token invalid_scope A refresh asked for scopes other than those of the connection
Any 429 Too many requests. Registration allows 20 per hour and authorization 30 per minute per source address. The token and revocation endpoints allow 60 per minute per client at one source address, and 600 per minute per source address