Skip to content
Open
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
76 changes: 76 additions & 0 deletions .github/workflows/integration-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
name: Integration Tests

on:
push:
branches: [main]
pull_request:
workflow_dispatch:
inputs:
node-version:
description: 'Node.js version'
required: true
type: choice
default: 'all'
options:
- 'all'
- '22'
- '24'
- '26'

jobs:
generate-node-version-matrix:
name: Generate Node Version Matrix
runs-on: ubuntu-latest
outputs:
node-versions: ${{ steps.set-node-versions.outputs.node-versions }}

steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3

- name: Set Node versions
id: set-node-versions
env:
NODE_VER: ${{ github.event.inputs.node-version }}
run: |

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Misleading action version comments (security/audit concern)

All three pinned actions have version comments that do not match any released version:

Action Comment claims Actual latest
actions/checkout # v6.0.3 v4.x
actions/setup-node # v6.4.0 v4.x
actions/upload-artifact # v7.0.1 v4.x

Pinning to a commit hash is the right security practice — the hash is what GitHub actually runs. But when the human-readable version comment is wrong by 2–3 major versions, auditors relying on the comment to verify the pin will draw false conclusions about what is deployed. Please verify each hash against the correct tag and update the comments to match:

# Example: verify checkout hash
gh api repos/actions/checkout/git/ref/tags/v4.2.2 --jq .object.sha

if [ "$NODE_VER" == "all" ] || [ -z "$NODE_VER" ]; then
echo "node-versions=[22, 24, 26]" >> $GITHUB_OUTPUT
else
echo "node-versions=[$NODE_VER]" >> $GITHUB_OUTPUT
fi

integration-tests:
name: Integration Tests (Node ${{ matrix.node-version }})
needs: [generate-node-version-matrix]
runs-on: ubuntu-latest

strategy:
fail-fast: false
matrix:
node-version: ${{ fromJSON(needs.generate-node-version-matrix.outputs.node-versions) }}

steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3

- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: ${{ matrix.node-version }}

- name: Install dependencies
run: npm ci

- name: Run integration tests
run: npm run test:integration
env:
HARPER_INTEGRATION_TEST_LOG_DIR: /tmp/harper-test-logs
FORCE_COLOR: '1'

- name: Upload Harper logs on failure
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: harper-logs-node-${{ matrix.node-version }}
path: /tmp/harper-test-logs/
retention-days: 7
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Harper MCP Server

