chore: Refactor mcp to use global monorepo oxlint and prettier (#41233)

This commit is contained in:
Rafael Audibert
2025-11-11 15:27:29 -03:00
committed by GitHub
parent a036f3c822
commit 71915db567
110 changed files with 928 additions and 1349 deletions

View File

@@ -26,47 +26,6 @@ jobs:
- '.github/workflows/mcp-ci.yml'
- '.github/workflows/mcp-publish.yml'
lint-and-format:
name: Lint, Format, and Type Check
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.mcp == 'true'
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
- name: Run linter
run: cd products/mcp && pnpm run lint
- name: Run formatter
run: cd products/mcp && pnpm run format
- name: Run type check
run: cd products/mcp && pnpm run typecheck
- name: Check for changes
run: |
if [ -n "$(git status --porcelain)" ]; then
echo "Code formatting or linting changes detected!"
git diff
exit 1
fi
unit-tests:
name: Unit Tests
runs-on: ubuntu-latest

View File

@@ -16,7 +16,10 @@
"common/plugin_transpiler/dist",
"common/hogvm/__tests__/__snapshots__",
"common/hogvm/__tests__/__snapshots__/**",
"frontend/src/queries/validators.js"
"frontend/src/queries/validators.js",
"products/mcp/**/generated.ts",
"products/mcp/schema/tool-inputs.json",
"products/mcp/python"
],
"rules": {
"no-constant-condition": "off",

View File

@@ -6,6 +6,7 @@ staticfiles
.env
*.code-workspace
.mypy_cache
.cache/
*Type.ts
.idea
.yalc
@@ -15,8 +16,7 @@ common/storybook/dist/
dist/
node_modules/
pnpm-lock.yaml
posthog/templates/email/*
posthog/templates/**/*.html
posthog/templates/**/*
common/hogvm/typescript/src/stl/bytecode.ts
common/hogvm/__tests__/__snapshots__/*
rust/
@@ -26,3 +26,10 @@ cli/tests/_cases/**/*
frontend/src/products.tsx
frontend/src/layout.html
**/fixtures/**
products/mcp/schema/**/*
products/mcp/python/**/*
products/mcp/**/generated.ts
products/mcp/typescript/worker-configuration.d.ts
frontend/src/taxonomy/core-filter-definitions-by-group.json
frontend/dist/
frontend/**/*LogicType.ts

View File

@@ -14,6 +14,7 @@
"^@posthog.*$",
"^lib/(.*)$|^scenes/(.*)$",
"^~/(.*)$",
"^@/(.*)$",
"^public/(.*)$",
"^products/(.*)$",
"^storybook/(.*)$",

View File

@@ -1,4 +0,0 @@
.cache/
src/taxonomy/core-filter-definitions-by-group.json
dist/
*LogicType.ts

View File

@@ -37,8 +37,8 @@
"schema:build:json": "ts-node ./bin/build-schema-json.mjs && prettier --write ./src/queries/schema.json && ts-node ./bin/build-validators.mjs && prettier --write ./src/queries/validators.js",
"test": "SHARD_IDX=${SHARD_INDEX:-1}; SHARD_TOTAL=${SHARD_COUNT:-1}; echo $SHARD_IDX/$SHARD_TOTAL; pnpm build:products && jest --testPathPattern='(frontend/|products/|common/)' --runInBand --forceExit --shard=$SHARD_IDX/$SHARD_TOTAL",
"jest": "pnpm build:products && jest",
"prettier": "prettier --write \"../{products,frontend/src}/**/*.{js,mjs,ts,tsx,json,yaml,yml,css,scss}\"",
"prettier:check": "prettier --check \"../{products,frontend/src}/**/*.{js,mjs,ts,tsx,json,yaml,yml,css,scss}\"",
"prettier": "prettier --write \"../{products,frontend/src}/**/*.{js,mjs,ts,tsx,json,yaml,yml,css,scss}\" --ignore-path \"../.prettierignore\"",
"prettier:check": "prettier --check \"../{products,frontend/src}/**/*.{js,mjs,ts,tsx,json,yaml,yml,css,scss}\" --ignore-path \"../.prettierignore\"",
"typescript:check": "tsc --noEmit && echo \"No errors reported by tsc.\"",
"lint": "pnpm lint:js && pnpm lint:css",
"lint:js": "cd .. && oxlint --quiet",

View File

@@ -1,12 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"files": {
"ignore": ["**/generated.ts", "schema/tool-inputs.json", "python/**", ".mypy_cache/**"]
},
"rules": {
"no-debugger": "off",
"no-console": "off",
"@typescript-eslint/explicit-function-return-type": "off",
"jest/no-restricted-matchers": "off"
}
}

View File

@@ -1,5 +0,0 @@
schema/tool-inputs.json
python/**
**/generated.ts
node_modules/
.mypy_cache/

View File

@@ -1,7 +0,0 @@
{
"trailingComma": "es5",
"tabWidth": 4,
"semi": false,
"singleQuote": true,
"printWidth": 100
}

View File

