Implementation of MCP for LLM Observability capture to PostHig
Some checks failed
CI - Semantic Release / Semantic Release (push) Failing after 7m48s

This commit is contained in:
2025-07-13 20:42:19 -05:00
commit 05af3880f6
45 changed files with 16894 additions and 0 deletions

9
.env.example Normal file
View File

@@ -0,0 +1,9 @@
# Enable debug logging
DEBUG=false
# Transport mode
TRANSPORT_MODE=<http | stdio>
# PostHog LLM Observability configuration
POSTHOG_API_KEY=<YourPostHogAPIKeyGoesHere>
POSTHOG_HOST=https://us.i.posthog.com

26
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,26 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
versioning-strategy: auto
labels:
- "dependencies"
commit-message:
prefix: "chore"
include: "scope"
allow:
- dependency-type: "direct"
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-patch"]
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
labels:
- "dependencies"
- "github-actions"

View File

@@ -0,0 +1,45 @@
name: CI - Dependabot Auto-merge
on:
pull_request:
branches: [main]
permissions:
contents: write
pull-requests: write
checks: read
jobs:
auto-merge-dependabot:
runs-on: ubuntu-latest
if: github.actor == 'dependabot[bot]'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Run linting
run: npm run lint
- name: Auto-approve PR
uses: hmarr/auto-approve-action@v4
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Enable auto-merge
if: success()
run: gh pr merge --auto --merge "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -0,0 +1,37 @@
name: CI - Dependency Check
on:
schedule:
- cron: '0 5 * * 1' # Run at 5 AM UTC every Monday
workflow_dispatch: # Allow manual triggering
jobs:
validate:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run npm audit
run: npm audit
- name: Check for outdated dependencies
run: npm outdated || true
- name: Run tests
run: npm test
- name: Run linting
run: npm run lint
- name: Build project
run: npm run build

View File

@@ -0,0 +1,66 @@
name: CI - Semantic Release
# This workflow is triggered on every push to the main branch
# It analyzes commits and automatically releases a new version when needed
on:
push:
branches: [main]
jobs:
release:
name: Semantic Release
runs-on: ubuntu-latest
# Permissions needed for creating releases, updating issues, and publishing packages
permissions:
contents: write # Needed to create releases and tags
issues: write # Needed to comment on issues
pull-requests: write # Needed to comment on pull requests
# packages permission removed as we're not using GitHub Packages
steps:
# Step 1: Check out the full Git history for proper versioning
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # Fetches all history for all branches and tags
# Step 2: Setup Node.js environment
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22 # Using Node.js 22
cache: 'npm' # Enable npm caching
# Step 3: Install dependencies with clean install
- name: Install dependencies
run: npm ci # Clean install preserving package-lock.json
# Step 4: Build the package
- name: Build
run: npm run build # Compiles TypeScript to JavaScript
# Step 5: Ensure executable permissions
- name: Set executable permissions
run: chmod +x dist/index.js
# Step 6: Run tests to ensure quality
- name: Verify tests
run: npm test # Runs Jest tests
# Step 7: Configure Git identity for releases
- name: Configure Git User
run: |
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git config --global user.name "github-actions[bot]"
# Step 8: Run semantic-release to analyze commits and publish to npm
- name: Semantic Release
id: semantic
env:
# Tokens needed for GitHub and npm authentication
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # For creating releases and commenting
NPM_TOKEN: ${{ secrets.NPM_TOKEN }} # For publishing to npm
run: |
echo "Running semantic-release for version bump and npm publishing"
npx semantic-release
# Note: GitHub Packages publishing has been removed

44
.gitignore vendored Normal file
View File

@@ -0,0 +1,44 @@
# Dependencies
node_modules/
# Build
dist/
# Coverage reports
coverage/
# Backup files
*.bak
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Editor directories and files
.idea/
.vscode/
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# macOS
.DS_Store
# Misc
.npm
.yarn-integrity
# Kilocode settings
.kilocode/

0
.gitkeep Normal file
View File

1
.node-version Normal file
View File

@@ -0,0 +1 @@
22.14.0

43
.npmignore Normal file
View File

@@ -0,0 +1,43 @@
# Source code
src/
*.ts
!*.d.ts
# Tests
*.test.ts
*.test.js
__tests__/
coverage/
jest.config.js
# Development files
.github/
.git/
.gitignore
.eslintrc
.eslintrc.js
.eslintignore
.prettierrc
.prettierrc.js
tsconfig.json
*.tsbuildinfo
# Editor directories
.idea/
.vscode/
*.swp
*.swo
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# CI/CD
.travis.yml
# Runtime data
.env
.env.*

7
.npmrc Normal file
View File

@@ -0,0 +1,7 @@
# This file is for local development only
# The CI/CD workflow will create its own .npmrc files
# For npm registry
registry=https://registry.npmjs.org/
# GitHub Packages configuration removed

8
.prettierrc Normal file
View File

@@ -0,0 +1,8 @@
{
"singleQuote": true,
"semi": true,
"useTabs": true,
"tabWidth": 4,
"printWidth": 80,
"trailingComma": "all"
}

34
.releaserc.json Normal file
View File

@@ -0,0 +1,34 @@
{
"branches": ["main"],
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
"@semantic-release/changelog",
[
"@semantic-release/exec",
{
"prepareCmd": "node scripts/update-version.js ${nextRelease.version} && npm run build && chmod +x dist/index.js"
}
],
[
"@semantic-release/npm",
{
"npmPublish": true,
"pkgRoot": "."
}
],
[
"@semantic-release/git",
{
"assets": [
"package.json",
"CHANGELOG.md",
"src/index.ts",
"src/cli/index.ts"
],
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
}
],
"@semantic-release/github"
]
}

7
CHANGELOG.md Normal file
View File

@@ -0,0 +1,7 @@
# Changelog
## [0.1.0] - 2024-08-01
### Initial Release
* Initial version of LLM Log MCP Server

197
README.md Normal file
View File

