diff --git a/.dev/sessions.md b/.dev/sessions.md index 061bafd0f..2a106db26 100644 --- a/.dev/sessions.md +++ b/.dev/sessions.md @@ -91,6 +91,13 @@ Newest first. - Added `description` as an optional catalogue config property (`configOptionalProperties.DESCRIPTION` in `modules/types`) — surfaces in both the root `/introspection` response and the per-catalogue `/introspection/:catalogId` response via conditional spread (key absent when not configured, not `undefined`) - Restructured `CatalogIntrospectionResponse`: removed `validOperators` from individual fields; added top-level `operators: Record` keyed by field type; `buildFieldOperators()` in `buildCatalogueIntrospection.ts` - Updated unit tests to match new shape; added coverage for `description` present/absent and `operators` deduplication +- Added `mcp-server` Docker target to both `docker/Dockerfile.local` and `docker/Dockerfile.jenkins`, mirroring the existing `server` target structure + - Both targets `COPY --from=scaffolding` the shared `node_modules` and `apps/mcp-server` source; no internal modules are copied because the MCP server is standalone (talks to Arranger over HTTP, not ES directly) + - CMD runs `node_modules/.bin/tsx ./apps/mcp-server/src/index.ts` (no shell-level pre-check; `validateArrangerConnection` runs in-app at startup) + - EXPOSE 3100 matches the `MCP_PORT` default +- Updated `docker/Dockerfile.jenkins` scaffolding stage to include `--workspace apps/mcp-server` in the `npm ci --omit=dev` install, so the jenkins production image installs the MCP server's runtime deps +- Moved `tsx` from `devDependencies` to `dependencies` in `apps/mcp-server/package.json` (load-bearing for the jenkins build, which runs `npm ci --omit=dev`; mirrors what `apps/search-server` already does) +- Updated both `.dockerignore` files (`Dockerfile.local.dockerignore`, `Dockerfile.jenkins.dockerignore`) to add `!apps/mcp-server`, allowing the folder through the build context **Decisions:** @@ -98,10 +105,15 @@ Newest first. - `buildFieldOperators` (not `buildTypeOperators`) — "field operators" is the established naming family in `modules/sqon` (`SqonFieldOp`, `SqonFieldOperatorDetail`, `getSqonFieldOperatorDetails`) - `description` on per-catalogue response too (not just root listing) — complete data at the endpoint; LLM context optimization is the MCP layer's responsibility - `getValidOperators` → `modules/sqon` consolidation is out of scope: requires redesigning `applicableTo` data in `getSqonFieldOperatorDetails` (range types incorrectly include `filter`, `some-not-in`, `all` at present); separate roadmap item +- MCP server target does NOT use a shell wrapper or pre-flight script. The app's own `validateArrangerConnection` handles the Arranger readiness check at startup — duplicating that at the shell level would be redundant. +- Kept tsx-from-source at runtime (not a tsc pre-build) to match `apps/search-server`'s pattern. Revisiting that for both apps together is a future concern. +- Did not modify `docker-compose.yml` — that's a separate "is the MCP server part of the dev stack?" decision. **Open threads:** - `getValidFieldOperators` → `modules/sqon` consolidation: follow-up when sqon consolidation roadmap item is picked up +- `package-lock.json` will need an `npm install` to reflect tsx moving sections in `apps/mcp-server/package.json`. +- `docker-compose.yml` does not include the MCP server. If the local Compose dev stack should boot MCP alongside server/ES/UI, that needs a follow-up (port 3100, depends_on server, ARRANGER_BASE_URL pointed at the `server` service). --- @@ -115,6 +127,12 @@ Newest first. - Added roadmap item: consolidate field-type-to-operator rules into `modules/sqon` - Fleshed out `sqon-builder` absorption into `modules/sqon` as a detailed roadmap item: what to keep (builder API, `reduceSQON`, filter manipulation, `from()`), what to fix (operator coverage gap — only `in`/`gt`/`lt` today), what to leave behind (the `& SQON` anti-pattern), and migration path - Added anchor links to all cross-references between `tech-debt.md` and `roadmap.md` +- Added `integration-tests/mcp-server` workspace with end-to-end tests for `apps/mcp-server` + - Spins up Arranger search-server in-process (multicatalog mode) with two test catalogs, then starts the MCP server pointed at it, then drives it over Streamable HTTP via the official MCP SDK Client + - Connection assertion is implicit: `validateArrangerConnection` runs before `app.listen`, so the suite reaching the test phase proves the MCP→Arranger contract works + - Test coverage: spinup/active (ping, capabilities, resource/tool listings), MCP resources (`arranger://introspection/server`, `arranger://introspection/sqon`, `arranger://introspection/catalog/{id}` via template), MCP tools (`list-catalogs`, `get-sqon-schema`, `get-catalog-fields` happy + 404 paths) + - 13 tests in 4 suites; runs against the same local ES used by `integration-tests/server` +- Added `integration-tests/mcp-server` to the root `package.json` workspaces list **Decisions:** @@ -123,11 +141,18 @@ Newest first. - Boolean values should be supported in SQON (not just string `"true"`); fix is additive — add `zod.boolean()` to `SqonScalarValueSchema`; confirmed this is an oversight, not deliberate - The `/introspection/fields` endpoint is the canonical LLM context source — the evaluation document should reference it specifically rather than "GraphQL introspection" - JSDoc/TSDoc should be added to functions and types as code is written or touched — not deferred to a documentation pass; inline docs are the safety net when `/docs` lags +- Backend: spin up search-server in-process (mirrors `integration-tests/server`), not external nor mocked — keeps the harness self-contained while exercising the real Arranger contract. +- Coverage: multicatalog only — exercises the catalog resource template and `list-catalogs` with >1 catalog. Single-catalog is a subset and not worth doubling runtime for. +- Catalog field introspection (`/introspection/{catalogId}`) reads from `catalogConfigs.extended`, which is empty in the existing `integration-tests/server` multiconfigs. New fixtures under `integration-tests/mcp-server/multiconfigs/` include populated `extended` arrays so the tests can assert real field metadata. +- Test files live under `test/` and the entry point is `test/index.test.ts`. Node 24's test runner auto-discovers `.ts` files in `test/`; node 20 does not, so this suite requires node 24+ (consistent with the project's `engines.node >= 20` but practically aligned with the dev shell setup). +- Lazy MCP client access via `getClient: () => Client` — `node:test` suite factories run at registration time, before `before()` hooks have populated state. Each test resolves the client when it actually runs. **Open threads:** - Boolean support in SQON schema: fix is clear but not yet implemented (two schema files, one in `sqon-builder`, one in `modules/sqon`) - `reduceSQON` extension for full operator set needs deliberate design (e.g. what does reducing two `between` ranges under `and` mean?) +- Single-catalog coverage is not exercised; can be added if MCP server adds single-catalog-specific behavior (currently it doesn't differentiate). +- Negative test for `validateArrangerConnection` failure on startup is covered by unit tests (`apps/mcp-server/src/arranger/validation.test.ts`); not duplicated as an integration test because the production startup path calls `process.exit(1)`, which is awkward to exercise in-process. --- diff --git a/apps/mcp-server/.env.schema b/apps/mcp-server/.env.schema new file mode 100644 index 000000000..6525e2843 --- /dev/null +++ b/apps/mcp-server/.env.schema @@ -0,0 +1,9 @@ +ARRANGER_BASE_URL=http://localhost:5050 +ARRANGER_CATALOGUES=server +ARRANGER_REQUEST_TIMEOUT_MS=10_000 + +MCP_HOST=0.0.0.0 +MCP_PORT=3100 +MCP_PATH=/mcp + +LOG_LEVEL=info diff --git a/apps/mcp-server/README.md b/apps/mcp-server/README.md index 05f9e5f8a..419b2c3a7 100644 --- a/apps/mcp-server/README.md +++ b/apps/mcp-server/README.md @@ -1,68 +1,153 @@ -# Arranger MCP Server Foundation +# Arranger MCP Server -This app is the starting point for an MCP server that learns how to talk to Arranger by consuming Arranger's introspection endpoints. +This app is an MCP server that learns how to talk to Arranger by consuming Arranger's introspection endpoints. -The current scaffold does not implement a full MCP transport or SDK binding yet. Instead, it defines: +The current scaffold implements the Streamable HTTP MCP transport using **v1.x** of the official [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk/tree/v1.x). -- how to read Arranger introspection -- how to model that information as MCP-friendly resources -- which MCP tools are natural to expose first +## Folder Structure -## Why this exists +```text +src/ +├── arranger/ +│ ├── client.ts # fetches Arranger introspection endpoints +│ ├── types.ts # response types for introspection payloads +│ └── validation.ts # validates the connection to Arranger +├── http/ +│ └── app.ts # MCP express app with Streamable HTTP transport +├── mcp/ +│ ├── resources.ts # registers MCP resources +│ └── tools.ts # registers MCP tools +├── utils/ +│ ├── config.ts # env/config parsing +│ ├── inMemoryEventStore.ts # in-memory storage util for dev +│ └── logger.ts # pino logger wrapper +├── index.ts # entrypoint for the application +└── server.ts # creates the MCP server +``` -The intended flow is: +## Quick Start -1. call Arranger's `/introspection` -2. call Arranger's `/introspection/sqon` -3. call Arranger's `/introspection/:catalogId` -4. expose those results to MCP clients as resources and tool-backed lookups +1. Install dependencies: -## Tools vs skills +```bash +# from project root +npm ci +``` -In this context: +2. Configure environment variables: -- **tools** are callable operations an MCP client can invoke, such as "list catalogs" or "get SQON schema" -- **resources** are readable documents the MCP client can fetch, such as the server introspection payload or a catalog field listing -- **skills** are not part of the MCP standard itself; they are higher-level agent behaviors or authored instructions layered on top of tools/resources +> [!NOTE] +> See [Configuration](#configuration) for more details. -For this scaffold, the important MCP building blocks are tools and resources. +```bash +# from apps/mcp-server +cp .env.schema .env +``` -## Folder layout +3. Build Arranger modules: -```text -src/ - arranger/ - client.ts # fetches Arranger introspection endpoints - types.ts # response types for introspection payloads - mcp/ - resources.ts # MCP-facing resource definitions backed by introspection - tools.ts # MCP-facing tool definitions backed by introspection - types.ts # simple MCP-ish types for the scaffold - config.ts # env/config parsing for the future MCP server - index.ts # composition entrypoint for the scaffold +```bash +# from project root +npm run modules:build ``` -## Intended first MCP features +4. (Optional) Ensure Elasticsearch and Arranger Server are running. -- `list_catalogs` -- `get_sqon_schema` -- `get_catalog_fields` +> [!NOTE] +> This is only necessary if you are developing against a local Arranger Server. See [Testing](#testing) for more details. -Those all map directly to already-implemented Arranger introspection endpoints. +```bash +# from project root +make start-es +ES_INDEX=file_centric DOCUMENT_TYPE=file CONFIGS_PATH=$(pwd)/docker/server npm run dev:server +``` -## Not implemented yet +5. Start the MCP Server: -- actual MCP SDK bootstrap -- stdin/stdout server transport -- authentication -- query execution tools -- SQON generation helpers beyond introspection exposure +```bash +# from project root +npm run mcp-server:dev +``` + +## Configuration + +Configuration of this application is done by providing [environment variables](#environment-variables) to the application at run time. + +> [!WARNING] +> If **required** environment variables are not available or misconfigured at run time, the application will shut down immediately. + +An example environment variables file is located at [`.env.schema`](./.env.schema). This example file lists all available configuration variables and is prepopulated with default values that should work to run the application locally. You can copy the contents of this file to populate a `.env`: + +```bash +# from apps/mcp-server +cp .env.schema .env +``` + +### Environment Variables + +| Name | Description | Type | Required | Default | +| -------------------------- | ----------------------------------------------------------------------- | -------- | ------------ | ----------------------- | +| `ARRANGER_BASE_URL` | URL for the Arranger Server | `string` | **Required** | `http://localhost:5050` | +| `ARRANGER_CATALOGUES` | Comma-separated list of Arranger catalogues to expose to the MCP Server | `string` | **Required** | `server` | +| `ARRANGER_REQUEST_TIMEOUT_MS` | Timeout for requests to Arranger | `number` | Optional | `10_000` | +| `MCP_HOST` | Host URL for the MCP server | `string` | Optional | `0.0.0.0` | +| `MCP_PORT` | Port the MCP Server will listen for requests on | `number` | Optional | `3100` | +| `MCP_PATH` | Endpoint for the MCP Streamable HTTP transport | `string` | Optional | `/mcp` | +| `LOG_LEVEL` | Pino [log level](https://getpino.io/#/docs/api?id=level-1) | `string` | Optional | `info` | + +## Testing + +### Local Arranger -## Suggested next step +To test the MCP Server against a **local** instance of Arranger Server: -When you're ready to implement the actual MCP server, keep this app focused on: +1. Confirm your [`apps/mcp-server/.env`](.env) configuration aligns with your local Arranger server. -- fetching and caching Arranger introspection -- exposing catalog and SQON metadata to MCP clients +2. Ensure ES and Arranger Server are running: -Then add query execution tools only after the metadata contract feels stable. +```bash +# from project root + +# start ES (note: you may need to seed ES with `make seed-es` after if this is your first time) +make start-es + +# start Arranger Server (config may vary) +ES_INDEX=file_centric DOCUMENT_TYPE=file CONFIGS_PATH=$(pwd)/docker/server npm run dev:server +``` + +3. Start the MCP Server: + +```bash +# from project root +npm run mcp-server:dev +``` + +4. Start the [MCP Inspector](https://github.com/modelcontextprotocol/inspector): + +```bash +# from project root +npm run mcp-server:inspect +``` + +5. You can then open the MCP Inspector URL in your web browser (`http://localhost:6274/?MCP_PROXY_AUTH_TOKEN={AUTH_TOKEN}`), connect to the MCP Server via Streamable HTTP, and test the Resources and Tools. + +### Remote Arranger + +To test against a **remote** instance of Arranger Server: + +1. Update the `ARRANGER_BASE_URL` and `ARRANGER_CATALOGUES` in your MCP Server `.env` file to point to and reflect the state of your remote Arranger. +2. Follow steps 3-5 of the [**local**](#local-arranger) testing instructions. + +### LM Studio + +To test with **LM Studio** instead of MCP Inspector: + +- Follow the LM Studio instructions to add an MCP server configuration: https://lmstudio.ai/docs/app/mcp + - Provide the config JSON in [`apps/mcp-server/mcp-inspector.json`](./mcp-inspector.json) + +## Not Implemented Yet + +- stdin/stdout server transport +- authentication +- query execution tools +- SQON generation helpers beyond introspection exposure diff --git a/apps/mcp-server/mcp-inspector.json b/apps/mcp-server/mcp-inspector.json new file mode 100644 index 000000000..4f15a1575 --- /dev/null +++ b/apps/mcp-server/mcp-inspector.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "mcp-server": { + "type": "streamable-http", + "url": "http://127.0.0.1:3100/mcp" + } + } +} diff --git a/apps/mcp-server/package.json b/apps/mcp-server/package.json index 7b313f93d..bb356113d 100644 --- a/apps/mcp-server/package.json +++ b/apps/mcp-server/package.json @@ -1,7 +1,45 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@overture-stack/arranger-mcp-server", + "description": "Arranger MCP Server", "version": "0.0.0-dev", + "homepage": "https://github.com/overture-stack/arranger#readme", + "bugs": { + "url": "https://github.com/overture-stack/arranger/issues" + }, + "main": "src/index.ts", + "types": "src/index.ts", + "repository": { + "directory": "apps/mcp-server", + "type": "git", + "url": "git+https://github.com/overture-stack/arranger.git" + }, "private": true, + "scripts": { + "dev": "NODE_ENV=development tsx watch --include './src/**/*' ./src/index.ts", + "start": "tsx ./src/index.ts", + "test": "tsx --test --experimental-test-module-mocks ./src/**/*.test.ts", + "inspect": "npx @modelcontextprotocol/inspector --config ./mcp-inspector.json --server mcp-server" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "cors": "^2.8.6", + "dotenv": "^16.6.1", + "express": "^4.21.2", + "pino": "^10.3.1", + "pino-pretty": "^13.1.3", + "tsx": "^4.21.0", + "zod": "^3.25.76" + }, + "devDependencies": { + "@tsconfig/node22": "^22.0.5", + "@types/cors": "^2.8.12", + "@types/express": "^4.17.14", + "@types/node": "^25.6.2", + "typescript": "^5.8.3" + }, + "imports": { + "#*": "./src/*" + }, "type": "module" } diff --git a/apps/mcp-server/src/arranger/client.ts b/apps/mcp-server/src/arranger/client.ts index 5f4704369..39cc0cce8 100644 --- a/apps/mcp-server/src/arranger/client.ts +++ b/apps/mcp-server/src/arranger/client.ts @@ -1,4 +1,4 @@ -import type { ArrangerMcpConfig } from '#config.js'; +import type { ArrangerMcpConfig } from '#utils/config.js'; import type { ArrangerCatalogIntrospection, diff --git a/apps/mcp-server/src/arranger/types.ts b/apps/mcp-server/src/arranger/types.ts index b7ca5dd74..de0e23bfc 100644 --- a/apps/mcp-server/src/arranger/types.ts +++ b/apps/mcp-server/src/arranger/types.ts @@ -1,6 +1,67 @@ +import { z as zod } from 'zod'; + export type { CatalogFieldIntrospection as ArrangerCatalogFieldDetails, CatalogIntrospectionResponse as ArrangerCatalogIntrospection, IntrospectionResponse as ArrangerServerIntrospection, SqonIntrospectionResponse as ArrangerSqonIntrospection, } from '../../../search-server/src/introspection/types.js'; + +// TODO: as part of tech debt item "Introspection types should be Zod-first to allow reuse as MCP output schemas", +// these types should be replaced with exports from the search-server's Zod schemas once that work is done. + +export const catalogsSchema = zod.record( + zod.object({ + description: zod.string().optional(), + documentType: zod.string(), + paths: zod.object({ + fields: zod.string().optional(), + graphql: zod.string(), + introspection: zod.string(), + }), + }), +); + +export const serverIntrospectionSchema = zod.object({ + catalogCount: zod.number(), + catalogs: catalogsSchema, + mode: zod.union([zod.literal('single'), zod.literal('multiple')]), + sqonSchemaPath: zod.string(), +}); + +const sqonOperatorDetailSchema = zod.object({ + applicableTo: zod.union([zod.literal('all'), zod.array(zod.string())]), + op: zod.string(), + valueType: zod.string(), +}); + +export const sqonIntrospectionSchema = zod.object({ + $schema: zod.string(), + aliases: zod.record(zod.string()), + description: zod.string(), + operators: zod.object({ + combination: zod.array(zod.string()), + field: zod.array(sqonOperatorDetailSchema), + }), + schema: zod.record(zod.unknown()), + title: zod.string(), + version: zod.string(), +}); + +const fieldSchema = zod.object({ + displayName: zod.string(), + type: zod.string(), + unit: zod.string().nullable().optional(), +}); + +export const catalogIntrospectionSchema = zod.object({ + catalogId: zod.string(), + description: zod.string().optional(), + documentType: zod.string(), + generatedAt: zod.string(), + meta: zod.object({ + authFiltered: zod.boolean(), + }), + operators: zod.record(zod.array(zod.string())), + fields: zod.record(fieldSchema), +}); diff --git a/apps/mcp-server/src/arranger/validation.test.ts b/apps/mcp-server/src/arranger/validation.test.ts new file mode 100644 index 000000000..c9565dd28 --- /dev/null +++ b/apps/mcp-server/src/arranger/validation.test.ts @@ -0,0 +1,127 @@ +import assert from 'node:assert'; +import { mock, suite, test } from 'node:test'; + +import { type ArrangerIntrospectionClient } from '#arranger/client.js'; +import type { + ArrangerCatalogIntrospection, + ArrangerServerIntrospection, + ArrangerSqonIntrospection, +} from '#arranger/types.js'; +import { validateArrangerConnection } from '#arranger/validation.js'; +import { type ArrangerMcpConfig } from '#utils/config.js'; + +const mockConfig = (catalogues: string[] = ['catalog-a']): ArrangerMcpConfig => ({ + arrangerBaseUrl: 'http://arranger.test', + catalogues, + requestTimeoutMs: 1000, + mcp: { + host: '0.0.0.0', + port: 3100, + path: '/mcp', + }, +}); + +const mockServerIntrospection = (catalogIds: string[]): ArrangerServerIntrospection => ({ + catalogCount: catalogIds.length, + catalogs: Object.fromEntries( + catalogIds.map((id) => [ + id, + { + documentType: 'doc', + paths: { + graphql: `/${id}/graphql`, + introspection: `/introspection/${id}`, + }, + }, + ]), + ), + mode: catalogIds.length > 1 ? 'multiple' : 'single', + sqonSchemaPath: '/introspection/sqon', +}); + +const mockSqonIntrospection = (): ArrangerSqonIntrospection => ({ + $schema: 'https://json-schema.org/draft/2020-12/schema', + aliases: {}, + description: '', + operators: { combination: [], field: [] }, + schema: {}, + title: 'SQON', + version: '0.0.0', +}); + +const mockCatalogIntrospection = (catalogId: string): ArrangerCatalogIntrospection => ({ + catalogId, + documentType: 'doc', + generatedAt: '2026-01-01T00:00:00Z', + meta: { authFiltered: false }, + operators: {}, + fields: {}, +}); + +const mockClient = (overrides: Partial = {}): ArrangerIntrospectionClient => ({ + getServerIntrospection: mock.fn(async () => mockServerIntrospection(['catalog-a'])), + getSqonIntrospection: mock.fn(async () => mockSqonIntrospection()), + getCatalogIntrospection: mock.fn(async (id: string) => mockCatalogIntrospection(id)), + ...overrides, +}); + +suite('validateArrangerConnection', () => { + test('resolves when introspection succeeds and all configured catalogues are available', async () => { + const config = mockConfig(['catalog-a', 'catalog-b']); + const client = mockClient({ + getServerIntrospection: mock.fn(async () => mockServerIntrospection(['catalog-a', 'catalog-b'])), + }); + + await assert.doesNotReject(validateArrangerConnection(config, client)); + }); + + test('throws when the /introspection endpoint is unreachable', async () => { + const config = mockConfig(); + const client = mockClient({ + getServerIntrospection: mock.fn(async () => { + throw new Error('Failed to fetch /introspection: 503 Service Unavailable'); + }), + }); + + await assert.rejects(validateArrangerConnection(config, client), { + message: /Arranger connection validation failed:.*\/introspection.*503/, + }); + }); + + test('throws when the /introspection/sqon endpoint is unreachable', async () => { + const config = mockConfig(); + const client = mockClient({ + getSqonIntrospection: mock.fn(async () => { + throw new Error('Failed to fetch /introspection/sqon: 404 Not Found'); + }), + }); + + await assert.rejects(validateArrangerConnection(config, client), { + message: /Arranger connection validation failed:.*\/introspection\/sqon.*404/, + }); + }); + + test('throws when a configured catalogue is not available on Arranger', async () => { + const config = mockConfig(['catalog-a', 'missing-catalog']); + const client = mockClient({ + getServerIntrospection: mock.fn(async () => mockServerIntrospection(['catalog-a'])), + }); + + await assert.rejects(validateArrangerConnection(config, client), { + message: /Configured catalogues not available on Arranger: missing-catalog/, + }); + }); + + test('throws when a network error occurs during validation', async () => { + const config = mockConfig(); + const client = mockClient({ + getServerIntrospection: mock.fn(async () => { + throw new Error('fetch failed: ECONNREFUSED'); + }), + }); + + await assert.rejects(validateArrangerConnection(config, client), { + message: /Arranger connection validation failed:.*ECONNREFUSED/, + }); + }); +}); diff --git a/apps/mcp-server/src/arranger/validation.ts b/apps/mcp-server/src/arranger/validation.ts new file mode 100644 index 000000000..b22f2034b --- /dev/null +++ b/apps/mcp-server/src/arranger/validation.ts @@ -0,0 +1,35 @@ +import { type ArrangerIntrospectionClient } from '#arranger/client.js'; +import { type ArrangerMcpConfig } from '#utils/config.js'; +import logger from '#utils/logger.js'; + +/** + * Validates the connection to Arranger by checking the /introspection and /introspection/sqon endpoints, + * and ensuring that all configured catalogues are available. + * @param config - The MCP server configuration containing Arranger connection details and catalogues to validate. + * @param client - An instance of ArrangerIntrospectionClient used to make requests to Arranger. + * @throws Will throw an error if the connection to Arranger fails or if any configured catalogue is not available. + */ +export const validateArrangerConnection = async ( + config: ArrangerMcpConfig, + client: ArrangerIntrospectionClient, +): Promise => { + try { + const server = await client.getServerIntrospection(); + await client.getSqonIntrospection(); + logger.info('Connected to Arranger /introspection and /introspection/sqon.'); + + const available = new Set(Object.keys(server.catalogs)); + const missing = config.catalogues.filter((catalogue) => !available.has(catalogue)); + if (missing.length > 0) { + throw new Error(`Configured catalogues not available on Arranger: ${missing.join(', ')}`); + } + + for (const catalogId of config.catalogues) { + await client.getCatalogIntrospection(catalogId); + } + logger.info({ catalogues: config.catalogues }, 'All configured catalogues validated.'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Arranger connection validation failed: ${message}`); + } +}; diff --git a/apps/mcp-server/src/config.ts b/apps/mcp-server/src/config.ts deleted file mode 100644 index 20edd3b12..000000000 --- a/apps/mcp-server/src/config.ts +++ /dev/null @@ -1,16 +0,0 @@ -export interface ArrangerMcpConfig { - arrangerBaseUrl: string; - requestTimeoutMs: number; -} - -const DEFAULT_ARRANGER_BASE_URL = 'http://localhost:5050'; -const DEFAULT_REQUEST_TIMEOUT_MS = 10_000; - -const trimTrailingSlash = (value: string) => value.replace(/\/+$/, ''); - -export const createArrangerMcpConfig = ( - overrides: Partial = {}, -): ArrangerMcpConfig => ({ - arrangerBaseUrl: trimTrailingSlash(overrides.arrangerBaseUrl || process.env.ARRANGER_BASE_URL || DEFAULT_ARRANGER_BASE_URL), - requestTimeoutMs: overrides.requestTimeoutMs || Number(process.env.ARRANGER_REQUEST_TIMEOUT_MS || DEFAULT_REQUEST_TIMEOUT_MS), -}); diff --git a/apps/mcp-server/src/http/app.ts b/apps/mcp-server/src/http/app.ts new file mode 100644 index 000000000..9ba307fa6 --- /dev/null +++ b/apps/mcp-server/src/http/app.ts @@ -0,0 +1,102 @@ +import { randomUUID } from 'node:crypto'; + +import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express'; +import { type McpServer } from '@modelcontextprotocol/sdk/server/mcp'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp'; +import { isInitializeRequest } from '@modelcontextprotocol/sdk/types'; +import { type Express, type Request, type Response } from 'express'; + +import { type ArrangerMcpConfig } from '#utils/config.js'; +import { InMemoryEventStore } from '#utils/inMemoryEventStore.js'; +import logger from '#utils/logger.js'; + +export type McpHttpApp = { + app: Express; + closeAllSessions: () => Promise; +}; + +// This code was adapted from the official MCP Server "Streamable HTTP" example: +// https://github.com/modelcontextprotocol/typescript-sdk/blob/v1.x/src/examples/server/simpleStreamableHttp.ts +export const createHttpApp = (config: ArrangerMcpConfig, serverFactory: () => McpServer): McpHttpApp => { + const transports: Record = {}; + + const postHandler = async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + try { + let transport: StreamableHTTPServerTransport; + if (sessionId && transports[sessionId]) { + transport = transports[sessionId]; + } else if (!sessionId && isInitializeRequest(req.body)) { + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + eventStore: new InMemoryEventStore(), + onsessioninitialized: (sid) => { + logger.info(`Session initialized with ID: ${sid}`); + transports[sid] = transport; + }, + }); + transport.onclose = () => { + const sid = transport.sessionId; + if (sid && transports[sid]) { + logger.info(`Transport closed for session ${sid}, removing from transports map`); + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete transports[sid]; + } + }; + + await serverFactory().connect(transport); + await transport.handleRequest(req, res, req.body); + return; + } else { + res.status(400).json({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Bad Request: No valid session ID provided' }, + id: null, + }); + return; + } + await transport.handleRequest(req, res, req.body); + } catch (error) { + logger.error({ error }, 'Error handling MCP POST'); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { code: -32603, message: 'Internal server error' }, + id: null, + }); + } + } + }; + + const sessionHandler = async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId || !transports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + return; + } + await transports[sessionId].handleRequest(req, res); + }; + + const app = createMcpExpressApp(); + const { + mcp: { path }, + } = config; + app.post(path, postHandler); + app.get(path, sessionHandler); + app.delete(path, sessionHandler); + + const closeAllSessions = async () => { + for (const sessionId of Object.keys(transports)) { + try { + logger.debug(`Closing transport for session ${sessionId}`); + await transports[sessionId].close(); + } catch (error) { + logger.error({ error, sessionId }, 'Error closing transport'); + } + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete transports[sessionId]; + } + }; + + return { app, closeAllSessions }; +}; diff --git a/apps/mcp-server/src/index.ts b/apps/mcp-server/src/index.ts index 1bf2907f0..0fee56e96 100644 --- a/apps/mcp-server/src/index.ts +++ b/apps/mcp-server/src/index.ts @@ -1,38 +1,9 @@ -import { createArrangerIntrospectionClient } from '#arranger/client.js'; -import { createArrangerMcpConfig } from '#config.js'; -import { buildCatalogResources, buildStaticResources } from '#mcp/resources.js'; -import { buildFoundationTools } from '#mcp/tools.js'; +import 'dotenv/config'; -export interface ArrangerMcpFoundation { - config: ReturnType; - tools: ReturnType; - resources: ReturnType; - loadCatalogResources(): Promise>; -} +import { startServer } from '#server.js'; +import logger from '#utils/logger.js'; -export const createArrangerMcpFoundation = (): ArrangerMcpFoundation => { - const config = createArrangerMcpConfig(); - const client = createArrangerIntrospectionClient(config); - - return { - config, - resources: buildStaticResources(), - tools: buildFoundationTools(), - loadCatalogResources: async () => { - const serverIntrospection = await client.getServerIntrospection(); - return buildCatalogResources(serverIntrospection); - }, - }; -}; - -/* - The intended next step is roughly: - - 1. create the MCP transport/server - 2. register static resources from `buildStaticResources()` - 3. register dynamic catalog resources after `getServerIntrospection()` - 4. implement tool handlers by calling: - - client.getServerIntrospection() - - client.getSqonIntrospection() - - client.getCatalogIntrospection(catalogId) -*/ +startServer().catch((err) => { + logger.error({ err }, 'Failed to start MCP server'); + process.exit(1); +}); diff --git a/apps/mcp-server/src/mcp/resources.ts b/apps/mcp-server/src/mcp/resources.ts index b4fdd4df3..4ff90acb1 100644 --- a/apps/mcp-server/src/mcp/resources.ts +++ b/apps/mcp-server/src/mcp/resources.ts @@ -1,25 +1,69 @@ -import type { ArrangerServerIntrospection } from '#arranger/types.js'; +import { ResourceTemplate, type McpServer } from '@modelcontextprotocol/sdk/server/mcp'; -import type { McpResourceDefinition } from './types.js'; +import { type McpServerDeps } from '#server.js'; -export const buildStaticResources = (): McpResourceDefinition[] => [ - { - description: 'Arranger-wide server summary and catalog inventory.', - name: 'arranger_server_introspection', - uri: 'arranger://introspection/server', - }, - { - description: 'Shared SQON schema and SQON operator metadata for this Arranger instance.', - name: 'arranger_sqon_schema', - uri: 'arranger://introspection/sqon', - }, -]; +const JSON_MIME = 'application/json'; -export const buildCatalogResources = ( - serverIntrospection: ArrangerServerIntrospection, -): McpResourceDefinition[] => - Object.keys(serverIntrospection.catalogs).map((catalogId) => ({ - description: `Field-level introspection for the "${catalogId}" catalog.`, - name: `arranger_catalog_${catalogId}`, - uri: `arranger://introspection/catalog/${catalogId}`, - })); +export const registerResources = (server: McpServer, { client }: McpServerDeps): void => { + server.registerResource( + 'arranger-server-introspection', + 'arranger://introspection/server', + { + title: 'Arranger Server Introspection', + description: 'Arranger-wide server summary and catalog inventory (GET /introspection).', + mimeType: JSON_MIME, + }, + async (uri) => { + const data = await client.getServerIntrospection(); + return { + contents: [{ uri: uri.href, mimeType: JSON_MIME, text: JSON.stringify(data, null, 2) }], + }; + }, + ); + + server.registerResource( + 'arranger-sqon-schema', + 'arranger://introspection/sqon', + { + title: 'SQON Schema', + description: + 'Shared SQON Schema and SQON operator metadata for this Arranger instance (GET /introspection/sqon).', + mimeType: JSON_MIME, + }, + async (uri) => { + const data = await client.getSqonIntrospection(); + return { + contents: [{ uri: uri.href, mimeType: JSON_MIME, text: JSON.stringify(data, null, 2) }], + }; + }, + ); + + server.registerResource( + 'arranger-catalog-fields', + new ResourceTemplate('arranger://introspection/catalog/{catalogId}', { + list: async () => { + const { catalogs } = await client.getServerIntrospection(); + return { + resources: Object.keys(catalogs).map((catalogId) => ({ + uri: `arranger://introspection/catalog/${catalogId}`, + name: catalogId, + mimeType: JSON_MIME, + })), + }; + }, + }), + { + title: 'Arranger Catalog Fields', + description: + 'Per-catalog field metadata: displayName, type, unit, validOperators (GET /introspection/:catalogId).', + mimeType: JSON_MIME, + }, + async (uri, { catalogId }) => { + const id = Array.isArray(catalogId) ? catalogId[0] : catalogId; + const data = await client.getCatalogIntrospection(id); + return { + contents: [{ uri: uri.href, mimeType: JSON_MIME, text: JSON.stringify(data, null, 2) }], + }; + }, + ); +}; diff --git a/apps/mcp-server/src/mcp/tools.ts b/apps/mcp-server/src/mcp/tools.ts index 438ec3463..9f37ffc07 100644 --- a/apps/mcp-server/src/mcp/tools.ts +++ b/apps/mcp-server/src/mcp/tools.ts @@ -1,27 +1,68 @@ -import type { McpToolDefinition } from './types.js'; +import { type McpServer } from '@modelcontextprotocol/sdk/server/mcp'; +import { z as zod } from 'zod'; -export const buildFoundationTools = (): McpToolDefinition[] => [ - { - description: 'List the catalogs exposed by the connected Arranger server.', - name: 'list_catalogs', - }, - { - description: 'Return the shared SQON schema and operator metadata for the connected Arranger server.', - name: 'get_sqon_schema', - }, - { - description: - 'Return field introspection for one catalogue. `operators` maps each field type to its valid SQON operators. `fields` lists each field with its `type`, `displayName`, optional `unit`, and optional `description`.', - inputSchema: { - properties: { - catalogId: { - description: 'Catalog identifier from the Arranger /introspection payload.', - type: 'string', - }, +import { + catalogIntrospectionSchema, + catalogsSchema, + serverIntrospectionSchema, + sqonIntrospectionSchema, +} from '#arranger/types.js'; +import { type McpServerDeps } from '#server.js'; + +export const registerTools = (server: McpServer, { client }: McpServerDeps): void => { + server.registerTool( + 'list-catalogs', + { + title: 'List Arranger Catalogs', + description: 'Returns the catalogs exposed by the connected Arranger server.', + outputSchema: zod.object({ catalogs: catalogsSchema }), + }, + async () => { + const data = await client.getServerIntrospection(); + const { catalogs } = serverIntrospectionSchema.parse(data); + const catalogIds = Object.keys(catalogs); + return { + content: [{ type: 'text', text: `Available catalogs: ${catalogIds.join(', ')}` }], + structuredContent: { catalogs }, + }; + }, + ); + + server.registerTool( + 'get-sqon-schema', + { + title: 'Get SQON Schema', + description: 'Returns the shared SQON Schema and operator metadata for the connected Arranger server.', + outputSchema: sqonIntrospectionSchema, + }, + async () => { + const data = await client.getSqonIntrospection(); + const sqonSchema = sqonIntrospectionSchema.parse(data); + return { + content: [{ type: 'text', text: JSON.stringify(sqonSchema) }], + structuredContent: sqonSchema, + }; + }, + ); + + server.registerTool( + 'get-catalog-fields', + { + title: 'Get Catalog Fields', + description: + 'Return field introspection for one catalogue. `operators` maps each field type to its valid SQON operators. `fields` lists each field with its `type`, `displayName`, optional `unit`, and optional `description`.', + inputSchema: { + catalogId: zod.string().min(1).describe('Catalog identifier from the Arranger /introspection payload.'), }, - required: ['catalogId'], - type: 'object', + outputSchema: catalogIntrospectionSchema, + }, + async ({ catalogId }) => { + const data = await client.getCatalogIntrospection(catalogId); + const catalogIntrospection = catalogIntrospectionSchema.parse(data); + return { + content: [{ type: 'text', text: JSON.stringify(catalogIntrospection, null, 2) }], + structuredContent: catalogIntrospection, + }; }, - name: 'get_catalog_fields', - }, -]; + ); +}; diff --git a/apps/mcp-server/src/mcp/types.ts b/apps/mcp-server/src/mcp/types.ts deleted file mode 100644 index 43c5fdc3c..000000000 --- a/apps/mcp-server/src/mcp/types.ts +++ /dev/null @@ -1,22 +0,0 @@ -export interface McpResourceDefinition { - name: string; - description: string; - uri: string; -} - -export interface McpToolArgument { - name: string; - description: string; - required?: boolean; - type: 'string' | 'number' | 'boolean'; -} - -export interface McpToolDefinition { - name: string; - description: string; - inputSchema?: { - type: 'object'; - properties: Record; - required?: string[]; - }; -} diff --git a/apps/mcp-server/src/server.ts b/apps/mcp-server/src/server.ts new file mode 100644 index 000000000..6a35230db --- /dev/null +++ b/apps/mcp-server/src/server.ts @@ -0,0 +1,59 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; + +import { createArrangerIntrospectionClient, type ArrangerIntrospectionClient } from '#arranger/client.js'; +import { validateArrangerConnection } from '#arranger/validation.js'; +import { createHttpApp } from '#http/app.js'; +import { registerResources } from '#mcp/resources.js'; +import { registerTools } from '#mcp/tools.js'; +import { createArrangerMcpConfig, type ArrangerMcpConfig } from '#utils/config.js'; +import logger from '#utils/logger.js'; + +export type McpServerDeps = { + config: ArrangerMcpConfig; + client: ArrangerIntrospectionClient; +}; + +export const createMcpServer = (deps: McpServerDeps): McpServer => { + const server = new McpServer({ name: 'arranger-mcp-server', version: '0.0.0-dev' }); + registerResources(server, deps); + registerTools(server, deps); + return server; +}; + +export const startServer = async (): Promise => { + const config = createArrangerMcpConfig(); + const client = createArrangerIntrospectionClient(config); + await validateArrangerConnection(config, client); + + const deps: McpServerDeps = { config, client }; + const { app, closeAllSessions } = createHttpApp(config, () => createMcpServer(deps)); + + const { host, port, path } = config.mcp; + app.listen(port, () => { + logger.info(`MCP server running at http://${host}:${port}${path}`); + }); + + const gracefulShutdown = async (signal: string) => { + logger.info(`Received ${signal}, initiating graceful shutdown...`); + + // Force shutdown fallback after 30 seconds + const hardShutdownTimeout = setTimeout(() => { + logger.error('Graceful shutdown timed out, forcing exit'); + process.exit(1); + }, 30000); + + hardShutdownTimeout.unref(); // Allow process to exit if this is the only thing left + + try { + await closeAllSessions(); + logger.info('Graceful shutdown complete, exiting now.'); + process.exit(0); + } catch (error) { + logger.error({ error }, 'Error during graceful shutdown, forcing exit'); + process.exit(1); + } + }; + + process.on('SIGINT', () => gracefulShutdown('SIGINT')); + process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); +}; diff --git a/apps/mcp-server/src/utils/config.test.ts b/apps/mcp-server/src/utils/config.test.ts new file mode 100644 index 000000000..61e4a0d48 --- /dev/null +++ b/apps/mcp-server/src/utils/config.test.ts @@ -0,0 +1,290 @@ +import assert from 'node:assert'; +import { after, afterEach, before, beforeEach, mock, suite, test } from 'node:test'; + +const ENV_KEYS = [ + 'ARRANGER_BASE_URL', + 'ARRANGER_CATALOGUES', + 'ARRANGER_REQUEST_TIMEOUT_MS', + 'MCP_HOST', + 'MCP_PORT', + 'MCP_PATH', + 'LOG_LEVEL', +] as const; + +// Redefining ArrangerMcpConfig type to avoid importing from config.ts before the logger module is mocked +type ArrangerMcpConfig = { + arrangerBaseUrl: string; + catalogues: string[]; + requestTimeoutMs: number; + mcp: { + host: string; + port: number; + path: string; + }; +}; + +const originalEnv: Partial> = {}; + +/** + * Sets env vars for tests, ensuring that any keys already defined in `ENV_KEYS` are cleared before applying overrides. + * @param overrides - Object containing env var values to set for the test. Keys should be from `ENV_KEYS`. + * @remark Use `undefined` to explicitly unset a variable. + * @remark This function does not restore original env vars after the test. + */ +const setEnv = (overrides: Partial>) => { + for (const key of ENV_KEYS) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete process.env[key]; + } + for (const [key, value] of Object.entries(overrides)) { + if (value !== undefined) { + process.env[key] = value; + } + } +}; + +suite('createArrangerMcpConfig', () => { + const errorLogs: string[] = []; + let loggerMock: ReturnType; + let exitMock: ReturnType; + let createArrangerMcpConfig: () => ArrangerMcpConfig; + let exitCode = 0; + + before(async () => { + // Mock the logger module **before** importing createArrangerMcpConfig + loggerMock = mock.module('#utils/logger.js', { + namedExports: { + createLogger: () => ({ + error: (msg: string) => { + errorLogs.push(msg); + }, + info: mock.fn(), + }), + }, + }); + + // Prevent the real process.exit from killing the test runner + exitMock = mock.method(process, 'exit', (code?: number) => { + exitCode = code ?? 0; + throw new Error('__process_exit__'); + }); + + // Dynamic import **after** the logger mock is registered, so that createArrangerMcpConfig uses the mocked logger + ({ createArrangerMcpConfig } = await import('#utils/config.js')); + }); + + beforeEach(() => { + // Capture original env vars before each test so they can be restored in afterEach + for (const key of ENV_KEYS) { + originalEnv[key] = process.env[key]; + } + // Reset captured logs and exit code before each test + errorLogs.length = 0; + exitCode = 0; + }); + + after(() => { + loggerMock.restore(); + exitMock.mock.restore(); + }); + + afterEach(() => { + // Restore original env vars after each test to avoid side effects + for (const key of ENV_KEYS) { + if (originalEnv[key] === undefined) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete process.env[key]; + } else { + process.env[key] = originalEnv[key]; + } + } + }); + + suite('successful configuration', () => { + test('builds config from process.env when all variables provided and valid', () => { + setEnv({ + ARRANGER_BASE_URL: 'https://arranger.example.com/', + ARRANGER_CATALOGUES: 'catalog-a, catalog-b ,catalog-c', + ARRANGER_REQUEST_TIMEOUT_MS: '5_000', + MCP_HOST: '127.0.0.1', + MCP_PORT: '4200', + MCP_PATH: '/custom-mcp', + LOG_LEVEL: 'debug', + }); + + const config = createArrangerMcpConfig(); + + assert.deepStrictEqual(config, { + arrangerBaseUrl: 'https://arranger.example.com', + catalogues: ['catalog-a', 'catalog-b', 'catalog-c'], + requestTimeoutMs: 5000, + mcp: { + host: '127.0.0.1', + port: 4200, + path: '/custom-mcp', + }, + }); + }); + + test('builds config with defaults for optional variables when only required variables are provided', () => { + setEnv({ + ARRANGER_BASE_URL: 'http://localhost:5050', + ARRANGER_CATALOGUES: 'catalog-a', + }); + + const config = createArrangerMcpConfig(); + + assert.deepStrictEqual(config, { + arrangerBaseUrl: 'http://localhost:5050', + catalogues: ['catalog-a'], + requestTimeoutMs: 10_000, + mcp: { + host: '0.0.0.0', + port: 3100, + path: '/mcp', + }, + }); + }); + + test('trims a single trailing slash from ARRANGER_BASE_URL', () => { + setEnv({ + ARRANGER_BASE_URL: 'https://arranger.example.com/', + ARRANGER_CATALOGUES: 'catalog-a', + }); + + const config = createArrangerMcpConfig(); + + assert.strictEqual(config.arrangerBaseUrl, 'https://arranger.example.com'); + }); + + test('filters empty entries from ARRANGER_CATALOGUES', () => { + setEnv({ + ARRANGER_BASE_URL: 'https://arranger.example.com', + ARRANGER_CATALOGUES: 'catalog-a,, catalog-b, ,catalog-c,', + }); + + const config = createArrangerMcpConfig(); + + assert.deepStrictEqual(config.catalogues, ['catalog-a', 'catalog-b', 'catalog-c']); + }); + }); + + suite('missing required environment variables', () => { + test('exits when ARRANGER_BASE_URL is missing', () => { + setEnv({ + ARRANGER_CATALOGUES: 'catalog-a', + }); + + assert.throws(() => createArrangerMcpConfig(), /__process_exit__/); + assert.strictEqual(exitCode, 1); + assert.match(errorLogs.join(''), /ARRANGER_BASE_URL is required and must be a valid URL/); + }); + + test('exits when ARRANGER_CATALOGUES is missing', () => { + setEnv({ + ARRANGER_BASE_URL: 'https://arranger.example.com', + }); + + assert.throws(() => createArrangerMcpConfig(), /__process_exit__/); + assert.strictEqual(exitCode, 1); + assert.match( + errorLogs.join(''), + /ARRANGER_CATALOGUES is required and must be a comma-separated list of catalogue names/, + ); + }); + + test('exits when both required variables are missing', () => { + setEnv({}); + + assert.throws(() => createArrangerMcpConfig(), /__process_exit__/); + assert.strictEqual(exitCode, 1); + const combined = errorLogs.join(''); + assert.match(combined, /ARRANGER_BASE_URL/); + assert.match(combined, /ARRANGER_CATALOGUES/); + }); + }); + + suite('invalid environment variables', () => { + test('exits when ARRANGER_BASE_URL is not a valid URL', () => { + setEnv({ + ARRANGER_BASE_URL: 'not-a-url', + ARRANGER_CATALOGUES: 'catalog-a', + }); + + assert.throws(() => createArrangerMcpConfig(), /__process_exit__/); + assert.strictEqual(exitCode, 1); + assert.match(errorLogs.join(''), /ARRANGER_BASE_URL must be a valid URL/); + }); + + test('exits when ARRANGER_CATALOGUES is an empty string', () => { + setEnv({ + ARRANGER_BASE_URL: 'https://arranger.example.com', + ARRANGER_CATALOGUES: '', + }); + + assert.throws(() => createArrangerMcpConfig(), /__process_exit__/); + assert.strictEqual(exitCode, 1); + assert.match(errorLogs.join(''), /ARRANGER_CATALOGUES is required and cannot be empty/); + }); + + test('exits when ARRANGER_REQUEST_TIMEOUT_MS is not a number', () => { + setEnv({ + ARRANGER_BASE_URL: 'https://arranger.example.com', + ARRANGER_CATALOGUES: 'catalog-a', + ARRANGER_REQUEST_TIMEOUT_MS: 'not-a-number', + }); + + assert.throws(() => createArrangerMcpConfig(), /__process_exit__/); + assert.strictEqual(exitCode, 1); + assert.match(errorLogs.join(''), /ARRANGER_REQUEST_TIMEOUT_MS must be a valid number/); + }); + + test('exits when ARRANGER_REQUEST_TIMEOUT_MS is not an integer', () => { + setEnv({ + ARRANGER_BASE_URL: 'https://arranger.example.com', + ARRANGER_CATALOGUES: 'catalog-a', + ARRANGER_REQUEST_TIMEOUT_MS: '1.5', + }); + + assert.throws(() => createArrangerMcpConfig(), /__process_exit__/); + assert.strictEqual(exitCode, 1); + assert.match(errorLogs.join(''), /ARRANGER_REQUEST_TIMEOUT_MS must be an integer/); + }); + + test('exits when ARRANGER_REQUEST_TIMEOUT_MS is not positive', () => { + setEnv({ + ARRANGER_BASE_URL: 'https://arranger.example.com', + ARRANGER_CATALOGUES: 'catalog-a', + ARRANGER_REQUEST_TIMEOUT_MS: '-100', + }); + + assert.throws(() => createArrangerMcpConfig(), /__process_exit__/); + assert.strictEqual(exitCode, 1); + assert.match(errorLogs.join(''), /ARRANGER_REQUEST_TIMEOUT_MS must be a positive number/); + }); + + test('exits when MCP_PORT exceeds 65535', () => { + setEnv({ + ARRANGER_BASE_URL: 'https://arranger.example.com', + ARRANGER_CATALOGUES: 'catalog-a', + MCP_PORT: '70000', + }); + + assert.throws(() => createArrangerMcpConfig(), /__process_exit__/); + assert.strictEqual(exitCode, 1); + assert.match(errorLogs.join(''), /MCP_PORT cannot exceed 65535/); + }); + + test('exits when LOG_LEVEL is not one of the allowed values', () => { + setEnv({ + ARRANGER_BASE_URL: 'https://arranger.example.com', + ARRANGER_CATALOGUES: 'catalog-a', + LOG_LEVEL: 'verbose', + }); + + assert.throws(() => createArrangerMcpConfig(), /__process_exit__/); + assert.strictEqual(exitCode, 1); + assert.match(errorLogs.join(''), /LOG_LEVEL must be one of: trace, debug, info, warn, error, fatal/); + }); + }); +}); diff --git a/apps/mcp-server/src/utils/config.ts b/apps/mcp-server/src/utils/config.ts new file mode 100644 index 000000000..86d512f92 --- /dev/null +++ b/apps/mcp-server/src/utils/config.ts @@ -0,0 +1,115 @@ +import { z as zod } from 'zod'; + +import { createLogger } from '#utils/logger.js'; + +const DEFAULT_REQUEST_TIMEOUT_MS = 10_000; + +const logger = createLogger('Config'); + +/** + * Utility function to trim trailing slashes from a URL string. + * @param value - The URL string to trim. + * @returns The input string with any trailing slashes removed. + * @example + * ```ts + * trimTrailingSlash('https://example.com/') // returns 'https://example.com' + * trimTrailingSlash('https://example.com/path/') // returns 'https://example.com/path' + * ``` + */ +const trimTrailingSlash = (value: string) => value.replace(/\/+$/, ''); + +/** + * Convert a comma-separated string of catalogue names into an array of trimmed strings, filtering out any empty values. + * @param cataloguesString - A comma-separated string of catalogue names. + * @returns An array of trimmed catalogue names. + * @example + * ```ts + * parseCatalogueList('catalogue1,catalogue2,catalogue3') // returns ['catalogue1', 'catalogue2', 'catalogue3'] + * parseCatalogueList('catalogue1,, catalogue2, ,catalogue3,') // returns ['catalogue1', 'catalogue2', 'catalogue3'] + * ``` + */ +const parseCatalogueList = (cataloguesString: string): string[] => { + return cataloguesString + .split(',') + .map((catalogue) => catalogue.trim()) + .filter(Boolean); +}; + +/** + * Zod schema for validating and parsing environment variables for the Arranger MCP server configuration. + * This schema ensures that all required values are present and correctly formatted, and provides default values + * where appropriate (i.e. for optional environment variables). + */ +const envSchema = zod.object({ + ARRANGER_BASE_URL: zod + .string({ + message: 'ARRANGER_BASE_URL is required and must be a valid URL', + }) + .url('ARRANGER_BASE_URL must be a valid URL') + .transform(trimTrailingSlash), + ARRANGER_CATALOGUES: zod + .string({ + message: 'ARRANGER_CATALOGUES is required and must be a comma-separated list of catalogue names', + }) + .min(1, 'ARRANGER_CATALOGUES is required and cannot be empty') + .transform(parseCatalogueList), + ARRANGER_REQUEST_TIMEOUT_MS: zod.preprocess( + (value) => { + if (typeof value === 'string') { + // Remove underscores to allow for more human-friendly large numbers (e.g., "10_000" instead of "10000") + return value.replace(/_/g, ''); + } + return value; + }, + zod.coerce + .number({ + message: 'ARRANGER_REQUEST_TIMEOUT_MS must be a valid number', + }) + .int('ARRANGER_REQUEST_TIMEOUT_MS must be an integer') + .positive('ARRANGER_REQUEST_TIMEOUT_MS must be a positive number') + .optional() + .default(DEFAULT_REQUEST_TIMEOUT_MS), + ), + MCP_HOST: zod.string().optional().default('0.0.0.0'), + MCP_PORT: zod.coerce.number().int().positive().max(65535, 'MCP_PORT cannot exceed 65535').optional().default(3100), + MCP_PATH: zod.string().optional().default('/mcp'), + LOG_LEVEL: zod + .enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal'], { + message: 'LOG_LEVEL must be one of: trace, debug, info, warn, error, fatal', + }) + .optional() + .default('info'), +}); + +/** + * Zod schema for the Arranger MCP server configuration, derived from `envSchema`. + * Transforms the validated env vars into a structured config object. + */ +const ArrangerMcpConfig = envSchema.transform((data) => ({ + arrangerBaseUrl: data.ARRANGER_BASE_URL, + catalogues: data.ARRANGER_CATALOGUES, + requestTimeoutMs: data.ARRANGER_REQUEST_TIMEOUT_MS, + mcp: { + host: data.MCP_HOST, + port: data.MCP_PORT, + path: data.MCP_PATH, + }, +})); +export type ArrangerMcpConfig = zod.infer; + +/** + * Validates and parses environment variables to create the Arranger MCP server configuration object. + * If validation fails, logs detailed error messages and exits the process. + * @returns - A validated and structured ArrangerMcpConfig object derived from env vars. + * @remarks - This function will terminate the process if any required environment variables are missing or invalid. + */ +export const createArrangerMcpConfig = (): ArrangerMcpConfig => { + const result = ArrangerMcpConfig.safeParse(process.env); + if (!result.success) { + const errorMessages = result.error.errors.map((err) => err.message).join('; '); + logger.error(`Arranger configuration validation failed: ${errorMessages}`); + logger.info('Exiting.'); + process.exit(1); + } + return result.data; +}; diff --git a/apps/mcp-server/src/utils/inMemoryEventStore.ts b/apps/mcp-server/src/utils/inMemoryEventStore.ts new file mode 100644 index 000000000..3f847ba3a --- /dev/null +++ b/apps/mcp-server/src/utils/inMemoryEventStore.ts @@ -0,0 +1,81 @@ +// In-memory event store implementation from the MCP TypeScript SDK examples: +// https://github.com/modelcontextprotocol/typescript-sdk/blob/v1.x/src/examples/shared/inMemoryEventStore.ts +// TODO: Replace with a persistent storage solution for production use +import { type EventStore } from '@modelcontextprotocol/sdk/server/streamableHttp'; +import { type JSONRPCMessage } from '@modelcontextprotocol/sdk/types'; + +/** + * Simple in-memory implementation of the EventStore interface for resumability + * This is primarily intended for examples and testing, not for production use + * where a persistent storage solution would be more appropriate. + */ +export class InMemoryEventStore implements EventStore { + private events = new Map(); + + /** + * Generates a unique event ID for a given stream ID + */ + private generateEventId(streamId: string): string { + return `${streamId}_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`; + } + + /** + * Extracts the stream ID from an event ID + */ + private getStreamIdFromEventId(eventId: string): string { + const parts = eventId.split('_'); + return parts.length > 0 ? parts[0] : ''; + } + + /** + * Stores an event with a generated event ID + * Implements EventStore.storeEvent + */ + async storeEvent(streamId: string, message: JSONRPCMessage): Promise { + const eventId = this.generateEventId(streamId); + this.events.set(eventId, { streamId, message }); + return eventId; + } + + /** + * Replays events that occurred after a specific event ID + * Implements EventStore.replayEventsAfter + */ + async replayEventsAfter( + lastEventId: string, + { send }: { send: (eventId: string, message: JSONRPCMessage) => Promise }, + ): Promise { + if (!lastEventId || !this.events.has(lastEventId)) { + return ''; + } + + // Extract the stream ID from the event ID + const streamId = this.getStreamIdFromEventId(lastEventId); + if (!streamId) { + return ''; + } + + let foundLastEvent = false; + + // Sort events by eventId for chronological ordering + const sortedEvents = [...this.events.entries()].sort((a, b) => a[0].localeCompare(b[0])); + + for (const [eventId, { streamId: eventStreamId, message }] of sortedEvents) { + // Only include events from the same stream + if (eventStreamId !== streamId) { + continue; + } + + // Start sending events after we find the lastEventId + if (eventId === lastEventId) { + foundLastEvent = true; + continue; + } + + if (foundLastEvent) { + await send(eventId, message); + } + } + return streamId; + } +} diff --git a/apps/mcp-server/src/utils/logger.ts b/apps/mcp-server/src/utils/logger.ts new file mode 100644 index 000000000..c1c10e128 --- /dev/null +++ b/apps/mcp-server/src/utils/logger.ts @@ -0,0 +1,60 @@ +import pino, { type Logger, type LoggerOptions } from 'pino'; +import pretty from 'pino-pretty'; + +/** + * Create a pretty stream for pino that outputs colourized logs with timestamps in UTC ISO format, and excludes pid and + * hostname from the log output. Logs are sent to stderr to keep stdout clean for potential MCP STDIO Transport. + */ +const createStream = () => + pretty({ + colorize: true, + translateTime: 'UTC:yyyy-mm-dd"T"HH:MM:ss.l"Z"', // UTC time with ISO format, e.g., 2024-06-01T12:00:00.000Z + ignore: 'pid,hostname', + destination: 2, // stderr + }); + +/** + * Base logger options for all logger instances. The logger name is set to 'mcp-server' and the log level is determined + * by the LOG_LEVEL environment variable, defaulting to 'info' if not set. + */ +const baseOptions: LoggerOptions = { + name: 'mcp-server', + level: process.env.LOG_LEVEL ?? 'info', +}; + +/** + * Creates a pino logger instance with optional module name prefix. If a module name is provided, it will be included + * as a prefix in all log messages from that logger instance. + * + * @param moduleName - Optional name of the module to include as a prefix in log messages. + * @remarks If no module name is provided, the logger will not include any prefix in log messages. + * + * @returns A pino logger instance, optionally with a module name prefix in log messages. + * + * @example + * ```ts + * const logger = createLogger('MyModule'); + * logger.info('This is an info message'); // Output: [MyModule] This is an info message + * ``` + */ +export const createLogger = (moduleName?: string): Logger => { + const logger = pino(baseOptions, createStream()); + + if (!moduleName) { + return logger; + } + + return logger.child( + {}, + { + msgPrefix: `[${moduleName}] `, + }, + ); +}; + +/** + * Default logger instance without a module name prefix for general use across the application. + */ +const logger = createLogger(); + +export default logger; diff --git a/docker/Dockerfile.jenkins b/docker/Dockerfile.jenkins index db8f990c2..64fa1d425 100644 --- a/docker/Dockerfile.jenkins +++ b/docker/Dockerfile.jenkins @@ -61,23 +61,23 @@ WORKDIR $APP_FOLDER COPY --from=scaffolding --chown=$APP_USER:$APP_USER $APP_FOLDER/node_modules ./node_modules COPY --from=scaffolding --chown=$APP_USER:$APP_USER $APP_FOLDER/apps/search-server ./apps/search-server COPY --from=scaffolding --chown=$APP_USER:$APP_USER \ -$APP_FOLDER/modules/graphql-router/dist \ -./modules/graphql-router/dist + $APP_FOLDER/modules/graphql-router/dist \ + ./modules/graphql-router/dist COPY --from=scaffolding --chown=$APP_USER:$APP_USER \ -$APP_FOLDER/modules/graphql-router/package.json \ -./modules/graphql-router/package.json + $APP_FOLDER/modules/graphql-router/package.json \ + ./modules/graphql-router/package.json COPY --from=scaffolding --chown=$APP_USER:$APP_USER \ -$APP_FOLDER/modules/types/dist \ -./modules/types/dist + $APP_FOLDER/modules/types/dist \ + ./modules/types/dist COPY --from=scaffolding --chown=$APP_USER:$APP_USER \ -$APP_FOLDER/modules/types/package.json \ -./modules/types/package.json + $APP_FOLDER/modules/types/package.json \ + ./modules/types/package.json COPY --from=scaffolding --chown=$APP_USER:$APP_USER \ -$APP_FOLDER/modules/sqon/dist \ -./modules/sqon/dist + $APP_FOLDER/modules/sqon/dist \ + ./modules/sqon/dist COPY --from=scaffolding --chown=$APP_USER:$APP_USER \ -$APP_FOLDER/modules/sqon/package.json \ -./modules/sqon/package.json + $APP_FOLDER/modules/sqon/package.json \ + ./modules/sqon/package.json COPY --from=scaffolding --chown=$APP_USER:$APP_USER $APP_FOLDER/scripts/ping-elasticsearch.sh ./scripts/ping-elasticsearch.sh COPY --from=scaffolding --chown=$APP_USER:$APP_USER $APP_FOLDER/scripts/retry-command.sh ./scripts/retry-command.sh @@ -93,3 +93,26 @@ CMD ["/bin/sh", "-c", "scripts/ping-elasticsearch.sh && node_modules/.bin/tsx . # Arranger MCP Server ####################################################### FROM node:24-alpine AS mcp-server + +ARG APP_FOLDER +ARG APP_USER +ARG APP_GID +ARG APP_UID + +LABEL org.opencontainers.image.source=https://github.com/overture-stack/arranger +LABEL org.opencontainers.image.description="Arranger MCP Server" + +RUN apk --no-cache add shadow \ + && groupmod -g $APP_GID $APP_USER \ + && usermod -u $APP_UID -g $APP_GID $APP_USER + +WORKDIR $APP_FOLDER + +COPY --from=scaffolding --chown=$APP_USER:$APP_USER $APP_FOLDER/node_modules ./node_modules +COPY --from=scaffolding --chown=$APP_USER:$APP_USER $APP_FOLDER/apps/mcp-server ./apps/mcp-server + +USER $APP_USER + +EXPOSE 3100 + +CMD ["node_modules/.bin/tsx", "./apps/mcp-server/src/index.ts"] diff --git a/docker/Dockerfile.jenkins.dockerignore b/docker/Dockerfile.jenkins.dockerignore index c658ac55d..3df526e2f 100644 --- a/docker/Dockerfile.jenkins.dockerignore +++ b/docker/Dockerfile.jenkins.dockerignore @@ -3,6 +3,7 @@ **/node_modules apps/**/* !apps/search-server +!apps/mcp-server docker-compose.yml docker docs diff --git a/docker/Dockerfile.local b/docker/Dockerfile.local index 97b0ad2b3..2d7a83675 100644 --- a/docker/Dockerfile.local +++ b/docker/Dockerfile.local @@ -49,23 +49,23 @@ WORKDIR $APP_FOLDER COPY --from=scaffolding --chown=$APP_USER:$APP_USER $APP_FOLDER/node_modules ./node_modules COPY --from=scaffolding --chown=$APP_USER:$APP_USER $APP_FOLDER/apps/search-server ./apps/search-server COPY --from=scaffolding --chown=$APP_USER:$APP_USER \ -$APP_FOLDER/modules/graphql-router/dist \ -./modules/graphql-router/dist + $APP_FOLDER/modules/graphql-router/dist \ + ./modules/graphql-router/dist COPY --from=scaffolding --chown=$APP_USER:$APP_USER \ -$APP_FOLDER/modules/graphql-router/package.json \ -./modules/graphql-router/package.json + $APP_FOLDER/modules/graphql-router/package.json \ + ./modules/graphql-router/package.json COPY --from=scaffolding --chown=$APP_USER:$APP_USER \ -$APP_FOLDER/modules/types/dist \ -./modules/types/dist + $APP_FOLDER/modules/types/dist \ + ./modules/types/dist COPY --from=scaffolding --chown=$APP_USER:$APP_USER \ -$APP_FOLDER/modules/types/package.json \ -./modules/types/package.json + $APP_FOLDER/modules/types/package.json \ + ./modules/types/package.json COPY --from=scaffolding --chown=$APP_USER:$APP_USER \ -$APP_FOLDER/modules/sqon/dist \ -./modules/sqon/dist + $APP_FOLDER/modules/sqon/dist \ + ./modules/sqon/dist COPY --from=scaffolding --chown=$APP_USER:$APP_USER \ -$APP_FOLDER/modules/sqon/package.json \ -./modules/sqon/package.json + $APP_FOLDER/modules/sqon/package.json \ + ./modules/sqon/package.json COPY --from=scaffolding --chown=$APP_USER:$APP_USER $APP_FOLDER/scripts/ping-elasticsearch.sh ./scripts/ping-elasticsearch.sh COPY --from=scaffolding --chown=$APP_USER:$APP_USER $APP_FOLDER/scripts/retry-command.sh ./scripts/retry-command.sh @@ -81,3 +81,24 @@ CMD ["/bin/sh", "-c", "scripts/ping-elasticsearch.sh && node_modules/.bin/tsx . # Arranger MCP Server ####################################################### FROM node:24-alpine AS mcp-server + +ARG APP_FOLDER +ARG APP_USER +ARG APP_GID +ARG APP_UID + +RUN apk --no-cache add shadow \ + && groupmod -g $APP_GID $APP_USER \ + && usermod -u $APP_UID -g $APP_GID $APP_USER + +WORKDIR $APP_FOLDER + +COPY --from=scaffolding --chown=$APP_USER:$APP_USER $APP_FOLDER/node_modules ./node_modules +COPY --from=scaffolding --chown=$APP_USER:$APP_USER $APP_FOLDER/apps/mcp-server ./apps/mcp-server + +USER $APP_USER + +EXPOSE 3100 + +CMD ["node_modules/.bin/tsx", "./apps/mcp-server/src/index.ts"] + diff --git a/docker/Dockerfile.local.dockerignore b/docker/Dockerfile.local.dockerignore index 1270255fb..f9e8cac18 100644 --- a/docker/Dockerfile.local.dockerignore +++ b/docker/Dockerfile.local.dockerignore @@ -5,6 +5,7 @@ **/node_modules apps/**/* !apps/search-server +!apps/mcp-server docker-compose.yml docker docs diff --git a/integration-tests/mcp-server/multiconfigs/catalog-a/base.json b/integration-tests/mcp-server/multiconfigs/catalog-a/base.json new file mode 100644 index 000000000..2f4fa12d6 --- /dev/null +++ b/integration-tests/mcp-server/multiconfigs/catalog-a/base.json @@ -0,0 +1,38 @@ +{ + "catalogId": "catalog-a", + "documentType": "modelA", + "esIndex": "testing-mcp-models_a", + "extended": [ + { + "displayName": "Analysis ID", + "displayType": "keyword", + "fieldName": "analysis_id", + "isActive": true, + "isArray": false, + "primaryKey": true, + "quickSearchEnabled": false, + "type": "keyword" + }, + { + "displayName": "Age at Diagnosis", + "displayType": "number", + "fieldName": "age_at_diagnosis", + "isActive": true, + "isArray": false, + "primaryKey": false, + "quickSearchEnabled": false, + "type": "long", + "unit": "years" + }, + { + "displayName": "Vital Status", + "displayType": "keyword", + "fieldName": "vital_status", + "isActive": true, + "isArray": false, + "primaryKey": false, + "quickSearchEnabled": false, + "type": "keyword" + } + ] +} diff --git a/integration-tests/mcp-server/multiconfigs/catalog-b/base.json b/integration-tests/mcp-server/multiconfigs/catalog-b/base.json new file mode 100644 index 000000000..4884c7828 --- /dev/null +++ b/integration-tests/mcp-server/multiconfigs/catalog-b/base.json @@ -0,0 +1,27 @@ +{ + "catalogId": "catalog-b", + "documentType": "modelB", + "esIndex": "testing-mcp-models_b", + "extended": [ + { + "displayName": "Sample ID", + "displayType": "keyword", + "fieldName": "sample_id", + "isActive": true, + "isArray": false, + "primaryKey": true, + "quickSearchEnabled": false, + "type": "keyword" + }, + { + "displayName": "Created At", + "displayType": "date", + "fieldName": "createdAt", + "isActive": true, + "isArray": false, + "primaryKey": false, + "quickSearchEnabled": false, + "type": "date" + } + ] +} diff --git a/integration-tests/mcp-server/package.json b/integration-tests/mcp-server/package.json new file mode 100644 index 000000000..0421d2236 --- /dev/null +++ b/integration-tests/mcp-server/package.json @@ -0,0 +1,17 @@ +{ + "name": "integration-tests-mcp-server", + "dependencies": { + "@elastic/elasticsearch": "^7.17.14", + "@modelcontextprotocol/sdk": "^1.29.0", + "@overture-stack/arranger-graphql-router": "file:../../modules/graphql-router" + }, + "devDependencies": { + "prettier": "^3.4.2", + "tsx": "^4.19.3" + }, + "private": true, + "scripts": { + "test": "tsx --test" + }, + "type": "module" +} diff --git a/integration-tests/mcp-server/test/assets/catalog_a.mappings.json b/integration-tests/mcp-server/test/assets/catalog_a.mappings.json new file mode 100644 index 000000000..3bc5c9128 --- /dev/null +++ b/integration-tests/mcp-server/test/assets/catalog_a.mappings.json @@ -0,0 +1,9 @@ +{ + "mappings": { + "properties": { + "analysis_id": { "type": "keyword" }, + "age_at_diagnosis": { "type": "long" }, + "vital_status": { "type": "keyword" } + } + } +} diff --git a/integration-tests/mcp-server/test/assets/catalog_b.mappings.json b/integration-tests/mcp-server/test/assets/catalog_b.mappings.json new file mode 100644 index 000000000..41ad28114 --- /dev/null +++ b/integration-tests/mcp-server/test/assets/catalog_b.mappings.json @@ -0,0 +1,8 @@ +{ + "mappings": { + "properties": { + "sample_id": { "type": "keyword" }, + "createdAt": { "type": "date" } + } + } +} diff --git a/integration-tests/mcp-server/test/index.test.ts b/integration-tests/mcp-server/test/index.test.ts new file mode 100644 index 000000000..afd229fc0 --- /dev/null +++ b/integration-tests/mcp-server/test/index.test.ts @@ -0,0 +1,245 @@ +import { after, before, suite } from 'node:test'; +import path from 'path'; + +import { Client } from '@modelcontextprotocol/sdk/client'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp'; +import { stringToNumber } from '@overture-stack/arranger-types/tools'; +import dotenv from 'dotenv'; + +import ArrangerServer from '../../../apps/search-server/src/server.js'; +import { buildSearchClient } from '../../../modules/graphql-router/src/index.js'; +import catalogABase from '../multiconfigs/catalog-a/base.json' with { type: 'json' }; +import catalogBBase from '../multiconfigs/catalog-b/base.json' with { type: 'json' }; + +import catalogAMappings from './assets/catalog_a.mappings.json' with { type: 'json' }; +import catalogBMappings from './assets/catalog_b.mappings.json' with { type: 'json' }; +import readResources from './readResources.js'; +import readTools from './readTools.js'; +import spinupActive from './spinupActive.js'; +import { startMcpServerForTest, type StartedMcpServer } from './startMcpServer.js'; + +dotenv.config({ path: path.resolve('../../.env.test') }); + +const esHost = process.env.ES_HOST || 'http://127.0.0.1:9200'; +const esPass = process.env.ES_PASS; +const esUser = process.env.ES_USER; +const setsIndex = process.env.ES_ARRANGER_SETS_INDEX || 'arranger-sets-mcp-testing'; +const setsType = process.env.ES_ARRANGER_SETS_TYPE || 'arranger-sets-mcp-testing'; +const searchEngine = process.env.SEARCH_ENGINE || 'elasticsearch'; +const arrangerPort = stringToNumber(process.env.SERVER_PORT, 5678); +const mcpPort = stringToNumber(process.env.MCP_TEST_PORT, 3199); + +const arrangerBaseUrl = `http://127.0.0.1:${arrangerPort}`; + +const catalogConfigs = [ + { + catalogId: catalogABase.catalogId, + documentType: catalogABase.documentType, + esIndex: catalogABase.esIndex, + mappings: catalogAMappings, + extendedFieldNames: catalogABase.extended.map((field) => field.fieldName), + }, + { + catalogId: catalogBBase.catalogId, + documentType: catalogBBase.documentType, + esIndex: catalogBBase.esIndex, + mappings: catalogBMappings, + extendedFieldNames: catalogBBase.extended.map((field) => field.fieldName), + }, +]; + +const configuredCatalogues = catalogConfigs.map((c) => c.catalogId); +const expectedDocumentTypes = Object.fromEntries(catalogConfigs.map((c) => [c.catalogId, c.documentType])); +const expectedFieldsByCatalog = Object.fromEntries(catalogConfigs.map((c) => [c.catalogId, c.extendedFieldNames])); + +const useESAuth = !!esPass && !!esUser; +const esClient = await buildSearchClient({ + client: searchEngine, + node: esHost, + ...(useESAuth && { + username: esUser, + password: esPass, + }), +}); + +const cleanupIndices = async () => { + const allTestIndices = [...catalogConfigs.map((c) => c.esIndex), setsIndex]; + const uniqueIndices = [...new Set(allTestIndices)]; + + const deletePromises = uniqueIndices.map(async (index) => { + try { + await esClient.indices.delete({ index }); + } catch (err: any) { + if (err?.meta?.body?.error?.type !== 'index_not_found_exception') { + console.warn(`Warning: Could not delete index ${index}:`, err.message); + } + } + }); + + await Promise.all(deletePromises); +}; + +// Test runtime context — populated by the `before` hook below, consumed by tests via `getClient()`. +// Defined here to avoid issues with test isolation and variable scope across the `before` hook and individual tests. +const context: { mcpClient?: Client } = {}; +const getClient = () => { + if (!context.mcpClient) { + throw new Error('MCP client has not been initialized — `before` hook did not run successfully'); + } + return context.mcpClient; +}; + +suite('integration-tests/mcp-server', { concurrency: false }, () => { + let arrangerApp: Awaited> | undefined; + let mcpServer: StartedMcpServer | undefined; + + // Does the following before tests run: + // - 1. Cleans up any existing test indices + // - 2. Initializes test indices with mappings for the test suite + // - 3. Starts an Arranger server in multicatalog mode + // - 4. Starts the MCP server + // - 5. Connects an MCP client to the MCP server and stores it in `context` for tests to use + before(async () => { + try { + await cleanupIndices(); + } catch { + // ignore — cleanup is best-effort + } + + try { + console.error('\n------------------------------------'); + console.log('Initializing Elasticsearch testing indices\n'); + + for (const { catalogId, esIndex, mappings } of catalogConfigs) { + console.debug(' - Creating index for', catalogId); + await esClient.indices.create({ + index: esIndex, + body: mappings, + }); + } + + console.log('\n Success!'); + } catch (err) { + console.error('------------------------------------'); + console.error('FATAL: Index setup failed - aborting tests\n'); + console.error(` ${err}\n`); + console.error('------------------------------------\n'); + process.exit(1); + } + + try { + console.error('\n------------------------------------'); + console.log('Setting up Arranger - Multicatalog Mode for MCP tests\n'); + + arrangerApp = await ArrangerServer({ + catalogConfigsPath: './multiconfigs', + disableDownloads: false, + disableFilters: false, + disablePlayground: false, + disableSets: true, + enableAdmin: false, + enableNetworkAggregation: undefined, + esClient, + serverPort: arrangerPort, + setsIndex, + setsType, + }); + } catch (err) { + console.error('\n\n------------------------------------'); + console.error('FATAL: Arranger Server is not available - aborting tests\n'); + console.error(` ${err instanceof Error ? err.stack : err}\n`); + console.error('------------------------------------\n'); + process.exit(1); + } + + try { + console.error('\n------------------------------------'); + console.log('Starting MCP Server for tests\n'); + + mcpServer = await startMcpServerForTest({ + arrangerBaseUrl, + catalogues: configuredCatalogues, + requestTimeoutMs: 5000, + mcp: { + host: '127.0.0.1', + port: mcpPort, + path: '/mcp', + }, + }); + } catch (err) { + console.error('\n\n------------------------------------'); + console.error('FATAL: MCP Server failed to start (likely a connection issue with Arranger)\n'); + console.error(` ${err instanceof Error ? err.stack : err}\n`); + console.error('------------------------------------\n'); + process.exit(1); + } + + try { + console.error('\n------------------------------------'); + console.log('Connecting MCP Client over Streamable HTTP\n'); + + const mcpClient = new Client({ name: 'arranger-mcp-server-integration-tests', version: '0.0.0-test' }); + const transport = new StreamableHTTPClientTransport(new URL(mcpServer.url)); + await mcpClient.connect(transport); + context.mcpClient = mcpClient; + } catch (err) { + console.error('\n\n------------------------------------'); + console.error('FATAL: MCP Client failed to connect to MCP Server\n'); + console.error(` ${err instanceof Error ? err.stack : err}\n`); + console.error('------------------------------------\n'); + process.exit(1); + } + }); + + suite('Startup and active spinup', () => { + spinupActive({ getClient, configuredCatalogues }); + }); + + suite('Resources', () => { + readResources({ + getClient, + configuredCatalogues, + expectedDocumentTypes, + expectedFieldsByCatalog, + }); + }); + + suite('Tools', () => { + readTools({ + getClient, + configuredCatalogues, + expectedDocumentTypes, + expectedFieldsByCatalog, + }); + }); + + after(async () => { + try { + await context.mcpClient?.close(); + console.log('\nDisconnected MCP Client\n'); + } catch (err) { + console.warn('Warning: error closing MCP client:', err); + } + + try { + await mcpServer?.shutdown(); + console.log('\nStopped MCP Server\n'); + } catch (err) { + console.warn('Warning: error shutting down MCP server:', err); + } + + try { + arrangerApp?.close(); + console.log('\nStopped Arranger Server\n'); + } catch (err) { + console.warn('Warning: error closing Arranger server:', err); + } + + try { + await cleanupIndices(); + console.log('\nCleared Elasticsearch testing indices\n'); + } catch (err) { + console.warn('Warning: error cleaning up indices:', err); + } + }); +}); diff --git a/integration-tests/mcp-server/test/readResources.ts b/integration-tests/mcp-server/test/readResources.ts new file mode 100644 index 000000000..c84980487 --- /dev/null +++ b/integration-tests/mcp-server/test/readResources.ts @@ -0,0 +1,79 @@ +import assert from 'node:assert'; +import { test } from 'node:test'; + +import { type Client } from '@modelcontextprotocol/sdk/client'; + +export type ResourceEnv = { + getClient: () => Client; + configuredCatalogues: string[]; + expectedDocumentTypes: Record; + expectedFieldsByCatalog: Record; +}; + +const readJsonResource = async (mcpClient: Client, uri: string) => { + const result = await mcpClient.readResource({ uri }); + assert.ok(result.contents.length > 0, `expected contents for resource ${uri}`); + const [first] = result.contents; + assert.equal(first.mimeType, 'application/json'); + assert.equal(first.uri, uri); + assert.equal(typeof first.text, 'string'); + return JSON.parse(first.text as string); +}; + +export default ({ getClient, configuredCatalogues, expectedDocumentTypes, expectedFieldsByCatalog }: ResourceEnv) => { + test('1.reads arranger://introspection/server', async () => { + const data = await readJsonResource(getClient(), 'arranger://introspection/server'); + + assert.equal(data.mode, 'multiple'); + assert.equal(data.catalogCount, configuredCatalogues.length); + assert.equal(data.sqonSchemaPath, '/introspection/sqon'); + + for (const catalogId of configuredCatalogues) { + const catalog = data.catalogs[catalogId]; + assert.ok(catalog, `expected catalog '${catalogId}' in server introspection`); + assert.equal(catalog.documentType, expectedDocumentTypes[catalogId]); + assert.equal(catalog.paths.graphql, `/${catalogId}/graphql`); + assert.equal(catalog.paths.introspection, `/introspection/${catalogId}`); + } + }); + + test('2.reads arranger://introspection/sqon', async () => { + const data = await readJsonResource(getClient(), 'arranger://introspection/sqon'); + + assert.equal(typeof data.version, 'string'); + assert.equal(typeof data.title, 'string'); + assert.ok(data.schema, 'expected SQON schema body'); + assert.ok(data.operators, 'expected SQON operator metadata'); + assert.ok(Array.isArray(data.operators.combination)); + assert.ok(Array.isArray(data.operators.field)); + }); + + test('3.lists catalog field resources via the resource template', async () => { + const { resourceTemplates } = await getClient().listResourceTemplates(); + const template = resourceTemplates.find( + (t) => t.uriTemplate === 'arranger://introspection/catalog/{catalogId}', + ); + assert.ok(template, 'expected catalog-fields resource template to be registered'); + }); + + test('4.reads arranger://introspection/catalog/{id} for each configured catalogue', async () => { + for (const catalogId of configuredCatalogues) { + const data = await readJsonResource(getClient(), `arranger://introspection/catalog/${catalogId}`); + + assert.equal(data.catalogId, catalogId); + assert.equal(data.documentType, expectedDocumentTypes[catalogId]); + assert.equal(typeof data.generatedAt, 'string'); + assert.ok(data.fields, 'expected fields object on catalog introspection'); + + const fieldNames = Object.keys(data.fields).sort(); + const expectedFieldNames = [...expectedFieldsByCatalog[catalogId]].sort(); + assert.deepEqual(fieldNames, expectedFieldNames); + + for (const fieldName of expectedFieldNames) { + const field = data.fields[fieldName]; + assert.equal(typeof field.displayName, 'string'); + assert.equal(typeof field.type, 'string'); + } + } + }); +}; diff --git a/integration-tests/mcp-server/test/readTools.ts b/integration-tests/mcp-server/test/readTools.ts new file mode 100644 index 000000000..079bfb3eb --- /dev/null +++ b/integration-tests/mcp-server/test/readTools.ts @@ -0,0 +1,82 @@ +import assert from 'node:assert'; +import { test } from 'node:test'; + +import { type Client } from '@modelcontextprotocol/sdk/client'; + +export type ToolEnv = { + getClient: () => Client; + configuredCatalogues: string[]; + expectedDocumentTypes: Record; + expectedFieldsByCatalog: Record; +}; + +const getTextContent = (result: Awaited>): string => { + assert.notEqual(result.isError, true, `tool call returned isError: ${JSON.stringify(result)}`); + assert.ok(Array.isArray(result.content), 'expected tool result content to be an array'); + + const [first] = result.content as { type: string; text?: string }[]; + assert.ok(first, 'expected at least one content entry in tool result'); + assert.equal(first.type, 'text'); + assert.equal(typeof first.text, 'string'); + return first.text as string; +}; + +export default ({ getClient, configuredCatalogues, expectedFieldsByCatalog }: ToolEnv) => { + test("1.'list-catalogs' returns the configured catalogue IDs", async () => { + const result = await getClient().callTool({ name: 'list-catalogs' }); + const text = getTextContent(result); + + for (const catalogId of configuredCatalogues) { + assert.ok(text.includes(catalogId), `expected '${catalogId}' in list-catalogs output, got: ${text}`); + } + }); + + test("2.'get-sqon-schema' returns a valid SQON introspection payload", async () => { + const result = await getClient().callTool({ name: 'get-sqon-schema' }); + const text = getTextContent(result); + const data = JSON.parse(text); + + assert.equal(typeof data.version, 'string'); + assert.equal(typeof data.title, 'string'); + assert.ok(data.schema); + assert.ok(data.operators); + assert.ok(Array.isArray(data.operators.combination)); + assert.ok(Array.isArray(data.operators.field)); + }); + + test("3.'get-catalog-fields' returns field metadata for each configured catalogue", async () => { + for (const catalogId of configuredCatalogues) { + const result = await getClient().callTool({ + name: 'get-catalog-fields', + arguments: { catalogId }, + }); + + const text = getTextContent(result); + const data = JSON.parse(text); + + assert.equal(data.catalogId, catalogId); + assert.ok(data.fields, `expected fields object for '${catalogId}'`); + + const fieldNames = Object.keys(data.fields).sort(); + const expected = [...expectedFieldsByCatalog[catalogId]].sort(); + assert.deepEqual(fieldNames, expected); + + // Tool also declares an outputSchema -> structured content should match the text content. + const structured = ( + result as { structuredContent?: { catalogId: string; fields: Record } } + ).structuredContent; + assert.ok(structured, "expected 'get-catalog-fields' to return structuredContent"); + assert.equal(structured?.catalogId, catalogId); + assert.deepEqual(Object.keys(structured?.fields ?? {}).sort(), expected); + } + }); + + test("4.'get-catalog-fields' returns an error for an unknown catalogue", async () => { + const result = await getClient().callTool({ + name: 'get-catalog-fields', + arguments: { catalogId: 'this-catalog-does-not-exist' }, + }); + + assert.equal(result.isError, true, 'expected tool call to surface the upstream Arranger 404 as an MCP error'); + }); +}; diff --git a/integration-tests/mcp-server/test/spinupActive.ts b/integration-tests/mcp-server/test/spinupActive.ts new file mode 100644 index 000000000..cef69e269 --- /dev/null +++ b/integration-tests/mcp-server/test/spinupActive.ts @@ -0,0 +1,53 @@ +import assert from 'node:assert'; +import { test } from 'node:test'; + +import { type Client } from '@modelcontextprotocol/sdk/client'; + +export type SpinupEnv = { + getClient: () => Client; + configuredCatalogues: string[]; +}; + +/** + * Asserts that the MCP server started successfully and is reachable via the MCP protocol. + * + * Reaching this suite means `startMcpServerForTest` already validated the connection to Arranger + * (`validateArrangerConnection` runs before `app.listen`). Anything here is a redundancy check + * to confirm that the MCP transport is wired correctly and that the resources/tools registered + * by the server are visible to a client. + */ +export default ({ getClient, configuredCatalogues }: SpinupEnv) => { + test('1.responds to a ping over the MCP transport', async () => { + await assert.doesNotReject(getClient().ping()); + }); + + test('2.reports server name and version after initialization', async () => { + const info = getClient().getServerVersion(); + assert.ok(info, 'expected server version info to be populated after connect()'); + assert.equal(info?.name, 'arranger-mcp-server'); + }); + + test('3.advertises resources and tools capabilities', async () => { + const capabilities = getClient().getServerCapabilities(); + assert.ok(capabilities, 'expected server capabilities to be populated after connect()'); + assert.ok(capabilities?.resources, 'expected resources capability'); + assert.ok(capabilities?.tools, 'expected tools capability'); + }); + + test('4.lists the three resources registered by the MCP server', async () => { + const { resources } = await getClient().listResources(); + const uris = resources.map((resource) => resource.uri).sort(); + const expected = [ + 'arranger://introspection/server', + 'arranger://introspection/sqon', + ...configuredCatalogues.map((id) => `arranger://introspection/catalog/${id}`), + ].sort(); + assert.deepEqual(uris, expected); + }); + + test('5.lists the three tools registered by the MCP server', async () => { + const { tools } = await getClient().listTools(); + const names = tools.map((tool) => tool.name).sort(); + assert.deepEqual(names, ['get-catalog-fields', 'get-sqon-schema', 'list-catalogs']); + }); +}; diff --git a/integration-tests/mcp-server/test/startMcpServer.ts b/integration-tests/mcp-server/test/startMcpServer.ts new file mode 100644 index 000000000..b774a106e --- /dev/null +++ b/integration-tests/mcp-server/test/startMcpServer.ts @@ -0,0 +1,55 @@ +import { type Server } from 'http'; + +import { createArrangerIntrospectionClient } from '../../../apps/mcp-server/src/arranger/client.js'; +import { validateArrangerConnection } from '../../../apps/mcp-server/src/arranger/validation.js'; +import { createHttpApp } from '../../../apps/mcp-server/src/http/app.js'; +import { createMcpServer } from '../../../apps/mcp-server/src/server.js'; +import type { ArrangerMcpConfig } from '../../../apps/mcp-server/src/utils/config.js'; + +export type StartedMcpServer = { + config: ArrangerMcpConfig; + httpServer: Server; + url: string; + shutdown: () => Promise; +}; + +/** + * Starts the MCP server in-process for integration testing. + * + * Mirrors `startServer()` from `apps/mcp-server/src/server.ts`, but accepts a config object + * directly (instead of reading `process.env`) and returns the http.Server so the test harness + * can close it during teardown. + * + * Calling `validateArrangerConnection` here also serves as the "startup proves connectivity" + * assertion: if the configured Arranger isn't reachable, this throws and the test fails. + */ +export const startMcpServerForTest = async (config: ArrangerMcpConfig): Promise => { + const introspectionClient = createArrangerIntrospectionClient(config); + + await validateArrangerConnection(config, introspectionClient); + + const { app, closeAllSessions } = createHttpApp(config, () => + createMcpServer({ config, client: introspectionClient }), + ); + + const { host, port, path } = config.mcp; + + const httpServer = await new Promise((resolve, reject) => { + const server = app.listen(port, host, () => resolve(server)); + server.once('error', reject); + }); + + const shutdown = async () => { + await closeAllSessions(); + await new Promise((resolve, reject) => { + httpServer.close((err) => (err ? reject(err) : resolve())); + }); + }; + + return { + config, + httpServer, + url: `http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${port}${path}`, + shutdown, + }; +}; diff --git a/integration-tests/mcp-server/tsconfig.json b/integration-tests/mcp-server/tsconfig.json new file mode 100644 index 000000000..321e54413 --- /dev/null +++ b/integration-tests/mcp-server/tsconfig.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + /* Modules */ + "module": "nodenext" /* Specify what module code is generated. */, + "moduleResolution": "nodenext" /* Specify how TypeScript looks up a file from a given module specifier. */, + + /* JavaScript Support */ + "allowJs": true /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */, + "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, + + /* Emit */ + "noEmit": true /* Disable emitting files from a compilation. */, + + /* Type Checking */ + "erasableSyntaxOnly": true + } +} diff --git a/package-lock.json b/package-lock.json index 581cfe33c..e05626348 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "apps/search-server", "apps/mcp-server", "integration-tests/import", + "integration-tests/mcp-server", "integration-tests/server", "modules/charts", "modules/components", @@ -43,7 +44,53 @@ }, "apps/mcp-server": { "name": "@overture-stack/arranger-mcp-server", - "version": "0.0.0-dev" + "version": "0.0.0-dev", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "cors": "^2.8.6", + "dotenv": "^16.6.1", + "express": "^4.21.2", + "pino": "^10.3.1", + "pino-pretty": "^13.1.3", + "tsx": "^4.21.0", + "zod": "^3.25.76" + }, + "devDependencies": { + "@tsconfig/node22": "^22.0.5", + "@types/cors": "^2.8.12", + "@types/express": "^4.17.14", + "@types/node": "^25.6.2", + "typescript": "^5.8.3" + } + }, + "apps/mcp-server/node_modules/@types/node": { + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "apps/mcp-server/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "apps/mcp-server/node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" }, "apps/search-server": { "name": "@overture-stack/arranger-search-server", @@ -88,6 +135,18 @@ "ts-jest": "^29.0.5" } }, + "integration-tests/mcp-server": { + "name": "integration-tests-mcp-server", + "dependencies": { + "@elastic/elasticsearch": "^7.17.14", + "@modelcontextprotocol/sdk": "^1.29.0", + "@overture-stack/arranger-graphql-router": "file:../../modules/graphql-router" + }, + "devDependencies": { + "prettier": "^3.4.2", + "tsx": "^4.19.3" + } + }, "integration-tests/server": { "name": "integration-tests-search-server", "dependencies": { @@ -809,37 +868,6 @@ "@esbuild/win32-x64": "0.17.19" } }, - "modules/types/node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "nanoid": "^3.3.12", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, "modules/types/node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3355,474 +3383,6 @@ "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", "license": "MIT" }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -4263,6 +3823,18 @@ "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -5532,6 +5104,352 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -5878,6 +5796,12 @@ "zod": "^3.21.4" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -7250,222 +7174,6 @@ } } }, - "node_modules/@swc/core-darwin-arm64": { - "version": "1.15.33", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.33.tgz", - "integrity": "sha512-N+L0uXhuO7FIfzqwgxmzv0zIpV0qEp8wPX3QQs2p4atjMoywup2JTeDlXPw+z9pWJGCae3JjM+tZ6myclI+2gA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-darwin-x64": { - "version": "1.15.33", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.33.tgz", - "integrity": "sha512-/Il4QHSOhV4FekbsDtkrNmKbsX26oSysvgrRswa/RYOHXAkwXDbB4jaeKq6PsJLSPkzJ2KzQ061gtBnk0vNHfA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.15.33", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.33.tgz", - "integrity": "sha512-C64hBnBxq4viOPQ8hlx+2lJ23bzZBGnjw7ryALmS+0Q3zHmwO8lw1/DArLENw4Q18/0w5wdEO1k3m1wWNtKGqQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.15.33", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.33.tgz", - "integrity": "sha512-TRJfnJbX3jqpxRDRoieMzRiCBS5jOmXNb3iQXmcgjFEHKLnAgK1RZRU8Cq1MsPqO4jAJp/ld1G4O3fXuxv85uw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.15.33", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.33.tgz", - "integrity": "sha512-il7tYM+CpUNzieQbwAjFT1P8zqAhmGWNAGhQZBnxurXZ0aNn+5nqYFTEUKNZl7QibtT0uQXzTZrNGHCIj6Y1Og==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-ppc64-gnu": { - "version": "1.15.33", - "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.33.tgz", - "integrity": "sha512-ZtNBwN0Z7CFj9Il0FcPaKdjgP7URyKu/3RfH46vq+0paOBqLj4NYldD6Qo//Duif/7IOtAraUfDOmp0PLAufog==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-s390x-gnu": { - "version": "1.15.33", - "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.33.tgz", - "integrity": "sha512-De1IyajoOmhOYYjw/lx66bKlyDpHZTueqwpDrWgf5O7T6d1ODeJJO9/OqMBmrBQc5C+dNnlmIufHsp4QVCWufA==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.15.33", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.33.tgz", - "integrity": "sha512-mGTH0YxmUN+x6vRN/I6NOk5X0ogNktkwPnJ94IMvR7QjhRDwL0O8RXEDhyUM0YtwWrryBOqaJQBX4zruxEPRGw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-x64-musl": { - "version": "1.15.33", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.33.tgz", - "integrity": "sha512-hj628ZkSEJf6zMf5VMbYrG2O6QqyTIp2qwY6VlCjvIa9lAEZ5c2lfPblCLVGYubTeLJDxadLB/CxqQYOQABeEQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.15.33", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.33.tgz", - "integrity": "sha512-GV2oohtN2/5+KSccl86VULu3aT+LrISC8uzgSq0FRnikpD+Zwc+sBlXmoKQ+Db6jI57ITUOIB8jRkdGMABC29g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.15.33", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.33.tgz", - "integrity": "sha512-gtyvzSNR8DHKfFEA2uqb8Ld1myqi6uEg2jyeUq3ikn5ytYs7H8RpZYC8mdy4NXr8hfcdJfCLXPlYaqqfBXpoEQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.15.33", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.33.tgz", - "integrity": "sha512-d6fRqQSkJI+kmMEBWaDQ7TMl8+YjLYbwRUPZQ9DY0ORBJeTzOrG0twvfvlZ2xgw6jA0ScQKgfBm4vHLSLl5Hqg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, "node_modules/@swc/counter": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", @@ -9444,7 +9152,6 @@ "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -9476,7 +9183,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "dev": true, "license": "MIT", "dependencies": { "ajv": "^8.0.0" @@ -10430,6 +10136,15 @@ "node": ">= 4.5.0" } }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/autobind-decorator": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/autobind-decorator/-/autobind-decorator-1.4.3.tgz", @@ -13710,6 +13425,12 @@ "node": ">=18" } }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "license": "MIT" + }, "node_modules/colormin": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/colormin/-/colormin-1.1.2.tgz", @@ -14295,7 +14016,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -14984,6 +14704,15 @@ "url": "https://opencollective.com/date-fns" } }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/de-indent": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", @@ -16861,6 +16590,15 @@ "node": ">=0.8.0" } }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/evp_bytestokey": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", @@ -17037,6 +16775,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -17148,11 +16904,16 @@ "node": ">= 0.4" } }, + "node_modules/fast-copy": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.3.tgz", + "integrity": "sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -17192,11 +16953,16 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "license": "MIT" + }, "node_modules/fast-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", - "dev": true, "funding": [ { "type": "github", @@ -18591,6 +18357,12 @@ "he": "bin/he" } }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "license": "MIT" + }, "node_modules/history": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", @@ -18654,6 +18426,15 @@ "node": ">=0.10.0" } }, + "node_modules/hono": { + "version": "4.12.23", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", + "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/hosted-git-info": { "version": "2.8.9", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", @@ -19294,6 +19075,10 @@ "resolved": "integration-tests/import", "link": true }, + "node_modules/integration-tests-mcp-server": { + "resolved": "integration-tests/mcp-server", + "link": true + }, "node_modules/integration-tests-search-server": { "resolved": "integration-tests/server", "link": true @@ -19351,6 +19136,15 @@ "node": ">=0.10.0" } }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -19849,6 +19643,12 @@ "node": ">=0.10.0" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -20066,7 +19866,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/isobject": { @@ -22107,11 +21906,19 @@ "dev": true, "license": "MIT" }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -22201,9 +22008,14 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -23293,7 +23105,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -24238,6 +24049,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -24787,7 +24607,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -24902,6 +24721,105 @@ "node": ">=6" } }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-pretty": { + "version": "13.1.3", + "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz", + "integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==", + "license": "MIT", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^4.0.0", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pump": "^3.0.0", + "secure-json-parse": "^4.0.0", + "sonic-boom": "^4.0.1", + "strip-json-comments": "^5.0.2" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "node_modules/pino-pretty/node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/pino-pretty/node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/pino-pretty/node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/pirates": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", @@ -24912,6 +24830,15 @@ "node": ">= 6" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkg-dir": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-2.0.0.tgz", @@ -28510,6 +28437,22 @@ "dev": true, "license": "MIT" }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/promise": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", @@ -28805,6 +28748,12 @@ ], "license": "MIT" }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/radium": { "version": "0.19.6", "resolved": "https://registry.npmjs.org/radium/-/radium-0.19.6.tgz", @@ -29846,6 +29795,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/recast": { "version": "0.11.23", "resolved": "https://registry.npmjs.org/recast/-/recast-0.11.23.tgz", @@ -30260,7 +30218,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -30564,6 +30521,32 @@ "dev": true, "license": "MIT" }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/run-async": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", @@ -30730,6 +30713,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -31035,7 +31027,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -31048,7 +31039,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -31389,6 +31379,15 @@ "dev": true, "license": "MIT" }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/sort-keys": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-1.1.2.tgz", @@ -31543,6 +31542,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -32451,6 +32459,24 @@ "node": ">=0.8" } }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, "node_modules/through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", @@ -36163,7 +36189,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -36554,24 +36579,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/package.json b/package.json index 767f7db49..eb83f116a 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,10 @@ "graphql-router:build": "npm run build -w modules/graphql-router", "graphql-router:watch": "npm run watch -w modules/graphql-router", "install:memory-safe": "npm i --ignore-scripts && npm rebuild --foreground-scripts --workspaces --loglevel=warn", - "modules:build": "npm run build -w modules", + "mcp-server": "npm run start --w apps/mcp-server", + "mcp-server:dev": "npm run dev --w apps/mcp-server", + "mcp-server:inspect": "npm run inspect --w apps/mcp-server", + "modules:build": "npm run build --w modules", "modules:tag": "npm version --ws --force-publish --yes", "modules:watch": "npm run watch -w modules", "publish::ci": "npm publish --ws --if-present", @@ -71,6 +74,7 @@ "apps/search-server", "apps/mcp-server", "integration-tests/import", + "integration-tests/mcp-server", "integration-tests/server", "modules/charts", "modules/components",