Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
829 changes: 829 additions & 0 deletions docs/mcp/implementation-blueprint.md

Large diffs are not rendered by default.

537 changes: 537 additions & 0 deletions docs/mcp/spec.md

Large diffs are not rendered by default.

102 changes: 102 additions & 0 deletions packages/mcp-server/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# @accounter/mcp-server

Remote MCP (Model Context Protocol) server that exposes a curated, **read-only** subset of Accounter
capabilities to Claude clients (Claude.ai / Claude Desktop).

See the design docs:

- [`docs/mcp/spec.md`](../../docs/mcp/spec.md) — connector specification
- [`docs/mcp/implementation-blueprint.md`](../../docs/mcp/implementation-blueprint.md) — incremental
implementation plan

## Status

Early scaffolding. This package is being built incrementally following the prompt pack in the
implementation blueprint. It currently contains the package skeleton, strict environment
configuration, a minimal HTTP server with a `/health` endpoint and graceful shutdown, an MCP
transport route (`POST /mcp`) that speaks JSON-RPC 2.0 and lists an internal smoke tool, per-request
structured logging with request/correlation ids, the OAuth protected-resource metadata endpoint, and
Auth0 bearer-token verification on the MCP endpoint. Fine-grained authorization and production tools
are not implemented yet.

## OAuth discovery

