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
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: CI
on:
pull_request:
push:
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npm run lint
- run: npm test
- run: npm run test:workers
- run: npm run validate:json
- run: npm run registry:check
- run: npm run deploy:dry-run
61 changes: 22 additions & 39 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -1,47 +1,30 @@
name: Deploy to Cloudflare

name: Deploy
on:
workflow_dispatch:
push:
branches: [main]
workflow_dispatch:

permissions:
contents: read

concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: true

jobs:
deploy:
verify:
runs-on: ubuntu-latest
name: deploy (${{ matrix.name }})
strategy:
fail-fast: false
matrix:
include:
- name: exmxc-workers
config: wrangler.jsonc
steps:
- uses: actions/checkout@v5
- name: Deploy ${{ matrix.name }}
uses: cloudflare/wrangler-action@v4
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
wranglerVersion: "4"
command: deploy --config ${{ matrix.config }}

verify:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npm run lint
- run: npm test
- run: npm run test:workers
- run: npm run validate:json
- run: npm run registry:check
- run: npm run deploy:dry-run
deploy:
needs: verify
runs-on: ubuntu-latest
name: verify live
needs: deploy
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: "22"
- name: Wait for edge propagation
run: sleep 25
- name: Live acceptance
run: node scripts/live-acceptance.mjs
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npx wrangler deploy
- run: sleep 30
- run: node scripts/live-acceptance.mjs
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ The code keeps the ES-module Worker entrypoint (`export default { fetch(request,

Canonical MCP discovery is served from `https://mcp.exmxc.ai/.well-known/mcp.json`; the apex `https://exmxc.ai/.well-known/mcp.json` is intentionally not served because the apex is Webflow on a DNS-only record. The transport endpoint is `https://mcp.exmxc.ai/mcp`.

- `GET /` — REST/WebMCP discovery document
- `GET /` — REST/MCP discovery document
- `GET /.well-known/mcp.json` — MCP discovery pointer
- `GET /capabilities.json` — generated capability inventory
- `GET /.well-known/tool-registry.json` — generated tool registry
Expand Down Expand Up @@ -253,3 +253,19 @@ scripts/build-registry-packet.mjs Registry packet generator
registry/ Generated MCP registry submission packet
workers/root-discovery/worker.js Unused root .well-known MCP pointer Worker reference
```

## MCP modernization notes (v2.3.0)

exmxc exposes a REST/JSON intelligence API plus an MCP server using Streamable HTTP on Cloudflare Workers. Tool and resource inventories are generated from `lib/registry.js`; public REST aliases and bundled dataset payloads are preserved.

### ADS signal route

`GET /api/ai-jobs-signal` without signal parameters returns the deterministic public benchmark. Paid signal generation uses `POST /api/ai-jobs-signal` and requires `Authorization: Bearer <ADS_SIGNAL_KEY>`. The Cloudflare deployment should add a rate rule on this path as defense in depth.

### Audit route

`/audit/run` and the `ex.eei.audit.run` tool remain public but validate targets before contacting the upstream audit service. The upstream service at `exmxc-audit.vercel.app` must independently enforce DNS-resolution and redirect checks against private, loopback, link-local, and reserved ranges; that external security dependency is not satisfied by this repository alone. Conservative Cloudflare rate limiting should be applied to `/audit/run` and audit calls arriving via `/mcp` as deployment configuration.

### Structured content compatibility

Successful tool calls keep `content[0].text` as the JSON serialization of the complete handler result. `structuredContent` is only emitted for protocol-valid object results; top-level array results remain text-only to avoid introducing an unapproved wrapper.
2 changes: 1 addition & 1 deletion SYSTEM_MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ The following surfaces are generated from these constants rather than hand-maint

## Transport routes

### REST/WebMCP discovery
### REST/MCP discovery

- `GET /`
- `GET /.well-known/mcp.json`
Expand Down
39 changes: 29 additions & 10 deletions lib/ads-classifier.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,31 @@ function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}

export async function classifyPostings(postings, anthropicApiKey) {
try {
if (!Array.isArray(postings) || postings.length === 0 || !anthropicApiKey) {
return [];
}
export class ADSUpstreamError extends Error {
constructor(message, status = 502) {
super(message);
this.name = "ADSUpstreamError";
this.status = status;
}
}

export async function classifyPostings(postings, anthropicApiKey, { signal } = {}) {
if (!Array.isArray(postings) || postings.length === 0) {
return [];
}
if (!anthropicApiKey) {
throw new ADSUpstreamError("Missing Anthropic API key", 502);
}

try {
const response = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": anthropicApiKey,
"anthropic-version": "2023-06-01"
},
signal,
body: JSON.stringify({
model: "claude-sonnet-4-20250514",
max_tokens: 1000,
Expand All @@ -54,19 +66,26 @@ export async function classifyPostings(postings, anthropicApiKey) {
});

if (!response.ok) {
return [];
throw new ADSUpstreamError("ADS classification upstream failed", 502);
}

const payload = await response.json();
const text = payload?.content?.[0]?.text;
if (!text) {
return [];
throw new ADSUpstreamError("ADS classification upstream returned an empty response", 502);
}

const parsed = JSON.parse(text);
return Array.isArray(parsed?.classified) ? parsed.classified : [];
} catch {
return [];
if (!Array.isArray(parsed?.classified)) {
throw new ADSUpstreamError("ADS classification upstream returned invalid JSON", 502);
}
return parsed.classified;
} catch (error) {
if (error?.name === "AbortError" || error?.name === "TimeoutError") {
throw new ADSUpstreamError("ADS classification upstream timed out", 504);
}
if (error instanceof ADSUpstreamError) throw error;
throw new ADSUpstreamError("ADS classification upstream failed", 502);
}
}

Expand Down
32 changes: 25 additions & 7 deletions lib/http.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,38 @@
export const CACHE = {
NO_STORE: "no-store",
NO_CACHE: "no-cache",
PUBLIC: "public, max-age=3600"
};

export const CORS_HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "content-type, mcp-protocol-version"
"Access-Control-Allow-Headers": "content-type, mcp-protocol-version, authorization"
};

export const JSON_HEADERS = {
"Content-Type": "application/json",
...CORS_HEADERS,
"Cache-Control": "public, max-age=3600"
};
export function mcpCorsHeaders(request, env = {}) {
const origin = request.headers.get("Origin");
const allowed = String(env.MCP_ALLOWED_ORIGINS || "https://exmxc.ai,https://www.exmxc.ai,https://mcp.exmxc.ai")
.split(",")
.map((value) => value.trim())
.filter(Boolean);
const base = {
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "content-type, mcp-protocol-version",
"Cache-Control": CACHE.NO_STORE
};
if (!origin) return base;
if (!allowed.includes(origin)) return { ...base, "Vary": "Origin" };
return { ...base, "Access-Control-Allow-Origin": origin, "Vary": "Origin" };
}

export function jsonResponse(payload, init = {}) {
return new Response(JSON.stringify(payload, null, 2), {
...init,
headers: {
...JSON_HEADERS,
"Content-Type": "application/json",
...CORS_HEADERS,
"Cache-Control": CACHE.PUBLIC,
...(init.headers || {})
}
});
Expand Down
Loading
Loading