A server implementation of the [Model Context Protocol (MCP)](https://github.com/modelcontextprotocol), designed to expose data in HarperDB as structured "Resources" accessible via standardized JSON-RPC calls.
A server implementation of the [Model Context Protocol (MCP)](https://github.com/modelcontextprotocol), designed to expose data in Harper as structured "Resources" accessible via standardized JSON-RPC calls.

> **Note:** Requires HarperDB version 4.5.10 or later.
> **Note:** Requires Harper version 5 or later.

## What is Harper
[Harper](https://www.harpersystems.dev/) is a Composable Application Platform that merges database, cache, app logic, and messaging into a single runtime. Components like this plug directly into Harper, letting you build and scale distributed services fast, without managing separate systems. Built for geo-distributed apps with low latency and high uptime by default.
Expand All @@ -25,12 +25,12 @@ A server implementation of the [Model Context Protocol (MCP)](https://github.com
### Prerequisites

- [Harper](https://docs.harperdb.io/docs/deployments/install-harperdb/) stack installed globally.
- Ensure HarperDB v4.5.10 or later is configured and running with necessary databases and schemas.
- Ensure Harper v5 or later is configured and running with necessary databases and schemas.
- Environment variable `HOST` should be set to the base URL of your server. This is used to construct resource URIs.

### Deploying to Harper

The Harper `mcp-server` is published to NPM and can be installed using [Harper's Operation API](https://docs.harperdb.io/docs/developers/operations-api/components).
The Harper `mcp-server` is published to npm and can be installed using [Harper's Operation API](https://docs.harperdb.io/docs/developers/operations-api/components).

i.e.

Expand All @@ -45,7 +45,7 @@ i.e.

## Security & Authentication

Harper employs role-based, attribute-level security to ensure users access only authorized data. Requests to the server are authenticated using HarperDB's built-in authentication mechanisms, which include Basic Auth, JWT, and mTLS.
Harper employs role-based, attribute-level security to ensure users access only authorized data. Requests to the server are authenticated using Harper's built-in authentication mechanisms, which include Basic Auth, JWT, and mTLS.
See [Harper Security Docs](https://docs.harperdb.io/docs/developers/security/) for more details.

## API
Expand All @@ -54,7 +54,7 @@ See [Harper Security Docs](https://docs.harperdb.io/docs/developers/security/) f

The server implements the following MCP methods:

- **`resources/list`**: Lists all available resources (HarperDB tables and custom resources).
- **`resources/list`**: Lists all available resources (Harper tables and custom resources).
- **`resources/read`**: Retrieves data for a specific resource based on its URI.

A single endpoint, `/mcp` handles all requests. The server uses JSON-RPC 2.0 for communication.
Expand All @@ -65,7 +65,7 @@ A single endpoint, `/mcp` handles all requests. The server uses JSON-RPC 2.0 for

### Resource URIs

- **Tables:** Resources representing HarperDB tables are accessed via URIs like:
- **Tables:** Resources representing Harper tables are accessed via URIs like:

```
{HOST}/{table_name}
Expand Down
2 changes: 1 addition & 1 deletion dist/resources/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Resource } from 'harperdb';
import { Resource } from 'harper';
import { resourceList } from './mcpResources/resourceList.js';
import { resourceRead } from './mcpResources/resourceRead.js';
import { MCPMethods } from '../constants/index.js';
Expand Down
2 changes: 1 addition & 1 deletion dist/resources/mcpResources/resourceList.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { server } from 'harperdb';
import { server } from 'harper';
export const resourceList = () => {
const resources = [];
for (const harperResource of server.resources.values()) {
Expand Down
2 changes: 1 addition & 1 deletion dist/resources/mcpResources/resourceRead.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { server, logger } from 'harperdb';
import { server, logger } from 'harper';
export const resourceRead = async (params) => {
try {
if (!params?.uri) {
Expand Down
114 changes: 114 additions & 0 deletions integrationTests/mcp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { suite, test, before, after } from 'node:test';
import { strictEqual, ok, deepStrictEqual } from 'node:assert/strict';

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleanup: deepStrictEqual is imported but never used

deepStrictEqual is destructured from node:assert/strict on line 2 but is not called anywhere in the file. Remove it to keep imports clean and avoid lint noise.

// Before
import { strictEqual, ok, deepStrictEqual } from "node:assert/strict";

// After
import { strictEqual, ok } from "node:assert/strict";

import { setupHarperWithFixture, teardownHarper, type ContextWithHarper } from '@harperfast/integration-testing';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import { createRequire } from 'node:module';

const __dirname = dirname(fileURLToPath(import.meta.url));
const FIXTURE_PATH = resolve(__dirname, '..');

// harper's `exports` only exposes ".", so 'harper/dist/bin/harper.js' is not resolvable.
// Resolve the CLI from the exported main entry and pass it explicitly.
const require = createRequire(import.meta.url);
const harperBinPath = resolve(dirname(require.resolve('harper')), 'bin/harper.js');

function authFetch(
ctx: ContextWithHarper,
path: string,
init: RequestInit & { headers?: Record<string, string> } = {}
) {
const { headers = {}, ...rest } = init;
const creds = Buffer.from(`${ctx.harper.admin.username}:${ctx.harper.admin.password}`).toString('base64');
return fetch(`${ctx.harper.httpURL}${path}`, {
...rest,
headers: { Authorization: `Basic ${creds}`, ...headers },
});
}

function mcpPost(ctx: ContextWithHarper, body: Record<string, unknown>) {
return authFetch(ctx, '/mcp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}

void suite('MCP Server', (ctx: ContextWithHarper) => {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: parseInt NaN bypasses the ?? 250 default limit, sending NaN to Resource.get()

In resourceRead.ts, limit and start are parsed with parseInt(value, 10). When value is not a valid integer (e.g., ?limit=abc or ?limit=), parseInt returns NaN. The nullish-coalescing operator ?? 250 only guards against null/undefinedNaN passes through unchanged:

parseInt("abc", 10)   // NaN
NaN ?? 250             // NaN  (not 250!)

Resource.get({ limit: NaN, offset: NaN }) receives an invalid numeric argument. Depending on Harpers internal handling, this could result in a full-table scan (unbounded query), a runtime error, or silent query failure.

Fix: Use Number.isFinite to validate before passing:

limit = Number.isFinite(parsed) ? parsed : undefined;

or inline at the call site: limit: Number.isFinite(limit) ? limit : 250.

before(async () => {
await setupHarperWithFixture(ctx, FIXTURE_PATH, { harperBinPath });
});

after(async () => {
await teardownHarper(ctx);
});

void test('Harper starts successfully', async () => {
const res = await authFetch(ctx, '/');
ok([200, 400, 404].includes(res.status), `Unexpected status ${res.status}`);
});

void test('POST /mcp with resources/list returns resources array', async () => {
const res = await mcpPost(ctx, {
jsonrpc: '2.0',
id: 1,
method: 'resources/list',
});
strictEqual(res.status, 200);
const body = (await res.json()) as Record<string, unknown>;
strictEqual(body.jsonrpc, '2.0');
strictEqual(body.id, 1);
ok(body.result !== undefined, 'expected result field');
const result = body.result as Record<string, unknown>;
ok(Array.isArray(result.resources), 'expected resources array');
});

void test('POST /mcp with resources/read returns error when uri missing', async () => {
const res = await mcpPost(ctx, {
jsonrpc: '2.0',
id: 2,
method: 'resources/read',
params: {},
});
strictEqual(res.status, 200);
const body = (await res.json()) as Record<string, unknown>;
strictEqual(body.jsonrpc, '2.0');
ok(body.error !== undefined, 'expected error for missing uri');
const error = body.error as Record<string, unknown>;
strictEqual(error.code, -32602);
});

void test('POST /mcp with unknown method returns method-not-found error', async () => {
const res = await mcpPost(ctx, {
jsonrpc: '2.0',
id: 3,
method: 'tools/call',
});
strictEqual(res.status, 200);
const body = (await res.json()) as Record<string, unknown>;
strictEqual(body.jsonrpc, '2.0');
ok(body.error !== undefined, 'expected error for unknown method');
const error = body.error as Record<string, unknown>;
strictEqual(error.code, -32601);
});

void test('POST /mcp with resources/read and valid uri returns contents', async () => {
// Provide a URI that points to the mcp endpoint itself (any valid resource path)
// The server returns empty contents for an unmatched path — that is valid behavior.
const res = await mcpPost(ctx, {
jsonrpc: '2.0',
id: 4,
method: 'resources/read',
params: { uri: `${ctx.harper.httpURL}/nonexistent` },
});
strictEqual(res.status, 200);
const body = (await res.json()) as Record<string, unknown>;
strictEqual(body.jsonrpc, '2.0');
// Either a result with contents or an error — both are valid outcomes
ok(body.result !== undefined || body.error !== undefined, 'expected result or error');
if (body.result) {
const result = body.result as Record<string, unknown>;
ok(Array.isArray(result.contents), 'expected contents array');
}
});
});
Loading