`GET /.well-known/oauth-protected-resource` serves an
[RFC 9728](https://www.rfc-editor.org/rfc/rfc9728) protected-resource metadata document so Claude
clients can discover the authorization server:

```json
{
"resource": "<MCP_PUBLIC_BASE_URL>",
"authorization_servers": ["<AUTH0_ISSUER_URL>"],
"bearer_methods_supported": ["header"]
}
```

The document is fully config-driven (no hardcoded URLs). `bearer_methods_supported: ["header"]`
signals that tokens are accepted only via the `Authorization` header, never query params.

## Observability

Every request is assigned a `requestId` and a `correlationId` (the latter inherited from an inbound
`X-Correlation-Id` header when present, otherwise generated). The correlation id is echoed back on
the response. Structured JSON logs are emitted at request start and completion, carrying
`requestId`, `correlationId`, `method`, `route`, and — on completion — `status` and `latencyMs`.
Secrets and authorization headers are never logged.

## Running locally

```bash
# with required env vars set (see Configuration below):
yarn workspace @accounter/mcp-server dev
curl http://localhost:3100/health
# → {"status":"ok","service":"@accounter/mcp-server","version":"…","uptimeSeconds":…}
```

The server handles `SIGINT`/`SIGTERM` by closing connections and exiting cleanly (forcing exit after
a grace period).

### MCP endpoint

The transport lives at `POST /mcp` and accepts JSON-RPC 2.0. Requests **must** carry a valid Auth0
bearer token in the `Authorization` header. The token is verified (signature via the tenant JWKS,
plus `issuer`, `audience`, and expiry). A request with no token gets a `401` pointing at the
protected-resource metadata document; a request with an invalid/expired token gets a `401` with
`error="invalid_token"`. Supported methods: `initialize`, `ping`, `tools/list`, and `tools/call`
(for the internal `accounter_smoke_ping` tool). Unknown methods return a deterministic JSON-RPC
`-32601` error; notifications receive `202 Accepted` with no body.

```bash
curl -sX POST http://localhost:3100/mcp -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <token>' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

## Configuration

Environment variables are validated at startup with a strict schema
([`src/config/env.ts`](src/config/env.ts)). Missing required variables or malformed values cause the
process to exit immediately with a clear error. Secrets are supplied via the environment only.

| Variable | Required | Default | Description |
| ----------------------------- | -------- | ------------------- | --------------------------------------------------------------- |
| `MCP_PUBLIC_BASE_URL` | yes | — | Public HTTPS origin of this MCP server (used in OAuth metadata) |
| `AUTH0_ISSUER_URL` | yes | — | Auth0 issuer/tenant URL used to validate access tokens |
| `AUTH0_AUDIENCE` | yes | — | Expected `aud` claim for incoming access tokens |
| `GRAPHQL_UPSTREAM_URL` | yes | — | Base URL of the Accounter GraphQL server the tools call |
| `MCP_SERVER_PORT` | no | `3100` | TCP port the HTTP transport listens on |
| `MCP_ENABLED` | no | `1` | Master kill-switch (`1` on / `0` off) |
| `MCP_TOOL_ALLOWLIST` | no | `''` (none) | Comma-separated tool names allowed (empty = least privilege) |
| `AUTH0_JWKS_URL` | no | derived from issuer | JWKS endpoint; defaults to `<issuer>/.well-known/jwks.json` |
| `GRAPHQL_UPSTREAM_TIMEOUT_MS` | no | `10000` | Upstream GraphQL request timeout budget (ms) |
| `MCP_RATE_LIMIT_CONFIG` | no | `''` (defaults) | Optional rate-limit override spec (parsed by the limiter later) |

## Scripts

```bash
yarn workspace @accounter/mcp-server build # tsc → dist/
yarn workspace @accounter/mcp-server dev # run entrypoint with tsx (watch)
yarn workspace @accounter/mcp-server lint # eslint
yarn workspace @accounter/mcp-server test # vitest (package-scoped)
yarn workspace @accounter/mcp-server typecheck # tsc --noEmit
```
52 changes: 52 additions & 0 deletions packages/mcp-server/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{
"name": "@accounter/mcp-server",
"version": "0.0.0",
"type": "module",
"description": "Remote MCP (Model Context Protocol) server exposing a curated, read-only subset of Accounter capabilities to Claude clients",
"repository": {
"type": "git",
"url": "git+https://github.com/Urigo/accounter-fullstack.git",
"directory": "packages/mcp-server"
},
"homepage": "https://github.com/Urigo/accounter-fullstack/tree/main/packages/mcp-server#readme",
"bugs": {
"url": "https://github.com/Urigo/accounter-fullstack/issues"
},
"author": "Gil Gardosh <gilgardosh@gmail.com>",
"license": "MIT",
"private": true,
"engines": {
"node": "26.5.0"
},
"main": "dist/index.js",
"module": "dist/index.js",
"files": [
"dist"
],
"keywords": [
"mcp",
"model-context-protocol",
"connector",
"accounter"
],
"scripts": {
"build": "tsc",
"dev": "tsx watch src/index.ts",
"lint": "eslint './src/**/*.{js,ts,tsx}' --quiet",
"start": "node dist/index.js",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"dotenv": "17.4.2",
"jose": "6.2.3",
"zod": "4.4.3"
},
"devDependencies": {
"@types/node": "25.9.5",
"tsx": "4.23.1",
"typescript": "6.0.3",
"vitest": "4.1.10"
}
}
58 changes: 58 additions & 0 deletions packages/mcp-server/src/__tests__/context.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { IncomingMessage } from 'node:http';
import { describe, expect, it } from 'vitest';
import {
createRequestContext,
elapsedMs,
getRequestContext,
setRequestContext,
} from '../context.js';

function req(overrides: Partial<IncomingMessage> = {}): IncomingMessage {
return { headers: {}, method: 'GET', url: '/health', ...overrides } as unknown as IncomingMessage;
}

describe('createRequestContext', () => {
it('mints a request id and a correlation id', () => {
const ctx = createRequestContext(req());
expect(ctx.requestId).toMatch(/[0-9a-f-]{36}/);
expect(ctx.correlationId).toMatch(/[0-9a-f-]{36}/);
expect(ctx.requestId).not.toBe(ctx.correlationId);
});

it('inherits the correlation id from the inbound header', () => {
const ctx = createRequestContext(req({ headers: { 'x-correlation-id': 'trace-abc' } }));
expect(ctx.correlationId).toBe('trace-abc');
});

it('captures method and route without the query string', () => {
const ctx = createRequestContext(req({ method: 'POST', url: '/mcp?token=secret' }));
expect(ctx.method).toBe('POST');
expect(ctx.route).toBe('/mcp');
});

it('does not throw on a malformed URL, falling back to a path split', () => {
// `//` cannot be parsed relative to the placeholder host and throws in
// `new URL`; context creation must still succeed.
const ctx = createRequestContext(req({ url: '//?x=1' }));
expect(ctx.route).toBe('//');
});

