Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
22 changes: 22 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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
31 changes: 31 additions & 0 deletions .github/workflows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<org>/currents-mcp:<version>` — immutable semver (e.g. `2.3.3`)
- `ghcr.io/<org>/currents-mcp:<channel>` — 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.
Expand Down
81 changes: 81 additions & 0 deletions .github/workflows/publish.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ permissions:
id-token: write
contents: write
pull-requests: write
packages: write
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

jobs:
publish:
Expand Down Expand Up @@ -90,3 +91,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}"

16 changes: 12 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,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

ENTRYPOINT ["node", "dist/index.mjs"]
# Default API URL environment variable (must include the /v1 path segment)
ENV CURRENTS_API_URL=https://api.currents.dev/v1

# 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"]
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <your-currents-api-key>
```

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.
Expand Down
1 change: 1 addition & 0 deletions mcp-server/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 6 additions & 2 deletions mcp-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
85 changes: 85 additions & 0 deletions mcp-server/src/http.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {};
global.fetch = vi.fn().mockImplementation((_url, init: RequestInit) => {
const auth = (init.headers as Record<string, string>).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");
});
});
Loading
Loading