diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e482aad --- /dev/null +++ b/.dockerignore @@ -0,0 +1,22 @@ +# Dependencies and build output +**/node_modules +**/dist +**/coverage + +# Version control and editor +.git +.gitignore +.cursor +.vscode + +# Logs and local artifacts +**/*.log +npm-debug.log* + +# CI and docs not needed in the image +.github +**/*.md +!mcp-server/README.md + +# Test fixtures +mcp-server/test diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 0636c5e..ca87057 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -39,6 +39,37 @@ Creates a new release with changelog, git tag, and GitHub release. --- +### `publish.yaml` - Publish NPM Package and Container Image + +Publishes the `@currents/mcp` npm package and a public container image to GitHub Container Registry (GHCR) in the same workflow run. + +**Triggers:** + +- Manual dispatch only (workflow_dispatch) + +**Inputs:** + +- `channel` (choice): NPM distribution tag — `alpha`, `beta`, `latest`, or `oldversion` + +**What it does:** + +1. Validates that `latest` channel publishes only from a `release/*` branch or tag +2. Builds and publishes `@currents/mcp` to npm with the selected channel tag +3. On success, builds the root [Dockerfile](../../Dockerfile) and pushes the image to GHCR + +**Container image tags** (both applied to the same build): + +- `ghcr.io//currents-mcp:` — immutable semver (e.g. `2.3.3`) +- `ghcr.io//currents-mcp:` — moves with the npm dist-tag (`latest`, `beta`, `alpha`, `oldversion`) + +**Usage:** + +1. Go to Actions → "Publish NPM Package" +2. Select the NPM channel +3. Run workflow from the appropriate branch (e.g. `release/x.y.z` for `latest`) + +--- + ### `test.yml` - Unit Tests Runs the unit test suite on every push and pull request. diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 922010a..6428928 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -90,3 +90,83 @@ jobs: ] } + publish-image: + needs: publish + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.ref }} + + - name: Extract package version + id: extract_version + run: | + PACKAGE_VERSION=$(node -p "require('./mcp-server/package.json').version") + echo "package_version=$PACKAGE_VERSION" >> $GITHUB_OUTPUT + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=raw,value=${{ steps.extract_version.outputs.package_version }} + type=raw,value=${{ github.event.inputs.channel }} + + - uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + - name: Notify private MCP deploy pipeline + if: github.event.inputs.channel == 'latest' || github.event.inputs.channel == 'beta' + env: + DISPATCH_TOKEN: ${{ secrets.DISPATCH_TOKEN }} + run: | + set -euo pipefail + + TAG="${{ github.event.inputs.channel }}" + + # Resolve manifest digest from GHCR (public package — anonymous token works) + TOKEN=$(curl -fsSL "https://ghcr.io/token?scope=repository:currents-dev/currents-mcp:pull" | jq -r '.token') + DIGEST=$(curl -fsSI \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "Accept: application/vnd.docker.distribution.manifest.v2+json" \ + "https://ghcr.io/v2/currents-dev/currents-mcp/manifests/${TAG}" \ + | grep -i docker-content-digest | awk '{print $2}' | tr -d $'\r') + + if [ -z "${DIGEST}" ]; then + echo "Failed to resolve GHCR digest for tag ${TAG}" + exit 1 + fi + + HTTP_STATUS=$(curl -sS -o /tmp/dispatch-response.json -w "%{http_code}" -X POST \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${DISPATCH_TOKEN}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/repos/currents-dev/currents/dispatches" \ + -d "{\"event_type\":\"mcp-image-published\",\"client_payload\":{\"tag\":\"${TAG}\",\"digest\":\"${DIGEST}\"}}") + + if [ "${HTTP_STATUS}" -lt 200 ] || [ "${HTTP_STATUS}" -ge 300 ]; then + echo "repository_dispatch failed (HTTP ${HTTP_STATUS}):" + cat /tmp/dispatch-response.json + exit 1 + fi + + echo "Dispatched mcp-image-published for tag=${TAG} digest=${DIGEST}" + diff --git a/Dockerfile b/Dockerfile index 8b2a169..80560af 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,8 @@ WORKDIR /app # Install dependencies COPY mcp-server/package.json mcp-server/package-lock.json ./ -RUN npm ci --production=false +# prepare runs `git config` for local hooks; skip in image (no git in alpine). +RUN npm ci --production=false --ignore-scripts # Copy source code COPY mcp-server/tsconfig.json mcp-server/tsdown.config.ts ./ @@ -27,7 +28,15 @@ COPY --from=build /app/assets ./assets COPY --from=build /app/package.json ./package.json COPY --from=build /app/node_modules ./node_modules -# Default API URL environment variable -ENV CURRENTS_API_URL=https://api.currents.dev +# Default API URL environment variable (must include the /v1 path segment) +ENV CURRENTS_API_URL=https://api.currents.dev/v1 -ENTRYPOINT ["node", "dist/index.mjs"] +# Port the Streamable HTTP server listens on (TLS terminates at the proxy in front). +ENV PORT=3000 +EXPOSE 3000 + +# This image serves the hosted remote MCP endpoint (Streamable HTTP) at /mcp. +# No API key is baked in: callers pass their Currents API key as a Bearer token +# per request. The stdio transport remains available via the npm package bin +# (node dist/index.mjs) for local Claude Desktop usage. +ENTRYPOINT ["node", "dist/http.mjs"] diff --git a/README.md b/README.md index e5de941..218fdd0 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,47 @@ Add the following to enable Currents MCP on Claude Desktop (edit `claude_desktop } ``` +### Remote (hosted) MCP endpoint + +In addition to the local stdio transport above, the same server can run as a hosted +Streamable HTTP endpoint (e.g. `https://mcp.currents.dev/mcp`) for use with remote +connectors such as the Claude web/mobile apps. + +The hosted server performs **no authentication of its own**. Each request must carry +your Currents API key as a Bearer token, which is passed through to the Currents API: + +``` +Authorization: Bearer +``` + +Example client config (remote connector): + +```json +{ + "mcpServers": { + "currents": { + "url": "https://mcp.currents.dev/mcp", + "headers": { + "Authorization": "Bearer your-api-key" + } + } + } +} +``` + +Running the HTTP server yourself: + +```bash +# from the mcp-server package +npm run build && PORT=3000 npm run start:http +# or via Docker (serves /mcp, exposes the configured PORT) +docker build -t currents-mcp . && docker run -p 3000:3000 currents-mcp +``` + +The Node server speaks plain HTTP; TLS and the public domain terminate at the +reverse proxy / load balancer in front of the container. A `GET /healthz` endpoint +is provided for liveness checks. + ### ⚠️ Notice By connecting AI tools (e.g., via MCP) to Currents, you are granting them access to your API key, test results and CI metadata. It is your responsibility to vet any AI agents or services you use, and to ensure they handle your data securely. diff --git a/mcp-server/package-lock.json b/mcp-server/package-lock.json index ca34d0c..60d02fb 100644 --- a/mcp-server/package-lock.json +++ b/mcp-server/package-lock.json @@ -16,6 +16,7 @@ "zod": "^4.3.5" }, "bin": { + "currents-mcp-http": "dist/http.mjs", "mcp": "dist/index.mjs" }, "devDependencies": { diff --git a/mcp-server/package.json b/mcp-server/package.json index 27d975c..19a44a5 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -13,7 +13,10 @@ "dashboard", "reporting" ], - "bin": "./dist/index.mjs", + "bin": { + "mcp": "./dist/index.mjs", + "currents-mcp-http": "./dist/http.mjs" + }, "version": "2.3.3", "description": "Currents MCP server", "main": "./dist/api.cjs", @@ -27,10 +30,11 @@ } }, "scripts": { - "build": "tsdown && chmod 755 dist/index.mjs", + "build": "tsdown && chmod 755 dist/index.mjs dist/http.mjs", "publish:mcp": "npm run publish:npm", "publish:npm": "npm run rm && npm run build && ./publish.cjs", "start": "node dist/index.mjs", + "start:http": "node dist/http.mjs", "rm": "rm -rf dist", "prepare": "git config core.hooksPath mcp-server/scripts/hooks", "sync-readme": "node scripts/sync-readme-tools.mjs", diff --git a/mcp-server/src/http.test.ts b/mcp-server/src/http.test.ts new file mode 100644 index 0000000..131a4d4 --- /dev/null +++ b/mcp-server/src/http.test.ts @@ -0,0 +1,85 @@ +import type { IncomingMessage } from "node:http"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { requestContext } from "./lib/context.js"; +import { fetchApi } from "./lib/request.js"; +import { extractApiKey } from "./http.js"; + +vi.mock("./lib/env.js", () => ({ + CURRENTS_API_KEY: "env-key", + CURRENTS_API_URL: "https://api.test.com", +})); + +vi.mock("./lib/logger.js", () => ({ + logger: { error: vi.fn(), debug: vi.fn() }, +})); + +const reqWithAuth = (authorization?: string) => + ({ headers: authorization ? { authorization } : {} }) as IncomingMessage; + +describe("extractApiKey", () => { + it("extracts the token from a Bearer header", () => { + expect(extractApiKey(reqWithAuth("Bearer abc123"))).toBe("abc123"); + }); + + it("is case-insensitive and trims surrounding whitespace", () => { + expect(extractApiKey(reqWithAuth(" bearer abc123 "))).toBe("abc123"); + }); + + it("returns the raw value when there is no Bearer prefix", () => { + expect(extractApiKey(reqWithAuth("abc123"))).toBe("abc123"); + }); + + it("returns undefined when no Authorization header is present", () => { + expect(extractApiKey(reqWithAuth())).toBeUndefined(); + }); +}); + +describe("API key passthrough via request context", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("uses the per-request key for the outbound Currents call", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue({ ok: true, json: async () => ({}) }); + global.fetch = fetchMock; + + await requestContext.run({ apiKey: "req-key" }, () => fetchApi("/runs/1")); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.test.com/runs/1", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer req-key", + }), + }), + ); + }); + + it("does not bleed keys across concurrent requests", async () => { + const authByKey: Record = {}; + global.fetch = vi.fn().mockImplementation((_url, init: RequestInit) => { + const auth = (init.headers as Record).Authorization; + // Record which Authorization header each path saw. + const path = String(_url); + authByKey[path] = auth; + return Promise.resolve({ ok: true, json: async () => ({}) }); + }); + + await Promise.all([ + requestContext.run({ apiKey: "key-a" }, async () => { + await new Promise((r) => setTimeout(r, 5)); + return fetchApi("/a"); + }), + requestContext.run({ apiKey: "key-b" }, () => fetchApi("/b")), + ]); + + expect(authByKey["https://api.test.com/a"]).toBe("Bearer key-a"); + expect(authByKey["https://api.test.com/b"]).toBe("Bearer key-b"); + }); +}); diff --git a/mcp-server/src/http.ts b/mcp-server/src/http.ts new file mode 100644 index 0000000..e861718 --- /dev/null +++ b/mcp-server/src/http.ts @@ -0,0 +1,135 @@ +#!/usr/bin/env node +import { + createServer, + type IncomingMessage, + type Server, + type ServerResponse, +} from "node:http"; +import { fileURLToPath } from "node:url"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { requestContext } from "./lib/context.js"; +import { logger } from "./lib/logger.js"; +import { createMcpServer } from "./server.js"; + +const PORT = Number(process.env.PORT ?? 3000); +const MCP_PATH = "/mcp"; + +/** + * Pulls the caller's Currents API key out of the inbound Authorization header. + * + * The MCP server performs no authentication itself: whatever token arrives is + * passed through to the Currents API, which is the sole auth authority. A + * missing or invalid key therefore surfaces as the Currents API's own 401. + */ +export function extractApiKey(req: IncomingMessage): string | undefined { + const auth = req.headers.authorization?.trim(); + if (!auth) { + return undefined; + } + const match = /^Bearer\s+(.+)$/i.exec(auth); + return (match ? match[1] : auth).trim(); +} + +async function readJsonBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(chunk as Buffer); + } + if (chunks.length === 0) { + return undefined; + } + const raw = Buffer.concat(chunks).toString("utf-8"); + return raw ? JSON.parse(raw) : undefined; +} + +function sendJson(res: ServerResponse, status: number, payload: unknown): void { + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(payload)); +} + +function jsonRpcError(code: number, message: string) { + return { jsonrpc: "2.0" as const, error: { code, message }, id: null }; +} + +/** + * Handles a single MCP request in stateless mode: a fresh server + transport + * per request, with the per-request API key carried via AsyncLocalStorage so + * the shared tools/api layer needs no changes. + */ +async function handleMcpPost( + req: IncomingMessage, + res: ServerResponse, +): Promise { + let body: unknown; + try { + body = await readJsonBody(req); + } catch { + sendJson(res, 400, jsonRpcError(-32700, "Parse error")); + return; + } + + const apiKey = extractApiKey(req); + const server = createMcpServer(); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + }); + + res.on("close", () => { + void transport.close(); + void server.close(); + }); + + try { + await server.connect(transport); + await requestContext.run({ apiKey }, () => + transport.handleRequest(req, res, body), + ); + } catch (error) { + logger.error({ err: error }, "Error handling MCP request"); + if (!res.headersSent) { + sendJson(res, 500, jsonRpcError(-32603, "Internal server error")); + } + } +} + +export function createHttpServer(): Server { + return createServer((req, res) => { + const url = new URL( + req.url ?? "/", + `http://${req.headers.host ?? "localhost"}`, + ); + + if (req.method === "GET" && url.pathname === "/healthz") { + sendJson(res, 200, { status: "ok" }); + return; + } + + if (url.pathname === MCP_PATH) { + if (req.method === "POST") { + void handleMcpPost(req, res); + return; + } + // Stateless: no standalone SSE stream (GET) or session teardown (DELETE). + res.writeHead(405, { Allow: "POST", "Content-Type": "application/json" }); + res.end(JSON.stringify(jsonRpcError(-32000, "Method not allowed"))); + return; + } + + sendJson(res, 404, { error: "Not found" }); + }); +} + +export function start(port: number = PORT): Server { + const httpServer = createHttpServer(); + httpServer.listen(port, () => { + logger.debug( + `🚀 Currents MCP HTTP server listening on :${port}${MCP_PATH}`, + ); + }); + return httpServer; +} + +// Only start listening when executed directly (not when imported by tests). +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + start(); +} diff --git a/mcp-server/src/lib/context.test.ts b/mcp-server/src/lib/context.test.ts new file mode 100644 index 0000000..082da6f --- /dev/null +++ b/mcp-server/src/lib/context.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from "vitest"; +import { getApiKey, requestContext } from "./context.js"; + +// Falls back to this when no per-request context is set (stdio path). +vi.mock("./env.js", () => ({ + CURRENTS_API_KEY: "env-key", + CURRENTS_API_URL: "https://api.test.com", +})); + +describe("getApiKey", () => { + it("falls back to the env key when no request context is set", () => { + expect(getApiKey()).toBe("env-key"); + }); + + it("prefers the per-request context key over the env key", () => { + const result = requestContext.run({ apiKey: "req-key" }, () => getApiKey()); + expect(result).toBe("req-key"); + }); + + it("falls back to env when the context apiKey is undefined", () => { + const result = requestContext.run({}, () => getApiKey()); + expect(result).toBe("env-key"); + }); + + it("isolates keys across concurrent requests (no bleed)", async () => { + const seen: Record = {}; + + const run = (key: string) => + requestContext.run({ apiKey: key }, async () => { + // Yield so both contexts are active concurrently. + await new Promise((resolve) => setTimeout(resolve, key === "a" ? 5 : 0)); + seen[key] = getApiKey(); + }); + + await Promise.all([run("a"), run("b")]); + + expect(seen).toEqual({ a: "a", b: "b" }); + // Outside any context, still the env fallback. + expect(getApiKey()).toBe("env-key"); + }); +}); diff --git a/mcp-server/src/lib/context.ts b/mcp-server/src/lib/context.ts new file mode 100644 index 0000000..96b5889 --- /dev/null +++ b/mcp-server/src/lib/context.ts @@ -0,0 +1,26 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import { CURRENTS_API_KEY } from "./env.js"; + +export interface RequestContext { + /** Per-request Currents API key (e.g. from the inbound Authorization header). */ + apiKey?: string; +} + +/** + * Carries per-request data (such as the caller's API key) down to the shared + * api/tools layer without changing any handler signatures. The HTTP transport + * populates this per request; the stdio transport never does, so it falls back + * to the env var. + */ +export const requestContext = new AsyncLocalStorage(); + +/** + * Resolves the Currents API key for the current request. + * + * - Remote (HTTP): the key set via `requestContext.run(...)` wins. + * - Local (stdio): no context is set, so it falls back to `CURRENTS_API_KEY`. + */ +export function getApiKey(): string { + const contextKey = requestContext.getStore()?.apiKey; + return contextKey ?? CURRENTS_API_KEY; +} diff --git a/mcp-server/src/lib/request.ts b/mcp-server/src/lib/request.ts index b0ade34..6f583e0 100644 --- a/mcp-server/src/lib/request.ts +++ b/mcp-server/src/lib/request.ts @@ -1,4 +1,5 @@ -import { CURRENTS_API_KEY, CURRENTS_API_URL } from "./env.js"; +import { getApiKey } from "./context.js"; +import { CURRENTS_API_URL } from "./env.js"; import { logger } from "./logger.js"; const USER_AGENT = "currents-app/1.0"; @@ -13,7 +14,7 @@ export async function fetchApi(path: string): Promise { const headers = { "User-Agent": USER_AGENT, Accept: "application/json", - Authorization: "Bearer " + CURRENTS_API_KEY, + Authorization: "Bearer " + getApiKey(), }; try { @@ -35,7 +36,7 @@ export async function postApi(path: string, body: B): Promise { "User-Agent": USER_AGENT, Accept: "application/json", "Content-Type": "application/json", - Authorization: "Bearer " + CURRENTS_API_KEY, + Authorization: "Bearer " + getApiKey(), }; try { @@ -68,7 +69,7 @@ export async function putApi(path: string, body?: B): Promise { "User-Agent": USER_AGENT, Accept: "application/json", "Content-Type": "application/json", - Authorization: "Bearer " + CURRENTS_API_KEY, + Authorization: "Bearer " + getApiKey(), }; try { @@ -93,7 +94,7 @@ export async function deleteApi(path: string): Promise { const headers = { "User-Agent": USER_AGENT, Accept: "application/json", - Authorization: "Bearer " + CURRENTS_API_KEY, + Authorization: "Bearer " + getApiKey(), }; try { diff --git a/mcp-server/src/server.test.ts b/mcp-server/src/server.test.ts index 49f740b..8a895d5 100644 --- a/mcp-server/src/server.test.ts +++ b/mcp-server/src/server.test.ts @@ -22,8 +22,10 @@ vi.mock("@modelcontextprotocol/sdk/server/stdio.js", () => ({ StdioServerTransport: class {}, })); -// Triggers all registerTool calls at module scope -import "./server.js"; +// Building the server triggers all registerTool calls +import { createMcpServer } from "./server.js"; + +createMcpServer(); // SEP-986 (Final): tool names SHOULD be 1–64 characters const TOOL_NAME_MAX_LENGTH = 64; diff --git a/mcp-server/src/server.ts b/mcp-server/src/server.ts index 5793053..01f9d8a 100644 --- a/mcp-server/src/server.ts +++ b/mcp-server/src/server.ts @@ -59,20 +59,28 @@ declare const __VERSION__: string; const logoBase64 = __LOGO_BASE64__; const version = __VERSION__; -const server = new McpServer({ - name: "currents", - version, - icons: [ - { - src: `data:image/png;base64,${logoBase64}`, - mimeType: "image/png", - sizes: ["256x256", "128x128", "64x64", "32x32", "16x16"], - }, - ], -}); - -// Actions API tools -server.registerTool( +/** + * Builds a fully configured MCP server with all Currents tools registered. + * + * A factory (rather than a shared singleton) lets the stdio transport create + * one instance and the stateless HTTP transport create a fresh instance per + * request, keeping the tool/api layer identical across both. + */ +export function createMcpServer(): McpServer { + const server = new McpServer({ + name: "currents", + version, + icons: [ + { + src: `data:image/png;base64,${logoBase64}`, + mimeType: "image/png", + sizes: ["256x256", "128x128", "64x64", "32x32", "16x16"], + }, + ], + }); + + // Actions API tools + server.registerTool( "currents-list-actions", { description: @@ -461,12 +469,16 @@ server.registerTool( deleteWebhookTool.handler, ); + return server; +} + /** Starts the MCP server over stdio (used by the CLI and programmatic embedders). */ export async function startMcpServer(): Promise { if (!CURRENTS_API_KEY) { throw new Error(MISSING_CURRENTS_API_KEY_MESSAGE); } + const server = createMcpServer(); const transport = new StdioServerTransport(); await server.connect(transport); logger.debug("🚀 Currents MCP Server is live"); diff --git a/mcp-server/tsdown.config.ts b/mcp-server/tsdown.config.ts index bb225a5..21942fa 100644 --- a/mcp-server/tsdown.config.ts +++ b/mcp-server/tsdown.config.ts @@ -7,6 +7,7 @@ const { version } = JSON.parse(readFileSync("./package.json", "utf-8")); export default defineConfig({ entry: { index: "./src/index.ts", + http: "./src/http.ts", api: "./src/api.ts", }, format: ["esm", "cjs"],