@@ -20,21 +20,21 @@ npx @posthog/wizard@latest mcp add
```json
{
"mcpServers": {
"posthog": {
"command": "npx",
"args": [
"-y",
"mcp-remote@latest",
"https://mcp.posthog.com/mcp", // You can replace this with https://mcp.posthog.com/sse if your client does not support Streamable HTTP
"--header",
"Authorization:${POSTHOG_AUTH_HEADER}"
],
"env": {
"POSTHOG_AUTH_HEADER": "Bearer {INSERT_YOUR_PERSONAL_API_KEY_HERE}"
}
}
"mcpServers": {
"posthog": {
"command": "npx",
"args": [
"-y",
"mcp-remote@latest",
"https://mcp.posthog.com/mcp", // You can replace this with https://mcp.posthog.com/sse if your client does not support Streamable HTTP
"--header",
"Authorization:${POSTHOG_AUTH_HEADER}"
],
"env": {
"POSTHOG_AUTH_HEADER": "Bearer {INSERT_YOUR_PERSONAL_API_KEY_HERE}"
}
}
}
}
```
@@ -51,27 +51,27 @@ If you want to call MCP from Node (outside an IDE), use the Model Context Protoc
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import { ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js'
import { URL } from 'node:url'
import { mkdirSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { URL } from 'node:url'
const AUTH = process.env.POSTHOG_AUTH_HEADER // "Bearer phx_…"
const MCP_URL = process.env.MCP_URL || 'https://mcp.posthog.com/mcp'
if (!AUTH?.startsWith('Bearer ')) {
console.error('Set POSTHOG_AUTH_HEADER="Bearer phx_..."')
process.exit(1)
console.error('Set POSTHOG_AUTH_HEADER="Bearer phx_..."')
process.exit(1)
}
const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), {
requestInit: {
headers: {
Authorization: AUTH,
// Required for Streamable HTTP (JSON + SSE)
Accept: 'application/json, text/event-stream',
},
requestInit: {
headers: {
Authorization: AUTH,
// Required for Streamable HTTP (JSON + SSE)
Accept: 'application/json, text/event-stream',
},
serverInfo: { name: 'example-node-client', version: '0.0.1' },
},
serverInfo: { name: 'example-node-client', version: '0.0.1' },
})
const client = new Client({ name: 'example-node-client', version: '0.0.1' })
@@ -115,26 +115,26 @@ docker build -t posthog-mcp .
```json
{
"mcpServers": {
"posthog": {
"type": "stdio",
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"--env",
"POSTHOG_AUTH_HEADER=${POSTHOG_AUTH_HEADER}",
"--env",
"POSTHOG_REMOTE_MCP_URL=${POSTHOG_REMOTE_MCP_URL:-https://mcp.posthog.com/mcp}",
"posthog-mcp"
],
"env": {
"POSTHOG_AUTH_HEADER": "Bearer {INSERT_YOUR_PERSONAL_API_KEY_HERE}",
"POSTHOG_REMOTE_MCP_URL": "https://mcp.posthog.com/mcp"
}
}
"mcpServers": {
"posthog": {
"type": "stdio",
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"--env",
"POSTHOG_AUTH_HEADER=${POSTHOG_AUTH_HEADER}",
"--env",
"POSTHOG_REMOTE_MCP_URL=${POSTHOG_REMOTE_MCP_URL:-https://mcp.posthog.com/mcp}",
"posthog-mcp"
],
"env": {
"POSTHOG_AUTH_HEADER": "Bearer {INSERT_YOUR_PERSONAL_API_KEY_HERE}",
"POSTHOG_REMOTE_MCP_URL": "https://mcp.posthog.com/mcp"
}
}
}
}
```
@@ -239,14 +239,5 @@ Alternatively, you can use the following configuration in the MCP Inspector:
Use transport type `STDIO`.
**Command:**
```bash
npx
```
**Arguments:**
```bash
-y mcp-remote@latest http://localhost:8787/mcp --header "Authorization: Bearer {INSERT_YOUR_PERSONAL_API_KEY_HERE}"
```
- **Command**: `npx`
- **Arguments**: `-y mcp-remote@latest http://localhost:8787/mcp --header "Authorization: Bearer {INSERT_YOUR_PERSONAL_API_KEY_HERE}"`

View File

@@ -1,15 +1,16 @@
import { openai } from '@ai-sdk/openai'
import { PostHogAgentToolkit } from '@posthog/agent-toolkit/integrations/ai-sdk'
import { generateText, stepCountIs } from 'ai'
import 'dotenv/config'
async function analyzeProductUsage() {
import { PostHogAgentToolkit } from '@posthog/agent-toolkit/integrations/ai-sdk'
async function analyzeProductUsage(): Promise<void> {
const agentToolkit = new PostHogAgentToolkit({
posthogPersonalApiKey: process.env.POSTHOG_PERSONAL_API_KEY!,
posthogApiBaseUrl: process.env.POSTHOG_API_BASE_URL || 'https://us.posthog.com',
})
const result = await generateText({
await generateText({
model: openai('gpt-5-mini'),
tools: await agentToolkit.getTools(),
stopWhen: stepCountIs(30),
@@ -24,7 +25,7 @@ async function analyzeProductUsage() {
})
}
async function main() {
async function main(): Promise<void> {
try {
await analyzeProductUsage()
} catch (error) {

View File

@@ -1,10 +1,11 @@
import { ChatPromptTemplate, MessagesPlaceholder } from '@langchain/core/prompts'
import { ChatOpenAI } from '@langchain/openai'
import { PostHogAgentToolkit } from '@posthog/agent-toolkit/integrations/langchain'
import { AgentExecutor, createToolCallingAgent } from 'langchain/agents'
import 'dotenv/config'
import { AgentExecutor, createToolCallingAgent } from 'langchain/agents'
async function analyzeProductUsage() {
import { PostHogAgentToolkit } from '@posthog/agent-toolkit/integrations/langchain'
async function analyzeProductUsage(): Promise<void> {
const agentToolkit = new PostHogAgentToolkit({
posthogPersonalApiKey: process.env.POSTHOG_PERSONAL_API_KEY!,
posthogApiBaseUrl: process.env.POSTHOG_API_BASE_URL || 'https://us.posthog.com',
@@ -38,7 +39,7 @@ async function analyzeProductUsage() {
maxIterations: 5,
})
const result = await agentExecutor.invoke({
await agentExecutor.invoke({
input: `Please analyze our product usage:
1. Get all available insights (limit 100) and pick the 5 most relevant ones
@@ -49,7 +50,7 @@ async function analyzeProductUsage() {
})
}
async function main() {
async function main(): Promise<void> {
try {
await analyzeProductUsage()
} catch (error) {

View File

@@ -19,8 +19,8 @@ cp .env.example .env
```
3. Update your `.env` file with:
- `POSTHOG_PERSONAL_API_KEY`: Your PostHog personal API key
- `OPENAI_API_KEY`: Your OpenAI API key
- `POSTHOG_PERSONAL_API_KEY`: Your PostHog personal API key
- `OPENAI_API_KEY`: Your OpenAI API key
## Usage
@@ -38,9 +38,9 @@ The example will:
2. Load all available PostHog tools from the MCP server
3. Create a LangChain agent with access to PostHog data
4. Analyze product usage by:
- Getting available insights
- Querying data for the most relevant ones
- Summarizing key findings
- Getting available insights
- Querying data for the most relevant ones
- Summarizing key findings
## Available Tools

View File

@@ -1,5 +1,5 @@
{
"name": "posthog-mcp-monorepo",
"name": "mcp",
"version": "0.0.0",
"private": true,
"scripts": {
@@ -11,13 +11,7 @@
"test:integration": "cd typescript && pnpm test:integration",
"schema:build:python": "bash python/scripts/generate-pydantic-models.sh",
"schema:build": "pnpm run schema:build:json && pnpm run schema:build:python",
"format": "prettier --write . && oxlint --fix --fix-suggestions --quiet .",
"lint": "prettier --check . && oxlint .",
"format:python": "cd python && uv run ruff format .",
"lint:python": "cd python && uv run ruff check --fix .",
"test:python": "cd python && uv run pytest tests/ -v",
"typecheck": "cd typescript && pnpm typecheck",
"typecheck:python": "cd python && uvx ty check",
"docker:build": "docker build -t posthog-mcp .",
"docker:run": "docker run -i --rm --env POSTHOG_AUTH_HEADER=${POSTHOG_AUTH_HEADER} --env POSTHOG_REMOTE_MCP_URL=${POSTHOG_REMOTE_MCP_URL:-https://mcp.posthog.com/mcp} posthog-mcp",
"docker:inspector": "npx @modelcontextprotocol/inspector docker run -i --rm --env POSTHOG_AUTH_HEADER=${POSTHOG_AUTH_HEADER} --env POSTHOG_REMOTE_MCP_URL=${POSTHOG_REMOTE_MCP_URL:-https://mcp.posthog.com/mcp} posthog-mcp"

View File

@@ -21,14 +21,15 @@ import { generateText } from 'ai'
import { PostHogAgentToolkit } from '@posthog/agent-toolkit/integrations/ai-sdk'
const toolkit = new PostHogAgentToolkit({
posthogPersonalApiKey: process.env.POSTHOG_PERSONAL_API_KEY!,
posthogApiBaseUrl: 'https://us.posthog.com', // or https://eu.posthog.com if you are hosting in the EU
posthogPersonalApiKey: process.env.POSTHOG_PERSONAL_API_KEY!,
posthogApiBaseUrl: 'https://us.posthog.com', // or https://eu.posthog.com if you are hosting in the EU
})
const result = await generateText({
model: openai('gpt-4'),
tools: await toolkit.getTools(),
prompt: 'Analyze our product usage by getting the top 5 most interesting insights and summarising the data from them.',
model: openai('gpt-4'),
tools: await toolkit.getTools(),
prompt:
'Analyze our product usage by getting the top 5 most interesting insights and summarising the data from them.',
})
```
@@ -44,24 +45,24 @@ import { AgentExecutor, createToolCallingAgent } from 'langchain/agents'
import { PostHogAgentToolkit } from '@posthog/agent-toolkit/integrations/langchain'
const toolkit = new PostHogAgentToolkit({
posthogPersonalApiKey: process.env.POSTHOG_PERSONAL_API_KEY!,
posthogApiBaseUrl: 'https://us.posthog.com', // or https://eu.posthog.com if you are hosting in the EU
posthogPersonalApiKey: process.env.POSTHOG_PERSONAL_API_KEY!,
posthogApiBaseUrl: 'https://us.posthog.com', // or https://eu.posthog.com if you are hosting in the EU
})
const tools = await toolkit.getTools()
const llm = new ChatOpenAI({ model: 'gpt-4' })
const prompt = ChatPromptTemplate.fromMessages([
['system', 'You are a data analyst with access to PostHog analytics'],
['human', '{input}'],
new MessagesPlaceholder('agent_scratchpad'),
['system', 'You are a data analyst with access to PostHog analytics'],
['human', '{input}'],
new MessagesPlaceholder('agent_scratchpad'),
])
const agent = createToolCallingAgent({ llm, tools, prompt })
const executor = new AgentExecutor({ agent, tools })
const result = await executor.invoke({
input: 'Analyze our product usage by getting the top 5 most interesting insights and summarising the data from them.',
input: 'Analyze our product usage by getting the top 5 most interesting insights and summarising the data from them.',
})
```

View File

@@ -1,10 +1,9 @@
#!/usr/bin/env tsx
// Generates JSON schema from Zod tool-inputs schemas for Python Pydantic schema generation
import * as fs from 'node:fs'
import * as path from 'node:path'
import { zodToJsonSchema } from 'zod-to-json-schema'
import * as schemas from '../src/schema/tool-inputs'
const outputPath = path.join(__dirname, '../../schema/tool-inputs.json')

View File

@@ -1,5 +1,4 @@
#!/usr/bin/env tsx
import { execSync } from 'node:child_process'
import * as fs from 'node:fs'
@@ -37,7 +36,7 @@ function generateClient() {
}
}
function cleanup() {
function cleanup(): void {
try {
if (fs.existsSync(TEMP_SCHEMA_PATH)) {
fs.unlinkSync(TEMP_SCHEMA_PATH)
@@ -47,7 +46,7 @@ function cleanup() {
}
}
async function main() {
async function main(): Promise<void> {
const schemaFetched = await fetchSchema()
if (!schemaFetched) {
process.exit(1)

View File

@@ -1,3 +1,5 @@
import { z } from 'zod'
import { ErrorCode } from '@/lib/errors'
import { withPagination } from '@/lib/utils/api'
import { getSearchParamsFromRecord } from '@/lib/utils/helper-functions'
@@ -51,7 +53,7 @@ import { type Organization, OrganizationSchema } from '@/schema/orgs'
import { type Project, ProjectSchema } from '@/schema/projects'
import type { ExperimentCreateSchema } from '@/schema/tool-inputs'
import { isShortId } from '@/tools/insights/utils'
import { z } from 'zod'
import type {
CreateSurveyInput,
GetSurveySpecificStatsInput,
@@ -81,6 +83,9 @@ export interface ApiConfig {
apiToken: string
baseUrl: string
}
type Endpoint = Record<string, any>
export class ApiClient {
private config: ApiConfig
private baseUrl: string
@@ -93,14 +98,15 @@ export class ApiClient {
this.generated = createApiClient(buildApiFetcher(this.config), this.baseUrl)
}
private buildHeaders() {
private buildHeaders(): Record<string, string> {
return {
Authorization: `Bearer ${this.config.apiToken}`,
'Content-Type': 'application/json',
}
}
getProjectBaseUrl(projectId: string) {
getProjectBaseUrl(projectId: string): string {
if (projectId === '@current') {
return this.baseUrl
}
@@ -108,11 +114,7 @@ export class ApiClient {
return `${this.baseUrl}/project/${projectId}`
}
private async fetchWithSchema<T>(
url: string,
schema: z.ZodType<T>,
options?: RequestInit
): Promise<Result<T>> {
private async fetchWithSchema<T>(url: string, schema: z.ZodType<T>, options?: RequestInit): Promise<Result<T>> {
try {
const response = await fetch(url, {
...options,
@@ -158,17 +160,14 @@ export class ApiClient {
}
}
organizations() {
organizations(): Endpoint {
return {
list: async (): Promise<Result<Organization[]>> => {
const responseSchema = z.object({
results: z.array(OrganizationSchema),
})
const result = await this.fetchWithSchema(
`${this.baseUrl}/api/organizations/`,
responseSchema
)
const result = await this.fetchWithSchema(`${this.baseUrl}/api/organizations/`, responseSchema)
if (result.success) {
return { success: true, data: result.data.results }
@@ -177,10 +176,7 @@ export class ApiClient {
},
get: async ({ orgId }: { orgId: string }): Promise<Result<Organization>> => {
return this.fetchWithSchema(
`${this.baseUrl}/api/organizations/${orgId}/`,
OrganizationSchema
)
return this.fetchWithSchema(`${this.baseUrl}/api/organizations/${orgId}/`, OrganizationSchema)
},
projects: ({ orgId }: { orgId: string }) => {
@@ -205,7 +201,7 @@ export class ApiClient {
}
}
apiKeys() {
apiKeys(): Endpoint {
return {
current: async (): Promise<Result<ApiRedactedPersonalApiKey>> => {
return this.fetchWithSchema(
@@ -216,13 +212,10 @@ export class ApiClient {
}
}
projects() {
projects(): Endpoint {
return {
get: async ({ projectId }: { projectId: string }): Promise<Result<Project>> => {
return this.fetchWithSchema(
`${this.baseUrl}/api/projects/${projectId}/`,
ProjectSchema
)
return this.fetchWithSchema(`${this.baseUrl}/api/projects/${projectId}/`, ProjectSchema)
},
propertyDefinitions: async ({
@@ -268,9 +261,7 @@ export class ApiClient {
ApiPropertyDefinitionSchema
)
const propertyDefinitionsWithoutHidden = propertyDefinitions.filter(
(def) => !def.hidden
)
const propertyDefinitionsWithoutHidden = propertyDefinitions.filter((def) => !def.hidden)
return { success: true, data: propertyDefinitionsWithoutHidden }
} catch (error) {
@@ -304,7 +295,7 @@ export class ApiClient {
}
}
experiments({ projectId }: { projectId: string }) {
experiments({ projectId }: { projectId: string }): Endpoint {
return {
list: async (): Promise<Result<Experiment[]>> => {
try {
@@ -320,11 +311,7 @@ export class ApiClient {
}
},
get: async ({
experimentId,
}: {
experimentId: number
}): Promise<Result<Experiment>> => {
get: async ({ experimentId }: { experimentId: number }): Promise<Result<Experiment>> => {
return this.fetchWithSchema(
`${this.baseUrl}/api/projects/${projectId}/experiments/${experimentId}/`,
ExperimentSchema
@@ -474,10 +461,7 @@ export class ApiClient {
const sharedSecondaryMetrics = (experiment.saved_metrics || [])
.filter(({ metadata }) => metadata.type === 'secondary')
.map(({ query }) => query)
const allSecondaryMetrics = [
...(experiment.metrics_secondary || []),
...sharedSecondaryMetrics,
]
const allSecondaryMetrics = [...(experiment.metrics_secondary || []), ...sharedSecondaryMetrics]
// Execute queries for primary metrics
const primaryResults = await Promise.all(
@@ -504,7 +488,7 @@ export class ApiClient {
)
return result.success ? result.data : null
} catch (error) {
} catch {
return null
}
})
@@ -535,7 +519,7 @@ export class ApiClient {
)
return result.success ? result.data : null
} catch (error) {
} catch {
return null
}
})
@@ -552,9 +536,7 @@ export class ApiClient {
}
},
create: async (
experimentData: z.infer<typeof ExperimentCreateSchema>
): Promise<Result<Experiment>> => {
create: async (experimentData: z.infer<typeof ExperimentCreateSchema>): Promise<Result<Experiment>> => {
// Transform agent input to API payload
const createBody = ExperimentCreatePayloadSchema.parse(experimentData)
@@ -624,11 +606,9 @@ export class ApiClient {
}
}
featureFlags({ projectId }: { projectId: string }) {
featureFlags({ projectId }: { projectId: string }): Endpoint {
return {
list: async (): Promise<
Result<Array<{ id: number; key: string; name: string; active: boolean }>>
> => {
list: async (): Promise<Result<Array<{ id: number; key: string; name: string; active: boolean }>>> => {
try {
const schema = FeatureFlagSchema.pick({
id: true,
@@ -680,9 +660,7 @@ export class ApiClient {
key,
}: {
key: string
}): Promise<
Result<{ id: number; key: string; name: string; active: boolean } | undefined>
> => {
}): Promise<Result<{ id: number; key: string; name: string; active: boolean } | undefined>> => {
const listResult = await this.featureFlags({ projectId }).list()
if (!listResult.success) {
@@ -768,20 +746,13 @@ export class ApiClient {
)
},
delete: async ({
flagId,
}: {
flagId: number
}): Promise<Result<{ success: boolean; message: string }>> => {
delete: async ({ flagId }: { flagId: number }): Promise<Result<{ success: boolean; message: string }>> => {
try {
const response = await fetch(
`${this.baseUrl}/api/projects/${projectId}/feature_flags/${flagId}/`,
{
method: 'PATCH',
headers: this.buildHeaders(),
body: JSON.stringify({ deleted: true }),
}
)
const response = await fetch(`${this.baseUrl}/api/projects/${projectId}/feature_flags/${flagId}/`, {
method: 'PATCH',
headers: this.buildHeaders(),
body: JSON.stringify({ deleted: true }),
})
if (!response.ok) {
throw new Error(`Failed to delete feature flag: ${response.statusText}`)
@@ -801,26 +772,21 @@ export class ApiClient {
}
}
insights({ projectId }: { projectId: string }) {
insights({ projectId }: { projectId: string }): Endpoint {
return {
list: async ({ params }: { params?: ListInsightsData } = {}): Promise<
Result<Array<Schemas.Insight>>
> => {
list: async ({ params }: { params?: ListInsightsData } = {}): Promise<Result<Array<Schemas.Insight>>> => {
try {
const response = await this.generated.get(
'/api/projects/{project_id}/insights/',
{
path: { project_id: projectId },
query: params
? {
limit: params.limit,
offset: params.offset,
//@ts-expect-error search is not implemented as a query parameter
search: params.search,
}
: {},
}
)
const response = await this.generated.get('/api/projects/{project_id}/insights/', {
path: { project_id: projectId },
query: params
? {
limit: params.limit,
offset: params.offset,
//@ts-expect-error search is not implemented as a query parameter
search: params.search,
}
: {},
})
return { success: true, data: response.results }
} catch (error) {
@@ -828,11 +794,7 @@ export class ApiClient {
}
},
create: async ({
data,
}: {
data: CreateInsightInput
}): Promise<Result<SimpleInsight>> => {
create: async ({ data }: { data: CreateInsightInput }): Promise<Result<SimpleInsight>> => {
const validatedInput = CreateInsightInputSchema.parse(data)
return this.fetchWithSchema(
@@ -881,13 +843,7 @@ export class ApiClient {
)
},
update: async ({
insightId,
data,
}: {
insightId: number
data: any
}): Promise<Result<SimpleInsight>> => {
update: async ({ insightId, data }: { insightId: number; data: any }): Promise<Result<SimpleInsight>> => {
return this.fetchWithSchema(
`${this.baseUrl}/api/projects/${projectId}/insights/${insightId}/`,
SimpleInsightSchema,
@@ -904,14 +860,11 @@ export class ApiClient {
insightId: number
}): Promise<Result<{ success: boolean; message: string }>> => {
try {
const response = await fetch(
`${this.baseUrl}/api/projects/${projectId}/insights/${insightId}/`,
{
method: 'PATCH',
headers: this.buildHeaders(),
body: JSON.stringify({ deleted: true }),
}
)
const response = await fetch(`${this.baseUrl}/api/projects/${projectId}/insights/${insightId}/`, {
method: 'PATCH',
headers: this.buildHeaders(),
body: JSON.stringify({ deleted: true }),
})
if (!response.ok) {
throw new Error(`Failed to delete insight: ${response.statusText}`)
@@ -976,7 +929,7 @@ export class ApiClient {
}
}
dashboards({ projectId }: { projectId: string }) {
dashboards({ projectId }: { projectId: string }): Endpoint {
return {
list: async ({ params }: { params?: ListDashboardsData } = {}): Promise<
Result<
@@ -1021,22 +974,14 @@ export class ApiClient {
return result
},
get: async ({
dashboardId,
}: {
dashboardId: number
}): Promise<Result<SimpleDashboard>> => {
get: async ({ dashboardId }: { dashboardId: number }): Promise<Result<SimpleDashboard>> => {
return this.fetchWithSchema(
`${this.baseUrl}/api/projects/${projectId}/dashboards/${dashboardId}/`,
SimpleDashboardSchema
)
},
create: async ({
data,
}: {
data: CreateDashboardInput
}): Promise<Result<{ id: number; name: string }>> => {
create: async ({ data }: { data: CreateDashboardInput }): Promise<Result<{ id: number; name: string }>> => {
const validatedInput = CreateDashboardInputSchema.parse(data)
const createResponseSchema = z.object({
@@ -1124,36 +1069,25 @@ export class ApiClient {
}
}
query({ projectId }: { projectId: string }) {
query({ projectId }: { projectId: string }): Endpoint {
return {
execute: async ({
queryBody,
}: {
queryBody: any
}): Promise<Result<{ results: any[] }>> => {
execute: async ({ queryBody }: { queryBody: any }): Promise<Result<{ results: any[] }>> => {
const responseSchema = z.object({
results: z.array(z.any()),
})
return this.fetchWithSchema(
`${this.baseUrl}/api/environments/${projectId}/query/`,
responseSchema,
{
method: 'POST',
body: JSON.stringify({ query: queryBody }),
}
)
return this.fetchWithSchema(`${this.baseUrl}/api/environments/${projectId}/query/`, responseSchema, {
method: 'POST',
body: JSON.stringify({ query: queryBody }),
})
},
}
}
users() {
users(): Endpoint {
return {
me: async (): Promise<Result<ApiUser>> => {
const result = await this.fetchWithSchema(
`${this.baseUrl}/api/users/@me/`,
ApiUserSchema
)
const result = await this.fetchWithSchema(`${this.baseUrl}/api/users/@me/`, ApiUserSchema)
if (!result.success) {
return result
@@ -1167,7 +1101,7 @@ export class ApiClient {
}
}
surveys({ projectId }: { projectId: string }) {
surveys({ projectId }: { projectId: string }): Endpoint {
return {
list: async ({ params }: { params?: ListSurveysInput } = {}): Promise<
Result<Array<SurveyListItemOutput>>
@@ -1207,21 +1141,13 @@ export class ApiClient {
)
},
create: async ({
data,
}: {
data: CreateSurveyInput
}): Promise<Result<SurveyOutput>> => {
create: async ({ data }: { data: CreateSurveyInput }): Promise<Result<SurveyOutput>> => {
const validatedInput = CreateSurveyInputSchema.parse(data)
return this.fetchWithSchema(
`${this.baseUrl}/api/projects/${projectId}/surveys/`,
SurveyOutputSchema,
{
method: 'POST',
body: JSON.stringify(validatedInput),
}
)
return this.fetchWithSchema(`${this.baseUrl}/api/projects/${projectId}/surveys/`, SurveyOutputSchema, {
method: 'POST',
body: JSON.stringify(validatedInput),
})
},
update: async ({
@@ -1266,9 +1192,7 @@ export class ApiClient {
)
if (!response.ok) {
throw new Error(
`Failed to ${softDelete ? 'archive' : 'delete'} survey: ${response.statusText}`
)
throw new Error(`Failed to ${softDelete ? 'archive' : 'delete'} survey: ${response.statusText}`)
}
return {
@@ -1295,9 +1219,7 @@ export class ApiClient {
return this.fetchWithSchema(url, SurveyResponseStatsOutputSchema)
},
stats: async (
params: GetSurveySpecificStatsInput
): Promise<Result<SurveyResponseStatsOutput>> => {
stats: async (params: GetSurveySpecificStatsInput): Promise<Result<SurveyResponseStatsOutput>> => {
const validatedParams = GetSurveySpecificStatsInputSchema.parse(params)
const searchParams = getSearchParamsFromRecord(validatedParams)

View File

@@ -1,9 +1,7 @@
import type { ApiConfig } from './client'
import type { createApiClient } from './generated'
export const buildApiFetcher: (config: ApiConfig) => Parameters<typeof createApiClient>[0] = (
config
) => {
export const buildApiFetcher: (config: ApiConfig) => Parameters<typeof createApiClient>[0] = (config) => {
return {
fetch: async (input) => {
const headers = new Headers()
@@ -41,9 +39,7 @@ export const buildApiFetcher: (config: ApiConfig) => Parameters<typeof createApi
if (!response.ok) {
const errorResponse = await response.json()
throw new Error(
`Failed request: [${response.status}] ${JSON.stringify(errorResponse)}`
)
throw new Error(`Failed request: [${response.status}] ${JSON.stringify(errorResponse)}`)
}
return response

View File

@@ -31,12 +31,7 @@ export async function docsSearch(apiKey: string, userQuery: string): Promise<str
const data = (await response.json()) as InkeepResponse
if (
data.choices &&
data.choices.length > 0 &&
data.choices[0]?.message &&
data.choices[0].message.content
) {
if (data.choices && data.choices.length > 0 && data.choices[0]?.message && data.choices[0].message.content) {
return data.choices[0].message.content
}
console.error('Inkeep API response format unexpected:', data)

View File

@@ -1,3 +1,6 @@
import { type Tool as VercelTool, tool } from 'ai'
import type { z } from 'zod'
import { ApiClient } from '@/api/client'
import { SessionManager } from '@/lib/utils/SessionManager'
import { StateManager } from '@/lib/utils/StateManager'
@@ -5,8 +8,6 @@ import { MemoryCache } from '@/lib/utils/cache/MemoryCache'
import { hash } from '@/lib/utils/helper-functions'
import { getToolsFromContext } from '@/tools'
import type { Context } from '@/tools/types'
import { type Tool as VercelTool, tool } from 'ai'
import type { z } from 'zod'
/**
* Options for the PostHog Agent Toolkit

View File

@@ -1,3 +1,6 @@
import { DynamicStructuredTool } from '@langchain/core/tools'
import type { z } from 'zod'
import { ApiClient } from '@/api/client'
import { SessionManager } from '@/lib/utils/SessionManager'
import { StateManager } from '@/lib/utils/StateManager'
@@ -5,8 +8,6 @@ import { MemoryCache } from '@/lib/utils/cache/MemoryCache'
import { hash } from '@/lib/utils/helper-functions'
import { getToolsFromContext } from '@/tools'
import type { Context } from '@/tools/types'
import { DynamicStructuredTool } from '@langchain/core/tools'
import type { z } from 'zod'
/**
* Options for the PostHog Agent Toolkit

View File

@@ -51,26 +51,23 @@ export class MyMCP extends McpAgent<Env> {
_sessionManager: SessionManager | undefined
get requestProperties() {
get requestProperties(): RequestProperties {
return this.props as RequestProperties
}
get cache() {
get cache(): DurableObjectCache<State> {
if (!this.requestProperties.userHash) {
throw new Error('User hash is required to use the cache')
}
if (!this._cache) {
this._cache = new DurableObjectCache<State>(
this.requestProperties.userHash,
this.ctx.storage
)
this._cache = new DurableObjectCache<State>(this.requestProperties.userHash, this.ctx.storage)
}
return this._cache
}
get sessionManager() {
get sessionManager(): SessionManager {
if (!this._sessionManager) {
this._sessionManager = new SessionManager(this.cache)
}
@@ -89,10 +86,7 @@ export class MyMCP extends McpAgent<Env> {
baseUrl: 'https://eu.posthog.com',
})
const [usResult, euResult] = await Promise.all([
usClient.users().me(),
euClient.users().me(),
])
const [usResult, euResult] = await Promise.all([usClient.users().me(), euClient.users().me()])
if (usResult.success) {
await this.cache.set('region', 'us')
@@ -107,7 +101,7 @@ export class MyMCP extends McpAgent<Env> {
return undefined
}
async getBaseUrl() {
async getBaseUrl(): Promise<string> {
if (CUSTOM_BASE_URL) {
return CUSTOM_BASE_URL
}
@@ -121,7 +115,7 @@ export class MyMCP extends McpAgent<Env> {
return 'https://us.posthog.com'
}
async api() {
async api(): Promise<ApiClient> {
if (!this._api) {
const baseUrl = await this.getBaseUrl()
this._api = new ApiClient({
@@ -133,7 +127,7 @@ export class MyMCP extends McpAgent<Env> {
return this._api
}
async getDistinctId() {
async getDistinctId(): Promise<string> {
let _distinctId = await this.cache.get('distinctId')
if (!_distinctId) {
@@ -148,7 +142,7 @@ export class MyMCP extends McpAgent<Env> {
return _distinctId
}
async trackEvent(event: AnalyticsEvent, properties: Record<string, any> = {}) {
async trackEvent(event: AnalyticsEvent, properties: Record<string, any> = {}): Promise<void> {
try {
const distinctId = await this.getDistinctId()
@@ -160,16 +154,14 @@ export class MyMCP extends McpAgent<Env> {
properties: {
...(this.requestProperties.sessionId
? {
$session_id: await this.sessionManager.getSessionUuid(
this.requestProperties.sessionId
),
$session_id: await this.sessionManager.getSessionUuid(this.requestProperties.sessionId),
}
: {}),
...properties,
},
})
} catch (error) {
//
} catch {
// skip
}
}
@@ -177,7 +169,7 @@ export class MyMCP extends McpAgent<Env> {
tool: Tool<z.ZodObject<TSchema>>,
handler: (params: z.infer<z.ZodObject<TSchema>>) => Promise<any>
): void {
const wrappedHandler = async (params: z.infer<z.ZodObject<TSchema>>) => {
const wrappedHandler = async (params: z.infer<z.ZodObject<TSchema>>): Promise<any> => {
const validation = tool.schema.safeParse(params)
if (!validation.success) {
@@ -245,7 +237,7 @@ export class MyMCP extends McpAgent<Env> {
}
}
async init() {
async init(): Promise<void> {
const context = await this.getContext()
// Register prompts and resources

View File

@@ -2,7 +2,7 @@ import { PostHog } from 'posthog-node'
let _client: PostHog | undefined
export const getPostHogClient = () => {
export const getPostHogClient = (): PostHog => {
if (!_client) {
_client = new PostHog('sTMFPsFhdP1Ssg', {
host: 'https://us.i.posthog.com',

View File

@@ -1,6 +1,7 @@
import { getPostHogClient } from '@/integrations/mcp/utils/client'
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
import { getPostHogClient } from '@/integrations/mcp/utils/client'
export class MCPToolError extends Error {
public readonly tool: string
public readonly originalError: unknown
@@ -13,22 +14,6 @@ export class MCPToolError extends Error {
this.originalError = originalError
this.timestamp = new Date()
}
getTrackingData() {
return {
tool: this.tool,
message: this.message,
timestamp: this.timestamp.toISOString(),
originalError:
this.originalError instanceof Error
? {
name: this.originalError.name,
message: this.originalError.message,
stack: this.originalError.stack,
}
: String(this.originalError),
}
}
}
/**
@@ -45,20 +30,11 @@ export class MCPToolError extends Error {
*
* @returns A structured error message.
*/
export function handleToolError(
error: any,
tool?: string,
distinctId?: string,
sessionUuid?: string
): CallToolResult {
export function handleToolError(error: any, tool?: string, distinctId?: string, sessionUuid?: string): CallToolResult {
const mcpError =
error instanceof MCPToolError
? error
: new MCPToolError(
error instanceof Error ? error.message : String(error),
tool || 'unknown',
error
)
: new MCPToolError(error instanceof Error ? error.message : String(error), tool || 'unknown', error)
const properties: Record<string, any> = {
team: 'growth',

View File

@@ -1,7 +1,8 @@
import { v7 as uuidv7 } from 'uuid'
import type { PrefixedString } from '@/lib/types'
import type { ScopedCache } from '@/lib/utils/cache/ScopedCache'
import type { State } from '@/tools'
import { v7 as uuidv7 } from 'uuid'
export class SessionManager {
private cache: ScopedCache<State>

View File

@@ -1,6 +1,7 @@
import type { ApiClient } from '@/api/client'
import type { ApiUser } from '@/schema/api'
import type { State } from '@/tools/types'
import type { ScopedCache } from './cache/ScopedCache'
export class StateManager {
@@ -13,7 +14,7 @@ export class StateManager {
this._api = api
}
private async _fetchUser() {
private async _fetchUser(): Promise<ApiUser> {
const userResult = await this._api.users().me()
if (!userResult.success) {
throw new Error(`Failed to get user: ${userResult.error.message}`)
@@ -21,7 +22,7 @@ export class StateManager {
return userResult.data
}
async getUser() {
async getUser(): Promise<ApiUser> {
if (!this._user) {
this._user = await this._fetchUser()
}
@@ -29,7 +30,7 @@ export class StateManager {
return this._user
}
private async _fetchApiKey() {
private async _fetchApiKey(): Promise<NonNullable<State['apiKey']>> {
const apiKeyResult = await this._api.apiKeys().current()
if (!apiKeyResult.success) {
throw new Error(`Failed to get API key: ${apiKeyResult.error.message}`)
@@ -37,7 +38,7 @@ export class StateManager {
return apiKeyResult.data
}
async getApiKey() {
async getApiKey(): Promise<NonNullable<State['apiKey']>> {
let _apiKey = await this._cache.get('apiKey')
if (!_apiKey) {
@@ -48,7 +49,7 @@ export class StateManager {
return _apiKey
}
async getDistinctId() {
async getDistinctId(): Promise<NonNullable<State['distinctId']>> {
let _distinctId = await this._cache.get('distinctId')
if (!_distinctId) {
@@ -81,19 +82,13 @@ export class StateManager {
return { projectId }
}
if (
scoped_organizations.length === 0 ||
scoped_organizations.includes(activeOrganization.id)
) {
if (scoped_organizations.length === 0 || scoped_organizations.includes(activeOrganization.id)) {
return { organizationId: activeOrganization.id, projectId: activeTeam.id }
}
const organizationId = scoped_organizations[0]!
const projectsResult = await this._api
.organizations()
.projects({ orgId: organizationId })
.list()
const projectsResult = await this._api.organizations().projects({ orgId: organizationId }).list()
if (!projectsResult.success) {
throw projectsResult.error
@@ -108,7 +103,10 @@ export class StateManager {
return { organizationId, projectId: Number(projectId) }
}
async setDefaultOrganizationAndProject() {
async setDefaultOrganizationAndProject(): Promise<{
organizationId: string | undefined
projectId: number
}> {
const { organizationId, projectId } = await this._getDefaultOrganizationAndProject()
if (organizationId) {

View File

@@ -1,12 +1,8 @@
import { ApiListResponseSchema } from '@/schema/api'
import type { z } from 'zod'
export const withPagination = async <T>(
url: string,
apiToken: string,
dataSchema: z.ZodType<T>
): Promise<T[]> => {
import { ApiListResponseSchema } from '@/schema/api'
export const withPagination = async <T>(url: string, apiToken: string, dataSchema: z.ZodType<T>): Promise<T[]> => {
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${apiToken}`,
@@ -33,22 +29,19 @@ export const withPagination = async <T>(
return results
}
export const hasScope = (scopes: string[], requiredScope: string) => {
export const hasScope = (scopes: string[], requiredScope: string): boolean => {
if (scopes.includes('*')) {
return true
}
// if read scoped required, and write present, return true
if (
requiredScope.endsWith(':read') &&
scopes.includes(requiredScope.replace(':read', ':write'))
) {
if (requiredScope.endsWith(':read') && scopes.includes(requiredScope.replace(':read', ':write'))) {
return true
}
return scopes.includes(requiredScope)
}
export const hasScopes = (scopes: string[], requiredScopes: string[]) => {
export const hasScopes = (scopes: string[], requiredScopes: string[]): boolean => {
return requiredScopes.every((scope) => hasScope(scopes, scope))
}

View File

@@ -1,6 +1,6 @@
import crypto from 'node:crypto'
export function hash(data: string) {
export function hash(data: string): string {
// Use PBKDF2 with sufficient computational effort for security
// 100,000 iterations provides good security while maintaining reasonable performance
const salt = crypto.createHash('sha256').update('posthog_mcp_salt').digest()

View File

@@ -2,6 +2,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { z } from 'zod'
import type { Context } from '@/tools/types'
import { setupEventsPrompt } from './setup-events'
export interface Prompt {
@@ -27,7 +28,7 @@ export async function getPromptsFromContext(context: Context): Promise<Prompt[]>
return [await setupEventsPrompt(context)]
}
export async function registerPrompts(server: McpServer, context: Context) {
export async function registerPrompts(server: McpServer, context: Context): Promise<void> {
const prompts = await getPromptsFromContext(context)
for (const prompt of prompts) {

View File

@@ -1,14 +1,16 @@
import type { Context } from '@/tools/types'
import type { Prompt } from './index'
import { ResourceUri } from '@/resources'
import type { Context } from '@/tools/types'
import type { Prompt } from './index'
// oxlint-disable-next-line @typescript-eslint/no-unused-vars
export async function setupEventsPrompt(_context: Context): Promise<Prompt> {
return {
name: 'posthog-setup',
title: 'Setup a deep PostHog integration',
description:
'Automatically instrument your Next.js project with PostHog event tracking, user identification, and error tracking',
handler: async (_context, _args) => {
handler: async () => {
return {
messages: [
{

View File

@@ -1,10 +1,12 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { Context } from '@/tools/types'
import { registerIntegrationResources } from './integration'
// Re-export ResourceUri for external consumers
export { ResourceUri } from './integration'
export function registerResources(server: McpServer, context: Context) {
export function registerResources(server: McpServer, context: Context): void {
registerIntegrationResources(server, context)
}

View File

@@ -1,15 +1,16 @@
import { type McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'
import { type Unzipped, strFromU8, unzipSync } from 'fflate'
import type { Context } from '@/tools/types'
import {
FRAMEWORK_DOCS,
EXAMPLES_MARKDOWN_URL,
FRAMEWORK_DOCS,
FRAMEWORK_MARKDOWN_FILES,
isSupportedFramework,
getSupportedFrameworks,
getSupportedFrameworksList,
isSupportedFramework,
} from './framework-mappings'
import { unzipSync, strFromU8, type Unzipped } from 'fflate'
// Import workflow markdown files
import workflowBegin from './workflow-guides/1.0-event-setup-begin.md'
import workflowEdit from './workflow-guides/1.1-event-setup-edit.md'
@@ -35,8 +36,7 @@ export enum ResourceUri {
/**
* Message appended to workflow resources to indicate the next step
*/
export const WORKFLOW_NEXT_STEP_MESSAGE =
'Upon completion, access the following resource to continue:'
export const WORKFLOW_NEXT_STEP_MESSAGE = 'Upon completion, access the following resource to continue:'
/**
* URL to the identify() documentation
@@ -67,7 +67,6 @@ async function fetchExamplesMarkdown(): Promise<Unzipped> {
return cachedExamplesMarkdown
}
console.log('Fetching PostHog examples markdown...')
const response = await fetch(EXAMPLES_MARKDOWN_URL)
if (!response.ok) {
@@ -77,7 +76,6 @@ async function fetchExamplesMarkdown(): Promise<Unzipped> {
const arrayBuffer = await response.arrayBuffer()
const uint8Array = new Uint8Array(arrayBuffer)
cachedExamplesMarkdown = unzipSync(uint8Array)
console.log('Examples markdown cached successfully')
return cachedExamplesMarkdown
}
@@ -107,7 +105,8 @@ const workflowSequence = [
/**
* Registers all PostHog integration resources with the MCP server
*/
export function registerIntegrationResources(server: McpServer, _context: Context) {
// oxlint-disable-next-line @typescript-eslint/no-unused-vars
export function registerIntegrationResources(server: McpServer, _context: Context): void {
// Fetch examples markdown at startup
fetchExamplesMarkdown().catch((error) => {
console.error('Failed to fetch examples markdown:', error)
@@ -216,10 +215,7 @@ export function registerIntegrationResources(server: McpServer, _context: Contex
const frameworks = getSupportedFrameworks()
return {
resources: frameworks.map((framework) => ({
uri: ResourceUri.EXAMPLE_PROJECT_FRAMEWORK.replace(
'{framework}',
framework
),
uri: ResourceUri.EXAMPLE_PROJECT_FRAMEWORK.replace('{framework}', framework),
name: `PostHog ${framework} example project`,
description: `Example project code for ${framework}`,
mimeType: 'text/markdown',

View File

@@ -35,6 +35,7 @@ export const ApiEventDefinitionSchema = z.object({
tags: z.array(z.string().nullish()).nullish(),
})
// oxlint-disable-next-line @typescript-eslint/explicit-function-return-type
export const ApiListResponseSchema = <T extends z.ZodType>(dataSchema: T) =>
z.object({
count: z.number().nullish(),

View File

@@ -1,5 +1,6 @@
import { v4 as uuidv4 } from 'uuid'
import { z } from 'zod'
import { FeatureFlagSchema } from './flags'
import {
ExperimentCreateSchema as ToolExperimentCreateSchema,
@@ -237,7 +238,7 @@ export type ExperimentUpdateApiPayload = z.infer<typeof ExperimentUpdateApiPaylo
/**
* Helper to conditionally add properties only if they exist and are not empty
*/
const getPropertiesIfNotEmpty = (props: any) => {
const getPropertiesIfNotEmpty = <T>(props: T): { properties?: T } => {
return props && Object.keys(props).length > 0 ? { properties: props } : {}
}
@@ -365,67 +366,61 @@ export type ExperimentCreatePayload = z.output<typeof ExperimentCreatePayloadSch
* Transform user-friendly update input to API payload format for experiment updates
* This handles partial updates with the same transformation patterns as creation
*/
export const ExperimentUpdateTransformSchema = ToolExperimentUpdateInputSchema.transform(
(input) => {
const updatePayload: Record<string, any> = {}
export const ExperimentUpdateTransformSchema = ToolExperimentUpdateInputSchema.transform((input) => {
const updatePayload: Record<string, any> = {}
// Basic fields - direct mapping
if (input.name !== undefined) {
updatePayload.name = input.name
}
if (input.description !== undefined) {
updatePayload.description = input.description
}
// Transform metrics if provided
if (input.primary_metrics !== undefined) {
updatePayload.metrics = input.primary_metrics.map(transformMetricToApi)
updatePayload.primary_metrics_ordered_uuids = updatePayload.metrics.map(
(m: any) => m.uuid!
)
}
if (input.secondary_metrics !== undefined) {
updatePayload.metrics_secondary = input.secondary_metrics.map(transformMetricToApi)
updatePayload.secondary_metrics_ordered_uuids = updatePayload.metrics_secondary.map(
(m: any) => m.uuid!
)
}
// Transform minimum detectable effect into parameters
if (input.minimum_detectable_effect !== undefined) {
updatePayload.parameters = {
...updatePayload.parameters,
minimum_detectable_effect: input.minimum_detectable_effect,
}
}
// Handle experiment state management
if (input.launch === true) {
updatePayload.start_date = new Date().toISOString()
}
if (input.conclude !== undefined) {
updatePayload.conclusion = input.conclude
updatePayload.end_date = new Date().toISOString()
if (input.conclusion_comment !== undefined) {
updatePayload.conclusion_comment = input.conclusion_comment
}
}
if (input.restart === true) {
updatePayload.end_date = null
updatePayload.conclusion = null
updatePayload.conclusion_comment = null
}
if (input.archive !== undefined) {
updatePayload.archived = input.archive
}
return updatePayload
// Basic fields - direct mapping
if (input.name !== undefined) {
updatePayload.name = input.name
}
).pipe(ExperimentUpdateApiPayloadSchema)
if (input.description !== undefined) {
updatePayload.description = input.description
}
// Transform metrics if provided
if (input.primary_metrics !== undefined) {
updatePayload.metrics = input.primary_metrics.map(transformMetricToApi)
updatePayload.primary_metrics_ordered_uuids = updatePayload.metrics.map((m: any) => m.uuid!)
}
if (input.secondary_metrics !== undefined) {
updatePayload.metrics_secondary = input.secondary_metrics.map(transformMetricToApi)
updatePayload.secondary_metrics_ordered_uuids = updatePayload.metrics_secondary.map((m: any) => m.uuid!)
}
// Transform minimum detectable effect into parameters
if (input.minimum_detectable_effect !== undefined) {
updatePayload.parameters = {
...updatePayload.parameters,
minimum_detectable_effect: input.minimum_detectable_effect,
}
}
// Handle experiment state management
if (input.launch === true) {
updatePayload.start_date = new Date().toISOString()
}
if (input.conclude !== undefined) {
updatePayload.conclusion = input.conclude
updatePayload.end_date = new Date().toISOString()
if (input.conclusion_comment !== undefined) {
updatePayload.conclusion_comment = input.conclusion_comment
}
}
if (input.restart === true) {
updatePayload.end_date = null
updatePayload.conclusion = null
updatePayload.conclusion_comment = null
}
if (input.archive !== undefined) {
updatePayload.archived = input.archive
}
return updatePayload
}).pipe(ExperimentUpdateApiPayloadSchema)
export type ExperimentUpdateTransform = z.output<typeof ExperimentUpdateTransformSchema>

View File

@@ -10,36 +10,21 @@ export interface PostHogFlagsResponse {
results?: PostHogFeatureFlag[]
}
const base = ['exact', 'is_not', 'is_set', 'is_not_set'] as const
const stringOps = [
...base,
'icontains',
'not_icontains',
'regex',
'not_regex',
'is_cleaned_path_exact',
] as const
const stringOps = [...base, 'icontains', 'not_icontains', 'regex', 'not_regex', 'is_cleaned_path_exact'] as const
const numberOps = [...base, 'gt', 'gte', 'lt', 'lte', 'min', 'max'] as const
const booleanOps = [...base] as const
const arrayOps = ['in', 'not_in'] as const
const operatorSchema = z.enum([
...stringOps,
...numberOps,
...booleanOps,
...arrayOps,
] as unknown as [string, ...string[]])
const operatorSchema = z.enum([...stringOps, ...numberOps, ...booleanOps, ...arrayOps] as unknown as [
string,
...string[],
])
export const PersonPropertyFilterSchema = z
.object({
key: z.string(),
value: z.union([
z.string(),
z.number(),
z.boolean(),
z.array(z.string()),
z.array(z.number()),
]),
value: z.union([z.string(), z.number(), z.boolean(), z.array(z.string()), z.array(z.number())]),
operator: operatorSchema.optional(),
})
.superRefine((data, ctx) => {

View File

@@ -1,10 +1,7 @@
import {
ApiEventDefinitionSchema,
type ApiListResponseSchema,
ApiPropertyDefinitionSchema,
} from '@/schema/api'
import type { z } from 'zod'
import { ApiEventDefinitionSchema, type ApiListResponseSchema, ApiPropertyDefinitionSchema } from '@/schema/api'
export const PropertyDefinitionSchema = ApiPropertyDefinitionSchema.pick({
name: true,
property_type: true,

View File

@@ -198,10 +198,7 @@ const DataVisualizationNodeSchema = z.object({
})
// Any insight query
const InsightQuerySchema = z.discriminatedUnion('kind', [
InsightVizNodeSchema,
DataVisualizationNodeSchema,
])
const InsightQuerySchema = z.discriminatedUnion('kind', [InsightVizNodeSchema, DataVisualizationNodeSchema])
// Export all schemas
export {

View File

@@ -1,4 +1,5 @@
import { z } from 'zod'
import { FilterGroupsSchema } from './flags.js'
// Survey question types
@@ -29,9 +30,7 @@ const ChoiceResponseBranching = z
"Only include keys for responses that should branch to a specific question or 'end'. Omit keys for responses that should proceed to the next question (default behavior)."
),
})
.describe(
'For single choice questions: use choice indices as string keys ("0", "1", "2", etc.)'
)
.describe('For single choice questions: use choice indices as string keys ("0", "1", "2", etc.)')
// NPS sentiment branching - uses sentiment categories
const NPSSentimentBranching = z
@@ -41,9 +40,7 @@ const NPSSentimentBranching = z
.record(
z
.enum(['detractors', 'passives', 'promoters'])
.describe(
'NPS sentiment categories: detractors (0-6), passives (7-8), promoters (9-10)'
),
.describe('NPS sentiment categories: detractors (0-6), passives (7-8), promoters (9-10)'),
z.union([z.number(), z.literal('end')])
)
.describe(
@@ -95,12 +92,7 @@ const ChoiceBranching = z.union([
SpecificQuestionBranching,
])
const NPSBranching = z.union([
NextQuestionBranching,
EndBranching,
NPSSentimentBranching,
SpecificQuestionBranching,
])
const NPSBranching = z.union([NextQuestionBranching, EndBranching, NPSSentimentBranching, SpecificQuestionBranching])
const RatingBranching = z.union([
NextQuestionBranching,
@@ -129,14 +121,8 @@ const RatingQuestion = BaseSurveyQuestionSchema.extend({
.union([z.literal(3), z.literal(5), z.literal(7)])
.optional()
.describe('Rating scale can be one of 3, 5, or 7'),
lowerBoundLabel: z
.string()
.optional()
.describe("Label for the lowest rating (e.g., 'Very Poor')"),
upperBoundLabel: z
.string()
.optional()
.describe("Label for the highest rating (e.g., 'Excellent')"),
lowerBoundLabel: z.string().optional().describe("Label for the lowest rating (e.g., 'Very Poor')"),
upperBoundLabel: z.string().optional().describe("Label for the highest rating (e.g., 'Excellent')"),
branching: RatingBranching.optional(),
}).superRefine((data, ctx) => {
// Validate display-specific scale constraints
@@ -178,14 +164,8 @@ const NPSRatingQuestion = BaseSurveyQuestionSchema.extend({
type: z.literal('rating'),
display: z.literal('number').describe('NPS questions always use numeric scale'),
scale: z.literal(10).describe('NPS questions always use 0-10 scale'),
lowerBoundLabel: z
.string()
.optional()
.describe("Label for 0 rating (typically 'Not at all likely')"),
upperBoundLabel: z
.string()
.optional()
.describe("Label for 10 rating (typically 'Extremely likely')"),
lowerBoundLabel: z.string().optional().describe("Label for 0 rating (typically 'Not at all likely')"),
upperBoundLabel: z.string().optional().describe("Label for 10 rating (typically 'Extremely likely')"),
branching: NPSBranching.optional(),
}).superRefine((data, ctx) => {
// Validate response-based branching for NPS rating questions
@@ -212,13 +192,8 @@ const SingleChoiceQuestion = BaseSurveyQuestionSchema.extend({
.array(z.string().min(1, 'Choice text cannot be empty'))
.min(2, 'Must have at least 2 choices')
.max(20, 'Cannot have more than 20 choices')
.describe(
'Array of choice options. Choice indices (0, 1, 2, etc.) are used for branching logic'
),
shuffleOptions: z
.boolean()
.optional()
.describe('Whether to randomize the order of choices for each respondent'),
.describe('Array of choice options. Choice indices (0, 1, 2, etc.) are used for branching logic'),
shuffleOptions: z.boolean().optional().describe('Whether to randomize the order of choices for each respondent'),
hasOpenChoice: z
.boolean()
.optional()
@@ -269,13 +244,8 @@ const MultipleChoiceQuestion = BaseSurveyQuestionSchema.extend({
.array(z.string().min(1, 'Choice text cannot be empty'))
.min(2, 'Must have at least 2 choices')
.max(20, 'Cannot have more than 20 choices')
.describe(
'Array of choice options. Multiple selections allowed. No branching logic supported.'
),
shuffleOptions: z
.boolean()
.optional()
.describe('Whether to randomize the order of choices for each respondent'),
.describe('Array of choice options. Multiple selections allowed. No branching logic supported.'),
shuffleOptions: z.boolean().optional().describe('Whether to randomize the order of choices for each respondent'),
hasOpenChoice: z
.boolean()
.optional()
@@ -360,10 +330,7 @@ const SurveyConditions = z.object({
.optional(),
deviceTypes: z.array(z.enum(['Desktop', 'Mobile', 'Tablet'])).optional(),
deviceTypesMatchType: MatchTypeEnum.optional(),
linkedFlagVariant: z
.string()
.optional()
.describe('The variant of the feature flag linked to this survey'),
linkedFlagVariant: z.string().optional().describe('The variant of the feature flag linked to this survey'),
})
// Survey appearance customization - input schema
@@ -453,11 +420,7 @@ export const CreateSurveyInputSchema = z.object({
.describe(
'When at least one question is answered, the response is stored (true). The response is stored when all questions are answered (false).'
),
linked_flag_id: z
.number()
.nullable()
.optional()
.describe('The feature flag linked to this survey'),
linked_flag_id: z.number().nullable().optional().describe('The feature flag linked to this survey'),
targeting_flag_filters: FilterGroupsSchema.optional().describe(
"Target specific users based on their properties. Example: {groups: [{properties: [{key: 'email', value: ['@company.com'], operator: 'icontains'}], rollout_percentage: 100}]}"
),
@@ -467,10 +430,7 @@ export const UpdateSurveyInputSchema = z.object({
name: z.string().min(1, 'Survey name cannot be empty').optional(),
description: z.string().optional(),
type: z.enum(['popover', 'api', 'widget', 'external_survey']).optional(),
questions: z
.array(SurveyQuestionInputSchema)
.min(1, 'Survey must have at least one question')
.optional(),
questions: z.array(SurveyQuestionInputSchema).min(1, 'Survey must have at least one question').optional(),
conditions: SurveyConditions.optional(),
appearance: SurveyAppearance.optional(),
schedule: z
@@ -483,16 +443,12 @@ export const UpdateSurveyInputSchema = z.object({
.string()
.datetime()
.optional()
.describe(
'When the survey should start being shown to users. Setting this will launch the survey'
),
.describe('When the survey should start being shown to users. Setting this will launch the survey'),
end_date: z
.string()
.datetime()
.optional()
.describe(
'When the survey stopped being shown to users. Setting this will complete the survey.'
),
.describe('When the survey stopped being shown to users. Setting this will complete the survey.'),
archived: z.boolean().optional(),
responses_limit: z
.number()
@@ -523,15 +479,8 @@ export const UpdateSurveyInputSchema = z.object({
.describe(
'When at least one question is answered, the response is stored (true). The response is stored when all questions are answered (false).'
),
linked_flag_id: z
.number()
.nullable()
.optional()
.describe('The feature flag to link to this survey'),
targeting_flag_id: z
.number()
.optional()
.describe('An existing targeting flag to use for this survey'),
linked_flag_id: z.number().nullable().optional().describe('The feature flag to link to this survey'),
targeting_flag_id: z.number().optional().describe('An existing targeting flag to use for this survey'),
targeting_flag_filters: FilterGroupsSchema.optional().describe(
"Target specific users based on their properties. Example: {groups: [{properties: [{key: 'email', value: ['@company.com'], operator: 'icontains'}], rollout_percentage: 50}]}"
),

View File

@@ -1,4 +1,5 @@
import { z } from 'zod'
import {
AddInsightToDashboardSchema,
CreateDashboardInputSchema,
@@ -84,9 +85,7 @@ export const ExperimentUpdateInputSchema = z.object({
.describe(
"Metric type: 'mean' for average values, 'funnel' for conversion flows, 'ratio' for comparing two metrics"
),
event_name: z
.string()
.describe("PostHog event name (e.g., '$pageview', 'add_to_cart', 'purchase')"),
event_name: z.string().describe("PostHog event name (e.g., '$pageview', 'add_to_cart', 'purchase')"),
funnel_steps: z
.array(z.string())
.optional()
@@ -104,10 +103,7 @@ export const ExperimentUpdateInputSchema = z.object({
name: z.string().optional().describe('Human-readable metric name'),
metric_type: z.enum(['mean', 'funnel', 'ratio']).describe('Metric type'),
event_name: z.string().describe('PostHog event name'),
funnel_steps: z
.array(z.string())
.optional()
.describe('For funnel metrics only: Array of event names'),
funnel_steps: z.array(z.string()).optional().describe('For funnel metrics only: Array of event names'),
properties: z.record(z.any()).optional().describe('Event properties to filter on'),
description: z.string().optional().describe('What this metric measures'),
})
@@ -115,10 +111,7 @@ export const ExperimentUpdateInputSchema = z.object({
.optional()
.describe('Update secondary metrics'),
minimum_detectable_effect: z
.number()
.optional()
.describe('Update minimum detectable effect in percentage'),
minimum_detectable_effect: z.number().optional().describe('Update minimum detectable effect in percentage'),
// Experiment state management
launch: z.boolean().optional().describe('Launch experiment (set start_date) or keep as draft'),
@@ -130,26 +123,18 @@ export const ExperimentUpdateInputSchema = z.object({
conclusion_comment: z.string().optional().describe('Comment about experiment conclusion'),
restart: z
.boolean()
.optional()
.describe('Restart concluded experiment (clears end_date and conclusion)'),
restart: z.boolean().optional().describe('Restart concluded experiment (clears end_date and conclusion)'),
archive: z.boolean().optional().describe('Archive or unarchive experiment'),
})
export const ExperimentUpdateSchema = z.object({
experimentId: z.number().describe('The ID of the experiment to update'),
data: ExperimentUpdateInputSchema.describe(
'The experiment data to update using user-friendly format'
),
data: ExperimentUpdateInputSchema.describe('The experiment data to update using user-friendly format'),
})
export const ExperimentCreateSchema = z.object({
name: z
.string()
.min(1)
.describe('Experiment name - should clearly describe what is being tested'),
name: z.string().min(1).describe('Experiment name - should clearly describe what is being tested'),
description: z
.string()
@@ -167,9 +152,7 @@ export const ExperimentCreateSchema = z.object({
type: z
.enum(['product', 'web'])
.default('product')
.describe(
"Experiment type: 'product' for backend/API changes, 'web' for frontend UI changes"
),
.describe("Experiment type: 'product' for backend/API changes, 'web' for frontend UI changes"),
// Primary metrics with guidance
primary_metrics: z
@@ -196,9 +179,7 @@ export const ExperimentCreateSchema = z.object({
description: z
.string()
.optional()
.describe(
"What this metric measures and why it's important for the experiment"
),
.describe("What this metric measures and why it's important for the experiment"),
})
)
.optional()
@@ -216,9 +197,7 @@ export const ExperimentCreateSchema = z.object({
.describe(
"Metric type: 'mean' for average values, 'funnel' for conversion flows, 'ratio' for comparing two metrics"
),
event_name: z
.string()
.describe("REQUIRED: PostHog event name. Use '$pageview' if unsure."),
event_name: z.string().describe("REQUIRED: PostHog event name. Use '$pageview' if unsure."),
funnel_steps: z
.array(z.string())
.optional()
@@ -236,15 +215,9 @@ export const ExperimentCreateSchema = z.object({
variants: z
.array(
z.object({
key: z
.string()
.describe("Variant key (e.g., 'control', 'variant_a', 'new_design')"),
key: z.string().describe("Variant key (e.g., 'control', 'variant_a', 'new_design')"),
name: z.string().optional().describe('Human-readable variant name'),
rollout_percentage: z
.number()
.min(0)
.max(100)
.describe('Percentage of users to show this variant'),
rollout_percentage: z.number().min(0).max(100).describe('Percentage of users to show this variant'),
})
)
.optional()
@@ -261,10 +234,7 @@ export const ExperimentCreateSchema = z.object({
),
// Exposure and targeting
filter_test_accounts: z
.boolean()
.default(true)
.describe('Whether to filter out internal test accounts'),
filter_test_accounts: z.boolean().default(true).describe('Whether to filter out internal test accounts'),
target_properties: z
.record(z.any())
@@ -275,16 +245,12 @@ export const ExperimentCreateSchema = z.object({
draft: z
.boolean()
.default(true)
.describe(
'Create as draft (true) or launch immediately (false). Recommend draft for review first'
),
.describe('Create as draft (true) or launch immediately (false). Recommend draft for review first'),
holdout_id: z
.number()
.optional()
.describe(
'Holdout group ID if this experiment should exclude users from other experiments'
),
.describe('Holdout group ID if this experiment should exclude users from other experiments'),
})
export const FeatureFlagCreateSchema = z.object({
@@ -360,22 +326,13 @@ export const OrganizationSetActiveSchema = z.object({
export const ProjectGetAllSchema = z.object({})
export const ProjectEventDefinitionsSchema = z.object({
q: z
.string()
.optional()
.describe('Search query to filter event names. Only use if there are lots of events.'),
q: z.string().optional().describe('Search query to filter event names. Only use if there are lots of events.'),
})
export const ProjectPropertyDefinitionsInputSchema = z.object({
type: z.enum(['event', 'person']).describe('Type of properties to get'),
eventName: z
.string()
.describe('Event name to filter properties by, required for event type')
.optional(),
includePredefinedProperties: z
.boolean()
.optional()
.describe('Whether to include predefined properties'),
eventName: z.string().describe('Event name to filter properties by, required for event type').optional(),
includePredefinedProperties: z.boolean().optional().describe('Whether to include predefined properties'),
})
export const ProjectSetActiveSchema = z.object({

View File

@@ -22,12 +22,12 @@ Define your tool's input schema using Zod. Keep inputs **simple and user-friendl
```typescript
export const FeatureFlagCreateSchema = z.object({
name: z.string(),
key: z.string(),
description: z.string(),
filters: FilterGroupsSchema,
active: z.boolean(),
tags: z.array(z.string()).optional(),
name: z.string(),
key: z.string(),
description: z.string(),
filters: FilterGroupsSchema,
active: z.boolean(),
tags: z.array(z.string()).optional(),
})
```
@@ -41,41 +41,42 @@ export const FeatureFlagCreateSchema = z.object({
### 2. Create Tool Handler (`tools/featureFlags/create.ts`)
```typescript
import type { z } from 'zod'
import { FeatureFlagCreateSchema } from '@/schema/tool-inputs'
import type { Context, ToolBase } from '@/tools/types'
import type { z } from 'zod'
const schema = FeatureFlagCreateSchema
type Params = z.infer<typeof schema>
export const createHandler = async (context: Context, params: Params) => {
const { name, key, description, filters, active, tags } = params
const projectId = await context.stateManager.getProjectId()
const { name, key, description, filters, active, tags } = params
const projectId = await context.stateManager.getProjectId()
// Call API client method
const flagResult = await context.api.featureFlags({ projectId }).create({
data: { name, key, description, filters, active, tags },
})
// Call API client method
const flagResult = await context.api.featureFlags({ projectId }).create({
data: { name, key, description, filters, active, tags },
})
if (!flagResult.success) {
throw new Error(`Failed to create feature flag: ${flagResult.error.message}`)
}
if (!flagResult.success) {
throw new Error(`Failed to create feature flag: ${flagResult.error.message}`)
}
// Add context that is useful, like in this case a URL for the LLM to link to.
const featureFlagWithUrl = {
...flagResult.data,
url: `${context.api.getProjectBaseUrl(projectId)}/feature_flags/${flagResult.data.id}`,
}
// Add context that is useful, like in this case a URL for the LLM to link to.
const featureFlagWithUrl = {
...flagResult.data,
url: `${context.api.getProjectBaseUrl(projectId)}/feature_flags/${flagResult.data.id}`,
}
return {
content: [{ type: 'text', text: JSON.stringify(featureFlagWithUrl) }],
}
return {
content: [{ type: 'text', text: JSON.stringify(featureFlagWithUrl) }],
}
}
const tool = (): ToolBase<typeof schema> => ({
name: 'create-feature-flag',
schema,
handler: createHandler,
name: 'create-feature-flag',
schema,
handler: createHandler,
})
export default tool
@@ -95,20 +96,20 @@ Add a clear, actionable description for your tool, assign it to a feature, speci
```json
{
"create-feature-flag": {
"title": "Create Feature Flag",
"description": "Creates a new feature flag in the project. Once you have created a feature flag, you should: Ask the user if they want to add it to their codebase, Use the \"search-docs\" tool to find documentation on how to add feature flags to the codebase (search for the right language / framework), Clarify where it should be added and then add it.",
"category": "Feature flags", // This will be displayed in the docs, but not readable by the MCP client
"feature": "flags",
"summary": "Creates a new feature flag in the project.", // This will be displayed in the docs, but not readable by the MCP client.
"required_scopes": ["feature_flag:write"], // You can find a list of available scopes here: https://github.com/PostHog/posthog/blob/31082f4bcc4c45a0ac830777b8a3048e7752a1bc/frontend/src/lib/scopes.tsx
"annotations": {
"destructiveHint": false, // Does the tool delete or destructively modify data?
"idempotentHint": false, // Can the tool be safely called multiple times with same result?
"openWorldHint": true, // Does the tool interact with external systems or create new resources?
"readOnlyHint": false // Is the tool read-only (doesn't modify any state)?
}
"create-feature-flag": {
"title": "Create Feature Flag",
"description": "Creates a new feature flag in the project. Once you have created a feature flag, you should: Ask the user if they want to add it to their codebase, Use the \"search-docs\" tool to find documentation on how to add feature flags to the codebase (search for the right language / framework), Clarify where it should be added and then add it.",
"category": "Feature flags", // This will be displayed in the docs, but not readable by the MCP client
"feature": "flags",
"summary": "Creates a new feature flag in the project.", // This will be displayed in the docs, but not readable by the MCP client.
"required_scopes": ["feature_flag:write"], // You can find a list of available scopes here: https://github.com/PostHog/posthog/blob/31082f4bcc4c45a0ac830777b8a3048e7752a1bc/frontend/src/lib/scopes.tsx
"annotations": {
"destructiveHint": false, // Does the tool delete or destructively modify data?
"idempotentHint": false, // Can the tool be safely called multiple times with same result?
"openWorldHint": true, // Does the tool interact with external systems or create new resources?
"readOnlyHint": false // Is the tool read-only (doesn't modify any state)?
}
}
}
```
@@ -140,63 +141,64 @@ If you do add a new feature, make sure to update the `README.md` in the root of
Always include integration tests to help us catch if there is a change to the underlying API:
```typescript
import {
cleanupResources,
createTestClient,
createTestContext,
generateUniqueKey,
parseToolResponse,
setActiveProjectAndOrg,
} from '@/shared/test-utils'
import createFeatureFlagTool from '@/tools/featureFlags/create'
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
import {
cleanupResources,
createTestClient,
createTestContext,
generateUniqueKey,
parseToolResponse,
setActiveProjectAndOrg,
} from '@/shared/test-utils'
import createFeatureFlagTool from '@/tools/featureFlags/create'
describe('Feature Flags', () => {
let context: Context
const createdResources: CreatedResources = {
featureFlags: [],
insights: [],
dashboards: [],
}
let context: Context
const createdResources: CreatedResources = {
featureFlags: [],
insights: [],
dashboards: [],
}
beforeAll(async () => {
const client = createTestClient()
context = createTestContext(client)
await setActiveProjectAndOrg(context, TEST_PROJECT_ID!, TEST_ORG_ID!)
beforeAll(async () => {
const client = createTestClient()
context = createTestContext(client)
await setActiveProjectAndOrg(context, TEST_PROJECT_ID!, TEST_ORG_ID!)
})
afterEach(async () => {
await cleanupResources(context.api, TEST_PROJECT_ID!, createdResources)
})
describe('create-feature-flag tool', () => {
const createTool = createFeatureFlagTool()
it('should create a feature flag with minimal required fields', async () => {
const params = {
name: 'Test Feature Flag',
key: generateUniqueKey('test-flag'),
description: 'Integration test flag',
filters: { groups: [] },
active: true,
}
const result = await createTool.handler(context, params)
const flagData = parseToolResponse(result)
expect(flagData.id).toBeTruthy()
expect(flagData.key).toBe(params.key)
expect(flagData.name).toBe(params.name)
expect(flagData.active).toBe(params.active)
expect(flagData.url).toContain('/feature_flags/')
createdResources.featureFlags.push(flagData.id)
})
afterEach(async () => {
await cleanupResources(context.api, TEST_PROJECT_ID!, createdResources)
})
describe('create-feature-flag tool', () => {
const createTool = createFeatureFlagTool()
it('should create a feature flag with minimal required fields', async () => {
const params = {
name: 'Test Feature Flag',
key: generateUniqueKey('test-flag'),
description: 'Integration test flag',
filters: { groups: [] },
active: true,
}
const result = await createTool.handler(context, params)
const flagData = parseToolResponse(result)
expect(flagData.id).toBeDefined()
expect(flagData.key).toBe(params.key)
expect(flagData.name).toBe(params.name)
expect(flagData.active).toBe(params.active)
expect(flagData.url).toContain('/feature_flags/')
createdResources.featureFlags.push(flagData.id)
})
it('should create a feature flag with complex filters', async () => {
// Test with more complex scenarios
})
it('should create a feature flag with complex filters', async () => {
// Test with more complex scenarios
})
})
})
```

View File

@@ -1,21 +1,20 @@
import type { z } from 'zod'
import { DashboardAddInsightSchema } from '@/schema/tool-inputs'
import { resolveInsightId } from '@/tools/insights/utils'
import type { Context, ToolBase } from '@/tools/types'
import type { z } from 'zod'
const schema = DashboardAddInsightSchema
type Params = z.infer<typeof schema>
export const addInsightHandler = async (context: Context, params: Params) => {
export const addInsightHandler: ToolBase<typeof schema>['handler'] = async (context: Context, params: Params) => {
const { data } = params
const projectId = await context.stateManager.getProjectId()
const numericInsightId = await resolveInsightId(context, data.insightId, projectId)
const insightResult = await context.api
.insights({ projectId })
.get({ insightId: data.insightId })
const insightResult = await context.api.insights({ projectId }).get({ insightId: data.insightId })
if (!insightResult.success) {
throw new Error(`Failed to get insight: ${insightResult.error.message}`)

View File

@@ -1,12 +1,13 @@
import type { z } from 'zod'
import { DashboardCreateSchema } from '@/schema/tool-inputs'
import type { Context, ToolBase } from '@/tools/types'
import type { z } from 'zod'
const schema = DashboardCreateSchema
type Params = z.infer<typeof schema>
export const createHandler = async (context: Context, params: Params) => {
export const createHandler: ToolBase<typeof schema>['handler'] = async (context: Context, params: Params) => {
const { data } = params
const projectId = await context.stateManager.getProjectId()
const dashboardResult = await context.api.dashboards({ projectId }).create({ data })

View File

@@ -1,12 +1,13 @@
import type { z } from 'zod'
import { DashboardDeleteSchema } from '@/schema/tool-inputs'
import type { Context, ToolBase } from '@/tools/types'
import type { z } from 'zod'
const schema = DashboardDeleteSchema
type Params = z.infer<typeof schema>
export const deleteHandler = async (context: Context, params: Params) => {
export const deleteHandler: ToolBase<typeof schema>['handler'] = async (context: Context, params: Params) => {
const { dashboardId } = params
const projectId = await context.stateManager.getProjectId()
const result = await context.api.dashboards({ projectId }).delete({ dashboardId })

View File

@@ -1,12 +1,13 @@
import type { z } from 'zod'
import { DashboardGetSchema } from '@/schema/tool-inputs'
import type { Context, ToolBase } from '@/tools/types'
import type { z } from 'zod'
const schema = DashboardGetSchema
type Params = z.infer<typeof schema>
export const getHandler = async (context: Context, params: Params) => {
export const getHandler: ToolBase<typeof schema>['handler'] = async (context: Context, params: Params) => {
const { dashboardId } = params
const projectId = await context.stateManager.getProjectId()
const dashboardResult = await context.api.dashboards({ projectId }).get({ dashboardId })

View File

@@ -1,17 +1,16 @@
import type { z } from 'zod'
import { DashboardGetAllSchema } from '@/schema/tool-inputs'
import type { Context, ToolBase } from '@/tools/types'
import type { z } from 'zod'
const schema = DashboardGetAllSchema
type Params = z.infer<typeof schema>
export const getAllHandler = async (context: Context, params: Params) => {
export const getAllHandler: ToolBase<typeof schema>['handler'] = async (context: Context, params: Params) => {
const { data } = params
const projectId = await context.stateManager.getProjectId()
const dashboardsResult = await context.api
.dashboards({ projectId })
.list({ params: data ?? {} })
const dashboardsResult = await context.api.dashboards({ projectId }).list({ params: data ?? {} })
if (!dashboardsResult.success) {
throw new Error(`Failed to get dashboards: ${dashboardsResult.error.message}`)

View File

@@ -1,17 +1,16 @@
import type { z } from 'zod'
import { DashboardUpdateSchema } from '@/schema/tool-inputs'
import type { Context, ToolBase } from '@/tools/types'
import type { z } from 'zod'
const schema = DashboardUpdateSchema
type Params = z.infer<typeof schema>
export const updateHandler = async (context: Context, params: Params) => {
export const updateHandler: ToolBase<typeof schema>['handler'] = async (context: Context, params: Params) => {
const { dashboardId, data } = params
const projectId = await context.stateManager.getProjectId()
const dashboardResult = await context.api
.dashboards({ projectId })
.update({ dashboardId, data })
const dashboardResult = await context.api.dashboards({ projectId }).update({ dashboardId, data })
if (!dashboardResult.success) {
throw new Error(`Failed to update dashboard: ${dashboardResult.error.message}`)

View File

@@ -1,13 +1,14 @@
import type { z } from 'zod'
import { docsSearch } from '@/inkeepApi'
import { DocumentationSearchSchema } from '@/schema/tool-inputs'
import type { Context, ToolBase } from '@/tools/types'
import type { z } from 'zod'
const schema = DocumentationSearchSchema
type Params = z.infer<typeof schema>
export const searchDocsHandler = async (context: Context, params: Params) => {
export const searchDocsHandler: ToolBase<typeof schema>['handler'] = async (context: Context, params: Params) => {
const { query } = params
const inkeepApiKey = context.env.INKEEP_API_KEY

View File

@@ -1,12 +1,13 @@
import type { z } from 'zod'
import { ErrorTrackingDetailsSchema } from '@/schema/tool-inputs'
import type { Context, ToolBase } from '@/tools/types'
import type { z } from 'zod'
const schema = ErrorTrackingDetailsSchema
type Params = z.infer<typeof schema>
export const errorDetailsHandler = async (context: Context, params: Params) => {
export const errorDetailsHandler: ToolBase<typeof schema>['handler'] = async (context: Context, params: Params) => {
const { issueId, dateFrom, dateTo } = params
const projectId = await context.stateManager.getProjectId()

View File

@@ -1,12 +1,13 @@
import type { z } from 'zod'
import { ErrorTrackingListSchema } from '@/schema/tool-inputs'
import type { Context, ToolBase } from '@/tools/types'
import type { z } from 'zod'
const schema = ErrorTrackingListSchema
type Params = z.infer<typeof schema>
export const listErrorsHandler = async (context: Context, params: Params) => {
export const listErrorsHandler: ToolBase<typeof schema>['handler'] = async (context: Context, params: Params) => {
const { orderBy, dateFrom, dateTo, orderDirection, filterTestAccounts, status } = params
const projectId = await context.stateManager.getProjectId()

View File

@@ -1,6 +1,7 @@
import type { z } from 'zod'
import { ExperimentCreateSchema } from '@/schema/tool-inputs'
import type { Context, ToolBase } from '@/tools/types'
import type { z } from 'zod'
const schema = ExperimentCreateSchema
@@ -10,7 +11,7 @@ type Params = z.infer<typeof schema>
* Create a comprehensive A/B test experiment with guided setup
* This tool helps users create well-configured experiments through conversation
*/
export const createExperimentHandler = async (context: Context, params: Params) => {
export const createExperimentHandler: ToolBase<typeof schema>['handler'] = async (context: Context, params: Params) => {
const projectId = await context.stateManager.getProjectId()
const result = await context.api.experiments({ projectId }).create(params)

View File

@@ -1,12 +1,13 @@
import type { z } from 'zod'
import { ExperimentDeleteSchema } from '@/schema/tool-inputs'
import type { Context, ToolBase } from '@/tools/types'
import type { z } from 'zod'
const schema = ExperimentDeleteSchema
type Params = z.infer<typeof schema>
export const deleteHandler = async (context: Context, { experimentId }: Params) => {
export const deleteHandler: ToolBase<typeof schema>['handler'] = async (context: Context, { experimentId }: Params) => {
const projectId = await context.stateManager.getProjectId()
const deleteResult = await context.api.experiments({ projectId }).delete({

View File

@@ -1,12 +1,13 @@
import type { z } from 'zod'
import { ExperimentGetSchema } from '@/schema/tool-inputs'
import type { Context, ToolBase } from '@/tools/types'
import type { z } from 'zod'
const schema = ExperimentGetSchema
type Params = z.infer<typeof schema>
export const getHandler = async (context: Context, { experimentId }: Params) => {
export const getHandler: ToolBase<typeof schema>['handler'] = async (context: Context, { experimentId }: Params) => {
const projectId = await context.stateManager.getProjectId()
const result = await context.api.experiments({ projectId }).get({

View File

@@ -1,12 +1,9 @@
import { ExperimentGetAllSchema } from '@/schema/tool-inputs'
import type { Context, ToolBase } from '@/tools/types'
import type { z } from 'zod'
const schema = ExperimentGetAllSchema
type Params = z.infer<typeof schema>
export const getAllHandler = async (context: Context, _params: Params) => {
export const getAllHandler: ToolBase<typeof schema>['handler'] = async (context: Context) => {
const projectId = await context.stateManager.getProjectId()
const results = await context.api.experiments({ projectId }).list()

View File

@@ -1,7 +1,8 @@
import type { z } from 'zod'
import { ExperimentResultsResponseSchema } from '@/schema/experiments'
import { ExperimentResultsGetSchema } from '@/schema/tool-inputs'
import type { Context, ToolBase } from '@/tools/types'
import type { z } from 'zod'
const schema = ExperimentResultsGetSchema
@@ -12,7 +13,7 @@ type Params = z.infer<typeof schema>
* This tool fetches the experiment details and executes the necessary queries
* to get metrics results (both primary and secondary) and exposure data
*/
export const getResultsHandler = async (context: Context, params: Params) => {
export const getResultsHandler: ToolBase<typeof schema>['handler'] = async (context: Context, params: Params) => {
const projectId = await context.stateManager.getProjectId()
const result = await context.api.experiments({ projectId }).getMetricResults({

View File

@@ -1,14 +1,15 @@
import type { z } from 'zod'
import { ExperimentUpdateTransformSchema } from '@/schema/experiments'
import { ExperimentUpdateSchema } from '@/schema/tool-inputs'
import { getToolDefinition } from '@/tools/toolDefinitions'
import type { Context, Tool } from '@/tools/types'
import type { z } from 'zod'
import type { Context, Tool, ToolBase } from '@/tools/types'
const schema = ExperimentUpdateSchema
type Params = z.infer<typeof schema>
export const updateHandler = async (context: Context, params: Params) => {
export const updateHandler: ToolBase<typeof schema>['handler'] = async (context: Context, params: Params) => {
const { experimentId, data } = params
const projectId = await context.stateManager.getProjectId()

View File

@@ -1,12 +1,13 @@
import type { z } from 'zod'
import { FeatureFlagCreateSchema } from '@/schema/tool-inputs'
import type { Context, ToolBase } from '@/tools/types'
import type { z } from 'zod'
const schema = FeatureFlagCreateSchema
type Params = z.infer<typeof schema>
export const createHandler = async (context: Context, params: Params) => {
export const createHandler: ToolBase<typeof schema>['handler'] = async (context: Context, params: Params) => {
const { name, key, description, filters, active, tags } = params
const projectId = await context.stateManager.getProjectId()

View File

@@ -1,12 +1,13 @@
import type { z } from 'zod'
import { FeatureFlagDeleteSchema } from '@/schema/tool-inputs'
import type { Context, ToolBase } from '@/tools/types'
import type { z } from 'zod'
const schema = FeatureFlagDeleteSchema
type Params = z.infer<typeof schema>
export const deleteHandler = async (context: Context, params: Params) => {
export const deleteHandler: ToolBase<typeof schema>['handler'] = async (context: Context, params: Params) => {
const { flagKey } = params
const projectId = await context.stateManager.getProjectId()

View File

@@ -1,12 +1,9 @@
import { FeatureFlagGetAllSchema } from '@/schema/tool-inputs'
import type { Context, ToolBase } from '@/tools/types'
import type { z } from 'zod'
const schema = FeatureFlagGetAllSchema
type Params = z.infer<typeof schema>
export const getAllHandler = async (context: Context, _params: Params) => {
export const getAllHandler: ToolBase<typeof schema>['handler'] = async (context: Context) => {
const projectId = await context.stateManager.getProjectId()
const flagsResult = await context.api.featureFlags({ projectId }).list()

View File

@@ -1,12 +1,16 @@
import type { z } from 'zod'
import { FeatureFlagGetDefinitionSchema } from '@/schema/tool-inputs'
import type { Context, ToolBase } from '@/tools/types'
import type { z } from 'zod'
const schema = FeatureFlagGetDefinitionSchema
type Params = z.infer<typeof schema>
export const getDefinitionHandler = async (context: Context, { flagId, flagKey }: Params) => {
export const getDefinitionHandler: ToolBase<typeof schema>['handler'] = async (
context: Context,
{ flagId, flagKey }: Params
) => {
if (!flagId && !flagKey) {
return {
content: [
@@ -21,9 +25,7 @@ export const getDefinitionHandler = async (context: Context, { flagId, flagKey }
const projectId = await context.stateManager.getProjectId()
if (flagId) {
const flagResult = await context.api
.featureFlags({ projectId })
.get({ flagId: String(flagId) })
const flagResult = await context.api.featureFlags({ projectId }).get({ flagId: String(flagId) })
if (!flagResult.success) {
throw new Error(`Failed to get feature flag: ${flagResult.error.message}`)
}

View File

@@ -1,12 +1,13 @@
import type { z } from 'zod'
import { FeatureFlagUpdateSchema } from '@/schema/tool-inputs'
import type { Context, ToolBase } from '@/tools/types'
import type { z } from 'zod'
const schema = FeatureFlagUpdateSchema
type Params = z.infer<typeof schema>
export const updateHandler = async (context: Context, params: Params) => {
export const updateHandler: ToolBase<typeof schema>['handler'] = async (context: Context, params: Params) => {
const { flagKey, data } = params
const projectId = await context.stateManager.getProjectId()

View File

@@ -1,37 +1,22 @@
import type { Context, Tool, ToolBase, ZodObjectAny } from './types'
import { ApiClient } from '@/api/client'
import { SessionManager } from '@/lib/utils/SessionManager'
import { StateManager } from '@/lib/utils/StateManager'
import { hasScopes } from '@/lib/utils/api'
import { MemoryCache } from '@/lib/utils/cache/MemoryCache'
import { hash } from '@/lib/utils/helper-functions'
import { getToolsForFeatures as getFilteredToolNames, getToolDefinition } from './toolDefinitions'
import createFeatureFlag from './featureFlags/create'
import deleteFeatureFlag from './featureFlags/delete'
import getAllFeatureFlags from './featureFlags/getAll'
// Feature Flags
import getFeatureFlagDefinition from './featureFlags/getDefinition'
import updateFeatureFlag from './featureFlags/update'
import getOrganizationDetails from './organizations/getDetails'
// Organizations
import getOrganizations from './organizations/getOrganizations'
import setActiveOrganization from './organizations/setActive'
import eventDefinitions from './projects/eventDefinitions'
// Projects
import getProjects from './projects/getProjects'
import getProperties from './projects/propertyDefinitions'
import setActiveProject from './projects/setActive'
// Dashboards
import addInsightToDashboard from './dashboards/addInsight'
import createDashboard from './dashboards/create'
import deleteDashboard from './dashboards/delete'
import getDashboard from './dashboards/get'
import getAllDashboards from './dashboards/getAll'
import updateDashboard from './dashboards/update'
// Documentation
import searchDocs from './documentation/searchDocs'
import errorDetails from './errorTracking/errorDetails'
// Error Tracking
import errorDetails from './errorTracking/errorDetails'
import listErrors from './errorTracking/listErrors'
// Experiments
import createExperiment from './experiments/create'
import deleteExperiment from './experiments/delete'
@@ -39,32 +24,33 @@ import getExperiment from './experiments/get'
import getAllExperiments from './experiments/getAll'
import getExperimentResults from './experiments/getResults'
import updateExperiment from './experiments/update'
// Feature Flags
import createFeatureFlag from './featureFlags/create'
import deleteFeatureFlag from './featureFlags/delete'
import getAllFeatureFlags from './featureFlags/getAll'
import getFeatureFlagDefinition from './featureFlags/getDefinition'
import updateFeatureFlag from './featureFlags/update'
// Insights
import createInsight from './insights/create'
import deleteInsight from './insights/delete'
import getInsight from './insights/get'
// Insights
import getAllInsights from './insights/getAll'
import queryInsight from './insights/query'
import updateInsight from './insights/update'
import addInsightToDashboard from './dashboards/addInsight'
import createDashboard from './dashboards/create'
import deleteDashboard from './dashboards/delete'
import getDashboard from './dashboards/get'
// Dashboards
import getAllDashboards from './dashboards/getAll'
import updateDashboard from './dashboards/update'
import generateHogQLFromQuestion from './query/generateHogQLFromQuestion'
// Query
import queryRun from './query/run'
import { hasScopes } from '@/lib/utils/api'
// LLM Observability
import getLLMCosts from './llmAnalytics/getLLMCosts'
// Organizations
import getOrganizationDetails from './organizations/getDetails'
import getOrganizations from './organizations/getOrganizations'
import setActiveOrganization from './organizations/setActive'
// Projects
import eventDefinitions from './projects/eventDefinitions'
import getProjects from './projects/getProjects'
import getProperties from './projects/propertyDefinitions'
import setActiveProject from './projects/setActive'
// Query
import generateHogQLFromQuestion from './query/generateHogQLFromQuestion'
import queryRun from './query/run'
// Surveys
import createSurvey from './surveys/create'
import deleteSurvey from './surveys/delete'
@@ -73,6 +59,9 @@ import getAllSurveys from './surveys/getAll'
import surveysGlobalStats from './surveys/global-stats'
import surveyStats from './surveys/stats'
import updateSurvey from './surveys/update'
// Misc
import { getToolsForFeatures as getFilteredToolNames, getToolDefinition } from './toolDefinitions'
import type { Context, Tool, ToolBase, ZodObjectAny } from './types'
// Map of tool names to tool factory functions
const TOOL_MAP: Record<string, () => ToolBase<ZodObjectAny>> = {
@@ -142,10 +131,7 @@ const TOOL_MAP: Record<string, () => ToolBase<ZodObjectAny>> = {
'survey-stats': surveyStats,
}
export const getToolsFromContext = async (
context: Context,
features?: string[]
): Promise<Tool<ZodObjectAny>[]> => {
export const getToolsFromContext = async (context: Context, features?: string[]): Promise<Tool<ZodObjectAny>[]> => {
const allowedToolNames = getFilteredToolNames(features)
const toolBases: ToolBase<ZodObjectAny>[] = []
@@ -173,14 +159,14 @@ export const getToolsFromContext = async (
})
try {
const { scopes } = await context.stateManager.getApiKey()
const { scopes } = (await context.stateManager.getApiKey())!
const filteredTools = tools.filter((tool) => {
return hasScopes(scopes, tool.scopes)
})
return filteredTools
} catch (error) {
} catch {
// OAuth tokens don't support /api/personal_api_keys/@current endpoint yet
// Return empty tool set until backend OAuth support is added
return []

View File

@@ -1,12 +1,13 @@
import type { z } from 'zod'
import { InsightCreateSchema } from '@/schema/tool-inputs'
import type { Context, ToolBase } from '@/tools/types'
import type { z } from 'zod'
const schema = InsightCreateSchema
type Params = z.infer<typeof schema>
export const createHandler = async (context: Context, params: Params) => {
export const createHandler: ToolBase<typeof schema>['handler'] = async (context: Context, params: Params) => {
const { data } = params
const projectId = await context.stateManager.getProjectId()
const insightResult = await context.api.insights({ projectId }).create({ data })

View File

@@ -1,13 +1,15 @@
import type { z } from 'zod'
import { InsightDeleteSchema } from '@/schema/tool-inputs'
import type { Context, ToolBase } from '@/tools/types'
import { resolveInsightId } from './utils'
import type { z } from 'zod'
const schema = InsightDeleteSchema
type Params = z.infer<typeof schema>
export const deleteHandler = async (context: Context, params: Params) => {
export const deleteHandler: ToolBase<typeof schema>['handler'] = async (context: Context, params: Params) => {
const { insightId } = params
const projectId = await context.stateManager.getProjectId()

View File

@@ -1,12 +1,13 @@
import type { z } from 'zod'
import { InsightGetSchema } from '@/schema/tool-inputs'
import type { Context, ToolBase } from '@/tools/types'
import type { z } from 'zod'
const schema = InsightGetSchema
type Params = z.infer<typeof schema>
export const getHandler = async (context: Context, params: Params) => {
export const getHandler: ToolBase<typeof schema>['handler'] = async (context: Context, params: Params) => {
const { insightId } = params
const projectId = await context.stateManager.getProjectId()
const insightResult = await context.api.insights({ projectId }).get({ insightId })

View File

@@ -1,12 +1,13 @@
import type { z } from 'zod'
import { InsightGetAllSchema } from '@/schema/tool-inputs'
import type { Context, ToolBase } from '@/tools/types'
import type { z } from 'zod'
const schema = InsightGetAllSchema
type Params = z.infer<typeof schema>
export const getAllHandler = async (context: Context, params: Params) => {
export const getAllHandler: ToolBase<typeof schema>['handler'] = async (context: Context, params: Params) => {
const { data } = params
const projectId = await context.stateManager.getProjectId()
const insightsResult = await context.api.insights({ projectId }).list({ params: { ...data } })

View File

@@ -1,12 +1,13 @@
import type { z } from 'zod'
import { InsightQueryInputSchema } from '@/schema/tool-inputs'
import type { Context, ToolBase } from '@/tools/types'
import type { z } from 'zod'
const schema = InsightQueryInputSchema
type Params = z.infer<typeof schema>
export const queryHandler = async (context: Context, params: Params) => {
export const queryHandler: ToolBase<typeof schema>['handler'] = async (context: Context, params: Params) => {
const { insightId } = params
const projectId = await context.stateManager.getProjectId()

View File

@@ -1,13 +1,15 @@
import type { z } from 'zod'
import { InsightUpdateSchema } from '@/schema/tool-inputs'
import type { Context, ToolBase } from '@/tools/types'
import type { z } from 'zod'
import { resolveInsightId } from './utils'
const schema = InsightUpdateSchema
type Params = z.infer<typeof schema>
export const updateHandler = async (context: Context, params: Params) => {
export const updateHandler: ToolBase<typeof schema>['handler'] = async (context: Context, params: Params) => {
const { insightId, data } = params
const projectId = await context.stateManager.getProjectId()

View File

@@ -4,11 +4,7 @@ export const isShortId = (id: string): boolean => {
return /^[A-Za-z0-9]{8}$/.test(id)
}
export const resolveInsightId = async (
context: Context,
insightId: string,
projectId: string
): Promise<number> => {
export const resolveInsightId = async (context: Context, insightId: string, projectId: string): Promise<number> => {
if (isShortId(insightId)) {
const result = await context.api.insights({ projectId }).get({ insightId })

View File

@@ -1,12 +1,13 @@
import type { z } from 'zod'
import { LLMAnalyticsGetCostsSchema } from '@/schema/tool-inputs'
import type { Context, ToolBase } from '@/tools/types'
import type { z } from 'zod'
const schema = LLMAnalyticsGetCostsSchema
type Params = z.infer<typeof schema>
export const getLLMCostsHandler = async (context: Context, params: Params) => {
export const getLLMCostsHandler: ToolBase<typeof schema>['handler'] = async (context: Context, params: Params) => {
const { projectId, days } = params
const trendsQuery = {
@@ -31,9 +32,7 @@ export const getLLMCostsHandler = async (context: Context, params: Params) => {
},
}
const costsResult = await context.api
.query({ projectId: String(projectId) })
.execute({ queryBody: trendsQuery })
const costsResult = await context.api.query({ projectId: String(projectId) }).execute({ queryBody: trendsQuery })
if (!costsResult.success) {
throw new Error(`Failed to get LLM costs: ${costsResult.error.message}`)
}

View File

@@ -1,12 +1,9 @@
import { OrganizationGetDetailsSchema } from '@/schema/tool-inputs'
import type { Context, ToolBase } from '@/tools/types'
import type { z } from 'zod'
const schema = OrganizationGetDetailsSchema
type Params = z.infer<typeof schema>
export const getDetailsHandler = async (context: Context, _params: Params) => {
export const getDetailsHandler: ToolBase<typeof schema>['handler'] = async (context: Context) => {
const orgId = await context.stateManager.getOrgID()
if (!orgId) {

View File

@@ -1,12 +1,9 @@
import { OrganizationGetAllSchema } from '@/schema/tool-inputs'
import type { Context, ToolBase } from '@/tools/types'
import type { z } from 'zod'
const schema = OrganizationGetAllSchema
type Params = z.infer<typeof schema>
export const getOrganizationsHandler = async (context: Context, _params: Params) => {
export const getOrganizationsHandler: ToolBase<typeof schema>['handler'] = async (context: Context) => {
const orgsResult = await context.api.organizations().list()
if (!orgsResult.success) {
throw new Error(`Failed to get organizations: ${orgsResult.error.message}`)

View File

@@ -1,12 +1,13 @@
import type { z } from 'zod'
import { OrganizationSetActiveSchema } from '@/schema/tool-inputs'
import type { Context, ToolBase } from '@/tools/types'
import type { z } from 'zod'
const schema = OrganizationSetActiveSchema
type Params = z.infer<typeof schema>
export const setActiveHandler = async (context: Context, params: Params) => {
export const setActiveHandler: ToolBase<typeof schema>['handler'] = async (context: Context, params: Params) => {
const { orgId } = params
await context.cache.set('orgId', orgId)

View File

@@ -1,18 +1,20 @@
import type { z } from 'zod'
import { EventDefinitionSchema } from '@/schema/properties'
import { ProjectEventDefinitionsSchema } from '@/schema/tool-inputs'
import type { Context, ToolBase } from '@/tools/types'
import type { z } from 'zod'
const schema = ProjectEventDefinitionsSchema
type Params = z.infer<typeof schema>
export const eventDefinitionsHandler = async (context: Context, _params: Params) => {
export const eventDefinitionsHandler: ToolBase<typeof schema>['handler'] = async (
context: Context,
_params: Params
) => {
const projectId = await context.stateManager.getProjectId()
const eventDefsResult = await context.api
.projects()
.eventDefinitions({ projectId, search: _params.q })
const eventDefsResult = await context.api.projects().eventDefinitions({ projectId, search: _params.q })
if (!eventDefsResult.success) {
throw new Error(`Failed to get event definitions: ${eventDefsResult.error.message}`)

View File

@@ -1,12 +1,9 @@
import { ProjectGetAllSchema } from '@/schema/tool-inputs'
import type { Context, ToolBase } from '@/tools/types'
import type { z } from 'zod'
const schema = ProjectGetAllSchema
type Params = z.infer<typeof schema>
export const getProjectsHandler = async (context: Context, _params: Params) => {
export const getProjectsHandler: ToolBase<typeof schema>['handler'] = async (context: Context) => {
const orgId = await context.stateManager.getOrgID()
if (!orgId) {

View File

@@ -1,13 +1,17 @@
import type { z } from 'zod'
import { PropertyDefinitionSchema } from '@/schema/properties'
import { ProjectPropertyDefinitionsInputSchema } from '@/schema/tool-inputs'
import type { Context, ToolBase } from '@/tools/types'
import type { z } from 'zod'
const schema = ProjectPropertyDefinitionsInputSchema
type Params = z.infer<typeof schema>
export const propertyDefinitionsHandler = async (context: Context, params: Params) => {
export const propertyDefinitionsHandler: ToolBase<typeof schema>['handler'] = async (
context: Context,
params: Params
) => {
const projectId = await context.stateManager.getProjectId()
if (!params.eventName && params.type === 'event') {
@@ -25,9 +29,7 @@ export const propertyDefinitionsHandler = async (context: Context, params: Param
})
if (!propDefsResult.success) {
throw new Error(
`Failed to get property definitions for ${params.type}s: ${propDefsResult.error.message}`
)
throw new Error(`Failed to get property definitions for ${params.type}s: ${propDefsResult.error.message}`)
}
const simplifiedProperties = PropertyDefinitionSchema.array().parse(propDefsResult.data)

View File

@@ -1,12 +1,13 @@
import type { z } from 'zod'
import { ProjectSetActiveSchema } from '@/schema/tool-inputs'
import type { Context, ToolBase } from '@/tools/types'
import type { z } from 'zod'
const schema = ProjectSetActiveSchema
type Params = z.infer<typeof schema>
export const setActiveHandler = async (context: Context, params: Params) => {
export const setActiveHandler: ToolBase<typeof schema>['handler'] = async (context: Context, params: Params) => {
const { projectId } = params
await context.cache.set('projectId', projectId.toString())

View File

@@ -1,12 +1,13 @@
import type { z } from 'zod'
import { InsightGenerateHogQLFromQuestionSchema } from '@/schema/tool-inputs'
import type { Context, ToolBase } from '@/tools/types'
import type { z } from 'zod'
const schema = InsightGenerateHogQLFromQuestionSchema
type Params = z.infer<typeof schema>
export const generateHogQLHandler = async (context: Context, params: Params) => {
export const generateHogQLHandler: ToolBase<typeof schema>['handler'] = async (context: Context, params: Params) => {
const { question } = params
const projectId = await context.stateManager.getProjectId()

View File

@@ -1,12 +1,13 @@
import type { z } from 'zod'
import { QueryRunInputSchema } from '@/schema/tool-inputs'
import type { Context, ToolBase } from '@/tools/types'
import type { z } from 'zod'
const schema = QueryRunInputSchema
type Params = z.infer<typeof schema>
export const queryRunHandler = async (context: Context, params: Params) => {
export const queryRunHandler: ToolBase<typeof schema>['handler'] = async (context: Context, params: Params) => {
const { query } = params
const projectId = await context.stateManager.getProjectId()

View File

@@ -1,12 +1,13 @@
import type { z } from 'zod'
import { SurveyCreateSchema } from '@/schema/tool-inputs'
import { formatSurvey } from '@/tools/surveys/utils/survey-utils'
import type { Context, ToolBase } from '@/tools/types'
import type { z } from 'zod'
const schema = SurveyCreateSchema
type Params = z.infer<typeof schema>
export const createHandler = async (context: Context, params: Params) => {
export const createHandler: ToolBase<typeof schema>['handler'] = async (context: Context, params: Params) => {
const projectId = await context.stateManager.getProjectId()
// Process questions to handle branching logic

View File

@@ -1,11 +1,12 @@
import type { z } from 'zod'
import { SurveyDeleteSchema } from '@/schema/tool-inputs'
import type { Context, ToolBase } from '@/tools/types'
import type { z } from 'zod'
const schema = SurveyDeleteSchema
type Params = z.infer<typeof schema>
export const deleteHandler = async (context: Context, params: Params) => {
export const deleteHandler: ToolBase<typeof schema>['handler'] = async (context: Context, params: Params) => {
const { surveyId } = params
const projectId = await context.stateManager.getProjectId()

View File

@@ -1,12 +1,13 @@
import type { z } from 'zod'
import { SurveyGetSchema } from '@/schema/tool-inputs'
import { formatSurvey } from '@/tools/surveys/utils/survey-utils'
import type { Context, ToolBase } from '@/tools/types'
import type { z } from 'zod'
const schema = SurveyGetSchema
type Params = z.infer<typeof schema>
export const getHandler = async (context: Context, params: Params) => {
export const getHandler: ToolBase<typeof schema>['handler'] = async (context: Context, params: Params) => {
const { surveyId } = params
const projectId = await context.stateManager.getProjectId()

View File

@@ -1,12 +1,13 @@
import type { z } from 'zod'
import { SurveyGetAllSchema } from '@/schema/tool-inputs'
import { formatSurveys } from '@/tools/surveys/utils/survey-utils'
import type { Context, ToolBase } from '@/tools/types'
import type { z } from 'zod'
const schema = SurveyGetAllSchema
type Params = z.infer<typeof schema>
export const getAllHandler = async (context: Context, params: Params) => {
export const getAllHandler: ToolBase<typeof schema>['handler'] = async (context: Context, params: Params) => {
const projectId = await context.stateManager.getProjectId()
const surveysResult = await context.api.surveys({ projectId }).list(params ? { params } : {})

View File

@@ -1,11 +1,12 @@
import type { z } from 'zod'
import { SurveyGlobalStatsSchema } from '@/schema/tool-inputs'
import type { Context, ToolBase } from '@/tools/types'
import type { z } from 'zod'
const schema = SurveyGlobalStatsSchema
type Params = z.infer<typeof schema>
export const globalStatsHandler = async (context: Context, params: Params) => {
export const globalStatsHandler: ToolBase<typeof schema>['handler'] = async (context: Context, params: Params) => {
const projectId = await context.stateManager.getProjectId()
const result = await context.api.surveys({ projectId }).globalStats({ params })

View File

@@ -1,11 +1,12 @@
import type { z } from 'zod'
import { SurveyStatsSchema } from '@/schema/tool-inputs'
import type { Context, ToolBase } from '@/tools/types'
import type { z } from 'zod'
const schema = SurveyStatsSchema
type Params = z.infer<typeof schema>
export const statsHandler = async (context: Context, params: Params) => {
export const statsHandler: ToolBase<typeof schema>['handler'] = async (context: Context, params: Params) => {
const projectId = await context.stateManager.getProjectId()
const result = await context.api.surveys({ projectId }).stats({

View File

@@ -1,12 +1,13 @@
import type { z } from 'zod'
import { SurveyUpdateSchema } from '@/schema/tool-inputs'
import { formatSurvey } from '@/tools/surveys/utils/survey-utils'
import type { Context, ToolBase } from '@/tools/types'
import type { z } from 'zod'
const schema = SurveyUpdateSchema
type Params = z.infer<typeof schema>
export const updateHandler = async (context: Context, params: Params) => {
export const updateHandler: ToolBase<typeof schema>['handler'] = async (context: Context, params: Params) => {
const { surveyId, ...data } = params
const projectId = await context.stateManager.getProjectId()

View File

@@ -12,11 +12,7 @@ export interface FormattedSurvey extends Omit<SurveyData, 'end_date'> {
/**
* Formats a survey with consistent status logic and additional fields
*/
export function formatSurvey(
survey: SurveyData,
context: Context,
projectId: string
): FormattedSurvey {
export function formatSurvey(survey: SurveyData, context: Context, projectId: string): FormattedSurvey {
const status = survey.archived
? 'archived'
: survey.start_date === null || survey.start_date === undefined
@@ -43,10 +39,6 @@ export function formatSurvey(
/**
* Formats multiple surveys consistently
*/
export function formatSurveys(
surveys: SurveyData[],
context: Context,
projectId: string
): FormattedSurvey[] {
export function formatSurveys(surveys: SurveyData[], context: Context, projectId: string): FormattedSurvey[] {
return surveys.map((survey) => formatSurvey(survey, context, projectId))
}

View File

@@ -1,4 +1,5 @@
import z from 'zod'
import toolDefinitionsJson from '../../../schema/tool-definitions.json'
export const ToolDefinitionSchema = z.object({

View File

@@ -1,10 +1,11 @@
import type { z } from 'zod'
import type { ApiClient } from '@/api/client'
import type { PrefixedString } from '@/lib/types'
import type { StateManager } from '@/lib/utils/StateManager'
import type { SessionManager } from '@/lib/utils/SessionManager'
import type { StateManager } from '@/lib/utils/StateManager'
import type { ScopedCache } from '@/lib/utils/cache/ScopedCache'
import type { ApiRedactedPersonalApiKey } from '@/schema/api'
import type { z } from 'zod'
export type CloudRegion = 'us' | 'eu'

View File

@@ -6,11 +6,11 @@ These tests should verify that the API works as intended.
1. **Configure environment:**
```bash
cp .env.test.example .env.test
```
```bash
cp .env.test.example .env.test
```
Edit `.env.test` and set an api token and base url for your local PostHog instance
Edit `.env.test` and set an api token and base url for your local PostHog instance
## Running Tests

View File

@@ -1,6 +1,7 @@
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
import { ApiClient } from '@/api/client'
import type { CreateInsightInput } from '@/schema/insights'
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
const API_BASE_URL = process.env.TEST_POSTHOG_API_BASE_URL || 'http://localhost:8010'
const API_TOKEN = process.env.TEST_POSTHOG_PERSONAL_API_KEY
@@ -163,31 +164,28 @@ describe('API Client Integration Tests', { concurrent: false }, () => {
}
})
it.each(['event', 'person'] as const)(
'should get property definitions for %s type',
async (type) => {
const result = await client.projects().propertyDefinitions({
projectId: testProjectId,
type,
eventNames: type === 'event' ? ['$pageview'] : undefined,
excludeCoreProperties: false,
filterByEventNames: type === 'event',
isFeatureFlag: false,
limit: 100,
})
it.each(['event', 'person'] as const)('should get property definitions for %s type', async (type) => {
const result = await client.projects().propertyDefinitions({
projectId: testProjectId,
type,
eventNames: type === 'event' ? ['$pageview'] : undefined,
excludeCoreProperties: false,
filterByEventNames: type === 'event',
isFeatureFlag: false,
limit: 100,
})
expect(result.success).toBe(true)
expect(result.success).toBe(true)
if (result.success) {
expect(Array.isArray(result.data)).toBe(true)
if (result.data.length > 0) {
const propDef = result.data[0]
expect(propDef).toHaveProperty('id')
expect(propDef).toHaveProperty('name')
}
if (result.success) {
expect(Array.isArray(result.data)).toBe(true)
if (result.data.length > 0) {
const propDef = result.data[0]
expect(propDef).toHaveProperty('id')
expect(propDef).toHaveProperty('name')
}
}
)
})
it('should get event definitions', async () => {
const result = await client.projects().eventDefinitions({ projectId: testProjectId })
@@ -279,9 +277,7 @@ describe('API Client Integration Tests', { concurrent: false }, () => {
createdResources.featureFlags.push(flagId)
// Get by ID
const getResult = await client
.featureFlags({ projectId: testProjectId })
.get({ flagId })
const getResult = await client.featureFlags({ projectId: testProjectId }).get({ flagId })
expect(getResult.success).toBe(true)
if (getResult.success) {
@@ -290,9 +286,7 @@ describe('API Client Integration Tests', { concurrent: false }, () => {
}
// Find by key
const findResult = await client
.featureFlags({ projectId: testProjectId })
.findByKey({ key: testKey })
const findResult = await client.featureFlags({ projectId: testProjectId }).findByKey({ key: testKey })
expect(findResult.success).toBe(true)
if (findResult.success && findResult.data) {
@@ -301,21 +295,17 @@ describe('API Client Integration Tests', { concurrent: false }, () => {
}
// Update
const updateResult = await client
.featureFlags({ projectId: testProjectId })
.update({
key: testKey,
data: {
name: 'Updated Test Flag',
active: false,
},
})
const updateResult = await client.featureFlags({ projectId: testProjectId }).update({
key: testKey,
data: {
name: 'Updated Test Flag',
active: false,
},
})
expect(updateResult.success).toBe(true)
// Verify update
const updatedGetResult = await client
.featureFlags({ projectId: testProjectId })
.get({ flagId })
const updatedGetResult = await client.featureFlags({ projectId: testProjectId }).get({ flagId })
if (updatedGetResult.success) {
expect(updatedGetResult.data.name).toBe('Updated Test Flag')
expect(updatedGetResult.data.active).toBe(false)
@@ -344,9 +334,7 @@ describe('API Client Integration Tests', { concurrent: false }, () => {
})
it.skip('should execute SQL insight query', async () => {
const result = await client
.insights({ projectId: testProjectId })
.sqlInsight({ query: 'SELECT 1 as test' })
const result = await client.insights({ projectId: testProjectId }).sqlInsight({ query: 'SELECT 1 as test' })
if (!result.success) {
console.error('Failed to execute SQL insight:', (result as any).error)
@@ -523,8 +511,7 @@ describe('API Client Integration Tests', { concurrent: false }, () => {
],
breakdownFilter: {
breakdown_type: breakdownType,
breakdown:
breakdownType === 'event' ? '$current_url' : '$browser',
breakdown: breakdownType === 'event' ? '$current_url' : '$browser',
breakdown_limit: 10,
},
properties: [],
@@ -828,8 +815,7 @@ describe('API Client Integration Tests', { concurrent: false }, () => {
},
],
funnelsFilter: {
funnelWindowInterval:
unit === 'minute' ? 30 : unit === 'hour' ? 2 : 7,
funnelWindowInterval: unit === 'minute' ? 30 : unit === 'hour' ? 2 : 7,
funnelWindowIntervalUnit: unit,
},
properties: [],
@@ -1005,9 +991,7 @@ describe('API Client Integration Tests', { concurrent: false }, () => {
createdResources.dashboards.push(dashboardId)
// Get
const getResult = await client
.dashboards({ projectId: testProjectId })
.get({ dashboardId })
const getResult = await client.dashboards({ projectId: testProjectId }).get({ dashboardId })
expect(getResult.success).toBe(true)
if (getResult.success) {
@@ -1043,9 +1027,7 @@ describe('API Client Integration Tests', { concurrent: false }, () => {
status: 'active',
}
const result = await client
.query({ projectId: testProjectId })
.execute({ queryBody: errorQuery })
const result = await client.query({ projectId: testProjectId }).execute({ queryBody: errorQuery })
if (!result.success) {
console.error('Failed to execute error query:', (result as any).error)
@@ -1082,9 +1064,7 @@ describe('API Client Integration Tests', { concurrent: false }, () => {
},
}
const result = await client
.query({ projectId: testProjectId })
.execute({ queryBody: trendsQuery })
const result = await client.query({ projectId: testProjectId }).execute({ queryBody: trendsQuery })
expect(result.success).toBe(true)
@@ -1126,7 +1106,7 @@ describe('API Client Integration Tests', { concurrent: false }, () => {
description?: string
}>
} = {}
) => {
): Promise<any> => {
const timestamp = Date.now()
const createResult = await client.experiments({ projectId: testProjectId }).create({
name: options.name || `Test Experiment ${timestamp}`,
@@ -1160,9 +1140,7 @@ describe('API Client Integration Tests', { concurrent: false }, () => {
return createResult.data
}
throw new Error(
`Failed to create test experiment: ${(createResult as any).error?.message}`
)
throw new Error(`Failed to create test experiment: ${(createResult as any).error?.message}`)
}
it.skip('should list experiments', async () => {
@@ -1344,19 +1322,17 @@ describe('API Client Integration Tests', { concurrent: false }, () => {
})
// Try to get exposures (may not have data immediately)
const exposureResult = await client
.experiments({ projectId: testProjectId })
.getExposures({
experimentId: experiment.id,
refresh: true,
})
const exposureResult = await client.experiments({ projectId: testProjectId }).getExposures({
experimentId: experiment.id,
refresh: true,
})
// Should succeed even if no exposure data yet
expect(exposureResult.success).toBe(true)
if (exposureResult.success) {
expect(exposureResult.data).toHaveProperty('exposures')
expect(exposureResult.data.exposures).toBeDefined()
expect(exposureResult.data.exposures).toBeTruthy()
}
})
@@ -1366,12 +1342,10 @@ describe('API Client Integration Tests', { concurrent: false }, () => {
draft: true,
})
const exposureResult = await client
.experiments({ projectId: testProjectId })
.getExposures({
experimentId: experiment.id,
refresh: false,
})
const exposureResult = await client.experiments({ projectId: testProjectId }).getExposures({
experimentId: experiment.id,
refresh: false,
})
expect(exposureResult.success).toBe(false)
expect((exposureResult as any).error.message).toContain('has not started yet')
@@ -1393,12 +1367,10 @@ describe('API Client Integration Tests', { concurrent: false }, () => {
})
// Try to get metric results
const metricsResult = await client
.experiments({ projectId: testProjectId })
.getMetricResults({
experimentId: experiment.id,
refresh: true,
})
const metricsResult = await client.experiments({ projectId: testProjectId }).getMetricResults({
experimentId: experiment.id,
refresh: true,
})
expect(metricsResult.success).toBe(true)
@@ -1417,12 +1389,10 @@ describe('API Client Integration Tests', { concurrent: false }, () => {
draft: true,
})
const metricsResult = await client
.experiments({ projectId: testProjectId })
.getMetricResults({
experimentId: experiment.id,
refresh: false,
})
const metricsResult = await client.experiments({ projectId: testProjectId }).getMetricResults({
experimentId: experiment.id,
refresh: false,
})
expect(metricsResult.success).toBe(false)
expect((metricsResult as any).error.message).toContain('has not started yet')
@@ -1466,12 +1436,7 @@ describe('API Client Integration Tests', { concurrent: false }, () => {
if (updateResult.success) {
expect(updateResult.data.parameters?.feature_flag_variants).toHaveLength(4)
const variants = updateResult.data.parameters?.feature_flag_variants || []
expect(variants.map((v) => v.key)).toEqual([
'control',
'variant_a',
'variant_b',
'variant_c',
])
expect(variants.map((v) => v.key)).toEqual(['control', 'variant_a', 'variant_b', 'variant_c'])
}
})
@@ -1563,9 +1528,7 @@ describe('API Client Integration Tests', { concurrent: false }, () => {
createdResources.experiments.push(experimentId)
// READ
const getResult = await client
.experiments({ projectId: testProjectId })
.get({ experimentId })
const getResult = await client.experiments({ projectId: testProjectId }).get({ experimentId })
expect(getResult.success).toBe(true)
@@ -1592,9 +1555,7 @@ describe('API Client Integration Tests', { concurrent: false }, () => {
}
// DELETE
const deleteResult = await client
.experiments({ projectId: testProjectId })
.delete({ experimentId })
const deleteResult = await client.experiments({ projectId: testProjectId }).delete({ experimentId })
expect(deleteResult.success).toBe(true)
if (deleteResult.success) {
@@ -1603,9 +1564,7 @@ describe('API Client Integration Tests', { concurrent: false }, () => {
}
// Verify deletion worked
const getAfterDeleteResult = await client
.experiments({ projectId: testProjectId })
.get({ experimentId })
const getAfterDeleteResult = await client.experiments({ projectId: testProjectId }).get({ experimentId })
expect(getAfterDeleteResult.success).toBe(false)

View File

@@ -1,14 +1,15 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { describe, expect, it, beforeEach } from 'vitest'
import { strFromU8, unzipSync } from 'fflate'
import { beforeEach, describe, expect, it } from 'vitest'
import { registerIntegrationResources } from '@/resources/integration'
import { ResourceUri } from '@/resources/integration/index'
import {
getSupportedFrameworks,
EXAMPLES_MARKDOWN_URL,
FRAMEWORK_MARKDOWN_FILES,
getSupportedFrameworks,
} from '@/resources/integration/framework-mappings'
import { ResourceUri } from '@/resources/integration/index'
import type { Context } from '@/tools/types'
import { unzipSync, strFromU8 } from 'fflate'
const createMockContext = (): Context => ({
api: {} as any,
@@ -41,7 +42,7 @@ describe('Example Resources - Markdown Artifact Loading', () => {
const uint8Array = new Uint8Array(arrayBuffer)
const unzipped = unzipSync(uint8Array)
expect(unzipped).toBeDefined()
expect(unzipped).toBeTruthy()
expect(Object.keys(unzipped).length).toBeGreaterThan(0)
}, 30000) // 30 second timeout for network request
@@ -54,10 +55,9 @@ describe('Example Resources - Markdown Artifact Loading', () => {
const frameworks = getSupportedFrameworks()
for (const framework of frameworks) {
const filename =
FRAMEWORK_MARKDOWN_FILES[framework as keyof typeof FRAMEWORK_MARKDOWN_FILES]
const filename = FRAMEWORK_MARKDOWN_FILES[framework as keyof typeof FRAMEWORK_MARKDOWN_FILES]
const fileData = unzipped[filename]
expect(fileData).toBeDefined()
expect(fileData).toBeTruthy()
expect(fileData!.length).toBeGreaterThan(0)
}
}, 30000)
@@ -71,10 +71,9 @@ describe('Example Resources - Markdown Artifact Loading', () => {
const frameworks = getSupportedFrameworks()
for (const framework of frameworks) {
const filename =
FRAMEWORK_MARKDOWN_FILES[framework as keyof typeof FRAMEWORK_MARKDOWN_FILES]
const filename = FRAMEWORK_MARKDOWN_FILES[framework as keyof typeof FRAMEWORK_MARKDOWN_FILES]
const markdownData = unzipped[filename]
expect(markdownData).toBeDefined()
expect(markdownData).toBeTruthy()
const markdown = strFromU8(markdownData!)
expect(markdown).toBeTruthy()
@@ -95,17 +94,15 @@ describe('Example Resources - Markdown Artifact Loading', () => {
.startsWith(ResourceUri.EXAMPLE_PROJECT_FRAMEWORK.replace('{framework}', ''))
) as any
expect(exampleTemplate).toBeDefined()
expect(exampleTemplate).toBeTruthy()
const frameworks = getSupportedFrameworks()
for (const framework of frameworks) {
const uri = new URL(
ResourceUri.EXAMPLE_PROJECT_FRAMEWORK.replace('{framework}', framework)
)
const uri = new URL(ResourceUri.EXAMPLE_PROJECT_FRAMEWORK.replace('{framework}', framework))
const result = await exampleTemplate.readCallback(uri, { framework })
expect(result).toBeDefined()
expect(result).toBeTruthy()
expect(result.contents).toHaveLength(1)
expect(result.contents[0].text).toBeTruthy()
expect(result.contents[0].text).toContain('# PostHog')
@@ -122,13 +119,11 @@ describe('Example Resources - Markdown Artifact Loading', () => {
) as any
const invalidFramework = 'invalid-framework-xyz'
const uri = new URL(
ResourceUri.EXAMPLE_PROJECT_FRAMEWORK.replace('{framework}', invalidFramework)
)
const uri = new URL(ResourceUri.EXAMPLE_PROJECT_FRAMEWORK.replace('{framework}', invalidFramework))
await expect(
exampleTemplate.readCallback(uri, { framework: invalidFramework })
).rejects.toThrow(/is not supported yet/)
await expect(exampleTemplate.readCallback(uri, { framework: invalidFramework })).rejects.toThrow(
/is not supported yet/
)
}, 30000)
it('should cache the markdown ZIP and reuse it', async () => {
@@ -142,16 +137,12 @@ describe('Example Resources - Markdown Artifact Loading', () => {
const framework = 'nextjs-app-router'
// First call should fetch and cache
const uri1 = new URL(
ResourceUri.EXAMPLE_PROJECT_FRAMEWORK.replace('{framework}', framework)
)
const uri1 = new URL(ResourceUri.EXAMPLE_PROJECT_FRAMEWORK.replace('{framework}', framework))
const result1 = await exampleTemplate.readCallback(uri1, { framework })
expect(result1.contents[0].text).toBeTruthy()
// Second call should use cache (we can't directly verify caching, but we verify it works)
const uri2 = new URL(
ResourceUri.EXAMPLE_PROJECT_FRAMEWORK.replace('{framework}', framework)
)
const uri2 = new URL(ResourceUri.EXAMPLE_PROJECT_FRAMEWORK.replace('{framework}', framework))
const result2 = await exampleTemplate.readCallback(uri2, { framework })
expect(result2.contents[0].text).toBeTruthy()

View File

@@ -1,7 +1,8 @@
import { describe, expect, it } from 'vitest'
import { SessionManager } from '@/lib/utils/SessionManager'
import { getToolsFromContext } from '@/tools'
import type { Context } from '@/tools/types'
import { describe, expect, it } from 'vitest'
const createMockContext = (): Context => ({
api: {} as any,

View File

@@ -1,10 +1,11 @@
import { expect } from 'vitest'
import { ApiClient } from '@/api/client'
import { SessionManager } from '@/lib/utils/SessionManager'
import { StateManager } from '@/lib/utils/StateManager'
import { MemoryCache } from '@/lib/utils/cache/MemoryCache'
import type { InsightQuery } from '@/schema/query'
import type { Context } from '@/tools/types'
import { expect } from 'vitest'
export const API_BASE_URL = process.env.TEST_POSTHOG_API_BASE_URL || 'http://localhost:8010'
export const API_TOKEN = process.env.TEST_POSTHOG_PERSONAL_API_KEY
@@ -18,7 +19,7 @@ export interface CreatedResources {
surveys: string[]
}
export function validateEnvironmentVariables() {
export function validateEnvironmentVariables(): void {
if (!API_TOKEN) {
throw new Error('TEST_POSTHOG_PERSONAL_API_KEY environment variable is required')
}
@@ -54,7 +55,7 @@ export function createTestContext(client: ApiClient): Context {
return context
}
export async function setActiveProjectAndOrg(context: Context, projectId: string, orgId: string) {
export async function setActiveProjectAndOrg(context: Context, projectId: string, orgId: string): Promise<void> {
const cache = context.cache
await cache.set('projectId', projectId)
await cache.set('orgId', orgId)
@@ -64,7 +65,7 @@ export async function cleanupResources(
client: ApiClient,
projectId: string,
resources: CreatedResources
) {
): Promise<void> {
for (const flagId of resources.featureFlags) {
try {
await client.featureFlags({ projectId }).delete({ flagId })
@@ -102,8 +103,8 @@ export async function cleanupResources(
resources.surveys = []
}
export function parseToolResponse(result: any) {
expect(result.content).toBeDefined()
export function parseToolResponse(result: any): any {
expect(result.content).toBeTruthy()
expect(result.content[0].type).toBe('text')
return JSON.parse(result.content[0].text)
}
@@ -143,12 +144,7 @@ export const SAMPLE_HOGQL_QUERIES: Record<SampleHogQLQuery, InsightQuery> = {
},
}
type SampleTrendQuery =
| 'basicPageviews'
| 'uniqueUsers'
| 'multipleEvents'
| 'withBreakdown'
| 'withPropertyFilter'
type SampleTrendQuery = 'basicPageviews' | 'uniqueUsers' | 'multipleEvents' | 'withBreakdown' | 'withPropertyFilter'
export const SAMPLE_TREND_QUERIES: Record<SampleTrendQuery, InsightQuery> = {
basicPageviews: {

View File

@@ -1,21 +1,22 @@
import { describe, it, expect, beforeAll, afterEach } from 'vitest'
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
import {
validateEnvironmentVariables,
type CreatedResources,
TEST_ORG_ID,
TEST_PROJECT_ID,
cleanupResources,
createTestClient,
createTestContext,
setActiveProjectAndOrg,
cleanupResources,
parseToolResponse,
generateUniqueKey,
TEST_PROJECT_ID,
TEST_ORG_ID,
type CreatedResources,
parseToolResponse,
setActiveProjectAndOrg,
validateEnvironmentVariables,
} from '@/shared/test-utils'
import createDashboardTool from '@/tools/dashboards/create'
import updateDashboardTool from '@/tools/dashboards/update'
import deleteDashboardTool from '@/tools/dashboards/delete'
import getAllDashboardsTool from '@/tools/dashboards/getAll'
import getDashboardTool from '@/tools/dashboards/get'
import getAllDashboardsTool from '@/tools/dashboards/getAll'
import updateDashboardTool from '@/tools/dashboards/update'
import type { Context } from '@/tools/types'
describe('Dashboards', { concurrent: false }, () => {
@@ -53,7 +54,7 @@ describe('Dashboards', { concurrent: false }, () => {
const result = await createTool.handler(context, params)
const dashboardData = parseToolResponse(result)
expect(dashboardData.id).toBeDefined()
expect(dashboardData.id).toBeTruthy()
expect(dashboardData.name).toBe(params.data.name)
expect(dashboardData.url).toContain('/dashboard/')
@@ -73,7 +74,7 @@ describe('Dashboards', { concurrent: false }, () => {
const result = await createTool.handler(context, params)
const dashboardData = parseToolResponse(result)
expect(dashboardData.id).toBeDefined()
expect(dashboardData.id).toBeTruthy()
expect(dashboardData.name).toBe(params.data.name)
createdResources.dashboards.push(dashboardData.id)

View File

@@ -1,13 +1,14 @@
import { describe, it, expect, beforeAll, afterEach } from 'vitest'
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
import {
validateEnvironmentVariables,
type CreatedResources,
TEST_ORG_ID,
TEST_PROJECT_ID,
cleanupResources,
createTestClient,
createTestContext,
setActiveProjectAndOrg,
cleanupResources,
TEST_PROJECT_ID,
TEST_ORG_ID,
type CreatedResources,
validateEnvironmentVariables,
} from '@/shared/test-utils'
import searchDocsTool from '@/tools/documentation/searchDocs'
import type { Context } from '@/tools/types'
@@ -54,7 +55,7 @@ describe('Documentation', { concurrent: false }, () => {
})
expect(result.content[0].type).toBe('text')
expect(result.content[0].text).toBeDefined()
expect(result.content[0].text).toBeTruthy()
expect(result.content[0].text.length).toBeGreaterThan(0)
})
@@ -64,7 +65,7 @@ describe('Documentation', { concurrent: false }, () => {
})
expect(result.content[0].type).toBe('text')
expect(result.content[0].text).toBeDefined()
expect(result.content[0].text).toBeTruthy()
expect(result.content[0].text.length).toBeGreaterThan(0)
})
@@ -74,7 +75,7 @@ describe('Documentation', { concurrent: false }, () => {
})
expect(result.content[0].type).toBe('text')
expect(result.content[0].text).toBeDefined()
expect(result.content[0].text).toBeTruthy()
})
})
@@ -86,7 +87,7 @@ describe('Documentation', { concurrent: false }, () => {
await searchTool.handler(context, { query: '' })
expect.fail('Should have thrown validation error')
} catch (error) {
expect(error).toBeDefined()
expect(error).toBeTruthy()
}
})
})

View File

@@ -1,19 +1,20 @@
import { describe, it, expect, beforeAll, afterEach } from 'vitest'
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
import { OrderByErrors, OrderDirectionErrors, StatusErrors } from '@/schema/errors'
import {
validateEnvironmentVariables,
type CreatedResources,
TEST_ORG_ID,
TEST_PROJECT_ID,
cleanupResources,
createTestClient,
createTestContext,
setActiveProjectAndOrg,
cleanupResources,
TEST_PROJECT_ID,
TEST_ORG_ID,
type CreatedResources,
parseToolResponse,
setActiveProjectAndOrg,
validateEnvironmentVariables,
} from '@/shared/test-utils'
import listErrorsTool from '@/tools/errorTracking/listErrors'
import errorDetailsTool from '@/tools/errorTracking/errorDetails'
import listErrorsTool from '@/tools/errorTracking/listErrors'
import type { Context } from '@/tools/types'
import { OrderByErrors, OrderDirectionErrors, StatusErrors } from '@/schema/errors'
describe('Error Tracking', { concurrent: false }, () => {
let context: Context

View File

@@ -1,20 +1,21 @@
import { describe, it, expect, beforeAll, afterEach } from 'vitest'
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
import {
validateEnvironmentVariables,
type CreatedResources,
TEST_ORG_ID,
TEST_PROJECT_ID,
cleanupResources,
createTestClient,
createTestContext,
setActiveProjectAndOrg,
cleanupResources,
TEST_PROJECT_ID,
TEST_ORG_ID,
type CreatedResources,
parseToolResponse,
generateUniqueKey,
parseToolResponse,
setActiveProjectAndOrg,
validateEnvironmentVariables,
} from '@/shared/test-utils'
import createExperimentTool from '@/tools/experiments/create'
import deleteExperimentTool from '@/tools/experiments/delete'
import getAllExperimentsTool from '@/tools/experiments/getAll'
import getExperimentTool from '@/tools/experiments/get'
import getAllExperimentsTool from '@/tools/experiments/getAll'
import getExperimentResultsTool from '@/tools/experiments/getResults'
import updateExperimentTool from '@/tools/experiments/update'
import type { Context } from '@/tools/types'
@@ -30,7 +31,7 @@ describe('Experiments', { concurrent: false }, () => {
const createdExperiments: number[] = []
// Helper function to track created experiments and their feature flags
const trackExperiment = (experiment: any) => {
const trackExperiment = (experiment: any): void => {
if (experiment.id) {
createdExperiments.push(experiment.id)
}
@@ -80,7 +81,7 @@ describe('Experiments', { concurrent: false }, () => {
const result = await createTool.handler(context, params as any)
const experiment = parseToolResponse(result)
expect(experiment.id).toBeDefined()
expect(experiment.id).toBeTruthy()
expect(experiment.name).toBe(params.name)
expect(experiment.feature_flag_key).toBe(params.feature_flag_key)
expect(experiment.start_date).toBeNull() // Draft experiments have no start date
@@ -103,7 +104,7 @@ describe('Experiments', { concurrent: false }, () => {
const result = await createTool.handler(context, params as any)
const experiment = parseToolResponse(result)
expect(experiment.id).toBeDefined()
expect(experiment.id).toBeTruthy()
expect(experiment.name).toBe(params.name)
expect(experiment.feature_flag_key).toBe(params.feature_flag_key)
@@ -127,7 +128,7 @@ describe('Experiments', { concurrent: false }, () => {
const result = await createTool.handler(context, params as any)
const experiment = parseToolResponse(result)
expect(experiment.id).toBeDefined()
expect(experiment.id).toBeTruthy()
expect(experiment.parameters?.feature_flag_variants).toHaveLength(3)
expect(experiment.parameters?.feature_flag_variants?.[0]?.key).toBe('control')
expect(experiment.parameters?.feature_flag_variants?.[0]?.rollout_percentage).toBe(33)
@@ -156,7 +157,7 @@ describe('Experiments', { concurrent: false }, () => {
const result = await createTool.handler(context, params as any)
const experiment = parseToolResponse(result)
expect(experiment.id).toBeDefined()
expect(experiment.id).toBeTruthy()
expect(experiment.metrics).toHaveLength(1)
trackExperiment(experiment)
@@ -183,7 +184,7 @@ describe('Experiments', { concurrent: false }, () => {
const result = await createTool.handler(context, params as any)
const experiment = parseToolResponse(result)
expect(experiment.id).toBeDefined()
expect(experiment.id).toBeTruthy()
expect(experiment.metrics).toHaveLength(1)
trackExperiment(experiment)
@@ -209,7 +210,7 @@ describe('Experiments', { concurrent: false }, () => {
const result = await createTool.handler(context, params as any)
const experiment = parseToolResponse(result)
expect(experiment.id).toBeDefined()
expect(experiment.id).toBeTruthy()
expect(experiment.metrics).toHaveLength(1)
trackExperiment(experiment)
@@ -252,7 +253,7 @@ describe('Experiments', { concurrent: false }, () => {
const result = await createTool.handler(context, params as any)
const experiment = parseToolResponse(result)
expect(experiment.id).toBeDefined()
expect(experiment.id).toBeTruthy()
expect(experiment.metrics).toHaveLength(2)
expect(experiment.metrics_secondary).toHaveLength(2)
@@ -272,7 +273,7 @@ describe('Experiments', { concurrent: false }, () => {
const result = await createTool.handler(context, params as any)
const experiment = parseToolResponse(result)
expect(experiment.id).toBeDefined()
expect(experiment.id).toBeTruthy()
trackExperiment(experiment)
})
@@ -290,7 +291,7 @@ describe('Experiments', { concurrent: false }, () => {
const result = await createTool.handler(context, params as any)
const experiment = parseToolResponse(result)
expect(experiment.id).toBeDefined()
expect(experiment.id).toBeTruthy()
trackExperiment(experiment)
})
@@ -306,7 +307,7 @@ describe('Experiments', { concurrent: false }, () => {
const result = await createTool.handler(context, params as any)
const experiment = parseToolResponse(result)
expect(experiment.id).toBeDefined()
expect(experiment.id).toBeTruthy()
trackExperiment(experiment)
})
})
@@ -343,7 +344,7 @@ describe('Experiments', { concurrent: false }, () => {
// Verify our test experiments are in the list
for (const testExp of testExperiments) {
const found = allExperiments.find((e: any) => e.id === testExp.id)
expect(found).toBeDefined()
expect(found).toBeTruthy()
}
})
@@ -391,9 +392,7 @@ describe('Experiments', { concurrent: false }, () => {
it('should handle non-existent experiment ID', async () => {
const nonExistentId = 999999
await expect(
getTool.handler(context, { experimentId: nonExistentId })
).rejects.toThrow()
await expect(getTool.handler(context, { experimentId: nonExistentId })).rejects.toThrow()
})
})
@@ -521,7 +520,7 @@ describe('Experiments', { concurrent: false }, () => {
trackExperiment(createdExperiment)
// Verify creation
expect(createdExperiment.id).toBeDefined()
expect(createdExperiment.id).toBeTruthy()
expect(createdExperiment.name).toBe(createParams.name)
expect(createdExperiment.parameters?.feature_flag_variants).toHaveLength(2)
expect(createdExperiment.metrics).toHaveLength(2)
@@ -538,7 +537,7 @@ describe('Experiments', { concurrent: false }, () => {
const listResult = await getAllTool.handler(context, {})
const allExperiments = parseToolResponse(listResult)
const found = allExperiments.find((e: any) => e.id === createdExperiment.id)
expect(found).toBeDefined()
expect(found).toBeTruthy()
})
it('should create experiment with complex funnel metrics', async () => {
@@ -579,7 +578,7 @@ describe('Experiments', { concurrent: false }, () => {
const result = await createTool.handler(context, params as any)
const experiment = parseToolResponse(result)
expect(experiment.id).toBeDefined()
expect(experiment.id).toBeTruthy()
expect(experiment.metrics).toHaveLength(1)
expect(experiment.metrics_secondary).toHaveLength(1)
@@ -603,7 +602,7 @@ describe('Experiments', { concurrent: false }, () => {
const result = await createTool.handler(context, params as any)
const experiment = parseToolResponse(result)
expect(experiment.id).toBeDefined()
expect(experiment.id).toBeTruthy()
trackExperiment(experiment)
})
@@ -621,7 +620,7 @@ describe('Experiments', { concurrent: false }, () => {
const result = await createTool.handler(context, params as any)
const experiment = parseToolResponse(result)
expect(experiment.id).toBeDefined()
expect(experiment.id).toBeTruthy()
trackExperiment(experiment)
})
@@ -644,7 +643,7 @@ describe('Experiments', { concurrent: false }, () => {
const result = await createTool.handler(context, params as any)
const experiment = parseToolResponse(result)
expect(experiment.id).toBeDefined()
expect(experiment.id).toBeTruthy()
expect(experiment.metrics || []).toHaveLength(0)
expect(experiment.metrics_secondary || []).toHaveLength(0)
@@ -687,7 +686,7 @@ describe('Experiments', { concurrent: false }, () => {
trackExperiment(experiment)
} catch (error) {
// Expected for invalid configuration
expect(error).toBeDefined()
expect(error).toBeTruthy()
}
})
@@ -710,7 +709,7 @@ describe('Experiments', { concurrent: false }, () => {
const result = await createTool.handler(context, params as any)
const experiment = parseToolResponse(result)
expect(experiment.id).toBeDefined()
expect(experiment.id).toBeTruthy()
expect(experiment.metrics).toHaveLength(1)
trackExperiment(experiment)
@@ -736,7 +735,7 @@ describe('Experiments', { concurrent: false }, () => {
const result = await createTool.handler(context, params as any)
const experiment = parseToolResponse(result)
expect(experiment.id).toBeDefined()
expect(experiment.id).toBeTruthy()
trackExperiment(experiment)
})
@@ -754,11 +753,11 @@ describe('Experiments', { concurrent: false }, () => {
try {
const result = await createTool.handler(context, params as any)
const experiment = parseToolResponse(result)
expect(experiment.id).toBeDefined()
expect(experiment.id).toBeTruthy()
trackExperiment(experiment)
} catch (error) {
// Some APIs might reject very long names
expect(error).toBeDefined()
expect(error).toBeTruthy()
}
})
})
@@ -779,7 +778,7 @@ describe('Experiments', { concurrent: false }, () => {
const createResult = await createTool.handler(context, createParams as any)
const experiment = parseToolResponse(createResult)
expect(experiment.id).toBeDefined()
expect(experiment.id).toBeTruthy()
trackExperiment(experiment)
// Delete the experiment
@@ -811,7 +810,7 @@ describe('Experiments', { concurrent: false }, () => {
await deleteTool.handler(context, deleteParams)
expect.fail('Should have thrown an error for invalid experiment ID')
} catch (error) {
expect(error).toBeDefined()
expect(error).toBeTruthy()
expect((error as Error).message).toContain('Failed to delete experiment')
}
})
@@ -828,7 +827,7 @@ describe('Experiments', { concurrent: false }, () => {
const createResult = await createTool.handler(context, createParams as any)
const experiment = parseToolResponse(createResult)
expect(experiment.id).toBeDefined()
expect(experiment.id).toBeTruthy()
trackExperiment(experiment)
// Delete the experiment twice
@@ -844,7 +843,7 @@ describe('Experiments', { concurrent: false }, () => {
await deleteTool.handler(context, deleteParams)
expect.fail('Should have thrown an error for already deleted experiment')
} catch (error) {
expect(error).toBeDefined()
expect(error).toBeTruthy()
expect((error as Error).message).toContain('Failed to delete experiment')
expect((error as Error).message).toContain('404')
}
@@ -866,7 +865,7 @@ describe('Experiments', { concurrent: false }, () => {
await deleteTool.handler(context, {} as any)
expect.fail('Should have thrown validation error for missing experimentId')
} catch (error) {
expect(error).toBeDefined()
expect(error).toBeTruthy()
}
})
})
@@ -888,7 +887,7 @@ describe('Experiments', { concurrent: false }, () => {
const createResult = await createTool.handler(context, createParams as any)
const experiment = parseToolResponse(createResult)
expect(experiment.id).toBeDefined()
expect(experiment.id).toBeTruthy()
trackExperiment(experiment)
// Update basic fields
@@ -937,7 +936,7 @@ describe('Experiments', { concurrent: false }, () => {
const updateResult = await updateTool.handler(context, launchParams)
const launchedExperiment = parseToolResponse(updateResult)
expect(launchedExperiment.start_date).toBeDefined() // Running experiments have start date
expect(launchedExperiment.start_date).toBeTruthy() // Running experiments have start date
expect(launchedExperiment.end_date).toBeNull() // But no end date yet
trackExperiment(experiment)
@@ -970,7 +969,7 @@ describe('Experiments', { concurrent: false }, () => {
const updateResult = await updateTool.handler(context, stopParams)
const stoppedExperiment = parseToolResponse(updateResult)
expect(stoppedExperiment.end_date).toBeDefined()
expect(stoppedExperiment.end_date).toBeTruthy()
// Note: API may not set conclusion field automatically, it depends on the backend implementation
// The important thing is that end_date is set, indicating the experiment is stopped
@@ -1018,7 +1017,7 @@ describe('Experiments', { concurrent: false }, () => {
expect(restartedExperiment.end_date).toBeNull()
expect(restartedExperiment.conclusion).toBeNull()
expect(restartedExperiment.conclusion_comment).toBeNull()
expect(restartedExperiment.start_date).toBeDefined() // Restarted experiments have start date
expect(restartedExperiment.start_date).toBeTruthy() // Restarted experiments have start date
expect(restartedExperiment.end_date).toBeNull() // But no end date
trackExperiment(experiment)
@@ -1157,7 +1156,7 @@ describe('Experiments', { concurrent: false }, () => {
await updateTool.handler(context, updateParams)
expect.fail('Should have thrown an error for invalid experiment ID')
} catch (error) {
expect(error).toBeDefined()
expect(error).toBeTruthy()
expect((error as Error).message).toContain('Failed to update experiment')
}
})
@@ -1167,7 +1166,7 @@ describe('Experiments', { concurrent: false }, () => {
await updateTool.handler(context, { data: { name: 'Test' } } as any)
expect.fail('Should have thrown validation error for missing experimentId')
} catch (error) {
expect(error).toBeDefined()
expect(error).toBeTruthy()
}
})
@@ -1239,12 +1238,12 @@ describe('Experiments', { concurrent: false }, () => {
const experiment = parseToolResponse(result)
// Check actual date fields instead of computed status
expect(experiment.start_date).toBeDefined() // Should have start date if launched
expect(experiment.start_date).toBeTruthy() // Should have start date if launched
trackExperiment(experiment)
} catch (error) {
// Some environments might not allow immediate launch
expect(error).toBeDefined()
expect(error).toBeTruthy()
}
})
})

View File

@@ -1,21 +1,22 @@
import { describe, it, expect, beforeAll, afterEach } from 'vitest'
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
import {
validateEnvironmentVariables,
type CreatedResources,
TEST_ORG_ID,
TEST_PROJECT_ID,
cleanupResources,
createTestClient,
createTestContext,
setActiveProjectAndOrg,
cleanupResources,
TEST_PROJECT_ID,
TEST_ORG_ID,
type CreatedResources,
parseToolResponse,
generateUniqueKey,
parseToolResponse,
setActiveProjectAndOrg,
validateEnvironmentVariables,
} from '@/shared/test-utils'
import createFeatureFlagTool from '@/tools/featureFlags/create'
import updateFeatureFlagTool from '@/tools/featureFlags/update'
import deleteFeatureFlagTool from '@/tools/featureFlags/delete'
import getAllFeatureFlagsTool from '@/tools/featureFlags/getAll'
import getFeatureFlagDefinitionTool from '@/tools/featureFlags/getDefinition'
import updateFeatureFlagTool from '@/tools/featureFlags/update'
import type { Context } from '@/tools/types'
describe('Feature Flags', { concurrent: false }, () => {
@@ -53,7 +54,7 @@ describe('Feature Flags', { concurrent: false }, () => {
const result = await createTool.handler(context, params)
const flagData = parseToolResponse(result)
expect(flagData.id).toBeDefined()
expect(flagData.id).toBeTruthy()
expect(flagData.key).toBe(params.key)
expect(flagData.name).toBe(params.name)
expect(flagData.active).toBe(params.active)
@@ -75,7 +76,7 @@ describe('Feature Flags', { concurrent: false }, () => {
const result = await createTool.handler(context, params)
const flagData = parseToolResponse(result)
expect(flagData.id).toBeDefined()
expect(flagData.id).toBeTruthy()
expect(flagData.key).toBe(params.key)
expect(flagData.name).toBe(params.name)
@@ -109,7 +110,7 @@ describe('Feature Flags', { concurrent: false }, () => {
const result = await createTool.handler(context, params)
const flagData = parseToolResponse(result)
expect(flagData.id).toBeDefined()
expect(flagData.id).toBeTruthy()
expect(flagData.key).toBe(params.key)
expect(flagData.name).toBe(params.name)
@@ -186,7 +187,7 @@ describe('Feature Flags', { concurrent: false }, () => {
const updateResult = await updateTool.handler(context, updateParams)
const updatedFlag = parseToolResponse(updateResult)
expect(updatedFlag.id).toBeDefined()
expect(updatedFlag.id).toBeTruthy()
expect(updatedFlag.key).toBe(createParams.key)
})
})
@@ -223,7 +224,7 @@ describe('Feature Flags', { concurrent: false }, () => {
// Verify our test flags are in the list
for (const testFlag of testFlags) {
const found = allFlags.find((f: any) => f.id === testFlag.id)
expect(found).toBeDefined()
expect(found).toBeTruthy()
expect(found.key).toBe(testFlag.key)
}
})
@@ -276,9 +277,7 @@ describe('Feature Flags', { concurrent: false }, () => {
const result = await getDefinitionTool.handler(context, { flagKey: nonExistentKey })
expect(result.content[0].text).toBe(
`Error: Flag with key "${nonExistentKey}" not found.`
)
expect(result.content[0].text).toBe(`Error: Flag with key "${nonExistentKey}" not found.`)
})
})
@@ -301,7 +300,7 @@ describe('Feature Flags', { concurrent: false }, () => {
// Delete the flag
const deleteResult = await deleteTool.handler(context, { flagKey: createParams.key })
expect(deleteResult.content).toBeDefined()
expect(deleteResult.content).toBeTruthy()
expect(deleteResult.content[0].type).toBe('text')
const deleteResponse = parseToolResponse(deleteResult)
expect(deleteResponse.success).toBe(true)
@@ -312,9 +311,7 @@ describe('Feature Flags', { concurrent: false }, () => {
const getResult = await getDefinitionTool.handler(context, {
flagKey: createParams.key,
})
expect(getResult.content[0].text).toBe(
`Error: Flag with key "${createParams.key}" not found.`
)
expect(getResult.content[0].text).toBe(`Error: Flag with key "${createParams.key}" not found.`)
})
it('should handle deletion of non-existent flag', async () => {

View File

@@ -1,3 +1,5 @@
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
import {
type CreatedResources,
SAMPLE_HOGQL_QUERIES,
@@ -18,7 +20,6 @@ import getAllInsightsTool from '@/tools/insights/getAll'
import queryInsightTool from '@/tools/insights/query'
import updateInsightTool from '@/tools/insights/update'
import type { Context } from '@/tools/types'
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
describe('Insights', { concurrent: false }, () => {
// All tests run sequentially to avoid conflicts with shared PostHog project
@@ -57,7 +58,7 @@ describe('Insights', { concurrent: false }, () => {
const result = await createTool.handler(context, params)
const insightData = parseToolResponse(result)
expect(insightData.id).toBeDefined()
expect(insightData.id).toBeTruthy()
expect(insightData.name).toBe(params.data.name)
expect(insightData.url).toContain('/insights/')
@@ -77,7 +78,7 @@ describe('Insights', { concurrent: false }, () => {
const result = await createTool.handler(context, params)
const insightData = parseToolResponse(result)
expect(insightData.id).toBeDefined()
expect(insightData.id).toBeTruthy()
expect(insightData.name).toBe(params.data.name)
createdResources.insights.push(insightData.id)
@@ -97,7 +98,7 @@ describe('Insights', { concurrent: false }, () => {
const result = await createTool.handler(context, params)
const insightData = parseToolResponse(result)
expect(insightData.id).toBeDefined()
expect(insightData.id).toBeTruthy()
expect(insightData.name).toBe(params.data.name)
createdResources.insights.push(insightData.id)

Some files were not shown because too many files have changed in this diff Show More