# How to expose your SaaS data to Claude and ChatGPT with MCP (a real 44-tool server)

> Source: <https://dev.to/raphaelaubry/how-to-expose-your-saas-data-to-claude-and-chatgpt-with-mcp-a-real-44-tool-server-2gn2>
> Published: 2026-11-06 04:40:56+00:00

*Originally published on howseen.ai: [howseen.ai/mcp](https://howseen.ai/mcp?utm_source=devto&utm_medium=referral&utm_campaign=o2-syndication)*

*By Raphaël Aubry, founder of Howseen*

Our product measures how ChatGPT, Gemini, Perplexity and Google's AI answers talk about a brand. That data lived in a dashboard, while more of our users were already working inside Claude, ChatGPT or Cursor. So we brought the data to the assistant instead of asking people to open one more tab, and shipped an MCP server. It runs in production at `https://app.howseen.ai/api/mcp`, version 1.3.0, with 44 tools: 26 read tools and 18 write tools. This post walks through the design choices, with the real code, so you can do the same for your own SaaS.

Tool discovery works without a key, so you can list the tools right now:

```
curl -s -X POST https://app.howseen.ai/api/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' \
  | jq '.result.tools | length'
# 44
```

Count the read-only ones:

```
... | jq '[.result.tools[] | select(.annotations.readOnlyHint)] | length'
# 26
```

Now try to call a tool without a key:

```
curl -s -X POST https://app.howseen.ai/api/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_brands","arguments":{}}}'
```

You get an HTTP 401 with `WWW-Authenticate: Bearer realm="howseen"` and a JSON-RPC error (code `-32001`) whose message tells you where to create a key. That split, open discovery and keyed calls, is the first design decision.

The app is a Next.js route handler on a serverless runtime. Keeping MCP sessions in memory between requests does not fit that model, so the server is stateless: one server and one transport per POST, JSON responses, no session id.

``` js
const server = buildMcpServer(ctx);
const transport = new WebStandardStreamableHTTPServerTransport({
  sessionIdGenerator: undefined,
  enableJsonResponse: true,
});
await server.connect(transport);
return withCors(await transport.handleRequest(fwd));
```

`sessionIdGenerator: undefined` turns sessions off. `enableJsonResponse: true` returns a plain JSON body instead of opening an event stream. Since there is no stream to open, a GET asking for `text/event-stream` gets a 405, and a plain GET returns a small JSON description (name, version, tool names, auth rules) that doubles as a health check.

One detail to get right: we read the request body once to decide on auth, so we hand the transport a fresh `Request` with the same bytes. Otherwise the transport finds an already consumed body.

MCP directories and clients probe a remote server before the user has pasted anything: `initialize`, then `tools/list`. If those calls return 401, your server looks broken in every listing. These methods only return static metadata, so we let them through:

``` js
export const DISCOVERY_METHODS: ReadonlySet<string> = new Set([
  "initialize",
  "notifications/initialized",
  "ping",
  "tools/list",
  "prompts/list",
  "resources/list",
  "resources/templates/list",
]);

export function decideMcpAuth(keyPresent: boolean, body: unknown): McpAuthDecision {
  if (keyPresent) return "authenticate";
  return isDiscoveryOnly(body) ? "open" : "needs_key";
}
```

Two guards keep this safe. First, JSON-RPC allows batches, so `isDiscoveryOnly` requires every message in the batch to be a discovery method: one `tools/call` hidden in a batch makes the whole request need a key. Second, the keyless context passes a database handle that throws on any access, so even a coding mistake cannot leak data:

```
db: new Proxy({}, { get() { throw new Error("no data access without an API key"); } }) as SupabaseClient,
```

Users create a personal key in Settings. It starts with `hs_live_`, is shown once, and only its sha256 hash is stored. The server reads it from `Authorization: Bearer hs_live_...`.

Some AI apps cannot send custom headers, so the key can also go in the server URL as `?key=`. We document that this URL must be treated like a password and that a leaked key can be revoked in Settings.

When a key is missing or invalid, the 401 carries a JSON-RPC error body, not just a status code. MCP clients display that message to the user, so it is where you explain what to do next.

Every tool declares MCP annotations. Here is what `tools/list` returns for the first read tool:

```
{
  "name": "list_brands",
  "title": "List brands",
  "annotations": {
    "readOnlyHint": true,
    "destructiveHint": false,
    "openWorldHint": false
  }
}
```

Clients use `readOnlyHint` to decide what they can run without asking. Our 26 read tools cover the questions people actually ask: which buyer questions name a competitor and not their brand (`get_prompt_gaps`), which sites AI cites in those answers (` get_easiest_wins`), what moved since the last scan (` get_changes`), real answer excerpts with their citations (` search_responses`).

The read tools call the same data layer as our REST API, so the numbers in Claude match the dashboard. That matters more than it sounds: a figure in the chat that differs from the app would make users doubt both.

The 18 write tools (track a question, add a competitor, draft an article, publish it, request indexing...) all go through one gate, in a fixed order:

```
export function writeGate(o: { scopes: readonly string[] | null | undefined; confirm?: boolean | null; blocked?: string | null }): GateResult {
  if (!hasScope(o.scopes, "write")) return { kind: "forbidden", message: READ_ONLY_KEY_MESSAGE };
  if (o.blocked) return { kind: "blocked", message: o.blocked };
  if (o.confirm !== true) return { kind: "preview" };
  return { kind: "execute" };
}
```

`confirm: true`, nothing changes. The tool returns a preview: what will change, the quota impact, and the exact arguments to send back with The server instructions tell the model to show the preview, wait for an explicit yes, and never chain several confirmed changes from one approval. A model can still ignore instructions, which is why the gate is in code and not only in the prompt.

The server also exposes three ready-made prompts (`weekly_review`, `client_report`, `fix_my_site`) that chain the tools for common jobs. Each key is limited to 120 requests per minute and 5,000 per day.

The server is listed in the official MCP registry as `ai.howseen/howseen-mcp`, with a remote entry like this:

```
"remotes": [{
  "type": "streamable-http",
  "url": "https://app.howseen.ai/api/mcp",
  "headers": [{ "name": "Authorization", "isRequired": true, "isSecret": true }]
}]
```

You can check any entry with `curl "https://registry.modelcontextprotocol.io/v0/servers?search=howseen"`. Keyless discovery is what lets registries and directories read your tool list without credentials.

The full tool list, setup per app (Claude, ChatGPT, Cursor, Claude Code, Codex) and the REST endpoints are on the [Howseen MCP page](https://howseen.ai/mcp?utm_source=devto&utm_medium=referral&utm_campaign=o2-syndication), and ready-to-paste commands are in [MCP use cases](https://howseen.ai/mcp-use-cases?utm_source=devto&utm_medium=referral&utm_campaign=o2-syndication).

See what AI says about your brand: [howseen.ai](https://howseen.ai/?utm_source=devto&utm_medium=referral&utm_campaign=o2-syndication)
