├── .husky └── pre-commit ├── .vscode ├── extensions.json └── settings.json ├── .release-please-manifest.json ├── examples ├── langchain-js │ ├── .gitignore │ ├── .env.example │ ├── tsconfig.json │ ├── package.json │ ├── README.md │ └── src │ │ └── index.ts ├── ai-sdk │ ├── .env.example │ ├── tsconfig.json │ ├── package.json │ ├── README.md │ └── src │ │ └── index.ts └── langchain │ ├── .env.example │ ├── pyproject.toml │ ├── README.md │ └── posthog_agent_example.py ├── typescript ├── src │ ├── lib │ │ ├── types.ts │ │ ├── analytics.ts │ │ ├── errors.ts │ │ ├── constants.ts │ │ └── utils │ │ │ ├── cache │ │ │ ├── ScopedCache.ts │ │ │ ├── MemoryCache.ts │ │ │ └── DurableObjectCache.ts │ │ │ ├── helper-functions.ts │ │ │ ├── SessionManager.ts │ │ │ ├── api.ts │ │ │ └── StateManager.ts │ ├── index.ts │ ├── schema │ │ ├── orgs.ts │ │ ├── projects.ts │ │ ├── properties.ts │ │ ├── errors.ts │ │ ├── api.ts │ │ ├── dashboards.ts │ │ ├── insights.ts │ │ └── flags.ts │ ├── integrations │ │ ├── mcp │ │ │ └── utils │ │ │ │ ├── client.ts │ │ │ │ └── handleToolError.ts │ │ ├── ai-sdk │ │ │ └── index.ts │ │ └── langchain │ │ │ └── index.ts │ ├── tools │ │ ├── organizations │ │ │ ├── setActive.ts │ │ │ ├── getOrganizations.ts │ │ │ └── getDetails.ts │ │ ├── projects │ │ │ ├── setActive.ts │ │ │ ├── getProjects.ts │ │ │ ├── eventDefinitions.ts │ │ │ └── propertyDefinitions.ts │ │ ├── insights │ │ │ ├── utils.ts │ │ │ ├── delete.ts │ │ │ ├── get.ts │ │ │ ├── create.ts │ │ │ ├── getAll.ts │ │ │ ├── update.ts │ │ │ └── query.ts │ │ ├── experiments │ │ │ ├── getAll.ts │ │ │ ├── get.ts │ │ │ ├── delete.ts │ │ │ ├── create.ts │ │ │ ├── getResults.ts │ │ │ └── update.ts │ │ ├── featureFlags │ │ │ ├── getAll.ts │ │ │ ├── update.ts │ │ │ ├── create.ts │ │ │ ├── delete.ts │ │ │ └── getDefinition.ts │ │ ├── dashboards │ │ │ ├── delete.ts │ │ │ ├── get.ts │ │ │ ├── getAll.ts │ │ │ ├── create.ts │ │ │ ├── update.ts │ │ │ └── addInsight.ts │ │ ├── surveys │ │ │ ├── global-stats.ts │ │ │ ├── delete.ts │ │ │ ├── stats.ts │ │ │ ├── get.ts │ │ │ ├── getAll.ts │ │ │ ├── utils │ │ │ │ └── survey-utils.ts │ │ │ ├── create.ts │ │ │ └── update.ts │ │ ├── query │ │ │ ├── run.ts │ │ │ └── generateHogQLFromQuestion.ts │ │ ├── documentation │ │ │ └── searchDocs.ts │ │ ├── errorTracking │ │ │ ├── errorDetails.ts │ │ │ └── listErrors.ts │ │ ├── llmAnalytics │ │ │ └── getLLMCosts.ts │ │ ├── types.ts │ │ ├── toolDefinitions.ts │ │ └── index.ts │ ├── inkeepApi.ts │ └── api │ │ └── fetcher.ts ├── .env.test.example ├── tsup.config.ts ├── tests │ ├── setup.ts │ ├── README.md │ ├── unit │ │ ├── api-client.test.ts │ │ ├── url-routing.test.ts │ │ ├── tool-filtering.test.ts │ │ └── SessionManager.test.ts │ ├── integration │ │ └── feature-routing.test.ts │ └── tools │ │ ├── llmAnalytics.integration.test.ts │ │ ├── documentation.integration.test.ts │ │ ├── errorTracking.integration.test.ts │ │ ├── organizations.integration.test.ts │ │ └── dashboards.integration.test.ts ├── vitest.integration.config.mts ├── vitest.config.mts ├── tsconfig.json ├── wrangler.jsonc ├── scripts │ ├── update-openapi-client.ts │ └── generate-tool-schema.ts ├── package.json └── README.md ├── python ├── posthog_agent_toolkit │ ├── integrations │ │ ├── __init__.py │ │ └── langchain │ │ │ ├── __init__.py │ │ │ └── toolkit.py │ └── __init__.py ├── pytest.ini ├── .vscode │ └── settings.json ├── pyproject.toml ├── README.md └── scripts │ └── generate-pydantic-models.sh ├── .gitignore ├── .github └── workflows │ ├── release-please.yml │ ├── publish.yml │ ├── release-python.yml │ ├── ci-typescript.yml │ └── ci-python.yml ├── README.md ├── Dockerfile ├── biome.json ├── .release-please-config.json ├── LICENSE ├── package.json └── CLAUDE.md /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | pnpm run lint 2 | pnpm run format 3 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["biomejs.biome"] 3 | } 4 | -------------------------------------------------------------------------------- /.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript": "0.1.0", 3 | "python": "0.1.0" 4 | } 5 | -------------------------------------------------------------------------------- /examples/langchain-js/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .env 4 | .env.local 5 | *.log -------------------------------------------------------------------------------- /typescript/src/lib/types.ts: -------------------------------------------------------------------------------- 1 | type PrefixedString = `${T}:${string}`; 2 | -------------------------------------------------------------------------------- /python/posthog_agent_toolkit/integrations/__init__.py: -------------------------------------------------------------------------------- 1 | """PostHog Agent Toolkit Integrations.""" 2 | -------------------------------------------------------------------------------- /typescript/src/lib/analytics.ts: -------------------------------------------------------------------------------- 1 | export type AnalyticsEvent = "mcp tool call" | "mcp tool response"; 2 | -------------------------------------------------------------------------------- /typescript/src/lib/errors.ts: -------------------------------------------------------------------------------- 1 | export enum ErrorCode { 2 | INVALID_API_KEY = "INVALID_API_KEY", 3 | } 4 | -------------------------------------------------------------------------------- /python/posthog_agent_toolkit/__init__.py: -------------------------------------------------------------------------------- 1 | """PostHog Agent Toolkit for Python.""" 2 | 3 | __version__ = "0.1.0" 4 | -------------------------------------------------------------------------------- /examples/ai-sdk/.env.example: -------------------------------------------------------------------------------- 1 | POSTHOG_PERSONAL_API_KEY=phx_your_token_here 2 | POSTHOG_API_BASE_URL=https://us.posthog.com 3 | OPENAI_API_KEY=sk-your_openai_key_here 4 | -------------------------------------------------------------------------------- /examples/langchain-js/.env.example: -------------------------------------------------------------------------------- 1 | POSTHOG_PERSONAL_API_KEY=phx_your_token_here 2 | POSTHOG_API_BASE_URL=https://us.posthog.com 3 | OPENAI_API_KEY=sk-your_openai_key_here -------------------------------------------------------------------------------- /python/posthog_agent_toolkit/integrations/langchain/__init__.py: -------------------------------------------------------------------------------- 1 | """PostHog LangChain Integration.""" 2 | 3 | from .toolkit import PostHogAgentToolkit 4 | 5 | __all__ = ["PostHogAgentToolkit"] 6 | -------------------------------------------------------------------------------- /typescript/src/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | getToolsFromContext, 3 | PostHogAgentToolkit, 4 | type PostHogToolsOptions, 5 | } from "./tools"; 6 | export type { Context, State, Tool } from "./tools/types"; 7 | -------------------------------------------------------------------------------- /typescript/src/schema/orgs.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const OrganizationSchema = z.object({ 4 | id: z.string().uuid(), 5 | name: z.string(), 6 | }); 7 | 8 | export type Organization = z.infer; 9 | -------------------------------------------------------------------------------- /examples/langchain/.env.example: -------------------------------------------------------------------------------- 1 | # PostHog Configuration 2 | POSTHOG_PERSONAL_API_KEY=your_posthog_api_key_here 3 | POSTHOG_MCP_URL=https://mcp.posthog.com/mcp # Optional 4 | 5 | # OpenAI Configuration (for LangChain agent) 6 | OPENAI_API_KEY=your_openai_api_key_here -------------------------------------------------------------------------------- /typescript/.env.test.example: -------------------------------------------------------------------------------- 1 | # Test environment configuration 2 | TEST_POSTHOG_API_BASE_URL=http://localhost:8010 3 | TEST_POSTHOG_PERSONAL_API_KEY=your_posthog_api_token_here 4 | TEST_ORG_ID=your_test_organization_id_here 5 | TEST_PROJECT_ID=your_test_project_id_here -------------------------------------------------------------------------------- /typescript/src/schema/projects.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const ProjectSchema = z.object({ 4 | id: z.number(), 5 | name: z.string(), 6 | organization: z.string().uuid(), 7 | }); 8 | 9 | export type Project = z.infer; 10 | -------------------------------------------------------------------------------- /python/pytest.ini: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | asyncio_mode = auto 3 | testpaths = tests 4 | python_files = test_*.py *_test.py 5 | python_classes = Test* 6 | python_functions = test_* 7 | addopts = -v --tb=short 8 | env = 9 | PYTHONPATH = . 10 | filterwarnings = ignore::DeprecationWarning -------------------------------------------------------------------------------- /typescript/src/lib/constants.ts: -------------------------------------------------------------------------------- 1 | import { env } from "cloudflare:workers"; 2 | 3 | export const DEV = false; 4 | 5 | export const CUSTOM_BASE_URL = env.POSTHOG_BASE_URL || (DEV ? "http://localhost:8010" : undefined); 6 | 7 | export const MCP_DOCS_URL = "https://posthog.com/docs/model-context-protocol"; 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | # wrangler files 4 | .wrangler 5 | .dev.vars* 6 | .mcp.json 7 | .env.test 8 | 9 | # Editor-specific files 10 | .cursor/ 11 | 12 | # Python 13 | python/venv/ 14 | __pycache__/ 15 | .mypy_cache/.ruff_cache/ 16 | .pytest_cache/ 17 | 18 | .env 19 | 20 | dist/ 21 | 22 | .venv/ -------------------------------------------------------------------------------- /typescript/src/integrations/mcp/utils/client.ts: -------------------------------------------------------------------------------- 1 | import { PostHog } from "posthog-node"; 2 | 3 | let _client: PostHog | undefined; 4 | 5 | export const getPostHogClient = () => { 6 | if (!_client) { 7 | _client = new PostHog("sTMFPsFhdP1Ssg", { 8 | host: "https://us.i.posthog.com", 9 | flushAt: 1, 10 | flushInterval: 0, 11 | }); 12 | } 13 | 14 | return _client; 15 | }; 16 | -------------------------------------------------------------------------------- /typescript/src/lib/utils/cache/ScopedCache.ts: -------------------------------------------------------------------------------- 1 | export abstract class ScopedCache> { 2 | constructor(private scope: string) {} 3 | 4 | abstract get(key: K): Promise; 5 | abstract set(key: K, value: T[K]): Promise; 6 | abstract delete(key: K): Promise; 7 | abstract clear(): Promise; 8 | } 9 | -------------------------------------------------------------------------------- /typescript/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig({ 4 | entry: { 5 | index: "src/index.ts", 6 | tools: "src/tools/index.ts", 7 | "ai-sdk": "src/integrations/ai-sdk/index.ts", 8 | langchain: "src/integrations/langchain/index.ts", 9 | }, 10 | format: ["cjs", "esm"], 11 | dts: true, 12 | clean: true, 13 | splitting: false, 14 | treeshake: true, 15 | }); 16 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | 6 | permissions: 7 | contents: write 8 | pull-requests: write 9 | 10 | name: release-please 11 | 12 | jobs: 13 | release-please: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: googleapis/release-please-action@v4 17 | with: 18 | token: ${{ secrets.POSTHOG_BOT_PAT }} 19 | release-type: node 20 | -------------------------------------------------------------------------------- /typescript/tests/setup.ts: -------------------------------------------------------------------------------- 1 | import { config } from "dotenv"; 2 | import { resolve } from "node:path"; 3 | import { vi } from "vitest"; 4 | 5 | // Load .env.test file 6 | config({ path: resolve(process.cwd(), ".env.test") }); 7 | 8 | // Mock cloudflare:workers module for Node.js test environment 9 | vi.mock("cloudflare:workers", () => ({ 10 | env: { 11 | POSTHOG_BASE_URL: undefined, // Use default from constants 12 | }, 13 | })); 14 | -------------------------------------------------------------------------------- /typescript/vitest.integration.config.mts: -------------------------------------------------------------------------------- 1 | import tsconfigPaths from "vite-tsconfig-paths"; 2 | import { defineConfig } from "vitest/config"; 3 | 4 | export default defineConfig({ 5 | plugins: [tsconfigPaths({ root: "." })], 6 | test: { 7 | globals: true, 8 | environment: "node", 9 | testTimeout: 30000, 10 | setupFiles: ["tests/setup.ts"], 11 | include: ["tests/**/*.integration.test.ts"], 12 | exclude: ["node_modules/**", "dist/**"], 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /typescript/vitest.config.mts: -------------------------------------------------------------------------------- 1 | import tsconfigPaths from "vite-tsconfig-paths"; 2 | import { defineConfig } from "vitest/config"; 3 | 4 | export default defineConfig({ 5 | plugins: [tsconfigPaths({ root: "." })], 6 | test: { 7 | globals: true, 8 | environment: "node", 9 | testTimeout: 10000, 10 | setupFiles: ["tests/setup.ts"], 11 | include: ["tests/**/*.test.ts"], 12 | exclude: ["node_modules/**", "dist/**", "tests/**/*.integration.test.ts"], 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /examples/ai-sdk/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "lib": ["ES2022"], 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true 15 | }, 16 | "include": ["src/**/*"] 17 | } 18 | -------------------------------------------------------------------------------- /typescript/tests/README.md: -------------------------------------------------------------------------------- 1 | # API Integration Tests 2 | 3 | These tests should verify that the API works as intended. 4 | 5 | ## Setup 6 | 7 | 1. **Configure environment:** 8 | ```bash 9 | cp .env.test.example .env.test 10 | ``` 11 | Edit `.env.test` and set an api token and base url for your local PostHog instance 12 | 13 | ## Running Tests 14 | 15 | ```bash 16 | # Run all tests 17 | npm run test 18 | 19 | # Run tests in watch mode 20 | npm run test:watch 21 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PostHog MCP 2 | 3 | The MCP server has been moved into the PostHog Monorepo - you can find it [here](https://github.com/PostHog/posthog/tree/master/products/mcp). 4 | 5 | Documentation: https://posthog.com/docs/model-context-protocol 6 | 7 | ## Use the MCP Server 8 | 9 | ### Quick install 10 | 11 | You can install the MCP server automatically into Cursor, Claude, Claude Code, VS Code and Zed by running the following command: 12 | 13 | ``` 14 | npx @posthog/wizard@latest mcp add 15 | ``` 16 | -------------------------------------------------------------------------------- /examples/langchain-js/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "lib": ["ES2022"], 7 | "outDir": "./dist", 8 | "rootDir": "./src", 9 | "strict": true, 10 | "esModuleInterop": true, 11 | "skipLibCheck": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "resolveJsonModule": true, 14 | "allowJs": true, 15 | "types": ["node"] 16 | }, 17 | "include": ["src/**/*"], 18 | "exclude": ["node_modules", "dist"] 19 | } 20 | -------------------------------------------------------------------------------- /examples/ai-sdk/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "posthog-ai-sdk-example", 3 | "version": "1.0.0", 4 | "description": "Example using PostHog tools with the AI SDK", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "tsx src/index.ts" 8 | }, 9 | "dependencies": { 10 | "@ai-sdk/openai": "^2.0.28", 11 | "@posthog/agent-toolkit": "^0.2.1", 12 | "ai": "^5.0.40", 13 | "dotenv": "^16.4.7", 14 | "zod": "^3.24.4" 15 | }, 16 | "devDependencies": { 17 | "@types/node": "^22.15.34", 18 | "tsx": "^4.20.3", 19 | "typescript": "^5.8.3" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /examples/langchain/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "posthog-langchain-example" 3 | version = "0.1.0" 4 | description = "PostHog LangChain integration example" 5 | readme = "README.md" 6 | requires-python = ">=3.11" 7 | dependencies = [ 8 | "langchain>=0.3.0", 9 | "langchain-openai>=0.2.0", 10 | "langchain-core>=0.3.0", 11 | "python-dotenv>=1.0.0", 12 | "posthog-agent-toolkit>=0.1.0", 13 | ] 14 | 15 | [tool.ruff] 16 | line-length = 120 17 | target-version = "py311" 18 | 19 | [tool.black] 20 | line-length = 120 21 | target-version = ['py311'] 22 | -------------------------------------------------------------------------------- /examples/langchain-js/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "posthog-langchain-js-example", 3 | "version": "1.0.0", 4 | "description": "Example using PostHog tools with Langchain JS", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "tsx src/index.ts" 8 | }, 9 | "dependencies": { 10 | "@langchain/core": "^0.3.72", 11 | "@langchain/openai": "^0.6.9", 12 | "@posthog/agent-toolkit": "^0.2.1", 13 | "dotenv": "^16.4.7", 14 | "langchain": "^0.3.31", 15 | "zod": "^3.24.4" 16 | }, 17 | "devDependencies": { 18 | "@types/node": "^22.15.34", 19 | "tsx": "^4.20.3", 20 | "typescript": "^5.8.3" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /python/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.defaultInterpreterPath": "./.venv/bin/python", 3 | "python.linting.enabled": true, 4 | "python.linting.ruffEnabled": true, 5 | "python.linting.flake8Enabled": false, 6 | "python.formatting.provider": "none", 7 | "editor.formatOnType": false, 8 | "editor.codeActionsOnSave": { 9 | "source.fixAll": "never" 10 | }, 11 | "[python]": { 12 | "editor.defaultFormatter": "charliermarsh.ruff", 13 | "editor.formatOnSave": true, 14 | "editor.codeActionsOnSave": { 15 | "source.organizeImports": "explicit", 16 | "source.fixAll.ruff": "explicit" 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use a lightweight Node.js image 2 | FROM node:20-slim 3 | 4 | # Set the working directory inside the container 5 | WORKDIR /usr/src/app 6 | 7 | # Install mcp-remote globally to avoid npx overhead on each run 8 | RUN npm install -g mcp-remote@latest 9 | 10 | # Set default environment variable for POSTHOG_REMOTE_MCP_URL 11 | ENV POSTHOG_REMOTE_MCP_URL=https://mcp.posthog.com/mcp 12 | 13 | # The entrypoint will run mcp-remote with proper stdio handling 14 | # POSTHOG_AUTH_HEADER should be just the token (e.g., phx_...), we'll add "Bearer " prefix 15 | ENTRYPOINT ["sh", "-c", "mcp-remote \"${POSTHOG_REMOTE_MCP_URL}\" --header \"Authorization:Bearer ${POSTHOG_AUTH_HEADER}\""] 16 | -------------------------------------------------------------------------------- /typescript/src/schema/properties.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApiEventDefinitionSchema, 3 | type ApiListResponseSchema, 4 | ApiPropertyDefinitionSchema, 5 | } from "@/schema/api"; 6 | import type { z } from "zod"; 7 | 8 | export const PropertyDefinitionSchema = ApiPropertyDefinitionSchema.pick({ 9 | name: true, 10 | property_type: true, 11 | }); 12 | 13 | export const EventDefinitionSchema = ApiEventDefinitionSchema.pick({ 14 | name: true, 15 | last_seen_at: true, 16 | }); 17 | 18 | export type PropertyDefinition = z.infer; 19 | export type PropertyDefinitionsResponse = z.infer< 20 | ReturnType> 21 | >; 22 | -------------------------------------------------------------------------------- /examples/ai-sdk/README.md: -------------------------------------------------------------------------------- 1 | # PostHog AI SDK Integration Example 2 | 3 | This example demonstrates how to use PostHog tools with the AI SDK using the `@posthog/agent-toolkit` package. 4 | 5 | ## Features 6 | 7 | - Uses the `tool()` helper function from AI SDK for type-safe tool integration 8 | - Automatically infers tool input types from Zod schemas 9 | - Provides access to all PostHog MCP tools (feature flags, insights, dashboards, etc.) 10 | 11 | ## Setup 12 | 13 | 1. Install dependencies: 14 | ```bash 15 | npm install 16 | ``` 17 | 18 | 2. Copy the environment file and fill in your credentials: 19 | ```bash 20 | cp .env.example .env 21 | ``` 22 | 23 | 3. Run the example: 24 | ```bash 25 | npm run dev 26 | ``` -------------------------------------------------------------------------------- /typescript/src/tools/organizations/setActive.ts: -------------------------------------------------------------------------------- 1 | import { OrganizationSetActiveSchema } from "@/schema/tool-inputs"; 2 | import type { Context, ToolBase } from "@/tools/types"; 3 | import type { z } from "zod"; 4 | 5 | const schema = OrganizationSetActiveSchema; 6 | 7 | type Params = z.infer; 8 | 9 | export const setActiveHandler = async (context: Context, params: Params) => { 10 | const { orgId } = params; 11 | await context.cache.set("orgId", orgId); 12 | 13 | return { 14 | content: [{ type: "text", text: `Switched to organization ${orgId}` }], 15 | }; 16 | }; 17 | 18 | const tool = (): ToolBase => ({ 19 | name: "switch-organization", 20 | schema, 21 | handler: setActiveHandler, 22 | }); 23 | 24 | export default tool; 25 | -------------------------------------------------------------------------------- /typescript/src/tools/projects/setActive.ts: -------------------------------------------------------------------------------- 1 | import { ProjectSetActiveSchema } from "@/schema/tool-inputs"; 2 | import type { Context, ToolBase } from "@/tools/types"; 3 | import type { z } from "zod"; 4 | 5 | const schema = ProjectSetActiveSchema; 6 | 7 | type Params = z.infer; 8 | 9 | export const setActiveHandler = async (context: Context, params: Params) => { 10 | const { projectId } = params; 11 | 12 | await context.cache.set("projectId", projectId.toString()); 13 | 14 | return { 15 | content: [{ type: "text", text: `Switched to project ${projectId}` }], 16 | }; 17 | }; 18 | 19 | const tool = (): ToolBase => ({ 20 | name: "switch-project", 21 | schema, 22 | handler: setActiveHandler, 23 | }); 24 | 25 | export default tool; 26 | -------------------------------------------------------------------------------- /examples/langchain-js/README.md: -------------------------------------------------------------------------------- 1 | # PostHog Langchain JS Integration Example 2 | 3 | This example demonstrates how to use PostHog tools with Langchain JS using the `@posthog/agent-toolkit` package. 4 | 5 | ## Features 6 | 7 | - Uses the `DynamicStructuredTool` class from Langchain for type-safe tool integration 8 | - Automatically infers tool input types from Zod schemas 9 | - Provides access to all PostHog MCP tools (feature flags, insights, dashboards, etc.) 10 | - Works with any Langchain-compatible LLM 11 | 12 | ## Setup 13 | 14 | 1. Install dependencies: 15 | ```bash 16 | npm install 17 | ``` 18 | 19 | 2. Copy the environment file and fill in your credentials: 20 | ```bash 21 | cp .env.example .env 22 | ``` 23 | 24 | 3. Run the example: 25 | ```bash 26 | npm run dev 27 | ``` -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.associations": { 3 | "wrangler.json": "jsonc" 4 | }, 5 | "editor.defaultFormatter": "biomejs.biome", 6 | "editor.formatOnSave": true, 7 | "editor.codeActionsOnSave": { 8 | "quickfix.biome": "explicit", 9 | "source.organizeImports.biome": "explicit" 10 | }, 11 | "[javascript]": { 12 | "editor.defaultFormatter": "biomejs.biome" 13 | }, 14 | "[typescript]": { 15 | "editor.defaultFormatter": "biomejs.biome" 16 | }, 17 | "[json]": { 18 | "editor.defaultFormatter": "biomejs.biome" 19 | }, 20 | "[jsonc]": { 21 | "editor.defaultFormatter": "biomejs.biome" 22 | }, 23 | "typescript.preferences.includePackageJsonAutoImports": "off", 24 | "javascript.preferences.includePackageJsonAutoImports": "off", 25 | "eslint.enable": false, 26 | "prettier.enable": false 27 | } 28 | -------------------------------------------------------------------------------- /typescript/src/tools/insights/utils.ts: -------------------------------------------------------------------------------- 1 | import type { Context } from "@/tools/types"; 2 | 3 | export const isShortId = (id: string): boolean => { 4 | return /^[A-Za-z0-9]{8}$/.test(id); 5 | }; 6 | 7 | export const resolveInsightId = async ( 8 | context: Context, 9 | insightId: string, 10 | projectId: string, 11 | ): Promise => { 12 | if (isShortId(insightId)) { 13 | const result = await context.api.insights({ projectId }).get({ insightId }); 14 | 15 | if (!result.success) { 16 | throw new Error(`Failed to resolve insight: ${result.error.message}`); 17 | } 18 | 19 | return result.data.id; 20 | } 21 | 22 | const numericId = Number.parseInt(insightId, 10); 23 | if (Number.isNaN(numericId)) { 24 | throw new Error(`Invalid insight ID format: ${insightId}`); 25 | } 26 | 27 | return numericId; 28 | }; 29 | -------------------------------------------------------------------------------- /typescript/src/lib/utils/helper-functions.ts: -------------------------------------------------------------------------------- 1 | import crypto from "node:crypto"; 2 | 3 | export function hash(data: string) { 4 | // Use PBKDF2 with sufficient computational effort for security 5 | // 100,000 iterations provides good security while maintaining reasonable performance 6 | const salt = crypto.createHash("sha256").update("posthog_mcp_salt").digest(); 7 | return crypto.pbkdf2Sync(data, salt, 100000, 32, "sha256").toString("hex"); 8 | } 9 | 10 | export function getSearchParamsFromRecord( 11 | params: Record, 12 | ): URLSearchParams { 13 | const searchParams = new URLSearchParams(); 14 | 15 | for (const [key, value] of Object.entries(params)) { 16 | if (value !== undefined) { 17 | searchParams.append(key, String(value)); 18 | } 19 | } 20 | 21 | return searchParams; 22 | } 23 | -------------------------------------------------------------------------------- /typescript/src/tools/organizations/getOrganizations.ts: -------------------------------------------------------------------------------- 1 | import { OrganizationGetAllSchema } from "@/schema/tool-inputs"; 2 | import type { Context, ToolBase } from "@/tools/types"; 3 | import type { z } from "zod"; 4 | 5 | const schema = OrganizationGetAllSchema; 6 | 7 | type Params = z.infer; 8 | 9 | export const getOrganizationsHandler = async (context: Context, _params: Params) => { 10 | const orgsResult = await context.api.organizations().list(); 11 | if (!orgsResult.success) { 12 | throw new Error(`Failed to get organizations: ${orgsResult.error.message}`); 13 | } 14 | 15 | return { 16 | content: [{ type: "text", text: JSON.stringify(orgsResult.data) }], 17 | }; 18 | }; 19 | 20 | const tool = (): ToolBase => ({ 21 | name: "organizations-get", 22 | schema, 23 | handler: getOrganizationsHandler, 24 | }); 25 | 26 | export default tool; 27 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.6.2/schema.json", 3 | "organizeImports": { 4 | "enabled": true 5 | }, 6 | "files": { 7 | "ignore": [ 8 | "worker-configuration.d.ts", 9 | ".mypy_cache/**", 10 | "schema/tool-inputs.json", 11 | "python/**", 12 | "**/generated.ts" 13 | ] 14 | }, 15 | "vcs": { 16 | "enabled": true, 17 | "clientKind": "git", 18 | "useIgnoreFile": true 19 | }, 20 | "linter": { 21 | "enabled": true, 22 | "rules": { 23 | "recommended": true, 24 | "suspicious": { 25 | "noExplicitAny": "off", 26 | "noDebugger": "off", 27 | "noConsoleLog": "off", 28 | "noConfusingVoidType": "off", 29 | "noAssignInExpressions": "off" 30 | }, 31 | "style": { 32 | "noNonNullAssertion": "off" 33 | } 34 | } 35 | }, 36 | "formatter": { 37 | "enabled": true, 38 | "indentWidth": 4, 39 | "lineWidth": 100 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /typescript/src/tools/experiments/getAll.ts: -------------------------------------------------------------------------------- 1 | import { ExperimentGetAllSchema } from "@/schema/tool-inputs"; 2 | import type { Context, ToolBase } from "@/tools/types"; 3 | import type { z } from "zod"; 4 | 5 | const schema = ExperimentGetAllSchema; 6 | 7 | type Params = z.infer; 8 | 9 | export const getAllHandler = async (context: Context, _params: Params) => { 10 | const projectId = await context.stateManager.getProjectId(); 11 | 12 | const results = await context.api.experiments({ projectId }).list(); 13 | 14 | if (!results.success) { 15 | throw new Error(`Failed to get experiments: ${results.error.message}`); 16 | } 17 | 18 | return { content: [{ type: "text", text: JSON.stringify(results.data) }] }; 19 | }; 20 | 21 | const tool = (): ToolBase => ({ 22 | name: "experiment-get-all", 23 | schema, 24 | handler: getAllHandler, 25 | }); 26 | 27 | export default tool; 28 | -------------------------------------------------------------------------------- /typescript/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2021", 4 | "lib": ["es2021"], 5 | "jsx": "react-jsx", 6 | "module": "es2022", 7 | "moduleResolution": "Bundler", 8 | "resolveJsonModule": true, 9 | "allowJs": true, 10 | "checkJs": false, 11 | "noEmit": true, 12 | "isolatedModules": true, 13 | "allowSyntheticDefaultImports": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "strict": true, 16 | "noImplicitReturns": true, 17 | "noFallthroughCasesInSwitch": true, 18 | "noUncheckedIndexedAccess": true, 19 | "exactOptionalPropertyTypes": true, 20 | "skipLibCheck": true, 21 | "types": ["node", "./worker-configuration.d.ts"], 22 | "baseUrl": ".", 23 | "paths": { 24 | "@/*": ["src/*", "tests/*"] 25 | } 26 | }, 27 | "include": ["src/**/*.ts", "scripts/**/*.ts", "tests/**/*.ts", "worker-configuration.d.ts"], 28 | "exclude": ["node_modules", "dist"] 29 | } 30 | -------------------------------------------------------------------------------- /.release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", 3 | "bootstrap-sha": "main", 4 | "release-type": "node", 5 | "bump-minor-pre-major": true, 6 | "bump-patch-for-minor-pre-major": true, 7 | "include-v-in-tag": true, 8 | "separate-pull-requests": true, 9 | "changelog-sections": [ 10 | { "type": "feat", "section": "Features" }, 11 | { "type": "fix", "section": "Bug Fixes" }, 12 | { "type": "docs", "section": "Documentation" }, 13 | { "type": "chore", "section": "Miscellaneous" } 14 | ], 15 | "packages": { 16 | "typescript": { 17 | "release-type": "node", 18 | "package-name": "@posthog/agent-toolkit", 19 | "changelog-path": "typescript/CHANGELOG.md" 20 | }, 21 | "python": { 22 | "release-type": "python", 23 | "package-name": "posthog-agent-toolkit", 24 | "changelog-path": "python/CHANGELOG.md" 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /typescript/src/lib/utils/cache/MemoryCache.ts: -------------------------------------------------------------------------------- 1 | import { ScopedCache } from "@/lib/utils/cache/ScopedCache"; 2 | 3 | const _cacheStore = new Map(); 4 | 5 | export class MemoryCache> extends ScopedCache { 6 | private cache: Map = new Map(); 7 | 8 | constructor(scope: string) { 9 | super(scope); 10 | this.cache = _cacheStore.get(scope) || new Map(); 11 | _cacheStore.set(scope, this.cache); 12 | } 13 | 14 | async get(key: K): Promise { 15 | return this.cache.get(key as string); 16 | } 17 | 18 | async set(key: K, value: T[K]): Promise { 19 | this.cache.set(key as string, value); 20 | return; 21 | } 22 | 23 | async delete(key: K): Promise { 24 | this.cache.delete(key as string); 25 | } 26 | 27 | async clear(): Promise { 28 | this.cache.clear(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /typescript/src/tools/featureFlags/getAll.ts: -------------------------------------------------------------------------------- 1 | import { FeatureFlagGetAllSchema } from "@/schema/tool-inputs"; 2 | import type { Context, ToolBase } from "@/tools/types"; 3 | import type { z } from "zod"; 4 | 5 | const schema = FeatureFlagGetAllSchema; 6 | 7 | type Params = z.infer; 8 | 9 | export const getAllHandler = async (context: Context, _params: Params) => { 10 | const projectId = await context.stateManager.getProjectId(); 11 | 12 | const flagsResult = await context.api.featureFlags({ projectId }).list(); 13 | 14 | if (!flagsResult.success) { 15 | throw new Error(`Failed to get feature flags: ${flagsResult.error.message}`); 16 | } 17 | 18 | return { content: [{ type: "text", text: JSON.stringify(flagsResult.data) }] }; 19 | }; 20 | 21 | const tool = (): ToolBase => ({ 22 | name: "feature-flag-get-all", 23 | schema, 24 | handler: getAllHandler, 25 | }); 26 | 27 | export default tool; 28 | -------------------------------------------------------------------------------- /typescript/src/tools/experiments/get.ts: -------------------------------------------------------------------------------- 1 | import { ExperimentGetSchema } from "@/schema/tool-inputs"; 2 | import type { Context, ToolBase } from "@/tools/types"; 3 | import type { z } from "zod"; 4 | 5 | const schema = ExperimentGetSchema; 6 | 7 | type Params = z.infer; 8 | 9 | export const getHandler = async (context: Context, { experimentId }: Params) => { 10 | const projectId = await context.stateManager.getProjectId(); 11 | 12 | const result = await context.api.experiments({ projectId }).get({ 13 | experimentId: experimentId, 14 | }); 15 | 16 | if (!result.success) { 17 | throw new Error(`Failed to get experiment: ${result.error.message}`); 18 | } 19 | 20 | return { content: [{ type: "text", text: JSON.stringify(result.data) }] }; 21 | }; 22 | 23 | const tool = (): ToolBase => ({ 24 | name: "experiment-get", 25 | schema, 26 | handler: getHandler, 27 | }); 28 | 29 | export default tool; 30 | -------------------------------------------------------------------------------- /typescript/src/tools/dashboards/delete.ts: -------------------------------------------------------------------------------- 1 | import { DashboardDeleteSchema } from "@/schema/tool-inputs"; 2 | import type { Context, ToolBase } from "@/tools/types"; 3 | import type { z } from "zod"; 4 | 5 | const schema = DashboardDeleteSchema; 6 | 7 | type Params = z.infer; 8 | 9 | export const deleteHandler = async (context: Context, params: Params) => { 10 | const { dashboardId } = params; 11 | const projectId = await context.stateManager.getProjectId(); 12 | const result = await context.api.dashboards({ projectId }).delete({ dashboardId }); 13 | 14 | if (!result.success) { 15 | throw new Error(`Failed to delete dashboard: ${result.error.message}`); 16 | } 17 | 18 | return { content: [{ type: "text", text: JSON.stringify(result.data) }] }; 19 | }; 20 | 21 | const tool = (): ToolBase => ({ 22 | name: "dashboard-delete", 23 | schema, 24 | handler: deleteHandler, 25 | }); 26 | 27 | export default tool; 28 | -------------------------------------------------------------------------------- /typescript/src/tools/dashboards/get.ts: -------------------------------------------------------------------------------- 1 | import { DashboardGetSchema } from "@/schema/tool-inputs"; 2 | import type { Context, ToolBase } from "@/tools/types"; 3 | import type { z } from "zod"; 4 | 5 | const schema = DashboardGetSchema; 6 | 7 | type Params = z.infer; 8 | 9 | export const getHandler = async (context: Context, params: Params) => { 10 | const { dashboardId } = params; 11 | const projectId = await context.stateManager.getProjectId(); 12 | const dashboardResult = await context.api.dashboards({ projectId }).get({ dashboardId }); 13 | 14 | if (!dashboardResult.success) { 15 | throw new Error(`Failed to get dashboard: ${dashboardResult.error.message}`); 16 | } 17 | 18 | return { content: [{ type: "text", text: JSON.stringify(dashboardResult.data) }] }; 19 | }; 20 | 21 | const tool = (): ToolBase => ({ 22 | name: "dashboard-get", 23 | schema, 24 | handler: getHandler, 25 | }); 26 | 27 | export default tool; 28 | -------------------------------------------------------------------------------- /typescript/src/tools/surveys/global-stats.ts: -------------------------------------------------------------------------------- 1 | import { SurveyGlobalStatsSchema } from "@/schema/tool-inputs"; 2 | import type { Context, ToolBase } from "@/tools/types"; 3 | import type { z } from "zod"; 4 | 5 | const schema = SurveyGlobalStatsSchema; 6 | type Params = z.infer; 7 | 8 | export const globalStatsHandler = async (context: Context, params: Params) => { 9 | const projectId = await context.stateManager.getProjectId(); 10 | 11 | const result = await context.api.surveys({ projectId }).globalStats({ params }); 12 | 13 | if (!result.success) { 14 | throw new Error(`Failed to get survey global stats: ${result.error.message}`); 15 | } 16 | 17 | return { 18 | content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }], 19 | }; 20 | }; 21 | 22 | const tool = (): ToolBase => ({ 23 | name: "surveys-global-stats", 24 | schema, 25 | handler: globalStatsHandler, 26 | }); 27 | 28 | export default tool; 29 | -------------------------------------------------------------------------------- /typescript/src/tools/query/run.ts: -------------------------------------------------------------------------------- 1 | import { QueryRunInputSchema } from "@/schema/tool-inputs"; 2 | import type { Context, ToolBase } from "@/tools/types"; 3 | import type { z } from "zod"; 4 | 5 | const schema = QueryRunInputSchema; 6 | 7 | type Params = z.infer; 8 | 9 | export const queryRunHandler = async (context: Context, params: Params) => { 10 | const { query } = params; 11 | 12 | const projectId = await context.stateManager.getProjectId(); 13 | 14 | const queryResult = await context.api.insights({ projectId }).query({ 15 | query: query, 16 | }); 17 | 18 | if (!queryResult.success) { 19 | throw new Error(`Failed to query insight: ${queryResult.error.message}`); 20 | } 21 | 22 | return { content: [{ type: "text", text: JSON.stringify(queryResult.data.results) }] }; 23 | }; 24 | 25 | const tool = (): ToolBase => ({ 26 | name: "query-run", 27 | schema, 28 | handler: queryRunHandler, 29 | }); 30 | 31 | export default tool; 32 | -------------------------------------------------------------------------------- /typescript/src/tools/experiments/delete.ts: -------------------------------------------------------------------------------- 1 | import { ExperimentDeleteSchema } from "@/schema/tool-inputs"; 2 | import type { Context, ToolBase } from "@/tools/types"; 3 | import type { z } from "zod"; 4 | 5 | const schema = ExperimentDeleteSchema; 6 | 7 | type Params = z.infer; 8 | 9 | export const deleteHandler = async (context: Context, { experimentId }: Params) => { 10 | const projectId = await context.stateManager.getProjectId(); 11 | 12 | const deleteResult = await context.api.experiments({ projectId }).delete({ 13 | experimentId, 14 | }); 15 | 16 | if (!deleteResult.success) { 17 | throw new Error(`Failed to delete experiment: ${deleteResult.error.message}`); 18 | } 19 | 20 | return { 21 | content: [{ type: "text", text: JSON.stringify(deleteResult.data) }], 22 | }; 23 | }; 24 | 25 | const tool = (): ToolBase => ({ 26 | name: "experiment-delete", 27 | schema, 28 | handler: deleteHandler, 29 | }); 30 | 31 | export default tool; 32 | -------------------------------------------------------------------------------- /typescript/src/tools/surveys/delete.ts: -------------------------------------------------------------------------------- 1 | import { SurveyDeleteSchema } from "@/schema/tool-inputs"; 2 | import type { Context, Tool, ToolBase } from "@/tools/types"; 3 | import type { z } from "zod"; 4 | 5 | const schema = SurveyDeleteSchema; 6 | type Params = z.infer; 7 | 8 | export const deleteHandler = async (context: Context, params: Params) => { 9 | const { surveyId } = params; 10 | const projectId = await context.stateManager.getProjectId(); 11 | 12 | const deleteResult = await context.api.surveys({ projectId }).delete({ 13 | surveyId, 14 | }); 15 | 16 | if (!deleteResult.success) { 17 | throw new Error(`Failed to delete survey: ${deleteResult.error.message}`); 18 | } 19 | 20 | return { 21 | content: [{ type: "text", text: JSON.stringify(deleteResult.data) }], 22 | }; 23 | }; 24 | 25 | const tool = (): ToolBase => ({ 26 | name: "survey-delete", 27 | schema, 28 | handler: deleteHandler, 29 | }); 30 | 31 | export default tool; 32 | -------------------------------------------------------------------------------- /typescript/src/tools/dashboards/getAll.ts: -------------------------------------------------------------------------------- 1 | import { DashboardGetAllSchema } from "@/schema/tool-inputs"; 2 | import type { Context, ToolBase } from "@/tools/types"; 3 | import type { z } from "zod"; 4 | 5 | const schema = DashboardGetAllSchema; 6 | 7 | type Params = z.infer; 8 | 9 | export const getAllHandler = async (context: Context, params: Params) => { 10 | const { data } = params; 11 | const projectId = await context.stateManager.getProjectId(); 12 | const dashboardsResult = await context.api 13 | .dashboards({ projectId }) 14 | .list({ params: data ?? {} }); 15 | 16 | if (!dashboardsResult.success) { 17 | throw new Error(`Failed to get dashboards: ${dashboardsResult.error.message}`); 18 | } 19 | 20 | return { content: [{ type: "text", text: JSON.stringify(dashboardsResult.data) }] }; 21 | }; 22 | 23 | const tool = (): ToolBase => ({ 24 | name: "dashboards-get-all", 25 | schema, 26 | handler: getAllHandler, 27 | }); 28 | 29 | export default tool; 30 | -------------------------------------------------------------------------------- /typescript/src/tools/surveys/stats.ts: -------------------------------------------------------------------------------- 1 | import { SurveyStatsSchema } from "@/schema/tool-inputs"; 2 | import type { Context, ToolBase } from "@/tools/types"; 3 | import type { z } from "zod"; 4 | 5 | const schema = SurveyStatsSchema; 6 | type Params = z.infer; 7 | 8 | export const statsHandler = async (context: Context, params: Params) => { 9 | const projectId = await context.stateManager.getProjectId(); 10 | 11 | const result = await context.api.surveys({ projectId }).stats({ 12 | survey_id: params.survey_id, 13 | date_from: params.date_from, 14 | date_to: params.date_to, 15 | }); 16 | 17 | if (!result.success) { 18 | throw new Error(`Failed to get survey stats: ${result.error.message}`); 19 | } 20 | 21 | return { 22 | content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }], 23 | }; 24 | }; 25 | 26 | const tool = (): ToolBase => ({ 27 | name: "survey-stats", 28 | schema, 29 | handler: statsHandler, 30 | }); 31 | 32 | export default tool; 33 | -------------------------------------------------------------------------------- /typescript/src/tools/documentation/searchDocs.ts: -------------------------------------------------------------------------------- 1 | import { docsSearch } from "@/inkeepApi"; 2 | import { DocumentationSearchSchema } from "@/schema/tool-inputs"; 3 | import type { Context, ToolBase } from "@/tools/types"; 4 | import type { z } from "zod"; 5 | 6 | const schema = DocumentationSearchSchema; 7 | 8 | type Params = z.infer; 9 | 10 | export const searchDocsHandler = async (context: Context, params: Params) => { 11 | const { query } = params; 12 | const inkeepApiKey = context.env.INKEEP_API_KEY; 13 | 14 | if (!inkeepApiKey) { 15 | return { 16 | content: [ 17 | { 18 | type: "text", 19 | text: "Error: INKEEP_API_KEY is not configured.", 20 | }, 21 | ], 22 | }; 23 | } 24 | const resultText = await docsSearch(inkeepApiKey, query); 25 | return { content: [{ type: "text", text: resultText }] }; 26 | }; 27 | 28 | const tool = (): ToolBase => ({ 29 | name: "docs-search", 30 | schema, 31 | handler: searchDocsHandler, 32 | }); 33 | 34 | export default tool; 35 | -------------------------------------------------------------------------------- /typescript/src/tools/insights/delete.ts: -------------------------------------------------------------------------------- 1 | import { InsightDeleteSchema } from "@/schema/tool-inputs"; 2 | import type { Context, ToolBase } from "@/tools/types"; 3 | import { resolveInsightId } from "./utils"; 4 | import type { z } from "zod"; 5 | 6 | const schema = InsightDeleteSchema; 7 | 8 | type Params = z.infer; 9 | 10 | export const deleteHandler = async (context: Context, params: Params) => { 11 | const { insightId } = params; 12 | const projectId = await context.stateManager.getProjectId(); 13 | 14 | const numericId = await resolveInsightId(context, insightId, projectId); 15 | const result = await context.api.insights({ projectId }).delete({ insightId: numericId }); 16 | 17 | if (!result.success) { 18 | throw new Error(`Failed to delete insight: ${result.error.message}`); 19 | } 20 | 21 | return { content: [{ type: "text", text: JSON.stringify(result.data) }] }; 22 | }; 23 | 24 | const tool = (): ToolBase => ({ 25 | name: "insight-delete", 26 | schema, 27 | handler: deleteHandler, 28 | }); 29 | 30 | export default tool; 31 | -------------------------------------------------------------------------------- /typescript/src/tools/insights/get.ts: -------------------------------------------------------------------------------- 1 | import { InsightGetSchema } from "@/schema/tool-inputs"; 2 | import type { Context, ToolBase } from "@/tools/types"; 3 | import type { z } from "zod"; 4 | 5 | const schema = InsightGetSchema; 6 | 7 | type Params = z.infer; 8 | 9 | export const getHandler = async (context: Context, params: Params) => { 10 | const { insightId } = params; 11 | const projectId = await context.stateManager.getProjectId(); 12 | const insightResult = await context.api.insights({ projectId }).get({ insightId }); 13 | if (!insightResult.success) { 14 | throw new Error(`Failed to get insight: ${insightResult.error.message}`); 15 | } 16 | 17 | const insightWithUrl = { 18 | ...insightResult.data, 19 | url: `${context.api.getProjectBaseUrl(projectId)}/insights/${insightResult.data.short_id}`, 20 | }; 21 | 22 | return { content: [{ type: "text", text: JSON.stringify(insightWithUrl) }] }; 23 | }; 24 | 25 | const tool = (): ToolBase => ({ 26 | name: "insight-get", 27 | schema, 28 | handler: getHandler, 29 | }); 30 | 31 | export default tool; 32 | -------------------------------------------------------------------------------- /typescript/src/tools/surveys/get.ts: -------------------------------------------------------------------------------- 1 | import { SurveyGetSchema } from "@/schema/tool-inputs"; 2 | import { formatSurvey } from "@/tools/surveys/utils/survey-utils"; 3 | import type { Context, ToolBase } from "@/tools/types"; 4 | import type { z } from "zod"; 5 | 6 | const schema = SurveyGetSchema; 7 | type Params = z.infer; 8 | 9 | export const getHandler = async (context: Context, params: Params) => { 10 | const { surveyId } = params; 11 | const projectId = await context.stateManager.getProjectId(); 12 | 13 | const surveyResult = await context.api.surveys({ projectId }).get({ 14 | surveyId, 15 | }); 16 | 17 | if (!surveyResult.success) { 18 | throw new Error(`Failed to get survey: ${surveyResult.error.message}`); 19 | } 20 | 21 | const formattedSurvey = formatSurvey(surveyResult.data, context, projectId); 22 | 23 | return { 24 | content: [{ type: "text", text: JSON.stringify(formattedSurvey) }], 25 | }; 26 | }; 27 | 28 | const tool = (): ToolBase => ({ 29 | name: "survey-get", 30 | schema, 31 | handler: getHandler, 32 | }); 33 | 34 | export default tool; 35 | -------------------------------------------------------------------------------- /typescript/src/tools/insights/create.ts: -------------------------------------------------------------------------------- 1 | import { InsightCreateSchema } from "@/schema/tool-inputs"; 2 | import type { Context, ToolBase } from "@/tools/types"; 3 | import type { z } from "zod"; 4 | 5 | const schema = InsightCreateSchema; 6 | 7 | type Params = z.infer; 8 | 9 | export const createHandler = async (context: Context, params: Params) => { 10 | const { data } = params; 11 | const projectId = await context.stateManager.getProjectId(); 12 | const insightResult = await context.api.insights({ projectId }).create({ data }); 13 | if (!insightResult.success) { 14 | throw new Error(`Failed to create insight: ${insightResult.error.message}`); 15 | } 16 | 17 | const insightWithUrl = { 18 | ...insightResult.data, 19 | url: `${context.api.getProjectBaseUrl(projectId)}/insights/${insightResult.data.short_id}`, 20 | }; 21 | 22 | return { content: [{ type: "text", text: JSON.stringify(insightWithUrl) }] }; 23 | }; 24 | 25 | const tool = (): ToolBase => ({ 26 | name: "insight-create-from-query", 27 | schema, 28 | handler: createHandler, 29 | }); 30 | 31 | export default tool; 32 | -------------------------------------------------------------------------------- /typescript/tests/unit/api-client.test.ts: -------------------------------------------------------------------------------- 1 | import { ApiClient } from "@/api/client"; 2 | import { describe, expect, it } from "vitest"; 3 | 4 | describe("ApiClient", () => { 5 | it("should create ApiClient with required config", () => { 6 | const client = new ApiClient({ 7 | apiToken: "test-token", 8 | baseUrl: "https://example.com", 9 | }); 10 | 11 | expect(client).toBeInstanceOf(ApiClient); 12 | }); 13 | 14 | it("should use custom baseUrl when provided", () => { 15 | const customUrl = "https://custom.example.com"; 16 | const client = new ApiClient({ 17 | apiToken: "test-token", 18 | baseUrl: customUrl, 19 | }); 20 | 21 | const baseUrl = (client as any).baseUrl; 22 | expect(baseUrl).toBe(customUrl); 23 | }); 24 | 25 | it("should build correct headers", () => { 26 | const client = new ApiClient({ 27 | apiToken: "test-token-123", 28 | baseUrl: "https://example.com", 29 | }); 30 | 31 | const headers = (client as any).buildHeaders(); 32 | expect(headers).toEqual({ 33 | Authorization: "Bearer test-token-123", 34 | "Content-Type": "application/json", 35 | }); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /typescript/src/tools/dashboards/create.ts: -------------------------------------------------------------------------------- 1 | import { DashboardCreateSchema } from "@/schema/tool-inputs"; 2 | import type { Context, ToolBase } from "@/tools/types"; 3 | import type { z } from "zod"; 4 | 5 | const schema = DashboardCreateSchema; 6 | 7 | type Params = z.infer; 8 | 9 | export const createHandler = async (context: Context, params: Params) => { 10 | const { data } = params; 11 | const projectId = await context.stateManager.getProjectId(); 12 | const dashboardResult = await context.api.dashboards({ projectId }).create({ data }); 13 | 14 | if (!dashboardResult.success) { 15 | throw new Error(`Failed to create dashboard: ${dashboardResult.error.message}`); 16 | } 17 | 18 | const dashboardWithUrl = { 19 | ...dashboardResult.data, 20 | url: `${context.api.getProjectBaseUrl(projectId)}/dashboard/${dashboardResult.data.id}`, 21 | }; 22 | 23 | return { content: [{ type: "text", text: JSON.stringify(dashboardWithUrl) }] }; 24 | }; 25 | 26 | const tool = (): ToolBase => ({ 27 | name: "dashboard-create", 28 | schema, 29 | handler: createHandler, 30 | }); 31 | 32 | export default tool; 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 PostHog 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /typescript/src/tools/insights/getAll.ts: -------------------------------------------------------------------------------- 1 | import { InsightGetAllSchema } from "@/schema/tool-inputs"; 2 | import type { Context, ToolBase } from "@/tools/types"; 3 | import type { z } from "zod"; 4 | 5 | const schema = InsightGetAllSchema; 6 | 7 | type Params = z.infer; 8 | 9 | export const getAllHandler = async (context: Context, params: Params) => { 10 | const { data } = params; 11 | const projectId = await context.stateManager.getProjectId(); 12 | const insightsResult = await context.api.insights({ projectId }).list({ params: { ...data } }); 13 | 14 | if (!insightsResult.success) { 15 | throw new Error(`Failed to get insights: ${insightsResult.error.message}`); 16 | } 17 | 18 | const insightsWithUrls = insightsResult.data.map((insight) => ({ 19 | ...insight, 20 | url: `${context.api.getProjectBaseUrl(projectId)}/insights/${insight.short_id}`, 21 | })); 22 | 23 | return { content: [{ type: "text", text: JSON.stringify(insightsWithUrls) }] }; 24 | }; 25 | 26 | const tool = (): ToolBase => ({ 27 | name: "insights-get-all", 28 | schema, 29 | handler: getAllHandler, 30 | }); 31 | 32 | export default tool; 33 | -------------------------------------------------------------------------------- /typescript/src/tools/projects/getProjects.ts: -------------------------------------------------------------------------------- 1 | import { ProjectGetAllSchema } from "@/schema/tool-inputs"; 2 | import type { Context, ToolBase } from "@/tools/types"; 3 | import type { z } from "zod"; 4 | 5 | const schema = ProjectGetAllSchema; 6 | 7 | type Params = z.infer; 8 | 9 | export const getProjectsHandler = async (context: Context, _params: Params) => { 10 | const orgId = await context.stateManager.getOrgID(); 11 | 12 | if (!orgId) { 13 | throw new Error( 14 | "API key does not have access to any organizations. This is likely because the API key is scoped to a project, and not an organization.", 15 | ); 16 | } 17 | 18 | const projectsResult = await context.api.organizations().projects({ orgId }).list(); 19 | 20 | if (!projectsResult.success) { 21 | throw new Error(`Failed to get projects: ${projectsResult.error.message}`); 22 | } 23 | 24 | return { 25 | content: [{ type: "text", text: JSON.stringify(projectsResult.data) }], 26 | }; 27 | }; 28 | 29 | const tool = (): ToolBase => ({ 30 | name: "projects-get", 31 | schema, 32 | handler: getProjectsHandler, 33 | }); 34 | 35 | export default tool; 36 | -------------------------------------------------------------------------------- /typescript/src/tools/surveys/getAll.ts: -------------------------------------------------------------------------------- 1 | import { SurveyGetAllSchema } from "@/schema/tool-inputs"; 2 | import { formatSurveys } from "@/tools/surveys/utils/survey-utils"; 3 | import type { Context, ToolBase } from "@/tools/types"; 4 | import type { z } from "zod"; 5 | 6 | const schema = SurveyGetAllSchema; 7 | type Params = z.infer; 8 | 9 | export const getAllHandler = async (context: Context, params: Params) => { 10 | const projectId = await context.stateManager.getProjectId(); 11 | 12 | const surveysResult = await context.api.surveys({ projectId }).list(params ? { params } : {}); 13 | 14 | if (!surveysResult.success) { 15 | throw new Error(`Failed to get surveys: ${surveysResult.error.message}`); 16 | } 17 | 18 | const formattedSurveys = formatSurveys(surveysResult.data, context, projectId); 19 | 20 | const response = { 21 | results: formattedSurveys, 22 | }; 23 | 24 | return { 25 | content: [{ type: "text", text: JSON.stringify(response) }], 26 | }; 27 | }; 28 | 29 | const tool = (): ToolBase => ({ 30 | name: "surveys-get-all", 31 | schema, 32 | handler: getAllHandler, 33 | }); 34 | 35 | export default tool; 36 | -------------------------------------------------------------------------------- /typescript/src/tools/organizations/getDetails.ts: -------------------------------------------------------------------------------- 1 | import { OrganizationGetDetailsSchema } from "@/schema/tool-inputs"; 2 | import type { Context, ToolBase } from "@/tools/types"; 3 | import type { z } from "zod"; 4 | 5 | const schema = OrganizationGetDetailsSchema; 6 | 7 | type Params = z.infer; 8 | 9 | export const getDetailsHandler = async (context: Context, _params: Params) => { 10 | const orgId = await context.stateManager.getOrgID(); 11 | 12 | if (!orgId) { 13 | throw new Error( 14 | "API key does not have access to any organizations. This is likely because the API key is scoped to a project, and not an organization.", 15 | ); 16 | } 17 | 18 | const orgResult = await context.api.organizations().get({ orgId }); 19 | 20 | if (!orgResult.success) { 21 | throw new Error(`Failed to get organization details: ${orgResult.error.message}`); 22 | } 23 | 24 | return { 25 | content: [{ type: "text", text: JSON.stringify(orgResult.data) }], 26 | }; 27 | }; 28 | 29 | const tool = (): ToolBase => ({ 30 | name: "organization-details-get", 31 | schema, 32 | handler: getDetailsHandler, 33 | }); 34 | 35 | export default tool; 36 | -------------------------------------------------------------------------------- /typescript/src/tools/dashboards/update.ts: -------------------------------------------------------------------------------- 1 | import { DashboardUpdateSchema } from "@/schema/tool-inputs"; 2 | import type { Context, ToolBase } from "@/tools/types"; 3 | import type { z } from "zod"; 4 | 5 | const schema = DashboardUpdateSchema; 6 | 7 | type Params = z.infer; 8 | 9 | export const updateHandler = async (context: Context, params: Params) => { 10 | const { dashboardId, data } = params; 11 | const projectId = await context.stateManager.getProjectId(); 12 | const dashboardResult = await context.api 13 | .dashboards({ projectId }) 14 | .update({ dashboardId, data }); 15 | 16 | if (!dashboardResult.success) { 17 | throw new Error(`Failed to update dashboard: ${dashboardResult.error.message}`); 18 | } 19 | 20 | const dashboardWithUrl = { 21 | ...dashboardResult.data, 22 | url: `${context.api.getProjectBaseUrl(projectId)}/dashboard/${dashboardResult.data.id}`, 23 | }; 24 | 25 | return { content: [{ type: "text", text: JSON.stringify(dashboardWithUrl) }] }; 26 | }; 27 | 28 | const tool = (): ToolBase => ({ 29 | name: "dashboard-update", 30 | schema, 31 | handler: updateHandler, 32 | }); 33 | 34 | export default tool; 35 | -------------------------------------------------------------------------------- /typescript/src/tools/featureFlags/update.ts: -------------------------------------------------------------------------------- 1 | import { FeatureFlagUpdateSchema } from "@/schema/tool-inputs"; 2 | import type { Context, ToolBase } from "@/tools/types"; 3 | import type { z } from "zod"; 4 | 5 | const schema = FeatureFlagUpdateSchema; 6 | 7 | type Params = z.infer; 8 | 9 | export const updateHandler = async (context: Context, params: Params) => { 10 | const { flagKey, data } = params; 11 | const projectId = await context.stateManager.getProjectId(); 12 | 13 | const flagResult = await context.api.featureFlags({ projectId }).update({ 14 | key: flagKey, 15 | data: data, 16 | }); 17 | 18 | if (!flagResult.success) { 19 | throw new Error(`Failed to update feature flag: ${flagResult.error.message}`); 20 | } 21 | 22 | const featureFlagWithUrl = { 23 | ...flagResult.data, 24 | url: `${context.api.getProjectBaseUrl(projectId)}/feature_flags/${flagResult.data.id}`, 25 | }; 26 | 27 | return { 28 | content: [{ type: "text", text: JSON.stringify(featureFlagWithUrl) }], 29 | }; 30 | }; 31 | 32 | const tool = (): ToolBase => ({ 33 | name: "update-feature-flag", 34 | schema, 35 | handler: updateHandler, 36 | }); 37 | 38 | export default tool; 39 | -------------------------------------------------------------------------------- /typescript/src/tools/projects/eventDefinitions.ts: -------------------------------------------------------------------------------- 1 | import { EventDefinitionSchema } from "@/schema/properties"; 2 | import { ProjectEventDefinitionsSchema } from "@/schema/tool-inputs"; 3 | import type { Context, ToolBase } from "@/tools/types"; 4 | import type { z } from "zod"; 5 | 6 | const schema = ProjectEventDefinitionsSchema; 7 | 8 | type Params = z.infer; 9 | 10 | export const eventDefinitionsHandler = async (context: Context, _params: Params) => { 11 | const projectId = await context.stateManager.getProjectId(); 12 | 13 | const eventDefsResult = await context.api 14 | .projects() 15 | .eventDefinitions({ projectId, search: _params.q }); 16 | 17 | if (!eventDefsResult.success) { 18 | throw new Error(`Failed to get event definitions: ${eventDefsResult.error.message}`); 19 | } 20 | 21 | const simplifiedEvents = eventDefsResult.data.map((def) => EventDefinitionSchema.parse(def)); 22 | 23 | return { 24 | content: [{ type: "text", text: JSON.stringify(simplifiedEvents) }], 25 | }; 26 | }; 27 | 28 | const tool = (): ToolBase => ({ 29 | name: "event-definitions-list", 30 | schema, 31 | handler: eventDefinitionsHandler, 32 | }); 33 | 34 | export default tool; 35 | -------------------------------------------------------------------------------- /typescript/src/schema/errors.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export enum OrderByErrors { 4 | Occurrences = "occurrences", 5 | FirstSeen = "first_seen", 6 | LastSeen = "last_seen", 7 | Users = "users", 8 | Sessions = "sessions", 9 | } 10 | 11 | export enum OrderDirectionErrors { 12 | Ascending = "ASC", 13 | Descending = "DESC", 14 | } 15 | 16 | export enum StatusErrors { 17 | Active = "active", 18 | Resolved = "resolved", 19 | All = "all", 20 | Suppressed = "suppressed", 21 | } 22 | 23 | export const ListErrorsSchema = z.object({ 24 | orderBy: z.nativeEnum(OrderByErrors).optional(), 25 | dateFrom: z.string().datetime().optional(), 26 | dateTo: z.string().datetime().optional(), 27 | orderDirection: z.nativeEnum(OrderDirectionErrors).optional(), 28 | filterTestAccounts: z.boolean().optional(), 29 | status: z.nativeEnum(StatusErrors).optional(), 30 | }); 31 | 32 | export const ErrorDetailsSchema = z.object({ 33 | issueId: z.string().uuid(), 34 | dateFrom: z.string().datetime().optional(), 35 | dateTo: z.string().datetime().optional(), 36 | }); 37 | 38 | export type ListErrorsData = z.infer; 39 | 40 | export type ErrorDetailsData = z.infer; 41 | -------------------------------------------------------------------------------- /typescript/src/tools/featureFlags/create.ts: -------------------------------------------------------------------------------- 1 | import { FeatureFlagCreateSchema } from "@/schema/tool-inputs"; 2 | import type { Context, ToolBase } from "@/tools/types"; 3 | import type { z } from "zod"; 4 | 5 | const schema = FeatureFlagCreateSchema; 6 | 7 | type Params = z.infer; 8 | 9 | export const createHandler = async (context: Context, params: Params) => { 10 | const { name, key, description, filters, active, tags } = params; 11 | const projectId = await context.stateManager.getProjectId(); 12 | 13 | const flagResult = await context.api.featureFlags({ projectId }).create({ 14 | data: { name, key, description, filters, active, tags }, 15 | }); 16 | 17 | if (!flagResult.success) { 18 | throw new Error(`Failed to create feature flag: ${flagResult.error.message}`); 19 | } 20 | 21 | const featureFlagWithUrl = { 22 | ...flagResult.data, 23 | url: `${context.api.getProjectBaseUrl(projectId)}/feature_flags/${flagResult.data.id}`, 24 | }; 25 | 26 | return { 27 | content: [{ type: "text", text: JSON.stringify(featureFlagWithUrl) }], 28 | }; 29 | }; 30 | 31 | const tool = (): ToolBase => ({ 32 | name: "create-feature-flag", 33 | schema, 34 | handler: createHandler, 35 | }); 36 | 37 | export default tool; 38 | -------------------------------------------------------------------------------- /typescript/src/tools/query/generateHogQLFromQuestion.ts: -------------------------------------------------------------------------------- 1 | import { InsightGenerateHogQLFromQuestionSchema } from "@/schema/tool-inputs"; 2 | import type { Context, ToolBase } from "@/tools/types"; 3 | import type { z } from "zod"; 4 | 5 | const schema = InsightGenerateHogQLFromQuestionSchema; 6 | 7 | type Params = z.infer; 8 | 9 | export const generateHogQLHandler = async (context: Context, params: Params) => { 10 | const { question } = params; 11 | const projectId = await context.stateManager.getProjectId(); 12 | 13 | const result = await context.api.insights({ projectId }).sqlInsight({ query: question }); 14 | 15 | if (!result.success) { 16 | throw new Error(`Failed to execute SQL insight: ${result.error.message}`); 17 | } 18 | 19 | if (result.data.length === 0) { 20 | return { 21 | content: [ 22 | { 23 | type: "text", 24 | text: "Received an empty SQL insight or no data in the stream.", 25 | }, 26 | ], 27 | }; 28 | } 29 | return { content: [{ type: "text", text: JSON.stringify(result.data) }] }; 30 | }; 31 | 32 | const tool = (): ToolBase => ({ 33 | name: "query-generate-hogql-from-question", 34 | schema, 35 | handler: generateHogQLHandler, 36 | }); 37 | 38 | export default tool; 39 | -------------------------------------------------------------------------------- /typescript/src/tools/insights/update.ts: -------------------------------------------------------------------------------- 1 | import { InsightUpdateSchema } from "@/schema/tool-inputs"; 2 | import type { Context, ToolBase } from "@/tools/types"; 3 | import type { z } from "zod"; 4 | import { resolveInsightId } from "./utils"; 5 | 6 | const schema = InsightUpdateSchema; 7 | 8 | type Params = z.infer; 9 | 10 | export const updateHandler = async (context: Context, params: Params) => { 11 | const { insightId, data } = params; 12 | const projectId = await context.stateManager.getProjectId(); 13 | 14 | const numericId = await resolveInsightId(context, insightId, projectId); 15 | 16 | const insightResult = await context.api.insights({ projectId }).update({ 17 | insightId: numericId, 18 | data, 19 | }); 20 | 21 | if (!insightResult.success) { 22 | throw new Error(`Failed to update insight: ${insightResult.error.message}`); 23 | } 24 | 25 | const insightWithUrl = { 26 | ...insightResult.data, 27 | url: `${context.api.getProjectBaseUrl(projectId)}/insights/${insightResult.data.short_id}`, 28 | }; 29 | 30 | return { content: [{ type: "text", text: JSON.stringify(insightWithUrl) }] }; 31 | }; 32 | 33 | const tool = (): ToolBase => ({ 34 | name: "insight-update", 35 | schema, 36 | handler: updateHandler, 37 | }); 38 | 39 | export default tool; 40 | -------------------------------------------------------------------------------- /typescript/src/inkeepApi.ts: -------------------------------------------------------------------------------- 1 | export interface InkeepResponse { 2 | choices: Array<{ 3 | message: { 4 | content: string; 5 | }; 6 | }>; 7 | } 8 | 9 | export async function docsSearch(apiKey: string, userQuery: string): Promise { 10 | if (!apiKey) { 11 | throw new Error("No API key provided"); 12 | } 13 | 14 | const response = await fetch("https://api.inkeep.com/v1/chat/completions", { 15 | method: "POST", 16 | headers: { 17 | "Content-Type": "application/json", 18 | Authorization: `Bearer ${apiKey}`, 19 | }, 20 | body: JSON.stringify({ 21 | model: "inkeep-qa-expert", 22 | messages: [{ role: "user", content: userQuery }], 23 | }), 24 | }); 25 | 26 | if (!response.ok) { 27 | const errorText = await response.text(); 28 | console.error("Inkeep API error:", errorText); 29 | throw new Error(`Error querying Inkeep API: ${response.status} ${errorText}`); 30 | } 31 | 32 | const data = (await response.json()) as InkeepResponse; 33 | 34 | if ( 35 | data.choices && 36 | data.choices.length > 0 && 37 | data.choices[0]?.message && 38 | data.choices[0].message.content 39 | ) { 40 | return data.choices[0].message.content; 41 | } 42 | console.error("Inkeep API response format unexpected:", data); 43 | throw new Error("Unexpected response format from Inkeep API."); 44 | } 45 | -------------------------------------------------------------------------------- /typescript/src/tools/errorTracking/errorDetails.ts: -------------------------------------------------------------------------------- 1 | import { ErrorTrackingDetailsSchema } from "@/schema/tool-inputs"; 2 | import type { Context, ToolBase } from "@/tools/types"; 3 | import type { z } from "zod"; 4 | 5 | const schema = ErrorTrackingDetailsSchema; 6 | 7 | type Params = z.infer; 8 | 9 | export const errorDetailsHandler = async (context: Context, params: Params) => { 10 | const { issueId, dateFrom, dateTo } = params; 11 | const projectId = await context.stateManager.getProjectId(); 12 | 13 | const errorQuery = { 14 | kind: "ErrorTrackingQuery", 15 | dateRange: { 16 | date_from: dateFrom || new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(), 17 | date_to: dateTo || new Date().toISOString(), 18 | }, 19 | volumeResolution: 0, 20 | issueId, 21 | }; 22 | 23 | const errorsResult = await context.api.query({ projectId }).execute({ queryBody: errorQuery }); 24 | if (!errorsResult.success) { 25 | throw new Error(`Failed to get error details: ${errorsResult.error.message}`); 26 | } 27 | 28 | return { 29 | content: [{ type: "text", text: JSON.stringify(errorsResult.data.results) }], 30 | }; 31 | }; 32 | 33 | const tool = (): ToolBase => ({ 34 | name: "error-details", 35 | schema, 36 | handler: errorDetailsHandler, 37 | }); 38 | 39 | export default tool; 40 | -------------------------------------------------------------------------------- /typescript/src/tools/experiments/create.ts: -------------------------------------------------------------------------------- 1 | import { ExperimentCreateSchema } from "@/schema/tool-inputs"; 2 | import type { Context, ToolBase } from "@/tools/types"; 3 | import type { z } from "zod"; 4 | 5 | const schema = ExperimentCreateSchema; 6 | 7 | type Params = z.infer; 8 | 9 | /** 10 | * Create a comprehensive A/B test experiment with guided setup 11 | * This tool helps users create well-configured experiments through conversation 12 | */ 13 | export const createExperimentHandler = async (context: Context, params: Params) => { 14 | const projectId = await context.stateManager.getProjectId(); 15 | 16 | const result = await context.api.experiments({ projectId }).create(params); 17 | 18 | if (!result.success) { 19 | throw new Error(`Failed to create experiment: ${result.error.message}`); 20 | } 21 | 22 | const experiment = result.data; 23 | const experimentWithUrl = { 24 | ...experiment, 25 | url: `${context.api.getProjectBaseUrl(projectId)}/experiments/${experiment.id}`, 26 | }; 27 | 28 | return { 29 | content: [ 30 | { 31 | type: "text", 32 | text: JSON.stringify(experimentWithUrl, null, 2), 33 | }, 34 | ], 35 | }; 36 | }; 37 | 38 | const tool = (): ToolBase => ({ 39 | name: "experiment-create", 40 | schema, 41 | handler: createExperimentHandler, 42 | }); 43 | 44 | export default tool; 45 | -------------------------------------------------------------------------------- /typescript/src/lib/utils/SessionManager.ts: -------------------------------------------------------------------------------- 1 | import type { ScopedCache } from "@/lib/utils/cache/ScopedCache"; 2 | import type { State } from "@/tools"; 3 | import { v7 as uuidv7 } from "uuid"; 4 | 5 | export class SessionManager { 6 | private cache: ScopedCache; 7 | 8 | constructor(cache: ScopedCache) { 9 | this.cache = cache; 10 | } 11 | 12 | async _getKey(sessionId: string): Promise> { 13 | return `session:${sessionId}`; 14 | } 15 | 16 | async getSessionUuid(sessionId: string): Promise { 17 | const key = await this._getKey(sessionId); 18 | 19 | const existingSession = await this.cache.get(key); 20 | 21 | if (existingSession?.uuid) { 22 | return existingSession.uuid; 23 | } 24 | 25 | const newSessionUuid = uuidv7(); 26 | 27 | await this.cache.set(key, { uuid: newSessionUuid }); 28 | 29 | return newSessionUuid; 30 | } 31 | 32 | async hasSession(sessionId: string): Promise { 33 | const key = await this._getKey(sessionId); 34 | 35 | const session = await this.cache.get(key); 36 | return !!session?.uuid; 37 | } 38 | 39 | async removeSession(sessionId: string): Promise { 40 | const key = await this._getKey(sessionId); 41 | 42 | await this.cache.delete(key); 43 | } 44 | 45 | async clearAllSessions(): Promise { 46 | await this.cache.clear(); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /typescript/src/tools/featureFlags/delete.ts: -------------------------------------------------------------------------------- 1 | import { FeatureFlagDeleteSchema } from "@/schema/tool-inputs"; 2 | import type { Context, ToolBase } from "@/tools/types"; 3 | import type { z } from "zod"; 4 | 5 | const schema = FeatureFlagDeleteSchema; 6 | 7 | type Params = z.infer; 8 | 9 | export const deleteHandler = async (context: Context, params: Params) => { 10 | const { flagKey } = params; 11 | const projectId = await context.stateManager.getProjectId(); 12 | 13 | const flagResult = await context.api.featureFlags({ projectId }).findByKey({ key: flagKey }); 14 | if (!flagResult.success) { 15 | throw new Error(`Failed to find feature flag: ${flagResult.error.message}`); 16 | } 17 | 18 | if (!flagResult.data) { 19 | return { 20 | content: [{ type: "text", text: "Feature flag is already deleted." }], 21 | }; 22 | } 23 | 24 | const deleteResult = await context.api.featureFlags({ projectId }).delete({ 25 | flagId: flagResult.data.id, 26 | }); 27 | if (!deleteResult.success) { 28 | throw new Error(`Failed to delete feature flag: ${deleteResult.error.message}`); 29 | } 30 | 31 | return { 32 | content: [{ type: "text", text: JSON.stringify(deleteResult.data) }], 33 | }; 34 | }; 35 | 36 | const tool = (): ToolBase => ({ 37 | name: "delete-feature-flag", 38 | schema, 39 | handler: deleteHandler, 40 | }); 41 | 42 | export default tool; 43 | -------------------------------------------------------------------------------- /examples/langchain/README.md: -------------------------------------------------------------------------------- 1 | # PostHog LangChain Python Integration Example 2 | 3 | This example demonstrates how to use PostHog tools with LangChain using the `posthog_agent_toolkit` package, which provides a wrapper around the PostHog MCP (Model Context Protocol) server. 4 | 5 | ## Setup 6 | 7 | 1. Install dependencies: 8 | ```bash 9 | pip install posthog-agent-toolkit 10 | # Or if using uv: 11 | uv sync 12 | ``` 13 | 14 | 2. Copy the environment file and fill in your credentials: 15 | ```bash 16 | cp .env.example .env 17 | ``` 18 | 19 | 3. Update your `.env` file with: 20 | - `POSTHOG_PERSONAL_API_KEY`: Your PostHog personal API key 21 | - `OPENAI_API_KEY`: Your OpenAI API key 22 | 23 | ## Usage 24 | 25 | Run the example: 26 | ```bash 27 | python posthog_agent_example.py 28 | # Or if using uv: 29 | uv run python posthog_agent_example.py 30 | ``` 31 | 32 | The example will: 33 | 1. Connect to the PostHog MCP server using your personal API key 34 | 2. Load all available PostHog tools from the MCP server 35 | 3. Create a LangChain agent with access to PostHog data 36 | 4. Analyze product usage by: 37 | - Getting available insights 38 | - Querying data for the most relevant ones 39 | - Summarizing key findings 40 | 41 | ## Available Tools 42 | 43 | For a complete list of all available tools and their capabilities, see the [PostHog MCP documentation](https://posthog.com/docs/model-context-protocol). -------------------------------------------------------------------------------- /typescript/src/tools/llmAnalytics/getLLMCosts.ts: -------------------------------------------------------------------------------- 1 | import { LLMAnalyticsGetCostsSchema } from "@/schema/tool-inputs"; 2 | import type { Context, ToolBase } from "@/tools/types"; 3 | import type { z } from "zod"; 4 | 5 | const schema = LLMAnalyticsGetCostsSchema; 6 | 7 | type Params = z.infer; 8 | 9 | export const getLLMCostsHandler = async (context: Context, params: Params) => { 10 | const { projectId, days } = params; 11 | 12 | const trendsQuery = { 13 | kind: "TrendsQuery", 14 | dateRange: { 15 | date_from: `-${days || 6}d`, 16 | date_to: null, 17 | }, 18 | filterTestAccounts: true, 19 | series: [ 20 | { 21 | event: "$ai_generation", 22 | name: "$ai_generation", 23 | math: "sum", 24 | math_property: "$ai_total_cost_usd", 25 | kind: "EventsNode", 26 | }, 27 | ], 28 | breakdownFilter: { 29 | breakdown_type: "event", 30 | breakdown: "$ai_model", 31 | }, 32 | }; 33 | 34 | const costsResult = await context.api 35 | .query({ projectId: String(projectId) }) 36 | .execute({ queryBody: trendsQuery }); 37 | if (!costsResult.success) { 38 | throw new Error(`Failed to get LLM costs: ${costsResult.error.message}`); 39 | } 40 | return { 41 | content: [{ type: "text", text: JSON.stringify(costsResult.data.results) }], 42 | }; 43 | }; 44 | 45 | const tool = (): ToolBase => ({ 46 | name: "get-llm-total-costs-for-project", 47 | schema, 48 | handler: getLLMCostsHandler, 49 | }); 50 | 51 | export default tool; 52 | -------------------------------------------------------------------------------- /typescript/src/tools/errorTracking/listErrors.ts: -------------------------------------------------------------------------------- 1 | import { ErrorTrackingListSchema } from "@/schema/tool-inputs"; 2 | import type { Context, ToolBase } from "@/tools/types"; 3 | import type { z } from "zod"; 4 | 5 | const schema = ErrorTrackingListSchema; 6 | 7 | type Params = z.infer; 8 | 9 | export const listErrorsHandler = async (context: Context, params: Params) => { 10 | const { orderBy, dateFrom, dateTo, orderDirection, filterTestAccounts, status } = params; 11 | const projectId = await context.stateManager.getProjectId(); 12 | 13 | const errorQuery = { 14 | kind: "ErrorTrackingQuery", 15 | orderBy: orderBy || "occurrences", 16 | dateRange: { 17 | date_from: dateFrom || new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(), 18 | date_to: dateTo || new Date().toISOString(), 19 | }, 20 | volumeResolution: 1, 21 | orderDirection: orderDirection || "DESC", 22 | filterTestAccounts: filterTestAccounts ?? true, 23 | status: status || "active", 24 | }; 25 | 26 | const errorsResult = await context.api.query({ projectId }).execute({ queryBody: errorQuery }); 27 | if (!errorsResult.success) { 28 | throw new Error(`Failed to list errors: ${errorsResult.error.message}`); 29 | } 30 | 31 | return { 32 | content: [{ type: "text", text: JSON.stringify(errorsResult.data.results) }], 33 | }; 34 | }; 35 | 36 | const tool = (): ToolBase => ({ 37 | name: "list-errors", 38 | schema, 39 | handler: listErrorsHandler, 40 | }); 41 | 42 | export default tool; 43 | -------------------------------------------------------------------------------- /typescript/src/tools/insights/query.ts: -------------------------------------------------------------------------------- 1 | import { InsightQueryInputSchema } from "@/schema/tool-inputs"; 2 | import type { Context, ToolBase } from "@/tools/types"; 3 | import type { z } from "zod"; 4 | 5 | const schema = InsightQueryInputSchema; 6 | 7 | type Params = z.infer; 8 | 9 | export const queryHandler = async (context: Context, params: Params) => { 10 | const { insightId } = params; 11 | const projectId = await context.stateManager.getProjectId(); 12 | 13 | const insightResult = await context.api.insights({ projectId }).get({ insightId }); 14 | 15 | if (!insightResult.success) { 16 | throw new Error(`Failed to get insight: ${insightResult.error.message}`); 17 | } 18 | 19 | // Query the insight with parameters to get actual results 20 | const queryResult = await context.api.insights({ projectId }).query({ 21 | query: insightResult.data.query, 22 | }); 23 | 24 | if (!queryResult.success) { 25 | throw new Error(`Failed to query insight: ${queryResult.error.message}`); 26 | } 27 | 28 | const responseData = { 29 | insight: { 30 | url: `${context.api.getProjectBaseUrl(projectId)}/insights/${insightResult.data.short_id}`, 31 | ...insightResult.data, 32 | }, 33 | results: queryResult.data.results, 34 | }; 35 | 36 | return { content: [{ type: "text", text: JSON.stringify(responseData) }] }; 37 | }; 38 | 39 | const tool = (): ToolBase => ({ 40 | name: "insight-query", 41 | schema, 42 | handler: queryHandler, 43 | }); 44 | 45 | export default tool; 46 | -------------------------------------------------------------------------------- /typescript/src/lib/utils/api.ts: -------------------------------------------------------------------------------- 1 | import { ApiListResponseSchema } from "@/schema/api"; 2 | 3 | import type { z } from "zod"; 4 | 5 | export const withPagination = async ( 6 | url: string, 7 | apiToken: string, 8 | dataSchema: z.ZodType, 9 | ): Promise => { 10 | const response = await fetch(url, { 11 | headers: { 12 | Authorization: `Bearer ${apiToken}`, 13 | }, 14 | }); 15 | 16 | if (!response.ok) { 17 | throw new Error(`Failed to fetch ${url}: ${response.statusText}`); 18 | } 19 | 20 | const data = await response.json(); 21 | 22 | const responseSchema = ApiListResponseSchema>(dataSchema); 23 | 24 | const parsedData = responseSchema.parse(data); 25 | 26 | const results = parsedData.results.map((result: T) => result); 27 | 28 | if (parsedData.next) { 29 | const nextResults: T[] = await withPagination(parsedData.next, apiToken, dataSchema); 30 | return [...results, ...nextResults]; 31 | } 32 | 33 | return results; 34 | }; 35 | 36 | export const hasScope = (scopes: string[], requiredScope: string) => { 37 | if (scopes.includes("*")) { 38 | return true; 39 | } 40 | 41 | // if read scoped required, and write present, return true 42 | if ( 43 | requiredScope.endsWith(":read") && 44 | scopes.includes(requiredScope.replace(":read", ":write")) 45 | ) { 46 | return true; 47 | } 48 | 49 | return scopes.includes(requiredScope); 50 | }; 51 | 52 | export const hasScopes = (scopes: string[], requiredScopes: string[]) => { 53 | return requiredScopes.every((scope) => hasScope(scopes, scope)); 54 | }; 55 | -------------------------------------------------------------------------------- /typescript/src/api/fetcher.ts: -------------------------------------------------------------------------------- 1 | import type { ApiConfig } from "./client"; 2 | import type { createApiClient } from "./generated"; 3 | 4 | export const buildApiFetcher: (config: ApiConfig) => Parameters[0] = ( 5 | config, 6 | ) => { 7 | return { 8 | fetch: async (input) => { 9 | const headers = new Headers(); 10 | headers.set("Authorization", `Bearer ${config.apiToken}`); 11 | 12 | // Handle query parameters 13 | if (input.urlSearchParams) { 14 | input.url.search = input.urlSearchParams.toString(); 15 | } 16 | 17 | // Handle request body for mutation methods 18 | const body = ["post", "put", "patch", "delete"].includes(input.method.toLowerCase()) 19 | ? JSON.stringify(input.parameters?.body) 20 | : undefined; 21 | 22 | if (body) { 23 | headers.set("Content-Type", "application/json"); 24 | } 25 | 26 | // Add custom headers 27 | if (input.parameters?.header) { 28 | for (const [key, value] of Object.entries(input.parameters.header)) { 29 | if (value != null) { 30 | headers.set(key, String(value)); 31 | } 32 | } 33 | } 34 | 35 | const response = await fetch(input.url, { 36 | method: input.method.toUpperCase(), 37 | ...(body && { body }), 38 | headers, 39 | ...input.overrides, 40 | }); 41 | 42 | if (!response.ok) { 43 | const errorResponse = await response.json(); 44 | throw new Error( 45 | `Failed request: [${response.status}] ${JSON.stringify(errorResponse)}`, 46 | ); 47 | } 48 | 49 | return response; 50 | }, 51 | }; 52 | }; 53 | -------------------------------------------------------------------------------- /typescript/src/tools/surveys/utils/survey-utils.ts: -------------------------------------------------------------------------------- 1 | import type { SurveyListItemOutput, SurveyOutput } from "@/schema/surveys"; 2 | import type { Context } from "@/tools/types"; 3 | 4 | type SurveyData = SurveyOutput | SurveyListItemOutput; 5 | 6 | export interface FormattedSurvey extends Omit { 7 | status: "draft" | "active" | "completed" | "archived"; 8 | end_date?: string | undefined; 9 | url?: string; 10 | } 11 | 12 | /** 13 | * Formats a survey with consistent status logic and additional fields 14 | */ 15 | export function formatSurvey( 16 | survey: SurveyData, 17 | context: Context, 18 | projectId: string, 19 | ): FormattedSurvey { 20 | const status = survey.archived 21 | ? "archived" 22 | : survey.start_date === null || survey.start_date === undefined 23 | ? "draft" 24 | : survey.end_date 25 | ? "completed" 26 | : "active"; 27 | 28 | const formatted: FormattedSurvey = { 29 | ...survey, 30 | status, 31 | end_date: survey.end_date || undefined, // Don't show null end_date 32 | }; 33 | 34 | // Add URL if we have context and survey ID 35 | if (context && survey.id && projectId) { 36 | const baseUrl = context.api.getProjectBaseUrl(projectId); 37 | formatted.url = `${baseUrl}/surveys/${survey.id}`; 38 | } 39 | 40 | return formatted; 41 | } 42 | 43 | /** 44 | * Formats multiple surveys consistently 45 | */ 46 | export function formatSurveys( 47 | surveys: SurveyData[], 48 | context: Context, 49 | projectId: string, 50 | ): FormattedSurvey[] { 51 | return surveys.map((survey) => formatSurvey(survey, context, projectId)); 52 | } 53 | -------------------------------------------------------------------------------- /typescript/src/tools/projects/propertyDefinitions.ts: -------------------------------------------------------------------------------- 1 | import { PropertyDefinitionSchema } from "@/schema/properties"; 2 | import { ProjectPropertyDefinitionsInputSchema } from "@/schema/tool-inputs"; 3 | import type { Context, ToolBase } from "@/tools/types"; 4 | import type { z } from "zod"; 5 | 6 | const schema = ProjectPropertyDefinitionsInputSchema; 7 | 8 | type Params = z.infer; 9 | 10 | export const propertyDefinitionsHandler = async (context: Context, params: Params) => { 11 | const projectId = await context.stateManager.getProjectId(); 12 | 13 | if (!params.eventName && params.type === "event") { 14 | throw new Error("eventName is required for event type"); 15 | } 16 | 17 | const propDefsResult = await context.api.projects().propertyDefinitions({ 18 | projectId, 19 | eventNames: params.eventName ? [params.eventName] : undefined, 20 | filterByEventNames: params.type === "event", 21 | isFeatureFlag: false, 22 | limit: 200, 23 | type: params.type, 24 | excludeCoreProperties: !params.includePredefinedProperties, 25 | }); 26 | 27 | if (!propDefsResult.success) { 28 | throw new Error( 29 | `Failed to get property definitions for ${params.type}s: ${propDefsResult.error.message}`, 30 | ); 31 | } 32 | 33 | const simplifiedProperties = PropertyDefinitionSchema.array().parse(propDefsResult.data); 34 | 35 | return { 36 | content: [{ type: "text", text: JSON.stringify(simplifiedProperties) }], 37 | }; 38 | }; 39 | 40 | const tool = (): ToolBase => ({ 41 | name: "properties-list", 42 | schema, 43 | handler: propertyDefinitionsHandler, 44 | }); 45 | 46 | export default tool; 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "posthog-mcp-monorepo", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "prepare": "husky", 7 | "dev": "cd typescript && pnpm dev", 8 | "build": "cd typescript && pnpm build", 9 | "inspector": "cd typescript && pnpm inspector", 10 | "schema:build:json": "tsx typescript/scripts/generate-tool-schema.ts", 11 | "test": "cd typescript && pnpm test", 12 | "test:integration": "cd typescript && pnpm test:integration", 13 | "schema:build:python": "bash python/scripts/generate-pydantic-models.sh", 14 | "schema:build": "pnpm run schema:build:json && pnpm run schema:build:python", 15 | "format": "biome format --write", 16 | "lint": "biome lint --fix", 17 | "format:python": "cd python && uv run ruff format .", 18 | "lint:python": "cd python && uv run ruff check --fix .", 19 | "test:python": "cd python && uv run pytest tests/ -v", 20 | "typecheck": "cd typescript && pnpm typecheck", 21 | "typecheck:python": "cd python && uvx ty check", 22 | "docker:build": "docker build -t posthog-mcp .", 23 | "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", 24 | "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" 25 | }, 26 | "devDependencies": { 27 | "@biomejs/biome": "1.9.4", 28 | "husky": "^9.1.7", 29 | "tsx": "^4.20.3" 30 | }, 31 | "packageManager": "pnpm@9.15.5+sha256.8472168c3e1fd0bff287e694b053fccbbf20579a3ff9526b6333beab8df65a8d" 32 | } 33 | -------------------------------------------------------------------------------- /typescript/src/tools/types.ts: -------------------------------------------------------------------------------- 1 | import type { ApiClient } from "@/api/client"; 2 | import type { StateManager } from "@/lib/utils/StateManager"; 3 | import type { SessionManager } from "@/lib/utils/SessionManager"; 4 | import type { ScopedCache } from "@/lib/utils/cache/ScopedCache"; 5 | import type { ApiRedactedPersonalApiKey } from "@/schema/api"; 6 | import type { z } from "zod"; 7 | 8 | export type CloudRegion = "us" | "eu"; 9 | 10 | export type SessionState = { 11 | uuid: string; 12 | }; 13 | 14 | export type State = { 15 | projectId: string | undefined; 16 | orgId: string | undefined; 17 | distinctId: string | undefined; 18 | region: CloudRegion | undefined; 19 | apiKey: ApiRedactedPersonalApiKey | undefined; 20 | } & Record, SessionState>; 21 | 22 | export type Env = { 23 | INKEEP_API_KEY: string | undefined; 24 | }; 25 | 26 | export type Context = { 27 | api: ApiClient; 28 | cache: ScopedCache; 29 | env: Env; 30 | stateManager: StateManager; 31 | sessionManager: SessionManager; 32 | }; 33 | 34 | export type Tool = { 35 | name: string; 36 | title: string; 37 | description: string; 38 | schema: TSchema; 39 | handler: (context: Context, params: z.infer) => Promise; 40 | scopes: string[]; 41 | annotations: { 42 | destructiveHint: boolean; 43 | idempotentHint: boolean; 44 | openWorldHint: boolean; 45 | readOnlyHint: boolean; 46 | }; 47 | }; 48 | 49 | export type ToolBase = Omit< 50 | Tool, 51 | "title" | "description" | "scopes" | "annotations" 52 | >; 53 | 54 | export type ZodObjectAny = z.ZodObject; 55 | -------------------------------------------------------------------------------- /typescript/src/tools/dashboards/addInsight.ts: -------------------------------------------------------------------------------- 1 | import { DashboardAddInsightSchema } from "@/schema/tool-inputs"; 2 | import { resolveInsightId } from "@/tools/insights/utils"; 3 | import type { Context, ToolBase } from "@/tools/types"; 4 | import type { z } from "zod"; 5 | 6 | const schema = DashboardAddInsightSchema; 7 | 8 | type Params = z.infer; 9 | 10 | export const addInsightHandler = async (context: Context, params: Params) => { 11 | const { data } = params; 12 | const projectId = await context.stateManager.getProjectId(); 13 | 14 | const numericInsightId = await resolveInsightId(context, data.insightId, projectId); 15 | 16 | const insightResult = await context.api 17 | .insights({ projectId }) 18 | .get({ insightId: data.insightId }); 19 | 20 | if (!insightResult.success) { 21 | throw new Error(`Failed to get insight: ${insightResult.error.message}`); 22 | } 23 | 24 | const result = await context.api.dashboards({ projectId }).addInsight({ 25 | data: { 26 | ...data, 27 | insightId: numericInsightId, 28 | }, 29 | }); 30 | 31 | if (!result.success) { 32 | throw new Error(`Failed to add insight to dashboard: ${result.error.message}`); 33 | } 34 | 35 | const resultWithUrls = { 36 | ...result.data, 37 | dashboard_url: `${context.api.getProjectBaseUrl(projectId)}/dashboard/${data.dashboardId}`, 38 | insight_url: `${context.api.getProjectBaseUrl(projectId)}/insights/${insightResult.data.short_id}`, 39 | }; 40 | 41 | return { content: [{ type: "text", text: JSON.stringify(resultWithUrls) }] }; 42 | }; 43 | 44 | const tool = (): ToolBase => ({ 45 | name: "add-insight-to-dashboard", 46 | schema, 47 | handler: addInsightHandler, 48 | }); 49 | 50 | export default tool; 51 | -------------------------------------------------------------------------------- /typescript/src/tools/experiments/getResults.ts: -------------------------------------------------------------------------------- 1 | import { ExperimentResultsResponseSchema } from "@/schema/experiments"; 2 | import { ExperimentResultsGetSchema } from "@/schema/tool-inputs"; 3 | import type { Context, ToolBase } from "@/tools/types"; 4 | import type { z } from "zod"; 5 | 6 | const schema = ExperimentResultsGetSchema; 7 | 8 | type Params = z.infer; 9 | 10 | /** 11 | * Get experiment results including metrics and exposures data 12 | * This tool fetches the experiment details and executes the necessary queries 13 | * to get metrics results (both primary and secondary) and exposure data 14 | */ 15 | export const getResultsHandler = async (context: Context, params: Params) => { 16 | const projectId = await context.stateManager.getProjectId(); 17 | 18 | const result = await context.api.experiments({ projectId }).getMetricResults({ 19 | experimentId: params.experimentId, 20 | refresh: params.refresh, 21 | }); 22 | 23 | if (!result.success) { 24 | throw new Error(`Failed to get experiment results: ${result.error.message}`); 25 | } 26 | 27 | const { experiment, primaryMetricsResults, secondaryMetricsResults, exposures } = result.data; 28 | 29 | // Format the response using the schema 30 | const parsedExperiment = ExperimentResultsResponseSchema.parse({ 31 | experiment, 32 | primaryMetricsResults, 33 | secondaryMetricsResults, 34 | exposures, 35 | }); 36 | 37 | return { 38 | content: [ 39 | { 40 | type: "text", 41 | text: JSON.stringify(parsedExperiment, null, 2), 42 | }, 43 | ], 44 | }; 45 | }; 46 | 47 | const tool = (): ToolBase => ({ 48 | name: "experiment-results-get", 49 | schema, 50 | handler: getResultsHandler, 51 | }); 52 | 53 | export default tool; 54 | -------------------------------------------------------------------------------- /typescript/src/tools/surveys/create.ts: -------------------------------------------------------------------------------- 1 | import { SurveyCreateSchema } from "@/schema/tool-inputs"; 2 | import { formatSurvey } from "@/tools/surveys/utils/survey-utils"; 3 | import type { Context, ToolBase } from "@/tools/types"; 4 | import type { z } from "zod"; 5 | 6 | const schema = SurveyCreateSchema; 7 | type Params = z.infer; 8 | 9 | export const createHandler = async (context: Context, params: Params) => { 10 | const projectId = await context.stateManager.getProjectId(); 11 | 12 | // Process questions to handle branching logic 13 | if (params.questions) { 14 | params.questions = params.questions.map((question: any) => { 15 | // Handle single choice questions - convert numeric keys to strings 16 | if ( 17 | "branching" in question && 18 | question.branching?.type === "response_based" && 19 | question.type === "single_choice" 20 | ) { 21 | question.branching.responseValues = Object.fromEntries( 22 | Object.entries(question.branching.responseValues).map(([key, value]) => { 23 | return [String(key), value]; 24 | }), 25 | ); 26 | } 27 | return question; 28 | }); 29 | } 30 | 31 | const surveyResult = await context.api.surveys({ projectId }).create({ 32 | data: params, 33 | }); 34 | 35 | if (!surveyResult.success) { 36 | throw new Error(`Failed to create survey: ${surveyResult.error.message}`); 37 | } 38 | 39 | const formattedSurvey = formatSurvey(surveyResult.data, context, projectId); 40 | 41 | return { 42 | content: [{ type: "text", text: JSON.stringify(formattedSurvey) }], 43 | }; 44 | }; 45 | 46 | const tool = (): ToolBase => ({ 47 | name: "survey-create", 48 | schema, 49 | handler: createHandler, 50 | }); 51 | 52 | export default tool; 53 | -------------------------------------------------------------------------------- /typescript/src/tools/surveys/update.ts: -------------------------------------------------------------------------------- 1 | import { SurveyUpdateSchema } from "@/schema/tool-inputs"; 2 | import { formatSurvey } from "@/tools/surveys/utils/survey-utils"; 3 | import type { Context, ToolBase } from "@/tools/types"; 4 | import type { z } from "zod"; 5 | 6 | const schema = SurveyUpdateSchema; 7 | type Params = z.infer; 8 | 9 | export const updateHandler = async (context: Context, params: Params) => { 10 | const { surveyId, ...data } = params; 11 | 12 | const projectId = await context.stateManager.getProjectId(); 13 | 14 | if (data.questions) { 15 | data.questions = data.questions.map((question: any) => { 16 | // Handle single choice questions - convert numeric keys to strings 17 | if ( 18 | "branching" in question && 19 | question.branching?.type === "response_based" && 20 | question.type === "single_choice" 21 | ) { 22 | question.branching.responseValues = Object.fromEntries( 23 | Object.entries(question.branching.responseValues).map(([key, value]) => { 24 | return [String(key), value]; 25 | }), 26 | ); 27 | } 28 | return question; 29 | }); 30 | } 31 | 32 | const surveyResult = await context.api.surveys({ projectId }).update({ 33 | surveyId, 34 | data, 35 | }); 36 | 37 | if (!surveyResult.success) { 38 | throw new Error(`Failed to update survey: ${surveyResult.error.message}`); 39 | } 40 | 41 | const formattedSurvey = formatSurvey(surveyResult.data, context, projectId); 42 | 43 | return { 44 | content: [{ type: "text", text: JSON.stringify(formattedSurvey) }], 45 | }; 46 | }; 47 | 48 | const tool = (): ToolBase => ({ 49 | name: "survey-update", 50 | schema, 51 | handler: updateHandler, 52 | }); 53 | 54 | export default tool; 55 | -------------------------------------------------------------------------------- /typescript/src/tools/toolDefinitions.ts: -------------------------------------------------------------------------------- 1 | import z from "zod"; 2 | import toolDefinitionsJson from "../../../schema/tool-definitions.json"; 3 | 4 | export const ToolDefinitionSchema = z.object({ 5 | description: z.string(), 6 | category: z.string(), 7 | feature: z.string(), 8 | summary: z.string(), 9 | title: z.string(), 10 | required_scopes: z.array(z.string()), 11 | annotations: z.object({ 12 | destructiveHint: z.boolean(), 13 | idempotentHint: z.boolean(), 14 | openWorldHint: z.boolean(), 15 | readOnlyHint: z.boolean(), 16 | }), 17 | }); 18 | 19 | export type ToolDefinition = z.infer; 20 | 21 | export type ToolDefinitions = Record; 22 | 23 | let _toolDefinitions: ToolDefinitions | undefined = undefined; 24 | 25 | export function getToolDefinitions(): ToolDefinitions { 26 | if (!_toolDefinitions) { 27 | _toolDefinitions = z.record(z.string(), ToolDefinitionSchema).parse(toolDefinitionsJson); 28 | } 29 | return _toolDefinitions; 30 | } 31 | 32 | export function getToolDefinition(toolName: string): ToolDefinition { 33 | const toolDefinitions = getToolDefinitions(); 34 | 35 | const definition = toolDefinitions[toolName]; 36 | 37 | if (!definition) { 38 | throw new Error(`Tool definition not found for: ${toolName}`); 39 | } 40 | 41 | return definition; 42 | } 43 | 44 | export function getToolsForFeatures(features?: string[]): string[] { 45 | const toolDefinitions = getToolDefinitions(); 46 | 47 | if (!features || features.length === 0) { 48 | return Object.keys(toolDefinitions); 49 | } 50 | 51 | return Object.entries(toolDefinitions) 52 | .filter(([_, definition]) => definition.feature && features.includes(definition.feature)) 53 | .map(([toolName, _]) => toolName); 54 | } 55 | -------------------------------------------------------------------------------- /typescript/src/lib/utils/cache/DurableObjectCache.ts: -------------------------------------------------------------------------------- 1 | import { ScopedCache } from "@/lib/utils/cache/ScopedCache"; 2 | 3 | interface DurableObjectStorage { 4 | get(key: string): Promise; 5 | put(key: string, value: T): Promise; 6 | delete(key: string): Promise; 7 | delete(keys: string[]): Promise; 8 | list(options?: { 9 | prefix?: string; 10 | start?: string; 11 | end?: string; 12 | limit?: number; 13 | reverse?: boolean; 14 | }): Promise>; 15 | } 16 | 17 | export class DurableObjectCache> extends ScopedCache { 18 | private storage: DurableObjectStorage; 19 | private userHash: string; 20 | 21 | constructor(scope: string, storage: DurableObjectStorage) { 22 | super(scope); 23 | this.userHash = scope; 24 | this.storage = storage; 25 | } 26 | 27 | private getScopedKey(key: string): string { 28 | return `user:${this.userHash}:${key}`; 29 | } 30 | 31 | async get(key: K): Promise { 32 | const scopedKey = this.getScopedKey(key as string); 33 | return await this.storage.get(scopedKey); 34 | } 35 | 36 | async set(key: K, value: T[K]): Promise { 37 | const scopedKey = this.getScopedKey(key as string); 38 | await this.storage.put(scopedKey, value); 39 | } 40 | 41 | async delete(key: K): Promise { 42 | const scopedKey = this.getScopedKey(key as string); 43 | await this.storage.delete(scopedKey); 44 | } 45 | 46 | async clear(): Promise { 47 | const prefix = `user:${this.userHash}:`; 48 | const keys = await this.storage.list({ prefix }); 49 | const keysArray = Array.from(keys.keys()); 50 | await this.storage.delete(keysArray); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /python/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "posthog-agent-toolkit" 3 | version = "0.1.2" 4 | description = "PostHog Agent Toolkit for LangChain and other AI frameworks" 5 | readme = "README.md" 6 | requires-python = ">=3.11" 7 | authors = [ 8 | { name = "PostHog", email = "hey@posthog.com" }, 9 | ] 10 | dependencies = [ 11 | "pydantic>=2.5.0", 12 | "httpx>=0.25.0", 13 | "typing-extensions>=4.8.0", 14 | "python-dateutil>=2.8.2", 15 | "python-dotenv>=1.0.0", 16 | "langchain-mcp-adapters>=0.1.0", 17 | "langchain-core>=0.1.0", 18 | ] 19 | 20 | [dependency-groups] 21 | dev = [ 22 | "datamodel-code-generator[http]>=0.25.0", 23 | "ruff>=0.1.0", 24 | "pytest>=7.0.0", 25 | "pytest-asyncio>=0.21.0", 26 | "build>=1.3.0", 27 | "twine>=6.2.0", 28 | ] 29 | 30 | [build-system] 31 | requires = ["hatchling"] 32 | build-backend = "hatchling.build" 33 | 34 | [tool.hatch.build.targets.wheel] 35 | packages = ["posthog_agent_toolkit"] 36 | 37 | [tool.hatch.build.targets.sdist] 38 | exclude = [ 39 | ".venv/", 40 | "dist/", 41 | "*.egg-info/", 42 | ] 43 | 44 | [tool.ruff] 45 | target-version = "py311" 46 | line-length = 160 47 | indent-width = 4 48 | exclude = [ 49 | "schema/tool_inputs.py", # Auto-generated file 50 | ] 51 | 52 | [tool.ruff.lint] 53 | select = [ 54 | "E", # pycodestyle errors 55 | "W", # pycodestyle warnings 56 | "F", # pyflakes 57 | "I", # isort 58 | "B", # flake8-bugbear 59 | "C4", # flake8-comprehensions 60 | "UP", # pyupgrade 61 | ] 62 | ignore = [ 63 | "E501", # line too long 64 | ] 65 | 66 | [tool.ruff.format] 67 | quote-style = "double" 68 | indent-style = "space" 69 | skip-magic-trailing-comma = false 70 | line-ending = "auto" 71 | 72 | [tool.uv] 73 | dev-dependencies = [] 74 | -------------------------------------------------------------------------------- /typescript/src/tools/experiments/update.ts: -------------------------------------------------------------------------------- 1 | import { ExperimentUpdateTransformSchema } from "@/schema/experiments"; 2 | import { ExperimentUpdateSchema } from "@/schema/tool-inputs"; 3 | import { getToolDefinition } from "@/tools/toolDefinitions"; 4 | import type { Context, Tool } from "@/tools/types"; 5 | import type { z } from "zod"; 6 | 7 | const schema = ExperimentUpdateSchema; 8 | 9 | type Params = z.infer; 10 | 11 | export const updateHandler = async (context: Context, params: Params) => { 12 | const { experimentId, data } = params; 13 | const projectId = await context.stateManager.getProjectId(); 14 | 15 | // Transform the tool input to API payload format 16 | const apiPayload = ExperimentUpdateTransformSchema.parse(data); 17 | 18 | const updateResult = await context.api.experiments({ projectId }).update({ 19 | experimentId, 20 | updateData: apiPayload, 21 | }); 22 | 23 | if (!updateResult.success) { 24 | throw new Error(`Failed to update experiment: ${updateResult.error.message}`); 25 | } 26 | 27 | const experimentWithUrl = { 28 | ...updateResult.data, 29 | url: `${context.api.getProjectBaseUrl(projectId)}/experiments/${updateResult.data.id}`, 30 | }; 31 | 32 | return { 33 | content: [{ type: "text", text: JSON.stringify(experimentWithUrl, null, 2) }], 34 | }; 35 | }; 36 | 37 | const definition = getToolDefinition("experiment-update"); 38 | 39 | const tool = (): Tool => ({ 40 | name: "experiment-update", 41 | title: definition.title, 42 | description: definition.description, 43 | schema, 44 | handler: updateHandler, 45 | scopes: ["experiments:write"], 46 | annotations: { 47 | destructiveHint: false, 48 | idempotentHint: true, 49 | openWorldHint: true, 50 | readOnlyHint: false, 51 | }, 52 | }); 53 | 54 | export default tool; 55 | -------------------------------------------------------------------------------- /typescript/wrangler.jsonc: -------------------------------------------------------------------------------- 1 | /** 2 | * For more details on how to configure Wrangler, refer to: 3 | * https://developers.cloudflare.com/workers/wrangler/configuration/ 4 | */ 5 | { 6 | "$schema": "node_modules/wrangler/config-schema.json", 7 | "name": "mcp1", 8 | "main": "src/integrations/mcp/index.ts", 9 | "compatibility_date": "2025-03-10", 10 | "compatibility_flags": ["nodejs_compat"], 11 | "migrations": [ 12 | { 13 | "new_sqlite_classes": ["MyMCP"], 14 | "tag": "v1" 15 | } 16 | ], 17 | "durable_objects": { 18 | "bindings": [ 19 | { 20 | "class_name": "MyMCP", 21 | "name": "MCP_OBJECT" 22 | } 23 | ] 24 | }, 25 | "observability": { 26 | "enabled": true 27 | } 28 | /** 29 | * Smart Placement 30 | * Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement 31 | */ 32 | // "placement": { "mode": "smart" }, 33 | 34 | /** 35 | * Bindings 36 | * Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including 37 | * databases, object storage, AI inference, real-time communication and more. 38 | * https://developers.cloudflare.com/workers/runtime-apis/bindings/ 39 | */ 40 | 41 | /** 42 | * Environment Variables 43 | * https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables 44 | */ 45 | // "vars": { "MY_VARIABLE": "production_value" }, 46 | /** 47 | * Note: Use secrets to store sensitive data. 48 | * https://developers.cloudflare.com/workers/configuration/secrets/ 49 | */ 50 | 51 | /** 52 | * Static Assets 53 | * https://developers.cloudflare.com/workers/static-assets/binding/ 54 | */ 55 | // "assets": { "directory": "./public/", "binding": "ASSETS" }, 56 | 57 | /** 58 | * Service Bindings (communicate between multiple Workers) 59 | * https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings 60 | */ 61 | // "services": [{ "binding": "MY_SERVICE", "service": "my-service" }] 62 | } 63 | -------------------------------------------------------------------------------- /typescript/src/tools/featureFlags/getDefinition.ts: -------------------------------------------------------------------------------- 1 | import { FeatureFlagGetDefinitionSchema } from "@/schema/tool-inputs"; 2 | import type { Context, ToolBase } from "@/tools/types"; 3 | import type { z } from "zod"; 4 | 5 | const schema = FeatureFlagGetDefinitionSchema; 6 | 7 | type Params = z.infer; 8 | 9 | export const getDefinitionHandler = async (context: Context, { flagId, flagKey }: Params) => { 10 | if (!flagId && !flagKey) { 11 | return { 12 | content: [ 13 | { 14 | type: "text", 15 | text: "Error: Either flagId or flagKey must be provided.", 16 | }, 17 | ], 18 | }; 19 | } 20 | 21 | const projectId = await context.stateManager.getProjectId(); 22 | 23 | if (flagId) { 24 | const flagResult = await context.api 25 | .featureFlags({ projectId }) 26 | .get({ flagId: String(flagId) }); 27 | if (!flagResult.success) { 28 | throw new Error(`Failed to get feature flag: ${flagResult.error.message}`); 29 | } 30 | return { 31 | content: [{ type: "text", text: JSON.stringify(flagResult.data) }], 32 | }; 33 | } 34 | 35 | if (flagKey) { 36 | const flagResult = await context.api 37 | .featureFlags({ projectId }) 38 | .findByKey({ key: flagKey }); 39 | 40 | if (!flagResult.success) { 41 | throw new Error(`Failed to find feature flag: ${flagResult.error.message}`); 42 | } 43 | if (flagResult.data) { 44 | return { 45 | content: [{ type: "text", text: JSON.stringify(flagResult.data) }], 46 | }; 47 | } 48 | return { 49 | content: [ 50 | { 51 | type: "text", 52 | text: `Error: Flag with key "${flagKey}" not found.`, 53 | }, 54 | ], 55 | }; 56 | } 57 | 58 | return { 59 | content: [ 60 | { 61 | type: "text", 62 | text: "Error: Could not determine or find the feature flag.", 63 | }, 64 | ], 65 | }; 66 | }; 67 | 68 | const tool = (): ToolBase => ({ 69 | name: "feature-flag-get-definition", 70 | schema, 71 | handler: getDefinitionHandler, 72 | }); 73 | 74 | export default tool; 75 | -------------------------------------------------------------------------------- /python/README.md: -------------------------------------------------------------------------------- 1 | # posthog-agent-toolkit 2 | 3 | Tools to give agents access to your PostHog data, manage feature flags, create insights, and more. 4 | 5 | This is a Python wrapper around the PostHog MCP (Model Context Protocol) server, providing easy integration with AI frameworks like LangChain. 6 | 7 | ## Installation 8 | 9 | ```bash 10 | pip install posthog-agent-toolkit 11 | ``` 12 | 13 | ## Quick Start 14 | 15 | The toolkit provides integrations for popular AI frameworks: 16 | 17 | ### Using with LangChain 18 | 19 | ```python 20 | from langchain_openai import ChatOpenAI 21 | from langchain.agents import AgentExecutor, create_tool_calling_agent 22 | from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder 23 | from posthog_agent_toolkit.integrations.langchain.toolkit import PostHogAgentToolkit 24 | 25 | # Initialize the PostHog toolkit 26 | toolkit = PostHogAgentToolkit( 27 | personal_api_key="your_posthog_personal_api_key", 28 | url="https://mcp.posthog.com/mcp" # or your own, if you are self hosting the MCP server 29 | ) 30 | 31 | # Get the tools 32 | tools = await toolkit.get_tools() 33 | 34 | # Initialize the LLM 35 | llm = ChatOpenAI(model="gpt-5-mini") 36 | 37 | # Create a prompt 38 | prompt = ChatPromptTemplate.from_messages([ 39 | ("system", "You are a data analyst with access to PostHog analytics"), 40 | ("human", "{input}"), 41 | MessagesPlaceholder("agent_scratchpad"), 42 | ]) 43 | 44 | # Create and run the agent 45 | agent = create_tool_calling_agent(llm=llm, tools=tools, prompt=prompt) 46 | executor = AgentExecutor(agent=agent, tools=tools) 47 | 48 | result = await executor.ainvoke({ 49 | "input": "Analyze our product usage by getting the top 5 most interesting insights and summarising the data from them." 50 | }) 51 | ``` 52 | 53 | **[→ See full LangChain example](https://github.com/posthog/mcp/tree/main/examples/langchain)** 54 | 55 | ## Available Tools 56 | 57 | For a list of all available tools, please see the [docs](https://posthog.com/docs/model-context-protocol). -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: "Publish" 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | workflow_dispatch: 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | check-package-version: 13 | name: Check package version and detect an update 14 | runs-on: ubuntu-24.04 15 | outputs: 16 | committed-version: ${{ steps.check-package-version.outputs.committed-version }} 17 | published-version: ${{ steps.check-package-version.outputs.published-version }} 18 | is-new-version: ${{ steps.check-package-version.outputs.is-new-version }} 19 | steps: 20 | - name: Checkout the repository 21 | uses: actions/checkout@v4 22 | 23 | - name: Check package version and detect an update 24 | id: check-package-version 25 | uses: PostHog/check-package-version@v2 26 | with: 27 | path: typescript/ 28 | 29 | release: 30 | name: Publish release if new version 31 | runs-on: ubuntu-24.04 32 | needs: check-package-version 33 | if: needs.check-package-version.outputs.is-new-version == 'true' 34 | env: 35 | COMMITTED_VERSION: ${{ needs.check-package-version.outputs.committed-version }} 36 | PUBLISHED_VERSION: ${{ needs.check-package-version.outputs.published-version }} 37 | steps: 38 | - name: Checkout the repository 39 | uses: actions/checkout@v4 40 | with: 41 | fetch-depth: 0 42 | 43 | - name: Set up Node 18 44 | uses: actions/setup-node@v4 45 | with: 46 | node-version: 18 47 | registry-url: https://registry.npmjs.org 48 | 49 | - name: Install pnpm 50 | run: npm install -g pnpm 51 | 52 | - name: Install package.json dependencies with pnpm 53 | run: cd typescript && pnpm install 54 | 55 | - name: Build the package 56 | run: cd typescript && pnpm build 57 | 58 | - name: Publish the package in the npm registry 59 | run: cd typescript && pnpm publish --access public 60 | env: 61 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} -------------------------------------------------------------------------------- /python/posthog_agent_toolkit/integrations/langchain/toolkit.py: -------------------------------------------------------------------------------- 1 | """PostHog Agent Toolkit for LangChain using MCP.""" 2 | 3 | from typing import Any 4 | 5 | from langchain_core.tools import BaseTool 6 | from langchain_mcp_adapters.client import MultiServerMCPClient 7 | 8 | 9 | class PostHogAgentToolkit: 10 | """ 11 | A toolkit for interacting with PostHog tools via the MCP server. 12 | """ 13 | 14 | _tools: list[BaseTool] | None 15 | client: MultiServerMCPClient 16 | 17 | def __init__( 18 | self, 19 | url: str = "https://mcp.posthog.com/mcp", 20 | personal_api_key: str | None = None, 21 | ): 22 | """ 23 | Initialize the PostHog Agent Toolkit. 24 | 25 | Args: 26 | url: The URL of the PostHog MCP server (default: https://mcp.posthog.com/mcp/) 27 | personal_api_key: PostHog API key for authentication 28 | """ 29 | 30 | if not personal_api_key: 31 | raise ValueError("A personal API key is required.") 32 | 33 | config = self._get_config(url, personal_api_key) 34 | 35 | self.client = MultiServerMCPClient(config) 36 | 37 | self._tools: list[BaseTool] | None = None 38 | 39 | @staticmethod 40 | def _get_config(url: str, personal_api_key: str) -> dict[str, dict[str, Any]]: 41 | return { 42 | "posthog": { 43 | "url": url, 44 | "transport": "streamable_http", 45 | "headers": { 46 | "Authorization": f"Bearer {personal_api_key}", 47 | "X-Client-Package": "posthog-agent-toolkit", 48 | }, 49 | } 50 | } 51 | 52 | async def get_tools(self) -> list[BaseTool]: 53 | """ 54 | Get all available PostHog tools as LangChain compatible tools. 55 | 56 | Returns: 57 | List of BaseTool instances that can be used with LangChain agents 58 | """ 59 | if self._tools is None: 60 | self._tools = await self.client.get_tools() 61 | return self._tools 62 | -------------------------------------------------------------------------------- /examples/ai-sdk/src/index.ts: -------------------------------------------------------------------------------- 1 | import { openai } from "@ai-sdk/openai"; 2 | import { PostHogAgentToolkit } from "@posthog/agent-toolkit/integrations/ai-sdk"; 3 | import { generateText, stepCountIs } from "ai"; 4 | import "dotenv/config"; 5 | 6 | async function analyzeProductUsage() { 7 | console.log("🚀 PostHog AI Agent - Product Usage Analysis\n"); 8 | 9 | const agentToolkit = new PostHogAgentToolkit({ 10 | posthogPersonalApiKey: process.env.POSTHOG_PERSONAL_API_KEY!, 11 | posthogApiBaseUrl: process.env.POSTHOG_API_BASE_URL || "https://us.posthog.com", 12 | }); 13 | 14 | const result = await generateText({ 15 | model: openai("gpt-5-mini"), 16 | tools: await agentToolkit.getTools(), 17 | stopWhen: stepCountIs(30), 18 | system: `You are a data analyst. Your task is to do a deep dive into what's happening in our product.`, 19 | prompt: `Please analyze our product usage: 20 | 21 | 1. Get all available insights (limit 100) and pick the 5 most relevant ones 22 | 2. For each insight, query its data 23 | 3. Summarize the key findings in a brief report 24 | 25 | Keep your response focused and data-driven.`, 26 | }); 27 | 28 | console.log("📊 Analysis Complete!\n"); 29 | console.log("=".repeat(50)); 30 | console.log(result.text); 31 | console.log("=".repeat(50)); 32 | 33 | // Show tool usage summary 34 | const toolCalls = result.steps.flatMap((step) => step.toolCalls ?? []); 35 | if (toolCalls.length > 0) { 36 | console.log("\n🔧 Tools Used:"); 37 | const toolUsage = toolCalls.reduce( 38 | (acc, call) => { 39 | acc[call.toolName] = (acc[call.toolName] || 0) + 1; 40 | return acc; 41 | }, 42 | {} as Record, 43 | ); 44 | 45 | for (const [tool, count] of Object.entries(toolUsage)) { 46 | console.log(` • ${tool}: ${count} call${count > 1 ? "s" : ""}`); 47 | } 48 | } 49 | } 50 | 51 | async function main() { 52 | try { 53 | await analyzeProductUsage(); 54 | } catch (error) { 55 | console.error("Error:", error); 56 | process.exit(1); 57 | } 58 | } 59 | 60 | main().catch(console.error); 61 | -------------------------------------------------------------------------------- /.github/workflows/release-python.yml: -------------------------------------------------------------------------------- 1 | name: "Python Package Release" 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - "python/pyproject.toml" 9 | workflow_dispatch: 10 | 11 | jobs: 12 | release: 13 | name: Publish Python package 14 | runs-on: ubuntu-latest 15 | permissions: 16 | id-token: write 17 | env: 18 | TWINE_USERNAME: __token__ 19 | TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} 20 | steps: 21 | - name: Checkout the repository 22 | uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2 23 | with: 24 | fetch-depth: 0 25 | token: ${{ secrets.POSTHOG_BOT_PAT }} 26 | 27 | - name: Set up Python 28 | uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 29 | with: 30 | python-version: 3.11.11 31 | 32 | - name: Install uv 33 | uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1 34 | with: 35 | enable-cache: true 36 | pyproject-file: 'python/pyproject.toml' 37 | 38 | - name: Detect version 39 | working-directory: python 40 | run: | 41 | VERSION=$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])") 42 | echo "REPO_VERSION=$VERSION" >> $GITHUB_ENV 43 | 44 | - name: Prepare for building release 45 | run: uv sync --group dev 46 | working-directory: python 47 | 48 | - name: Build package 49 | run: uv run python -m build 50 | working-directory: python 51 | 52 | - name: Publish to PyPI 53 | run: uv run python -m twine upload dist/* 54 | working-directory: python 55 | 56 | - name: Create GitHub release 57 | uses: actions/create-release@0cb9c9b65d5d1901c1f53e5e66eaf4afd303e70e # v1 58 | env: 59 | GITHUB_TOKEN: ${{ secrets.POSTHOG_BOT_PAT }} 60 | with: 61 | tag_name: posthog_agent_toolkit-v${{ env.REPO_VERSION }} 62 | release_name: posthog_agent_toolkit ${{ env.REPO_VERSION }} -------------------------------------------------------------------------------- /typescript/tests/unit/url-routing.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | describe("URL Routing", () => { 4 | const testCases = [ 5 | { path: "/mcp", params: "", expected: { path: "/mcp", features: undefined } }, 6 | { 7 | path: "/mcp", 8 | params: "?features=dashboards", 9 | expected: { path: "/mcp", features: ["dashboards"] }, 10 | }, 11 | { 12 | path: "/mcp", 13 | params: "?features=dashboards,insights", 14 | expected: { path: "/mcp", features: ["dashboards", "insights"] }, 15 | }, 16 | { path: "/sse", params: "", expected: { path: "/sse", features: undefined } }, 17 | { 18 | path: "/sse", 19 | params: "?features=flags,org", 20 | expected: { path: "/sse", features: ["flags", "org"] }, 21 | }, 22 | { 23 | path: "/sse/message", 24 | params: "", 25 | expected: { path: "/sse/message", features: undefined }, 26 | }, 27 | { 28 | path: "/sse/message", 29 | params: "?features=flags", 30 | expected: { path: "/sse/message", features: ["flags"] }, 31 | }, 32 | ]; 33 | 34 | describe("Query parameter parsing", () => { 35 | it.each(testCases)("should parse $path$params correctly", ({ path, params, expected }) => { 36 | const url = new URL(`https://example.com${path}${params}`); 37 | 38 | expect(url.pathname).toBe(expected.path); 39 | 40 | const featuresParam = url.searchParams.get("features"); 41 | const features = featuresParam ? featuresParam.split(",").filter(Boolean) : undefined; 42 | expect(features).toEqual(expected.features); 43 | }); 44 | }); 45 | 46 | describe("Features string parsing", () => { 47 | const featureTests = [ 48 | { input: "dashboards,insights,flags", expected: ["dashboards", "insights", "flags"] }, 49 | { input: "dashboards", expected: ["dashboards"] }, 50 | { input: "dashboards,,insights,", expected: ["dashboards", "insights"] }, 51 | { input: "", expected: [] }, 52 | ]; 53 | 54 | it.each(featureTests)("should parse '$input' as $expected", ({ input, expected }) => { 55 | const features = input.split(",").filter(Boolean); 56 | expect(features).toEqual(expected); 57 | }); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /typescript/tests/integration/feature-routing.test.ts: -------------------------------------------------------------------------------- 1 | import { SessionManager } from "@/lib/utils/SessionManager"; 2 | import { getToolsFromContext } from "@/tools"; 3 | import type { Context } from "@/tools/types"; 4 | import { describe, expect, it } from "vitest"; 5 | 6 | const createMockContext = (): Context => ({ 7 | api: {} as any, 8 | cache: {} as any, 9 | env: { INKEEP_API_KEY: undefined }, 10 | stateManager: { 11 | getApiKey: async () => ({ scopes: ["*"] }), 12 | } as any, 13 | sessionManager: new SessionManager({} as any), 14 | }); 15 | 16 | describe("Feature Routing Integration", () => { 17 | const integrationTests = [ 18 | { 19 | features: undefined, 20 | description: "all tools when no features specified", 21 | expectedTools: [ 22 | "feature-flag-get-definition", 23 | "dashboard-create", 24 | "insights-get-all", 25 | "organizations-get", 26 | "list-errors", 27 | ], 28 | }, 29 | { 30 | features: ["dashboards"], 31 | description: "only dashboard tools", 32 | expectedTools: [ 33 | "dashboard-create", 34 | "dashboards-get-all", 35 | "dashboard-get", 36 | "dashboard-update", 37 | "dashboard-delete", 38 | "add-insight-to-dashboard", 39 | ], 40 | }, 41 | { 42 | features: ["flags", "workspace"], 43 | description: "tools from multiple features", 44 | expectedTools: [ 45 | "feature-flag-get-definition", 46 | "create-feature-flag", 47 | "feature-flag-get-all", 48 | "organizations-get", 49 | "switch-organization", 50 | "projects-get", 51 | ], 52 | }, 53 | { 54 | features: ["invalid", "flags", "unknown"], 55 | description: "valid tools ignoring invalid features", 56 | expectedTools: ["feature-flag-get-definition", "create-feature-flag"], 57 | }, 58 | ]; 59 | 60 | it.each(integrationTests)("should return $description", async ({ features, expectedTools }) => { 61 | const context = createMockContext(); 62 | const tools = await getToolsFromContext(context, features); 63 | const toolNames = tools.map((t) => t.name); 64 | 65 | for (const tool of expectedTools) { 66 | expect(toolNames).toContain(tool); 67 | } 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /examples/langchain-js/src/index.ts: -------------------------------------------------------------------------------- 1 | import { ChatPromptTemplate, MessagesPlaceholder } from "@langchain/core/prompts"; 2 | import { ChatOpenAI } from "@langchain/openai"; 3 | import { PostHogAgentToolkit } from "@posthog/agent-toolkit/integrations/langchain"; 4 | import { AgentExecutor, createToolCallingAgent } from "langchain/agents"; 5 | import "dotenv/config"; 6 | 7 | async function analyzeProductUsage() { 8 | console.log("🚀 PostHog Langchain Agent - Product Usage Analysis\n"); 9 | 10 | const agentToolkit = new PostHogAgentToolkit({ 11 | posthogPersonalApiKey: process.env.POSTHOG_PERSONAL_API_KEY!, 12 | posthogApiBaseUrl: process.env.POSTHOG_API_BASE_URL || "https://us.posthog.com", 13 | }); 14 | 15 | const tools = await agentToolkit.getTools(); 16 | 17 | const llm = new ChatOpenAI({ 18 | model: "gpt-5-mini", 19 | }); 20 | 21 | const prompt = ChatPromptTemplate.fromMessages([ 22 | [ 23 | "system", 24 | "You are a data analyst. Your task is to do a deep dive into what's happening in our product. Be concise and data-driven in your responses.", 25 | ], 26 | ["human", "{input}"], 27 | new MessagesPlaceholder("agent_scratchpad"), 28 | ]); 29 | 30 | const agent = createToolCallingAgent({ 31 | llm, 32 | tools, 33 | prompt, 34 | }); 35 | 36 | const agentExecutor = new AgentExecutor({ 37 | agent, 38 | tools, 39 | verbose: false, 40 | maxIterations: 5, 41 | }); 42 | 43 | const result = await agentExecutor.invoke({ 44 | input: `Please analyze our product usage: 45 | 46 | 1. Get all available insights (limit 100) and pick the 5 most relevant ones 47 | 2. For each insight, query its data 48 | 3. Summarize the key findings in a brief report 49 | 50 | Keep your response focused and data-driven.`, 51 | }); 52 | 53 | console.log("\n📊 Analysis Complete!\n"); 54 | console.log("=".repeat(50)); 55 | console.log(result.output); 56 | console.log("=".repeat(50)); 57 | } 58 | 59 | async function main() { 60 | try { 61 | await analyzeProductUsage(); 62 | } catch (error) { 63 | console.error("Error:", error); 64 | process.exit(1); 65 | } 66 | } 67 | 68 | main().catch(console.error); 69 | -------------------------------------------------------------------------------- /typescript/scripts/update-openapi-client.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env tsx 2 | 3 | import { execSync } from "node:child_process"; 4 | import * as fs from "node:fs"; 5 | 6 | const SCHEMA_URL = "https://app.posthog.com/api/schema/"; 7 | const TEMP_SCHEMA_PATH = "temp-openapi.yaml"; 8 | const OUTPUT_PATH = "src/api/generated.ts"; 9 | 10 | async function fetchSchema() { 11 | console.log("Fetching OpenAPI schema from PostHog API..."); 12 | 13 | try { 14 | const response = await fetch(SCHEMA_URL); 15 | if (!response.ok) { 16 | throw new Error(`Failed to fetch schema: ${response.status} ${response.statusText}`); 17 | } 18 | 19 | const schema = await response.text(); 20 | fs.writeFileSync(TEMP_SCHEMA_PATH, schema, "utf-8"); 21 | console.log(`✓ Schema saved to ${TEMP_SCHEMA_PATH}`); 22 | 23 | return true; 24 | } catch (error) { 25 | console.error("Error fetching schema:", error); 26 | return false; 27 | } 28 | } 29 | 30 | function generateClient() { 31 | console.log("Generating TypeScript client..."); 32 | 33 | try { 34 | execSync(`pnpm typed-openapi ${TEMP_SCHEMA_PATH} --output ${OUTPUT_PATH}`, { 35 | stdio: "inherit", 36 | }); 37 | console.log(`✓ Client generated at ${OUTPUT_PATH}`); 38 | return true; 39 | } catch (error) { 40 | console.error("Error generating client:", error); 41 | return false; 42 | } 43 | } 44 | 45 | function cleanup() { 46 | try { 47 | if (fs.existsSync(TEMP_SCHEMA_PATH)) { 48 | fs.unlinkSync(TEMP_SCHEMA_PATH); 49 | console.log("✓ Cleaned up temporary schema file"); 50 | } 51 | } catch (error) { 52 | console.error("Warning: Could not clean up temporary file:", error); 53 | } 54 | } 55 | 56 | async function main() { 57 | console.log("Starting OpenAPI client update...\n"); 58 | 59 | const schemaFetched = await fetchSchema(); 60 | if (!schemaFetched) { 61 | process.exit(1); 62 | } 63 | 64 | const clientGenerated = generateClient(); 65 | 66 | cleanup(); 67 | 68 | if (!clientGenerated) { 69 | process.exit(1); 70 | } 71 | 72 | console.log("\n✅ OpenAPI client successfully updated!"); 73 | } 74 | 75 | main().catch((error) => { 76 | console.error("Unexpected error:", error); 77 | process.exit(1); 78 | }); 79 | -------------------------------------------------------------------------------- /typescript/src/integrations/ai-sdk/index.ts: -------------------------------------------------------------------------------- 1 | import { ApiClient } from "@/api/client"; 2 | import { SessionManager } from "@/lib/utils/SessionManager"; 3 | import { StateManager } from "@/lib/utils/StateManager"; 4 | import { MemoryCache } from "@/lib/utils/cache/MemoryCache"; 5 | import { hash } from "@/lib/utils/helper-functions"; 6 | import { getToolsFromContext } from "@/tools"; 7 | import type { Context } from "@/tools/types"; 8 | import { type Tool as VercelTool, tool } from "ai"; 9 | import type { z } from "zod"; 10 | 11 | /** 12 | * Options for the PostHog Agent Toolkit 13 | */ 14 | export type PostHogToolsOptions = { 15 | posthogPersonalApiKey: string; 16 | posthogApiBaseUrl: string; 17 | }; 18 | 19 | export class PostHogAgentToolkit { 20 | public options: PostHogToolsOptions; 21 | 22 | /** 23 | * Create a new PostHog Agent Toolkit 24 | * @param options - The options for the PostHog Agent Toolkit 25 | */ 26 | constructor(options: PostHogToolsOptions) { 27 | this.options = options; 28 | } 29 | 30 | /** 31 | * Get the context for the PostHog Agent Toolkit 32 | * @returns A context object 33 | */ 34 | getContext(): Context { 35 | const api = new ApiClient({ 36 | apiToken: this.options.posthogPersonalApiKey, 37 | baseUrl: this.options.posthogApiBaseUrl, 38 | }); 39 | 40 | const scope = hash(this.options.posthogPersonalApiKey); 41 | const cache = new MemoryCache(scope); 42 | 43 | return { 44 | api, 45 | cache, 46 | env: { 47 | INKEEP_API_KEY: undefined, 48 | }, 49 | stateManager: new StateManager(cache, api), 50 | sessionManager: new SessionManager(cache), 51 | }; 52 | } 53 | 54 | /** 55 | * Get all the tools for the PostHog Agent Toolkit 56 | * @returns A record of tool names to Vercel tools 57 | */ 58 | async getTools(): Promise> { 59 | const context = this.getContext(); 60 | const allTools = await getToolsFromContext(context); 61 | 62 | return allTools.reduce( 63 | (acc, t) => { 64 | acc[t.name] = tool({ 65 | description: t.description, 66 | inputSchema: t.schema, 67 | execute: async (arg: z.output) => { 68 | return t.handler(context, arg); 69 | }, 70 | }); 71 | 72 | return acc; 73 | }, 74 | {} as Record, 75 | ); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /typescript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@posthog/agent-toolkit", 3 | "version": "0.2.2", 4 | "description": "PostHog tools for AI agents", 5 | "main": "dist/index.js", 6 | "module": "dist/index.mjs", 7 | "types": "dist/index.d.ts", 8 | "exports": { 9 | ".": { 10 | "types": "./dist/index.d.ts", 11 | "import": "./dist/index.mjs", 12 | "require": "./dist/index.js" 13 | }, 14 | "./tools": { 15 | "types": "./dist/tools.d.ts", 16 | "import": "./dist/tools.mjs", 17 | "require": "./dist/tools.js" 18 | }, 19 | "./integrations/ai-sdk": { 20 | "types": "./dist/ai-sdk.d.ts", 21 | "import": "./dist/ai-sdk.mjs", 22 | "require": "./dist/ai-sdk.js" 23 | }, 24 | "./integrations/langchain": { 25 | "types": "./dist/langchain.d.ts", 26 | "import": "./dist/langchain.mjs", 27 | "require": "./dist/langchain.js" 28 | } 29 | }, 30 | "scripts": { 31 | "build": "tsup", 32 | "dev": "wrangler dev", 33 | "deploy": "wrangler deploy", 34 | "cf-typegen": "wrangler types", 35 | "inspector": "npx @modelcontextprotocol/inspector npx -y mcp-remote@latest http://localhost:8787/mcp", 36 | "test": "vitest", 37 | "test:integration": "vitest run --config vitest.integration.config.mts", 38 | "test:watch": "vitest watch", 39 | "typecheck": "tsc --noEmit", 40 | "generate-client": "tsx scripts/update-openapi-client.ts" 41 | }, 42 | "keywords": ["posthog", "mcp", "ai", "agents", "analytics", "feature-flags"], 43 | "author": "PostHog Inc.", 44 | "license": "MIT", 45 | "peerDependencies": { 46 | "@langchain/core": "^0.3.72", 47 | "@langchain/openai": "^0.6.9", 48 | "ai": "^5.0.0", 49 | "langchain": "^0.3.31" 50 | }, 51 | "dependencies": { 52 | "@modelcontextprotocol/sdk": "^1.17.3", 53 | "agents": "^0.0.113", 54 | "ai": "^5.0.18", 55 | "posthog-node": "^4.18.0", 56 | "uuid": "^11.1.0", 57 | "zod": "^3.24.4" 58 | }, 59 | "devDependencies": { 60 | "@langchain/core": "^0.3.72", 61 | "@langchain/openai": "^0.6.9", 62 | "@types/dotenv": "^6.1.1", 63 | "@types/node": "^22.15.34", 64 | "dotenv": "^16.4.7", 65 | "langchain": "^0.3.31", 66 | "tsup": "^8.5.0", 67 | "tsx": "^4.20.5", 68 | "typed-openapi": "^2.2.2", 69 | "typescript": "^5.8.3", 70 | "vite": "^5.0.0", 71 | "vite-tsconfig-paths": "^5.1.4", 72 | "vitest": "^3.2.4", 73 | "wrangler": "^4.14.4", 74 | "zod-to-json-schema": "^3.24.6" 75 | }, 76 | "files": ["dist", "README.md"] 77 | } 78 | -------------------------------------------------------------------------------- /typescript/scripts/generate-tool-schema.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env tsx 2 | 3 | // Generates JSON schema from Zod tool-inputs schemas for Python Pydantic schema generation 4 | 5 | import * as fs from "node:fs"; 6 | import * as path from "node:path"; 7 | import { zodToJsonSchema } from "zod-to-json-schema"; 8 | import * as schemas from "../src/schema/tool-inputs"; 9 | 10 | const outputPath = path.join(__dirname, "../../schema/tool-inputs.json"); 11 | 12 | console.log("Generating JSON schema from Zod tool-inputs schemas..."); 13 | 14 | try { 15 | // Convert all Zod schemas to JSON Schema 16 | const jsonSchemas = { 17 | $schema: "http://json-schema.org/draft-07/schema#", 18 | definitions: {} as Record, 19 | }; 20 | 21 | // Add each schema to the definitions 22 | for (const [schemaName, zodSchema] of Object.entries(schemas)) { 23 | if (schemaName.endsWith("Schema")) { 24 | console.log(`Converting ${schemaName}...`); 25 | const jsonSchema = zodToJsonSchema(zodSchema, { 26 | name: schemaName, 27 | $refStrategy: "none", 28 | }); 29 | 30 | // Remove the top-level $schema to avoid conflicts 31 | jsonSchema.$schema = undefined; 32 | 33 | // Extract the actual schema from nested definitions if present 34 | let actualSchema = jsonSchema; 35 | const schemaObj = jsonSchema as any; 36 | 37 | // If there's nested definitions with the schema name, use that 38 | if (schemaObj.definitions?.[schemaName]) { 39 | actualSchema = schemaObj.definitions[schemaName]; 40 | } 41 | // If there's a $ref pointing to itself, and definitions exist, extract the definition 42 | else if (schemaObj.$ref?.includes(schemaName) && schemaObj.definitions) { 43 | actualSchema = schemaObj.definitions[schemaName] || schemaObj; 44 | } 45 | 46 | // Clean up any remaining $schema references 47 | if (actualSchema.$schema) { 48 | actualSchema.$schema = undefined; 49 | } 50 | 51 | jsonSchemas.definitions[schemaName] = actualSchema; 52 | } 53 | } 54 | 55 | // Write the combined schema 56 | const schemaString = JSON.stringify(jsonSchemas, null, 2); 57 | fs.writeFileSync(outputPath, schemaString); 58 | 59 | console.log(`✅ JSON schema generated successfully at: ${outputPath}`); 60 | console.log( 61 | `📋 Generated schemas for ${Object.keys(jsonSchemas.definitions).length} tool input types`, 62 | ); 63 | } catch (err) { 64 | console.error("❌ Error generating schema:", err); 65 | process.exit(1); 66 | } 67 | -------------------------------------------------------------------------------- /typescript/src/integrations/langchain/index.ts: -------------------------------------------------------------------------------- 1 | import { ApiClient } from "@/api/client"; 2 | import { SessionManager } from "@/lib/utils/SessionManager"; 3 | import { StateManager } from "@/lib/utils/StateManager"; 4 | import { MemoryCache } from "@/lib/utils/cache/MemoryCache"; 5 | import { hash } from "@/lib/utils/helper-functions"; 6 | import { getToolsFromContext } from "@/tools"; 7 | import type { Context } from "@/tools/types"; 8 | import { DynamicStructuredTool } from "@langchain/core/tools"; 9 | import type { z } from "zod"; 10 | 11 | /** 12 | * Options for the PostHog Agent Toolkit 13 | */ 14 | export type PostHogToolsOptions = { 15 | posthogPersonalApiKey: string; 16 | posthogApiBaseUrl: string; 17 | }; 18 | 19 | export class PostHogAgentToolkit { 20 | public options: PostHogToolsOptions; 21 | 22 | /** 23 | * Create a new PostHog Agent Toolkit 24 | * @param options - The options for the PostHog Agent Toolkit 25 | */ 26 | constructor(options: PostHogToolsOptions) { 27 | this.options = options; 28 | } 29 | 30 | /** 31 | * Get the context for the PostHog Agent Toolkit 32 | * @returns A context object 33 | */ 34 | getContext(): Context { 35 | const api = new ApiClient({ 36 | apiToken: this.options.posthogPersonalApiKey, 37 | baseUrl: this.options.posthogApiBaseUrl, 38 | }); 39 | 40 | const scope = hash(this.options.posthogPersonalApiKey); 41 | const cache = new MemoryCache(scope); 42 | 43 | return { 44 | api, 45 | cache, 46 | env: { 47 | INKEEP_API_KEY: undefined, 48 | }, 49 | stateManager: new StateManager(cache, api), 50 | sessionManager: new SessionManager(cache), 51 | }; 52 | } 53 | 54 | /** 55 | * Get all the tools for the PostHog Agent Toolkit 56 | * @returns An array of DynamicStructuredTool tools 57 | */ 58 | async getTools(): Promise { 59 | const context = this.getContext(); 60 | const allTools = await getToolsFromContext(context); 61 | 62 | return allTools.map((t) => { 63 | return new DynamicStructuredTool({ 64 | name: t.name, 65 | description: t.description, 66 | schema: t.schema, 67 | func: async (arg: z.output) => { 68 | const result = await t.handler(context, arg); 69 | 70 | if (typeof result === "string") { 71 | return result; 72 | } 73 | 74 | const text = result.content.map((c: { text: string }) => c.text).join("\n"); 75 | 76 | return text; 77 | }, 78 | }); 79 | }); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /typescript/src/schema/api.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const ApiPropertyDefinitionSchema = z.object({ 4 | id: z.string(), 5 | name: z.string(), 6 | description: z.string().nullish(), 7 | is_numerical: z.boolean().nullish(), 8 | updated_at: z.string().nullish(), 9 | updated_by: z.any().nullish(), 10 | is_seen_on_filtered_events: z.boolean().nullish(), 11 | property_type: z.enum(["String", "Numeric", "Boolean", "DateTime"]).nullish(), 12 | verified: z.boolean().nullish(), 13 | verified_at: z.string().nullish(), 14 | verified_by: z.any().nullish(), 15 | hidden: z.boolean().nullish(), 16 | tags: z.array(z.string()).nullish(), 17 | }); 18 | 19 | export const ApiEventDefinitionSchema = z.object({ 20 | id: z.string().uuid(), 21 | name: z.string(), 22 | owner: z.string().nullish(), 23 | description: z.string().nullish(), 24 | created_at: z.string().nullish(), 25 | updated_at: z.string().nullish(), 26 | updated_by: z.any().nullish(), 27 | last_seen_at: z.string().nullish(), 28 | verified: z.boolean().nullish(), 29 | verified_at: z.string().nullish(), 30 | verified_by: z.any().nullish(), 31 | hidden: z.boolean().nullish(), 32 | is_action: z.boolean().nullish(), 33 | post_to_slack: z.boolean().nullish(), 34 | default_columns: z.array(z.string().nullish()).nullish(), 35 | tags: z.array(z.string().nullish()).nullish(), 36 | }); 37 | 38 | export const ApiListResponseSchema = (dataSchema: T) => 39 | z.object({ 40 | count: z.number().nullish(), 41 | next: z.string().nullish(), 42 | previous: z.string().nullish(), 43 | results: z.array(dataSchema), 44 | }); 45 | 46 | export const ApiUserSchema = z.object({ 47 | distinct_id: z.string(), 48 | organizations: z.array( 49 | z.object({ 50 | id: z.string().uuid(), 51 | }), 52 | ), 53 | team: z.object({ 54 | id: z.number(), 55 | organization: z.string().uuid(), 56 | }), 57 | organization: z.object({ 58 | id: z.string().uuid(), 59 | }), 60 | }); 61 | 62 | export const ApiRedactedPersonalApiKeySchema = z.object({ 63 | scopes: z.array(z.string()), // TODO: restrict available tools automatically based on scopes 64 | scoped_teams: z.array(z.number()), 65 | scoped_organizations: z.array(z.string()), 66 | }); 67 | 68 | export type ApiPropertyDefinition = z.infer; 69 | export type ApiEventDefinition = z.infer; 70 | export type ApiUser = z.infer; 71 | export type ApiRedactedPersonalApiKey = z.infer; 72 | -------------------------------------------------------------------------------- /typescript/src/integrations/mcp/utils/handleToolError.ts: -------------------------------------------------------------------------------- 1 | import { getPostHogClient } from "@/integrations/mcp/utils/client"; 2 | import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; 3 | 4 | export class MCPToolError extends Error { 5 | public readonly tool: string; 6 | public readonly originalError: unknown; 7 | public readonly timestamp: Date; 8 | 9 | constructor(message: string, tool: string, originalError?: unknown) { 10 | super(message); 11 | this.name = "MCPToolError"; 12 | this.tool = tool; 13 | this.originalError = originalError; 14 | this.timestamp = new Date(); 15 | } 16 | 17 | getTrackingData() { 18 | return { 19 | tool: this.tool, 20 | message: this.message, 21 | timestamp: this.timestamp.toISOString(), 22 | originalError: 23 | this.originalError instanceof Error 24 | ? { 25 | name: this.originalError.name, 26 | message: this.originalError.message, 27 | stack: this.originalError.stack, 28 | } 29 | : String(this.originalError), 30 | }; 31 | } 32 | } 33 | 34 | /** 35 | * Handles tool errors and returns a structured error message. 36 | * Any errors that originate from the tool SHOULD be reported inside the result 37 | * object, with `isError` set to true, _not_ as an MCP protocol-level error 38 | * response. Otherwise, the LLM would not be able to see that an error occurred 39 | * and self-correct. 40 | * 41 | * @param error - The error object. 42 | * @param tool - Tool that caused the error. 43 | * @param distinctId - User's distinct ID for tracking. 44 | * @param sessionId - Session UUID for tracking. 45 | * @returns A structured error message. 46 | */ 47 | export function handleToolError( 48 | error: any, 49 | tool?: string, 50 | distinctId?: string, 51 | sessionUuid?: string, 52 | ): CallToolResult { 53 | const mcpError = 54 | error instanceof MCPToolError 55 | ? error 56 | : new MCPToolError( 57 | error instanceof Error ? error.message : String(error), 58 | tool || "unknown", 59 | error, 60 | ); 61 | 62 | const properties: Record = { 63 | team: "growth", 64 | tool: mcpError.tool, 65 | $exception_fingerprint: `${mcpError.tool}-${mcpError.message}`, 66 | }; 67 | 68 | if (sessionUuid) { 69 | properties.$session_id = sessionUuid; 70 | } 71 | 72 | getPostHogClient().captureException(mcpError, distinctId, properties); 73 | 74 | return { 75 | content: [ 76 | { 77 | type: "text", 78 | text: `Error: [${mcpError.tool}]: ${mcpError.message}`, 79 | }, 80 | ], 81 | isError: true, 82 | }; 83 | } 84 | -------------------------------------------------------------------------------- /typescript/README.md: -------------------------------------------------------------------------------- 1 | # @posthog/agent-toolkit 2 | 3 | Tools to give agents access to your PostHog data, manage feature flags, create insights, and more. 4 | 5 | ## Installation 6 | 7 | ```bash 8 | npm install @posthog/agent-toolkit 9 | ``` 10 | 11 | ## Quick Start 12 | 13 | The toolkit provides integrations for popular AI frameworks: 14 | 15 | ### Using with Vercel AI SDK 16 | 17 | ```typescript 18 | import { openai } from "@ai-sdk/openai"; 19 | import { PostHogAgentToolkit } from "@posthog/agent-toolkit/integrations/ai-sdk"; 20 | import { generateText } from "ai"; 21 | 22 | const toolkit = new PostHogAgentToolkit({ 23 | posthogPersonalApiKey: process.env.POSTHOG_PERSONAL_API_KEY!, 24 | posthogApiBaseUrl: "https://us.posthog.com" // or https://eu.posthog.com if you are hosting in the EU 25 | }); 26 | 27 | const result = await generateText({ 28 | model: openai("gpt-4"), 29 | tools: await toolkit.getTools(), 30 | prompt: "Analyze our product usage by getting the top 5 most interesting insights and summarising the data from them." 31 | }); 32 | ``` 33 | 34 | **[→ See full Vercel AI SDK example](https://github.com/posthog/mcp/tree/main/examples/ai-sdk)** 35 | 36 | ### Using with LangChain.js 37 | 38 | ```typescript 39 | import { ChatOpenAI } from "@langchain/openai"; 40 | import { PostHogAgentToolkit } from "@posthog/agent-toolkit/integrations/langchain"; 41 | import { AgentExecutor, createToolCallingAgent } from "langchain/agents"; 42 | import { ChatPromptTemplate, MessagesPlaceholder } from "@langchain/core/prompts"; 43 | 44 | const toolkit = new PostHogAgentToolkit({ 45 | posthogPersonalApiKey: process.env.POSTHOG_PERSONAL_API_KEY!, 46 | posthogApiBaseUrl: "https://us.posthog.com" // or https://eu.posthog.com if you are hosting in the EU 47 | }); 48 | 49 | const tools = await toolkit.getTools(); 50 | const llm = new ChatOpenAI({ model: "gpt-4" }); 51 | 52 | const prompt = ChatPromptTemplate.fromMessages([ 53 | ["system", "You are a data analyst with access to PostHog analytics"], 54 | ["human", "{input}"], 55 | new MessagesPlaceholder("agent_scratchpad"), 56 | ]); 57 | 58 | const agent = createToolCallingAgent({ llm, tools, prompt }); 59 | const executor = new AgentExecutor({ agent, tools }); 60 | 61 | const result = await executor.invoke({ 62 | input: "Analyze our product usage by getting the top 5 most interesting insights and summarising the data from them." 63 | }); 64 | ``` 65 | 66 | **[→ See full LangChain.js example](https://github.com/posthog/mcp/tree/main/examples/langchain-js)** 67 | 68 | ## Available Tools 69 | 70 | For a list of all available tools, please see the [docs](https://posthog.com/docs/model-context-protocol). -------------------------------------------------------------------------------- /python/scripts/generate-pydantic-models.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | # Get the directory of this script 6 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 7 | PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" 8 | PYTHON_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" 9 | 10 | # Input and output paths 11 | INPUT_PATH="$PROJECT_ROOT/schema/tool-inputs.json" 12 | OUTPUT_PATH="$PYTHON_ROOT/schema/tool_inputs.py" 13 | 14 | # Ensure output directory exists 15 | mkdir -p "$(dirname "$OUTPUT_PATH")" 16 | 17 | # Check if input file exists 18 | if [ ! -f "$INPUT_PATH" ]; then 19 | echo "❌ Error: JSON schema not found at $INPUT_PATH" 20 | echo "Please run 'pnpm run schema:build:json' first to generate the JSON schema" 21 | exit 1 22 | fi 23 | 24 | echo "🔧 Generating Pydantic models from $INPUT_PATH" 25 | 26 | # Ensure uv environment is synced with dev dependencies 27 | echo "🐍 Setting up uv environment..." 28 | cd "$PYTHON_ROOT" 29 | uv sync --dev 30 | 31 | # Generate schema.py from schema.json 32 | uv run datamodel-codegen \ 33 | --class-name='ToolInputs' \ 34 | --collapse-root-models \ 35 | --target-python-version 3.11 \ 36 | --disable-timestamp \ 37 | --use-one-literal-as-default \ 38 | --use-default \ 39 | --use-default-kwarg \ 40 | --use-subclass-enum \ 41 | --input "$INPUT_PATH" \ 42 | --input-file-type jsonschema \ 43 | --output "$OUTPUT_PATH" \ 44 | --output-model-type pydantic_v2.BaseModel \ 45 | --custom-file-header "# mypy: disable-error-code=\"assignment\"" \ 46 | --set-default-enum-member \ 47 | --capitalise-enum-members \ 48 | --wrap-string-literal \ 49 | --use-field-description \ 50 | --use-schema-description \ 51 | --field-constraints \ 52 | --use-annotated 53 | 54 | echo "✅ Generated Pydantic models at $OUTPUT_PATH" 55 | 56 | # Format with ruff 57 | echo "📝 Formatting with ruff..." 58 | uv run ruff format "$OUTPUT_PATH" 59 | 60 | # Check and autofix with ruff 61 | echo "🔍 Checking with ruff..." 62 | uv run ruff check --fix "$OUTPUT_PATH" 63 | 64 | # Replace class Foo(str, Enum) with class Foo(StrEnum) for proper handling in format strings in python 3.11 65 | # Remove this when https://github.com/koxudaxi/datamodel-code-generator/issues/1313 is resolved 66 | echo "🔄 Updating enum imports for Python 3.11+..." 67 | if sed --version 2>&1 | grep -q GNU; then 68 | # GNU sed 69 | sed -i -e 's/str, Enum/StrEnum/g' "$OUTPUT_PATH" 70 | sed -i 's/from enum import Enum/from enum import Enum, StrEnum/g' "$OUTPUT_PATH" 71 | else 72 | # BSD/macOS sed 73 | sed -i '' -e 's/str, Enum/StrEnum/g' "$OUTPUT_PATH" 74 | sed -i '' 's/from enum import Enum/from enum import Enum, StrEnum/g' "$OUTPUT_PATH" 75 | fi 76 | 77 | echo "🎉 Successfully generated Pydantic models!" 78 | echo "📋 Output file: $OUTPUT_PATH" 79 | -------------------------------------------------------------------------------- /typescript/tests/tools/llmAnalytics.integration.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeAll, afterEach } from "vitest"; 2 | import { 3 | validateEnvironmentVariables, 4 | createTestClient, 5 | createTestContext, 6 | setActiveProjectAndOrg, 7 | cleanupResources, 8 | TEST_PROJECT_ID, 9 | TEST_ORG_ID, 10 | type CreatedResources, 11 | parseToolResponse, 12 | } from "@/shared/test-utils"; 13 | import getLLMCostsTool from "@/tools/llmAnalytics/getLLMCosts"; 14 | import type { Context } from "@/tools/types"; 15 | 16 | describe("LLM Analytics", { concurrent: false }, () => { 17 | let context: Context; 18 | const createdResources: CreatedResources = { 19 | featureFlags: [], 20 | insights: [], 21 | dashboards: [], 22 | surveys: [], 23 | }; 24 | 25 | beforeAll(async () => { 26 | validateEnvironmentVariables(); 27 | const client = createTestClient(); 28 | context = createTestContext(client); 29 | await setActiveProjectAndOrg(context, TEST_PROJECT_ID!, TEST_ORG_ID!); 30 | }); 31 | 32 | afterEach(async () => { 33 | await cleanupResources(context.api, TEST_PROJECT_ID!, createdResources); 34 | }); 35 | 36 | describe("get-llm-costs tool", () => { 37 | const costsTool = getLLMCostsTool(); 38 | 39 | it("should get LLM costs with default days (6 days)", async () => { 40 | const result = await costsTool.handler(context, { 41 | projectId: Number(TEST_PROJECT_ID), 42 | }); 43 | const costsData = parseToolResponse(result); 44 | 45 | expect(Array.isArray(costsData)).toBe(true); 46 | }); 47 | 48 | it("should get LLM costs for custom time period", async () => { 49 | const result = await costsTool.handler(context, { 50 | projectId: Number(TEST_PROJECT_ID), 51 | days: 30, 52 | }); 53 | const costsData = parseToolResponse(result); 54 | 55 | expect(Array.isArray(costsData)).toBe(true); 56 | }); 57 | 58 | it("should get LLM costs for single day", async () => { 59 | const result = await costsTool.handler(context, { 60 | projectId: Number(TEST_PROJECT_ID), 61 | days: 1, 62 | }); 63 | const costsData = parseToolResponse(result); 64 | 65 | expect(Array.isArray(costsData)).toBe(true); 66 | }); 67 | }); 68 | 69 | describe("LLM Analytics workflow", () => { 70 | it("should support getting costs for different time periods", async () => { 71 | const costsTool = getLLMCostsTool(); 72 | 73 | const weekResult = await costsTool.handler(context, { 74 | projectId: Number(TEST_PROJECT_ID), 75 | days: 7, 76 | }); 77 | const weekData = parseToolResponse(weekResult); 78 | expect(Array.isArray(weekData)).toBe(true); 79 | 80 | const monthResult = await costsTool.handler(context, { 81 | projectId: Number(TEST_PROJECT_ID), 82 | days: 30, 83 | }); 84 | const monthData = parseToolResponse(monthResult); 85 | expect(Array.isArray(monthData)).toBe(true); 86 | }); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /typescript/tests/tools/documentation.integration.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeAll, afterEach } from "vitest"; 2 | import { 3 | validateEnvironmentVariables, 4 | createTestClient, 5 | createTestContext, 6 | setActiveProjectAndOrg, 7 | cleanupResources, 8 | TEST_PROJECT_ID, 9 | TEST_ORG_ID, 10 | type CreatedResources, 11 | } from "@/shared/test-utils"; 12 | import searchDocsTool from "@/tools/documentation/searchDocs"; 13 | import type { Context } from "@/tools/types"; 14 | 15 | describe("Documentation", { concurrent: false }, () => { 16 | let context: Context; 17 | const createdResources: CreatedResources = { 18 | featureFlags: [], 19 | insights: [], 20 | dashboards: [], 21 | surveys: [], 22 | }; 23 | 24 | beforeAll(async () => { 25 | validateEnvironmentVariables(); 26 | const client = createTestClient(); 27 | context = createTestContext(client); 28 | await setActiveProjectAndOrg(context, TEST_PROJECT_ID!, TEST_ORG_ID!); 29 | }); 30 | 31 | afterEach(async () => { 32 | await cleanupResources(context.api, TEST_PROJECT_ID!, createdResources); 33 | }); 34 | 35 | describe("search-docs tool", () => { 36 | const searchTool = searchDocsTool(); 37 | 38 | it("should handle missing INKEEP_API_KEY", async () => { 39 | const contextWithoutKey = { 40 | ...context, 41 | env: { ...context.env, INKEEP_API_KEY: undefined as any }, 42 | }; 43 | 44 | const result = await searchTool.handler(contextWithoutKey as Context, { 45 | query: "feature flags", 46 | }); 47 | 48 | expect(result.content[0].text).toBe("Error: INKEEP_API_KEY is not configured."); 49 | }); 50 | 51 | it.skip("should search documentation with valid query", async () => { 52 | const result = await searchTool.handler(context, { 53 | query: "feature flags", 54 | }); 55 | 56 | expect(result.content[0].type).toBe("text"); 57 | expect(result.content[0].text).toBeDefined(); 58 | expect(result.content[0].text.length).toBeGreaterThan(0); 59 | }); 60 | 61 | it.skip("should search for analytics documentation", async () => { 62 | const result = await searchTool.handler(context, { 63 | query: "analytics events tracking", 64 | }); 65 | 66 | expect(result.content[0].type).toBe("text"); 67 | expect(result.content[0].text).toBeDefined(); 68 | expect(result.content[0].text.length).toBeGreaterThan(0); 69 | }); 70 | 71 | it.skip("should handle empty query results", async () => { 72 | const result = await searchTool.handler(context, { 73 | query: "zxcvbnmasdfghjklqwertyuiop123456789", 74 | }); 75 | 76 | expect(result.content[0].type).toBe("text"); 77 | expect(result.content[0].text).toBeDefined(); 78 | }); 79 | }); 80 | 81 | describe("Documentation search workflow", () => { 82 | it("should validate query parameter is required", async () => { 83 | const searchTool = searchDocsTool(); 84 | 85 | try { 86 | await searchTool.handler(context, { query: "" }); 87 | expect.fail("Should have thrown validation error"); 88 | } catch (error) { 89 | expect(error).toBeDefined(); 90 | } 91 | }); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /.github/workflows/ci-typescript.yml: -------------------------------------------------------------------------------- 1 | name: TypeScript CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | 8 | jobs: 9 | lint-and-format: 10 | name: Lint, Format, and Type Check 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v4 16 | 17 | - name: Install pnpm 18 | uses: pnpm/action-setup@v4 19 | 20 | - name: Setup Node.js 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: '20' 24 | cache: 'pnpm' 25 | 26 | - name: Install dependencies 27 | run: pnpm install && cd typescript && pnpm install 28 | 29 | - name: Run linter 30 | run: pnpm run lint 31 | 32 | - name: Run formatter 33 | run: pnpm run format 34 | 35 | - name: Run type check 36 | run: cd typescript && pnpm run typecheck 37 | 38 | - name: Check for changes 39 | run: | 40 | if [ -n "$(git status --porcelain)" ]; then 41 | echo "Code formatting or linting changes detected!" 42 | git diff 43 | exit 1 44 | fi 45 | 46 | unit-tests: 47 | name: Unit Tests 48 | runs-on: ubuntu-latest 49 | permissions: 50 | contents: read 51 | concurrency: 52 | group: ${{ github.workflow }}-unit-${{ github.head_ref || github.ref }} 53 | cancel-in-progress: true 54 | 55 | steps: 56 | - name: Checkout code 57 | uses: actions/checkout@v4 58 | 59 | - name: Setup pnpm 60 | uses: pnpm/action-setup@v4 61 | 62 | - name: Setup Node.js 63 | uses: actions/setup-node@v4 64 | with: 65 | node-version: '20' 66 | cache: 'pnpm' 67 | 68 | - name: Install dependencies 69 | run: pnpm install && cd typescript && pnpm install 70 | 71 | - name: Run unit tests 72 | run: cd typescript && pnpm run test 73 | 74 | integration-tests: 75 | name: Integration Tests 76 | runs-on: ubuntu-latest 77 | needs: unit-tests 78 | permissions: 79 | contents: read 80 | concurrency: 81 | group: ${{ github.workflow }}-integration-${{ github.head_ref || github.ref }} 82 | cancel-in-progress: true 83 | 84 | steps: 85 | - name: Checkout code 86 | uses: actions/checkout@v4 87 | 88 | - name: Setup pnpm 89 | uses: pnpm/action-setup@v4 90 | 91 | - name: Setup Node.js 92 | uses: actions/setup-node@v4 93 | with: 94 | node-version: '20' 95 | cache: 'pnpm' 96 | 97 | - name: Install dependencies 98 | run: pnpm install && cd typescript && pnpm install 99 | 100 | - name: Run integration tests 101 | run: cd typescript && pnpm run test:integration 102 | env: 103 | TEST_POSTHOG_API_BASE_URL: ${{ secrets.TEST_API_BASE_URL }} 104 | TEST_POSTHOG_PERSONAL_API_KEY: ${{ secrets.TEST_API_TOKEN }} 105 | TEST_ORG_ID: ${{ secrets.TEST_ORG_ID }} 106 | TEST_PROJECT_ID: ${{ secrets.TEST_PROJECT_ID }} -------------------------------------------------------------------------------- /typescript/src/schema/dashboards.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const DashboardTileSchema = z.object({ 4 | insight: z.object({ 5 | short_id: z.string(), 6 | name: z.string(), 7 | derived_name: z.string().nullable(), 8 | description: z.string().nullable(), 9 | query: z.object({ 10 | kind: z.union([z.literal("InsightVizNode"), z.literal("DataVisualizationNode")]), 11 | source: z 12 | .any() 13 | .describe( 14 | "For new insights, use the query from your successful query-run tool call. For updates, the existing query can optionally be reused.", 15 | ), // NOTE: This is intentionally z.any() to avoid populating the context with the complicated query schema, but we prompt the LLM to use 'query-run' to check queries, before creating insights. 16 | }), 17 | created_at: z.string().nullish(), 18 | updated_at: z.string().nullish(), 19 | favorited: z.boolean().nullish(), 20 | tags: z.array(z.string()).nullish(), 21 | }), 22 | order: z.number(), 23 | color: z.string().nullish(), 24 | layouts: z.record(z.any()).nullish(), 25 | last_refresh: z.string().nullish(), 26 | is_cached: z.boolean().nullish(), 27 | }); 28 | 29 | // Base dashboard schema from PostHog API 30 | export const DashboardSchema = z.object({ 31 | id: z.number().int().positive(), 32 | name: z.string(), 33 | description: z.string().nullish(), 34 | pinned: z.boolean().nullish(), 35 | created_at: z.string(), 36 | created_by: z 37 | .object({ 38 | email: z.string().email(), 39 | }) 40 | .optional() 41 | .nullable(), 42 | is_shared: z.boolean().nullish(), 43 | deleted: z.boolean().nullish(), 44 | filters: z.record(z.any()).nullish(), 45 | variables: z.record(z.any()).nullish(), 46 | tags: z.array(z.string()).nullish(), 47 | tiles: z.array(DashboardTileSchema.nullish()).nullish(), 48 | }); 49 | 50 | export const SimpleDashboardSchema = DashboardSchema.pick({ 51 | id: true, 52 | name: true, 53 | description: true, 54 | tiles: true, 55 | }); 56 | 57 | // Input schema for creating dashboards 58 | export const CreateDashboardInputSchema = z.object({ 59 | name: z.string().min(1, "Dashboard name is required"), 60 | description: z.string().optional(), 61 | pinned: z.boolean().optional(), 62 | tags: z.array(z.string()).optional(), 63 | }); 64 | 65 | // Input schema for updating dashboards 66 | export const UpdateDashboardInputSchema = z.object({ 67 | name: z.string().optional(), 68 | description: z.string().optional(), 69 | pinned: z.boolean().optional(), 70 | tags: z.array(z.string()).optional(), 71 | }); 72 | 73 | // Input schema for listing dashboards 74 | export const ListDashboardsSchema = z.object({ 75 | limit: z.number().int().positive().optional(), 76 | offset: z.number().int().nonnegative().optional(), 77 | search: z.string().optional(), 78 | pinned: z.boolean().optional(), 79 | }); 80 | 81 | // Input schema for adding insight to dashboard 82 | export const AddInsightToDashboardSchema = z.object({ 83 | insightId: z.string(), 84 | dashboardId: z.number().int().positive(), 85 | }); 86 | 87 | // Type exports 88 | export type PostHogDashboard = z.infer; 89 | export type CreateDashboardInput = z.infer; 90 | export type UpdateDashboardInput = z.infer; 91 | export type ListDashboardsData = z.infer; 92 | export type AddInsightToDashboardInput = z.infer; 93 | export type SimpleDashboard = z.infer; 94 | -------------------------------------------------------------------------------- /typescript/src/schema/insights.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const InsightSchema = z.object({ 4 | id: z.number(), 5 | short_id: z.string(), 6 | name: z.string().nullish(), 7 | description: z.string().nullish(), 8 | filters: z.record(z.any()), 9 | query: z.any(), 10 | result: z.any().optional(), 11 | created_at: z.string(), 12 | updated_at: z.string(), 13 | created_by: z 14 | .object({ 15 | id: z.number(), 16 | uuid: z.string().uuid(), 17 | distinct_id: z.string(), 18 | first_name: z.string(), 19 | email: z.string(), 20 | }) 21 | .optional() 22 | .nullable(), 23 | favorited: z.boolean().nullish(), 24 | deleted: z.boolean(), 25 | dashboard: z.number().nullish(), 26 | layouts: z.record(z.any()).nullish(), 27 | color: z.string().nullish(), 28 | last_refresh: z.string().nullish(), 29 | refreshing: z.boolean().nullish(), 30 | tags: z.array(z.string()).nullish(), 31 | }); 32 | 33 | export const SimpleInsightSchema = InsightSchema.pick({ 34 | id: true, 35 | name: true, 36 | short_id: true, 37 | description: true, 38 | filters: true, 39 | query: true, 40 | created_at: true, 41 | updated_at: true, 42 | favorited: true, 43 | }); 44 | 45 | export const CreateInsightInputSchema = z.object({ 46 | name: z.string(), 47 | query: z.object({ 48 | kind: z.union([z.literal("InsightVizNode"), z.literal("DataVisualizationNode")]), 49 | source: z 50 | .any() 51 | .describe( 52 | "For new insights, use the query from your successful query-run tool call. For updates, the existing query can optionally be reused.", 53 | ), // NOTE: This is intentionally z.any() to avoid populating the context with the complicated query schema, but we prompt the LLM to use 'query-run' to check queries, before creating insights. 54 | }), 55 | description: z.string().optional(), 56 | favorited: z.boolean(), 57 | tags: z.array(z.string()).optional(), 58 | }); 59 | 60 | export const UpdateInsightInputSchema = z.object({ 61 | name: z.string().optional(), 62 | description: z.string().optional(), 63 | filters: z.record(z.any()).optional(), 64 | query: z.object({ 65 | kind: z.union([z.literal("InsightVizNode"), z.literal("DataVisualizationNode")]), 66 | source: z 67 | .any() 68 | .describe( 69 | "For new insights, use the query from your successful query-run tool call. For updates, the existing query can optionally be reused", 70 | ), // NOTE: This is intentionally z.any() to avoid populating the context with the complicated query schema, and to allow the LLM to make a change to an existing insight whose schema we do not support in our simplified subset of the full insight schema. 71 | }), 72 | favorited: z.boolean().optional(), 73 | dashboard: z.number().optional(), 74 | tags: z.array(z.string()).optional(), 75 | }); 76 | 77 | export const ListInsightsSchema = z.object({ 78 | limit: z.number().optional(), 79 | offset: z.number().optional(), 80 | favorited: z.boolean().optional(), 81 | search: z.string().optional(), 82 | }); 83 | 84 | export type PostHogInsight = z.infer; 85 | export type CreateInsightInput = z.infer; 86 | export type UpdateInsightInput = z.infer; 87 | export type ListInsightsData = z.infer; 88 | export type SimpleInsight = z.infer; 89 | 90 | export const SQLInsightResponseSchema = z.array( 91 | z.object({ 92 | type: z.string(), 93 | data: z.record(z.any()), 94 | }), 95 | ); 96 | 97 | export type SQLInsightResponse = z.infer; 98 | -------------------------------------------------------------------------------- /examples/langchain/posthog_agent_example.py: -------------------------------------------------------------------------------- 1 | """ 2 | PostHog LangChain Integration Example 3 | 4 | This example demonstrates how to use PostHog tools with LangChain using 5 | the local posthog_agent_toolkit package. It shows how to analyze product 6 | usage data similar to the TypeScript example. 7 | """ 8 | 9 | import asyncio 10 | import os 11 | import sys 12 | 13 | from dotenv import load_dotenv 14 | from langchain_openai import ChatOpenAI 15 | from langchain.agents import AgentExecutor, create_tool_calling_agent 16 | from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder 17 | from posthog_agent_toolkit.integrations.langchain.toolkit import PostHogAgentToolkit 18 | 19 | 20 | async def analyze_product_usage(): 21 | """Analyze product usage using PostHog data.""" 22 | 23 | print("🚀 PostHog LangChain Agent - Product Usage Analysis\n") 24 | 25 | # Initialize the PostHog toolkit with credentials 26 | toolkit = PostHogAgentToolkit( 27 | personal_api_key=os.getenv("POSTHOG_PERSONAL_API_KEY"), 28 | url=os.getenv("POSTHOG_MCP_URL", "https://mcp.posthog.com/mcp") 29 | ) 30 | 31 | # Get the tools 32 | tools = await toolkit.get_tools() 33 | 34 | # Initialize the LLM 35 | llm = ChatOpenAI( 36 | model="gpt-5-mini", 37 | temperature=0, 38 | api_key=os.getenv("OPENAI_API_KEY") 39 | ) 40 | 41 | # Create a system prompt for the agent 42 | prompt = ChatPromptTemplate.from_messages([ 43 | ( 44 | "system", 45 | "You are a data analyst. Your task is to do a deep dive into what's happening in our product. " 46 | "Be concise and data-driven in your responses." 47 | ), 48 | ("human", "{input}"), 49 | MessagesPlaceholder("agent_scratchpad"), 50 | ]) 51 | 52 | agent = create_tool_calling_agent( 53 | llm=llm, 54 | tools=tools, 55 | prompt=prompt, 56 | ) 57 | 58 | agent_executor = AgentExecutor( 59 | agent=agent, 60 | tools=tools, 61 | verbose=False, 62 | max_iterations=30, 63 | ) 64 | 65 | # Invoke the agent with an analysis request 66 | result = await agent_executor.ainvoke({ 67 | "input": """Please analyze our product usage: 68 | 69 | 1. Get all available insights (limit 100) 70 | 2. Pick the 5 MOST INTERESTING and VALUABLE insights - prioritize: 71 | - User behavior and engagement metrics 72 | - Conversion funnels 73 | - Retention and growth metrics 74 | - Product adoption insights 75 | - Revenue or business KPIs 76 | AVOID picking feature flag insights unless they show significant business impact 77 | 3. For each selected insight, query its data and explain why it's important 78 | 4. Summarize the key findings in a brief report with actionable recommendations 79 | 80 | Focus on insights that tell a story about user behavior and business performance.""" 81 | }) 82 | 83 | print("\n📊 Analysis Complete!\n") 84 | print("=" * 50) 85 | print(result["output"]) 86 | print("=" * 50) 87 | 88 | 89 | async def main(): 90 | """Main function to run the product usage analysis.""" 91 | try: 92 | # Load environment variables 93 | load_dotenv() 94 | 95 | # Run the analysis 96 | await analyze_product_usage() 97 | except Exception as error: 98 | print(f"Error: {error}") 99 | sys.exit(1) 100 | 101 | 102 | if __name__ == "__main__": 103 | asyncio.run(main()) -------------------------------------------------------------------------------- /typescript/src/schema/flags.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export interface PostHogFeatureFlag { 4 | id: number; 5 | key: string; 6 | name: string; 7 | } 8 | 9 | export interface PostHogFlagsResponse { 10 | results?: PostHogFeatureFlag[]; 11 | } 12 | const base = ["exact", "is_not", "is_set", "is_not_set"] as const; 13 | const stringOps = [ 14 | ...base, 15 | "icontains", 16 | "not_icontains", 17 | "regex", 18 | "not_regex", 19 | "is_cleaned_path_exact", 20 | ] as const; 21 | const numberOps = [...base, "gt", "gte", "lt", "lte", "min", "max"] as const; 22 | const booleanOps = [...base] as const; 23 | 24 | const arrayOps = ["in", "not_in"] as const; 25 | 26 | const operatorSchema = z.enum([ 27 | ...stringOps, 28 | ...numberOps, 29 | ...booleanOps, 30 | ...arrayOps, 31 | ] as unknown as [string, ...string[]]); 32 | 33 | export const PersonPropertyFilterSchema = z 34 | .object({ 35 | key: z.string(), 36 | value: z.union([ 37 | z.string(), 38 | z.number(), 39 | z.boolean(), 40 | z.array(z.string()), 41 | z.array(z.number()), 42 | ]), 43 | operator: operatorSchema.optional(), 44 | }) 45 | .superRefine((data, ctx) => { 46 | const { value, operator } = data; 47 | if (!operator) return; 48 | const isArray = Array.isArray(value); 49 | 50 | const valid = 51 | (typeof value === "string" && stringOps.includes(operator as any)) || 52 | (typeof value === "number" && numberOps.includes(operator as any)) || 53 | (typeof value === "boolean" && booleanOps.includes(operator as any)) || 54 | (isArray && arrayOps.includes(operator as any)); 55 | 56 | if (!valid) 57 | ctx.addIssue({ 58 | code: z.ZodIssueCode.custom, 59 | message: `operator "${operator}" is not valid for value type "${isArray ? "array" : typeof value}"`, 60 | }); 61 | 62 | if (!isArray && arrayOps.includes(operator as any)) 63 | ctx.addIssue({ 64 | code: z.ZodIssueCode.custom, 65 | message: `operator "${operator}" requires an array value`, 66 | }); 67 | }) 68 | .transform((data) => { 69 | // when using is_set or is_not_set, set the value the same as the operator 70 | if (data.operator === "is_set" || data.operator === "is_not_set") { 71 | data.value = data.operator; 72 | } 73 | 74 | return { 75 | ...data, 76 | type: "person", 77 | }; 78 | }); 79 | 80 | export type PersonPropertyFilter = z.infer; 81 | 82 | export const FiltersSchema = z.object({ 83 | properties: z.array(PersonPropertyFilterSchema), 84 | rollout_percentage: z.number(), 85 | }); 86 | 87 | export type Filters = z.infer; 88 | 89 | export const FilterGroupsSchema = z.object({ 90 | groups: z.array(FiltersSchema), 91 | }); 92 | 93 | export type FilterGroups = z.infer; 94 | 95 | export const CreateFeatureFlagInputSchema = z.object({ 96 | name: z.string(), 97 | key: z.string(), 98 | description: z.string(), 99 | filters: FilterGroupsSchema, 100 | active: z.boolean(), 101 | tags: z.array(z.string()).optional(), 102 | }); 103 | 104 | export type CreateFeatureFlagInput = z.infer; 105 | 106 | export const UpdateFeatureFlagInputSchema = CreateFeatureFlagInputSchema.omit({ 107 | key: true, 108 | }).partial(); 109 | 110 | export type UpdateFeatureFlagInput = z.infer; 111 | 112 | export const FeatureFlagSchema = z.object({ 113 | id: z.number(), 114 | key: z.string(), 115 | name: z.string(), 116 | description: z.string().nullish(), 117 | filters: z.any().nullish(), 118 | active: z.boolean(), 119 | tags: z.array(z.string()).optional(), 120 | }); 121 | 122 | export type FeatureFlag = z.infer; 123 | -------------------------------------------------------------------------------- /typescript/src/lib/utils/StateManager.ts: -------------------------------------------------------------------------------- 1 | import type { ApiClient } from "@/api/client"; 2 | import type { ApiUser } from "@/schema/api"; 3 | import type { State } from "@/tools/types"; 4 | import type { ScopedCache } from "./cache/ScopedCache"; 5 | 6 | export class StateManager { 7 | private _cache: ScopedCache; 8 | private _api: ApiClient; 9 | private _user?: ApiUser; 10 | 11 | constructor(cache: ScopedCache, api: ApiClient) { 12 | this._cache = cache; 13 | this._api = api; 14 | } 15 | 16 | private async _fetchUser() { 17 | const userResult = await this._api.users().me(); 18 | if (!userResult.success) { 19 | throw new Error(`Failed to get user: ${userResult.error.message}`); 20 | } 21 | return userResult.data; 22 | } 23 | 24 | async getUser() { 25 | if (!this._user) { 26 | this._user = await this._fetchUser(); 27 | } 28 | 29 | return this._user; 30 | } 31 | 32 | private async _fetchApiKey() { 33 | const apiKeyResult = await this._api.apiKeys().current(); 34 | if (!apiKeyResult.success) { 35 | throw new Error(`Failed to get API key: ${apiKeyResult.error.message}`); 36 | } 37 | return apiKeyResult.data; 38 | } 39 | 40 | async getApiKey() { 41 | let _apiKey = await this._cache.get("apiKey"); 42 | 43 | if (!_apiKey) { 44 | _apiKey = await this._fetchApiKey(); 45 | await this._cache.set("apiKey", _apiKey); 46 | } 47 | 48 | return _apiKey; 49 | } 50 | 51 | async getDistinctId() { 52 | let _distinctId = await this._cache.get("distinctId"); 53 | 54 | if (!_distinctId) { 55 | const user = await this.getUser(); 56 | 57 | await this._cache.set("distinctId", user.distinct_id); 58 | _distinctId = user.distinct_id; 59 | } 60 | 61 | return _distinctId; 62 | } 63 | 64 | private async _getDefaultOrganizationAndProject(): Promise<{ 65 | organizationId?: string; 66 | projectId: number; 67 | }> { 68 | const { scoped_organizations, scoped_teams } = await this.getApiKey(); 69 | const { organization: activeOrganization, team: activeTeam } = await this.getUser(); 70 | 71 | if (scoped_teams.length > 0) { 72 | // Keys scoped to projects should only be scoped to one project 73 | if (scoped_teams.length > 1) { 74 | throw new Error( 75 | "API key has access to multiple projects, please specify a single project ID or change the API key to have access to an organization to include the projects within it.", 76 | ); 77 | } 78 | 79 | const projectId = scoped_teams[0]!; 80 | 81 | return { projectId }; 82 | } 83 | 84 | if ( 85 | scoped_organizations.length === 0 || 86 | scoped_organizations.includes(activeOrganization.id) 87 | ) { 88 | return { organizationId: activeOrganization.id, projectId: activeTeam.id }; 89 | } 90 | 91 | const organizationId = scoped_organizations[0]!; 92 | 93 | const projectsResult = await this._api 94 | .organizations() 95 | .projects({ orgId: organizationId }) 96 | .list(); 97 | 98 | if (!projectsResult.success) { 99 | throw projectsResult.error; 100 | } 101 | 102 | if (projectsResult.data.length === 0) { 103 | throw new Error("API key does not have access to any projects"); 104 | } 105 | 106 | const projectId = projectsResult.data[0]!; 107 | 108 | return { organizationId, projectId: Number(projectId) }; 109 | } 110 | 111 | async setDefaultOrganizationAndProject() { 112 | const { organizationId, projectId } = await this._getDefaultOrganizationAndProject(); 113 | 114 | if (organizationId) { 115 | await this._cache.set("orgId", organizationId); 116 | } 117 | 118 | await this._cache.set("projectId", projectId.toString()); 119 | 120 | return { organizationId, projectId }; 121 | } 122 | 123 | async getOrgID(): Promise { 124 | const orgId = await this._cache.get("orgId"); 125 | 126 | if (!orgId) { 127 | const { organizationId } = await this.setDefaultOrganizationAndProject(); 128 | 129 | return organizationId; 130 | } 131 | 132 | return orgId; 133 | } 134 | 135 | async getProjectId(): Promise { 136 | const projectId = await this._cache.get("projectId"); 137 | 138 | if (!projectId) { 139 | const { projectId } = await this.setDefaultOrganizationAndProject(); 140 | return projectId.toString(); 141 | } 142 | 143 | return projectId; 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /typescript/tests/tools/errorTracking.integration.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeAll, afterEach } from "vitest"; 2 | import { 3 | validateEnvironmentVariables, 4 | createTestClient, 5 | createTestContext, 6 | setActiveProjectAndOrg, 7 | cleanupResources, 8 | TEST_PROJECT_ID, 9 | TEST_ORG_ID, 10 | type CreatedResources, 11 | parseToolResponse, 12 | } from "@/shared/test-utils"; 13 | import listErrorsTool from "@/tools/errorTracking/listErrors"; 14 | import errorDetailsTool from "@/tools/errorTracking/errorDetails"; 15 | import type { Context } from "@/tools/types"; 16 | import { OrderByErrors, OrderDirectionErrors, StatusErrors } from "@/schema/errors"; 17 | 18 | describe("Error Tracking", { concurrent: false }, () => { 19 | let context: Context; 20 | const createdResources: CreatedResources = { 21 | featureFlags: [], 22 | insights: [], 23 | dashboards: [], 24 | surveys: [], 25 | }; 26 | 27 | beforeAll(async () => { 28 | validateEnvironmentVariables(); 29 | const client = createTestClient(); 30 | context = createTestContext(client); 31 | await setActiveProjectAndOrg(context, TEST_PROJECT_ID!, TEST_ORG_ID!); 32 | }); 33 | 34 | afterEach(async () => { 35 | await cleanupResources(context.api, TEST_PROJECT_ID!, createdResources); 36 | }); 37 | 38 | describe("list-errors tool", () => { 39 | const listTool = listErrorsTool(); 40 | 41 | it("should list errors with default parameters", async () => { 42 | const result = await listTool.handler(context, {}); 43 | const errorData = parseToolResponse(result); 44 | 45 | expect(Array.isArray(errorData)).toBe(true); 46 | }); 47 | 48 | it("should list errors with custom date range", async () => { 49 | const dateFrom = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString(); 50 | const dateTo = new Date().toISOString(); 51 | 52 | const result = await listTool.handler(context, { 53 | dateFrom, 54 | dateTo, 55 | orderBy: OrderByErrors.Occurrences, 56 | orderDirection: OrderDirectionErrors.Descending, 57 | }); 58 | const errorData = parseToolResponse(result); 59 | 60 | expect(Array.isArray(errorData)).toBe(true); 61 | }); 62 | 63 | it("should filter by status", async () => { 64 | const result = await listTool.handler(context, { 65 | status: StatusErrors.Active, 66 | }); 67 | const errorData = parseToolResponse(result); 68 | 69 | expect(Array.isArray(errorData)).toBe(true); 70 | }); 71 | 72 | it("should handle empty results", async () => { 73 | const result = await listTool.handler(context, { 74 | dateFrom: new Date(Date.now() - 60000).toISOString(), 75 | dateTo: new Date(Date.now() - 30000).toISOString(), 76 | }); 77 | const errorData = parseToolResponse(result); 78 | 79 | expect(Array.isArray(errorData)).toBe(true); 80 | }); 81 | }); 82 | 83 | describe("error-details tool", () => { 84 | const detailsTool = errorDetailsTool(); 85 | 86 | it("should get error details by issue ID", async () => { 87 | const testIssueId = "00000000-0000-0000-0000-000000000000"; 88 | 89 | const result = await detailsTool.handler(context, { 90 | issueId: testIssueId, 91 | }); 92 | const errorDetails = parseToolResponse(result); 93 | 94 | expect(Array.isArray(errorDetails)).toBe(true); 95 | }); 96 | 97 | it("should get error details with custom date range", async () => { 98 | const testIssueId = "00000000-0000-0000-0000-000000000000"; 99 | const dateFrom = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); 100 | const dateTo = new Date().toISOString(); 101 | 102 | const result = await detailsTool.handler(context, { 103 | issueId: testIssueId, 104 | dateFrom, 105 | dateTo, 106 | }); 107 | const errorDetails = parseToolResponse(result); 108 | 109 | expect(Array.isArray(errorDetails)).toBe(true); 110 | }); 111 | }); 112 | 113 | describe("Error tracking workflow", () => { 114 | it("should support listing errors and getting details workflow", async () => { 115 | const listTool = listErrorsTool(); 116 | const detailsTool = errorDetailsTool(); 117 | 118 | const listResult = await listTool.handler(context, {}); 119 | const errorList = parseToolResponse(listResult); 120 | 121 | expect(Array.isArray(errorList)).toBe(true); 122 | 123 | if (errorList.length > 0 && errorList[0].issueId) { 124 | const firstError = errorList[0]; 125 | const detailsResult = await detailsTool.handler(context, { 126 | issueId: firstError.issueId, 127 | }); 128 | const errorDetails = parseToolResponse(detailsResult); 129 | 130 | expect(Array.isArray(errorDetails)).toBe(true); 131 | } else { 132 | const testIssueId = "00000000-0000-0000-0000-000000000000"; 133 | const detailsResult = await detailsTool.handler(context, { 134 | issueId: testIssueId, 135 | }); 136 | const errorDetails = parseToolResponse(detailsResult); 137 | 138 | expect(Array.isArray(errorDetails)).toBe(true); 139 | } 140 | }); 141 | }); 142 | }); 143 | -------------------------------------------------------------------------------- /.github/workflows/ci-python.yml: -------------------------------------------------------------------------------- 1 | name: Python CI 2 | permissions: 3 | contents: read 4 | 5 | on: 6 | push: 7 | branches: [ main ] 8 | pull_request: 9 | 10 | jobs: 11 | lint-and-format: 12 | name: Lint, Format, and Type Check 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v4 18 | 19 | - name: Set up Python 20 | uses: actions/setup-python@v5 21 | with: 22 | python-version: '3.11' 23 | 24 | - name: Install uv 25 | uses: astral-sh/setup-uv@v3 26 | with: 27 | version: "latest" 28 | 29 | - name: Install dependencies 30 | run: | 31 | cd python 32 | uv sync --dev 33 | 34 | - name: Run ruff linter 35 | run: | 36 | cd python 37 | uv run ruff check --fix . 38 | 39 | - name: Run ruff formatter 40 | run: | 41 | cd python 42 | uv run ruff format . 43 | 44 | - name: Check for changes 45 | run: | 46 | if [ -n "$(git status --porcelain)" ]; then 47 | echo "Code formatting or linting changes detected!" 48 | git diff 49 | exit 1 50 | fi 51 | 52 | - name: Run type checking 53 | run: | 54 | cd python 55 | uvx ty check 56 | 57 | # unit-tests: 58 | # name: Unit Tests 59 | # runs-on: ubuntu-latest 60 | 61 | # steps: 62 | # - name: Checkout code 63 | # uses: actions/checkout@v4 64 | 65 | # - name: Set up Python 66 | # uses: actions/setup-python@v5 67 | # with: 68 | # python-version: '3.11' 69 | 70 | # - name: Install uv 71 | # uses: astral-sh/setup-uv@v3 72 | # with: 73 | # version: "latest" 74 | 75 | # - name: Install dependencies 76 | # run: | 77 | # cd python 78 | # uv sync --dev 79 | 80 | # - name: Run unit tests 81 | # run: | 82 | # cd python 83 | # uv run pytest tests/ -v --tb=short --ignore-glob="*_integration.py" 84 | # env: 85 | # PYTHONPATH: ${{ github.workspace }}/python 86 | 87 | # integration-tests: 88 | # name: Integration Tests 89 | # runs-on: ubuntu-latest 90 | # permissions: 91 | # contents: read 92 | # concurrency: 93 | # group: ${{ github.workflow }}-integration-${{ github.head_ref || github.ref }} 94 | # cancel-in-progress: true 95 | 96 | # steps: 97 | # - name: Checkout code 98 | # uses: actions/checkout@v4 99 | 100 | # - name: Set up Python 101 | # uses: actions/setup-python@v5 102 | # with: 103 | # python-version: '3.11' 104 | 105 | # - name: Install uv 106 | # uses: astral-sh/setup-uv@v3 107 | # with: 108 | # version: "latest" 109 | 110 | # - name: Install dependencies 111 | # run: | 112 | # cd python 113 | # uv sync --dev 114 | 115 | # - name: Run integration tests 116 | # run: | 117 | # cd python 118 | # uv run pytest tests/tools/ -v --tb=short 119 | # env: 120 | # PYTHONPATH: ${{ github.workspace }}/python 121 | # TEST_POSTHOG_API_BASE_URL: ${{ secrets.TEST_API_BASE_URL }} 122 | # TEST_POSTHOG_PERSONAL_API_KEY: ${{ secrets.TEST_API_TOKEN }} 123 | # TEST_ORG_ID: ${{ secrets.TEST_ORG_ID }} 124 | # TEST_PROJECT_ID: ${{ secrets.TEST_PROJECT_ID }} 125 | 126 | schema-sync: 127 | name: Schema Synchronization Check 128 | runs-on: ubuntu-latest 129 | 130 | steps: 131 | - name: Checkout code 132 | uses: actions/checkout@v4 133 | 134 | - name: Setup pnpm 135 | uses: pnpm/action-setup@v4 136 | 137 | - name: Setup Node.js 138 | uses: actions/setup-node@v4 139 | with: 140 | node-version: '20' 141 | cache: 'pnpm' 142 | 143 | - name: Set up Python 144 | uses: actions/setup-python@v5 145 | with: 146 | python-version: '3.11' 147 | 148 | - name: Install uv 149 | uses: astral-sh/setup-uv@v3 150 | with: 151 | version: "latest" 152 | 153 | - name: Install Node.js dependencies 154 | run: pnpm install && cd typescript && pnpm install 155 | 156 | - name: Install Python dependencies 157 | run: | 158 | cd python 159 | uv sync --dev 160 | 161 | - name: Generate schemas 162 | run: pnpm run schema:build 163 | 164 | - name: Check if schemas are up to date 165 | run: | 166 | if [ -n "$(git status --porcelain)" ]; then 167 | echo "Python schemas are out of sync with TypeScript definitions!" 168 | echo "Please run 'pnpm run schema:build' to regenerate the schemas." 169 | git diff 170 | exit 1 171 | fi -------------------------------------------------------------------------------- /typescript/tests/tools/organizations.integration.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type CreatedResources, 3 | TEST_ORG_ID, 4 | TEST_PROJECT_ID, 5 | cleanupResources, 6 | createTestClient, 7 | createTestContext, 8 | parseToolResponse, 9 | setActiveProjectAndOrg, 10 | validateEnvironmentVariables, 11 | } from "@/shared/test-utils"; 12 | import getOrganizationDetailsTool from "@/tools/organizations/getDetails"; 13 | import getOrganizationsTool from "@/tools/organizations/getOrganizations"; 14 | import setActiveOrganizationTool from "@/tools/organizations/setActive"; 15 | import type { Context } from "@/tools/types"; 16 | import { afterEach, beforeAll, describe, expect, it } from "vitest"; 17 | 18 | describe.skip("Organizations", { concurrent: false }, () => { 19 | let context: Context; 20 | const createdResources: CreatedResources = { 21 | featureFlags: [], 22 | insights: [], 23 | dashboards: [], 24 | surveys: [], 25 | }; 26 | 27 | beforeAll(async () => { 28 | validateEnvironmentVariables(); 29 | const client = createTestClient(); 30 | context = createTestContext(client); 31 | await setActiveProjectAndOrg(context, TEST_PROJECT_ID!, TEST_ORG_ID!); 32 | }); 33 | 34 | afterEach(async () => { 35 | await cleanupResources(context.api, TEST_PROJECT_ID!, createdResources); 36 | }); 37 | 38 | describe("get-organizations tool", () => { 39 | const getTool = getOrganizationsTool(); 40 | 41 | it("should list all organizations", async () => { 42 | const result = await getTool.handler(context, {}); 43 | const orgs = parseToolResponse(result); 44 | 45 | expect(Array.isArray(orgs)).toBe(true); 46 | expect(orgs.length).toBeGreaterThan(0); 47 | 48 | const org = orgs[0]; 49 | expect(org).toHaveProperty("id"); 50 | expect(org).toHaveProperty("name"); 51 | }); 52 | 53 | it("should return organizations with proper structure", async () => { 54 | const result = await getTool.handler(context, {}); 55 | const orgs = parseToolResponse(result); 56 | 57 | const testOrg = orgs.find((org: any) => org.id === TEST_ORG_ID); 58 | expect(testOrg).toBeDefined(); 59 | expect(testOrg.id).toBe(TEST_ORG_ID); 60 | }); 61 | }); 62 | 63 | describe("set-active-organization tool", () => { 64 | const setTool = setActiveOrganizationTool(); 65 | const getTool = getOrganizationsTool(); 66 | 67 | it("should set active organization", async () => { 68 | const orgsResult = await getTool.handler(context, {}); 69 | const orgs = parseToolResponse(orgsResult); 70 | expect(orgs.length).toBeGreaterThan(0); 71 | 72 | const targetOrg = orgs[0]; 73 | const setResult = await setTool.handler(context, { orgId: targetOrg.id }); 74 | 75 | expect(setResult.content[0].text).toBe(`Switched to organization ${targetOrg.id}`); 76 | }); 77 | 78 | it("should handle invalid organization ID", async () => { 79 | try { 80 | await setTool.handler(context, { orgId: "invalid-org-id-12345" }); 81 | expect.fail("Should have thrown an error"); 82 | } catch (error) { 83 | expect(error).toBeDefined(); 84 | } 85 | }); 86 | }); 87 | 88 | describe("get-organization-details tool", () => { 89 | const getDetailsTool = getOrganizationDetailsTool(); 90 | 91 | it.skip("should get organization details for active org", async () => { 92 | const result = await getDetailsTool.handler(context, {}); 93 | const orgDetails = parseToolResponse(result); 94 | 95 | expect(orgDetails.id).toBe(TEST_ORG_ID); 96 | expect(orgDetails).toHaveProperty("name"); 97 | expect(orgDetails).toHaveProperty("projects"); 98 | expect(Array.isArray(orgDetails.projects)).toBe(true); 99 | }); 100 | 101 | it.skip("should include projects in organization details", async () => { 102 | const result = await getDetailsTool.handler(context, {}); 103 | const orgDetails = parseToolResponse(result); 104 | 105 | expect(orgDetails.projects).toBeDefined(); 106 | expect(Array.isArray(orgDetails.projects)).toBe(true); 107 | 108 | if (orgDetails.projects.length > 0) { 109 | const project = orgDetails.projects[0]; 110 | expect(project).toHaveProperty("id"); 111 | expect(project).toHaveProperty("name"); 112 | } 113 | 114 | const testProject = orgDetails.projects.find( 115 | (p: any) => p.id === Number(TEST_PROJECT_ID), 116 | ); 117 | expect(testProject).toBeDefined(); 118 | }); 119 | }); 120 | 121 | describe("Organization workflow", () => { 122 | it("should support listing and setting active org workflow", async () => { 123 | const getTool = getOrganizationsTool(); 124 | const setTool = setActiveOrganizationTool(); 125 | 126 | const orgsResult = await getTool.handler(context, {}); 127 | const orgs = parseToolResponse(orgsResult); 128 | expect(orgs.length).toBeGreaterThan(0); 129 | 130 | const targetOrg = orgs.find((org: any) => org.id === TEST_ORG_ID) || orgs[0]; 131 | 132 | const setResult = await setTool.handler(context, { orgId: targetOrg.id }); 133 | expect(setResult.content[0].text).toBe(`Switched to organization ${targetOrg.id}`); 134 | 135 | await context.cache.set("orgId", targetOrg.id); 136 | }); 137 | }); 138 | }); 139 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Development Commands 6 | 7 | - `pnpm run dev` - Start development server using Wrangler 8 | - `pnpm run deploy` - Deploy to Cloudflare Workers 9 | - `pnpm run format` - Format code using Biome 10 | - `pnpm run lint:fix` - Lint and fix code using Biome 11 | - `pnpm run schema:build:json` - Generate JSON schema from Zod schemas for Python/other language implementations 12 | - `wrangler types` - Generate TypeScript types for Cloudflare Workers 13 | 14 | ## Code Architecture 15 | 16 | This is a PostHog MCP (Model Context Protocol) server built on Cloudflare Workers that provides API access to PostHog analytics data. The server acts as a bridge between MCP clients (like Claude Desktop, Cursor, Windsurf) and PostHog's API. 17 | 18 | ### Project Structure 19 | 20 | The repository is organized to support multiple language implementations: 21 | 22 | ``` 23 | mcp/ 24 | ├── typescript/ # TypeScript implementation 25 | │ ├── src/ # TypeScript source code 26 | │ ├── tests/ # TypeScript tests 27 | │ └── scripts/ # TypeScript build scripts 28 | ├── python/ # Python implementation (planned) 29 | ├── schema/ # Shared schema files for all implementations 30 | │ └── tool-inputs.json # Generated JSON schema for cross-language compatibility 31 | ├── biome.json # Shared code formatting configuration 32 | └── CLAUDE.md # Project documentation 33 | ``` 34 | 35 | ### Key Components (TypeScript Implementation) 36 | 37 | - **Main MCP Class (`typescript/src/integrations/mcp/index.ts`)**: `MyMCP` extends `McpAgent` and defines all available tools for interacting with PostHog 38 | - **Unified API Client (`typescript/src/api/client.ts`)**: `ApiClient` class provides type-safe methods for all PostHog API interactions with proper error handling and schema validation 39 | - **Schema Validation (`typescript/src/schema/`)**: Zod schemas for validating API requests and responses 40 | - **Tool Input Schemas (`typescript/src/schema/tool-inputs.ts`)**: Centralized Zod schemas for all MCP tool inputs, exported to `schema/tool-inputs.json` for other language implementations 41 | - **Caching (`typescript/src/lib/utils/cache/`)**: User-scoped memory cache for storing project/org state 42 | - **Documentation Search (`typescript/src/inkeepApi.ts`)**: Integration with Inkeep for PostHog docs search 43 | - **Utility Functions (`typescript/src/lib/utils/api.ts`)**: Helper functions for pagination and URL generation 44 | 45 | ### Authentication & State Management 46 | 47 | - Uses Bearer token authentication via `Authorization` header 48 | - User state (active project/org) is cached per user hash derived from API token 49 | - Automatic project/org selection when user has only one option 50 | - State persists across requests within the same session 51 | 52 | ### API Architecture 53 | 54 | The codebase uses a unified API client pattern: 55 | 56 | - **API Client (`ApiClient`)**: Central class that handles all PostHog API requests with consistent error handling, authentication, and response validation 57 | - **Resource Methods**: API client is organized into resource-based methods: 58 | - `organizations()`: Organization CRUD and project listing 59 | - `projects()`: Project details and property definitions 60 | - `featureFlags()`: Feature flag CRUD operations 61 | - `insights()`: Insight CRUD, listing, and SQL queries 62 | - `dashboards()`: Dashboard CRUD and insight management 63 | - `query()`: Generic query execution for analytics 64 | - `users()`: User information and authentication 65 | - **Type Safety**: All methods return `Result` types with proper TypeScript definitions 66 | - **URL Generation**: Uses configurable `BASE_URL` from constants for environment-aware URLs (localhost in dev, PostHog production in prod) 67 | 68 | ### Tool Categories 69 | 70 | 1. **Organization/Project Management**: Get orgs, projects, set active context 71 | 2. **Feature Flags**: CRUD operations on feature flags 72 | 3. **Insights & Dashboards**: CRUD operations on insights and dashboards with proper URL generation 73 | 4. **Error Tracking**: Query errors and error details 74 | 5. **Data Warehouse**: SQL insights via natural language queries 75 | 6. **Documentation**: Search PostHog docs via Inkeep API 76 | 7. **Analytics**: LLM cost tracking and other metrics 77 | 78 | ### Environment Setup 79 | 80 | - Create `.dev.vars` file with `INKEEP_API_KEY` for docs search functionality 81 | - API token passed via Authorization header from MCP client configuration 82 | - **Development Mode**: Set `DEV = true` in `typescript/src/lib/constants.ts` to use `http://localhost:8010` for API calls and URLs 83 | - **Production Mode**: Set `DEV = false` to use `https://us.posthog.com` for API calls and URLs 84 | 85 | ### Schema Generation 86 | 87 | The project uses a centralized schema system to support multiple language implementations: 88 | 89 | - **Source**: Tool input schemas are defined in `typescript/src/schema/tool-inputs.ts` using Zod 90 | - **Generation**: Run `pnpm run schema:build:json` to generate `schema/tool-inputs.json` from Zod schemas 91 | - **Usage**: The generated JSON schema can be used to create Pydantic models or other language-specific types 92 | - **Script**: Schema generation logic is in `typescript/scripts/generate-tool-schema.ts` 93 | 94 | ### Code Style 95 | 96 | - Uses Biome for formatting (4-space indentation, 100 character line width) 97 | - TypeScript with strict mode enabled 98 | - Zod for runtime type validation 99 | - No explicit `any` types allowed (disabled in linter) 100 | - **Import Style**: Uses absolute imports with `@/` prefix (e.g., `import { ApiClient } from "@/api/client"`) 101 | - `@/` maps to `typescript/src/` directory 102 | - Configured in `tsconfig.json` path mapping -------------------------------------------------------------------------------- /typescript/tests/unit/tool-filtering.test.ts: -------------------------------------------------------------------------------- 1 | import { SessionManager } from "@/lib/utils/SessionManager"; 2 | import { getToolsFromContext } from "@/tools"; 3 | import { getToolsForFeatures } from "@/tools/toolDefinitions"; 4 | import type { Context } from "@/tools/types"; 5 | import { describe, expect, it } from "vitest"; 6 | 7 | describe("Tool Filtering - Features", () => { 8 | const featureTests = [ 9 | { 10 | features: undefined, 11 | description: "all tools when no features specified", 12 | expectedTools: [ 13 | "feature-flag-get-definition", 14 | "dashboard-create", 15 | "insights-get-all", 16 | "organizations-get", 17 | ], 18 | }, 19 | { 20 | features: [], 21 | description: "all tools when empty array passed", 22 | expectedTools: ["feature-flag-get-definition", "dashboard-create"], 23 | }, 24 | { 25 | features: ["flags"], 26 | description: "flag tools only", 27 | expectedTools: [ 28 | "feature-flag-get-definition", 29 | "feature-flag-get-all", 30 | "create-feature-flag", 31 | "update-feature-flag", 32 | "delete-feature-flag", 33 | ], 34 | }, 35 | { 36 | features: ["dashboards", "insights"], 37 | description: "dashboard and insight tools", 38 | expectedTools: [ 39 | "dashboard-create", 40 | "dashboards-get-all", 41 | "add-insight-to-dashboard", 42 | "insights-get-all", 43 | "query-generate-hogql-from-question", 44 | "query-run", 45 | "insight-create-from-query", 46 | ], 47 | }, 48 | { 49 | features: ["workspace"], 50 | description: "workspace tools", 51 | expectedTools: [ 52 | "organizations-get", 53 | "switch-organization", 54 | "projects-get", 55 | "switch-project", 56 | "property-definitions", 57 | ], 58 | }, 59 | { 60 | features: ["error-tracking"], 61 | description: "error tracking tools", 62 | expectedTools: ["list-errors", "error-details"], 63 | }, 64 | { 65 | features: ["experiments"], 66 | description: "experiment tools", 67 | expectedTools: ["experiment-get-all"], 68 | }, 69 | { 70 | features: ["llm-analytics"], 71 | description: "LLM analytics tools", 72 | expectedTools: ["get-llm-total-costs-for-project"], 73 | }, 74 | { 75 | features: ["docs"], 76 | description: "documentation tools", 77 | expectedTools: ["docs-search"], 78 | }, 79 | { 80 | features: ["invalid", "flags"], 81 | description: "valid tools when mixed with invalid features", 82 | expectedTools: ["feature-flag-get-definition"], 83 | }, 84 | { 85 | features: ["invalid", "unknown"], 86 | description: "empty array for only invalid features", 87 | expectedTools: [], 88 | }, 89 | ]; 90 | 91 | describe("getToolsForFeatures", () => { 92 | it.each(featureTests)("should return $description", ({ features, expectedTools }) => { 93 | const tools = getToolsForFeatures(features); 94 | 95 | for (const tool of expectedTools) { 96 | expect(tools).toContain(tool); 97 | } 98 | }); 99 | }); 100 | }); 101 | 102 | const createMockContext = (scopes: string[]): Context => ({ 103 | api: {} as any, 104 | cache: {} as any, 105 | env: { INKEEP_API_KEY: undefined }, 106 | stateManager: { 107 | getApiKey: async () => ({ scopes }), 108 | } as any, 109 | sessionManager: new SessionManager({} as any), 110 | }); 111 | 112 | describe("Tool Filtering - API Scopes", () => { 113 | it("should return all tools when user has * scope", async () => { 114 | const context = createMockContext(["*"]); 115 | const tools = await getToolsFromContext(context); 116 | const toolNames = tools.map((t) => t.name); 117 | 118 | expect(toolNames).toContain("dashboard-create"); 119 | expect(toolNames).toContain("create-feature-flag"); 120 | expect(toolNames).toContain("insight-create-from-query"); 121 | expect(toolNames.length).toBeGreaterThan(25); 122 | }); 123 | 124 | it("should only return dashboard tools when user has dashboard scopes", async () => { 125 | const context = createMockContext(["dashboard:read", "dashboard:write"]); 126 | const tools = await getToolsFromContext(context); 127 | const toolNames = tools.map((t) => t.name); 128 | 129 | expect(toolNames).toContain("dashboard-create"); 130 | expect(toolNames).toContain("dashboard-get"); 131 | expect(toolNames).toContain("dashboards-get-all"); 132 | expect(toolNames).toContain("add-insight-to-dashboard"); 133 | 134 | expect(toolNames).not.toContain("create-feature-flag"); 135 | expect(toolNames).not.toContain("organizations-get"); 136 | }); 137 | 138 | it("should include read tools when user has write scope", async () => { 139 | const context = createMockContext(["feature_flag:write"]); 140 | const tools = await getToolsFromContext(context); 141 | const toolNames = tools.map((t) => t.name); 142 | 143 | expect(toolNames).toContain("create-feature-flag"); 144 | expect(toolNames).toContain("feature-flag-get-all"); 145 | expect(toolNames).toContain("feature-flag-get-definition"); 146 | 147 | expect(toolNames).not.toContain("dashboard-create"); 148 | }); 149 | 150 | it("should only return read tools when user has read scope", async () => { 151 | const context = createMockContext(["insight:read"]); 152 | const tools = await getToolsFromContext(context); 153 | const toolNames = tools.map((t) => t.name); 154 | 155 | expect(toolNames).toContain("insights-get-all"); 156 | expect(toolNames).toContain("insight-get"); 157 | 158 | expect(toolNames).not.toContain("insight-create-from-query"); 159 | expect(toolNames).not.toContain("dashboard-create"); 160 | }); 161 | 162 | it("should return multiple scope tools when user has multiple scopes", async () => { 163 | const context = createMockContext([ 164 | "dashboard:read", 165 | "feature_flag:write", 166 | "organization:read", 167 | ]); 168 | const tools = await getToolsFromContext(context); 169 | const toolNames = tools.map((t) => t.name); 170 | 171 | expect(toolNames).toContain("dashboard-get"); 172 | expect(toolNames).toContain("create-feature-flag"); 173 | expect(toolNames).toContain("organization-details-get"); 174 | 175 | expect(toolNames).not.toContain("dashboard-create"); 176 | expect(toolNames).not.toContain("insight-create-from-query"); 177 | }); 178 | 179 | it("should return empty array when user has no matching scopes", async () => { 180 | const context = createMockContext(["some:unknown"]); 181 | const tools = await getToolsFromContext(context); 182 | 183 | expect(tools).toHaveLength(0); 184 | }); 185 | 186 | it("should return empty array when user has empty scopes", async () => { 187 | const context = createMockContext([]); 188 | const tools = await getToolsFromContext(context); 189 | 190 | expect(tools).toHaveLength(0); 191 | }); 192 | }); 193 | -------------------------------------------------------------------------------- /typescript/tests/unit/SessionManager.test.ts: -------------------------------------------------------------------------------- 1 | import { SessionManager } from "@/lib/utils/SessionManager"; 2 | import type { ScopedCache } from "@/lib/utils/cache/ScopedCache"; 3 | import type { State } from "@/tools"; 4 | import { beforeEach, describe, expect, it, vi } from "vitest"; 5 | 6 | vi.mock("uuid", () => ({ 7 | v7: vi.fn(() => "test-uuid-12345"), 8 | })); 9 | 10 | describe("SessionManager", () => { 11 | let mockCache: ScopedCache; 12 | let sessionManager: SessionManager; 13 | 14 | beforeEach(() => { 15 | mockCache = { 16 | get: vi.fn(), 17 | set: vi.fn(), 18 | delete: vi.fn(), 19 | clear: vi.fn(), 20 | has: vi.fn(), 21 | values: vi.fn(), 22 | entries: vi.fn(), 23 | keys: vi.fn(), 24 | } as unknown as ScopedCache; 25 | 26 | sessionManager = new SessionManager(mockCache); 27 | vi.clearAllMocks(); 28 | }); 29 | 30 | describe("getSessionUuid", () => { 31 | it("should return existing session uuid if it exists", async () => { 32 | const sessionId = "test-session-123"; 33 | const existingUuid = "existing-uuid-456"; 34 | 35 | (mockCache.get as any).mockResolvedValue({ uuid: existingUuid }); 36 | 37 | const result = await sessionManager.getSessionUuid(sessionId); 38 | 39 | expect(result).toBe(existingUuid); 40 | expect(mockCache.get).toHaveBeenCalledWith("session:test-session-123"); 41 | expect(mockCache.set).not.toHaveBeenCalled(); 42 | }); 43 | 44 | it("should create and return new session uuid if none exists", async () => { 45 | const sessionId = "test-session-123"; 46 | 47 | (mockCache.get as any).mockResolvedValue(null); 48 | 49 | const result = await sessionManager.getSessionUuid(sessionId); 50 | 51 | expect(result).toBe("test-uuid-12345"); 52 | expect(mockCache.get).toHaveBeenCalledWith("session:test-session-123"); 53 | expect(mockCache.set).toHaveBeenCalledWith("session:test-session-123", { 54 | uuid: "test-uuid-12345", 55 | }); 56 | }); 57 | 58 | it("should create new uuid if session exists but has no uuid", async () => { 59 | const sessionId = "test-session-123"; 60 | 61 | (mockCache.get as any).mockResolvedValue({}); 62 | 63 | const result = await sessionManager.getSessionUuid(sessionId); 64 | 65 | expect(result).toBe("test-uuid-12345"); 66 | expect(mockCache.set).toHaveBeenCalledWith("session:test-session-123", { 67 | uuid: "test-uuid-12345", 68 | }); 69 | }); 70 | }); 71 | 72 | describe("hasSession", () => { 73 | it("should return true if session exists with uuid", async () => { 74 | const sessionId = "test-session-123"; 75 | 76 | (mockCache.get as any).mockResolvedValue({ uuid: "some-uuid" }); 77 | 78 | const result = await sessionManager.hasSession(sessionId); 79 | 80 | expect(result).toBe(true); 81 | expect(mockCache.get).toHaveBeenCalledWith("session:test-session-123"); 82 | }); 83 | 84 | it("should return false if session does not exist", async () => { 85 | const sessionId = "test-session-123"; 86 | 87 | (mockCache.get as any).mockResolvedValue(null); 88 | 89 | const result = await sessionManager.hasSession(sessionId); 90 | 91 | expect(result).toBe(false); 92 | expect(mockCache.get).toHaveBeenCalledWith("session:test-session-123"); 93 | }); 94 | 95 | it("should return false if session exists but has no uuid", async () => { 96 | const sessionId = "test-session-123"; 97 | 98 | (mockCache.get as any).mockResolvedValue({}); 99 | 100 | const result = await sessionManager.hasSession(sessionId); 101 | 102 | expect(result).toBe(false); 103 | }); 104 | 105 | it("should return false if session exists with undefined uuid", async () => { 106 | const sessionId = "test-session-123"; 107 | 108 | (mockCache.get as any).mockResolvedValue({ uuid: undefined }); 109 | 110 | const result = await sessionManager.hasSession(sessionId); 111 | 112 | expect(result).toBe(false); 113 | }); 114 | }); 115 | 116 | describe("removeSession", () => { 117 | it("should delete session from cache", async () => { 118 | const sessionId = "test-session-123"; 119 | 120 | await sessionManager.removeSession(sessionId); 121 | 122 | expect(mockCache.delete).toHaveBeenCalledWith("session:test-session-123"); 123 | }); 124 | 125 | it("should handle removal of non-existent session gracefully", async () => { 126 | const sessionId = "non-existent-session"; 127 | 128 | (mockCache.delete as any).mockResolvedValue(undefined); 129 | 130 | await sessionManager.removeSession(sessionId); 131 | 132 | expect(mockCache.delete).toHaveBeenCalledWith("session:non-existent-session"); 133 | }); 134 | }); 135 | 136 | describe("clearAllSessions", () => { 137 | it("should clear all sessions from cache", async () => { 138 | await sessionManager.clearAllSessions(); 139 | 140 | expect(mockCache.clear).toHaveBeenCalled(); 141 | }); 142 | }); 143 | 144 | describe("_getKey", () => { 145 | it("should format session key correctly", async () => { 146 | const sessionId = "test-session"; 147 | 148 | const key = await sessionManager._getKey(sessionId); 149 | 150 | expect(key).toBe("session:test-session"); 151 | }); 152 | 153 | it("should handle special characters in session id", async () => { 154 | const sessionId = "test-session!@#$%^&*()"; 155 | 156 | const key = await sessionManager._getKey(sessionId); 157 | 158 | expect(key).toBe("session:test-session!@#$%^&*()"); 159 | }); 160 | 161 | it("should handle empty session id", async () => { 162 | const sessionId = ""; 163 | 164 | const key = await sessionManager._getKey(sessionId); 165 | 166 | expect(key).toBe("session:"); 167 | }); 168 | }); 169 | 170 | describe("integration scenarios", () => { 171 | it("should handle multiple sequential operations", async () => { 172 | const sessionId = "test-session"; 173 | 174 | (mockCache.get as any).mockResolvedValueOnce(null); 175 | (mockCache.get as any).mockResolvedValueOnce({ uuid: "test-uuid-12345" }); 176 | (mockCache.get as any).mockResolvedValueOnce(null); 177 | 178 | const uuid1 = await sessionManager.getSessionUuid(sessionId); 179 | expect(uuid1).toBe("test-uuid-12345"); 180 | expect(mockCache.set).toHaveBeenCalled(); 181 | 182 | const hasSession = await sessionManager.hasSession(sessionId); 183 | expect(hasSession).toBe(true); 184 | 185 | await sessionManager.removeSession(sessionId); 186 | expect(mockCache.delete).toHaveBeenCalled(); 187 | 188 | const hasSessionAfterRemove = await sessionManager.hasSession(sessionId); 189 | expect(hasSessionAfterRemove).toBe(false); 190 | }); 191 | 192 | it("should handle concurrent sessions", async () => { 193 | const sessionId1 = "session-1"; 194 | const sessionId2 = "session-2"; 195 | 196 | (mockCache.get as any).mockImplementation((key: string) => { 197 | if (key === "session:session-1") { 198 | return Promise.resolve({ uuid: "uuid-1" }); 199 | } 200 | if (key === "session:session-2") { 201 | return Promise.resolve({ uuid: "uuid-2" }); 202 | } 203 | return Promise.resolve(null); 204 | }); 205 | 206 | const [uuid1, uuid2] = await Promise.all([ 207 | sessionManager.getSessionUuid(sessionId1), 208 | sessionManager.getSessionUuid(sessionId2), 209 | ]); 210 | 211 | expect(uuid1).toBe("uuid-1"); 212 | expect(uuid2).toBe("uuid-2"); 213 | }); 214 | }); 215 | }); 216 | -------------------------------------------------------------------------------- /typescript/src/tools/index.ts: -------------------------------------------------------------------------------- 1 | import type { Context, Tool, ToolBase, ZodObjectAny } from "./types"; 2 | 3 | import { ApiClient } from "@/api/client"; 4 | import { SessionManager } from "@/lib/utils/SessionManager"; 5 | import { StateManager } from "@/lib/utils/StateManager"; 6 | import { MemoryCache } from "@/lib/utils/cache/MemoryCache"; 7 | import { hash } from "@/lib/utils/helper-functions"; 8 | import { getToolsForFeatures as getFilteredToolNames, getToolDefinition } from "./toolDefinitions"; 9 | 10 | import createFeatureFlag from "./featureFlags/create"; 11 | import deleteFeatureFlag from "./featureFlags/delete"; 12 | import getAllFeatureFlags from "./featureFlags/getAll"; 13 | // Feature Flags 14 | import getFeatureFlagDefinition from "./featureFlags/getDefinition"; 15 | import updateFeatureFlag from "./featureFlags/update"; 16 | 17 | import getOrganizationDetails from "./organizations/getDetails"; 18 | // Organizations 19 | import getOrganizations from "./organizations/getOrganizations"; 20 | import setActiveOrganization from "./organizations/setActive"; 21 | 22 | import eventDefinitions from "./projects/eventDefinitions"; 23 | // Projects 24 | import getProjects from "./projects/getProjects"; 25 | import getProperties from "./projects/propertyDefinitions"; 26 | import setActiveProject from "./projects/setActive"; 27 | 28 | // Documentation 29 | import searchDocs from "./documentation/searchDocs"; 30 | 31 | import errorDetails from "./errorTracking/errorDetails"; 32 | // Error Tracking 33 | import listErrors from "./errorTracking/listErrors"; 34 | 35 | // Experiments 36 | import createExperiment from "./experiments/create"; 37 | import deleteExperiment from "./experiments/delete"; 38 | import getExperiment from "./experiments/get"; 39 | import getAllExperiments from "./experiments/getAll"; 40 | import getExperimentResults from "./experiments/getResults"; 41 | import updateExperiment from "./experiments/update"; 42 | 43 | import createInsight from "./insights/create"; 44 | import deleteInsight from "./insights/delete"; 45 | 46 | import getInsight from "./insights/get"; 47 | // Insights 48 | import getAllInsights from "./insights/getAll"; 49 | import queryInsight from "./insights/query"; 50 | import updateInsight from "./insights/update"; 51 | 52 | import addInsightToDashboard from "./dashboards/addInsight"; 53 | import createDashboard from "./dashboards/create"; 54 | import deleteDashboard from "./dashboards/delete"; 55 | import getDashboard from "./dashboards/get"; 56 | 57 | // Dashboards 58 | import getAllDashboards from "./dashboards/getAll"; 59 | import updateDashboard from "./dashboards/update"; 60 | import generateHogQLFromQuestion from "./query/generateHogQLFromQuestion"; 61 | // Query 62 | import queryRun from "./query/run"; 63 | 64 | import { hasScopes } from "@/lib/utils/api"; 65 | // LLM Observability 66 | import getLLMCosts from "./llmAnalytics/getLLMCosts"; 67 | 68 | // Surveys 69 | import createSurvey from "./surveys/create"; 70 | import deleteSurvey from "./surveys/delete"; 71 | import getSurvey from "./surveys/get"; 72 | import getAllSurveys from "./surveys/getAll"; 73 | import surveysGlobalStats from "./surveys/global-stats"; 74 | import surveyStats from "./surveys/stats"; 75 | import updateSurvey from "./surveys/update"; 76 | 77 | // Map of tool names to tool factory functions 78 | const TOOL_MAP: Record ToolBase> = { 79 | // Feature Flags 80 | "feature-flag-get-definition": getFeatureFlagDefinition, 81 | "feature-flag-get-all": getAllFeatureFlags, 82 | "create-feature-flag": createFeatureFlag, 83 | "update-feature-flag": updateFeatureFlag, 84 | "delete-feature-flag": deleteFeatureFlag, 85 | 86 | // Organizations 87 | "organizations-get": getOrganizations, 88 | "switch-organization": setActiveOrganization, 89 | "organization-details-get": getOrganizationDetails, 90 | 91 | // Projects 92 | "projects-get": getProjects, 93 | "switch-project": setActiveProject, 94 | "event-definitions-list": eventDefinitions, 95 | "properties-list": getProperties, 96 | 97 | // Documentation - handled separately due to env check 98 | // "docs-search": searchDocs, 99 | 100 | // Error Tracking 101 | "list-errors": listErrors, 102 | "error-details": errorDetails, 103 | 104 | // Experiments 105 | "experiment-get-all": getAllExperiments, 106 | "experiment-get": getExperiment, 107 | "experiment-results-get": getExperimentResults, 108 | "experiment-create": createExperiment, 109 | "experiment-delete": deleteExperiment, 110 | "experiment-update": updateExperiment, 111 | 112 | // Insights 113 | "insights-get-all": getAllInsights, 114 | "insight-get": getInsight, 115 | "insight-create-from-query": createInsight, 116 | "insight-update": updateInsight, 117 | "insight-delete": deleteInsight, 118 | "insight-query": queryInsight, 119 | 120 | // Queries 121 | "query-generate-hogql-from-question": generateHogQLFromQuestion, 122 | "query-run": queryRun, 123 | 124 | // Dashboards 125 | "dashboards-get-all": getAllDashboards, 126 | "dashboard-get": getDashboard, 127 | "dashboard-create": createDashboard, 128 | "dashboard-update": updateDashboard, 129 | "dashboard-delete": deleteDashboard, 130 | "add-insight-to-dashboard": addInsightToDashboard, 131 | 132 | // LLM Observability 133 | "get-llm-total-costs-for-project": getLLMCosts, 134 | 135 | // Surveys 136 | "surveys-get-all": getAllSurveys, 137 | "survey-get": getSurvey, 138 | "survey-create": createSurvey, 139 | "survey-update": updateSurvey, 140 | "survey-delete": deleteSurvey, 141 | "surveys-global-stats": surveysGlobalStats, 142 | "survey-stats": surveyStats, 143 | }; 144 | 145 | export const getToolsFromContext = async ( 146 | context: Context, 147 | features?: string[], 148 | ): Promise[]> => { 149 | const allowedToolNames = getFilteredToolNames(features); 150 | const toolBases: ToolBase[] = []; 151 | 152 | for (const toolName of allowedToolNames) { 153 | // Special handling for docs-search which requires API key 154 | if (toolName === "docs-search" && context.env.INKEEP_API_KEY) { 155 | toolBases.push(searchDocs()); 156 | } else if (TOOL_MAP[toolName]) { 157 | toolBases.push(TOOL_MAP[toolName]()); 158 | } 159 | } 160 | 161 | const tools: Tool[] = toolBases.map((toolBase) => { 162 | const definition = getToolDefinition(toolBase.name); 163 | return { 164 | ...toolBase, 165 | title: definition.title, 166 | description: definition.description, 167 | scopes: definition.required_scopes ?? [], 168 | annotations: definition.annotations, 169 | }; 170 | }); 171 | 172 | const { scopes } = await context.stateManager.getApiKey(); 173 | 174 | const filteredTools = tools.filter((tool) => { 175 | return hasScopes(scopes, tool.scopes); 176 | }); 177 | 178 | return filteredTools; 179 | }; 180 | 181 | export type PostHogToolsOptions = { 182 | posthogApiToken: string; 183 | posthogApiBaseUrl: string; 184 | inkeepApiKey?: string; 185 | }; 186 | export class PostHogAgentToolkit { 187 | public options: PostHogToolsOptions; 188 | 189 | constructor(options: PostHogToolsOptions) { 190 | this.options = options; 191 | } 192 | 193 | getContext(): Context { 194 | const api = new ApiClient({ 195 | apiToken: this.options.posthogApiToken, 196 | baseUrl: this.options.posthogApiBaseUrl, 197 | }); 198 | 199 | const scope = hash(this.options.posthogApiToken); 200 | const cache = new MemoryCache(scope); 201 | 202 | return { 203 | api, 204 | cache, 205 | env: { 206 | INKEEP_API_KEY: this.options.inkeepApiKey, 207 | }, 208 | stateManager: new StateManager(cache, api), 209 | sessionManager: new SessionManager(cache), 210 | }; 211 | } 212 | async getTools(): Promise[]> { 213 | const context = this.getContext(); 214 | return await getToolsFromContext(context); 215 | } 216 | } 217 | 218 | export type { Context, State, Tool } from "./types"; 219 | -------------------------------------------------------------------------------- /typescript/tests/tools/dashboards.integration.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeAll, afterEach } from "vitest"; 2 | import { 3 | validateEnvironmentVariables, 4 | createTestClient, 5 | createTestContext, 6 | setActiveProjectAndOrg, 7 | cleanupResources, 8 | parseToolResponse, 9 | generateUniqueKey, 10 | TEST_PROJECT_ID, 11 | TEST_ORG_ID, 12 | type CreatedResources, 13 | } from "@/shared/test-utils"; 14 | import createDashboardTool from "@/tools/dashboards/create"; 15 | import updateDashboardTool from "@/tools/dashboards/update"; 16 | import deleteDashboardTool from "@/tools/dashboards/delete"; 17 | import getAllDashboardsTool from "@/tools/dashboards/getAll"; 18 | import getDashboardTool from "@/tools/dashboards/get"; 19 | import type { Context } from "@/tools/types"; 20 | 21 | describe("Dashboards", { concurrent: false }, () => { 22 | let context: Context; 23 | const createdResources: CreatedResources = { 24 | featureFlags: [], 25 | insights: [], 26 | dashboards: [], 27 | surveys: [], 28 | }; 29 | 30 | beforeAll(async () => { 31 | validateEnvironmentVariables(); 32 | const client = createTestClient(); 33 | context = createTestContext(client); 34 | await setActiveProjectAndOrg(context, TEST_PROJECT_ID!, TEST_ORG_ID!); 35 | }); 36 | 37 | afterEach(async () => { 38 | await cleanupResources(context.api, TEST_PROJECT_ID!, createdResources); 39 | }); 40 | 41 | describe("create-dashboard tool", () => { 42 | const createTool = createDashboardTool(); 43 | 44 | it("should create a dashboard with minimal fields", async () => { 45 | const params = { 46 | data: { 47 | name: generateUniqueKey("Test Dashboard"), 48 | description: "Integration test dashboard", 49 | pinned: false, 50 | }, 51 | }; 52 | 53 | const result = await createTool.handler(context, params); 54 | const dashboardData = parseToolResponse(result); 55 | 56 | expect(dashboardData.id).toBeDefined(); 57 | expect(dashboardData.name).toBe(params.data.name); 58 | expect(dashboardData.url).toContain("/dashboard/"); 59 | 60 | createdResources.dashboards.push(dashboardData.id); 61 | }); 62 | 63 | it("should create a dashboard with tags", async () => { 64 | const params = { 65 | data: { 66 | name: generateUniqueKey("Tagged Dashboard"), 67 | description: "Dashboard with tags", 68 | tags: ["test", "integration"], 69 | pinned: false, 70 | }, 71 | }; 72 | 73 | const result = await createTool.handler(context, params); 74 | const dashboardData = parseToolResponse(result); 75 | 76 | expect(dashboardData.id).toBeDefined(); 77 | expect(dashboardData.name).toBe(params.data.name); 78 | 79 | createdResources.dashboards.push(dashboardData.id); 80 | }); 81 | }); 82 | 83 | describe("update-dashboard tool", () => { 84 | const createTool = createDashboardTool(); 85 | const updateTool = updateDashboardTool(); 86 | 87 | it("should update dashboard name and description", async () => { 88 | const createParams = { 89 | data: { 90 | name: generateUniqueKey("Original Dashboard"), 91 | description: "Original description", 92 | pinned: false, 93 | }, 94 | }; 95 | 96 | const createResult = await createTool.handler(context, createParams); 97 | const createdDashboard = parseToolResponse(createResult); 98 | createdResources.dashboards.push(createdDashboard.id); 99 | 100 | const updateParams = { 101 | dashboardId: createdDashboard.id, 102 | data: { 103 | name: "Updated Dashboard Name", 104 | description: "Updated description", 105 | }, 106 | }; 107 | 108 | const updateResult = await updateTool.handler(context, updateParams); 109 | const updatedDashboard = parseToolResponse(updateResult); 110 | 111 | expect(updatedDashboard.id).toBe(createdDashboard.id); 112 | expect(updatedDashboard.name).toBe(updateParams.data.name); 113 | }); 114 | }); 115 | 116 | describe("get-all-dashboards tool", () => { 117 | const getAllTool = getAllDashboardsTool(); 118 | 119 | it("should return dashboards with proper structure", async () => { 120 | const result = await getAllTool.handler(context, {}); 121 | const dashboards = parseToolResponse(result); 122 | 123 | expect(Array.isArray(dashboards)).toBe(true); 124 | if (dashboards.length > 0) { 125 | const dashboard = dashboards[0]; 126 | expect(dashboard).toHaveProperty("id"); 127 | expect(dashboard).toHaveProperty("name"); 128 | } 129 | }); 130 | }); 131 | 132 | describe("get-dashboard tool", () => { 133 | const createTool = createDashboardTool(); 134 | const getTool = getDashboardTool(); 135 | 136 | it("should get a specific dashboard by ID", async () => { 137 | const createParams = { 138 | data: { 139 | name: generateUniqueKey("Get Test Dashboard"), 140 | description: "Test dashboard for get operation", 141 | pinned: false, 142 | }, 143 | }; 144 | 145 | const createResult = await createTool.handler(context, createParams); 146 | const createdDashboard = parseToolResponse(createResult); 147 | createdResources.dashboards.push(createdDashboard.id); 148 | 149 | const result = await getTool.handler(context, { dashboardId: createdDashboard.id }); 150 | const retrievedDashboard = parseToolResponse(result); 151 | 152 | expect(retrievedDashboard.id).toBe(createdDashboard.id); 153 | expect(retrievedDashboard.name).toBe(createParams.data.name); 154 | }); 155 | }); 156 | 157 | describe("delete-dashboard tool", () => { 158 | const createTool = createDashboardTool(); 159 | const deleteTool = deleteDashboardTool(); 160 | 161 | it("should delete a dashboard", async () => { 162 | const createParams = { 163 | data: { 164 | name: generateUniqueKey("Delete Test Dashboard"), 165 | description: "Test dashboard for deletion", 166 | pinned: false, 167 | }, 168 | }; 169 | 170 | const createResult = await createTool.handler(context, createParams); 171 | const createdDashboard = parseToolResponse(createResult); 172 | 173 | const deleteResult = await deleteTool.handler(context, { 174 | dashboardId: createdDashboard.id, 175 | }); 176 | const deleteResponse = parseToolResponse(deleteResult); 177 | 178 | expect(deleteResponse.success).toBe(true); 179 | expect(deleteResponse.message).toContain("deleted successfully"); 180 | }); 181 | }); 182 | 183 | describe("Dashboard workflow", () => { 184 | it("should support full CRUD workflow", async () => { 185 | const createTool = createDashboardTool(); 186 | const updateTool = updateDashboardTool(); 187 | const getTool = getDashboardTool(); 188 | const deleteTool = deleteDashboardTool(); 189 | 190 | const createParams = { 191 | data: { 192 | name: generateUniqueKey("Workflow Test Dashboard"), 193 | description: "Testing full workflow", 194 | pinned: false, 195 | }, 196 | }; 197 | 198 | const createResult = await createTool.handler(context, createParams); 199 | const createdDashboard = parseToolResponse(createResult); 200 | 201 | const getResult = await getTool.handler(context, { dashboardId: createdDashboard.id }); 202 | const retrievedDashboard = parseToolResponse(getResult); 203 | expect(retrievedDashboard.id).toBe(createdDashboard.id); 204 | 205 | const updateParams = { 206 | dashboardId: createdDashboard.id, 207 | data: { 208 | name: "Updated Workflow Dashboard", 209 | description: "Updated workflow description", 210 | }, 211 | }; 212 | 213 | const updateResult = await updateTool.handler(context, updateParams); 214 | const updatedDashboard = parseToolResponse(updateResult); 215 | expect(updatedDashboard.name).toBe(updateParams.data.name); 216 | 217 | const deleteResult = await deleteTool.handler(context, { 218 | dashboardId: createdDashboard.id, 219 | }); 220 | const deleteResponse = parseToolResponse(deleteResult); 221 | expect(deleteResponse.success).toBe(true); 222 | }); 223 | }); 224 | }); 225 | --------------------------------------------------------------------------------