Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 160 additions & 27 deletions docs/toolhive/guides-mcp/notion-remote.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ agents to search, read, create, and manage pages, databases, and other content
in your Notion workspace.

This is a remote MCP server that uses the **Auto-Discovered** authorization
method, meaning ToolHive handles OAuth authentication automatically with minimal
configuration required.
method, meaning ToolHive discovers Notion's OAuth endpoints automatically. On
the CLI and UI, this requires minimal configuration. On Kubernetes, it takes a
one-time OAuth client registration and an embedded authorization server setup,
described below.

## Metadata

Expand Down Expand Up @@ -79,50 +81,155 @@ thv restart notion-remote
</TabItem>
<TabItem value='k8s' label='Kubernetes'>

:::note
The ToolHive operator deploys a proxy in front of Notion's hosted remote MCP
server using an `MCPRemoteProxy` resource with an
[embedded authorization server](../concepts/embedded-auth-server.mdx).

The ToolHive Kubernetes Operator does not currently support remote MCP servers
using dynamic OAuth authentication. Instead, you can run the
[local Notion MCP server](https://github.com/makenotion/notion-mcp-server) in
Kubernetes using a static integration key.
The examples below use `https://notion-mcp.example.com` as a placeholder for
wherever you'll expose the proxy outside the cluster. Set up an Ingress or
Gateway route pointing at the proxy Service first (see
[Connect clients to MCP servers](../guides-k8s/connect-clients.mdx#connect-from-outside-the-cluster))
and substitute your real hostname everywhere this placeholder appears. Notion
redirects the user's browser to this URL during authentication, so it must be
genuinely reachable over HTTPS, not just resolvable inside the cluster.

:::
Notion's remote MCP server (`mcp.notion.com`) only supports Dynamic Client
Registration (RFC 7591) for third-party OAuth clients; there's no
dashboard-based app registration for it, unlike Notion's classic API
integrations. Once your hostname is live, register once to mint a persistent
client ID and secret:

```bash
curl -s -X POST https://mcp.notion.com/register \
-H "Content-Type: application/json" \
-d '{
"client_name": "my-toolhive-proxy",
"redirect_uris": ["https://notion-mcp.example.com/oauth/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "client_secret_post"
}'
```

Create an integration in Notion to obtain an authentication token by following
the instructions in the MCP server's
[README](https://github.com/makenotion/notion-mcp-server?tab=readme-ov-file#installation).
Save the returned `client_id` and `client_secret`; they don't expire. The
`redirect_uris` value must exactly match the callback URL you configure below.

Create a Kubernetes secret containing your Notion integration token
("`ntn_****`"):
Generate a signing key and an HMAC key for the embedded authorization server,
and store all three credentials as Secrets:

```bash
kubectl -n toolhive-system create secret generic notion-token --from-literal=token=<YOUR_TOKEN>
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out signing-key.pem
openssl rand -base64 32 > hmac-key.txt

kubectl create secret generic notion-auth-signing-key -n toolhive-system \
--from-file=signing-key=signing-key.pem
kubectl create secret generic notion-auth-hmac-key -n toolhive-system \
--from-literal=hmac-key="$(cat hmac-key.txt)"
kubectl create secret generic notion-client-secret -n toolhive-system \
--from-literal=client-secret=<YOUR_CLIENT_SECRET>
```

Create a Kubernetes manifest to deploy the Notion MCP server using your secret:
Create the embedded authorization server configuration, the OIDC config that
validates its own issued tokens, and the remote proxy:

```yaml title="notion.yaml"
```yaml title="notion-proxy.yaml"
apiVersion: toolhive.stacklok.dev/v1beta1
kind: MCPServer
kind: MCPExternalAuthConfig
metadata:
name: notion-embedded-auth
namespace: toolhive-system
spec:
type: embeddedAuthServer
embeddedAuthServer:
issuer: 'https://notion-mcp.example.com'
signingKeySecretRefs:
- name: notion-auth-signing-key
key: signing-key
hmacSecretRefs:
- name: notion-auth-hmac-key
key: hmac-key
upstreamProviders:
- name: notion
type: oauth2
oauth2Config:
authorizationEndpoint: 'https://mcp.notion.com/authorize'
tokenEndpoint: 'https://mcp.notion.com/token'
clientId: '<YOUR_CLIENT_ID>'
clientSecretRef:
name: notion-client-secret
key: client-secret
redirectUri: 'https://notion-mcp.example.com/oauth/callback'
---
apiVersion: toolhive.stacklok.dev/v1beta1
kind: MCPOIDCConfig
metadata:
name: notion-embedded-oidc
namespace: toolhive-system
spec:
type: inline
inline:
issuer: 'https://notion-mcp.example.com'
---
apiVersion: toolhive.stacklok.dev/v1beta1
kind: MCPRemoteProxy
metadata:
name: notion
namespace: toolhive-system
spec:
image: ghcr.io/stacklok/dockyard/npx/notion:2.2.1
transport: stdio
remoteUrl: https://mcp.notion.com/mcp
transport: streamable-http
proxyPort: 8080
secrets:
- name: notion-token
key: token
targetEnvName: NOTION_TOKEN
authServerRef:
kind: MCPExternalAuthConfig
name: notion-embedded-auth
oidcConfigRef:
name: notion-embedded-oidc
audience: 'https://notion-mcp.example.com/mcp'
resourceUrl: 'https://notion-mcp.example.com/mcp'
audit:
enabled: true
```

:::note[Keep `issuer` path-free]

Set `issuer` on both the `MCPExternalAuthConfig` and `MCPOIDCConfig` to your
bare host, with no path. The embedded authorization server's OAuth endpoints
(`/oauth/register`, `/oauth/authorize`, `/oauth/token`, `/oauth/callback`) are
always served at the host root, regardless of any path in `issuer`. Adding a
path here (for example, matching your MCP resource path) makes discovery
advertise endpoints that nothing actually serves, and every authentication
attempt fails with a generic "authorization header required" error.

Also set `redirectUri` on the upstream provider explicitly to
`<issuer>/oauth/callback`, as shown above, instead of omitting it.
[Default callback URL for upstream providers](../guides-k8s/auth-k8s.mdx#default-callback-url-for-upstream-providers)
documents the default as `{resourceUrl}/oauth/callback`, which works when
`resourceUrl` is a bare host. It breaks here because `resourceUrl` includes the
`/mcp` path: the callback route is only ever served at the host root, so the
computed default would land on a path nothing serves. Setting `redirectUri`
explicitly avoids relying on that default.

:::

Apply the manifest to your Kubernetes cluster:

```bash
kubectl apply -f notion.yaml
kubectl apply -f notion-proxy.yaml
```

Check the proxy status:

```bash
kubectl get mcpremoteproxy notion -n toolhive-system
```

Connect your MCP client to the proxy's URL. Each client authenticates once
through the OAuth flow, redirecting through Notion, then reuses the token issued
by the embedded authorization server. For production deployments, configure
Redis-backed session storage so tokens survive pod restarts. See
[Horizontal scaling](../guides-k8s/run-mcp-k8s.mdx#horizontal-scaling) and
[Redis session storage](../guides-k8s/redis-session-storage.mdx).

</TabItem>
</Tabs>

Expand Down Expand Up @@ -158,8 +265,12 @@ Here are some sample prompts you can use to interact with the Notion MCP server:
- **Be cautious with updates**: The MCP server can modify and delete content in
your Notion workspace. Always review changes before applying them to important
pages or databases.
- **Handle authentication errors**: If you see authentication errors, restart
the server to trigger a new OAuth flow: `thv restart notion-remote`
- **Handle authentication errors**: On the CLI or UI, restart the server to
trigger a new OAuth flow: `thv restart notion-remote`. On Kubernetes, a proxy
pod restart invalidates all sessions unless you've configured Redis-backed
session storage, so every client must re-authenticate; with Redis-backed
storage, only re-authenticate a client whose underlying Notion token was
revoked or can't be refreshed.

## Troubleshooting

Expand All @@ -168,20 +279,42 @@ Here are some sample prompts you can use to interact with the Notion MCP server:

If OAuth authentication fails or times out:

**CLI or UI:**

1. Ensure the callback port is not blocked by a firewall
2. Check that your browser allows pop-ups from ToolHive
3. Try restarting the server with `thv restart notion-remote`

**Kubernetes:** Your client authenticates against the embedded authorization
server, which then completes a separate OAuth exchange with Notion using your
registered client credentials. Check the proxy pod logs for errors from either
leg of that flow:

```bash
kubectl logs -n toolhive-system -l app.kubernetes.io/instance=notion | grep -i oauth
```

The most common causes are a path segment in `issuer` or a `redirectUri` that
doesn't exactly match what you registered with Notion. See the note above.

</details>
<details>
<summary>Server shows "Running" but tools don't work</summary>

The server may have lost authentication. Restart the server to re-authenticate:
The server may have lost authentication.

**CLI or UI:** Restart the server to re-authenticate:

```bash
thv restart notion-remote
```

**Kubernetes:** Check whether the proxy pod restarted recently
(`kubectl get pods -n toolhive-system`). Without Redis-backed session storage, a
restart invalidates every session and all clients must re-authenticate. With
Redis-backed storage, this instead means the affected client's underlying Notion
token was revoked or couldn't be refreshed; re-authenticate from that client.

</details>
<details>
<summary>Can't find content in search results</summary>
Expand Down