Implementation of MCP for LLM Observability capture to PostHig
Some checks failed
CI - Semantic Release / Semantic Release (push) Failing after 7m48s
Some checks failed
CI - Semantic Release / Semantic Release (push) Failing after 7m48s
This commit is contained in:
9
.env.example
Normal file
9
.env.example
Normal 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
26
.github/dependabot.yml
vendored
Normal 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"
|
||||||
45
.github/workflows/ci-dependabot-auto-merge.yml
vendored
Normal file
45
.github/workflows/ci-dependabot-auto-merge.yml
vendored
Normal 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 }}
|
||||||
37
.github/workflows/ci-dependency-check.yml
vendored
Normal file
37
.github/workflows/ci-dependency-check.yml
vendored
Normal 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
|
||||||
66
.github/workflows/ci-semantic-release.yml
vendored
Normal file
66
.github/workflows/ci-semantic-release.yml
vendored
Normal 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
44
.gitignore
vendored
Normal 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/
|
||||||
1
.node-version
Normal file
1
.node-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
22.14.0
|
||||||
43
.npmignore
Normal file
43
.npmignore
Normal 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
7
.npmrc
Normal 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
8
.prettierrc
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"semi": true,
|
||||||
|
"useTabs": true,
|
||||||
|
"tabWidth": 4,
|
||||||
|
"printWidth": 80,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
34
.releaserc.json
Normal file
34
.releaserc.json
Normal 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
7
CHANGELOG.md
Normal 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
197
README.md
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
# LLM Observability MCP for PostHog
|
||||||
|
|
||||||
|
[](https://www.npmjs.com/package/@sfiorini/llm-observability-mcp)
|
||||||
|
[](https://github.com/sfiorini/llm-observability-mcp)
|
||||||
|
[](https://www.typescriptlang.org/)
|
||||||
|
[](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
9
cspell.json
Normal 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
46
eslint.config.mjs
Normal 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
13099
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
116
package.json
Normal file
116
package.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
38
scripts/ensure-executable.js
Normal file
38
scripts/ensure-executable.js
Normal 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
3
scripts/package.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
204
scripts/update-version.js
Executable file
204
scripts/update-version.js
Executable 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
58
src/cli/index.ts
Normal 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');
|
||||||
|
}
|
||||||
60
src/cli/posthog-llm.cli.ts
Normal file
60
src/cli/posthog-llm.cli.ts
Normal 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 };
|
||||||
32
src/controllers/posthog-llm.controller.ts
Normal file
32
src/controllers/posthog-llm.controller.ts
Normal 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
68
src/index.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
74
src/resources/posthog-llm.resource.ts
Normal file
74
src/resources/posthog-llm.resource.ts
Normal 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
90
src/server/mcpServer.ts
Normal 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
20
src/server/stdio.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
189
src/server/streamableHttp.ts
Normal file
189
src/server/streamableHttp.ts
Normal 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;
|
||||||
|
}
|
||||||
61
src/services/posthog-llm.service.ts
Normal file
61
src/services/posthog-llm.service.ts
Normal 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 };
|
||||||
108
src/tools/posthog-llm.tool.ts
Normal file
108
src/tools/posthog-llm.tool.ts
Normal 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 };
|
||||||
41
src/tools/posthog-llm.types.ts
Normal file
41
src/tools/posthog-llm.types.ts
Normal 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
21
src/types/common.types.ts
Normal 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
157
src/utils/cli.test.util.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
131
src/utils/config.util.test.ts
Normal file
131
src/utils/config.util.test.ts
Normal 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
173
src/utils/config.util.ts
Normal 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');
|
||||||
24
src/utils/constants.util.ts
Normal file
24
src/utils/constants.util.ts
Normal 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';
|
||||||
128
src/utils/error-handler.util.test.ts
Normal file
128
src/utils/error-handler.util.test.ts
Normal 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' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
307
src/utils/error-handler.util.ts
Normal file
307
src/utils/error-handler.util.ts
Normal 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);
|
||||||
|
}
|
||||||
115
src/utils/error.util.test.ts
Normal file
115
src/utils/error.util.test.ts
Normal 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
240
src/utils/error.util.ts
Normal 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
131
src/utils/formatter.util.ts
Normal 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
369
src/utils/logger.util.ts
Normal 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
143
src/utils/transport.util.ts
Normal 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
115
tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user