@@ -0,0 +1,197 @@
# LLM Observability MCP for PostHog
[![NPM Version](https://img.shields.io/npm/v/@sfiorini/llm-observability-mcp)](https://www.npmjs.com/package/@sfiorini/llm-observability-mcp)
[![Build Status](https://img.shields.io/github/workflow/status/sfiorini/llm-observability-mcp/CI)](https://github.com/sfiorini/llm-observability-mcp)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.0%2B-blue)](https://www.typescriptlang.org/)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
A Model Context Protocol (MCP) server that provides a tool to capture LLM Observability events and send them to PostHog.
## Overview
This project is an MCP server designed to track and observe Large Language Model (LLM) interactions using [PostHog's LLM Observability](https://posthog.com/docs/llm-observability) features. It allows you to capture detailed information about LLM requests, responses, performance, and costs, providing valuable insights into your AI-powered applications.
The server can be run as a local process communicating over `stdio` or as a remote `http` server, making it compatible with any MCP client, such as AI-powered IDEs (e.g., VS Code with an MCP extension, Cursor) or custom applications.
## Features
- **Capture LLM Metrics**: Log key details of LLM interactions, including model, provider, latency, token counts, and more.
- **Flexible Transport**: Run as a local `stdio` process for tight IDE integration or as a standalone `http` server for remote access.
- **Dynamic Configuration**: Configure the server easily using environment variables.
- **Easy Integration**: Connect to MCP-compatible IDEs or use the programmatic client for use in any TypeScript/JavaScript application.
## Installation for Development
Follow these steps to set up the server for local development.
1. **Prerequisites**:
- Node.js (>=18.x)
- A [PostHog account](https://posthog.com/) with an API Key and Host URL.
2. **Clone and Install**:
```bash
git clone https://github.com/sfiorini/llm-observability-mcp.git
cd llm-observability-mcp
npm install
```
3. **Configuration**:
Create a `.env` file in the root of the project by copying the example file:
```bash
cp .env.example .env
```
Then, edit the `.env` file with your PostHog credentials and desired transport mode.
## Configuration
The server is configured via environment variables.
| Variable | Description | Default | Example |
| ----------------- | --------------------------------------------------------------------------- | --------- | ------------------------------------- |
| `POSTHOG_API_KEY` | **Required.** Your PostHog Project API Key. | - | `phc_...` |
| `POSTHOG_HOST` | **Required.** The URL of your PostHog instance. | - | `https://us.i.posthog.com` |
| `TRANSPORT_MODE` | The transport protocol to use. Can be `http` or `stdio`. | `http` | `stdio` |
| `DEBUG` | Set to `true` to enable detailed debug logging. | `false` | `true` |
## Running the Server
You can run the server in two modes:
- **HTTP Mode**: Runs a web server, typically for remote clients or IDEs like Cursor.
```bash
npm run mcp:http
```
The server will start on `http://localhost:3000`.
- **STDIO Mode**: Runs as a command-line process, ideal for local IDE integration where the IDE manages the process lifecycle.
```bash
npm run mcp:stdio
```
## Usage
### Connecting to an IDE (VS Code, Cursor, etc.)
You can integrate this tool with any MCP-compatible IDE. Add one of the following configurations to your IDE's MCP settings (e.g., in `.vscode/settings.json` for VS Code or `.kilocode/mcp.json` for a global setup).
#### Option 1: Local Stdio Process (Recommended)
This method lets the IDE manage the server as a local background process. It's efficient and doesn't require a separate terminal.
```json
{
"mcpServers": {
"llm-observability-mcp": {
"command": "node",
"args": [
"/path/to/your/projects/llm-log-mcp-server/dist/index.js"
],
"env": {
"TRANSPORT_MODE": "stdio",
"POSTHOG_API_KEY": "phc_...",
"POSTHOG_HOST": "https://us.i.posthog.com"
}
}
}
}
```
**Note**: Replace `/path/to/your/projects/llm-log-mcp-server` with the absolute path to this project directory.
#### Option 2: Remote HTTP Server
Use this if you prefer to run the server as a standalone process.
1. Run the server in a terminal: `npm run mcp:http`
2. Add the server URL to your IDE's configuration:
```json
{
"mcpServers": {
"llm-observability-mcp": {
"url": "http://localhost:3000/mcp"
}
}
}
```
#### Automatic Triggering via System Prompt
For IDE extensions that support system prompts, you can instruct the AI to automatically use this MCP tool for every interaction. Add the following to your IDE's system prompt configuration:
"Use the `capture_llm_observability` MCP tool and send all requested data. For the `userId`, send `<my_username>`."
Replace `<my_username>` with a unique identifier for yourself. This ensures that all LLM activity is automatically logged in PostHog without needing to give the command each time.
### Programmatic Usage
You can use an MCP client library to interact with the server programmatically from your own applications.
```typescript
import { McpClient } from '@modelcontextprotocol/sdk/client';
async function main() {
// Assumes the MCP server is running in HTTP mode
const client = new McpClient({
transport: {
type: 'http',
url: 'http://localhost:3000/mcp',
},
});
await client.connect();
const result = await client.useTool('capture_llm_observability', {
userId: 'user-123',
model: 'gpt-4',
provider: 'openai',
input: 'What is the capital of France?',
outputChoices: [{ text: 'Paris.' }],
inputTokens: 8,
outputTokens: 2,
latency: 0.5,
});
console.log('Tool result:', result);
await client.disconnect();
}
main().catch(console.error);
```
## Tool Reference: `capture_llm_observability`
This is the core tool provided by the server. It captures LLM usage in PostHog for observability, including requests, responses, and performance metrics.
### Parameters
| Parameter | Type | Required | Description |
| --------------- | ------------------- | -------- | ----------------------------------------------- |
| `userId` | `string` | Yes | The distinct ID of the user. |
| `model` | `string` | Yes | The model used (e.g., `gpt-4`, `claude-3`). |
| `provider` | `string` | Yes | The LLM provider (e.g., `openai`, `anthropic`). |
| `traceId` | `string` | No | The trace ID to group related AI events. |
| `input` | `any` | No | The input to the LLM (e.g., messages, prompt). |
| `outputChoices` | `any` | No | The output choices from the LLM. |
| `inputTokens` | `number` | No | The number of tokens in the input. |
| `outputTokens` | `number` | No | The number of tokens in the output. |
| `latency` | `number` | No | The latency of the LLM call in seconds. |
| `httpStatus` | `number` | No | The HTTP status code of the LLM API call. |
| `baseUrl` | `string` | No | The base URL of the LLM API. |
## Development
- **Run in dev mode (HTTP)**: `npm run dev:http`
- **Run tests**: `npm test`
- **Lint and format**: `npm run lint` and `npm run format`
## License
[MIT License](https://opensource.org/licenses/MIT)

9
cspell.json Normal file
View File

@@ -0,0 +1,9 @@
{
"version": "0.2",
"ignorePaths": ["src","dist", "node_modules", "coverage", "test", "tests", "package.json", "pnpm-lock.yaml", "pnpm-lock.yaml", "pnpm-lock.json", "pnpm-workspace.yaml", "cspell.json", "tsconfig.json", "tsconfig.build.json", "tsconfig.node.json", "tsconfig.test.json"],
"dictionaryDefinitions": [],
"dictionaries": [],
"words": [],
"ignoreWords": [],
"import": []
}

46
eslint.config.mjs Normal file
View File

@@ -0,0 +1,46 @@
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
import prettierPlugin from 'eslint-plugin-prettier';
import eslintConfigPrettier from 'eslint-config-prettier';
export default tseslint.config(
{
ignores: ['node_modules/**', 'dist/**', 'examples/**'],
},
eslint.configs.recommended,
...tseslint.configs.recommended,
{
plugins: {
prettier: prettierPlugin,
},
rules: {
'prettier/prettier': 'error',
indent: ['error', 'tab', { SwitchCase: 1 }],
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_' },
],
},
languageOptions: {
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
globals: {
node: 'readonly',
jest: 'readonly',
},
},
},
// Special rules for test files
{
files: ['**/*.test.ts'],
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-require-imports': 'off',
'@typescript-eslint/no-unsafe-function-type': 'off',
'@typescript-eslint/no-unused-vars': 'off',
},
},
eslintConfigPrettier,
);

13099
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

116
package.json Normal file
View File

@@ -0,0 +1,116 @@
{
"name": "@sfiorini/llm-observability-mcp",
"version": "0.1.0",
"description": "A Model Context Protocol (MCP) server that provides a tool to capture LLM Observability events and send them to PostHog.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"type": "commonjs",
"repository": {
"type": "git",
"url": "https://git.fiorinis.com/Home/llm-observability-mcp.git"
},
"bin": {
"mcp-server": "./dist/index.js"
},
"scripts": {
"build": "tsc",
"prepare": "npm run build && node scripts/ensure-executable.js",
"postinstall": "node scripts/ensure-executable.js",
"clean": "rm -rf dist coverage",
"test": "jest",
"test:coverage": "jest --coverage",
"lint": "eslint src --ext .ts --config eslint.config.mjs",
"update:deps": "npx npm-check-updates -u && npm install --legacy-peer-deps",
"format": "prettier --write 'src/**/*.ts' 'scripts/**/*.js'",
"cli": "npm run build && node dist/index.js",
"mcp:stdio": "TRANSPORT_MODE=stdio npm run build && node dist/index.js",
"mcp:http": "TRANSPORT_MODE=http npm run build && node dist/index.js",
"mcp:inspect": "TRANSPORT_MODE=http npm run build && (node dist/index.js &) && sleep 2 && npx @modelcontextprotocol/inspector http://localhost:3000/mcp",
"dev:stdio": "npm run build && npx @modelcontextprotocol/inspector -e TRANSPORT_MODE=stdio -e DEBUG=true node dist/index.js",
"dev:http": "DEBUG=true TRANSPORT_MODE=http npm run build && node dist/index.js"
},
"keywords": [
"mcp",
"model-context-protocol",
"typescript",
"claude",
"anthropic",
"ai",
"llm",
"llm-observability",
"server",
"stdio",
"http",
"streamable",
"cli",
"mcp-server"
],
"author": "Stefano Fiorini",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"devDependencies": {
"@eslint/js": "^9.31.0",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/exec": "^7.1.0",
"@semantic-release/git": "^10.0.1",
"@semantic-release/github": "^11.0.3",
"@semantic-release/npm": "^12.0.2",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/jest": "^30.0.0",
"@types/node": "^24.0.13",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.36.0",
"@typescript-eslint/parser": "^8.36.0",
"eslint": "^9.31.0",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-prettier": "^5.5.1",
"jest": "^30.0.4",
"prettier": "^3.6.2",
"semantic-release": "^24.2.7",
"ts-jest": "^29.4.0",
"typescript": "^5.8.3",
"typescript-eslint": "^8.36.0"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.15.1",
"commander": "^14.0.0",
"cors": "^2.8.5",
"dotenv": "^17.2.0",
"express": "^5.1.0",
"posthog-node": "^5.5.0",
"uuid": "^11.1.0",
"zod": "^3.25.67"
},
"publishConfig": {
"registry": "https://registry.npmjs.org/",
"access": "public"
},
"jest": {
"preset": "ts-jest",
"testEnvironment": "node",
"testMatch": [
"**/src/**/*.test.ts"
],
"collectCoverageFrom": [
"src/**/*.ts",
"!src/**/*.test.ts"
],
"transform": {
"^.+\\.tsx?$": [
"ts-jest",
{
"useESM": true
}
]
},
"moduleNameMapper": {
"(.*)\\.(js|jsx)$": "$1"
},
"extensionsToTreatAsEsm": [
".ts"
]
}
}

View File

@@ -0,0 +1,38 @@
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
// Use dynamic import meta for ESM compatibility
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const rootDir = path.resolve(__dirname, '..');
const entryPoint = path.join(rootDir, 'dist', 'index.js');
try {
if (fs.existsSync(entryPoint)) {
// Ensure the file is executable (cross-platform)
const currentMode = fs.statSync(entryPoint).mode;
// Check if executable bits are set (user, group, or other)
// Mode constants differ slightly across platforms, checking broadly
const isExecutable =
currentMode & fs.constants.S_IXUSR ||
currentMode & fs.constants.S_IXGRP ||
currentMode & fs.constants.S_IXOTH;
if (!isExecutable) {
// Set permissions to 755 (rwxr-xr-x) if not executable
fs.chmodSync(entryPoint, 0o755);
console.log(
`Made ${path.relative(rootDir, entryPoint)} executable`,
);
} else {
// console.log(`${path.relative(rootDir, entryPoint)} is already executable`);
}
} else {
// console.warn(`${path.relative(rootDir, entryPoint)} not found, skipping chmod`);
}
} catch (err) {
// console.warn(`Failed to set executable permissions: ${err.message}`);
// We use '|| true' in package.json, so no need to exit here
}

3
scripts/package.json Normal file
View File

@@ -0,0 +1,3 @@
{
"type": "module"
}

204
scripts/update-version.js Executable file
View File

@@ -0,0 +1,204 @@
#!/usr/bin/env node
/**
* Script to update version numbers across the project
* Usage: node scripts/update-version.js [version] [options]
* Options:
* --dry-run Show what changes would be made without applying them
* --verbose Show detailed logging information
*
* If no version is provided, it will use the version from package.json
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
// Get the directory name of the current module
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const rootDir = path.resolve(__dirname, '..');
// Parse command line arguments
const args = process.argv.slice(2);
const options = {
dryRun: args.includes('--dry-run'),
verbose: args.includes('--verbose'),
};
// Get the version (first non-flag argument)
let newVersion = args.find((arg) => !arg.startsWith('--'));
// Log helper function
const log = (message, verbose = false) => {
if (!verbose || options.verbose) {
console.log(message);
}
};
// File paths that may contain version information
const versionFiles = [
{
path: path.join(rootDir, 'package.json'),
pattern: /"version": "([^"]*)"/,
replacement: (match, currentVersion) =>
match.replace(currentVersion, newVersion),
},
{
path: path.join(rootDir, 'src', 'utils', 'constants.util.ts'),
pattern: /export const VERSION = ['"]([^'"]*)['"]/,
replacement: (match, currentVersion) =>
match.replace(currentVersion, newVersion),
},
// Also update the compiled JavaScript files if they exist
{
path: path.join(rootDir, 'dist', 'utils', 'constants.util.js'),
pattern: /exports.VERSION = ['"]([^'"]*)['"]/,
replacement: (match, currentVersion) =>
match.replace(currentVersion, newVersion),
optional: true, // Mark this file as optional
},
// Additional files can be added here with their patterns and replacement logic
];
/**
* Read the version from package.json
* @returns {string} The version from package.json
*/
function getPackageVersion() {
try {
const packageJsonPath = path.join(rootDir, 'package.json');
log(`Reading version from ${packageJsonPath}`, true);
const packageJson = JSON.parse(
fs.readFileSync(packageJsonPath, 'utf8'),
);
if (!packageJson.version) {
throw new Error('No version field found in package.json');
}
return packageJson.version;
} catch (error) {
console.error(`Error reading package.json: ${error.message}`);
process.exit(1);
}
}
/**
* Validate the semantic version format
* @param {string} version - The version to validate
* @returns {boolean} True if valid, throws error if invalid
*/
function validateVersion(version) {
// More comprehensive semver regex
const semverRegex =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
if (!semverRegex.test(version)) {
throw new Error(
`Invalid version format: ${version}\nPlease use semantic versioning format (e.g., 1.2.3, 1.2.3-beta.1, etc.)`,
);
}
return true;
}
/**
* Update version in a specific file
* @param {Object} fileConfig - Configuration for the file to update
*/
function updateFileVersion(fileConfig) {
const {
path: filePath,
pattern,
replacement,
optional = false,
} = fileConfig;
try {
log(`Checking ${filePath}...`, true);
if (!fs.existsSync(filePath)) {
if (optional) {
log(`Optional file not found (skipping): ${filePath}`, true);
return;
}
console.warn(`Warning: File not found: ${filePath}`);
return;
}
// Read file content
const fileContent = fs.readFileSync(filePath, 'utf8');
const match = fileContent.match(pattern);
if (!match) {
console.warn(`Warning: Version pattern not found in ${filePath}`);
return;
}
const currentVersion = match[1];
if (currentVersion === newVersion) {
log(
`Version in ${path.basename(filePath)} is already ${newVersion}`,
true,
);
return;
}
// Create new content with the updated version
const updatedContent = fileContent.replace(pattern, replacement);
// Write the changes or log them in dry run mode
if (options.dryRun) {
log(
`Would update version in ${filePath} from ${currentVersion} to ${newVersion}`,
);
} else {
// Create a backup of the original file
fs.writeFileSync(`${filePath}.bak`, fileContent);
log(`Backup created: ${filePath}.bak`, true);
// Write the updated content
fs.writeFileSync(filePath, updatedContent);
log(
`Updated version in ${path.basename(filePath)} from ${currentVersion} to ${newVersion}`,
);
}
} catch (error) {
if (optional) {
log(`Error with optional file ${filePath}: ${error.message}`, true);
return;
}
console.error(`Error updating ${filePath}: ${error.message}`);
process.exit(1);
}
}
// Main execution
try {
// If no version specified, get from package.json
if (!newVersion) {
newVersion = getPackageVersion();
log(
`No version specified, using version from package.json: ${newVersion}`,
);
}
// Validate the version format
validateVersion(newVersion);
// Update all configured files
for (const fileConfig of versionFiles) {
updateFileVersion(fileConfig);
}
if (options.dryRun) {
log(`\nDry run completed. No files were modified.`);
} else {
log(`\nVersion successfully updated to ${newVersion}`);
}
} catch (error) {
console.error(`\nVersion update failed: ${error.message}`);
process.exit(1);
}

58
src/cli/index.ts Normal file
View File

@@ -0,0 +1,58 @@
import { Command } from 'commander';
import { Logger } from '../utils/logger.util.js';
import { VERSION, CLI_NAME } from '../utils/constants.util.js';
import posthogLlmCli from './posthog-llm.cli.js';
/**
* CLI entry point for the LLM Log MCP Server
*
* This file registers all CLI commands and handles command line parsing
*/
// Package description
const DESCRIPTION =
'A LLM Log Model Context Protocol (MCP) server implementation using TypeScript';
/**
* Run the CLI with the provided arguments
*
* @param args Command line arguments to process
* @returns Promise that resolves when CLI command execution completes
*/
export async function runCli(args: string[]) {
const cliLogger = Logger.forContext('cli/index.ts', 'runCli');
cliLogger.debug('Initializing CLI with arguments', args);
const program = new Command();
program.name(CLI_NAME).description(DESCRIPTION).version(VERSION);
// Register CLI commands
cliLogger.debug('Registering CLI commands...');
posthogLlmCli.register(program);
cliLogger.debug('CLI commands registered successfully');
// Handle unknown commands
program.on('command:*', (operands) => {
cliLogger.error(`Unknown command: ${operands[0]}`);
console.log('');
program.help();
process.exit(1);
});
// Parse arguments; default to help if no command provided
cliLogger.debug('Parsing CLI arguments');
// Special handling for top-level commands
if (args.length === 1) {
// Check if it's a known top-level command
const command = program.commands.find((cmd) => cmd.name() === args[0]);
if (command) {
command.outputHelp();
process.exit(0);
}
}
await program.parseAsync(args.length ? args : ['--help'], { from: 'user' });
cliLogger.debug('CLI command execution completed');
}

View File

@@ -0,0 +1,60 @@
import { Command } from 'commander';
import { Logger } from '../utils/logger.util.js';
import { handleCliError } from '../utils/error.util.js';
import postHogLlmController from '../controllers/posthog-llm.controller.js';
const logger = Logger.forContext('cli/posthog-llm.cli.ts');
/**
* Register PostHog LLM CLI commands
* @param program The Commander program instance
*/
function register(program: Command) {
const methodLogger = logger.forMethod('register');
methodLogger.debug('Registering PostHog LLM CLI commands...');
program
.command('llm-observability-mcp')
.description('Interact with the PostHog LLM API.')
.argument('<eventName>', 'The name of the event to capture.')
.argument('<distinctId>', 'The distinct ID of the user.')
.option(
'-p, --properties <properties...>',
'JSON string of event properties. Can be a single quoted string or raw key-value pairs.',
)
.action(async (eventName, distinctId, options) => {
const actionLogger = logger.forMethod('action:capture');
try {
actionLogger.debug(`CLI posthog-llm capture called`, {
eventName,
distinctId,
options,
});
// Handle JSON properties from command object
let properties = {};
if (options.properties && options.properties.length > 0) {
const propertiesString = options.properties.join(' ');
try {
properties = JSON.parse(propertiesString);
} catch {
// Try to fix common issues with JSON formatting
const fixedJson = propertiesString
.replace(/(\w+):/g, '"$1":') // Add quotes around keys
.replace(/'/g, '"'); // Replace single quotes with double quotes
properties = JSON.parse(fixedJson);
}
}
const args = { eventName, distinctId, properties };
await postHogLlmController.capture(args);
} catch (error) {
handleCliError(error);
}
});
methodLogger.debug('PostHog LLM CLI commands registered successfully');
}
export default { register };

View File

@@ -0,0 +1,32 @@
import { Logger } from '../utils/logger.util.js';
import { ControllerResponse } from '../types/common.types.js';
import postHogLlmService from '../services/posthog-llm.service.js';
const logger = Logger.forContext('controllers/posthog-llm.controller.ts');
async function capture(args: {
eventName: string;
distinctId: string;
properties: Record<string, unknown>;
}): Promise<ControllerResponse> {
const methodLogger = logger.forMethod('capture');
methodLogger.debug('Capturing PostHog event...');
methodLogger.debug('Arguments:', args);
try {
await postHogLlmService.capture(args);
return {
content: 'Event captured successfully.',
};
} catch (error) {
methodLogger.error('Error capturing event:', error);
const errorMessage =
error instanceof Error ? error.message : 'Unknown error';
methodLogger.error('Error capturing event:', errorMessage);
return {
content: `Failed to capture event: ${errorMessage}`,
};
}
}
export default { capture };

68
src/index.ts Normal file
View File

@@ -0,0 +1,68 @@
#!/usr/bin/env node
import { Logger } from './utils/logger.util.js';
import { config } from './utils/config.util.js';
import { runCli } from './cli/index.js';
import { stdioTransport } from './server/stdio.js';
import { streamableHttpTransport } from './server/streamableHttp.js';
const logger = Logger.forContext('index.ts');
/**
* Start the MCP server with the specified transport mode
*/
export async function startServer(): Promise<void> {
const mainLogger = Logger.forContext('index.ts', 'startServer');
// Define available transport modes and their handlers
const transportModes = {
stdio: { handler: stdioTransport, name: 'stdio' },
http: { handler: streamableHttpTransport, name: 'http' },
};
// Get requested transport mode (default to stdio)
const requestedMode = (process.env.TRANSPORT_MODE || 'stdio').toLowerCase();
const transport =
transportModes[requestedMode as keyof typeof transportModes] ||
transportModes.stdio;
// Warn if requested mode is invalid
if (!transportModes[requestedMode as keyof typeof transportModes]) {
mainLogger.warn(
`Unknown TRANSPORT_MODE "${requestedMode}", defaulting to stdio`,
);
}
// Start the selected transport
mainLogger.info(
`Starting server with ${transport.name.toUpperCase()} transport`,
);
transport.handler();
}
/**
* Main entry point
*/
async function main() {
const mainLogger = Logger.forContext('index.ts', 'main');
// Load configuration
config.load();
// CLI mode - if any arguments are provided
if (process.argv.length > 2) {
mainLogger.info('CLI mode detected');
await runCli(process.argv.slice(2));
return;
}
// Server mode - determine transport and start server
await startServer();
}
// Run main if executed directly
if (require.main === module) {
main().catch((err) => {
logger.error('Unhandled error in main process', err);
process.exit(1);
});
}

View File

@@ -0,0 +1,74 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { Logger } from '../utils/logger.util.js';
import posthogLlmController from '../controllers/posthog-llm.controller.js';
import { formatErrorForMcpResource } from '../utils/error.util.js';
const logger = Logger.forContext('resources/posthog-llm.resource.ts');
/**
* Register PostHog LLM resources with the MCP server
*
* @param server The MCP server instance
*/
function registerResources(server: McpServer) {
const registerLogger = logger.forMethod('registerResources');
registerLogger.debug('Registering PostHog LLM resources...');
// Register the PostHog event capture resource
server.resource(
'capture_llm_observability',
'Capture a LLM event in PostHog',
async (uri: URL) => {
const methodLogger = logger.forMethod('posthogEventResource');
try {
methodLogger.debug('PostHog event resource called', {
uri: uri.toString(),
});
// Extract parameters from the URI
// Format: posthog://event_name/distinct_id?properties=JSON
const pathParts = uri.pathname.split('/').filter(Boolean);
const eventName = pathParts[0] || 'event';
const distinctId = pathParts[1] || 'anonymous';
// Parse properties from query parameters if present
let properties: Record<string, unknown> = {};
if (uri.searchParams.has('properties')) {
try {
properties = JSON.parse(
uri.searchParams.get('properties') || '{}',
);
} catch (e) {
methodLogger.warn('Failed to parse properties JSON', e);
}
}
// Call the controller to capture the event
const result = await posthogLlmController.capture({
eventName,
distinctId,
properties,
});
// Return the content as a text resource
return {
contents: [
{
uri: uri.toString(),
text: result.content,
mimeType: 'text/plain',
description: `PostHog event: ${eventName}`,
},
],
};
} catch (error) {
methodLogger.error('Resource error', error);
return formatErrorForMcpResource(error, uri.toString());
}
},
);
registerLogger.debug('PostHog LLM resources registered successfully');
}
export default { registerResources };

90
src/server/mcpServer.ts Normal file
View File

@@ -0,0 +1,90 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { Logger } from '../utils/logger.util';
import { config } from '../utils/config.util';
import { PACKAGE_NAME, VERSION } from '../utils/constants.util';
import posthogLlmResources from '../resources/posthog-llm.resource.js';
import posthogLlmTools from '../tools/posthog-llm.tool.js';
export function createServer() {
const serverLogger = Logger.forContext('utils/server.util.ts', 'getServer');
// Load configuration
serverLogger.info('Starting MCP server initialization...');
config.load();
if (config.getBoolean('DEBUG')) {
serverLogger.debug('Debug mode enabled');
}
serverLogger.info(`Initializing LLM Log MCP server v${VERSION}`);
const server = new McpServer({
name: PACKAGE_NAME,
version: VERSION,
});
// Register tools and resources
serverLogger.info('Registering MCP tools and resources...');
posthogLlmTools.registerTools(server);
posthogLlmResources.registerResources(server);
serverLogger.debug('All tools and resources registered');
return server;
}
/**
* Graceful shutdown handler
*/
export function shutdownServer(
transports:
| Record<string, SSEServerTransport | StreamableHTTPServerTransport>
| undefined,
) {
const shutdownLogger = Logger.forContext(
'utils/server.util.ts',
'shutdown',
);
const shutdown = async () => {
try {
shutdownLogger.info('Shutting down server...');
// Close all active transports to properly clean up resources
if (transports) {
for (const sessionId in transports) {
try {
shutdownLogger.debug(
`Closing transport for session ${sessionId}`,
);
const transport = transports[sessionId];
if (
transport &&
'close' in transport &&
typeof transport.close === 'function'
) {
await transport.close();
delete transports[sessionId];
}
} catch (error) {
shutdownLogger.error(
`Error closing transport for session ${sessionId}:`,
error,
);
}
}
}
shutdownLogger.info('Server shutdown complete');
process.exit(0);
} catch (err) {
shutdownLogger.error('Error during shutdown', err);
process.exit(1);
}
};
['SIGINT', 'SIGTERM'].forEach((signal) => {
process.on(signal as NodeJS.Signals, shutdown);
});
}

20
src/server/stdio.ts Normal file
View File

@@ -0,0 +1,20 @@
import { Logger } from '../utils/logger.util';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { createServer, shutdownServer } from './mcpServer';
export async function stdioTransport(): Promise<void> {
const stdioLogger = Logger.forContext('server/stdio.ts', 'stdioTransport');
stdioLogger.info('Using STDIO transport');
const transport = new StdioServerTransport();
try {
const server = createServer();
await server.connect(transport);
stdioLogger.info('MCP server started successfully on STDIO transport');
shutdownServer(undefined);
} catch (err) {
stdioLogger.error('Failed to start server on STDIO transport', err);
process.exit(1);
}
}

View File

@@ -0,0 +1,189 @@
import type { Request, Response } from 'express';
import express from 'express';
import cors from 'cors';
import { randomUUID } from 'crypto';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
import { InMemoryEventStore } from '@modelcontextprotocol/sdk/examples/shared/inMemoryEventStore.js';
import { Logger } from '../utils/logger.util';
import { VERSION } from '../utils/constants.util.js';
import { createServer, shutdownServer } from './mcpServer.js';
export async function streamableHttpTransport(): Promise<void> {
const streamableHttpLogger = Logger.forContext(
'server/streamableHttp.ts',
'streamableHttpTransport',
);
// HTTP transport with Express
streamableHttpLogger.info('Using Streamable HTTP transport');
const app = express();
app.use(express.json());
app.use(
cors({
origin: '*', // Allow all origins - adjust as needed for production
exposedHeaders: ['Mcp-Session-Id'],
}),
);
// Store transports by session ID
const transports: Record<
string,
StreamableHTTPServerTransport | SSEServerTransport
> = {};
const mcpEndpoint = '/mcp';
streamableHttpLogger.debug(`MCP endpoint: ${mcpEndpoint}`);
// Handle all MCP requests
app.all(mcpEndpoint, async (req: Request, res: Response) => {
streamableHttpLogger.debug(`Received ${req.method} request to /mcp`);
try {
// Check for existing session ID
const sessionId = req.headers['mcp-session-id'] as
| string
| undefined;
let transport: StreamableHTTPServerTransport;
if (sessionId && transports[sessionId]) {
// Check if the transport is of the correct type
const existingTransport = transports[sessionId];
if (
existingTransport instanceof StreamableHTTPServerTransport
) {
// Reuse existing transport
transport = existingTransport;
} else {
// Transport exists but is not a StreamableHTTPServerTransport (could be SSEServerTransport)
res.status(400).json({
jsonrpc: '2.0',
error: {
code: -32000,
message:
'Bad Request: Session exists but uses a different transport protocol',
},
id: null,
});
return;
}
} else if (
!sessionId &&
req.method === 'POST' &&
isInitializeRequest(req.body)
) {
const eventStore = new InMemoryEventStore();
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
eventStore,
onsessioninitialized: (sessionId) => {
// Store the transport by session ID when session is initialized
streamableHttpLogger.info(
`StreamableHTTP session initialized with ID: ${sessionId}`,
);
transports[sessionId] = transport;
},
});
// Connect the transport to the MCP server
const server = createServer();
await server.connect(transport);
} else {
// Invalid request - no session ID or not initialization request
res.status(400).json({
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Bad Request: No valid session ID provided',
},
id: null,
});
return;
}
// Handle the request with the transport
streamableHttpLogger.debug(
'MCP request received',
req,
res,
req.body,
);
await transport.handleRequest(req, res, req.body);
} catch (error) {
streamableHttpLogger.error('Error handling MCP request:', error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
error: {
code: -32603,
message: 'Internal server error',
},
id: null,
});
}
}
});
app.get('/sse', async (_req: Request, res: Response) => {
streamableHttpLogger.debug(
'Received GET request to /sse (deprecated SSE transport)',
);
const transport = new SSEServerTransport('/messages', res);
transports[transport.sessionId] = transport;
res.on('close', () => {
delete transports[transport.sessionId];
});
const server = createServer();
await server.connect(transport);
});
app.post('/messages', async (req: Request, res: Response) => {
const sessionId = req.query.sessionId as string;
let transport: SSEServerTransport;
const existingTransport = transports[sessionId];
if (existingTransport instanceof SSEServerTransport) {
// Reuse existing transport
transport = existingTransport;
} else {
// Transport exists but is not a SSEServerTransport (could be StreamableHTTPServerTransport)
res.status(400).json({
jsonrpc: '2.0',
error: {
code: -32000,
message:
'Bad Request: Session exists but uses a different transport protocol',
},
id: null,
});
return;
}
if (transport) {
await transport.handlePostMessage(req, res, req.body);
} else {
res.status(400).send('No transport found for sessionId');
}
});
// Health check endpoint
app.get('/', (_req: Request, res: Response) => {
res.send(`LLM Log MCP Server v${VERSION} is running`);
});
// Start HTTP server
const PORT = Number(process.env.PORT ?? 3000);
await new Promise<void>((resolve) => {
app.listen(PORT, () => {
streamableHttpLogger.info(
`HTTP transport listening on http://localhost:${PORT}${mcpEndpoint}`,
);
resolve();
});
});
shutdownServer(transports);
return;
}

View File

@@ -0,0 +1,61 @@
import { Logger } from '../utils/logger.util.js';
import { PostHog } from 'posthog-node';
import { config } from '../utils/config.util.js';
// Ensure configuration is loaded before accessing environment variables
config.load();
const logger = Logger.forContext('services/posthog-llm.service.ts');
const posthogApiKey = config.get('POSTHOG_API_KEY');
let posthogClient: PostHog | null = null;
if (posthogApiKey) {
posthogClient = new PostHog(posthogApiKey, {
host: config.get('POSTHOG_HOST') || 'https://app.posthog.com',
});
} else {
logger.warn('POSTHOG_API_KEY is not set. PostHog client not initialized.');
}
async function capture(args: {
eventName: string;
distinctId: string;
properties: Record<string, unknown>;
}): Promise<void> {
if (!posthogClient) {
logger.warn('PostHog client not initialized. Cannot capture event.');
return;
}
const allowedEvents = [
'$ai_generation',
'$ai_trace',
'$ai_span',
'$ai_embedding',
];
if (!allowedEvents.includes(args.eventName)) {
logger.error(
`Invalid event name: ${args.eventName}. Allowed values: ${allowedEvents.join(', ')}`,
);
return;
}
const methodLogger = logger.forMethod('capture');
methodLogger.debug('Capturing event in PostHog...');
posthogClient.capture({
distinctId: args.distinctId,
event: args.eventName,
properties: {
distinct_id: args.distinctId,
...args.properties,
},
});
// Make sure to flush the events
await posthogClient.shutdown();
methodLogger.debug('Event captured and flushed to PostHog.');
}
export default { capture };

View File

@@ -0,0 +1,108 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { Logger } from '../utils/logger.util.js';
import { formatErrorForMcpTool } from '../utils/error.util.js';
import posthogLlmController from '../controllers/posthog-llm.controller.js';
import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import {
GetToolInputSchema,
GetToolInputSchemaType,
} from './posthog-llm.types.js';
/**
* @function capturePosthogLlmObservability
* @description MCP Tool handler to capture LLM observability events in PostHog.
* It calls the posthogLlmController to track the data and formats the response for the MCP.
* @param {GetToolInputSchemaType} args - Arguments provided to the tool.
* @returns {Promise<CallToolResult>} Formatted response for the MCP.
* @throws {McpError} Formatted error if the controller or service layer encounters an issue.
*/
async function capturePosthogLlmObservability(
args: GetToolInputSchemaType,
): Promise<CallToolResult> {
const methodLogger = Logger.forContext(
'tools/posthog-llm.tool.ts',
'capturePosthogLlmObservability',
);
methodLogger.debug(`Capture LLM Observability in PostHog...`, args);
try {
const trackArgs = GetToolInputSchema.parse(args);
const posthogProperties: Record<string, unknown> = {
$ai_model: trackArgs.model,
$ai_provider: trackArgs.provider,
};
const toPostHogKey: Partial<
Record<keyof GetToolInputSchemaType, string>
> = {
input: '$ai_input',
outputChoices: '$ai_output_choices',
traceId: '$ai_trace_id',
inputTokens: '$ai_input_tokens',
outputTokens: '$ai_output_tokens',
latency: '$ai_latency',
httpStatus: '$ai_http_status',
baseUrl: '$ai_base_url',
};
for (const key of Object.keys(toPostHogKey) as Array<
keyof GetToolInputSchemaType
>) {
if (trackArgs[key] !== undefined) {
const posthogKey = toPostHogKey[key];
if (posthogKey) {
posthogProperties[posthogKey] = trackArgs[key];
}
}
}
// Pass validated args to the controller
const result = await posthogLlmController.capture({
eventName: '$ai_generation',
distinctId: trackArgs.userId,
properties: posthogProperties,
});
methodLogger.error(`Got the response from the controller`, result);
// Format the response for the MCP tool
return {
content: [
{
type: 'text' as const,
text: result.content,
},
],
};
} catch (error) {
methodLogger.error(`Error tracking LLM generation in PostHog`, error);
return formatErrorForMcpTool(error);
}
}
/**
* @function registerTools
* @description Registers the PostHog LLM observability tool ('capture_llm_observability') with the MCP server.
*
* @param {McpServer} server - The MCP server instance.
*/
function registerTools(server: McpServer) {
const methodLogger = Logger.forContext(
'tools/posthog-llm.tool.ts',
'registerTools',
);
methodLogger.debug(`Registering PostHog LLM observability tools...`);
server.tool(
'capture_llm_observability',
`Captures LLM usage in PostHog for observability, including requests, responses, and performance metrics`,
GetToolInputSchema.shape,
capturePosthogLlmObservability,
);
methodLogger.debug(
'Successfully registered capture_llm_observability tool.',
);
}
export default { registerTools };

View File

@@ -0,0 +1,41 @@
import { z } from 'zod';
/**
* Zod schema for the PostHog LLM observability tool arguments.
*/
export const GetToolInputSchema = z.object({
userId: z.string().describe('The distinct ID of the user'),
traceId: z.string().optional().describe('The trace ID to group AI events'),
model: z.string().describe('The model used (e.g., gpt-4, claude-3, etc.)'),
provider: z
.string()
.describe('The LLM provider (e.g., openai, anthropic, etc.)'),
input: z
.any()
.optional()
.describe('The input to the LLM (messages, prompt, etc.)'),
outputChoices: z.any().optional().describe('The output from the LLM'),
inputTokens: z
.number()
.optional()
.describe('The number of tokens in the input'),
outputTokens: z
.number()
.optional()
.describe('The number of tokens in the output'),
latency: z
.number()
.optional()
.describe('The latency of the LLM call in seconds'),
httpStatus: z
.number()
.optional()
.describe('The HTTP status code of the LLM call'),
baseUrl: z.string().optional().describe('The base URL of the LLM API'),
});
/**
* TypeScript type inferred from the GetToolInputSchema Zod schema.
* This represents the optional arguments passed to the tool handler and controller.
*/
export type GetToolInputSchemaType = z.infer<typeof GetToolInputSchema>;

21
src/types/common.types.ts Normal file
View File

@@ -0,0 +1,21 @@
/**
* Common type definitions shared across controllers.
* These types provide a standard interface for controller interactions.
* Centralized here to ensure consistency across the codebase.
*/
/**
* Common response structure for controller operations.
* All controller methods should return this structure.
*
* All output, including pagination information and any additional metadata,
* is now consolidated into the content field as a single Markdown-formatted string.
*/
export interface ControllerResponse {
/**
* Formatted content to be displayed to the user.
* A comprehensive Markdown-formatted string that includes all necessary information,
* including pagination details and any additional metadata.
*/
content: string;
}

157
src/utils/cli.test.util.ts Normal file
View File

@@ -0,0 +1,157 @@
import { spawn } from 'child_process';
import { join } from 'path';
/**
* Utility for testing CLI commands with real execution
*/
export class CliTestUtil {
/**
* Executes a CLI command and returns the result
*
* @param args - CLI arguments to pass to the command
* @param options - Test options
* @returns Promise with stdout, stderr, and exit code
*/
static async runCommand(
args: string[],
options: {
timeoutMs?: number;
env?: Record<string, string>;
} = {},
): Promise<{
stdout: string;
stderr: string;
exitCode: number;
}> {
// Default timeout of 30 seconds
const timeoutMs = options.timeoutMs || 30000;
// CLI execution path - points to the built CLI script
const cliPath = join(process.cwd(), 'dist', 'index.js');
// Log what command we're about to run
console.log(`Running CLI command: node ${cliPath} ${args.join(' ')}`);
return new Promise((resolve, reject) => {
// Set up timeout handler
const timeoutId = setTimeout(() => {
child.kill();
reject(new Error(`CLI command timed out after ${timeoutMs}ms`));
}, timeoutMs);
// Capture stdout and stderr
let stdout = '';
let stderr = '';
// Spawn the process with given arguments and enhanced environment
const child = spawn('node', [cliPath, ...args], {
env: {
...process.env,
...options.env,
DEBUG: 'true', // Enable debug logging
NODE_ENV: 'test', // Ensure tests are detected
},
});
// Collect stdout data
child.stdout.on('data', (data) => {
const chunk = data.toString();
stdout += chunk;
console.log(`STDOUT chunk: ${chunk.substring(0, 50)}...`);
});
// Collect stderr data
child.stderr.on('data', (data) => {
const chunk = data.toString();
stderr += chunk;
console.log(`STDERR chunk: ${chunk.substring(0, 50)}...`);
});
// Handle process completion
child.on('close', (exitCode) => {
clearTimeout(timeoutId);
console.log(`Command completed with exit code: ${exitCode}`);
console.log(`Total STDOUT length: ${stdout.length} chars`);
// Get the non-debug output for debugging purposes
const nonDebugOutput = stdout
.split('\n')
.filter((line) => !line.match(/^\[\d{2}:\d{2}:\d{2}\]/))
.join('\n');
console.log(
`Non-debug output length: ${nonDebugOutput.length} chars`,
);
console.log(`STDOUT excerpt: ${stdout.substring(0, 100)}...`);
console.log(
`Filtered excerpt: ${nonDebugOutput.substring(0, 100)}...`,
);
resolve({
stdout,
stderr,
exitCode: exitCode ?? 0,
});
});
// Handle process errors
child.on('error', (err) => {
clearTimeout(timeoutId);
console.error(`Command error: ${err.message}`);
reject(err);
});
});
}
/**
* Validates that stdout contains expected strings/patterns
*/
static validateOutputContains(
output: string,
expectedPatterns: (string | RegExp)[],
): void {
// Filter out debug log lines for cleaner validation
const cleanOutput = output
.split('\n')
.filter((line) => !line.match(/^\[\d{2}:\d{2}:\d{2}\]/))
.join('\n');
console.log('==== Cleaned output for validation ====');
console.log(cleanOutput);
console.log('=======================================');
for (const pattern of expectedPatterns) {
if (typeof pattern === 'string') {
expect(cleanOutput).toContain(pattern);
} else {
expect(cleanOutput).toMatch(pattern);
}
}
}
/**
* Validates Markdown output format
*/
static validateMarkdownOutput(output: string): void {
// Filter out debug log lines for cleaner validation
const cleanOutput = output
.split('\n')
.filter((line) => !line.match(/^\[\d{2}:\d{2}:\d{2}\]/))
.join('\n');
// Check for Markdown heading
expect(cleanOutput).toMatch(/^#\s.+/m);
// Check for markdown formatting elements like bold text, lists, etc.
const markdownElements = [
/\*\*.+\*\*/, // Bold text
/-\s.+/, // List items
/\|.+\|.+\|/, // Table rows
/\[.+\]\(.+\)/, // Links
];
expect(
markdownElements.some((pattern) => pattern.test(cleanOutput)),
).toBe(true);
}
}

View File

@@ -0,0 +1,131 @@
import {
ErrorType,
McpError,
createApiError,
createAuthMissingError,
createAuthInvalidError,
createUnexpectedError,
ensureMcpError,
formatErrorForMcpTool,
formatErrorForMcpResource,
} from './error.util.js';
describe('Error Utility', () => {
describe('McpError', () => {
it('should create an error with the correct properties', () => {
const error = new McpError('Test error', ErrorType.API_ERROR, 404);
expect(error).toBeInstanceOf(Error);
expect(error).toBeInstanceOf(McpError);
expect(error.message).toBe('Test error');
expect(error.type).toBe(ErrorType.API_ERROR);
expect(error.statusCode).toBe(404);
expect(error.name).toBe('McpError');
});
});
describe('Error Factory Functions', () => {
it('should create auth missing error', () => {
const error = createAuthMissingError();
expect(error).toBeInstanceOf(McpError);
expect(error.type).toBe(ErrorType.AUTH_MISSING);
expect(error.message).toBe(
'Authentication credentials are missing',
);
});
it('should create auth invalid error', () => {
const error = createAuthInvalidError('Invalid token');
expect(error).toBeInstanceOf(McpError);
expect(error.type).toBe(ErrorType.AUTH_INVALID);
expect(error.statusCode).toBe(401);
expect(error.message).toBe('Invalid token');
});
it('should create API error', () => {
const originalError = new Error('Original error');
const error = createApiError('API failed', 500, originalError);
expect(error).toBeInstanceOf(McpError);
expect(error.type).toBe(ErrorType.API_ERROR);
expect(error.statusCode).toBe(500);
expect(error.message).toBe('API failed');
expect(error.originalError).toBe(originalError);
});
it('should create unexpected error', () => {
const error = createUnexpectedError();
expect(error).toBeInstanceOf(McpError);
expect(error.type).toBe(ErrorType.UNEXPECTED_ERROR);
expect(error.message).toBe('An unexpected error occurred');
});
});
describe('ensureMcpError', () => {
it('should return the same error if it is already an McpError', () => {
const originalError = createApiError('Original error');
const error = ensureMcpError(originalError);
expect(error).toBe(originalError);
});
it('should wrap a standard Error', () => {
const originalError = new Error('Standard error');
const error = ensureMcpError(originalError);
expect(error).toBeInstanceOf(McpError);
expect(error.type).toBe(ErrorType.UNEXPECTED_ERROR);
expect(error.message).toBe('Standard error');
expect(error.originalError).toBe(originalError);
});
it('should handle non-Error objects', () => {
const error = ensureMcpError('String error');
expect(error).toBeInstanceOf(McpError);
expect(error.type).toBe(ErrorType.UNEXPECTED_ERROR);
expect(error.message).toBe('String error');
});
});
describe('formatErrorForMcpTool', () => {
it('should format an error for MCP tool response', () => {
const error = createApiError('API error');
const response = formatErrorForMcpTool(error);
expect(response).toHaveProperty('content');
expect(response.content).toHaveLength(1);
expect(response.content[0]).toHaveProperty('type', 'text');
expect(response.content[0]).toHaveProperty(
'text',
'Error: API error',
);
});
});
describe('formatErrorForMcpResource', () => {
it('should format an error for MCP resource response', () => {
const error = createApiError('API error');
const response = formatErrorForMcpResource(error, 'test://uri');
expect(response).toHaveProperty('contents');
expect(response.contents).toHaveLength(1);
expect(response.contents[0]).toHaveProperty('uri', 'test://uri');
expect(response.contents[0]).toHaveProperty(
'text',
'Error: API error',
);
expect(response.contents[0]).toHaveProperty(
'mimeType',
'text/plain',
);
expect(response.contents[0]).toHaveProperty(
'description',
'Error: API_ERROR',
);
});
});
});

173
src/utils/config.util.ts Normal file
View File

@@ -0,0 +1,173 @@
import fs from 'fs';
import path from 'path';
import { Logger } from './logger.util.js';
import dotenv from 'dotenv';
import os from 'os';
/**
* Configuration loader that handles multiple sources with priority:
* 1. Direct ENV pass (process.env)
* 2. .env file in project root
* 3. Global config file at $HOME/.mcp/configs.json
*/
class ConfigLoader {
private packageName: string;
private configLoaded: boolean = false;
/**
* Create a new ConfigLoader instance
* @param packageName The package name to use for global config lookup
*/
constructor(packageName: string) {
this.packageName = packageName;
}
/**
* Load configuration from all sources with proper priority
*/
load(): void {
const methodLogger = Logger.forContext('utils/config.util.ts', 'load');
if (this.configLoaded) {
methodLogger.debug('Configuration already loaded, skipping');
return;
}
methodLogger.debug('Loading configuration...');
// Priority 3: Load from global config file
this.loadFromGlobalConfig();
// Priority 2: Load from .env file
this.loadFromEnvFile();
// Priority 1: Direct ENV pass is already in process.env
// No need to do anything as it already has highest priority
this.configLoaded = true;
methodLogger.debug('Configuration loaded successfully');
}
/**
* Load configuration from .env file in project root
*/
private loadFromEnvFile(): void {
const methodLogger = Logger.forContext(
'utils/config.util.ts',
'loadFromEnvFile',
);
try {
const result = dotenv.config();
if (result.error) {
methodLogger.debug('No .env file found or error reading it');
return;
}
methodLogger.debug('Loaded configuration from .env file');
} catch (error) {
methodLogger.error('Error loading .env file', error);
}
}
/**
* Load configuration from global config file at $HOME/.mcp/configs.json
*/
private loadFromGlobalConfig(): void {
const methodLogger = Logger.forContext(
'utils/config.util.ts',
'loadFromGlobalConfig',
);
try {
const homedir = os.homedir();
const globalConfigPath = path.join(homedir, '.mcp', 'configs.json');
if (!fs.existsSync(globalConfigPath)) {
methodLogger.debug('Global config file not found');
return;
}
const configContent = fs.readFileSync(globalConfigPath, 'utf8');
const config = JSON.parse(configContent);
// Determine the potential keys for the current package
const shortKey = 'llm-observability-mcp'; // Project-specific short key
const fullPackageName = this.packageName; // e.g., '@sfiorini/llm-observability-mcp'
const unscopedPackageName =
fullPackageName.split('/')[1] || fullPackageName; // e.g., 'llm-observability-mcp'
const potentialKeys = [
shortKey,
fullPackageName,
unscopedPackageName,
];
let foundConfigSection: {
environments?: Record<string, unknown>;
} | null = null;
let usedKey: string | null = null;
for (const key of potentialKeys) {
if (
config[key] &&
typeof config[key] === 'object' &&
config[key].environments
) {
foundConfigSection = config[key];
usedKey = key;
methodLogger.debug(`Found configuration using key: ${key}`);
break; // Stop once found
}
}
if (!foundConfigSection || !foundConfigSection.environments) {
methodLogger.debug(
`No configuration found for ${
this.packageName
} using keys: ${potentialKeys.join(', ')}`,
);
return;
}
const environments = foundConfigSection.environments;
for (const [key, value] of Object.entries(environments)) {
// Only set if not already defined in process.env
if (process.env[key] === undefined) {
process.env[key] = String(value);
}
}
methodLogger.debug(
`Loaded configuration from global config file using key: ${usedKey}`,
);
} catch (error) {
methodLogger.error('Error loading global config file', error);
}
}
/**
* Get a configuration value
* @param key The configuration key
* @param defaultValue The default value if the key is not found
* @returns The configuration value or the default value
*/
get(key: string, defaultValue?: string): string | undefined {
return process.env[key] || defaultValue;
}
/**
* Get a boolean configuration value
* @param key The configuration key
* @param defaultValue The default value if the key is not found
* @returns The boolean configuration value or the default value
*/
getBoolean(key: string, defaultValue: boolean = false): boolean {
const value = this.get(key);
if (value === undefined) {
return defaultValue;
}
return value.toLowerCase() === 'true';
}
}
// Create and export a singleton instance with the package name from package.json
export const config = new ConfigLoader('@sfiorini/llm-observability-mcp');

View File

@@ -0,0 +1,24 @@
/**
* Application constants
*
* This file contains constants used throughout the application.
* Centralizing these values makes them easier to maintain and update.
*/
/**
* Current application version
* This should match the version in package.json
*/
export const VERSION = '0.1.0';
/**
* Package name with scope
* Used for initialization and identification
*/
export const PACKAGE_NAME = '@sfiorini/llm-observability-mcp';
/**
* CLI command name
* Used for binary name and CLI help text
*/
export const CLI_NAME = 'llm-observability-mcp';

View File

@@ -0,0 +1,128 @@
import {
ErrorCode,
detectErrorType,
buildErrorContext,
} from './error-handler.util.js';
import { createApiError, McpError, ErrorType } from './error.util.js';
describe('error-handler.util', () => {
describe('detectErrorType', () => {
it('should detect network errors', () => {
const networkErrors = [
'network error occurred',
'fetch failed with error',
'ECONNREFUSED on 127.0.0.1:8080',
'ENOTFOUND api.example.com',
'Failed to fetch data from server',
'Network request failed',
];
networkErrors.forEach((msg) => {
const { code, statusCode } = detectErrorType(new Error(msg));
expect(code).toBe(ErrorCode.NETWORK_ERROR);
expect(statusCode).toBe(500);
});
});
it('should detect rate limit errors', () => {
const rateLimitErrors = [
'rate limit exceeded',
'too many requests',
new McpError('API error', ErrorType.API_ERROR, 429),
];
rateLimitErrors.forEach((error) => {
const { code, statusCode } = detectErrorType(error);
expect(code).toBe(ErrorCode.RATE_LIMIT_ERROR);
expect(statusCode).toBe(429);
});
});
it('should detect not found errors', () => {
const notFoundErrors = [
'resource not found',
'entity does not exist',
new McpError('Not found', ErrorType.API_ERROR, 404),
];
notFoundErrors.forEach((error) => {
const { code } = detectErrorType(error);
expect(code).toBe(ErrorCode.NOT_FOUND);
});
});
it('should detect access denied errors', () => {
const accessDeniedErrors = [
'access denied',
'permission denied',
'not authorized to access',
'authentication required',
new McpError('Forbidden', ErrorType.API_ERROR, 403),
new McpError('Unauthorized', ErrorType.API_ERROR, 401),
];
accessDeniedErrors.forEach((error) => {
const { code } = detectErrorType(error);
expect(code).toBe(ErrorCode.ACCESS_DENIED);
});
});
it('should default to unexpected error when no patterns match', () => {
const { code, statusCode } = detectErrorType(
new Error('some random error'),
);
expect(code).toBe(ErrorCode.UNEXPECTED_ERROR);
expect(statusCode).toBe(500);
});
});
describe('buildErrorContext', () => {
it('should build a context object with all parameters', () => {
const context = buildErrorContext(
'User',
'create',
'controllers/user.controller.ts@create',
'user123',
{ requestBody: { name: 'Test User' } },
);
expect(context).toEqual({
entityType: 'User',
operation: 'create',
source: 'controllers/user.controller.ts@create',
entityId: 'user123',
additionalInfo: { requestBody: { name: 'Test User' } },
});
});
it('should build a context object with only required parameters', () => {
const context = buildErrorContext(
'User',
'list',
'controllers/user.controller.ts@list',
);
expect(context).toEqual({
entityType: 'User',
operation: 'list',
source: 'controllers/user.controller.ts@list',
});
});
it('should handle object entityId', () => {
const context = buildErrorContext(
'Document',
'get',
'controllers/document.controller.ts@get',
{ project: 'project1', id: 'doc123' },
);
expect(context).toEqual({
entityType: 'Document',
operation: 'get',
source: 'controllers/document.controller.ts@get',
entityId: { project: 'project1', id: 'doc123' },
});
});
});
});

View File

@@ -0,0 +1,307 @@
import { createApiError } from './error.util.js';
import { Logger } from './logger.util.js';
/**
* Standard error codes for consistent handling
*/
export enum ErrorCode {
NOT_FOUND = 'NOT_FOUND',
INVALID_CURSOR = 'INVALID_CURSOR',
ACCESS_DENIED = 'ACCESS_DENIED',
VALIDATION_ERROR = 'VALIDATION_ERROR',
UNEXPECTED_ERROR = 'UNEXPECTED_ERROR',
NETWORK_ERROR = 'NETWORK_ERROR',
RATE_LIMIT_ERROR = 'RATE_LIMIT_ERROR',
}
/**
* Context information for error handling
*/
export interface ErrorContext {
/**
* Source of the error (e.g., file path and function)
*/
source?: string;
/**
* Type of entity being processed (e.g., 'User')
*/
entityType?: string;
/**
* Identifier of the entity being processed
*/
entityId?: string | Record<string, string>;
/**
* Operation being performed (e.g., 'retrieving', 'searching')
*/
operation?: string;
/**
* Additional information for debugging
*/
additionalInfo?: Record<string, unknown>;
}
/**
* Helper function to create a consistent error context object
* @param entityType Type of entity being processed
* @param operation Operation being performed
* @param source Source of the error (typically file path and function)
* @param entityId Optional identifier of the entity
* @param additionalInfo Optional additional information for debugging
* @returns A formatted ErrorContext object
*/
export function buildErrorContext(
entityType: string,
operation: string,
source: string,
entityId?: string | Record<string, string>,
additionalInfo?: Record<string, unknown>,
): ErrorContext {
return {
entityType,
operation,
source,
...(entityId && { entityId }),
...(additionalInfo && { additionalInfo }),
};
}
/**
* Detect specific error types from raw errors
* @param error The error to analyze
* @param context Context information for better error detection
* @returns Object containing the error code and status code
*/
export function detectErrorType(
error: unknown,
context: ErrorContext = {},
): { code: ErrorCode; statusCode: number } {
const methodLogger = Logger.forContext(
'utils/error-handler.util.ts',
'detectErrorType',
);
methodLogger.debug(`Detecting error type`, { error, context });
const errorMessage = error instanceof Error ? error.message : String(error);
const statusCode =
error instanceof Error && 'statusCode' in error
? (error as { statusCode: number }).statusCode
: undefined;
// Network error detection
if (
errorMessage.includes('network error') ||
errorMessage.includes('fetch failed') ||
errorMessage.includes('ECONNREFUSED') ||
errorMessage.includes('ENOTFOUND') ||
errorMessage.includes('Failed to fetch') ||
errorMessage.includes('Network request failed')
) {
return { code: ErrorCode.NETWORK_ERROR, statusCode: 500 };
}
// Rate limiting detection
if (
errorMessage.includes('rate limit') ||
errorMessage.includes('too many requests') ||
statusCode === 429
) {
return { code: ErrorCode.RATE_LIMIT_ERROR, statusCode: 429 };
}
// Not Found detection
if (
errorMessage.includes('not found') ||
errorMessage.includes('does not exist') ||
statusCode === 404
) {
return { code: ErrorCode.NOT_FOUND, statusCode: 404 };
}
// Access Denied detection
if (
errorMessage.includes('access') ||
errorMessage.includes('permission') ||
errorMessage.includes('authorize') ||
errorMessage.includes('authentication') ||
statusCode === 401 ||
statusCode === 403
) {
return { code: ErrorCode.ACCESS_DENIED, statusCode: statusCode || 403 };
}
// Invalid Cursor detection
if (
(errorMessage.includes('cursor') ||
errorMessage.includes('startAt') ||
errorMessage.includes('page')) &&
(errorMessage.includes('invalid') || errorMessage.includes('not valid'))
) {
return { code: ErrorCode.INVALID_CURSOR, statusCode: 400 };
}
// Validation Error detection
if (
errorMessage.includes('validation') ||
errorMessage.includes('invalid') ||
errorMessage.includes('required') ||
statusCode === 400 ||
statusCode === 422
) {
return {
code: ErrorCode.VALIDATION_ERROR,
statusCode: statusCode || 400,
};
}
// Default to unexpected error
return {
code: ErrorCode.UNEXPECTED_ERROR,
statusCode: statusCode || 500,
};
}
/**
* Create user-friendly error messages based on error type and context
* @param code The error code
* @param context Context information for better error messages
* @param originalMessage The original error message
* @returns User-friendly error message
*/
export function createUserFriendlyErrorMessage(
code: ErrorCode,
context: ErrorContext = {},
originalMessage?: string,
): string {
const methodLogger = Logger.forContext(
'utils/error-handler.util.ts',
'createUserFriendlyErrorMessage',
);
const { entityType, entityId, operation } = context;
// Format entity ID for display
let entityIdStr = '';
if (entityId) {
if (typeof entityId === 'string') {
entityIdStr = entityId;
} else {
// Handle object entityId
entityIdStr = Object.values(entityId).join('/');
}
}
// Determine entity display name
const entity = entityType
? `${entityType}${entityIdStr ? ` ${entityIdStr}` : ''}`
: 'Resource';
let message = '';
switch (code) {
case ErrorCode.NOT_FOUND:
message = `${entity} not found${entityIdStr ? `: ${entityIdStr}` : ''}. Verify the ID is correct and that you have access to this ${entityType?.toLowerCase() || 'resource'}.`;
break;
case ErrorCode.ACCESS_DENIED:
message = `Access denied for ${entity.toLowerCase()}${entityIdStr ? ` ${entityIdStr}` : ''}. Verify your credentials and permissions.`;
break;
case ErrorCode.INVALID_CURSOR:
message = `Invalid pagination cursor. Use the exact cursor string returned from previous results.`;
break;
case ErrorCode.VALIDATION_ERROR:
message =
originalMessage ||
`Invalid data provided for ${operation || 'operation'} ${entity.toLowerCase()}.`;
break;
case ErrorCode.NETWORK_ERROR:
message = `Network error while ${operation || 'connecting to'} the service. Please check your internet connection and try again.`;
break;
case ErrorCode.RATE_LIMIT_ERROR:
message = `Rate limit exceeded. Please wait a moment and try again, or reduce the frequency of requests.`;
break;
default:
message = `An unexpected error occurred while ${operation || 'processing'} ${entity.toLowerCase()}.`;
}
// Include original message details if available and appropriate
if (
originalMessage &&
code !== ErrorCode.NOT_FOUND &&
code !== ErrorCode.ACCESS_DENIED
) {
message += ` Error details: ${originalMessage}`;
}
methodLogger.debug(`Created user-friendly message: ${message}`, {
code,
context,
});
return message;
}
/**
* Handle controller errors consistently
* @param error The error to handle
* @param context Context information for better error messages
* @returns Never returns, always throws an error
*/
export function handleControllerError(
error: unknown,
context: ErrorContext = {},
): never {
const methodLogger = Logger.forContext(
'utils/error-handler.util.ts',
'handleControllerError',
);
// Extract error details
const errorMessage = error instanceof Error ? error.message : String(error);
const statusCode =
error instanceof Error && 'statusCode' in error
? (error as { statusCode: number }).statusCode
: undefined;
// Detect error type using utility
const { code, statusCode: detectedStatus } = detectErrorType(
error,
context,
);
// Combine detected status with explicit status
const finalStatusCode = statusCode || detectedStatus;
// Format entity information for logging
const { entityType, entityId, operation } = context;
const entity = entityType || 'resource';
const entityIdStr = entityId
? typeof entityId === 'string'
? entityId
: JSON.stringify(entityId)
: '';
const actionStr = operation || 'processing';
// Log detailed error information
methodLogger.error(
`Error ${actionStr} ${entity}${
entityIdStr ? `: ${entityIdStr}` : ''
}: ${errorMessage}`,
error,
);
// Create user-friendly error message for the response
const message =
code === ErrorCode.VALIDATION_ERROR
? errorMessage
: createUserFriendlyErrorMessage(code, context, errorMessage);
// Throw an appropriate API error with the user-friendly message
throw createApiError(message, finalStatusCode, error);
}

View File

@@ -0,0 +1,115 @@
import {
McpError,
ErrorType,
createApiError,
createUnexpectedError,
getDeepOriginalError,
formatErrorForMcpTool,
} from './error.util.js';
describe('error.util', () => {
describe('getDeepOriginalError', () => {
it('should return the deepest original error in a chain', () => {
// Create a nested chain of errors
const deepestError = new Error('Root cause');
const middleError = createApiError(
'Middle error',
500,
deepestError,
);
const topError = createUnexpectedError('Top error', middleError);
// Should extract the deepest error
const result = getDeepOriginalError(topError);
expect(result).toBe(deepestError);
});
it('should handle null/undefined input', () => {
expect(getDeepOriginalError(null)).toBeNull();
expect(getDeepOriginalError(undefined)).toBeUndefined();
});
it('should return the input if it has no originalError', () => {
const error = new Error('Simple error');
expect(getDeepOriginalError(error)).toBe(error);
});
it('should handle non-Error objects', () => {
const nonError = { message: 'Not an error' };
expect(getDeepOriginalError(nonError)).toBe(nonError);
});
it('should prevent infinite recursion with circular references', () => {
const error1 = new McpError('Error 1', ErrorType.UNEXPECTED_ERROR);
const error2 = new McpError(
'Error 2',
ErrorType.UNEXPECTED_ERROR,
undefined,
error1,
);
// Create circular reference
error1.originalError = error2;
// Should not cause stack overflow, should return one of the errors
const result = getDeepOriginalError(error1);
expect(result).toBeTruthy();
expect(result instanceof Error).toBe(true);
});
});
describe('formatErrorForMcpTool', () => {
it('should format McpError with metadata', () => {
const error = createApiError('Test error', 404, {
detail: 'Not found',
});
const result = formatErrorForMcpTool(error);
// Check the content
expect(result.content).toEqual([
{
type: 'text',
text: 'Error: Test error',
},
]);
// Check the metadata
expect(result.metadata).toBeDefined();
expect(result.metadata?.errorType).toBe(ErrorType.API_ERROR);
expect(result.metadata?.statusCode).toBe(404);
expect(result.metadata?.errorDetails).toEqual({
detail: 'Not found',
});
});
it('should wrap non-McpError with metadata', () => {
const error = new Error('Regular error');
const result = formatErrorForMcpTool(error);
// Check content
expect(result.content[0].text).toBe('Error: Regular error');
// Check metadata
expect(result.metadata?.errorType).toBe(ErrorType.UNEXPECTED_ERROR);
expect(result.metadata?.errorDetails).toHaveProperty(
'message',
'Regular error',
);
});
it('should extract error message from non-Error objects', () => {
const result = formatErrorForMcpTool('String error');
expect(result.content[0].text).toBe('Error: String error');
expect(result.metadata?.errorType).toBe(ErrorType.UNEXPECTED_ERROR);
});
it('should extract deep original error details', () => {
const deepError = { code: 'DEEP_ERROR', message: 'Deep cause' };
const middleError = createApiError('Middle layer', 500, deepError);
const topError = createUnexpectedError('Top error', middleError);
const result = formatErrorForMcpTool(topError);
expect(result.metadata?.errorDetails).toEqual(deepError);
});
});
});

240
src/utils/error.util.ts Normal file
View File

@@ -0,0 +1,240 @@
import { Logger } from './logger.util.js';
/**
* Error types for classification
*/
export enum ErrorType {
AUTH_MISSING = 'AUTH_MISSING',
AUTH_INVALID = 'AUTH_INVALID',
API_ERROR = 'API_ERROR',
UNEXPECTED_ERROR = 'UNEXPECTED_ERROR',
}
/**
* Custom error class with type classification
*/
export class McpError extends Error {
type: ErrorType;
statusCode?: number;
originalError?: unknown;
constructor(
message: string,
type: ErrorType,
statusCode?: number,
originalError?: unknown,
) {
super(message);
this.name = 'McpError';
this.type = type;
this.statusCode = statusCode;
this.originalError = originalError;
}
}
/**
* Create an authentication missing error
*/
export function createAuthMissingError(
message: string = 'Authentication credentials are missing',
): McpError {
return new McpError(message, ErrorType.AUTH_MISSING);
}
/**
* Create an authentication invalid error
*/
export function createAuthInvalidError(
message: string = 'Authentication credentials are invalid',
): McpError {
return new McpError(message, ErrorType.AUTH_INVALID, 401);
}
/**
* Create an API error
*/
export function createApiError(
message: string,
statusCode?: number,
originalError?: unknown,
): McpError {
return new McpError(
message,
ErrorType.API_ERROR,
statusCode,
originalError,
);
}
/**
* Create an unexpected error
*/
export function createUnexpectedError(
message: string = 'An unexpected error occurred',
originalError?: unknown,
): McpError {
return new McpError(
message,
ErrorType.UNEXPECTED_ERROR,
undefined,
originalError,
);
}
/**
* Ensure an error is an McpError
*/
export function ensureMcpError(error: unknown): McpError {
if (error instanceof McpError) {
return error;
}
if (error instanceof Error) {
return createUnexpectedError(error.message, error);
}
return createUnexpectedError(String(error));
}
/**
* Get the deepest original error from an error chain
* @param error The error to extract the original cause from
* @returns The deepest original error or the error itself
*/
export function getDeepOriginalError(error: unknown): unknown {
if (!error) {
return error;
}
let current = error;
let depth = 0;
const maxDepth = 10; // Prevent infinite recursion
while (
depth < maxDepth &&
current instanceof Error &&
'originalError' in current &&
current.originalError
) {
current = current.originalError;
depth++;
}
return current;
}
/**
* Format error for MCP tool response
*/
export function formatErrorForMcpTool(error: unknown): {
content: Array<{ type: 'text'; text: string }>;
metadata?: {
errorType: ErrorType;
statusCode?: number;
errorDetails?: unknown;
};
} {
const methodLogger = Logger.forContext(
'utils/error.util.ts',
'formatErrorForMcpTool',
);
const mcpError = ensureMcpError(error);
methodLogger.error(`${mcpError.type} error`, mcpError);
// Get the deep original error for additional context
const originalError = getDeepOriginalError(mcpError.originalError);
// Safely extract details from the original error
const errorDetails =
originalError instanceof Error
? { message: originalError.message }
: originalError;
return {
content: [
{
type: 'text' as const,
text: `Error: ${mcpError.message}`,
},
],
metadata: {
errorType: mcpError.type,
statusCode: mcpError.statusCode,
errorDetails,
},
};
}
/**
* Format error for MCP resource response
*/
export function formatErrorForMcpResource(
error: unknown,
uri: string,
): {
contents: Array<{
uri: string;
text: string;
mimeType: string;
description?: string;
}>;
} {
const methodLogger = Logger.forContext(
'utils/error.util.ts',
'formatErrorForMcpResource',
);
const mcpError = ensureMcpError(error);
methodLogger.error(`${mcpError.type} error`, mcpError);
return {
contents: [
{
uri,
text: `Error: ${mcpError.message}`,
mimeType: 'text/plain',
description: `Error: ${mcpError.type}`,
},
],
};
}
/**
* Handle error in CLI context with improved user feedback
*/
export function handleCliError(error: unknown): never {
const methodLogger = Logger.forContext(
'utils/error.util.ts',
'handleCliError',
);
const mcpError = ensureMcpError(error);
methodLogger.error(`${mcpError.type} error`, mcpError);
// Print the error message
console.error(`Error: ${mcpError.message}`);
// Provide helpful context based on error type
if (mcpError.type === ErrorType.AUTH_MISSING) {
console.error(
'\nTip: Make sure to set up your API token in the configuration file or environment variables.',
);
} else if (mcpError.type === ErrorType.AUTH_INVALID) {
console.error(
'\nTip: Check that your API token is correct and has not expired.',
);
} else if (mcpError.type === ErrorType.API_ERROR) {
if (mcpError.statusCode === 429) {
console.error(
'\nTip: You may have exceeded your API rate limits. Try again later or upgrade your API plan.',
);
}
}
// Display DEBUG tip
if (process.env.DEBUG !== 'mcp:*') {
console.error(
'\nFor more detailed error information, run with DEBUG=mcp:* environment variable.',
);
}
process.exit(1);
}

131
src/utils/formatter.util.ts Normal file
View File

@@ -0,0 +1,131 @@
/**
* Standardized formatting utilities for consistent output across all CLI and Tool interfaces.
* These functions should be used by all formatters to ensure consistent formatting.
*/
/**
* Format a date in a standardized way: YYYY-MM-DD HH:MM:SS UTC
* @param dateString - ISO date string or Date object
* @returns Formatted date string
*/
export function formatDate(dateString?: string | Date): string {
if (!dateString) {
return 'Not available';
}
try {
const date =
typeof dateString === 'string' ? new Date(dateString) : dateString;
// Format: YYYY-MM-DD HH:MM:SS UTC
return date
.toISOString()
.replace('T', ' ')
.replace(/\.\d+Z$/, ' UTC');
} catch {
return 'Invalid date';
}
}
/**
* Format a URL as a markdown link
* @param url - URL to format
* @param title - Link title
* @returns Formatted markdown link
*/
export function formatUrl(url?: string, title?: string): string {
if (!url) {
return 'Not available';
}
const linkTitle = title || url;
return `[${linkTitle}](${url})`;
}
/**
* Format a heading with consistent style
* @param text - Heading text
* @param level - Heading level (1-6)
* @returns Formatted heading
*/
export function formatHeading(text: string, level: number = 1): string {
const validLevel = Math.min(Math.max(level, 1), 6);
const prefix = '#'.repeat(validLevel);
return `${prefix} ${text}`;
}
/**
* Format a list of key-value pairs as a bullet list
* @param items - Object with key-value pairs
* @param keyFormatter - Optional function to format keys
* @returns Formatted bullet list
*/
export function formatBulletList(
items: Record<string, unknown>,
keyFormatter?: (key: string) => string,
): string {
const lines: string[] = [];
for (const [key, value] of Object.entries(items)) {
if (value === undefined || value === null) {
continue;
}
const formattedKey = keyFormatter ? keyFormatter(key) : key;
const formattedValue = formatValue(value);
lines.push(`- **${formattedKey}**: ${formattedValue}`);
}
return lines.join('\n');
}
/**
* Format a value based on its type
* @param value - Value to format
* @returns Formatted value
*/
function formatValue(value: unknown): string {
if (value === undefined || value === null) {
return 'Not available';
}
if (value instanceof Date) {
return formatDate(value);
}
// Handle URL objects with url and title properties
if (typeof value === 'object' && value !== null && 'url' in value) {
const urlObj = value as { url: string; title?: string };
if (typeof urlObj.url === 'string') {
return formatUrl(urlObj.url, urlObj.title);
}
}
if (typeof value === 'string') {
// Check if it's a URL
if (value.startsWith('http://') || value.startsWith('https://')) {
return formatUrl(value);
}
// Check if it might be a date
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value)) {
return formatDate(value);
}
return value;
}
if (typeof value === 'boolean') {
return value ? 'Yes' : 'No';
}
return String(value);
}
/**
* Format a separator line
* @returns Separator line
*/
export function formatSeparator(): string {
return '---';
}

