Skip to content
Merged
Show file tree
Hide file tree
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
2 changes: 1 addition & 1 deletion .cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "cloro",
"displayName": "cloro",
"version": "0.1.0",
"version": "0.2.0",
"description": "Query ChatGPT, Perplexity, Gemini, Copilot, Grok, Google AI Mode, Google Search and Google News as agent tools, with country and state-level geo-targeting.",
"author": {
"name": "cloro",
Expand Down
20 changes: 13 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ Runs hosted at `mcp.cloro.dev`, or locally over stdio.

## How do you connect an agent to the cloro MCP server?

1. Get a cloro API key from the [dashboard](https://dashboard.cloro.dev).
2. Point your client at the hosted server, passing the key.
3. Ask the agent a question that needs a live answer engine.
In Claude, you sign in with OAuth and do not need a key:

The hosted server speaks Streamable HTTP. Put the key in the URL path so it works in clients that cannot set custom headers:
1. Go to **Customize → Connectors** and select **Add custom connector**.
2. Enter `https://mcp.cloro.dev/mcp`. Under **Authentication**, choose **Sign in when needed**.
3. Ask Claude to use a cloro tool. Sign in to cloro and select the organization whose credits pay for the calls.

Other clients use a cloro API key from the [dashboard](https://dashboard.cloro.dev). The hosted server speaks Streamable HTTP. Put the key in the URL path so it works in clients that cannot set custom headers:

```json
{
Expand Down Expand Up @@ -49,6 +51,8 @@ MCP_TRANSPORT=stdio CLORO_API_KEY=your_key node dist/index.js

`MCP_TRANSPORT` is `http` (default) or `stdio`. In stdio mode the client spawns the process and the key comes from `CLORO_API_KEY`; in http mode keys arrive per request and no key is configured on the server. `PORT` defaults to `8095` and `CLORO_API_URL` to `https://api.cloro.dev`.

OAuth is off unless you set `CLERK_ISSUER`. The hosted server sets it to `https://clerk.cloro.dev`. `api.cloro.dev` accepts only tokens from that issuer, so a self-hosted server that forwards to it must use the same value. `MCP_PUBLIC_URL` (default `https://mcp.cloro.dev`) sets the resource URL in the protected resource metadata.

## What tools does it expose?

| Tool | Endpoint |
Expand All @@ -64,13 +68,15 @@ MCP_TRANSPORT=stdio CLORO_API_KEY=your_key node dist/index.js
| `list_countries` | [`GET /v1/countries`](https://cloro.dev/docs/api-reference/endpoint/countries) |
| `list_states` | [`GET /v1/states`](https://cloro.dev/docs/api-reference/endpoint/states) |

The assistant tools take a `prompt` and a `country` (ISO 3166-1 alpha-2), plus an optional `state` for US state-level targeting. `scrape_google_ai_mode` targets sub-country with `location` or `uule` rather than `state`. `scrape_google` runs either from a `query` plus `country`, or from a complete `google.com/search` URL that carries the query and pagination itself, and accepts `include.aioverview` for Google's AI Overview. Every tool takes an optional `include` object for heavier payload fields; leave it unset for the leanest response.
The assistant tools take a `prompt` and a `country` (ISO 3166-1 alpha-2), plus an optional `state` for US state-level targeting. The Google tools take `gl` for the result geography and an optional `hl` for the interface language; `country` still works as a deprecated alias for `gl`. `scrape_google_ai_mode` targets sub-country with `location` or `uule` rather than `state`. `scrape_google` runs either from a `query` plus `gl`, or from a complete `google.com/search` URL that carries the query and pagination itself, and accepts `include.aioverview` for Google's AI Overview. Every tool takes an optional `include` object for heavier payload fields; leave it unset for the leanest response.

`scrape_grok` is registered, but Grok availability varies. Check the [provider status page](https://cloro.dev/docs/guides/providers) before relying on it.

## How it works

The server holds no business logic and no secrets. Each request forwards the caller's own API key to `api.cloro.dev` as a Bearer token, so authentication, credit billing, rate limiting and concurrency are all enforced by the API rather than here. In http mode every POST builds a fresh server and transport bound to that caller's key, which is why one deployment serves everyone with no session state.
The server holds no business logic and no secrets. Each request forwards the caller's own credential, an API key or an OAuth access token, to `api.cloro.dev` as a Bearer token, so authentication, credit billing, rate limiting and concurrency are all enforced by the API rather than here. In http mode every POST builds a fresh server and transport bound to that caller's credential, which is why one deployment serves everyone with no session state.

`initialize` and `tools/list` answer without a credential, so a client can list the tools before sign-in. The first `tools/call` without one returns `401` with a `WWW-Authenticate` header that points at `/.well-known/oauth-protected-resource/mcp`. An OAuth client reads the authorization server from there and runs the flow. The server checks a token's signature against the issuer's public JWKS (code in `src/oauth-tokens/`), so it needs no Clerk secret.

Tool input schemas are the same zod schemas the API validates with, vendored into `src/schemas/` from cloro's backend. They are copied rather than imported because that package is not published. The [API reference](https://cloro.dev/docs/api-reference/introduction) is authoritative if the two ever disagree.

Expand All @@ -86,7 +92,7 @@ The scrape tools call billable endpoints, so yes, at the same rate as the API. `

### Why is my API key in the URL?

Because several MCP clients cannot set custom headers. If yours can, use the `Authorization` header form instead and keep the key out of the path.
Because several MCP clients cannot set custom headers. If yours can, use the `Authorization` header form instead and keep the key out of the path. If your client supports OAuth, use that and there is no key at all. The URL path accepts API keys only, never OAuth tokens.

### What is the request timeout?

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@cloro/mcp-server",
"version": "0.1.0",
"version": "0.2.0",
"description": "MCP server for the cloro API. Query ChatGPT, Perplexity, Gemini, Copilot, Grok, Google AI Mode, Google Search and Google News as agent tools.",
"license": "MIT",
"type": "module",
Expand All @@ -20,6 +20,7 @@
"@modelcontextprotocol/sdk": "^1.29.0",
"cors": "^2.8.5",
"express": "^4.20.0",
"jose": "^6.2.3",
"zod": "^4.1.5"
},
"devDependencies": {
Expand Down
10 changes: 7 additions & 3 deletions server.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@
"name": "dev.cloro/cloro",
"title": "cloro",
"description": "Scrape AI answer engines and Google Search/News with country and state-level geo-targeting.",
"version": "0.1.0",
"version": "0.2.0",
"websiteUrl": "https://cloro.dev/docs/integrations/mcp",
"repository": {
"url": "https://github.com/cloro-dev/mcp-server",
"source": "github"
},
"icons": [
{
"src": "https://cloro.dev/favicon-192.png",
Expand All @@ -24,8 +28,8 @@
"headers": [
{
"name": "Authorization",
"description": "Bearer <your cloro API key>. Create one at https://cloro.dev.",
"isRequired": true,
"description": "Optional. Bearer <your cloro API key>, for clients that do not run the OAuth flow. Create a key at https://cloro.dev. Clients that support OAuth should omit this and complete the authorization flow instead — the server advertises its authorization server at /.well-known/oauth-protected-resource.",
"isRequired": false,
"isSecret": true
}
]
Expand Down
61 changes: 61 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,64 @@ export const config = {
// Sync scrapes can take up to 5 minutes server-side; leave headroom on top.
requestTimeoutMs: 320_000,
};

/**
* OAuth settings, read on access rather than at import.
*
* Everything above is fixed for the life of the process. These are not: the
* tests flip `CLERK_ISSUER` to exercise both the configured and unconfigured
* server, and freezing them at import would make that impossible without
* module-registry games.
*
* Clerk is the authorization server. `CLERK_ISSUER` unset disables the OAuth
* branch entirely — the protected resource metadata advertises no
* authorization server and API keys stay the only credential — which is what
* makes this deployable ahead of the Clerk configuration.
*/
export const oauth = {
/** Public origin this server is reached at. */
get publicUrl(): string {
return (process.env.MCP_PUBLIC_URL || "https://mcp.cloro.dev").replace(
/\/$/,
"",
);
},

get issuer(): string | undefined {
return process.env.CLERK_ISSUER;
},

get jwksUrl(): string | undefined {
return process.env.CLERK_JWKS_URL;
},

/**
* Scopes named in the 401 challenge. `user:org:read` is the load-bearing
* one: it puts the organization picker on Clerk's consent screen and is what
* makes the token carry the `org_id` claim the API resolves an organization
* from. Without it a token verifies but can charge nobody.
*/
get scopes(): string[] {
return (process.env.MCP_OAUTH_SCOPES || "profile email user:org:read")
.split(" ")
.filter(Boolean);
},

/**
* The canonical resource URI (RFC 8707). The spec requires this to match the
* URL the user enters in their client exactly, so it is built from the
* public origin and not from the listen address.
*/
get resourceUri(): string {
return `${this.publicUrl}/mcp`;
},

/** Where the RFC 9728 document lives, for the 401 `resource_metadata` hint. */
get resourceMetadataUrl(): string {
return `${this.publicUrl}/.well-known/oauth-protected-resource/mcp`;
},

get enabled(): boolean {
return Boolean(this.issuer);
},
};
Loading
Loading