it('measures elapsed time as a non-negative integer', () => {
const ctx = createRequestContext(req());
expect(elapsedMs(ctx)).toBeGreaterThanOrEqual(0);
expect(Number.isInteger(elapsedMs(ctx))).toBe(true);
});
});

describe('request context store', () => {
it('associates and retrieves a context per request', () => {
const request = req();
const ctx = createRequestContext(request);
setRequestContext(request, ctx);
expect(getRequestContext(request)).toBe(ctx);
});

it('returns undefined for a request with no stored context', () => {
expect(getRequestContext(req())).toBeUndefined();
});
});
16 changes: 16 additions & 0 deletions packages/mcp-server/src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { installProcessErrorHandlers, main, PACKAGE_NAME, start } from '../index.js';

describe('mcp-server entrypoint', () => {
it('exposes the package name', () => {
expect(PACKAGE_NAME).toBe('@accounter/mcp-server');
});

it('exports the startup surface', () => {
// `main`/`start` bind a port and are covered via server tests; here we only
// assert the entrypoint surface is wired up.
expect(typeof main).toBe('function');
expect(typeof start).toBe('function');
expect(typeof installProcessErrorHandlers).toBe('function');
});
});
88 changes: 88 additions & 0 deletions packages/mcp-server/src/__tests__/logger.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { RequestContext } from '../context.js';
import { completionFields, contextFields, createRequestLogger, log } from '../logger.js';

const ctx: RequestContext = {
requestId: 'req-1',
correlationId: 'corr-1',
method: 'POST',
route: '/mcp',
startTimeMs: performance.now(),
};

describe('log', () => {
afterEach(() => {
vi.restoreAllMocks();
});

it('writes a single-line JSON entry to console.log for non-error levels', () => {
const spy = vi.spyOn(console, 'log').mockImplementation(() => {});
log('info', 'hello', { a: 1 });
const entry = JSON.parse(spy.mock.calls[0][0] as string);
expect(entry).toMatchObject({ level: 'info', message: 'hello', a: 1 });
expect(typeof entry.timestamp).toBe('string');
});

it('routes error level to console.error', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
log('error', 'boom');
expect(spy).toHaveBeenCalledTimes(1);
});

it('does not throw on non-serializable fields, emitting a safe fallback', () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.spyOn(console, 'log').mockImplementation(() => {});

const circular: Record<string, unknown> = {};
circular.self = circular;

expect(() => log('info', 'with circular', { circular })).not.toThrow();
const fallback = JSON.parse(errorSpy.mock.calls[0][0] as string);
expect(fallback.message).toBe('failed to serialize log entry');
expect(fallback.originalMessage).toBe('with circular');
});
});

describe('createRequestLogger', () => {
afterEach(() => vi.restoreAllMocks());

it('merges context fields into every entry', () => {
const spy = vi.spyOn(console, 'log').mockImplementation(() => {});
const logger = createRequestLogger(ctx);
logger.info('hello', { extra: true });

const entry = JSON.parse(spy.mock.calls[0][0] as string);
expect(entry).toMatchObject({
requestId: 'req-1',
correlationId: 'corr-1',
method: 'POST',
route: '/mcp',
extra: true,
message: 'hello',
level: 'info',
});
});

it('routes error entries to console.error', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
createRequestLogger(ctx).error('boom');
expect(spy).toHaveBeenCalledTimes(1);
});
});

describe('field helpers', () => {
it('contextFields excludes timing internals', () => {
expect(contextFields(ctx)).toEqual({
requestId: 'req-1',
correlationId: 'corr-1',
method: 'POST',
route: '/mcp',
});
});

it('completionFields includes status and latency', () => {
const fields = completionFields(ctx, 200);
expect(fields.status).toBe(200);
expect(fields.latencyMs).toBeGreaterThanOrEqual(0);
});
});
Loading
Loading