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
43 changes: 43 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: ci

on:
push:
branches: [main]
pull_request:
branches: [main]

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

jobs:
typescript:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm --filter @keeperhub/mcp-client build
- run: pnpm --filter @keeperhub/mcp-client test
- run: pnpm --filter @keeperhub/mcp-client type-check

python:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9", "3.12"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install and test
working-directory: python
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
pytest -q
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Both clients implement the same kernel:

## Status

Early development. Neither package is published yet. The surface is still stabilizing.
**v0.1.0** — MCP HTTP transport implemented (session bootstrap, `tools/call`, 401/404 re-init, API key helpers). Ready for first publish to npm and PyPI. See [`.github/workflows`](.github/workflows) for release tags (`npm-v*`, `py-v*`).

## Where the plugins live

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
},
"scripts": {
"build": "pnpm -r build",
"test": "pnpm -r test",
"test": "pnpm -r test && pnpm run test:python",
"test:python": "cd python && python -m pip install -q -e \".[dev]\" && python -m pytest -q",
"type-check": "pnpm -r type-check"
},
"engines": {
Expand Down
19 changes: 19 additions & 0 deletions packages/mcp-client/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/

Copyright 2026 KeeperHub

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

The full Apache License 2.0 text is available at the URL above.
38 changes: 38 additions & 0 deletions packages/mcp-client/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# @keeperhub/mcp-client

Official TypeScript client for the KeeperHub MCP HTTP endpoint (`https://app.keeperhub.com/mcp`).

## Features

- Lazy MCP session (`initialize` + `mcp-session-id`)
- `tools/call` with JSON parsing of `content[0].text`
- Automatic re-init on **401** and **404** session expiry
- API key resolution (`KH_API_KEY`, `KEEPERHUB_API_KEY`)
- Key types: `kh_` (organization — MCP/REST) vs `wfb_` (webhooks only)

## Usage

```ts
import { getClient, resolveApiKey } from "@keeperhub/mcp-client";

const apiKey = resolveApiKey({ env: process.env });
if (!apiKey) throw new Error("KH_API_KEY not set");

const client = getClient(apiKey, {
clientInfo: { name: "my-plugin", version: "1.0.0" },
});

const workflows = await client.callTool("list_workflows", {});
```

## Develop

```bash
pnpm install
pnpm --filter @keeperhub/mcp-client build
pnpm --filter @keeperhub/mcp-client test
```

## License

Apache-2.0
16 changes: 6 additions & 10 deletions packages/mcp-client/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
{
"name": "@keeperhub/mcp-client",
"version": "0.0.0",
"version": "0.1.0",
"description": "Shared MCP client foundation for KeeperHub agent-framework adapters.",
"author": "KeeperHub",
"keywords": ["keeperhub", "mcp", "model-context-protocol", "workflow", "web3"],
"license": "Apache-2.0",
"type": "module",
"engines": {
Expand All @@ -17,11 +19,7 @@
"require": "./dist/index.cjs"
}
},
"files": [
"dist",
"README.md",
"LICENSE"
],
"files": ["dist", "README.md", "LICENSE"],
"publishConfig": {
"access": "public"
},
Expand All @@ -38,10 +36,8 @@
"build": "tsup",
"test": "vitest run",
"test:watch": "vitest",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@modelcontextprotocol/sdk": "1.29.0"
"type-check": "tsc --noEmit",
"prepublishOnly": "npm run build"
},
"devDependencies": {
"@types/node": "24.1.0",
Expand Down
217 changes: 217 additions & 0 deletions packages/mcp-client/src/__tests__/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";

import {
__resetClientForTests,
getClient,
KeeperHubMcpClient,
} from "../client";
import {
classifyApiKey,
getApiKeyKindWarning,
maskApiKey,
resolveApiKey,
validateApiKeyForMcp,
} from "../keys";

type FetchInit = RequestInit & { headers?: Record<string, string> };

function jsonResponse(
body: unknown,
init: { status?: number; sessionId?: string } = {},
): Response {
const headers = new Headers({ "content-type": "application/json" });
if (init.sessionId) headers.set("mcp-session-id", init.sessionId);
return new Response(JSON.stringify(body), {
status: init.status ?? 200,
headers,
});
}

function textResponse(
body: string,
init: { status?: number; sessionId?: string } = {},
): Response {
const headers = new Headers({ "content-type": "text/plain" });
if (init.sessionId) headers.set("mcp-session-id", init.sessionId);
return new Response(body, {
status: init.status ?? 200,
headers,
});
}

function captureFetch(responses: Array<() => Response>) {
const calls: Array<{ url: string; init: FetchInit; body: unknown }> = [];
let i = 0;
const fn = (async (url: RequestInfo | URL, init?: RequestInit) => {
const next = responses[i++];
if (!next) {
throw new Error(`Unexpected fetch call #${i} to ${String(url)}`);
}
let parsedBody: unknown;
if (init?.body && typeof init.body === "string") {
try {
parsedBody = JSON.parse(init.body);
} catch {
parsedBody = init.body;
}
}
calls.push({ url: String(url), init: (init ?? {}) as FetchInit, body: parsedBody });
return next();
}) as typeof fetch;
return { fn, calls };
}

describe("keys", () => {
it("classifies kh_ and wfb_ prefixes", () => {
expect(classifyApiKey("kh_abc")).toBe("org");
expect(classifyApiKey("wfb_abc")).toBe("webhook");
expect(classifyApiKey("other")).toBe("unknown");
});

it("rejects wfb_ keys for MCP", () => {
expect(getApiKeyKindWarning("wfb_test")).toContain("wfb_");
expect(() => validateApiKeyForMcp("wfb_test")).toThrow(/not interchangeable/);
});

it("resolves pluginConfig before env", () => {
expect(
resolveApiKey({
pluginConfig: { apiKey: "kh_from_config" },
env: { KH_API_KEY: "kh_from_env" },
}),
).toBe("kh_from_config");
});

it("masks api keys for display", () => {
expect(maskApiKey("kh_supersecret_value_123")).toBe("kh_s…_123");
});
});

describe("KeeperHubMcpClient", () => {
const silentLogger = {
warn: () => undefined,
info: () => undefined,
error: () => undefined,
debug: () => undefined,
};

beforeEach(() => __resetClientForTests());
afterEach(() => __resetClientForTests());

it("opens a session via initialize and uses the session id on tools/call", async () => {
const { fn, calls } = captureFetch([
() =>
jsonResponse({ result: { protocolVersion: "2024-11-05" } }, { sessionId: "sess-1" }),
() =>
jsonResponse({
result: {
content: [{ type: "text", text: JSON.stringify([{ id: "wf-1", name: "Demo" }]) }],
},
}),
]);

const client = new KeeperHubMcpClient({
apiKey: "kh_test",
logger: silentLogger,
fetchFn: fn,
});
const result = await client.callTool("list_workflows", {});

expect(result).toEqual([{ id: "wf-1", name: "Demo" }]);
expect(calls).toHaveLength(2);
expect((calls[0]!.body as { method: string }).method).toBe("initialize");
expect((calls[1]!.body as { method: string }).method).toBe("tools/call");
});

it("re-initializes on 401", async () => {
const { fn, calls } = captureFetch([
() => jsonResponse({ result: {} }, { sessionId: "sess-old" }),
() => jsonResponse({ error: "unauthorized" }, { status: 401 }),
() => jsonResponse({ result: {} }, { sessionId: "sess-new" }),
() =>
jsonResponse({
result: { content: [{ type: "text", text: '"ok"' }] },
}),
]);

const client = new KeeperHubMcpClient({
apiKey: "kh_test",
logger: silentLogger,
fetchFn: fn,
});
expect(await client.callTool("list_workflows", {})).toBe("ok");
expect(calls).toHaveLength(4);
});

it("re-initializes on 404 when the body mentions session", async () => {
const { fn } = captureFetch([
() => jsonResponse({ result: {} }, { sessionId: "sess-old" }),
() => textResponse("session expired", { status: 404 }),
() => jsonResponse({ result: {} }, { sessionId: "sess-new" }),
() =>
jsonResponse({
result: { content: [{ type: "text", text: '{"status":"queued"}' }] },
}),
]);

const client = new KeeperHubMcpClient({
apiKey: "kh_test",
logger: silentLogger,
fetchFn: fn,
});
expect(await client.callTool("execute_workflow", { workflowId: "wf-1" })).toEqual({
status: "queued",
});
});

it("does not re-initialize on unrelated 404", async () => {
const { fn } = captureFetch([
() => jsonResponse({ result: {} }, { sessionId: "sess-old" }),
() => textResponse("workflow not found", { status: 404 }),
]);

const client = new KeeperHubMcpClient({
apiKey: "kh_test",
logger: silentLogger,
fetchFn: fn,
});
await expect(
client.callTool("get_workflow", { workflowId: "missing" }),
).rejects.toThrow(/404/);
});

it("rejects empty api key", () => {
expect(
() =>
new KeeperHubMcpClient({
apiKey: "",
logger: silentLogger,
}),
).toThrow(/non-empty apiKey/);
});

it("rejects wfb_ keys at construction", () => {
expect(
() =>
new KeeperHubMcpClient({
apiKey: "wfb_webhook_only",
logger: silentLogger,
}),
).toThrow(/wfb_/);
});
});

describe("getClient singleton", () => {
beforeEach(() => __resetClientForTests());
afterEach(() => __resetClientForTests());

it("returns the same instance for the same api key", () => {
expect(getClient("kh_one")).toBe(getClient("kh_one"));
});

it("replaces the instance when the api key changes", () => {
const a = getClient("kh_one");
const b = getClient("kh_two");
expect(a).not.toBe(b);
});
});
Loading
Loading