跳到正文
DEV Community· Raphaël Aubry·· 3 小時前AI 評分63

Howseen 示範如何透過 MCP 將 SaaS 數據接入 Claude 和 ChatGPT,伺服器提供 44 個工具

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

AI 導讀

Howseen 創辦人 Raphaël Aubry 分享如何透過 MCP 將 SaaS 數據接入 Claude 和 ChatGPT,並以 Howseen 正式環境中的伺服器作示範。

正文

Originally published on howseen.ai: howseen.ai/mcp

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.

Try it before reading

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.

1. Transport: Streamable HTTP, stateless

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.

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.

2. Discovery without a key

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:

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,

3. Auth: a Bearer key, hashed at rest

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.

4. Tell the client which tools are safe

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.

5. Write tools: preview first, confirm second

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" };
}
  1. Keys are read only by default. A key needs the write scope ("Read + actions" in Settings) to change anything.
  2. Plan limits and quotas apply exactly as in the app. Over the limit, the tool refuses and says why.
  3. Without confirm: true, nothing changes. The tool returns a preview: what will change, the quota impact, and the exact arguments to send back with confirm: true.
  4. Only then does it execute, and the change is logged.

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.

6. Prompts and limits

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.

7. Get listed in the official registry

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.

What I would keep if I started again

  • Stateless from day one. No session store to debug on serverless.
  • Open discovery, keyed everything else, with the batch rule.
  • One data layer for the app, the REST API and MCP.
  • Write tools that preview by default, enforced in code.
  • Error messages written for the human reading them in Claude, not for a log file.

The full tool list, setup per app (Claude, ChatGPT, Cursor, Claude Code, Codex) and the REST endpoints are on the Howseen MCP page, and ready-to-paste commands are in MCP use cases.


See what AI says about your brand: howseen.ai

來源:DEV Community · dev.to