369
src/utils/logger.util.ts Normal file
View File

@@ -0,0 +1,369 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import * as crypto from 'crypto';
/**
* Format a timestamp for logging
* @returns Formatted timestamp [HH:MM:SS]
*/
function getTimestamp(): string {
const now = new Date();
return `[${now.toISOString().split('T')[1].split('.')[0]}]`;
}
/**
* Safely convert object to string with size limits
* @param obj Object to stringify
* @param maxLength Maximum length of the resulting string
* @returns Safely stringified object
*/
function safeStringify(obj: unknown, maxLength = 1000): string {
try {
const str = JSON.stringify(obj);
if (str.length <= maxLength) {
return str;
}
return `${str.substring(0, maxLength)}... (truncated, ${str.length} chars total)`;
} catch {
return '[Object cannot be stringified]';
}
}
/**
* Extract essential values from larger objects for logging
* @param obj The object to extract values from
* @param keys Keys to extract (if available)
* @returns Object containing only the specified keys
*/
function extractEssentialValues(
obj: Record<string, unknown>,
keys: string[],
): Record<string, unknown> {
const result: Record<string, unknown> = {};
keys.forEach((key) => {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
result[key] = obj[key];
}
});
return result;
}
/**
* Format source path consistently using the standardized format:
* [module/file.ts@function] or [module/file.ts]
*
* @param filePath File path (with or without src/ prefix)
* @param functionName Optional function name
* @returns Formatted source path according to standard pattern
*/
function formatSourcePath(filePath: string, functionName?: string): string {
// Always strip 'src/' prefix for consistency
const normalizedPath = filePath.replace(/^src\//, '');
return functionName
? `[${normalizedPath}@${functionName}]`
: `[${normalizedPath}]`;
}
/**
* Check if debug logging is enabled for a specific module
*
* This function parses the DEBUG environment variable to determine if a specific
* module should have debug logging enabled. The DEBUG variable can be:
* - 'true' or '1': Enable all debug logging
* - Comma-separated list of modules: Enable debug only for those modules
* - Module patterns with wildcards: e.g., 'controllers/*' enables all controllers
*
* Examples:
* - DEBUG=true
* - DEBUG=controllers/*,services/aws.sso.auth.service.ts
* - DEBUG=transport,utils/formatter*
*
* @param modulePath The module path to check against DEBUG patterns
* @returns true if debug is enabled for this module, false otherwise
*/
function isDebugEnabledForModule(modulePath: string): boolean {
const debugEnv = process.env.DEBUG;
if (!debugEnv) {
return false;
}
// If debug is set to true or 1, enable all debug logging
if (debugEnv === 'true' || debugEnv === '1') {
return true;
}
// Parse comma-separated debug patterns
const debugPatterns = debugEnv.split(',').map((p) => p.trim());
// Check if the module matches any pattern
return debugPatterns.some((pattern) => {
// Convert glob-like patterns to regex
// * matches anything within a path segment
// ** matches across path segments
const regexPattern = pattern
.replace(/\*/g, '.*') // Convert * to regex .*
.replace(/\?/g, '.'); // Convert ? to regex .
const regex = new RegExp(`^${regexPattern}$`);
return (
regex.test(modulePath) ||
// Check for pattern matches without the 'src/' prefix
regex.test(modulePath.replace(/^src\//, ''))
);
});
}
// Generate a unique session ID for this process
const SESSION_ID = crypto.randomUUID();
// Get the package name from environment variables or default to 'mcp-server'
const getPkgName = (): string => {
try {
// Try to get it from package.json first if available
const packageJsonPath = path.resolve(process.cwd(), 'package.json');
if (fs.existsSync(packageJsonPath)) {
const packageJson = JSON.parse(
fs.readFileSync(packageJsonPath, 'utf8'),
);
if (packageJson.name) {
// Extract the last part of the name if it's scoped
const match = packageJson.name.match(/(@[\w-]+\/)?(.+)/);
return match ? match[2] : packageJson.name;
}
}
} catch {
// Silently fail and use default
}
// Fallback to environment variable or default
return process.env.PACKAGE_NAME || 'mcp-server';
};
// MCP logs directory setup
const HOME_DIR = os.homedir();
const MCP_DATA_DIR = path.join(HOME_DIR, '.mcp', 'data');
const CLI_NAME = getPkgName();
// Ensure the MCP data directory exists
if (!fs.existsSync(MCP_DATA_DIR)) {
fs.mkdirSync(MCP_DATA_DIR, { recursive: true });
}
// Create the log file path with session ID
const LOG_FILENAME = `${CLI_NAME}.${SESSION_ID}.log`;
const LOG_FILEPATH = path.join(MCP_DATA_DIR, LOG_FILENAME);
// Write initial log header
fs.writeFileSync(
LOG_FILEPATH,
`# ${CLI_NAME} Log Session\n` +
`Session ID: ${SESSION_ID}\n` +
`Started: ${new Date().toISOString()}\n` +
`Process ID: ${process.pid}\n` +
`Working Directory: ${process.cwd()}\n` +
`Command: ${process.argv.join(' ')}\n\n` +
`## Log Entries\n\n`,
'utf8',
);
// Logger singleton to track initialization
let isLoggerInitialized = false;
/**
* Logger class for consistent logging across the application.
*
* RECOMMENDED USAGE:
*
* 1. Create a file-level logger using the static forContext method:
* ```
* const logger = Logger.forContext('controllers/myController.ts');
* ```
*
* 2. For method-specific logging, create a method logger:
* ```
* const methodLogger = Logger.forContext('controllers/myController.ts', 'myMethod');
* ```
*
* 3. Avoid using raw string prefixes in log messages. Instead, use contextualized loggers.
*
* 4. For debugging objects, use the debugResponse method to log only essential properties.
*
* 5. Set DEBUG environment variable to control which modules show debug logs:
* - DEBUG=true (enable all debug logs)
* - DEBUG=controllers/*,services/* (enable for specific module groups)
* - DEBUG=transport,utils/formatter* (enable specific modules, supports wildcards)
*/
class Logger {
private context?: string;
private modulePath: string;
private static sessionId = SESSION_ID;
private static logFilePath = LOG_FILEPATH;
constructor(context?: string, modulePath: string = '') {
this.context = context;
this.modulePath = modulePath;
// Log initialization message only once
if (!isLoggerInitialized) {
this.info(
`Logger initialized with session ID: ${Logger.sessionId}`,
);
this.info(`Logs will be saved to: ${Logger.logFilePath}`);
isLoggerInitialized = true;
}
}
/**
* Create a contextualized logger for a specific file or component.
* This is the preferred method for creating loggers.
*
* @param filePath The file path (e.g., 'controllers/aws.sso.auth.controller.ts')
* @param functionName Optional function name for more specific context
* @returns A new Logger instance with the specified context
*
* @example
* // File-level logger
* const logger = Logger.forContext('controllers/myController.ts');
*
* // Method-level logger
* const methodLogger = Logger.forContext('controllers/myController.ts', 'myMethod');
*/
static forContext(filePath: string, functionName?: string): Logger {
return new Logger(formatSourcePath(filePath, functionName), filePath);
}
/**
* Create a method level logger from a context logger
* @param method Method name
* @returns A new logger with the method context
*/
forMethod(method: string): Logger {
return Logger.forContext(this.modulePath, method);
}
private _formatMessage(message: string): string {
return this.context ? `${this.context} ${message}` : message;
}
private _formatArgs(args: unknown[]): unknown[] {
// If the first argument is an object and not an Error, safely stringify it
if (
args.length > 0 &&
typeof args[0] === 'object' &&
args[0] !== null &&
!(args[0] instanceof Error)
) {
args[0] = safeStringify(args[0]);
}
return args;
}
_log(
level: 'info' | 'warn' | 'error' | 'debug',
message: string,
...args: unknown[]
) {
// Skip debug messages if not enabled for this module
if (level === 'debug' && !isDebugEnabledForModule(this.modulePath)) {
return;
}
const timestamp = getTimestamp();
const prefix = `${timestamp} [${level.toUpperCase()}]`;
let logMessage = `${prefix} ${this._formatMessage(message)}`;
const formattedArgs = this._formatArgs(args);
if (formattedArgs.length > 0) {
// Handle errors specifically
if (formattedArgs[0] instanceof Error) {
const error = formattedArgs[0] as Error;
logMessage += ` Error: ${error.message}`;
if (error.stack) {
logMessage += `\n${error.stack}`;
}
// If there are more args, add them after the error
if (formattedArgs.length > 1) {
logMessage += ` ${formattedArgs
.slice(1)
.map((arg) =>
typeof arg === 'string' ? arg : safeStringify(arg),
)
.join(' ')}`;
}
} else {
logMessage += ` ${formattedArgs
.map((arg) =>
typeof arg === 'string' ? arg : safeStringify(arg),
)
.join(' ')}`;
}
}
// Write to log file
try {
fs.appendFileSync(Logger.logFilePath, `${logMessage}\n`, 'utf8');
} catch (err) {
// If we can't write to the log file, log the error to console
console.error(`Failed to write to log file: ${err}`);
}
if (process.env.NODE_ENV === 'test') {
console[level](logMessage);
} else {
console.error(logMessage);
}
}
info(message: string, ...args: unknown[]) {
this._log('info', message, ...args);
}
warn(message: string, ...args: unknown[]) {
this._log('warn', message, ...args);
}
error(message: string, ...args: unknown[]) {
this._log('error', message, ...args);
}
debug(message: string, ...args: unknown[]) {
this._log('debug', message, ...args);
}
/**
* Log essential information about an API response
* @param message Log message
* @param response API response object
* @param essentialKeys Keys to extract from the response
*/
debugResponse(
message: string,
response: Record<string, unknown>,
essentialKeys: string[],
) {
const essentialInfo = extractEssentialValues(response, essentialKeys);
this.debug(message, essentialInfo);
}
/**
* Get the current session ID
* @returns The UUID for the current logging session
*/
static getSessionId(): string {
return Logger.sessionId;
}
/**
* Get the current log file path
* @returns The path to the current log file
*/
static getLogFilePath(): string {
return Logger.logFilePath;
}
}
// Only export the Logger class to enforce contextual logging via Logger.forContext
export { Logger };

143
src/utils/transport.util.ts Normal file
View File

@@ -0,0 +1,143 @@
import { Logger } from './logger.util.js';
import {
createApiError,
createAuthInvalidError,
createUnexpectedError,
McpError,
} from './error.util.js';
// Create a contextualized logger for this file
const transportLogger = Logger.forContext('utils/transport.util.ts');
// Log transport utility initialization
transportLogger.debug('Transport utility initialized');
/**
* Interface for HTTP request options
*/
export interface RequestOptions {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
headers?: Record<string, string>;
body?: unknown;
}
/**
* Generic and reusable function to fetch data from any API endpoint.
* Handles standard HTTP request setup, response checking, basic error handling, and logging.
*
* @param url The full URL to fetch data from.
* @param options Request options including method, headers, and body.
* @returns The response data parsed as type T.
* @throws {McpError} If the request fails, including network errors, non-OK HTTP status, or JSON parsing issues.
*/
export async function fetchApi<T>(
url: string,
options: RequestOptions = {},
): Promise<T> {
const methodLogger = Logger.forContext(
'utils/transport.util.ts',
'fetchApi',
);
// Prepare standard request options
const requestOptions: RequestInit = {
method: options.method || 'GET',
headers: {
// Standard headers, allow overrides via options.headers
'Content-Type': 'application/json',
Accept: 'application/json',
...options.headers,
},
body: options.body ? JSON.stringify(options.body) : undefined,
};
methodLogger.debug(`Executing API call: ${requestOptions.method} ${url}`);
const startTime = performance.now(); // Track performance
try {
const response = await fetch(url, requestOptions);
const endTime = performance.now();
const duration = (endTime - startTime).toFixed(2);
methodLogger.debug(
`API call completed in ${duration}ms with status: ${response.status} ${response.statusText}`,
{ url, status: response.status },
);
// Check if the response status is OK (2xx)
if (!response.ok) {
const errorText = await response.text(); // Get error body for context
methodLogger.error(
`API error response (${response.status}):`,
errorText,
);
// Classify standard HTTP errors
if (response.status === 401) {
throw createAuthInvalidError(
'Authentication failed. Check API token if required.',
);
} else if (response.status === 403) {
throw createAuthInvalidError(
'Permission denied for the requested resource.',
);
} else if (response.status === 404) {
throw createApiError(
'Resource not found at the specified URL.',
response.status,
errorText,
);
} else {
// Generic API error for other non-2xx statuses
throw createApiError(
`API request failed with status ${response.status}: ${response.statusText}`,
response.status,
errorText,
);
}
}
// Attempt to parse the response body as JSON
try {
const responseData = await response.json();
methodLogger.debug('Response body successfully parsed as JSON.');
// methodLogger.debug('Response Data:', responseData); // Uncomment for full response logging
return responseData as T;
} catch (parseError) {
methodLogger.error(
'Failed to parse API response JSON:',
parseError,
);
// Throw a specific error for JSON parsing failure
throw createApiError(
`Failed to parse API response JSON: ${parseError instanceof Error ? parseError.message : String(parseError)}`,
response.status, // Include original status for context
parseError,
);
}
} catch (error) {
const endTime = performance.now();
const duration = (endTime - startTime).toFixed(2);
methodLogger.error(
`API call failed after ${duration}ms for ${url}:`,
error,
);
// Rethrow if it's already an McpError (e.g., from status checks or parsing)
if (error instanceof McpError) {
throw error;
}
// Handle potential network errors (TypeError in fetch)
if (error instanceof TypeError) {
throw createApiError(
`Network error during API call: ${error.message}`,
undefined, // No specific HTTP status for network errors
error,
);
}
// Wrap any other unexpected errors
throw createUnexpectedError('Unexpected error during API call', error);
}
}

115
tsconfig.json Normal file
View File

@@ -0,0 +1,115 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "ES2020", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
"lib": ["ES2020"], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "libReplacement": true, /* Enable lib replacement. */
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "NodeNext", /* Specify what module code is generated. */
// "rootDir": "./", /* Specify the root folder within your source files. */
"moduleResolution": "NodeNext", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
// "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
// "noUncheckedSideEffectImports": true, /* Check side effect imports. */
"resolveJsonModule": true, /* Enable importing .json files. */
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
"declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
"outDir": "./dist", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
/* Interop Constraints */
"isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
// "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
// "erasableSyntaxOnly": true, /* Do not allow runtime constructs that are not part of ECMAScript. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
"noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
"strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
"noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
"noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
"noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
},
"include": ["src/**/*"],
"exclude": ["**/*.test.ts", "**/*.tsconfig.ts"]
}